Compare commits

..
Author SHA1 Message Date
Andrew Gundersen
fd7235ae5b prototype browser audio code 2021-06-02 19:14:48 -05:00
Andrew Gundersen
b08061d080 more changes 2021-05-31 10:01:04 -05:00
Andrew Gundersen
ee63bb0ac1 more audio changes 2021-05-29 10:36:43 -05:00
Andrew Gundersen
eddbe140df electron audio module 2021-05-27 14:02:36 -05:00
Andrew Gundersen
5671c8854a proof concept new audio interface 2021-05-26 11:56:33 -05:00
Andrew Gundersen
c561e11839 minor changes 2021-05-25 15:21:57 -05:00
70 changed files with 4756 additions and 4236 deletions

1
.gitignore vendored
View file

@ -9,7 +9,6 @@ crash.log
# local env files # local env files
.env.local .env.local
.env.*.local .env.*.local
.env
# Log files # Log files
npm-debug.log* npm-debug.log*

79
build/config.gypi Normal file
View file

@ -0,0 +1,79 @@
# Do not edit. File was generated by node-gyp's "configure" step
{
"target_defaults": {
"cflags": [],
"default_configuration": "Release",
"defines": [],
"include_dirs": [],
"libraries": []
},
"variables": {
"asan": 0,
"build_v8_with_gn": "false",
"coverage": "false",
"dcheck_always_on": 0,
"debug_nghttp2": "false",
"debug_node": "false",
"enable_lto": "false",
"enable_pgo_generate": "false",
"enable_pgo_use": "false",
"error_on_warn": "false",
"force_dynamic_crt": 0,
"host_arch": "x64",
"icu_data_in": "../../deps/icu-tmp/icudt67l.dat",
"icu_endianness": "l",
"icu_gyp_path": "tools/icu/icu-generic.gyp",
"icu_path": "deps/icu-small",
"icu_small": "false",
"icu_ver_major": "67",
"is_debug": 0,
"llvm_version": "0.0",
"napi_build_version": "6",
"node_byteorder": "little",
"node_debug_lib": "false",
"node_enable_d8": "false",
"node_install_npm": "true",
"node_module_version": 83,
"node_no_browser_globals": "false",
"node_prefix": "/",
"node_release_urlbase": "https://nodejs.org/download/release/",
"node_shared": "false",
"node_shared_brotli": "false",
"node_shared_cares": "false",
"node_shared_http_parser": "false",
"node_shared_libuv": "false",
"node_shared_nghttp2": "false",
"node_shared_openssl": "false",
"node_shared_zlib": "false",
"node_tag": "",
"node_target_type": "executable",
"node_use_bundled_v8": "true",
"node_use_dtrace": "true",
"node_use_etw": "false",
"node_use_node_code_cache": "true",
"node_use_node_snapshot": "true",
"node_use_openssl": "true",
"node_use_v8_platform": "true",
"node_with_ltcg": "false",
"node_without_node_options": "false",
"openssl_fips": "",
"openssl_is_fips": "false",
"shlib_suffix": "83.dylib",
"target_arch": "x64",
"v8_enable_31bit_smis_on_64bit_arch": 0,
"v8_enable_gdbjit": 0,
"v8_enable_i18n_support": 1,
"v8_enable_inspector": 1,
"v8_enable_pointer_compression": 0,
"v8_no_strict_aliasing": 1,
"v8_optimized_debug": 1,
"v8_promise_internal_field_count": 1,
"v8_random_seed": 0,
"v8_trace_maps": 0,
"v8_use_siphash": 1,
"want_separate_host_toolset": 0,
"xcode_version": "11.0",
"nodedir": "/Users/Enrique/Library/Caches/node-gyp/14.4.0",
"standalone_static_library": 1
}
}

View file

