diff --git a/package.json b/package.json index 7fff91a..24d13b1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "animejs": "^3.2.0", "axios": "^0.21.1", "core-js": "^3.6.5", - "dotenv": "^10.0.0", "electron-is-dev": "^2.0.0", "electron-store": "^8.0.0", "electron-updater": "^4.3.8", diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..99ffe6a --- /dev/null +++ b/src/App.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/src/authPayload.ts b/src/authPayload.ts new file mode 100644 index 0000000..61d68b6 --- /dev/null +++ b/src/authPayload.ts @@ -0,0 +1,13 @@ + +import { store } from "@/background/store"; + +interface PlatformAuthProtocol { + key: string; + crimata_id: string; +} + +export const getAuthPayload = (): PlatformAuthProtocol => ({ + key: store.get('key'), + crimata_id: store.get('crimataId') +}); + diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..977c265 --- /dev/null +++ b/src/background.ts @@ -0,0 +1,25 @@ +/* + * Entry point for Crimata electron app. + * "Look on my Works, ye Mighty, and despair!" + */ + +"use strict"; + +import { initApp } from './background/init'; +import { protocol } from "electron"; + +// Scheme must be registered before the app is ready +protocol.registerSchemesAsPrivileged([ + { scheme: "app", privileges: { secure: true, standard: true } } +]); + +// Load environment variable +const isDev = require('electron-is-dev'); + +// NOTE Program Begins Here +(() => { + + console.log('Starting Crimata electron app.'); + initApp(isDev); + +})(); diff --git a/src/background/ipc/account.ts b/src/background/ipc/account.ts new file mode 100644 index 0000000..4bf6411 --- /dev/null +++ b/src/background/ipc/account.ts @@ -0,0 +1,119 @@ + +"use strict"; + +import { Profile } from "@/types"; +import { submit, fetchProfile, logout } from "@/api/account"; +import { ipcMain, IpcMainInvokeEvent } from "electron"; +import { store } from "@/background/store"; +import { endSession } from "@/background/session"; + + +const parseAuthRes = (authRes: any) => { + const token = authRes.headers['set-cookie'][0].split(";")[0].split("=")[1] as string; + const profile = authRes.data as Profile; + return { + token, + profile + } +}; + + +const onProfile = async ( + _event: IpcMainInvokeEvent, + _payload: null +): Promise => ( + + new Promise(async (resolve, reject) => { + console.log('[IPC]: user-profile'); + + // get jwt token and crimataId from store + const token = store.get('key'); + const crimataId = store.get('crimataId'); + + // authenticate and fetch profile + try { + + const res = await fetchProfile( + crimataId, + token + ); + + const parsed = parseAuthRes(res); + resolve(parsed.profile); + + } catch(e) { + reject(new Error('Unable to authenticate and fetch account profile.')); + } + }) +) + + +const onLogin = async ( + _event: IpcMainInvokeEvent, + payload: string +): Promise => ( + + new Promise(async (resolve, reject) => { + console.log('[IPC]: user-login'); + + const account = JSON.parse(payload); + + if ( account.password && account.email ) { + try { + const res = await submit(account.email, account.password); + const parsed = parseAuthRes(res); + + // save jwt token and profile + store.set('key', parsed.token); + store.set('crimataId', parsed.profile.crimataId); + + // return profile to renderer + resolve(parsed.profile); + + } catch(e) { + console.log('[API]', e); + reject(new Error('Failed to authenticate')); + } + } + }) +) + +const onLogout = async ( + _event: IpcMainInvokeEvent, + _payload: null +): Promise => ( + + new Promise(async (resolve, reject) => { + console.log('[IPC]: user-logout'); + + try { + // post logout to backend + await logout(); + + // remove key and crimataId + store.delete('key'); + store.delete('crimataId'); + + // TODO: kill crimata platform session + endSession(); + + resolve(); + } catch(e) { + reject(new Error('Failed to logout. Please try again.')); + } + }) +) + + +export default function useAccountListeners(): void { + + ipcMain.removeHandler("user-profile"); + ipcMain.handle("user-profile", onProfile); + + ipcMain.removeHandler("user-login"); + ipcMain.handle("user-login", onLogin); + + ipcMain.removeHandler("user-logout"); + ipcMain.handle("user-logout", onLogout); + +} diff --git a/src/background/ipc/session.ts b/src/background/ipc/session.ts new file mode 100644 index 0000000..7c42ea2 --- /dev/null +++ b/src/background/ipc/session.ts @@ -0,0 +1,62 @@ + +"use strict"; + +import { initSession, emitNewMessages, sendMessage } from '@/background/session'; +import { ipcMain, IpcMainInvokeEvent, IpcMainEvent } from "electron"; +import { initAudioIO } from "@/background/audio"; +import { ClientMessage } from "@/types"; + + +// Instantiate socket session with crimata-platorm. +const onSessionInit = ( + _event: IpcMainInvokeEvent, + _payload: null +): void => { + + console.log('[IPC]: init-session'); + + initSession(); + + initAudioIO(); +} + + +const onAppMounted = ( + _event: IpcMainInvokeEvent, + _payload: null +): void => { + + console.log('[IPC]: app-mounted'); + + emitNewMessages() +}; + + +// Handle messages from window/client. +const onClientMessage = ( + _event: IpcMainEvent, + payload: ClientMessage +): void => { + + console.log('[IPC]: client-message'); + + sendMessage(payload); +} + + +export default function useSessionListeners(): void { + + console.log('[IPC]: Init session listeners.'); + + // Attach listeners for frontend. + ipcMain.removeAllListeners("client-message"); + ipcMain.on("client-message", onClientMessage); + + // Attack browser window init listener. + ipcMain.removeAllListeners("app-mounted"); + ipcMain.on("app-mounted", onAppMounted); + + ipcMain.removeAllListeners("init-session"); + ipcMain.on("init-session", onSessionInit); + +} diff --git a/src/background/session.ts b/src/background/session.ts new file mode 100644 index 0000000..c90d32a --- /dev/null +++ b/src/background/session.ts @@ -0,0 +1,119 @@ +/* + * Creates a websocket session with Crimata Servers. + * + * Connects to Servers and attempts key authentication. Server will respond + * with key and user profile. We send the profile to the browser. We also + * resend this information on new broser window. We then serve as a + * communication interface between the window and the servers. It will + * automatically try to reconnect on websocket disconnect. + */ + +import { backgroundMitt } from "@/modules/emitter"; +import { ipcEmit, loadState } from './helpers'; +import WebSocket from 'ws'; + +import useWebSockets from "./websockets"; + +import { play } from "./audio"; +import { renderMessage } from "@/modules/message"; +import { SessionState } from "@/types"; + +let win = true; + +// Info saved to json on quit (key, newMessages). +let state: SessionState; + +let socket: WebSocket | null = null; + + +// Calls appropriate endpoint for a server message. +const onMessage = (data: string): void => { + let message = JSON.parse(data); + + if (message === "CLOSE_AUTH_FAIL") { + ipcEmit("session-auth-fail", null) + return; + } + + // Standard message. + if (message.content) { + + // Convert to render message + message = renderMessage( + message.content.text, + message.content.audio, + message.context, + message.modifier + ); + + if (win) { + console.log("SESS:Emitting standard message.") + if (message.audio) { + play(message.audio) + } + ipcEmit("render-message", message) + } + + else { + console.log("SESS:No window: saving message.") + state.newMessages.push(message); + } + } + + else { + if (win) { + ipcEmit("render-message", message) + } + } + +}; + + +// Websockets module. +const { createSocket, send } = useWebSockets(onMessage); + + +export const emitNewMessages = (): void => { + if (state) { + ipcEmit("update-state", { + newMessages: state.newMessages, + }); + } + +} + + +export const sendMessage = (payload: Record): void => { + try { + send(payload) + } catch(e) { + console.log("Unable to send message: ", payload); + } + +} + +export const endSession = (): void => { + if (socket) { + socket.close(); + socket = null; + } +} + + +// Call this to initialize session with Crimata servers. +export const initSession = (): void => { + console.log("SESS:Creating new session.") + + // Load Json or createState. + state = loadState("session.json"); + + // Open socket connection. + if (!socket) + socket = createSocket(); + + // Keep win up-to-date. + backgroundMitt.on('window-active', (state: boolean) => { + win = state; + }); + +} diff --git a/src/background/websockets.ts b/src/background/websockets.ts new file mode 100644 index 0000000..dec0203 --- /dev/null +++ b/src/background/websockets.ts @@ -0,0 +1,122 @@ + +"use strict"; + +import WebSocket from 'ws'; +import { getAuthPayload } from "./authPayload"; +import { ipcEmit } from './helpers'; + +let socket: WebSocket; + +const socketUrl = "ws://127.0.0.1:8760" + +const _connectionCheckTimeout = 4000; +const _reconnectTimeout = 1000; +let _connectionCheckInterval: ReturnType; + + +// Run every time we want to connect to backend. +export default function useWebSockets( + receiveCallback: (s: string) => void, + openCallback?: () => void +) { + + // Returns bool (sucess or fail). + const sendMessage = (data: any) => { + console.log("WS:Sending message: ", data) + + if (socket.readyState !== 1) { + return false + } + + else { + socket.send(JSON.stringify(data)) + return true + } + + } + + const send = async (data: Record): Promise => ( + new Promise((resolve, reject) => { + if (socket.readyState !== 1) { + reject(false); + } + socket.send(JSON.stringify(data)) + resolve(true); + + })) + + + const onOpen = (_event: WebSocket.OpenEvent) => { + + console.log("WS:Connected to WS Server!"); + const jwt = getAuthPayload(); + socket.send(JSON.stringify(jwt)); + + // ping server + _connectionCheckInterval = setInterval(() => { + + if (socket) socket.ping(null, true, (e: Error) => { + if (e) { + ipcEmit('connection-alive', false); + socket.close(); + setTimeout(createSocket, 1000); + } + }); + + }, _connectionCheckTimeout); + + if (openCallback) openCallback(); + + } + + const onServerMessage = (event: WebSocket.MessageEvent) => { + + console.log("WS:Message received: ", event.data); + + receiveCallback(event.data.toString()) + + } + + const onClose = (event: WebSocket.CloseEvent) => { + console.log("WS:Socket closed normally.", event.wasClean) + + clearInterval(_connectionCheckInterval); + + if (!event.wasClean) { + ipcEmit('connection-alive', false); + setTimeout(createSocket, 1000); + } + } + + // Reconnect automatically on error. + const onError = (event: WebSocket.ErrorEvent) => { + console.log("WS:WebSocket error: ", event.message); + } + + + const createSocket = (): WebSocket => { + + if (_connectionCheckInterval) clearInterval(_connectionCheckInterval); + + socket = new WebSocket(socketUrl); + + // Add listeners. + socket.addEventListener("open", onOpen); + socket.addEventListener("message", onServerMessage); + socket.addEventListener("close", onClose); + socket.addEventListener("error", onError); + socket.addEventListener("pong", () => { + ipcEmit('connection-alive', true); + }); + + return socket; + + } + + return { + createSocket, + sendMessage, + send + } + +} diff --git a/src/ipcRend/session.ts b/src/ipcRend/session.ts new file mode 100644 index 0000000..a5ff5fc --- /dev/null +++ b/src/ipcRend/session.ts @@ -0,0 +1,23 @@ + +import { useIpc } from "@/modules/ipc"; + +import { ClientMessage } from "@/types"; + +const { post } = useIpc(); + + +export const postMount = (): void => ( + post("app-mounted", null) +); + + +export const postInitSession = (): void => ( + post("init-session", null) +); + + +export const postMessage = (payload: ClientMessage): void => ( + post('client-message', payload) +); + +