diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000..214388f --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,3 @@ +> 1% +last 2 versions +not dead diff --git a/.gitignore b/.gitignore index 20128fc..67a7ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ crash.log # local env files .env.local .env.*.local -.env # Log files npm-debug.log* @@ -28,10 +27,3 @@ pnpm-debug.log* #Electron-builder output /dist_electron - -#Electron-builder config -electron-builder.yml - -#Window and session states -session.json -window.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 2c0d22c..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,63 +0,0 @@ -stages: - - Build - - Upload - - Release - -build: - stage: Build - tags: - # Use a macos runner to build. - - darwin - before_script: - - yarn - script: - - export VERSION=$(node -e "console.log(require('./package.json').version)") - - echo "VERSION=$VERSION" >> variables.env - - export APPNAME=$(node -e "console.log(require('./package.json').productName)") - - echo "APPNAME=$APPNAME" >> variables.env - - yarn build - artifacts: - reports: - dotenv: variables.env - name: $CI_COMMIT_REF_SLUG - paths: - - dist_electron/*.dmg - - dist_electron/*.zip - - dist_electron/*.yml - when: on_success - only: - - main - -variables: - PACKAGE: '${APPNAME}-${VERSION}.dmg' - PACKAGE_REGISTRY_URL: '${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${APPNAME}/${VERSION}' - -# Upload package to gitlab registry. -upload: - stage: Upload - needs: - - job: build - artifacts: true - rules: - - if: $CI_COMMIT_TAG - when: never # Do not run this job when a tag is created manually - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when when commits are pushed to the default branch - script: - # Take the package we built and place it in the package registry. - - 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file "dist_electron/${PACKAGE}" "${PACKAGE_REGISTRY_URL}/${PACKAGE}"' - -auto-release-master: - image: registry.gitlab.com/gitlab-org/release-cli - needs: - - job: build - artifacts: true - - job: upload - artifacts: true - stage: Release - rules: - - if: $CI_COMMIT_TAG - when: never # Do not run this job when a tag is created manually - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run this job when when commits are pushed to the default branch - script: - - echo "Release $VERSION" - - release-cli create --name "Release $VERSION" --tag-name v$VERSION --description "Release $CI_COMMIT_TITLE" --ref $CI_COMMIT_SHA --assets-link "{\"name\":\"${APPNAME}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${PACKAGE}\"}" diff --git a/README.md b/README.md index c13a8ab..6fb4b27 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ yarn install ### Compiles and hot-reloads for development ``` -yarn electron:serve +yarn dev ``` ### Compiles and minifies for production @@ -15,7 +15,7 @@ yarn electron:serve yarn electron:build ``` - +``` ### Customize configuration See [Configuration Reference](https://cli.vuejs.org/config/). diff --git a/build/config.gypi b/build/config.gypi new file mode 100644 index 0000000..6f84ed7 --- /dev/null +++ b/build/config.gypi @@ -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 + } +} diff --git a/gcloud.json b/gcloud.json new file mode 100644 index 0000000..5c68549 --- /dev/null +++ b/gcloud.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "crimata", + "private_key_id": "3a324fa3fcca4cea589e4b5007f4142db4b3a84d", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCxFJOjhIkYlyhn\nHjtpFC5EOwzNFZem6k6SS7zcgWbNLe9mcBsiTKCjpxItzkIo0pnT32yqw+AKsqpv\n6+LVyzzjfB7vtEUflvg6KgmYMS4ixOTCEbN3e2qj1t2RnITXZR2VGSGdoKKyqZm0\ntWrmexQvSN20cByL1vfxD3E69oYFGhVraa0zmm94WXHAzofT6DkvGdrbK1s1G9jI\nG3mdQ1jN71tghyloeHL+R/1iKQoZFYTkVe6yWvccSNAZelt/5sciCKRTCArXj89X\nLwU6jGOKIsyqx3BeUyWaSS59xpnGX3meSf5Q9U+j45ePUiqO6TTleF+kWiBwjUpp\nfeYcwEIDAgMBAAECggEAElkN9R7p75zV6F1XDYu0QYiWyncmx/o2Gu1zC6vywWa1\ni/korpSe/mX0ub9J1p3/p1balRUHlUQu6brYvYs56140fGTC1sOXQ7uQU+8glySs\niTk5TbOBeKluOsSrdP/6oTTB6Wm4AegVz4YOpgPxsvaLVkNZidnDxfvyIQhjGYsO\nsvRaw48eEmGOa1uvDhgvPOxiDp5YEdxqSue/7ylzjBdKecDyLgTSDDf/p+FwIx5B\n2d/0uRgQBaAk6D/DNOvN8GrYyakFmlDjE72nrBGAJq50UyV6THOSDk+8cK+2QiS1\nEDCFufj+7Dk1EBfBFUFY8ZBB/pphpEWyLoFl7NzGCQKBgQDaj/KOOcj6BkkTj1WM\nRW3KhOE3nhsCQEgQGCflpycEAZj6J7ktBG2/4teDt9sr+Epx8ymnQCZa9pmysdrD\nU0aWku1qUx0IUKTtZbAEbB+AnrWYDYkIWU8kRUUvNHNTBg43VyuohV9wprPih138\nHWMeNac82/XICz2A3rv+Xkx8KwKBgQDPaaEEGWVs3nIhf/p3M3/xWNKt8fhAERpG\n0YpTjgHW3FQFYjiCTTIcuLO3qsDGmfIAB9tL37s2Y3e2wKBGUj/o69Fr7sWVPdsX\nJtUc0+KiG2Bs0hsyZPdk9UQiQX8y2eq1NZW7vEoxWso1bM64WURB5N8ocWePNJJx\nL2Oe75rtiQKBgQDLfQQOiRxmNF3rOSMkAywyRr9NQgXRdbniSiszNQotP7OHDF7q\n29m2suOGfjIv4O6m4wdf8WkEfd4hsleETc9Ft6wVtyYrrLGxWWCk7WnzHVDjLY7s\n2AHIOjostf+9R8EKoz1BnFN8laibev71EQNMiBWZow1VX6m2hymurWs2mwKBgQCb\nuY3n2v14qOb92e1+U89KsEq1yMd/qpeU9jwqEaO14wS+aglNY5ItWEuuqWhFdE3q\n0ftHUzpnUnUOZD+xrI1JXsyEgegc7i0xi7lUBI3S8kUKTxGWW5IXXcKDCbPrxQtg\ndFPweSUnOyg4xnHKnVMPOjyGS+bZ8TnF+zOLoBAtKQKBgF6hAOIwF1DheXT4p8xS\nlSGhl8dBxjsJDVwbnFWQceGzGnq1e3ncy/UItt42UCDVRWTH4r7pQ9eKyd/3foUD\n6KDIcCrYo2KJh6ZNFJ67XF30U+TuEg5U/9jTbWqDOwYVXcMcnzQE4ZiGBxVHL6xr\nNc8FRW5+7xh4zNfzhGUGt5kt\n-----END PRIVATE KEY-----\n", + "client_email": "crimata-service-account@crimata.iam.gserviceaccount.com", + "client_id": "102914612880037864172", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/crimata-service-account%40crimata.iam.gserviceaccount.com" +} diff --git a/login.svg b/login.svg new file mode 100644 index 0000000..a2ccbf3 --- /dev/null +++ b/login.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + Email + Password + + Login + + Submit + Create account + + \ No newline at end of file diff --git a/package.json b/package.json index 24d13b1..5bb4571 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,24 @@ { "name": "Crimata", - "version": "0.9.9", - "private": true, + "productName": "crimata-messenger", "description": "Cross-platform messenger application built with electron, vue3, and TS.", + "version": "0.1.0", + "private": true, "author": { "name": "Enrique Hernandez" }, "scripts": { - "build": "vue-cli-service electron:build", - "dev": "vue-cli-service electron:serve", + "dev": "yarn electron:serve", + "serve": "vue-cli-service serve", + "build": "vue-cli-service build", + "test:unit": "vue-cli-service test:unit", + "lint": "vue-cli-service lint", + "electron:build": "vue-cli-service electron:build", + "electron:serve": "vue-cli-service electron:serve", "postinstall": "electron-builder install-app-deps", - "postuninstall": "electron-builder install-app-deps" + "postuninstal": "electron-builder install-app-deps" }, - "main": "init.js", + "main": "background.js", "dependencies": { "@google-cloud/speech": "^4.2.0", "@types/animejs": "^3.1.2", @@ -22,15 +28,10 @@ "@types/uuid": "^8.3.0", "@types/ws": "^7.2.7", "animejs": "^3.2.0", - "axios": "^0.21.1", "core-js": "^3.6.5", - "electron-is-dev": "^2.0.0", - "electron-store": "^8.0.0", - "electron-updater": "^4.3.8", "mitt": "^2.1.0", "naudiodon": "^2.3.2", "node-record-lpcm16": "^1.0.1", - "update-electron-app": "^2.0.1", "uuid": "^8.3.2", "vue": "^3.0.0-0", "vue-router": "^4.0.0-0", @@ -38,8 +39,6 @@ "ws": "^7.3.1" }, "devDependencies": { - "@types/axios": "^0.14.0", - "@types/electron-devtools-installer": "^2.2.0", "@types/jest": "^24.0.19", "@typescript-eslint/eslint-plugin": "^2.33.0", "@typescript-eslint/parser": "^2.33.0", @@ -56,36 +55,17 @@ "@wasm-tool/wasm-pack-plugin": "^1.3.1", "electron": "^9.0.0", "electron-devtools-installer": "^3.1.0", - "electron-log": "^4.3.4", "eslint": "^6.7.2", "eslint-plugin-vue": "^7.0.0-0", "lint-staged": "^9.5.0", "node-sass": "^4.12.0", "optimize-wasm-webpack-plugin": "^1.0.12", "sass-loader": "^8.0.2", - "spectron": "11.0.0", + "spectron": "^11.0.0", "typescript": "~3.9.3", - "vue-cli-plugin-electron-builder": "~2.0.0-rc.6", + "vue-cli-plugin-electron-builder": "~2.0.0-rc.4", "vue-jest": "^5.0.0-0" }, - "vue": { - "lintOnSave": false, - "pluginOptions": { - "electronBuilder": { - "mainProcessFile": "./src/init.ts", - "rendererProcessFile": "./src/render/main.ts", - "preload": "./src/render/preload.ts", - "builderOptions": { - "appId": "com.crimata.ElectronUpdaterApp", - "artifactName": "${productName}-${version}.${ext}", - "publish": { - "provider": "generic", - "url": "https://gitlab.com/api/v4/projects/25637892/jobs/artifacts/main/raw/dist_electron?job=build" - } - } - } - } - }, "gitHooks": { "pre-commit": "lint-staged" }, @@ -94,11 +74,5 @@ "vue-cli-service lint", "git add" ] - }, - "productName": "crimata-messenger", - "repository": { - "type": "git", - "url": "https://gitlab.com/crimata/electron-app.git", - "release": "latest" } } diff --git a/public/index.html b/public/index.html index 8f79d27..48809d8 100644 --- a/public/index.html +++ b/public/index.html @@ -11,7 +11,11 @@ -
+ +
diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..c60eb6c --- /dev/null +++ b/src/App.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/account.ts b/src/account.ts deleted file mode 100644 index 581da0e..0000000 --- a/src/account.ts +++ /dev/null @@ -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 => { - - /* 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 = 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 => { - - 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 - -} - diff --git a/src/api/account.ts b/src/api/account.ts deleted file mode 100644 index 45baca2..0000000 --- a/src/api/account.ts +++ /dev/null @@ -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 => (await post('/account/logout')); - - - - - - diff --git a/src/assets/crimata.svg b/src/assets/crimata.svg new file mode 100644 index 0000000..8bb28dc --- /dev/null +++ b/src/assets/crimata.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/audio.ts b/src/audio.ts deleted file mode 100644 index 7ae2562..0000000 --- a/src/audio.ts +++ /dev/null @@ -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 = (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 => ( - -// 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 { -// 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; -// } -// } -// } - - diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index 14ed36a..0000000 --- a/src/auth.ts +++ /dev/null @@ -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 - } -}; - - - - diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..065b4ec --- /dev/null +++ b/src/background.ts @@ -0,0 +1,25 @@ +/* + * Entry point for Crimata electron app. + * "Look on my Works, ye Mighty, and despair!" + */ + +"use strict"; + +import { protocol } from "electron"; +import { initApp } from './background/init'; + +// Scheme must be registered before the app is ready +protocol.registerSchemesAsPrivileged([ + { scheme: "app", privileges: { secure: true, standard: true } } +]); + +// Load environment variable +const isDevelopment = process.env.NODE_ENV !== "production"; + +// NOTE Program Begins Here +(async () => { + + console.log('Starting Crimata electron app.'); + initApp(isDevelopment); + +})(); diff --git a/src/background/audio.ts b/src/background/audio.ts new file mode 100644 index 0000000..5b0ef12 --- /dev/null +++ b/src/background/audio.ts @@ -0,0 +1,135 @@ +/* eslint @typescript-eslint/no-var-requires: "off" */ + +"use strict"; + +import { sendAudio } from './session' +import { ipcMain } from "electron"; +import { backgroundMitt } from './emitter'; + +const portAudio = require('naudiodon'); + +let ai: typeof portAudio.AudioIO | null = null; +let ao: typeof portAudio.AudioIO | null = null; +let record = false; +const audioContainer = { + input: '', +} +const encoding = "hex"; +const audioOptions = { + channelCount: 1, + sampleFormat: 16, + sampleRate: 16000, + deviceId: -1, + closeOnError: false, +} + +// callback run on space bar key up and down. +const updateRecorder = (_event, payload: { + isRecording: boolean; + uid: string | null; +}): void => { + record = payload.isRecording; + if (!record) { + if (payload.uid) sendAudio(audioContainer.input, payload.uid); + } +}; + +// listen for space key up/down event. +ipcMain.removeAllListeners('update-recorder'); +ipcMain.on('update-recorder', updateRecorder); + +// Split Buffer into an array of len-sized Buffers. +function bufSplit(buf: Buffer, len: number): Array { + const chunks = []; + let i = 0; + let L = len; + + while(i < buf.byteLength) { + chunks.push(buf.slice(i, L)); + i = L; + L += len; + } + + return chunks; +} + +// main audio function run by run.ts module. +export function initAudioIO(): void { + + if (!ai) { + ai = new portAudio.AudioIO({ inOptions: audioOptions }); + + // base64 encoding needed for google speech to text. + ai.setEncoding(encoding); + ai.start(); + + ai.on('data', (chunk: string) => { + if (record) { + console.log('recording...') + audioContainer.input += chunk; + } else { + if (audioContainer.input.length) audioContainer.input = ""; + } + }); + } + + if (!ao) { + ao = new portAudio.AudioIO({ outOptions: audioOptions }); + ao.start(); + } +} + +// play audio buffers. +export function play(input: Buffer): void { + let i = 0; + + const audio = bufSplit(input, 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; + + 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); + } + } +} + +export function stopStream() { + if(ai != null) { + ai.quit(); + ai = null; + } + if (ao != null) { + ao.quit(); + ao = null; + } +} + diff --git a/src/background/emitter.ts b/src/background/emitter.ts new file mode 100644 index 0000000..14aa77f --- /dev/null +++ b/src/background/emitter.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +const EventEmitter = require('events'); + +class BackgroundMitt extends EventEmitter { } + +export const backgroundMitt = new BackgroundMitt(); diff --git a/src/background/init.ts b/src/background/init.ts new file mode 100644 index 0000000..cb90050 --- /dev/null +++ b/src/background/init.ts @@ -0,0 +1,50 @@ + +"use strict"; + +import { app } from "electron"; +import { main } from './run'; +import { backgroundMitt } from './emitter'; +import { stopStream } from './audio'; + +let winActive: boolean; + +backgroundMitt.on('window-active', (state: boolean) => { + winActive = state; +}); + +export function initApp(dev: boolean): void { + + app.on("ready", () => { + main(); + }); + + // Quit when all windows are closed. + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + stopStream(); + app.quit(); + } + }); + + app.on("activate", () => { + if (winActive === false) { + main(); + } + }); + + // Exit cleanly on request from parent process in development mode. + if (dev) { + if (process.platform === "win32") { + process.on("message", data => { + if (data === "graceful-exit") { + app.quit(); + } + }); + } else { + process.on("SIGTERM", () => { + app.quit(); + }); + } + } +} + diff --git a/src/background/run.ts b/src/background/run.ts new file mode 100644 index 0000000..a007ad5 --- /dev/null +++ b/src/background/run.ts @@ -0,0 +1,24 @@ +"use strict"; + +import { createWindow } from './window'; +import { initSession } from './session'; +import { initAudioIO } from './audio'; + +let socket = false; + +/* + * The main function will be run after electron app is ready. + */ +export async function main() { + + // create main window. + await createWindow(); + + // Instantiate socket session with crimata-platorm. + if (!socket) initSession(); + socket = true; + + // Begin audio stream. + initAudioIO(); + +} diff --git a/src/background/session.ts b/src/background/session.ts new file mode 100644 index 0000000..49ddb13 --- /dev/null +++ b/src/background/session.ts @@ -0,0 +1,198 @@ +"use strict"; + +import WebSocket from 'ws'; +import { backgroundMitt } from './emitter'; +import { ipcMain } from "electron"; +import { play } from './audio'; + +import { + Creds, + Profile, + Annotation, + StandardMessage, + ClientMessage +} from "@/types/message/index"; + +import { clientMessage, renderMessage } from '@/modules/message'; + +const ip = 'ws://127.0.0.1'; +const port = 8760; +const reconnectTimeout = 3000; //ms +let socket: WebSocket; +let success = false; +let auth = false; + +const onAuthSession = async (e: any, payload: string | Creds) => { + + return new Promise((resolve, reject) => { + + console.log('Authenticating...'); + + // Handle auth response from server. + backgroundMitt.once('auth-res', (res: string) => { + + if (res == "locked") { + reject(res); + } else { + console.log('Success!'); + auth = true; + resolve(res); + } + + }); + + if (typeof payload !== "string") { + payload = JSON.stringify(payload) + } + + // Send token to backend + socket.send(payload); + + }); +}; + + +//---Message Handlers----------------------------------------------- + +const handleAuthMessage = (message: string) => { + backgroundMitt.emit('auth-res', message); +} + +const handleProfileMessage = (message: Profile) => { + backgroundMitt.emit('ipc-renderer', { + endpoint: 'update-profile', + message: message + }); +} + +const handleStandardMessage = (m: StandardMessage) => { + + // Create a render message object. + const message = renderMessage( + m.content.text, m.content.audio, m.context, m.modifier); + + // Play audio if any. + if (message.content.audio) { + const audioBytes = Buffer.from(m.content.audio as string, 'hex'); + m.content.audio = true; + play(audioBytes); + } + + backgroundMitt.emit('ipc-renderer', { + endpoint: 'render-message', + message: message + }); +} + +const handleAnnotationMessage = (message: Annotation) => { + backgroundMitt.emit('ipc-renderer', { + endpoint: 'render-message', + message: message + }); +} + +//------------------------------------------------------------------ + +const onMessage = (messageStr: string): void => { + + // Auth messages are strings. + if (!auth) { + handleAuthMessage(messageStr) + return + } + + const message = JSON.parse(messageStr) + console.log("New message:") + console.log(message) + + if (message.content) { + handleStandardMessage(message) + } + + else if (message.first) { + handleProfileMessage(message) + } + + else { + handleAnnotationMessage(message) + } + +}; + +const onClientMessage = (e: any, payload: ClientMessage): void => { + console.log('sending message'); + socket.send(JSON.stringify(payload)); +} + +const restart = () => { + auth = false; + initSession(); +} + +// Run every time we want to connect to backend. +export function initSession(): void { + + // Close existing sockets. + if (socket) { + socket.removeAllListeners(); + socket.terminate(); + socket.close(); + success = false; + } + + // Init new socket. + socket = new WebSocket(`${ip}:${port}`); + socket.binaryType = 'arraybuffer'; + + // handle renderer auth-token event + ipcMain.removeHandler('auth-session'); // avoid setting duplicate handlers + ipcMain.handle('auth-session', onAuthSession); + + // handle user message event + ipcMain.removeAllListeners('client-message'); + ipcMain.on('client-message', onClientMessage); + + // handle renderer logout event + ipcMain.removeAllListeners('logout'); + ipcMain.on('logout', (_event, _payload: string) => restart()); + + // Connect to the backend. + socket.on('open', () => { + console.log('Connected to Crimata Servers.'); + + // Try to authenticate token right away. + backgroundMitt.emit('ipc-renderer', { + endpoint: 'auth', + message: null + }); + + success = true; + + }); + + // Handle for failed connect, try again. + socket.on('error', (_e) => { + console.log('ERROR: Failed to connect.'); + socket.removeAllListeners(); + socket.close(); + setTimeout(() => { + if (!success) { + console.log('Reconnecting...'); + initSession(); + } + }, reconnectTimeout); + + }); + + socket.on('close', () => { + console.log('Connection droped! Restarting...'); + restart(); + }); + + socket.on("message", onMessage); +} + +export function sendAudio(audio: string, uid: string): void { + const m = clientMessage("", audio, uid); + socket.send(JSON.stringify(m)); +} \ No newline at end of file diff --git a/src/window.ts b/src/background/window.ts similarity index 53% rename from src/window.ts rename to src/background/window.ts index 21ebbca..cc669eb 100644 --- a/src/window.ts +++ b/src/background/window.ts @@ -1,39 +1,22 @@ "use strict"; -import { BrowserWindow, ipcMain, app } from "electron"; +import { BrowserWindow, ipcMain } from "electron"; import { createProtocol } from "vue-cli-plugin-electron-builder/lib"; -import { backgroundMitt } from './composables/useEmitter'; -import { saveToJson } from "./composables/useSaveToJSON"; +import { backgroundMitt } from './emitter'; +import { RenderMessage } from "@/types/message/index"; import * as path from "path"; import fs from 'fs'; -import { config } from "@/config"; -const { autoUpdater } = require('electron-updater'); + +interface IpcRendererPayload { + endpoint: string; + message: RenderMessage | null; +} let win: BrowserWindow | null; -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. const onNavBar = (_event: any, action: string): void => { + console.log("onNavBar") if (win) { if (action === "close") { win.close() @@ -44,45 +27,46 @@ const onNavBar = (_event: any, action: string): void => { } // Util function to render message on ipc-renderer event. -const postToWindow = (event: IpcRendererEvent): void => { +const renderMessage = (payload: IpcRendererPayload): void => { if (win) { - win.webContents.send(event.channel, event.payload); + win.webContents.send(payload.endpoint, { + message: payload.message + }); } } // Write a json with position and size of window. const saveWindowState = () => { - if (win) { - const bounds = win.getBounds(); - const position = win.getPosition(); + if (win) { + const bounds = win.getBounds(); + const position = win.getPosition(); - if (winState) { - - winState.width = bounds.width; - winState.height = bounds.height; - winState.x = position[0]; - winState.y = position[1] - - saveToJson("window.json", winState) + const state = JSON.stringify( + { + w: bounds.width, + h: bounds.height, + x: position[0], + y: position[1] + } + ); + fs.writeFile('windowState.json', state, (err) => { + if (err) throw err; + return; + }); } - } } // Do this on window mount. const onWindowMount = (): void => { - - // Must tell initApp that window exists. backgroundMitt.emit('window-active', true); // Handle win nav-bar event. - ipcMain.removeAllListeners("nav-bar") // avoid setting duplicate handlers - ipcMain.on("nav-bar", onNavBar); - + ipcMain.removeHandler('nav-bar'); // avoid setting duplicate handlers + ipcMain.handle('nav-bar', onNavBar); + // Gateway for messages to the frontend. - backgroundMitt.removeAllListeners("ipc-renderer") - backgroundMitt.on("ipc-renderer", postToWindow); - + backgroundMitt.on('ipc-renderer', renderMessage); } // Do this on window dismount (close). @@ -92,21 +76,21 @@ const onWindowDismount = (): void => { } // function used by run.ts to create the main window. -export default async function createWindow(): Promise { +export async function createWindow(): Promise { return new Promise((resolve, _reject) => { // avoid creating duplicate windows. if (win) resolve(); // Load the saved window state. - winState = loadWinState("window.json") + const state = JSON.parse(fs.readFileSync('windowState.json').toString()); // Define the browser window. win = new BrowserWindow({ - width: winState.width, - height: winState.height, - x: winState.x as number, - y: winState.y as number, + width: state.w, + height: state.h, + x: state.x, + y: state.y, resizable: true, backgroundColor: '#EBEBEB', frame: false, @@ -120,7 +104,7 @@ export default async function createWindow(): Promise { // Load the URL. if (process.env.WEBPACK_DEV_SERVER_URL) { win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string); // dev - } + } else { createProtocol("app"); @@ -135,11 +119,9 @@ export default async function createWindow(): Promise { win.on("resize", saveWindowState); win.once('ready-to-show', () => { - autoUpdater.checkForUpdatesAndNotify(); if (win) win.show() }) - // For showing/hiding the splash screen. win.webContents.on('did-finish-load', () => { if (win) win.webContents.send('window-ready', { message: true @@ -151,11 +133,3 @@ export default async function createWindow(): Promise { }); } - -autoUpdater.on('update-available', () => { - if (win) win.webContents.send('update_available'); -}); - -autoUpdater.on('update-downloaded', () => { - if (win) win.webContents.send('update_downloaded'); -}); diff --git a/src/components/inputItem/controllers/audioCtrl.ts b/src/components/inputItem/controllers/audioCtrl.ts new file mode 100644 index 0000000..9b2df9f --- /dev/null +++ b/src/components/inputItem/controllers/audioCtrl.ts @@ -0,0 +1,105 @@ +import anime from "animejs"; +import { onMounted, onUnmounted, ref, Ref } from "vue"; +import useMitt from "@/modules/mitt"; +import { useIpc } from '@/modules/ipc'; +import keyboardNameMap from "../keyBoardMaps/keyboardNameMap"; +import { renderMessage } from '@/modules/message'; + + +function showRecIcon () { + + anime({ + targets: '#recIcon', + opacity: [0, 0.75], + scale: [0.0, 1], + duration: 250, + easing: 'linear', + }) + +} + +function hideRecIcon () { + + anime({ + targets: '#recIcon', + opacity: [0.75, 0], + scale: [1, 0], + duration: 250, + easing: 'linear', + }) + +} + + +export default function useAudioInputController (typing: Ref) { + + // For sending messages. + const { post } = useIpc(); + const { emitter } = useMitt(); + + // Keepp track of when we are recording. + const recording = ref(false); + + //---Callbacks----------------------------------------------- + + const onKeyDown = (e: KeyboardEvent) => { + const cmd = keyboardNameMap[e.keyCode]; + // console.log(cmd) + + // Start recording on space bar. + if (cmd == "SPACE" && !recording.value && !typing.value) { + + post('update-recorder', { + isRecording: true, + uid: null + }); + + showRecIcon() + recording.value = true; + console.log("Recording...") + + } + + } + + const onKeyUp = (e: KeyboardEvent) => { + const cmd = keyboardNameMap[e.keyCode]; + + // Stop recording on space up. + if (cmd == "SPACE" && recording.value) { + + // Render audio immediately. + const message = renderMessage("", "", "", "sf") + emitter.emit("self-message", message); + + post('update-recorder', { + isRecording: false, + uid: message.uid + }); + + hideRecIcon() + recording.value = false; + console.log("Stopping record...") + + } + + } + + //----------------------------------------------------------- + + onMounted(() => { + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + }) + + onUnmounted(() => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }); + + + return { + recording + } + +} diff --git a/src/components/inputItem/controllers/textCtrl.ts b/src/components/inputItem/controllers/textCtrl.ts new file mode 100644 index 0000000..6a3c22d --- /dev/null +++ b/src/components/inputItem/controllers/textCtrl.ts @@ -0,0 +1,174 @@ +import anime from "animejs"; +import useMitt from "@/modules/mitt"; +import { useIpc } from '@/modules/ipc'; +import { Ref, ref, watch, onMounted, onUnmounted } from "vue"; +import keyboardNameMap from "../keyBoardMaps/keyboardNameMap"; +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) { + let textInput: HTMLInputElement | null; + + const { post } = useIpc(); + const { emitter } = useMitt(); + + let firstKey = true; + const typing = ref(false); + + // Prep inputItem for typing. + const prepInput = () => { + showTextInput() + typing.value = true + } + + // Clear and hide inputItem after done typing. + const clearInput = () => { + + if (textInput) { + textInput.value = ""; + textInput.blur(); + } + + hideTextInput() + firstKey = true; + typing.value = false; + } + + // Send a message and clean up after. + const sendMessage = () => { + if (textInput) { + + // Render message + const message = renderMessage(textInput.value, false, "", "sf") + emitter.emit("self-message", message); + + // Send it to the backend for processing. + const clientM = clientMessage(textInput.value, false, message.uid); + post('client-message', clientM); + + clearInput() + } + } + + //---Callbacks----------------------------------------------- + + const onKeyDown = (e: KeyboardEvent) => { + const key = keyboardNameMap[e.keyCode] + + if (textInput) { + + // First-key handler. + if (firstKey) { + if (key == "SPACE") return + prepInput() + } + + textInput.focus(); + + // Close input when no text or on ESC. + if (textInput.value == "" && !firstKey) { + clearInput() + return + } + + if (key === "ESCAPE") { + clearInput() + return + } + + // Close and send on enter. + if (key === "ENTER") { + if (textInput.value) { + sendMessage() + return + } + } + + if (firstKey) firstKey = false + } + } + + //----------------------------------------------------------- + + // Watch parent position and update side. + watch(elementX, (elementX, previous) => { + const winW = window.innerWidth + + // Logic depends on the side we are on. + if (side === "right") { + if (winW - elementX < 230) { + switchSide(side) + side = "left" + } + } + + else { + if (winW - elementX > 230) { + switchSide(side) + side = "right" + } + } + + }); + + onMounted(() => { + textInput = document.getElementById("textInput") as HTMLInputElement; + window.addEventListener("keydown", onKeyDown); + }) + + onUnmounted(() => { + window.removeEventListener("keydown", onKeyDown); + }); + + return { + typing + } + +} diff --git a/src/render/components/inputItem.vue b/src/components/inputItem/inputItem.vue similarity index 76% rename from src/render/components/inputItem.vue rename to src/components/inputItem/inputItem.vue index edd844f..c2d2c90 100644 --- a/src/render/components/inputItem.vue +++ b/src/components/inputItem/inputItem.vue @@ -6,17 +6,14 @@ :style="{ top: `${elementY}px`, left: `${elementX}px` }" >
{{ initials }}
- + - + @@ -26,22 +23,29 @@ + + \ No newline at end of file diff --git a/src/render/components/bubble.vue b/src/components/messageItem.vue similarity index 58% rename from src/render/components/bubble.vue rename to src/components/messageItem.vue index 982cef3..63c58bd 100644 --- a/src/render/components/bubble.vue +++ b/src/components/messageItem.vue @@ -1,52 +1,111 @@ -