@ -12,7 +12,7 @@
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps" "postuninstall": "electron-builder install-app-deps"
}, },
"main": "init.js", "main": "background.js",
"dependencies": { "dependencies": {
"@google-cloud/speech": "^4.2.0", "@google-cloud/speech": "^4.2.0",
"@types/animejs": "^3.1.2", "@types/animejs": "^3.1.2",
@ -22,13 +22,11 @@
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/ws": "^7.2.7", "@types/ws": "^7.2.7",
"animejs": "^3.2.0", "animejs": "^3.2.0",
"axios": "^0.21.1",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"electron-store": "^8.0.0",
"electron-updater": "^4.3.8", "electron-updater": "^4.3.8",
"mitt": "^2.1.0", "mitt": "^2.1.0",
"naudiodon": "^2.3.2", "naudiodon": "^2.3.5",
"node-record-lpcm16": "^1.0.1", "node-record-lpcm16": "^1.0.1",
"update-electron-app": "^2.0.1", "update-electron-app": "^2.0.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
@ -38,7 +36,6 @@
"ws": "^7.3.1" "ws": "^7.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/axios": "^0.14.0",
"@types/electron-devtools-installer": "^2.2.0", "@types/electron-devtools-installer": "^2.2.0",
"@types/jest": "^24.0.19", "@types/jest": "^24.0.19",
"@typescript-eslint/eslint-plugin": "^2.33.0", "@typescript-eslint/eslint-plugin": "^2.33.0",
@ -60,7 +57,7 @@
"eslint": "^6.7.2", "eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0-0", "eslint-plugin-vue": "^7.0.0-0",
"lint-staged": "^9.5.0", "lint-staged": "^9.5.0",
"node-sass": "^4.12.0", "node-sass": "4",
"optimize-wasm-webpack-plugin": "^1.0.12", "optimize-wasm-webpack-plugin": "^1.0.12",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"spectron": "11.0.0", "spectron": "11.0.0",
@ -72,9 +69,7 @@
"lintOnSave": false, "lintOnSave": false,
"pluginOptions": { "pluginOptions": {
"electronBuilder": { "electronBuilder": {
"mainProcessFile": "./src/init.ts", "preload": "src/preload.ts",
"rendererProcessFile": "./src/render/main.ts",
"preload": "./src/render/preload.ts",
"builderOptions": { "builderOptions": {
"appId": "com.crimata.ElectronUpdaterApp", "appId": "com.crimata.ElectronUpdaterApp",
"artifactName": "${productName}-${version}.${ext}", "artifactName": "${productName}-${version}.${ext}",

View file

@ -11,7 +11,11 @@
<noscript> <noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript> </noscript>
<div id="app"></div> <!-- <h1>Hello World!</h1>
We are using Node.js <span id="node-version"></span>, Chromium
<span id="chrome-version"></span>, and Electron
<span id="electron-version"></span>. -->
<div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
</body> </body>
</html> </html>

183
src/App.vue Normal file
View file

@ -0,0 +1,183 @@
<template>
<!-- Only render when profile has been set -->
<div id="app" v-if="(ready)">
<button id="titlebar" />
<!-- Minimize and exit buttons. -->
<span class="menu">
<button
class="menuButton exitButton"
@click.prevent="post('nav-bar', 'close')"
/>
<button
class="menuButton minimizeButton"
@click.prevent="post('nav-bar', 'min')"
/>
</span>
<!-- Main Pages -->
<Messenger
v-if="profile"
:profile="profile"
:newMessages="newMessages"
/>
<Login v-else />
</div>
<!-- Show spash screen if ready is false -->
<Splash v-else />
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, ref } from "vue";
import { IpcRendererEvent } from "electron";
import { useIpc } from "@/modules/ipc";
import Splash from "@/components/splash.vue";
import Messenger from "@/components/messenger.vue";
import Login from "@/components/login.vue";
export default defineComponent({
components: {
Splash,
Messenger,
Login
},
setup() {
const { post } = useIpc();
// Whether browser has received user info yet.
const ready = ref(false);
// Information about current user.
const profile = ref(false);
// New messages that browser missed while closed.
const newMessages = ref([])
// Receive updated information about the session.
const updateState = (_event: IpcRendererEvent, payload: any) => {
console.log("APP:Received updated profile and new messages: \n" +
` profile: ${payload.message.profile}\n` +
` new: ${payload.message.newMessages}`)
if (payload.message.profile) {
console.log(`APP:Logged-in, showing Messenger View.`)
} else {
console.log(`APP:Logged-out, showing Login View.`)
}
// Set profile and newMessages.
profile.value = payload.message.profile;
newMessages.value = payload.message.newMessages;
ready.value = true;
}
onMounted(() => {
console.log("APP:mounted.")
window.ipcRenderer.on("update-state", updateState)
window.ipcRenderer.on("update_available", () => {
console.log('testing auto update');
})
window.ipcRenderer.on("update_downloaded", () => {
console.log('testing update download');
})
post("app-mounted", "")
});
onUnmounted(() => {
window.ipcRenderer.removeAllListeners("update-state")
})
return {
ready,
profile,
post,
newMessages
}
}
})
</script>
<style lang="scss">
html, body {
margin: 0;
padding: 0;
// Background color set in window.ts
}
#app {
font-family: "SF Pro Text";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
width: 100vw;
border-radius: 15px;
}
#titlebar {
position: fixed;
width: 100vw;
height: 54px;
opacity: 0.75;
background-color: #EBEBEB;
-webkit-app-region: drag;
border: none;
outline: none;
z-index: 1;
}
.menu {
position: fixed;
margin-left: 20px;
margin-top: 20px;
z-index: 2;
}
.menuButton {
border: none;
outline:none;
min-width: 14px;
min-height: 14px;
border-radius: 7px;
}
.exitButton {
background-color: #FF6157;
}
.exitButton:active {
background: #c14645;
}
.minimizeButton {
background-color: #FFC12F;
margin-left: 8px;
}
.minimizeButton:active {
background-color: #c08e38;
}
</style>

View file

@ -1,95 +0,0 @@
import { postAuth, postLogin, postLogout } from "@/api/account";
import { endSession, launchSession } from "@/session";
import { getToken, setToken, setProfile, getProfile, clearStore } from "./store";
import { parseAuthRes } from "./auth";
import { ipcEmit } from "@/composables/useEmitter";
export const accountAuth = async (): Promise<Error | AuthState> => {
/* attempt to get a login token from the store */
const token = getToken();
/* try to login with it, returns platform secret and new token on success */
if (token) {
try {
const res = await postAuth(token);
const parsed = parseAuthRes(res);
setToken(parsed.token)
setProfile(parsed.profile);
return {
profile: parsed.profile,
token: parsed.token
};
} catch(e) {
console.log('[ACCOUNT]', e);
clearStore();
throw(new Error('Failed to authenticate.'));
}
} else {
throw(new Error('Unable to authenticate.'));
}
};
export const accountLogin: IpcHandlerCallback<AccountCredentials, Profile> = async (payload) => {
const account = payload as AccountCredentials;
try {
// attempt login with email password
const res = await postLogin(account.email, account.password);
const parsed = parseAuthRes(res);
// save jwt token and profile
setToken(parsed.token);
setProfile(parsed.profile);
// launch session
launchSession(parsed.token);
// return profile to renderer
return parsed.profile;
} catch(e) {
clearStore();
throw e;
}
}
export const accountLogout = async (): Promise<Error | void> => {
try {
// post logout to backend
await postLogout();
// remove key and crimataId
clearStore();
// kill crimata platform session
endSession();
return;
} catch(e) {
console.log('[ACCOUNT]', e);
return (new Error('Failed to logout. Please try again.'));
}
}
export const updateAppState = (): void => {
const profile = getProfile();
ipcEmit("set-profile", profile);
// ipcEmit('messages') etc
}

View file

@ -1,31 +0,0 @@
import useHttp from "@/composables/useHttp";
import axios from "axios";
import {config} from "@/config";
const { post } = useHttp();
export const postAuth = async (token: string) => (
await axios({
url: config.BUSINESS_URL + config.BUSINESS_PREFIX + '/account/authenticate',
headers: {
Cookie: `crimataCookie=${token}`
},
method: 'POST',
})
);
export const postLogin = async (email: string, password: string) => (
await post('/account/login', { email, password })
);
export const postLogout =
async (): Promise<null | Error> => (await post('/account/logout'));

14
src/assets/crimata.svg Normal file
View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13.795" height="12.759" viewBox="0 0 13.795 12.759">
<g id="Group_544" data-name="Group 544" transform="translate(-4127 -507)">
<g id="Group_33" data-name="Group 33" transform="translate(4127 507)">
<g id="Group_32" data-name="Group 32" transform="translate(0 0)">
<g id="Group_31" data-name="Group 31" transform="translate(0 6.627)">
<circle id="Ellipse_50" data-name="Ellipse 50" cx="3.066" cy="3.066" r="3.066" transform="translate(0 0)" fill="#383838"/>
<circle id="Ellipse_51" data-name="Ellipse 51" cx="3.066" cy="3.066" r="3.066" transform="translate(7.664 0)" fill="#383838"/>
</g>
<circle id="Ellipse_52" data-name="Ellipse 52" cx="3.066" cy="3.066" r="3.066" transform="translate(3.826 0)" fill="#383838"/>
<path id="Path_150" data-name="Path 150" d="M36.27,83.67" transform="translate(-33.199 -73.977)" fill="#ff0"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 973 B

View file

@ -1,187 +0,0 @@
/* eslint @typescript-eslint/no-var-requires: "off" */
"use strict";
// where the audio goes
let buffer: ArrayBuffer[] = [];
// place audio data in buffer
export const collect: IpcListenerCallback<ArrayBuffer> = (chunk) => {
if (chunk) buffer.push(chunk);
}
// return audio and clear buffer
export const flush = async () => {
const bufferCopy = buffer;
buffer = [];
return bufferCopy;
}
// import { backgroundMitt } from '@/modules/emitter';
// const portAudio = require('naudiodon');
// // Audio in and out stream objects.
// let ai: typeof portAudio.AudioIO | boolean = false;
// let ao: typeof portAudio.AudioIO | boolean = false;
// // Whether activly recording.
// let record = false;
// const audioContainer = {
// input: '',
// }
// const audioOptions = {
// channelCount: 1,
// sampleFormat: 16,
// sampleRate: 16000,
// deviceId: -1,
// closeOnError: false,
// }
// export const toggleRecord = (): void => { record = !record };
// export const fetchAudioInput = (): Promise<Error | string> => (
// new Promise((resolve, reject) => {
// try {
// resolve(audioContainer.input);
// toggleRecord();
// } catch (e) {
// reject(new Error('Failed to fetch the audio.'))
// }
// })
// )
// // Main audio function run by run.ts module.
// export function initAudioIO(): void {
// console.log("AUDIO:Starting io streams.")
// if (!ai) {
// // Initialize and start input stream.
// ai = new portAudio.AudioIO({ inOptions: audioOptions });
// ai.setEncoding("hex");
// ai.start();
// // On each data chunk...
// ai.on('data', (chunk: string) => {
// // If recording, we capture the data.
// if (record) {
// console.log('AUDIO:Recording...')
// audioContainer.input += chunk;
// }
// // Else, we don't capture and also clear audioContainer.
// else {
// if (audioContainer.input.length) {
// audioContainer.input = "";
// }
// }
// });
// }
// if (!ao) {
// // Initialize and start input stream.
// ao = new portAudio.AudioIO({ outOptions: audioOptions });
// ao.start();
// }
// }
// // ---Audio playback--------------------------------------------
// // Split Buffer into an array of len-sized Buffers.
// function bufSplit(buf: Buffer, len: number): Array<Buffer> {
// const chunks = [];
// let i = 0;
// let L = len;
// while(i < buf.byteLength) {
// chunks.push(buf.slice(i, L));
// i = L;
// L += len;
// }
// return chunks;
// }
// // Audio playback.
// export function play(input: string): void {
// // Format the audio.
// const audio = bufSplit(
// Buffer.from(input as string, 'hex'),
// 8192
// );
// // Called on end of write.
// const callback = () => {
// // We stop audio playback anim.
// backgroundMitt.emit('ipc-renderer', {
// endpoint: 'stop-playback-anim'
// });
// }
// write();
// // Iterate through audio array and write buffers to portAudio writable.
// function write() {
// let chunk: Buffer;
// let ok = true;
// let i = 0;
// do {
// chunk = audio[i];
// if (i === audio.length - 1) {
// // write last chunk.
// ao.write(chunk, null, callback);
// } else {
// // check for backpreassure.
// ok = ao.write(chunk, null);
// }
// i++;
// } while (i < audio.length && ok);
// if (i < audio.length) {
// // Had to stop early!
// // Write some more once it drains.
// ao.once('drain', write);
// }
// }
// }
// // -------------------------------------------------------------
// // Get's called on window close.
// export async function stopStream() {
// console.log("AUDIO:Stopping audio stream.")
// if (ai) {
// try {
// await ai.quit()
// } catch(e){
// console.log('AUDIO: Failed to shutdown audio input.');
// throw e;
// }
// }
// if (ao) {
// try {
// await ao.quit()
// } catch(e){
// console.log('AUDIO: Failed to shutdown audio output.');
// throw e;
// }
// }
// }

View file

@ -1,13 +0,0 @@
export 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
}
};

25
src/background.ts Normal file
View file

@ -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
(async () => {
console.log('Starting Crimata electron app.');
await initApp(isDev);
})();

62
src/background/helpers.ts Normal file
View file

@ -0,0 +1,62 @@
import fs from 'fs';
import { backgroundMitt } from "@/modules/emitter";
import { SessionState, WindowState } from "@/types";
import { app } from "electron";
const configPath = app.getPath("userData");
export const ipcEmit = (channel: string, payload: any) => {
backgroundMitt.emit('ipc-renderer', {
endpoint: channel,
message: payload
});
}
export const loadState = (fileName: string): SessionState => {
let state: SessionState;
try {
state = JSON.parse(fs.readFileSync(configPath + fileName).toString());
}
catch (error) {
state = {
key: false,
newMessages: []
}
}
return state
}
export const loadWinState = (fileName: string): WindowState => {
let state: WindowState;
try {
state = JSON.parse(fs.readFileSync(configPath + fileName).toString());
}
catch (error) {
state = {
width: 600,
height: 500,
x: null,
y: null,
}
}
return state
}
// Save session or window state.
export const saveToJson = (fileName: string, data: any) => {
fs.writeFile(configPath + fileName, JSON.stringify(data), (err) => {
if (err) {
console.log("Error when saving to json.")
}
})
}

85
src/background/init.ts Normal file
View file

@ -0,0 +1,85 @@
"use strict";
import { app, dialog } from "electron";
import { createWindow } from './window';
import { initSession } from './session';
import { backgroundMitt } from '@/modules/emitter';
let win: boolean;
// Listen for window creation.
backgroundMitt.on('window-active', (state: boolean) => {
win = state;
});
// Auto updating.
const { autoUpdater } = require('electron-updater')
autoUpdater.requestHeaders = { 'PRIVATE-TOKEN': 'mvvgWYwWnot4bisiQMh_' }
autoUpdater.on('update-available', (info: any) => {
console.log(`Update available: ${info.version}`)
})
autoUpdater.on('update-downloaded', (info: any) => {
const updateDialog = {
type: 'info',
buttons: ['Restart', 'Later'],
title: 'Application Update',
message: info.version,
detail: 'A new version has been downloaded. Restart the application to apply the updates.'
}
dialog.showMessageBox(updateDialog).then((returnValue) => {
if (returnValue.response === 0) autoUpdater.quitAndInstall()
})
})
// Run when electron app is initialized.
async function main(dev: boolean) {
console.log("MAIN:Initializing Electron App.")
// Must wait til window is created.
await createWindow();
// Instantiate socket session with crimata-platorm.
initSession(dev);
}
// Root function of app.
export function initApp(dev: boolean): void {
// On initial startup.
app.on("ready", () => {
if (!dev) autoUpdater.checkForUpdates()
main(dev)
});
// Must keep to ensure app doesn't quit on close.
app.on("before-quit", async () => {
});
// Must keep to ensure app doesn't quit on close.
app.on("window-all-closed", () => {
});
// When user clicks app icon (re-open)
app.on("activate", () => {
if (!win) {
createWindow();
}
});
// Exit cleanly on request from parent process in development mode.
if (dev) {
process.on("SIGTERM", () => {
app.quit();
});
}
}

167
src/background/session.ts Normal file
View file

@ -0,0 +1,167 @@
/*
* 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, saveToJson } from './helpers';
import { ipcMain, IpcMainEvent, IpcMainInvokeEvent } from "electron";
import useWebSockets from "@/modules/websockets";
import { renderMessage } from "@/modules/message";
import { AuthProtocol, SessionState, Profile } from "@/types";
let win = true;
// Info saved to json on quit (key, newMessages).
let state: SessionState;
// Profile of current user.
let profile: Profile | boolean;
// Called when server sends auth message.
const updateState = (res: AuthProtocol) => {
console.log("SESS:Auth message received: \n" +
` key: ${res.key}\n` +
` alias: ${res.profile}`)
if (state) {
// Update key.
state.key = res.key;
// Update the user profile.
profile = res.profile;
// Send upated profile to frontend.
console.log("SESS:Sending updated user profile to browser.")
ipcEmit("update-state", {
profile: profile,
newMessages: state.newMessages
})
// Save the updated state to json.
console.log("SESS:Saving session state.")
saveToJson("session.json", state)
}
}
// Send state on new window.
const onNewBrowserWindow = (_event: IpcMainInvokeEvent, _payload: any) => {
if (typeof profile !== 'undefined') {
console.log("SESS:Sending user profile to browser.")
ipcEmit("update-state", {
profile: profile,
newMessages: state.newMessages
})
}
}
// Calls appropriate endpoint for a server message.
const onMessage = (data: string) => {
let message = JSON.parse(data)
// AuthProtocol message.
if (message.hasOwnProperty("key")) {
updateState(message)
}
// Standard message.
else 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)
}
}
};
// When socket connects, we update state.
const onOpen = () => {
console.log(`SESS:Sending key: ${state.key}`)
if (state) {
sendMessage({
"key": state.key,
"usr": false,
"pwd": false
})
}
}
// Websockets module.
const { createSocket, sendMessage } = useWebSockets(onMessage, onOpen);
// Handle messages from window/client.
const onClientMessage = (_event: IpcMainEvent, payload: any) => {
console.log("New client message");
const success = sendMessage(payload);
if (!success) {
console.log("Unable to send message: ", payload);
}
}
// Call this to initialize session with Crimata servers.
export const initSession = (dev: boolean) => {
console.log("SESS:Creating new session.")
// Load Json or createState.
state = loadState("session.json")
console.log("SESS:State loaded: \n" +
` key: ${state.key}\n` +
` new: ${state.newMessages}`)
// Open socket connection.
let url = "ws://crimata.com:8760";
if (dev) url = "ws://localhost:8760";
createSocket(url);
// Attack browser window init listener.
ipcMain.removeAllListeners("app-mounted");
ipcMain.on("app-mounted", onNewBrowserWindow);
// Attach listeners for frontend.
ipcMain.removeAllListeners("client-message");
ipcMain.on("client-message", onClientMessage);
// Keep win up-to-date.
backgroundMitt.on('window-active', (state: boolean) => {
win = state;
});
}

View file

@ -1,37 +1,21 @@
"use strict"; "use strict";
import { BrowserWindow, ipcMain, app } from "electron"; import { BrowserWindow, ipcMain } from "electron";
import { createProtocol } from "vue-cli-plugin-electron-builder/lib"; import { createProtocol } from "vue-cli-plugin-electron-builder/lib";
import { backgroundMitt } from './composables/useEmitter'; import { backgroundMitt } from '@/modules/emitter';
import { saveToJson } from "./composables/useSaveToJSON"; import { RenderMessage, WindowState } from "@/types";
import { loadWinState, saveToJson } from "./helpers";
import * as path from "path"; import * as path from "path";
import fs from 'fs';
import { config } from "@/config";
const { autoUpdater } = require('electron-updater'); const { autoUpdater } = require('electron-updater');
interface IpcRendererPayload {
endpoint: string;
message: RenderMessage | null;
}
let win: BrowserWindow | null; let win: BrowserWindow | null;
let winState: WindowState; let winState: WindowState;
const loadWinState = (fileName: string): WindowState => {
let state: WindowState;
try {
state = JSON.parse(fs.readFileSync(config.configPath + fileName).toString());
}
catch (error) {
state = {
width: 600,
height: 500,
x: null,
y: null,
}
}
return state
}
// Called when a NavBar button is pressed. // Called when a NavBar button is pressed.
const onNavBar = (_event: any, action: string): void => { const onNavBar = (_event: any, action: string): void => {
if (win) { if (win) {
@ -44,9 +28,11 @@ const onNavBar = (_event: any, action: string): void => {
} }
// Util function to render message on ipc-renderer event. // Util function to render message on ipc-renderer event.
const postToWindow = <T>(event: IpcRendererEvent<T>): void => { const renderMessage = (payload: IpcRendererPayload): void => {
if (win) { if (win) {
win.webContents.send(event.channel, event.payload); win.webContents.send(payload.endpoint, {
message: payload.message
});
} }
} }
@ -71,6 +57,7 @@ const saveWindowState = () => {
// Do this on window mount. // Do this on window mount.
const onWindowMount = (): void => { const onWindowMount = (): void => {
console.log("BW:Adding window listeners. ")
// Must tell initApp that window exists. // Must tell initApp that window exists.
backgroundMitt.emit('window-active', true); backgroundMitt.emit('window-active', true);
@ -81,18 +68,20 @@ const onWindowMount = (): void => {
// Gateway for messages to the frontend. // Gateway for messages to the frontend.
backgroundMitt.removeAllListeners("ipc-renderer") backgroundMitt.removeAllListeners("ipc-renderer")
backgroundMitt.on("ipc-renderer", postToWindow); backgroundMitt.on("ipc-renderer", renderMessage);
console.log("BW:Listeners created.")
} }
// Do this on window dismount (close). // Do this on window dismount (close).
const onWindowDismount = (): void => { const onWindowDismount = (): void => {
console.log("BW:Window closed.")
win = null; win = null;
backgroundMitt.emit('window-active', false); backgroundMitt.emit('window-active', false);
} }
// function used by run.ts to create the main window. // function used by run.ts to create the main window.
export default async function createWindow(): Promise<void> { export async function createWindow(): Promise<void> {
return new Promise((resolve, _reject) => { return new Promise((resolve, _reject) => {
// avoid creating duplicate windows. // avoid creating duplicate windows.
@ -100,13 +89,14 @@ export default async function createWindow(): Promise<void> {
// Load the saved window state. // Load the saved window state.
winState = loadWinState("window.json") winState = loadWinState("window.json")
console.log(`BW:Creating window [${winState.width}, ${winState.height}].`)
// Define the browser window. // Define the browser window.
win = new BrowserWindow({ win = new BrowserWindow({
width: winState.width, width: winState.width,
height: winState.height, height: winState.height,
x: winState.x as number, x: winState.x,
y: winState.y as number, y: winState.y,
resizable: true, resizable: true,
backgroundColor: '#EBEBEB', backgroundColor: '#EBEBEB',
frame: false, frame: false,

View file

@ -0,0 +1,26 @@
import anime from "animejs";
export function showRecIcon () {
anime({
targets: '#recIcon',
opacity: [0, 0.75],
scale: [0.0, 1],
duration: 250,
easing: 'linear',
})
}
export function hideRecIcon () {
anime({
targets: '#recIcon',
opacity: [0.75, 0],
scale: [1, 0],
duration: 250,
easing: 'linear',
})
}

View file

@ -0,0 +1,261 @@
import useMitt from "@/modules/mitt";
import { useIpc } from '@/modules/ipc';
import { showRecIcon, hideRecIcon } from "./animations";
import { onMounted, onUnmounted, ref, Ref } from "vue";
import keyboardNameMap from "./keyBoardMaps/keyboardNameMap";
import { renderMessage, clientMessage } from '@/modules/message';
const { post, invoke } = useIpc();
function floatTo16bPCM(output: DataView, offset: number, input: Float32Array) {
for (var i = 0; i < input.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
function writeFloat32 (output: DataView, offset: number, input: Float32Array) {
for (var i = 0; i < input.length; i++, offset += 4) {
output.setFloat32(offset, input[i], true)
}
}
function writeString(view: DataView, offset: number, string: string) {
for (var i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function encodeWAV (samples: Float32Array, format: number, sampleRate: number, numChannels: number, bitDepth: number) {
var bytesPerSample = bitDepth / 8
var blockAlign = numChannels * bytesPerSample
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample)
var view = new DataView(buffer)
/* RIFF identifier */
writeString(view, 0, 'RIFF')
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * bytesPerSample, true)
/* RIFF type */
writeString(view, 8, 'WAVE')
/* format chunk identifier */
writeString(view, 12, 'fmt ')
/* format chunk length */
view.setUint32(16, 16, true)
/* sample format (raw) */
view.setUint16(20, format, true)
/* channel count */
view.setUint16(22, numChannels, true)
/* sample rate */
view.setUint32(24, sampleRate, true)
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * blockAlign, true)
/* block align (channel count * bytes per sample) */
view.setUint16(32, blockAlign, true)
/* bits per sample */
view.setUint16(34, bitDepth, true)
/* data chunk identifier */
writeString(view, 36, 'data')
/* data chunk length */
view.setUint32(40, samples.length * bytesPerSample, true)
/* write data */
if (format === 1) {
floatTo16bPCM(view, 44, samples)
} else {
writeFloat32(view, 44, samples)
}
return buffer
}
function mergeBuffers(bufferArray: Float32Array[], recLength: number) {
// initialize array to hold all samples
var result = new Float32Array(recLength);
// for each array in bufferArray, append values to result
var offset = 0;
for (var i = 0; i < bufferArray.length; i++) {
result.set(bufferArray[i], offset);
offset += bufferArray[i].length;
}
return result;
}
function exportBuffer(recBuffer: Float32Array[], recLength: number, exportSampleRate: number) {
var mergedBuffers = mergeBuffers(recBuffer, recLength);
var encodedWav = encodeWAV(mergedBuffers, 1, exportSampleRate, 1, 16);
var audioBlob = new Blob([encodedWav], {type: 'audio/wav'});
return audioBlob;
}
function postAudioBlob(blob: Blob, uid: string) {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = (e: ProgressEvent<FileReader>) => {
post("client-message", clientMessage("", reader.result as string, uid))
}
}
export default function useAudioInputController (typing: Ref) {
let audioContext: AudioContext;
let buffer: Float32Array[] = [];
let bufferLen = 0;
let sampleRate = 16000;
let numChannels = 1;
// For sending messages.
const { emitter } = useMitt();
const recording = ref(false);
// initiate media recorder
if (navigator.mediaDevices) {
console.log("Initializing media recorder.");
const usrOptions: any = {
audio: true,
video: false
}
navigator.mediaDevices.getUserMedia(usrOptions).then((stream: any) => {
audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
sampleRate = audioContext.sampleRate;
source.connect(processor);
processor.connect(audioContext.destination);
processor.onaudioprocess = (e: AudioProcessingEvent) => {
if (recording.value) {
const data: Float32Array = e.inputBuffer.getChannelData(0);
buffer.push(data);
bufferLen += data.length;
} else {
buffer = [];
bufferLen = 0;
}
};
});
}
const play = (dataUrl: string) => {
const snd = new Audio(dataUrl);
snd.addEventListener("canplaythrough", (event: any) => {
console.log("Playing");
snd.play();
});
}
// var play = function (blob: Blob) {
// // We'll use a FileReader to create and ArrayBuffer out of the audio response.
// var fileReader = new FileReader();
// fileReader.onload = function() {
// // Once we have an ArrayBuffer we can create our BufferSource and decode the result as an AudioBuffer.
// const playbackSource = audioContext.createBufferSource();
// audioContext.decodeAudioData(fileReader.result as Buffer, function(audioBuffer) {
// console.log(audioBuffer.length);
// console.log(audioBuffer.sampleRate);
// console.log(audioBuffer.numberOfChannels);
// console.log(audioBuffer.duration);
// // Set the source buffer as our new AudioBuffer.
// playbackSource.buffer = audioBuffer;
// // Set the destination (the actual audio-rendering device--your device's speakers).
// playbackSource.connect(audioContext.destination);
// // Add an "on ended" callback.
// playbackSource.onended = function(event) {
// console.log("Playback ended");
// };
// // Start the playback.
// playbackSource.start(0);
// });
// };
// fileReader.readAsArrayBuffer(blob);
// };
//---Callbacks-----------------------------------------------
const onKeyDown = (e: KeyboardEvent) => {
const cmd = keyboardNameMap[e.keyCode];
// Start recording on space bar.
if (cmd == "SPACE" && !typing.value && !recording.value) {
console.log("INPT:Starting record, filling buffer.")
recording.value = true;
showRecIcon();
}
}
const onKeyUp = async (e: KeyboardEvent) => {
const cmd = keyboardNameMap[e.keyCode];
// Stop recording on space up.
if (cmd == "SPACE" && recording.value) {
// get audio from buffer then stop recording
console.log("INPT:Stopping record.")
hideRecIcon()
// render a place holder message
const message = renderMessage(
"",
"",
"",
"sf"
);
emitter.emit("self-message", message);
const blob: Blob = exportBuffer(buffer, bufferLen, sampleRate);
postAudioBlob(blob, message.uid);
recording.value = false;
// const reader = new FileReader();
// reader.readAsDataURL(blob);
// reader.onload = (e: ProgressEvent<FileReader>) => {
// play(reader.result as string);
// }
}
}
//-----------------------------------------------------------
onMounted(() => {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
})
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
});
return {
recording
}
}

View file

@ -6,17 +6,15 @@
:style="{ top: `${elementY}px`, left: `${elementX}px` }" :style="{ top: `${elementY}px`, left: `${elementX}px` }"
> >
<div>{{ initials }}</div> <div>{{ initials }}</div>
<!-- Recording animation on space bar --> <!-- Recording animation on space bar -->
<span v-if="recording" class="play"></span> <span v-if="recording" class="play"></span>
<span v-if="recording" class="pause"></span> <span v-if="recording" class="pause"></span>
<span id="audio"></span>
<!-- Show text input on key-down --> <!-- Show text input on key-down -->
<input <TextInput />
id="textInput"
type="text"
/>
<!-- Show suggestions menu on click --> <!-- Show suggestions menu on click -->
<!-- <Menu /> --> <!-- <Menu /> -->
@ -27,19 +25,22 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import draggify from "@/render/composables/useDraggify"; import draggify from "@/modules/draggify";
import TextInput from "./textinput.vue";
import useTextInputController from import useTextInputController from "./textcontrol";
"@/render/components/controllers/inputItem.control.text"; import useAudioInputController from "./audiocontrol";
import useAudioInputController from
"@/render/components/controllers/inputItem.control.audio";
export default defineComponent({ export default defineComponent({
name: "InputItem", name: "InputItem",
props: ["initials"], props: ["initials"],
components: {
TextInput
},
setup() { setup() {
// Default values for position. // Default values for position.
@ -189,32 +190,4 @@ export default defineComponent({
} }
} }
#textInput {
position: absolute;
opacity: 0;
min-width: 150px;
height: 16px;
border-radius: 18px;
padding: 10px;
margin-right: 10px;
margin-left: 10px;
outline: none;
border: none;
pointer-events: none;
background-color: white;
z-index: -1;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
transform: translateX(50px) scale(0.3);
}
</style> </style>

View file

@ -0,0 +1,263 @@
// This has the UnShifted and Shifted characters that each key maps to
// Ones that are to be ignored for character input are empty.
const keyboardCharMap = [
["", ""], // [0]
["", ""], // [1]
["", ""], // [2]
["", ""], // [3]
["", ""], // [4]
["", ""], // [5]
["", ""], // [6]
["", ""], // [7]
["", ""], // [8]
["", ""], // [9]
["", ""], // [10]
["", ""], // [11]
["", ""], // [12]
["\r", "\r"], // [13] - MOST control characters are ignored. This one (Carriage Return, or "Enter") is significant!
["", ""], // [14]
["", ""], // [15]
["", ""], // [16]
["", ""], // [17]
["", ""], // [18]
["", ""], // [19]
["", ""], // [20]
["", ""], // [21]
["", ""], // [22]
["", ""], // [23]
["", ""], // [24]
["", ""], // [25]
["", ""], // [26]
["", ""], // [27]
["", ""], // [28]
["", ""], // [29]
["", ""], // [30]
["", ""], // [31]
[" ", " "], // [32] // SPACE! Don't "clean it up" and remove the space!
["", ""], // [33]
["", ""], // [34]
["", ""], // [35]
["", ""], // [36]
["", ""], // [37]
["", ""], // [38]
["", ""], // [39]
["", ""], // [40]
["", ""], // [41]
["", ""], // [42]
["", ""], // [43]
["", ""], // [44]
["", ""], // [45]
["", ""], // [46]
["", ""], // [47]
["0", ")"], // [48]
["1", "!"], // [49]
["2", "@"], // [50]
["3", "#"], // [51]
["4", "$"], // [52]
["5", "%"], // [53]
["6", "^"], // [54]
["7", "&"], // [55]
["8", "*"], // [56]
["9", "("], // [57]
["", ""], // [58]
[";", ":"], // [59]
["<", ""], // [60]
["=", ""], // [61]
[">", ""], // [62]
["?", ""], // [63] shifted; else "/"
["", ""], // [64]
["a", "A"], // [65]
["b", "B"], // [66]
["c", "C"], // [67]
["d", "D"], // [68]
["e", "E"], // [69]
["f", "F"], // [70]
["g", "G"], // [71]
["h", "H"], // [72]
["i", "I"], // [73]
["j", "J"], // [74]
["k", "K"], // [75]
["l", "L"], // [76]
["m", "M"], // [77]
["n", "N"], // [78]
["o", "O"], // [79]
["p", "P"], // [80]
["q", "Q"], // [81]
["r", "R"], // [82]
["s", "S"], // [83]
["t", "T"], // [84]
["u", "U"], // [85]
["v", "V"], // [86]
["w", "W"], // [87]
["x", "X"], // [88]
["y", "Y"], // [89]
["z", "Z"], // [90]
["", ""], // [91] Windows Key (Windows) or Command Key (Mac)
["", ""], // [92]
["", ""], // [93]
["", ""], // [94]
["", ""], // [95]
// Number Keypad Entries...
["0", ""], // [96]
["1", ""], // [97]
["2", ""], // [98]
["3", ""], // [99]
["4", ""], // [100]
["5", ""], // [101]
["6", ""], // [102]
["7", ""], // [103]
["8", ""], // [104]
["9", ""], // [105]
["*", ""], // [106]
["+", ""], // [107]
["", ""], // [108]
["-", ""], // [109]
[".", ""], // [110]
["/", ""], // [111]
["", ""], // [112]
["", ""], // [113]
["", ""], // [114]
["", ""], // [115]
["", ""], // [116]
["", ""], // [117]
["", ""], // [118]
["", ""], // [119]
["", ""], // [120]
["", ""], // [121]
["", ""], // [122]
["", ""], // [123]
["", ""], // [124]
["", ""], // [125]
["", ""], // [126]
["", ""], // [127]
["", ""], // [128]
["", ""], // [129]
["", ""], // [130]
["", ""], // [131]
["", ""], // [132]
["", ""], // [133]
["", ""], // [134]
["", ""], // [135]
["", ""], // [136]
["", ""], // [137]
["", ""], // [138]
["", ""], // [139]
["", ""], // [140]
["", ""], // [141]
["", ""], // [142]
["", ""], // [143]
["", ""], // [144]
["", ""], // [145]
["", ""], // [146]
["", ""], // [147]
["", ""], // [148]
["", ""], // [149]
["", ""], // [150]
["", ""], // [151]
["", ""], // [152]
["", ""], // [153]
["", ""], // [154]
["", ""], // [155]
["", ""], // [156]
["", ""], // [157]
["", ""], // [158]
["", ""], // [159]
["", ""], // [160]
["", ""], // [161]
["", ""], // [162]
["", ""], // [163]
["", ""], // [164]
["", ""], // [165]
["", ""], // [166]
["", ""], // [167]
["", ""], // [168]
["", ""], // [169]
["", ""], // [170]
["", ""], // [171]
["", ""], // [172]
["", ""], // [173]
["", ""], // [174]
["", ""], // [175]
["", ""], // [176]
["", ""], // [177]
["", ""], // [178]
["", ""], // [179]
["", ""], // [180]
["", ""], // [181]
["", ""], // [182]
["", ""], // [183]
["", ""], // [184]
["", ""], // [185]
[";", ":"], // [186]
["=", "+"], // [187]
[",", "<"], // [188]
["-", "_"], // [189]
[".", ">"], // [190]
["/", "?"], // [191]
["`", "~"], // [192]
["", ""], // [193]
["", ""], // [194]
["", ""], // [195]
["", ""], // [196]
["", ""], // [197]
["", ""], // [198]
["", ""], // [199]
["", ""], // [200]
["", ""], // [201]
["", ""], // [202]
["", ""], // [203]
["", ""], // [204]
["", ""], // [205]
["", ""], // [206]
["", ""], // [207]
["", ""], // [208]
["", ""], // [209]
["", ""], // [210]
["", ""], // [211]
["", ""], // [212]
["", ""], // [213]
["", ""], // [214]
["", ""], // [215]
["", ""], // [216]
["", ""], // [217]
["", ""], // [218]
["[", "{"], // [219]
["\\", "|"], // [220]
["]", "}"], // [221]
["'", '"'], // [222]
["", ""], // [223]
["", ""], // [224]
["", ""], // [225]
["", ""], // [226]
["", ""], // [227]
["", ""], // [228]
["", ""], // [229]
["", ""], // [230]
["", ""], // [231]
["", ""], // [232]
["", ""], // [233]
["", ""], // [234]
["", ""], // [235]
["", ""], // [236]
["", ""], // [237]
["", ""], // [238]
["", ""], // [239]
["", ""], // [240]
["", ""], // [241]
["", ""], // [242]
["", ""], // [243]
["", ""], // [244]
["", ""], // [245]
["", ""], // [246]
["", ""], // [247]
["", ""], // [248]
["", ""], // [249]
["", ""], // [250]
["", ""], // [251]
["", ""], // [252]
["", ""], // [253]
["", ""], // [254]
["", ""] // [255]
];
export default keyboardCharMap;

View file

@ -0,0 +1,261 @@
// names of known key codes (0-255)
const keyboardNameMap = [
"", // [0]
"", // [1]
"", // [2]
"CANCEL", // [3]
"", // [4]
"", // [5]
"HELP", // [6]
"", // [7]
"BACK_SPACE", // [8]
"TAB", // [9]
"", // [10]
"", // [11]
"CLEAR", // [12]
"ENTER", // [13]
"ENTER_SPECIAL", // [14]
"", // [15]
"SHIFT", // [16]
"CONTROL", // [17]
"ALT", // [18]
"PAUSE", // [19]
"CAPS_LOCK", // [20]
"KANA", // [21]
"EISU", // [22]
"JUNJA", // [23]
"FINAL", // [24]
"HANJA", // [25]
"", // [26]
"ESCAPE", // [27]
"CONVERT", // [28]
"NONCONVERT", // [29]
"ACCEPT", // [30]
"MODECHANGE", // [31]
"SPACE", // [32]
"PAGE_UP", // [33]
"PAGE_DOWN", // [34]
"END", // [35]
"HOME", // [36]
"LEFT", // [37]
"UP", // [38]
"RIGHT", // [39]
"DOWN", // [40]
"SELECT", // [41]
"PRINT", // [42]
"EXECUTE", // [43]
"PRINTSCREEN", // [44]
"INSERT", // [45]
"DELETE", // [46]
"", // [47]
"0", // [48]
"1", // [49]
"2", // [50]
"3", // [51]
"4", // [52]
"5", // [53]
"6", // [54]
"7", // [55]
"8", // [56]
"9", // [57]
"COLON", // [58]
"SEMICOLON", // [59]
"LESS_THAN", // [60]
"EQUALS", // [61]
"GREATER_THAN", // [62]
"QUESTION_MARK", // [63]
"AT", // [64]
"A", // [65]
"B", // [66]
"C", // [67]
"D", // [68]
"E", // [69]
"F", // [70]
"G", // [71]
"H", // [72]
"I", // [73]
"J", // [74]
"K", // [75]
"L", // [76]
"M", // [77]
"N", // [78]
"O", // [79]
"P", // [80]
"Q", // [81]
"R", // [82]
"S", // [83]
"T", // [84]
"U", // [85]
"V", // [86]
"W", // [87]
"X", // [88]
"Y", // [89]
"Z", // [90]
"OS_KEY", // [91] Windows Key (Windows) or Command Key (Mac)
"", // [92]
"CONTEXT_MENU", // [93]
"", // [94]
"SLEEP", // [95]
"NUMPAD0", // [96]
"NUMPAD1", // [97]
"NUMPAD2", // [98]
"NUMPAD3", // [99]
"NUMPAD4", // [100]
"NUMPAD5", // [101]
"NUMPAD6", // [102]
"NUMPAD7", // [103]
"NUMPAD8", // [104]
"NUMPAD9", // [105]
"MULTIPLY", // [106]
"ADD", // [107]
"SEPARATOR", // [108]
"SUBTRACT", // [109]
"DECIMAL", // [110]
"DIVIDE", // [111]
"F1", // [112]
"F2", // [113]
"F3", // [114]
"F4", // [115]
"F5", // [116]
"F6", // [117]
"F7", // [118]
"F8", // [119]
"F9", // [120]
"F10", // [121]
"F11", // [122]
"F12", // [123]
"F13", // [124]
"F14", // [125]
"F15", // [126]
"F16", // [127]
"F17", // [128]
"F18", // [129]
"F19", // [130]
"F20", // [131]
"F21", // [132]
"F22", // [133]
"F23", // [134]
"F24", // [135]
"", // [136]
"", // [137]
"", // [138]
"", // [139]
"", // [140]
"", // [141]
"", // [142]
"", // [143]
"NUM_LOCK", // [144]
"SCROLL_LOCK", // [145]
"WIN_OEM_FJ_JISHO", // [146]
"WIN_OEM_FJ_MASSHOU", // [147]
"WIN_OEM_FJ_TOUROKU", // [148]
"WIN_OEM_FJ_LOYA", // [149]
"WIN_OEM_FJ_ROYA", // [150]
"", // [151]
"", // [152]
"", // [153]
"", // [154]
"", // [155]
"", // [156]
"", // [157]
"", // [158]
"", // [159]
"CIRCUMFLEX", // [160]
"EXCLAMATION", // [161]
"DOUBLE_QUOTE", // [162]
"HASH", // [163]
"DOLLAR", // [164]
"PERCENT", // [165]
"AMPERSAND", // [166]
"UNDERSCORE", // [167]
"OPEN_PAREN", // [168]
"CLOSE_PAREN", // [169]
"ASTERISK", // [170]
"PLUS", // [171]
"PIPE", // [172]
"HYPHEN_MINUS", // [173]
"OPEN_CURLY_BRACKET", // [174]
"CLOSE_CURLY_BRACKET", // [175]
"TILDE", // [176]
"", // [177]
"", // [178]
"", // [179]
"", // [180]
"VOLUME_MUTE", // [181]
"VOLUME_DOWN", // [182]
"VOLUME_UP", // [183]
"", // [184]
"", // [185]
"SEMICOLON", // [186]
"EQUALS", // [187]
"COMMA", // [188]
"MINUS", // [189]
"PERIOD", // [190]
"SLASH", // [191]
"BACK_QUOTE", // [192]
"", // [193]
"", // [194]
"", // [195]
"", // [196]
"", // [197]
"", // [198]
"", // [199]
"", // [200]
"", // [201]
"", // [202]
"", // [203]
"", // [204]
"", // [205]
"", // [206]
"", // [207]
"", // [208]
"", // [209]
"", // [210]
"", // [211]
"", // [212]
"", // [213]
"", // [214]
"", // [215]
"", // [216]
"", // [217]
"", // [218]
"OPEN_BRACKET", // [219]
"BACK_SLASH", // [220]
"CLOSE_BRACKET", // [221]
"QUOTE", // [222]
"", // [223]
"META", // [224]
"ALTGR", // [225]
"", // [226]
"WIN_ICO_HELP", // [227]
"WIN_ICO_00", // [228]
"", // [229]
"WIN_ICO_CLEAR", // [230]
"", // [231]
"", // [232]
"WIN_OEM_RESET", // [233]
"WIN_OEM_JUMP", // [234]
"WIN_OEM_PA1", // [235]
"WIN_OEM_PA2", // [236]
"WIN_OEM_PA3", // [237]
"WIN_OEM_WSCTRL", // [238]
"WIN_OEM_CUSEL", // [239]
"WIN_OEM_ATTN", // [240]
"WIN_OEM_FINISH", // [241]
"WIN_OEM_COPY", // [242]
"WIN_OEM_AUTO", // [243]
"WIN_OEM_ENLW", // [244]
"WIN_OEM_BACKTAB", // [245]
"ATTN", // [246]
"CRSEL", // [247]
"EXSEL", // [248]
"EREOF", // [249]
"PLAY", // [250]
"ZOOM", // [251]
"", // [252]
"PA1", // [253]
"WIN_OEM_CLEAR", // [254]
"" // [255]
];
export default keyboardNameMap;

View file

@ -0,0 +1,49 @@
<template>
<input
id="textInput"
type="text"
/>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "TextInput",
})
</script>
<style lang="scss" scoped>
#textInput {
position: absolute;
opacity: 0;
min-width: 150px;
height: 16px;
border-radius: 18px;
padding: 10px;
margin-right: 10px;
margin-left: 10px;
outline: none;
border: none;
pointer-events: none;
background-color: white;
z-index: -1;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.15);
transform: translateX(50px) scale(0.3);
}
</style>

View file

@ -1,20 +1,70 @@
import anime from "animejs";
import useMitt from "@/modules/mitt";
import { useIpc } from '@/modules/ipc';
import { Ref, ref, watch, onMounted, onUnmounted } from "vue"; import { Ref, ref, watch, onMounted, onUnmounted } from "vue";
import { postMessage } from "@/render/ipc"; import keyboardNameMap from "./keyBoardMaps/keyboardNameMap";
import { newMessage, animateTextInput } from "./helpers"; import { clientMessage, renderMessage } from '@/modules/message';
//---Animations-----------------------------------------------
let side = "right"; // Side of parent we are on.
function showTextInput () {
const t1 = (side === "right") ? 50 : -70;
const t2 = (side === "right") ? 110 : -130;
anime({
targets: '#textInput',
opacity: [0, 1],
translateX: [t1, t2],
scale: [0.3, 1],
duration: 500,
easing: 'easeOutExpo',
})
}
function hideTextInput() {
const t = (side === "right") ? 50 : -80;
anime({
targets: '#textInput',
opacity: [1, 0],
translateX: t,
scale: 0.3,
duration: 500,
easing: 'easeOutExpo',
})
}
function switchSide(currentSide: string) {
const t = (currentSide === "right") ? -130 : 110;
anime({
targets: '#textInput',
translateX: t,
duration: 500,
easing: 'easeOutExpo',
})
}
//------------------------------------------------------------
export default function useTextInputController(elementX: Ref) { export default function useTextInputController(elementX: Ref) {
let textInput: HTMLInputElement | null; let textInput: HTMLInputElement | null;
const { side, show, hide, switchSide } = animateTextInput(); const { post } = useIpc();
const { emitter } = useMitt();
let firstKey = true; let firstKey = true;
const typing = ref(false); const typing = ref(false);
// Prep inputItem for typing. // Prep inputItem for typing.
const prepInput = () => { const prepInput = () => {
show() showTextInput()
typing.value = true typing.value = true
} }
@ -25,8 +75,8 @@ export default function useTextInputController(elementX: Ref) {
textInput.value = ""; textInput.value = "";
textInput.blur(); textInput.blur();
} }
hide() hideTextInput()
firstKey = true; firstKey = true;
typing.value = false; typing.value = false;
} }
@ -35,20 +85,28 @@ export default function useTextInputController(elementX: Ref) {
const sendMessage = () => { const sendMessage = () => {
if (textInput) { if (textInput) {
// Send it to the backend for processing. // Create the message.
const message = newMessage({ const message = renderMessage(
text: false textInput.value,
}); false,
"",
"sf"
)
// postMessage(message); emitter.emit("self-message", message);
// Send it to the backend for processing.
const clientM = clientMessage(textInput.value, null, message.uid);
post('client-message', clientM);
clearInput() clearInput()
} }
} }
// Keys that are capable of opening the text input (numbers and letters). // Keys that are capable of opening the text input (numbers and letters).
const isHotKey = (key: number) => { const hotKeyRange = keyboardNameMap.slice(47, 91)
if (key >= 47 && key <= 91) { // a letter const isHotKey = (key: string) => {
if (hotKeyRange.includes(key)) {
return true return true
} }
} }
@ -56,10 +114,10 @@ export default function useTextInputController(elementX: Ref) {
//---Callbacks----------------------------------------------- //---Callbacks-----------------------------------------------
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
const key = e.keyCode; const key = keyboardNameMap[e.keyCode]
if (textInput) { if (textInput) {
// Only runs on firstKey. // Only runs on firstKey.
if (firstKey) { if (firstKey) {
@ -73,18 +131,18 @@ export default function useTextInputController(elementX: Ref) {
textInput.focus(); textInput.focus();
// Close input when no text or on ESC. // Close input when no text or on ESC.
if ((textInput.value == "") && (!firstKey) && (key === 8)) { // backspace if ((textInput.value == "") && (!firstKey) && (key === "BACK_SPACE")) {
clearInput() clearInput()
return return
} }
if (key === 27) { // escape if (key === "ESCAPE") {
clearInput() clearInput()
return return
} }
// Close and send on enter. // Close and send on enter.
if (key === 13) { if (key === "ENTER") {
if (textInput.value) { if (textInput.value) {
sendMessage() sendMessage()
return return
@ -102,20 +160,20 @@ export default function useTextInputController(elementX: Ref) {
const winW = window.innerWidth const winW = window.innerWidth
// Logic depends on the side we are on. // Logic depends on the side we are on.
if (side.value === "right") { if (side === "right") {
if (winW - elementX < 230) { if (winW - elementX < 230) {
switchSide() switchSide(side)
side.value = "left" side = "left"
} }
} }
else { else {
if (winW - elementX > 230) { if (winW - elementX > 230) {
switchSide() switchSide(side)
side.value = "right" side = "right"
} }
} }
}); });
onMounted(() => { onMounted(() => {

View file

@ -25,7 +25,7 @@
<!-- submit button; position: fixed --> <!-- submit button; position: fixed -->
<button class="submitButton button" type="submit">Submit</button> <button class="submitButton button" type="submit">Submit</button>
</form> </form>
<!-- back to login button: position: fixed --> <!-- back to login button: position: fixed -->
@ -35,34 +35,23 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from "vue"; import { defineComponent, ref } from "vue";
import { setProfile } from '@/render/composables/useProfile'; import { useIpc } from "@/modules/ipc";
import { invokeLogin } from "@/render/ipc"; import { authRequest } from '@/modules/message';
export default defineComponent({ export default defineComponent({
name: "Login", name: "Login",
setup() { setup() {
const { post } = useIpc();
const usr = ref(""); const usr = ref("");
const pwd = ref(""); const pwd = ref("");
// Submit login credentials to the backend. // Submit login credentials to the backend.
const submitForm = async () => { const submitForm = () => {
console.log(`Submitting login form: ${usr.value}, ${pwd.value}`)
try { post("client-message", authRequest(false, usr.value, pwd.value))
const profile = await invokeLogin({
email: usr.value,
password: pwd.value
}) as Profile;
setProfile(profile)
} catch(e) {
console.log(e);
}
} }
return { return {

419
src/components/message.vue Normal file
View file

@ -0,0 +1,419 @@
<template>
<div
class="message"
:class="`${message.isChild}ChildMessage`"
:id="`${message.modifier}Message`"
:key="message.id"
>
<!-- Three types of messages: self (sf), friend (fr), and crimata (ai) -->
<!-- !denoted by the modifier attribute -->
<!-- Shows when a new session is created with Crimata. -->
<div
v-if="message.context === 'Crimata'"
class="divider"
>
<div>new session</div>
</div>
<!-- Includes bubble and context. -->
<div
:id="`${message.modifier}MessageBox`"
class="messageBox"
>
<!-- message bubble -->
<div
class="bubble"
:class="`${message.modifier}-${message.isChild}ChildBubble`"
:id="`${message.modifier}Bubble`"
>
<!-- Notification dot for new messages. -->
<div v-if="notify === true"
class="notify"
/>
<!-- Show loader when audio is being transcribed. -->
<div
v-if="message.content.text === ''"
class="contentLoader"
>
<div class="contentLoaderDot"></div>
<div class="contentLoaderDot"></div>
<div class="contentLoaderDot"></div>
</div>
<!-- Show question mark if transcription comes back as unknown. -->
<div
v-else-if="message.content.text == false"
class="questionMark"
>
?
</div>
<!-- Else, show text. -->
<div v-else>
{{ message.content.text }}
</div>
</div>
<!-- message context -->
<span
v-if="message.isChild === 'last' || message.isChild === 'none'"
class="context"
>
<!-- Render Crimata icon or friend's initials. -->
<div v-if="message.modifier == 'ai'" class="photo">
<img src="@/assets/crimata.svg"/>
</div>
<!-- Render Crimata icon or friend's initials. -->
<div v-if="message.modifier == 'fr'" class="photo"
:class="{ playing: isPlaying }"
>
{{ message.context[0] }}
</div>
{{ message.context }}
</span>
</div>
</div>
</template>
<script lang='ts'>
import { defineComponent, ref, onMounted } from 'vue';
export default defineComponent({
name: "MessageItem",
props: ["message"],
setup(props) {
let addListener = false;
const notify = ref(false)
const isPlaying = ref(false);
if (!props.message.seen) {
console.log("MSG:Checking...")
// newMessages always render with blue dot.
if (props.message.newMessage) {
console.log("MSG:New message, notifying.")
props.message.seen = true;
notify.value = true;
}
else {
// Do nothing if window is visible on setup.
if (document.visibilityState === "visible") {
props.message.seen = true;
}
// We must listen for window visible event.
else {
console.log("MSG:Unseen, adding listener.")
addListener = true;
}
}
}
// Callback for window visibilityState.
const onVisibilityChange = (_event: any) => {
if (document.visibilityState === "visible") {
console.log("MSG:Window visible, notifying.")
props.message.seen = true;
notify.value = true; // triggers blue dot anim.
// Once complete we can remove.
document.removeEventListener("visibilitychange", onVisibilityChange)
}
}
const onStopPlayback = (_event: any) => {
window.ipcRenderer.removeAllListeners("stop-playback-anim");
isPlaying.value = false
}
onMounted(() => {
// Listen for window focus event.
if (addListener) {
document.addEventListener("visibilitychange", onVisibilityChange)
}
// Turn on audio playback animation if audio.
if (props.message.content.audio === true) {
window.ipcRenderer.on("stop-playback-anim", onStopPlayback);
isPlaying.value = true
}
})
return {
notify,
isPlaying
}
}
})
</script>
<style lang="scss" scoped>
.message {
width: 100vw;
display: flex;
flex-direction: column;
padding-top: 9px;
padding-bottom: 9px;
}
.message:first-child {
margin-top: 55px;
}
.message:last-child {
margin-bottom: 18px;
}
.divider {
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
font-family: "SF Compact Display";
font-size: 12px;
font-weight: bold;
color: #9B9B9B;
margin-bottom: 18px;
}
.firstChildMessage {
padding-bottom: 2px;
}
.middleChildMessage {
padding-top: 2px;
padding-bottom: 2px;
}
.lastChildMessage {
padding-top: 2px;
}
#aiMessage {
align-items: flex-start;
}
#sfMessage {
align-items: flex-end;
}
#frMessage {
align-items: flex-start;
}
.messageBox {
position: relative;
display: flex;
flex-direction: column;
// Animate on render.
animation-name: appear;
animation-duration: 0.25s;
}
#aiMessageBox {
margin-left: 20px;
align-items: flex-start;
}
#sfMessageBox {
margin-right: 20px;
align-items: flex-end;
}
#frMessageBox {
margin-left: 20px;
align-items: flex-start;
}
.bubble {
position: relative;
max-width: 66vw;
display: flex;
flex-direction: column;
font-family: "SF Pro Text";
font-size: 14px;
padding: 10px;
border-radius: 18px;
}
@keyframes appear {
10% {
transform: scale(0.3);
}
100% {
transform: scale(1);
}
}
#aiBubble {
background-color: white;
}
#sfBubble {
background-color: #58c4fd;
color: white;
}
#frBubble {
background-color: white;
}
.sf-firstChildBubble {
border-bottom-right-radius: 9px;
}
.sf-middleChildBubble {
border-top-right-radius: 9px;
border-bottom-right-radius: 9px;
}
.sf-lastChildBubble {
border-top-right-radius: 9px;
}
.ai-firstChildBubble, .fr-firstChildBubble {
border-bottom-left-radius: 9px;
}
.ai-middleChildBubble, .fr-middleChildBubble {
border-top-left-radius: 9px;
border-bottom-left-radius: 9px;
}
.ai-lastChildBubble, .fr-lastChildBubble {
border-top-left-radius: 9px;
}
.notify {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #58D9FF;
top: -5px;
left: -5px;
border: 2px solid #EBEBEB;
transform: scale(0);
animation-name: notify-anim;
animation-duration: 5s;
}
@keyframes notify-anim {
0%, 90% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
.context {
position: relative;
display: flex;
align-items: center;
font-family: "SF Compact Display";
font-size: 12px;
font-weight: bold;
margin-top: 5px;
}
.photo {
display: flex;
justify-content: center;
align-items: center;
margin-right: 5px;
width: 30px;
height: 30px;
font-size: 14px;
background-color: white;
border-radius: 15px;
}
// Apply for audio message playback.
.playing {
animation-name: circle1;
animation-duration: 2s;
animation-iteration-count: infinite;
}
@keyframes circle1 {
0%,
100% {
box-shadow: 0 0 0 0.4em rgba(195, 195, 195, 0.4), 0 0 0 0.25em rgba(195, 195, 195, 0.15);
}
25% {
box-shadow: 0 0 0 0.15em rgba(195, 195, 195, 0.15), 0 0 0 0.4em rgba(195, 195, 195, 0.3);
}
50% {
box-shadow: 0 0 0 0.55em rgba(195, 195, 195, 0.55), 0 0 0 0.15em rgba(195, 195, 195, 0.05);
}
75% {
box-shadow: 0 0 0 0.25em rgba(195, 195, 195, 0.25), 0 0 0 0.55em rgba(195, 195, 195, 0.45);
}
}
.contentLoader {
display: flex;
}
.contentLoaderDot {
width: 5px;
height: 5px;
margin: 2px;
border-radius: 2.5px;
background-color: #357CA2;
}
.questionMark {
font-weight: 900;
color: #357CA2;
}
</style>

View file

@ -0,0 +1,115 @@
<template>
<InputItem :initials="profile.initials"/>
<Settings />
<div id="recIcon" />
<!-- List of message bubbles. -->
<div id="messenger">
<Message
v-for="message in messages"
:message="message[1]"
:key="message[0]"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted } from "vue";
import Message from "@/components/message.vue";
import InputItem from "@/components/inputitem/inputitem.vue";
import Settings from "@/components/settings.vue";
import useMitt from "@/modules/mitt";
import useMessages from "@/modules/messages";
export default defineComponent({
name: "Messenger",
props: ["profile", "newMessages"],
components: {
Message,
InputItem,
Settings
},
setup(props) {
// Listen for self-messages from inputItem.
const { emitter } = useMitt();
// Handle messages in view.
const { messages, prepMessageView, updateMessageView } = useMessages();
onMounted(() => {
const crimataId = props.profile.crimataId;
console.log(`MSGR:Initializing messenger for ${crimataId}.`)
// Load the message history.
if (crimataId === window.localStorage.getItem("last_usr")) {
prepMessageView(props.newMessages)
} else {
messages.value.clear()
}
// New content listeners.
emitter.on('self-message', (message) => updateMessageView(message));
window.ipcRenderer.on("render-message", (_e: any, payload: any) => {
updateMessageView(payload.message)
});
// Save the usr for next time.
window.localStorage.setItem("last_usr", crimataId)
});
onUnmounted(() => {
window.ipcRenderer.removeAllListeners("render-message");
window.ipcRenderer.removeAllListeners("annotate-message");
emitter.all.clear();
});
return {
messages
};
},
});
</script>
<style lang="scss" scoped>
#messenger {
width: 100vw;
height: 100vh;
overflow: auto;
}
#messenger::-webkit-scrollbar {
display: none;
}
#recIcon {
position: fixed;
right: 0;
opacity: 0;
transform: scale(0.0);
margin-top: 24px;
margin-right: 66px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #FF3B3B;
z-index: 2;
}
</style>

View file

@ -28,8 +28,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from "vue"; import { defineComponent, ref } from "vue";
import { clearProfile } from "@/render/composables/useProfile" import { useIpc } from "@/modules/ipc";
import { invokeLogout } from "@/render/ipc"; import { logoutRequest } from '@/modules/message';
export default defineComponent({ export default defineComponent({
name: "Settings", name: "Settings",
@ -37,6 +37,8 @@
setup() { setup() {
const toggleSettings = ref(false); const toggleSettings = ref(false);
const { post } = useIpc();
// Listen for escape key to close settings. // Listen for escape key to close settings.
const onEscape = (e: any) => { const onEscape = (e: any) => {
if(e.key === "Escape") { if(e.key === "Escape") {
@ -52,17 +54,9 @@
} }
// We ask server to log us out. // We ask server to log us out.
const onLogout = async () => { const onLogout = () => {
console.log("Submitting logout request.")
console.log("Submitting logout request."); post("client-message", logoutRequest())
try {
await invokeLogout();
clearProfile();
} catch(e) {
console.log('error')
}
} }
return { return {

View file

@ -1,31 +0,0 @@
const { autoUpdater } = require('electron-updater');
let win: boolean;
// Listen for window creation.
backgroundMitt.on('window-active', (state: boolean) => {
win = state;
});
// Auto updating.
autoUpdater.requestHeaders = { 'PRIVATE-TOKEN': 'mvvgWYwWnot4bisiQMh_' }
autoUpdater.on('update-available', (info: any) => {
console.log(`Update available: ${info.version}`)
})
autoUpdater.on('update-downloaded', (info: any) => {
const updateDialog = {
type: 'info',
buttons: ['Restart', 'Later'],
title: 'Application Update',
message: info.version,
detail: 'A new version has been downloaded. Restart the application to apply the updates.'
}
dialog.showMessageBox(updateDialog).then((returnValue) => {
if (returnValue.response === 0) autoUpdater.quitAndInstall()
})
})

View file

@ -1,51 +0,0 @@
import axios, { AxiosRequestConfig } from 'axios';
import {config} from "@/config";
const baseURL = config.BUSINESS_URL + config.BUSINESS_PREFIX;
interface Request {
endpoint: string;
query?: Record<string, any>;
config?: Record<string, any>;
}
const makeQuery = (reqQuery: Record<string, any>) => {
let result = '';
result = '?' + Object.entries(reqQuery)
.map(([ key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
return result;
};
export default function useHttp() {
const api = axios.create({
baseURL,
withCredentials: true,
});
const post = async (endpoint: string, payload?: Record<string, any>): Promise<any> => (
await api.post(endpoint, payload)
)
const get = async (req: Request) => {
if (req.query) {
req.endpoint += makeQuery(req.query);
}
const res = await api.get(req.endpoint, req.config);
return res;
};
return {
get, post
}
}

View file

@ -1,82 +0,0 @@
import { ipcMain, IpcMainInvokeEvent, IpcMainEvent } from "electron";
export class IpcHandler<InputParam, ReturnType> implements IIpcHandler<InputParam, ReturnType> {
readonly channel: string;
readonly _handlerCallback: IpcHandlerCallback<InputParam, ReturnType>;
constructor(options: {
channel: string;
handlerCallback: IpcHandlerCallback<InputParam, ReturnType>;
}) {
this.channel = options.channel;
this._handlerCallback = options.handlerCallback;
}
handle() {
this.remove();
ipcMain.handle(this.channel, this._onInvoke);
}
remove() {
ipcMain.removeHandler(this.channel);
}
private _onInvoke = (_e: IpcMainInvokeEvent, payload?: string | null): Promise<Error | ReturnType> => {
return new Promise(async (resolve, reject) => {
console.log(`[IPC] Handle:${this.channel}`);
try {
const params = payload ? JSON.parse(payload) : null;
const res = await this._handlerCallback(params);
resolve(res as unknown as ReturnType);
} catch(e) {
console.log(`[IPC] Error:${this.channel}`);
reject(e);
}
});
}
}
export class IpcListener<InputParam> implements IIpcListener<InputParam> {
readonly channel: string;
readonly _listenerCallback: IpcListenerCallback<InputParam>;
constructor(options: {
channel: string;
listenerCallback: IpcListenerCallback<InputParam>;
}) {
this.channel = options.channel;
this._listenerCallback = options.listenerCallback;
}
listen() {
this.remove();
ipcMain.on(this.channel, this._onPost);
}
remove() {
ipcMain.removeAllListeners(this.channel);
}
private _onPost = (_e: IpcMainEvent, payload?: string | null): void => {
console.log(`[IPC] Post: ${this.channel}`);
const params = payload ? JSON.parse(payload) : null;
this._listenerCallback(params);
}
}

View file

@ -1,36 +0,0 @@
export default class Canvas {
messages: Message[];
/* seed canvas with messages on init */
constructor(messages: Message[]) {
this.messages = messages;
ipcEmit("seed-view", this.messages);
}
/* add a new message to the canvas */
add(message: Message) {
this.messages.push(message);
ipcEmit("update-view", message);
}
/* update an existing message */
update(message: Message) {
/* get the target message */
let target_message = this.messages.filter((m: Message) => {
return m.uid = message.uid;
})[0];
/* replace the target message */
if (target_message) {
target_message = message;
ipcEmit("update-view", message);
}
}
}

View file

@ -1,13 +0,0 @@
import {config} from "@/config";
import fs from 'fs';
export const saveToJson = (fileName: string, data: any) => {
fs.writeFile(config.configPath + fileName, JSON.stringify(data), (err) => {
if (err) {
console.log("Error when saving to json.")
}
})
}

View file

@ -1,89 +0,0 @@
"use strict";
import WebSocket from 'ws';
const _connectionCheckTimeout = 4000;
const _reconnectTimeout = 1000;
let _connectionCheckInterval: ReturnType<typeof setTimeout>;
export default function useWebSockets(
messageCallback: (message: string) => void,
connectionStatusCallback: (alive: boolean) => void,
) {
let socket: WebSocket;
const send = async (data: Record<string, any>): Promise<boolean> => {
return new Promise((resolve, reject) => {
if (socket) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(data));
resolve(true);
}
}
reject(false);
});
}
const connect = (socketUrl: string, secret: string) => {
// avoid setting multiple interval;
if (_connectionCheckInterval) clearInterval(_connectionCheckInterval);
/* create a new socket */
socket = new WebSocket(socketUrl);
/* add event listeners */
socket.on("open", () => {
socket.send(JSON.stringify({key: secret}));
// ping server
_connectionCheckInterval = setInterval(() => {
socket.ping(null, true, (e: Error) => {
if (e) {
socket.close();
connectionStatusCallback(false);
setTimeout(() => connect(socketUrl, secret), _reconnectTimeout);
}
});
}, _connectionCheckTimeout);
});
socket.on("message", (event: WebSocket.MessageEvent) => {
console.log("message received", event);
messageCallback(event.toString())
});
socket.on("close", (event: WebSocket.CloseEvent) => {
connectionStatusCallback(false);
clearInterval(_connectionCheckInterval);
if (!event.wasClean) {
setTimeout(() => connect(socketUrl, secret), _reconnectTimeout);
}
});
socket.on("pong", () => connectionStatusCallback(true));
}
const close = () => {
if (socket) {
socket.close();
}
}
return {
connect,
send,
close
};
}

View file

@ -1,17 +0,0 @@
import { app } from "electron";
const env = process.env;
const PLATFORM_PORT = env.PLATFORM_PORT || 8760;
const PLATFORM_IP = env.PLATFORM_IP || 'http://127.0.0.1';
const BUSINESS_PORT = env.BUSINESS_PORT || 3000;
const BUSINESS_IP = env.BUSINESS_IP || 'http://127.0.0.1';
export const config = {
PLATFORM_URL: `${PLATFORM_IP}:${PLATFORM_PORT}`,
BUSINESS_URL: `${BUSINESS_IP}:${BUSINESS_PORT}`,
BUSINESS_PREFIX: '/api',
configPath: app.getPath('userData')
}

View file

@ -1,52 +0,0 @@
/**
* Entry point for Crimata electron app.
* "Look on my Works, ye Mighty, and despair!"
*/
"use strict";
import { app, protocol } from "electron";
import createWindow from "./window";
import main from "./main";
import { backgroundMitt } from '@/composables/useEmitter';
console.log('Starting Crimata electron app.');
// Scheme must be registered before the app is ready
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { secure: true, standard: true } }
]);
const isDev = require('electron-is-dev');
let win: boolean;
// Listen for window creation.
backgroundMitt.on('window-active', (state: boolean) => {
win = state;
});
/* Start main process on ready */
app.on("ready", async () => {
await main();
});
// Must keep to ensure app doesn't quit on close.
app.on("before-quit", async () => {
});
// Must keep to ensure app doesn't quit on close.
app.on("window-all-closed", () => {
});
// When user clicks app icon (re-open)
app.on("activate", () => {
if (!win) createWindow();
});
// Exit cleanly on request from parent process in development mode.
if (isDev) {
process.on("SIGTERM", () => {
app.quit();
});
}

View file

@ -1,28 +0,0 @@
"use strict";
import { accountLogin, accountLogout, accountProfile } from "@/account";
import { IpcHandler } from "@/composables/useIpcMain";
import { flush } from "@/audio";
const LOGIN_CHANNEL = "invoke-account-login";
const LOGOUT_CHANNEL = "invoke-account-logout";
const GET_AUDIO_CHANNEL = "invoke-audio-flush";
export const loginHandler = new IpcHandler({
channel: LOGIN_CHANNEL,
handlerCallback: accountLogin
});
export const logoutHandler = new IpcHandler({
channel: LOGOUT_CHANNEL,
handlerCallback: accountLogout
});
export const getAudioHandler = new IpcHandler({
channel: GET_AUDIO_CHANNEL,
handlerCallback: flush
});

View file

@ -1,33 +0,0 @@
"use strict";
import * as handlers from "./handlers";
import * as listeners from "./listeners";
const ipcHandlers: IPCHandlers = {};
const ipcListeners: IPCListeners = {};
const _initHandlers = (): void => {
for (const [key, handler] of Object.entries(handlers)) {
if (!(key in ipcHandlers)) {
ipcHandlers[key] = handler;
handler.handle();
}
}
};
const _initListeners = (): void => {
for (const [key, listener] of Object.entries(listeners)) {
if (!(key in ipcListeners)) {
ipcListeners[key] = listener;
listener.listen();
}
}
};
export default function initIpcMain(): void {
_initHandlers();
_initListeners();
}

View file

@ -1,24 +0,0 @@
import { IpcListener } from "@/composables/useIpcMain"
import { sendMessage } from '@/session';
import { collect } from "@/audio";
import { updateAppState } from "@/account";
const CLIENT_MESSAGE_CHANNEL = "post-session-send"
const GET_AUDIO_CHANNEL = "post-audio-collect";
const APP_MOUNT_CHANNEL = "post-app-mount";
export const messageListener = new IpcListener<Message>({
channel: CLIENT_MESSAGE_CHANNEL,
listenerCallback: sendMessage
});
export const audioChunkListener = new IpcListener<ArrayBuffer>({
channel: GET_AUDIO_CHANNEL,
listenerCallback: collect
});
export const appMountListener = new IpcListener<null>({
channel: APP_MOUNT_CHANNEL,
listenerCallback: updateAppState
});

View file

@ -1,39 +1,15 @@
/** // src/main.ts
* Where the background logic really begins, gets called by app.onReady().
*
* Handles authentication. If profile is set, we launch a session, which consis
* of opening a connection with the platform, initializing the audio streams.
*
* The session is primarily an interface between the frontend and the platform,
* relaying messages from one to the other.
*
*/
import initIpcMain from "@/ipc/index"; import App from "./App.vue";
import { accountAuth, updateAppState } from "./account";
import { launchSession } from "./session";
import createWindow from "./window";
let authState: AuthState | null; import mitt from "mitt";
import { createApp } from "vue";
export default async function main() {
/* initiate controls for frontend to use when needed */ // Handle events.
initIpcMain(); const emitter = mitt();
/* launch browser window */ const app = createApp(App)
await createWindow();
try { app.provide("mitt", emitter)
authState = await accountAuth() as AuthState; app.mount("#app");
} catch(e) {
console.log('AUTH:', e);
authState = null;
} finally {
if (authState) {
launchSession(authState.token as string);
}
updateAppState();
}
}

View file

@ -1,16 +1,13 @@
/* eslint-disable */
import { Emitter } from "mitt";
// Backend emitter // Backend emitter
//
type Mitt = Emitter;
const EventEmitter = require('events'); const EventEmitter = require('events');
class BackgroundMitt extends EventEmitter { } class BackgroundMitt extends EventEmitter { }
export const backgroundMitt = new BackgroundMitt(); export const backgroundMitt = new BackgroundMitt();
export const ipcEmit = <T>(channel: string, payload: T) => {
backgroundMitt.emit('ipc-renderer', {
channel,
payload
});
};

21
src/modules/ipc.ts Normal file
View file

@ -0,0 +1,21 @@
export const useIpc = () => {
const invoke = async (endpoint: string, payload: any) => {
try {
const res = await window.ipcRenderer.invoke(endpoint, payload);
return res;
} catch (e) {
throw e;
}
}
const post = (endpoint: string, payload: any) => {
window.ipcRenderer.send(endpoint, payload);
};
return {
invoke,
post
}
}

62
src/modules/message.ts Normal file
View file

@ -0,0 +1,62 @@
import {
RenderMessage,
ClientMessage,
ClientRequest,
AuthRequest,
LogoutRequest
} from "@/types";
import { v4 as uuidv4 } from 'uuid';
function getTimeStamp(): number {
const currentdate = new Date();
return currentdate.getTime();
}
// Create a RenderMessage object.
export const renderMessage = (text: boolean | string, audio: boolean | string, context: string, modifier: string): RenderMessage => (
{
content: {
text: text,
audio: audio
},
context: context,
modifier: modifier,
time: getTimeStamp(),
uid: uuidv4(),
isChild: "none",
seen: false,
newMessage: false
}
)
export const clientMessage = (text: string, audio: string | null, uid: string): ClientMessage => (
{
audio,
text,
uid
}
)
export const clientRequest = (intent: string, params: object, epic: string | boolean): ClientRequest => (
{
intent: intent,
params: params,
epic: epic,
confidence: 1.0
}
)
export const authRequest = (key: boolean | string, usr: boolean | string, pwd: boolean | string): AuthRequest => (
{
key,
usr,
pwd
}
)
export const logoutRequest = (): LogoutRequest => (
{
logout: true
}
)

125
src/modules/messages.ts Normal file
View file

@ -0,0 +1,125 @@
import { ref } from 'vue';
import useScroll from "@/modules/scroll";
import { RenderMessage, Annotation } from "@/types";
const messages = ref(new Map());
const addMessage = (message: RenderMessage) => {
messages.value.set(message.uid, message)
}
const updateMessage = (annotation: Annotation) => {
const message = messages.value.get(annotation.uid)
message.context = annotation.context
message.content.text = annotation.text
}
const loadSavedMessages = () => {
const rawData = window.localStorage.getItem("crimata_messages");
if (rawData) {
const messageData = JSON.parse(rawData)
messages.value = new Map(Object.entries(messageData));
}
}
const saveMessages = () => {
const messageData = Object.fromEntries(messages.value);
window.localStorage.setItem("crimata_messages", JSON.stringify(messageData));
}
const isSimmilar = (messageA: RenderMessage, messageB: RenderMessage) => {
if ((Math.abs(messageA.time - messageB.time) < 20000) && (messageA.modifier == messageB.modifier) && (messageA.context == messageB.context)) {
return true
}
return false
}
const updateGrouping = () => {
console.log("updating grouping")
const refs = Array.from(messages.value.keys())
// Get the last three messages.
const first = messages.value.get(refs[refs.length - 1])
const second = messages.value.get(refs[refs.length - 2])
const third = messages.value.get(refs[refs.length - 3])
// If messages are simmilar, update the classes.
if ((first) && (second)) {
if (isSimmilar(first, second)) {
first.isChild = "last" // i.e. last in group.
second.isChild = "first"
if (third) {
if ((third.isChild == "first") || (third.isChild == "middle")) {
second.isChild = "middle"
}
}
}
}
}
export default function useMessages() {
// Scroll controller.
const { setScroll, updateScrollRef, adjustScroll } = useScroll("messenger");
// Main function for updating the message view.
const updateMessageView = (message: RenderMessage | Annotation) => {
// Step 1: See if user is scrolled down.
updateScrollRef()
// Step 2: Add the new content to the view.
if ("content" in message) {
addMessage(message)
} else {
updateMessage(message)
}
// Step 3: Pop off oldest message (if > 200).
if (messages.value.size >= 200) {
const oldest = Array.from(messages.value.keys()).shift();
messages.value.delete(oldest);
}
// Step 4: Update grouping.
updateGrouping()
// Setp 5: Scroll the view (if scrolled down).
setTimeout(adjustScroll, 20);
// Step 6: Save the view data.
saveMessages()
}
// Seed message view with message history.
const prepMessageView = (newMessages: RenderMessage[]) => {
console.log("MSGR:Prepping messenger view.")
// Load and render saved messages and immediately scroll to bottom.
loadSavedMessages()
setTimeout(setScroll.bind(false), 10);
// Render new messages, then wait 1s to scroll.
if (newMessages.length) {
console.log("MSGR:Adding new messages")
newMessages.forEach(message => {
message.newMessage = true;
addMessage(message)
})
setTimeout(setScroll.bind(true), 1000);
}
}
return {
messages,
prepMessageView,
updateMessageView
}
}

19
src/modules/mitt.ts Normal file
View file

@ -0,0 +1,19 @@
import { inject } from "vue";
import { Emitter } from "mitt";
// Frontend emitter
type Mitt = Emitter;
let emitter: Mitt;
export default function useMitt() {
const emitterInject: Mitt | undefined = inject("mitt");
if (emitterInject) {
emitter = emitterInject;
}
return {
emitter
}
}

50
src/modules/scroll.ts Normal file
View file

@ -0,0 +1,50 @@
export default function useScroll(element: string) {
let isScrolledToBottom: boolean;
// Set the initial scroll position.
const setScroll = (smooth: boolean) => {
const view = document.getElementById(element)
if (view) {
view.scrollTo({
top: view.scrollHeight - view.clientHeight,
behavior: (smooth) ? 'smooth' : 'auto'
});
}
}
// Update isScrolledToBottom
const updateScrollRef = () => {
const view = document.getElementById(element)
if (view) {
isScrolledToBottom = view.scrollHeight - view.clientHeight <= view.scrollTop + 1
}
}
// Adjust scroll after we add content to the messenger.
const adjustScroll = () => {
const view = document.getElementById(element)
if (view) {
if (isScrolledToBottom) {
view.scrollTo({
top: view.scrollHeight - view.clientHeight,
behavior: 'smooth'
});
}
}
}
return {
updateScrollRef,
adjustScroll,
setScroll,
}
}

73
src/modules/websockets.ts Normal file
View file

@ -0,0 +1,73 @@
"use strict";
import WebSocket from 'ws';
import { ipcMain } from "electron";
let socket: WebSocket;
// Run every time we want to connect to backend.
export default function useWebSockets(receiveCallback: (s: string) => any, openCallback: () => any) {
// 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 onOpen = (event: WebSocket.OpenEvent) => {
console.log("WS:Connected to WS Server!");
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.")
}
// Reconnect automatically on error.
const onError = (event: WebSocket.ErrorEvent) => {
console.log("WS:WebSocket error: ", event.message);
console.log("Attempting reconnect in 1s.")
setTimeout(createSocket, 1000)
}
const createSocket = (url: string) => {
socket = new WebSocket(url)
// Add listeners.
socket.addEventListener("open", onOpen)
socket.addEventListener("message", onServerMessage)
socket.addEventListener("close", onClose)
socket.addEventListener("error", onError)
console.log("WS:New socket created.")
}
return {
createSocket,
sendMessage
}
}

View file

@ -1,76 +0,0 @@
<template>
<!-- Only render when profile has been set -->
<main id="app" v-if="authComplete">
<Header />
<Messenger
v-if="profile"
/>
<!-- Main Components
-->
<Login v-else />
</main>
<!-- Show spash screen if ready is false -->
<Splash v-else />
</template>
<script lang="ts">
import { defineComponent, onMounted } from "vue";
import { IpcRendererEvent } from "electron";
import Splash from "@/render/components/splash.vue";
import Messenger from "@/render/components/messenger.vue";
import Login from "@/render/components/login.vue";
import Header from "@/render/components/header.vue";
import { profile, authComplete } from "@/render/composables/useProfile";
import { postAppMount } from "@/render/ipc";
export default defineComponent({
components: {
Splash,
Messenger,
Login,
Header
},
setup() {
onMounted(() => postAppMount());
return {
authComplete,
profile
}
}
})
</script>
<style lang="scss">
html, body {
margin: 0;
padding: 0;
}
#app {
font-family: "SF Pro Text";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
width: 100vw;
border-radius: 15px;
}
</style>

View file

@ -1,331 +0,0 @@
<template>
<div :class="`${type}-message`">
<!-- message bubble -->
<div :class="`${type}-${child}-bubble`">
<div class="notification-dot"/>
<div class="error-dot"/>
{{ text }}
</div>
<!-- message context -->
<span class="context">
<div :class="`${type}-icon`"/>
{{ context }}
</span>
</div>
</template>
<script lang='ts'>
import { defineComponent, ref, onMounted } from 'vue';
export default defineComponent({
name: "Bubble",
props: ["text", "context", "type", "position"],
setup() {
const seen = ref(false);
/* initialize seen state */
if (document.visibilityState === "visible") {
seen.value = true;
} else seen.value = false;
onMounted(() => {
if(seen.value)
document.addEventListener("visibilitychange", () => {
seen.value = true
});
});
return {
seen
};
}
})
</script>
<style lang="scss" scoped>
.message {
width: 100vw;
display: flex;
flex-direction: column;
padding-top: 9px;
padding-bottom: 9px;
}
.message:first-child {
margin-top: 55px;
}
.message:last-child {
margin-bottom: 18px;
}
.sf-message {
@extend .message;
justify-content: flex-end;
}
.ai-message, .fr-message {
@extend .message;
justify-content: flex-start;
}
.bubble {
position: relative;
max-width: 66vw;
font-family: "SF Pro Text";
font-size: 14px;
padding: 10px;
border-radius: 18px;
}
.sf-bubble {
@extend .bubble;
background-color: #58c4fd;
color: white;
}
.firstChildMessage {
padding-bottom: 2px;
}
.middleChildMessage {
padding-top: 2px;
padding-bottom: 2px;
}
.lastChildMessage {
padding-top: 2px;
}
#aiMessage {
align-items: flex-start;
}
#sfMessage {
align-items: flex-end;
}
#frMessage {
align-items: flex-start;
}
.messageBox {
position: relative;
display: flex;
flex-direction: column;
// Animate on render.
animation-name: appear;
animation-duration: 0.25s;
}
#aiMessageBox {
margin-left: 20px;
align-items: flex-start;
}
#sfMessageBox {
margin-right: 20px;
align-items: flex-end;
}
#frMessageBox {
margin-left: 20px;
align-items: flex-start;
}
.bubble {
position: relative;
max-width: 66vw;
display: flex;
flex-direction: column;
font-family: "SF Pro Text";
font-size: 14px;
padding: 10px;
border-radius: 18px;
}
@keyframes appear {
10% {
transform: scale(0.3);
}
100% {
transform: scale(1);
}
}
#aiBubble {
background-color: white;
}
#sfBubble {
background-color: #58c4fd;
color: white;
}
#frBubble {
background-color: white;
}
.sf-firstChildBubble {
border-bottom-right-radius: 9px;
}
.sf-middleChildBubble {
border-top-right-radius: 9px;
border-bottom-right-radius: 9px;
}
.sf-lastChildBubble {
border-top-right-radius: 9px;
}
.ai-firstChildBubble, .fr-firstChildBubble {
border-bottom-left-radius: 9px;
}
.ai-middleChildBubble, .fr-middleChildBubble {
border-top-left-radius: 9px;
border-bottom-left-radius: 9px;
}
.ai-lastChildBubble, .fr-lastChildBubble {
border-top-left-radius: 9px;
}
.notify {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #58D9FF;
top: -5px;
left: -5px;
border: 2px solid #EBEBEB;
transform: scale(0);
animation-name: notify-anim;
animation-duration: 5s;
}
@keyframes notify-anim {
0%, 90% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
.context {
position: relative;
display: flex;
align-items: center;
font-family: "SF Compact Display";
font-size: 12px;
font-weight: bold;
margin-top: 5px;
}
.photo {
display: flex;
justify-content: center;
align-items: center;
margin-right: 5px;
width: 30px;
height: 30px;
font-size: 14px;
background-color: white;
border-radius: 15px;
}
// Apply for audio message playback.
.playing {
animation-name: circle1;
animation-duration: 2s;
animation-iteration-count: infinite;
}
@keyframes circle1 {
0%,
100% {
box-shadow: 0 0 0 0.4em rgba(195, 195, 195, 0.4), 0 0 0 0.25em rgba(195, 195, 195, 0.15);
}
25% {
box-shadow: 0 0 0 0.15em rgba(195, 195, 195, 0.15), 0 0 0 0.4em rgba(195, 195, 195, 0.3);
}
50% {
box-shadow: 0 0 0 0.55em rgba(195, 195, 195, 0.55), 0 0 0 0.15em rgba(195, 195, 195, 0.05);
}
75% {
box-shadow: 0 0 0 0.25em rgba(195, 195, 195, 0.25), 0 0 0 0.55em rgba(195, 195, 195, 0.45);
}
}
.contentLoader {
display: flex;
}
.contentLoaderDot {
width: 5px;
height: 5px;
margin: 2px;
border-radius: 2.5px;
background-color: #357CA2;
}
.questionMark {
font-weight: 900;
color: #357CA2;
}
// .divider {
// width: 100vw;
// display: flex;
// justify-content: center;
// align-items: center;
// font-family: "SF Compact Display";
// font-size: 12px;
// font-weight: bold;
// color: #9B9B9B;
// margin-bottom: 18px;
// }
</style>

View file

@ -1,102 +0,0 @@
import { ref } from "vue";
import anime from "animejs";
import { v4 as uuidv4 } from 'uuid';
export function animateTextInput () {
const side = ref("right");
function show () {
const t1 = (side.value === "right") ? 50 : -70;
const t2 = (side.value === "right") ? 110 : -130;
anime({
targets: '#textInput',
opacity: [0, 1],
translateX: [t1, t2],
scale: [0.3, 1],
duration: 500,
easing: 'easeOutExpo',
})
}
function hide () {
const t = (side.value === "right") ? 50 : -80;
anime({
targets: '#textInput',
opacity: [1, 0],
translateX: t,
scale: 0.3,
duration: 500,
easing: 'easeOutExpo',
})
}
function switchSide () {
const t = (side.value === "right") ? -130 : 110;
anime({
targets: '#textInput',
translateX: t,
duration: 500,
easing: 'easeOutExpo',
})
}
return {
side,
show,
hide,
switchSide
};
}
export function animateAudioInput () {
function show () {
anime({
targets: '#recIcon',
opacity: [0, 0.75],
scale: [0.0, 1],
duration: 250,
easing: 'linear',
})
}
function hide () {
anime({
targets: '#recIcon',
opacity: [0.75, 0],
scale: [1, 0],
duration: 250,
easing: 'linear',
})
}
return {
show,
hide
};
}
export function newMessage ({
text=false,
audio=false,
context=false,
uid=uuidv4()
}) {
return {
text: text,
audio: audio,
context: context,
uid: uid
};
}

View file

@ -1,77 +0,0 @@
import { onMounted, onUnmounted, ref, Ref } from "vue";
import { postMessage } from "@/render/ipc";
import { newMessage, animateAudioInput } from "./helpers";
import { invokeReturnAudio, postAudioChunk } from "@/render/ipc";
export default function useAudioInputController (typing: Ref) {
const recording = ref(false);
let mediaRecorder: MediaRecorder;
const { show, hide } = animateAudioInput();
// initialize audio
const conf = {audio: true, video: false}
navigator.mediaDevices.getUserMedia(conf).then((stream: MediaStream) => {
const options = {mimeType: 'audio/webm'};
mediaRecorder = new MediaRecorder(stream, options);
// post any mew audio to backend
mediaRecorder.addEventListener('dataavailable', (e: BlobEvent) => {
e.data.arrayBuffer().then((buff: ArrayBuffer) => {
postAudioChunk(buff);
});
});
// get audio and post new message to backend
mediaRecorder.addEventListener('stop', (_e: Event) => {
invokeReturnAudio().then((audio: ArrayBuffer[] | Error) => {
console.log(audio);
// postMessage(newMessage({audio: audio}));
});
});
});
// start recording on space bar
const record = () => {
console.log("INPT:Capturing audio...")
mediaRecorder.start();
recording.value = true;
show()
}
// stop recording and send on release
const stop = () => {
console.log("INPT:Stopping record.")
mediaRecorder.stop();
recording.value = false;
hide();
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.keyCode == 32 && !typing.value) record();
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.keyCode == 32 && recording.value) stop();
}
//-----------------------------------------------------------
onMounted(() => {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
});
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
})
return {
recording
}
}

View file

@ -1,38 +0,0 @@
import { ref } from 'vue';
import useScroll from "@/render/composables/useScroll";
const messagesRef = ref();
/* seed the canvas with messages */
const seedCanvas = (messages: Message[]) => {
messagesRef.value = messages;
}
const addMessage = (message: Message) => {
messagesRef.value.push(message);
}
const updateMessage = (message: Message) => {
let target_message = messagesRef.value.filter((m: Message) => {
return m.uid = message.uid;
})[0];
if (target_message) {
target_message = message;
}
}
export default function useMessages() {
const { updateScrollRef, adjustScroll } = useScroll("messenger");
return {
messagesRef,
seedCanvas,
addMessage,
updateMessage
};
}

View file

@ -1,89 +0,0 @@
<template>
<button id="titlebar" />
<span id="menu">
<button
class="menuButton exitButton"
@click.prevent="postNavBarExit"
/>
<button
class="menuButton minimizeButton"
@click.prevent="postNavBarMin"
/>
</span>
</template>
<script lang="ts">
// import { postNavBarExit, postNavBarMin } from "@/render/ipc";
import { defineComponent } from "vue";
export default defineComponent({
name: "Header",
setup() {
const postNavBarExit = () => {};
const postNavBarMin = () => {};
return {
postNavBarExit,
postNavBarMin
}
}
});
</script>
<style lang="scss">
#titlebar {
position: fixed;
width: 100vw;
height: 54px;
opacity: 0.75;
background-color: #EBEBEB;
-webkit-app-region: drag;
border: none;
outline: none;
z-index: 1;
}
#menu {
position: fixed;
margin-left: 20px;
margin-top: 20px;
z-index: 2;
}
.menuButton {
border: none;
outline:none;
min-width: 14px;
min-height: 14px;
border-radius: 7px;
}
.exitButton {
background-color: #FF6157;
}
.exitButton:active {
background: #c14645;
}
.minimizeButton {
background-color: #FFC12F;
margin-left: 8px;
}
.minimizeButton:active {
background-color: #c08e38;
}
</style>

View file

@ -1,82 +0,0 @@
<template>
<!-- Position fixed items -->
<InputItem :initials="profile.initials"/>
<Settings />
<div id="recIcon" />
<!-- List of message bubbles. -->
<div id="messenger">
<Bubble
v-for="message in messages"
:text="message.content.text"
:context="message.context"
:key="message[0]"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted } from "vue";
import InputItem from "@/render/components/inputItem.vue";
import Settings from "@/render/components/settings.vue";
import Bubble from "@/render/components/bubble.vue";
import { profile } from "@/render/composables/useProfile";
import { messages } from "@/render/composables/useMessages";
export default defineComponent({
name: "Messenger",
components: {
InputItem,
Settings,
Bubble
},
setup() {
return {
messages,
profile
};
},
});
</script>
<style lang="scss" scoped>
#messenger {
width: 100vw;
height: 100vh;
overflow: auto;
}
#messenger::-webkit-scrollbar {
display: none;
}
#recIcon {
position: fixed;
right: 0;
opacity: 0;
transform: scale(0.0);
margin-top: 24px;
margin-right: 66px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #FF3B3B;
z-index: 2;
}
</style>

View file

@ -1,54 +0,0 @@
import { IpcRendererEvent } from "electron";
export class IpcRendererListener<InputParam> implements IIpcListener<InputParam> {
readonly channel: string;
readonly _listenerCallback: IpcListenerCallback<InputParam>;
constructor(options: {
channel: string;
listenerCallback: IpcListenerCallback<InputParam>;
}) {
this.channel = options.channel;
this._listenerCallback = options.listenerCallback;
}
listen() {
this.remove();
window.ipcRenderer.on(this.channel, this._onPost);
}
remove() {
window.ipcRenderer.removeAllListeners(this.channel);
}
private _onPost = (_e: IpcRendererEvent, payload: InputParam): void => {
console.log(`[IPC] Post: ${this.channel}`);
this._listenerCallback(payload);
}
}
export default function useIpcRenderer () {
const invoke = async (endpoint: string, payload: any) => {
try {
const res = await window.ipcRenderer.invoke(endpoint, payload);
return res;
} catch (e) {
throw e;
}
}
const post = (endpoint: string, payload: any) => {
window.ipcRenderer.send(endpoint, payload);
};
return {
invoke,
post,
}
}

View file

@ -1,33 +0,0 @@
// shared
import { ref, Ref } from "vue";
import useScroll from "@/render/composables/useScroll";
export const messages: Ref<Array<Message>> = ref([]);
export const setMessages: IpcListenerCallback<Array<Message>> = (payload) => {
messages.value = payload as Array<Message>;
}
export const addMessage: IpcListenerCallback<Message> = (payload) => {
messages.value.push(payload as Message);
}
export const updateMessage: IpcListenerCallback<Message> = (payload) => {
const message = payload as Message;
let targetMessage = messages.value.filter((m: Message) => {
return m.uid = message.uid;
})[0];
if (targetMessage) {
targetMessage = message;
}
}
export default {
messages,
setMessages,
addMessage,
updateMessage
};

View file

@ -1,27 +0,0 @@
// shared
import { ref } from "vue";
export const profile = ref();
export const authComplete = ref(false);
export const setProfile: IpcListenerCallback<Profile | null> = (payload) => {
payload ? profile.value = payload : clearProfile();
showRender();
};
export const clearProfile = () => {
profile.value = null;
};
export const showRender = () => {
authComplete.value = true;
};
export default {
setProfile,
clearProfile,
profile,
showRender,
authComplete,
};

View file

@ -1,29 +0,0 @@
export default function useScroll(element: string) {
let isScrolledToBottom: boolean;
const view = document.getElementById(element)
// Update isScrolledToBottom
const updateScrollRef = () => {
if (view) isScrolledToBottom = view.scrollHeight - view.clientHeight <= view.scrollTop + 1;
return isScrolledToBottom;
}
// Adjust scroll after we add content to the messenger.
const adjustScroll = () => {
if (view) {
view.scrollTo({
top: view.scrollHeight - view.clientHeight,
behavior: 'smooth'
});
}
}
return {
updateScrollRef,
adjustScroll,
};
}

View file

@ -1,80 +0,0 @@
import useIpc from "@/render/composables/useIpcRend";
import * as rendererListeners from "./listeners";
const { post, invoke } = useIpc();
/**
*
* Account and auth related endpoints
*
*/
export const invokeLogin = async (
payload: LoginPayload
): Promise<Profile | Error> => (
await invoke('invoke-account-login', JSON.stringify(payload))
);
export const invokeLogout = async (): Promise<void> => (
await invoke("invoke-account-logout", null)
);
/**
*
* Audio endpoints
*
*/
export const postAudioChunk = (chunk: ArrayBuffer): void => (
post("post-audio-collect", chunk)
);
export const invokeReturnAudio = async (): Promise<ArrayBuffer[] | Error> => (
await invoke("invoke-audio-flush", null)
);
/**
*
* Crimata Platform (session) endpoints
*
*/
export const invokeSession = async (cid: string): Promise<Profile | Error> => (
await invoke("messenger-init", cid)
);
export const postMessage = (payload: Message): void => (
post('post-session-send', payload)
);
export const postAppMount = (): void => (
post('post-app-mount', null)
);
/**
*
* Ipc Renderer Listeners
*
*/
let ipcListeners: IPCListeners = {};
export const initIpcRendererListeners = () => {
for (const [key, listener] of Object.entries(rendererListeners)) {
if (!(key in ipcListeners)) {
ipcListeners[key] = listener;
listener.listen();
}
}
};
export const removeListeners = () => {
for (const [key, listener] of Object.entries(rendererListeners)) {
listener.remove();
}
ipcListeners = {};
};

View file

@ -1,32 +0,0 @@
import { IpcRendererListener } from "./composables/useIpcRend"
import { setProfile } from "./composables/useProfile";
import { setMessages, addMessage, updateMessage } from "./composables/useMessages";
const SET_PROFILE_CHANNEL = "set-profile";
const INIT_MESSAGES_CHANNEL = "init-messages";
const ADD_MESSAGE_CHANNEL = "add-message";
const UPDATE_MESSAGE_CHANNEL = "update-message";
export const setProfileListener = new IpcRendererListener({
channel: SET_PROFILE_CHANNEL,
listenerCallback: setProfile
});
export const initMessagesListener = new IpcRendererListener({
channel: INIT_MESSAGES_CHANNEL,
listenerCallback: setMessages
});
export const addMessagesListener = new IpcRendererListener({
channel: ADD_MESSAGE_CHANNEL,
listenerCallback: addMessage
});
export const updateMessagesListener = new IpcRendererListener({
channel: UPDATE_MESSAGE_CHANNEL,
listenerCallback: updateMessage
});

View file

@ -1,21 +0,0 @@
// src/main.ts
import App from "./App.vue";
import mitt from "mitt";
import { createApp } from "vue";
import { initIpcRendererListeners } from "./ipc"
// Handle ipcMain events.
initIpcRendererListeners();
// Handle events.
const emitter = mitt();
const app = createApp(App);
app.provide("mitt", emitter);
app.mount("#app");

View file

@ -1,101 +0,0 @@
// import useAudio from "@/audio";
import { ipcEmit } from "@/composables/useEmitter";
import useWebsockets from "./composables/useWebsockets";
import {config} from "@/config";
/* data structure of messages that's tied to the UI */
const uiState: any | null = null;
/* start and stop audio functionality */
// const { initAudio, closeAudio } = useAudio();
const isInitMessage = (message: any): boolean => {
return true;
};
const deauthenticate = (): void => {
console.log('deauthenticating')
};
const isAddMessage = (message: any): boolean => {
return true;
};
/**
* Controls for interfacing with the platform.
* Takes an onMessage callback which we define below.
*/
const onMessageCallback = (payload: string) => {
const message = JSON.parse(payload);
console.log(typeof message);
/* if the platform fails to authenticate, we must back down */
if (message === "CLOSE_AUTH_FAIL") {
deauthenticate();
return;
}
ipcEmit("add-message", message)
return
/* on init, platform sends state, used to init canvas */
if (isInitMessage(message)) {
ipcEmit("init-messages", message)
}
else if (isAddMessage(message)) {
ipcEmit("add-messages", message)
}
else {
ipcEmit("update-messages", message)
}
}
const onConnectionStatusCallback = (alive: boolean) => {
// console.log('[Session]: Connection Alive: ', alive);
// ipcEmit('connection-state', alive);
}
const { connect, send, close } = useWebsockets(
onMessageCallback,
onConnectionStatusCallback
);
/* send a message to the platform */
export function sendMessage<Message>(message: Message): void {
/* socket send */
send(message);
}
/* launch a new session (the main process for authenticated users) */
export function launchSession(platformKey: string) {
/* connect to the platform */
connect(config.PLATFORM_URL, platformKey);
/* initialize the audio streams */
// initAudio();
}
export function endSession() {
// closeAudio();
close();
}

View file

@ -1,31 +0,0 @@
const Store = require('electron-store');
const schema = {
token: {
type: 'string',
},
profile: {}
};
const store = new Store({
schema,
encryptionKey: "super user test"
});
export const getToken = (): string | undefined => (store.get("token"));
export const clearToken = (): void => (store.delete("token"));
export const setToken = (token: string): void => (store.set('token', token));
export const setProfile = (profile: Profile): Profile => (store.set('profile', profile));
export const getProfile = (): Profile => (store.get('profile'));
export const clearProfile = (): void => (store.delete('profile'));
export const clearStore = (): void => {
clearToken();
clearProfile();
}

View file

@ -1,77 +1,75 @@
export interface RenderMessage {
content: {
text: boolean | string;
audio: boolean | string;
};
context: string;
modifier: string;
time: number;
uid: string;
isChild: string;
seen: boolean;
newMessage: boolean;
}
interface Message {
text: boolean | string; export interface ClientMessage {
context: boolean | string; text: string;
audio: boolean | string; audio: string | null;
type: 1 | 2 | 3;
uid: string; uid: string;
} }
interface ViewMessage extends Message { export interface ClientRequest {
child: string; intent: string;
params: object;
epic: string | boolean;
confidence: number;
} }
interface WindowState { export interface SessionState {
key: string | boolean;
newMessages: RenderMessage[];
}
export interface WindowState {
width: number; width: number;
height: number; height: number;
x: number | null; x: number | null;
y: number | null; y: number | null;
} }
interface Profile { export interface AuthRequest {
key: boolean | string;
usr: boolean | string;
pwd: boolean | string;
}
export interface Profile {
crimataId: string; crimataId: string;
alias: string; alias: string;
initials: string; initials: string;
} }
interface LoginPayload { export interface AuthProtocol {
email: string; key: boolean | string;
password: string; profile: boolean | Profile;
} }
interface AuthState { export interface LogoutRequest {
profile: Profile | null; logout: boolean;
token: string | null;
} }
interface AccountCredentials { export interface Annotation {
email: string; text: string;
password: string; context: string;
} uid: string;
/*
* Electron Ipc
*/
interface IpcHandlerCallback<I, O> {
(payload: I | null): Promise<Error | O>;
}
interface IpcListenerCallback<T> {
(payload: T | null): void;
}
interface IIpcHandler<I, O> {
handle(): void;
remove(): void;
readonly _handlerCallback: IpcHandlerCallback<I, O>;
}
interface IIpcListener<I> {
listen(): void;
remove(): void;
readonly _listenerCallback: IpcListenerCallback<I>;
}
interface IPCHandlers {
[handler: string]: IIpcHandler<any, any>;
}
interface IPCListeners {
[listener: string]: IIpcListener<any> | null;
}
interface IpcRendererEvent<T> {
channel: string;
payload: T | null;
} }
export interface StandardMessage {
content: {
text: boolean | string;
audio: boolean | string;
};
context: string;
modifier: string;
}

View file

@ -6,7 +6,6 @@ const wss = new WebSocket.Server({
let auth = false; let auth = false;
wss.on("connection", function connection(ws, req) { wss.on("connection", function connection(ws, req) {
ws.on("message", function incoming(message) { ws.on("message", function incoming(message) {
console.log(message) console.log(message)
@ -20,13 +19,13 @@ wss.on("connection", function connection(ws, req) {
auth = true; auth = true;
} else { } else {
const parsed = JSON.parse(message); const parsed = JSON.parse(message);
console.log('got a message', message); console.log('got a message', message)
} }
} else { } else {
const parsed = JSON.parse(message); const parsed = JSON.parse(message);
console.log('parsed', parsed); console.log('parsed', parsed)
} }
}); });

View file

@ -32,7 +32,6 @@
] ]
}, },
"include": [ "include": [
"**/*.ts",
"src/*.ts", "src/*.ts",
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",

4177
yarn.lock

File diff suppressed because it is too large Load diff