From 4df736f59f8047e6fd50dcb5ea1e06944e8323a2 Mon Sep 17 00:00:00 2001 From: Matthew Holder Date: Thu, 31 Oct 2024 20:05:07 -0500 Subject: [PATCH 1/7] Added tRPC and moved many IPC system to tRPC --- PLAN.md | 5 +- package.json | 23 +- src/core/attachments.ts | 27 ++ src/core/rpc.ts | 21 + src/main/dao/sources.ts | 36 ++ src/main/dao/storage.ts | 44 +++ src/main/dao/switches.ts | 37 ++ src/main/dao/ties.ts | 38 ++ src/main/info/app.ts | 12 + src/main/info/config.ts | 15 + src/main/info/user.ts | 13 + src/main/main.ts | 18 +- src/main/plugins/ports.ts | 15 - src/main/routes/data/sources.ts | 26 ++ src/main/routes/data/storage.ts | 25 ++ src/main/routes/data/switches.ts | 21 + src/main/routes/data/ties.ts | 23 ++ src/main/routes/ports.ts | 29 ++ src/main/routes/router.ts | 27 ++ src/main/server.ts | 42 ++ src/main/services/database.ts | 254 ++++++++++++ src/main/services/level.d.ts | 10 +- src/main/services/level.js | 167 ++++---- .../system => main/services}/ports.ts | 48 +-- src/main/services/trpc.ts | 8 + src/preload/api.d.ts | 89 +---- src/preload/index.ts | 6 +- src/preload/plugins/info/app.ts | 20 - src/preload/plugins/info/config.ts | 18 + src/preload/plugins/info/user.ts | 20 - src/preload/plugins/level.ts | 36 -- src/preload/plugins/ports.ts | 13 - src/preload/plugins/services.ts | 14 +- src/preload/plugins/{ => services}/driver.ts | 4 +- .../plugins/{info => services}/process.ts | 0 src/preload/plugins/{ => services}/startup.ts | 4 +- src/preload/plugins/{ => services}/system.ts | 4 +- src/preload/plugins/{ => services}/updates.ts | 4 +- src/renderer/BridgeCmdr.vue | 40 +- src/renderer/data/database.ts | 199 ---------- src/renderer/data/level.d.ts | 10 - src/renderer/data/level.js | 298 -------------- src/renderer/data/set.ts | 65 --- src/renderer/data/store.ts | 65 --- src/renderer/helpers/attachment.ts | 25 +- src/renderer/helpers/location.ts | 10 +- src/renderer/locales/en/messages.json | 1 - src/renderer/main.ts | 2 - src/renderer/modals/SourceDialog.vue | 7 +- src/renderer/modals/SwitchDialog.vue | 6 +- src/renderer/modals/TieDialog.vue | 10 +- src/renderer/pages/FirstRunLogic.vue | 20 +- src/renderer/pages/GeneralPage.vue | 27 +- src/renderer/pages/MainDashboard.vue | 4 +- src/renderer/pages/SettingsBackupPage.vue | 6 +- src/renderer/pages/SettingsPage.vue | 26 +- src/renderer/pages/SourceList.vue | 6 +- src/renderer/pages/SourcePage.vue | 26 +- src/renderer/pages/SwitchList.vue | 10 +- .../{system => services}/appUpdates.ts | 0 .../{data => services}/backup/export.ts | 8 +- .../backup/formats/version0.ts | 0 .../backup/formats/version1.ts | 0 .../backup/formats/version2.ts | 2 +- .../{data => services}/backup/import.ts | 15 +- .../{stores => services}/dashboard.ts | 16 +- src/renderer/{system => services}/driver.ts | 2 +- src/renderer/services/ports.ts | 27 ++ src/renderer/services/rpc.ts | 11 + src/renderer/{stores => services}/settings.ts | 2 +- src/renderer/services/source.ts | 59 +++ src/renderer/{data => services}/storage.ts | 97 ++--- src/renderer/services/store.ts | 131 +++++++ src/renderer/services/switch.ts | 54 +++ src/renderer/services/tie.ts | 64 +++ .../{utilities => services}/tracking.ts | 0 src/renderer/system/source.ts | 57 --- src/renderer/system/switch.ts | 67 ++-- src/renderer/system/tie.ts | 73 ---- src/tests/drivers/extron/sis.test.ts | 10 +- src/tests/drivers/sony/rs485.test.ts | 10 +- src/tests/drivers/tesla-smart/kvm.test.ts | 10 +- src/tests/drivers/tesla-smart/matrix.test.ts | 10 +- src/tests/drivers/tesla-smart/sdi.test.ts | 10 +- src/tests/env.test.ts | 44 +-- src/tests/level.test.ts | 18 +- src/tests/ports.test.ts | 194 +++++---- src/tests/support/mock.ts | 13 +- src/tests/support/serial.ts | 4 - yarn.lock | 370 ++++++------------ 90 files changed, 1735 insertions(+), 1722 deletions(-) create mode 100644 src/core/attachments.ts create mode 100644 src/core/rpc.ts create mode 100644 src/main/dao/sources.ts create mode 100644 src/main/dao/storage.ts create mode 100644 src/main/dao/switches.ts create mode 100644 src/main/dao/ties.ts create mode 100644 src/main/info/app.ts create mode 100644 src/main/info/config.ts create mode 100644 src/main/info/user.ts delete mode 100644 src/main/plugins/ports.ts create mode 100644 src/main/routes/data/sources.ts create mode 100644 src/main/routes/data/storage.ts create mode 100644 src/main/routes/data/switches.ts create mode 100644 src/main/routes/data/ties.ts create mode 100644 src/main/routes/ports.ts create mode 100644 src/main/routes/router.ts create mode 100644 src/main/server.ts create mode 100644 src/main/services/database.ts rename src/{renderer/system => main/services}/ports.ts (69%) create mode 100644 src/main/services/trpc.ts delete mode 100644 src/preload/plugins/info/app.ts create mode 100644 src/preload/plugins/info/config.ts delete mode 100644 src/preload/plugins/info/user.ts delete mode 100644 src/preload/plugins/level.ts delete mode 100644 src/preload/plugins/ports.ts rename src/preload/plugins/{ => services}/driver.ts (87%) rename src/preload/plugins/{info => services}/process.ts (100%) rename src/preload/plugins/{ => services}/startup.ts (79%) rename src/preload/plugins/{ => services}/system.ts (93%) rename src/preload/plugins/{ => services}/updates.ts (87%) delete mode 100644 src/renderer/data/database.ts delete mode 100644 src/renderer/data/level.d.ts delete mode 100644 src/renderer/data/level.js delete mode 100644 src/renderer/data/set.ts delete mode 100644 src/renderer/data/store.ts rename src/renderer/{system => services}/appUpdates.ts (100%) rename src/renderer/{data => services}/backup/export.ts (90%) rename src/renderer/{data => services}/backup/formats/version0.ts (100%) rename src/renderer/{data => services}/backup/formats/version1.ts (100%) rename src/renderer/{data => services}/backup/formats/version2.ts (84%) rename src/renderer/{data => services}/backup/import.ts (89%) rename src/renderer/{stores => services}/dashboard.ts (93%) rename src/renderer/{system => services}/driver.ts (98%) create mode 100644 src/renderer/services/ports.ts create mode 100644 src/renderer/services/rpc.ts rename src/renderer/{stores => services}/settings.ts (98%) create mode 100644 src/renderer/services/source.ts rename src/renderer/{data => services}/storage.ts (52%) create mode 100644 src/renderer/services/store.ts create mode 100644 src/renderer/services/switch.ts create mode 100644 src/renderer/services/tie.ts rename src/renderer/{utilities => services}/tracking.ts (100%) delete mode 100644 src/renderer/system/source.ts delete mode 100644 src/renderer/system/tie.ts diff --git a/PLAN.md b/PLAN.md index fe6f672..8cf22e7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,8 +1,9 @@ - Milestones - v2.1 - Switch the majority of the IPC using tRPC. - - Level uses a streaming IPC, so leave it be for now. - - Anything passing a file may still require some custom hanlding. + - Updater will require websocket for subscriptions. + - System will require moving the open and save file support to DOM APIs. + - Drivers will require an overhaul to no longer need handles. - More drivers. - Move more modules to core. - Drivers diff --git a/package.json b/package.json index 6e7c6b5..3a08a0b 100644 --- a/package.json +++ b/package.json @@ -66,13 +66,14 @@ "@mdi/svg": "^7.4.47", "@sindresorhus/is": "^7.0.1", "@sixxgate/lint": "^3.2.1", + "@trpc/client": "^10.45.2", + "@trpc/server": "^10.45.2", "@tsconfig/node20": "^20.1.4", "@tsconfig/strictest": "^2.0.5", - "@types/abstract-leveldown": "^7.2.5", "@types/duplexify": "^3.6.4", "@types/eslint": "^8.56.12", "@types/ini": "^4.1.1", - "@types/level": "^6.0.3", + "@types/leveldown": "^4.0.6", "@types/levelup": "^5.1.5", "@types/node": "^20.16.15", "@types/pouchdb-core": "^7.0.15", @@ -83,7 +84,7 @@ "@typescript-eslint/parser": "^8.7.0", "@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue-jsx": "^4.0.1", - "@vitest/coverage-v8": "^2.1.3", + "@vitest/coverage-v8": "^2.1.4", "@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-typescript": "^13.0.0", "@vue/tsconfig": "^0.5.1", @@ -91,8 +92,7 @@ "@vuelidate/validators": "^2.0.4", "@vueuse/core": "^11.1.0", "@vueuse/shared": "^11.1.0", - "@zip.js/zip.js": "^2.7.52", - "abstract-leveldown": "^7.2.0", + "@zip.js/zip.js": "^2.7.53", "assert": "^2.1.0", "auto-bind": "^5.0.1", "buffer": "^6.0.3", @@ -115,12 +115,12 @@ "execa": "^9.4.1", "husky": "^9.1.6", "ini": "^4.1.3", + "js-base64": "^3.7.7", "levelup": "^5.1.1", "mime": "^4.0.4", - "multileveldown": "^5.0.1", - "npm-check-updates": "^17.1.6", + "npm-check-updates": "^17.1.9", "npm-run-all2": "^6.2.6", - "pinia": "^2.2.4", + "pinia": "^2.2.5", "pouchdb-adapter-leveldb-core": "^9.0.0", "pouchdb-core": "^9.0.0", "pouchdb-find": "^9.0.0", @@ -129,6 +129,7 @@ "sass": "^1.79.6", "setimmediate": "^1.0.5", "stream-browserify": "^3.0.0", + "superjson": "^2.2.1", "tslib": "^2.7.0", "type-fest": "^4.26.1", "typescript": "^5.6.3", @@ -139,12 +140,12 @@ "vite-plugin-vue-devtools": "^7.4.6", "vite-plugin-vuetify": "^2.0.4", "vite-tsconfig-paths": "^5.0.1", - "vitest": "^2.1.3", + "vitest": "^2.1.4", "vue": "^3.5.12", "vue-eslint-parser": "^9.4.3", "vue-i18n": "^9.14.1", "vue-router": "^4.4.5", - "vue-tsc": "^2.1.8", + "vue-tsc": "^2.1.10", "vuetify": "^3.7.3", "xdg-basedir": "^5.1.0", "zod": "^3.23.8" @@ -152,7 +153,7 @@ "dependencies": { "@electron-toolkit/utils": "^3.0.0", "electron-log": "^5.2.0", - "level": "^7.0.1", + "leveldown": "^6.1.1", "serialport": "^12.0.0" } } diff --git a/src/core/attachments.ts b/src/core/attachments.ts new file mode 100644 index 0000000..1b9efe6 --- /dev/null +++ b/src/core/attachments.ts @@ -0,0 +1,27 @@ +export class Attachment extends Uint8Array { + readonly name + readonly type + + static async fromFile(file: File) { + return new Attachment(file.name, file.type, await file.arrayBuffer()) + } + + static async fromPouchAttachment(name: string, attachment: PouchDB.Core.FullAttachment) { + if (Buffer.isBuffer(attachment.data)) { + return new Attachment(name, attachment.content_type, attachment.data.buffer) + } + + if (attachment.data instanceof Blob) { + return new Attachment(name, attachment.content_type, await attachment.data.arrayBuffer()) + } + + const textEncoder = new TextEncoder() + return new Attachment(name, attachment.content_type, textEncoder.encode(attachment.data)) + } + + constructor(name: string, type: string, data: ArrayBufferLike) { + super(data) + this.name = name + this.type = type + } +} diff --git a/src/core/rpc.ts b/src/core/rpc.ts new file mode 100644 index 0000000..c0abf0d --- /dev/null +++ b/src/core/rpc.ts @@ -0,0 +1,21 @@ +import { Base64 } from 'js-base64' +import { SuperJSON } from 'superjson' +import { Attachment } from './attachments' + +export default function useSuperJson() { + SuperJSON.registerCustom( + { + isApplicable: (v) => v instanceof Attachment, + serialize: (attachment) => ({ + name: attachment.name, + type: attachment.type, + data: Base64.fromUint8Array(attachment) + }), + deserialize: (attachment) => + new Attachment(attachment.name, attachment.type, Base64.toUint8Array(attachment.data)) + }, + 'Attachment' + ) + + return SuperJSON +} diff --git a/src/main/dao/sources.ts b/src/main/dao/sources.ts new file mode 100644 index 0000000..c71c86d --- /dev/null +++ b/src/main/dao/sources.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { defineDatabase, getInsertable, getUpdateable } from '../services/database' +import useTiesDatabase from './ties' +import type { getDocument, DocumentId } from '../services/database' + +export type Source = getDocument +export const Source = z.object({ + title: z.string().min(1), + image: z.string().min(1).nullable() +}) + +export const useSourcesDatabase = defineDatabase({ + name: 'sources', + schema: Source, + setup: (base) => { + const ties = useTiesDatabase() + + return { + remove: async (id: DocumentId) => { + await base.remove(id) + + const related = await ties.forSource(id) + await Promise.all( + related.map(async ({ _id }) => { + await ties.remove(_id) + }) + ) + } + } + } +}) + +export type NewSource = getInsertable +export const NewSource = getInsertable(Source) +export type SourceUpdate = getUpdateable +export const SourceUpdate = getUpdateable(Source) diff --git a/src/main/dao/storage.ts b/src/main/dao/storage.ts new file mode 100644 index 0000000..5e84a9a --- /dev/null +++ b/src/main/dao/storage.ts @@ -0,0 +1,44 @@ +import { memo } from 'radash' +import { useLevelDb } from '../services/level' + +export type UserStore = ReturnType +const useUserStore = memo(function useUserStore() { + const { levelup } = useLevelDb() + + const booted = levelup('_userStorage') + + function defineOperation( + op: (db: Awaited, ...args: Args) => Promise + ) { + return async (...args: Args) => await op(await booted, ...args) + } + + const getItem = defineOperation(async function getItem(db, key: string) { + try { + return (await db.get(key, { asBuffer: false })) as string + } catch { + return null + } + }) + + const setItem = defineOperation(async function setItem(db, key: string, value: string) { + await db.put(key, value) + }) + + const removeItem = defineOperation(async function removeItem(db, key: string) { + await db.del(key) + }) + + const clear = defineOperation(async function clear(db) { + await db.clear() + }) + + return { + getItem, + setItem, + removeItem, + clear + } +}) + +export default useUserStore diff --git a/src/main/dao/switches.ts b/src/main/dao/switches.ts new file mode 100644 index 0000000..dd6032f --- /dev/null +++ b/src/main/dao/switches.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { defineDatabase, DocumentId, getInsertable, getUpdateable } from '../services/database' +import useTiesDatabase from './ties' +import type { getDocument } from '../services/database' + +export type Switch = getDocument +export const Switch = z.object({ + driverId: DocumentId, + title: z.string().min(1), + path: z.string().min(1) +}) + +export const useSwitchesDatabase = defineDatabase({ + name: 'switches', + schema: Switch, + setup: (base) => { + const ties = useTiesDatabase() + + return { + remove: async (id: DocumentId) => { + await base.remove(id) + + const related = await ties.forSwitch(id) + await Promise.all( + related.map(async ({ _id }) => { + await ties.remove(_id) + }) + ) + } + } + } +}) + +export type NewSwitch = getInsertable +export const NewSwitch = getInsertable(Switch) +export type SwitchUpdate = getUpdateable +export const SwitchUpdate = getUpdateable(Switch) diff --git a/src/main/dao/ties.ts b/src/main/dao/ties.ts new file mode 100644 index 0000000..e447cab --- /dev/null +++ b/src/main/dao/ties.ts @@ -0,0 +1,38 @@ +import { map } from 'radash' +import { z } from 'zod' +import { defineDatabase, DocumentId, getInsertable, getUpdateable } from '../services/database' +import type { getDocument } from '../services/database' + +export type Tie = getDocument +export const Tie = z.object({ + sourceId: DocumentId, + switchId: DocumentId, + inputChannel: z.number().int().nonnegative(), + outputChannels: z.object({ + video: z.number().int().nonnegative().optional(), + audio: z.number().int().nonnegative().optional() + }) +}) + +const useTiesDatabase = defineDatabase({ + name: 'ties', + schema: Tie, + indices: [{ sourceId: ['sourceId'], switchId: ['switchId'] }], + setup: (base) => ({ + forSwitch: base.defineOperation( + async (db, switchId: DocumentId) => + await db.find({ selector: { switchId } }).then(async (r) => await map(r.docs, base.prepare)) + ), + forSource: base.defineOperation( + async (db, sourceId: DocumentId) => + await db.find({ selector: { sourceId } }).then(async (r) => await map(r.docs, base.prepare)) + ) + }) +}) + +export type NewTie = getInsertable +export const NewTie = getInsertable(Tie) +export type TieUpdate = getUpdateable +export const TieUpdate = getUpdateable(Tie) + +export default useTiesDatabase diff --git a/src/main/info/app.ts b/src/main/info/app.ts new file mode 100644 index 0000000..10243ba --- /dev/null +++ b/src/main/info/app.ts @@ -0,0 +1,12 @@ +import { app } from 'electron' +import { memo } from 'radash' + +/** Basic application information. */ +export type AppInfo = ReturnType + +const useAppInfo = memo(() => ({ + name: app.getName(), + version: app.getVersion() as `${number}.${number}.${number}` +})) + +export default useAppInfo diff --git a/src/main/info/config.ts b/src/main/info/config.ts new file mode 100644 index 0000000..19bb043 --- /dev/null +++ b/src/main/info/config.ts @@ -0,0 +1,15 @@ +import { memo } from 'radash' +import type { ReadonlyDeep } from 'type-fest' + +export type AppConfig = ReadonlyDeep> +const useAppConfig = memo(function useAppConfig() { + const config = { + rpcUrl: 'http://127.0.0.1:7180' + } + + process.env['rpc_url_'] = config.rpcUrl + + return config +}) + +export default useAppConfig diff --git a/src/main/info/user.ts b/src/main/info/user.ts new file mode 100644 index 0000000..1af19e5 --- /dev/null +++ b/src/main/info/user.ts @@ -0,0 +1,13 @@ +import os from 'node:os' +import { app } from 'electron' +import { memo } from 'radash' + +/** Basic user information. */ +export type UserInfo = ReturnType + +const useUserInfo = memo(() => ({ + name: os.userInfo().username, + locale: app.getLocale() +})) + +export default useUserInfo diff --git a/src/main/main.ts b/src/main/main.ts index 3fad07f..e784e83 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -4,12 +4,12 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { app, shell, BrowserWindow, nativeTheme } from 'electron' import Logger from 'electron-log' import appIcon from '../../resources/icon.png?asset&asarUnpack' +import useAppConfig from './info/config' import registerDrivers from './plugins/drivers' -import usePorts from './plugins/ports' import useCrypto from './plugins/webcrypto' +import useApiServer from './server' import useDrivers from './services/driver' import useHandles from './services/handle' -import useLevelServer from './services/level' import useStartup from './services/startup' import useSystem from './services/system' import useUpdater from './services/updater' @@ -37,7 +37,9 @@ async function createWindow() { useContentSize: true, webPreferences: { preload: joinPath(__dirname, '../preload/index.mjs'), - sandbox: false + sandbox: false, + // TODO: Properly setup CORS for the app. + webSecurity: false } }) @@ -84,11 +86,6 @@ async function createWindow() { throw logError(toError(lastError)) } -// Add application information. -process.env['app_version_'] = app.getVersion() -process.env['app_name_'] = app.getName() -process.env['user_locale_'] = app.getLocale() - // Let's change the web session path. const configDir = app.getPath('userData') app.setPath('sessionData', resolvePath(configDir, '.websession')) @@ -142,11 +139,12 @@ await app.whenReady() // Set app user model id for windows electronApp.setAppUserModelId('org.sleepingcats.BridgeCmdr') +useAppConfig() + +useApiServer() useCrypto() -usePorts() useUpdater() useSystem() -useLevelServer() useDrivers() registerDrivers() await useStartup() diff --git a/src/main/plugins/ports.ts b/src/main/plugins/ports.ts deleted file mode 100644 index be3eb91..0000000 --- a/src/main/plugins/ports.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ipcMain } from 'electron' -import { memo } from 'radash' -import { SerialPort } from 'serialport' -import { ipcProxy } from '../utilities' -import type { PortApi } from '../../preload/api' - -const usePorts = memo(function usePorts() { - ipcMain.handle('ports:list', ipcProxy(SerialPort.list)) - - return { - list: SerialPort.list - } satisfies PortApi -}) - -export default usePorts diff --git a/src/main/routes/data/sources.ts b/src/main/routes/data/sources.ts new file mode 100644 index 0000000..53099dd --- /dev/null +++ b/src/main/routes/data/sources.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { NewSource, SourceUpdate, useSourcesDatabase } from '../../dao/sources' +import { DocumentId } from '../../services/database' +import { procedure, router } from '../../services/trpc' +import { Attachment } from '@/attachments' + +export type { Source, NewSource, SourceUpdate } from '../../dao/sources' + +const InsertInputs = z.tuple([NewSource]).rest(z.instanceof(Attachment)) +const UpdateInputs = z.tuple([SourceUpdate]).rest(z.instanceof(Attachment)) + +export default function useSourcesRouter() { + const sources = useSourcesDatabase() + return router({ + compact: procedure.mutation(async () => { + await sources.compact() + }), + all: procedure.query(async () => await sources.all()), + get: procedure.input(DocumentId).query(async ({ input }) => await sources.get(input)), + add: procedure.input(InsertInputs).mutation(async ({ input }) => await sources.add(...input)), + update: procedure.input(UpdateInputs).mutation(async ({ input }) => await sources.update(...input)), + remove: procedure.input(DocumentId).mutation(async ({ input }) => { + await sources.remove(input) + }) + }) +} diff --git a/src/main/routes/data/storage.ts b/src/main/routes/data/storage.ts new file mode 100644 index 0000000..5ce4569 --- /dev/null +++ b/src/main/routes/data/storage.ts @@ -0,0 +1,25 @@ +import { memo } from 'radash' +import { z } from 'zod' +import useUserStore from '../../dao/storage' +import { procedure, router } from '../../services/trpc' + +const useUserStoreRouter = memo(function useUserStoreRouter() { + const storage = useUserStore() + + const SetItemInputs = z.tuple([z.string(), z.string()]) + + return router({ + getItem: procedure.input(z.string()).query(async ({ input }) => await storage.getItem(input)), + setItem: procedure.input(SetItemInputs).mutation(async ({ input }) => { + await storage.setItem(...input) + }), + removeItem: procedure.input(z.string()).mutation(async ({ input }) => { + await storage.removeItem(input) + }), + clear: procedure.mutation(async () => { + await storage.clear() + }) + }) +}) + +export default useUserStoreRouter diff --git a/src/main/routes/data/switches.ts b/src/main/routes/data/switches.ts new file mode 100644 index 0000000..d282b49 --- /dev/null +++ b/src/main/routes/data/switches.ts @@ -0,0 +1,21 @@ +import { NewSwitch, SwitchUpdate, useSwitchesDatabase } from '../../dao/switches' +import { DocumentId } from '../../services/database' +import { procedure, router } from '../../services/trpc' + +export { Switch, NewSwitch, SwitchUpdate } from '../../dao/switches' + +export default function useSourcesRouter() { + const switches = useSwitchesDatabase() + return router({ + compact: procedure.mutation(async () => { + await switches.compact() + }), + all: procedure.query(async () => await switches.all()), + get: procedure.input(DocumentId).query(async ({ input }) => await switches.get(input)), + add: procedure.input(NewSwitch).mutation(async ({ input }) => await switches.add(input)), + update: procedure.input(SwitchUpdate).mutation(async ({ input }) => await switches.update(input)), + remove: procedure.input(DocumentId).mutation(async ({ input }) => { + await switches.remove(input) + }) + }) +} diff --git a/src/main/routes/data/ties.ts b/src/main/routes/data/ties.ts new file mode 100644 index 0000000..cf1d821 --- /dev/null +++ b/src/main/routes/data/ties.ts @@ -0,0 +1,23 @@ +import useTiesDatabase, { NewTie, TieUpdate } from '../../dao/ties' +import { DocumentId } from '../../services/database' +import { procedure, router } from '../../services/trpc' + +export type { Tie, NewTie, TieUpdate } from '../../dao/ties' + +export default function useTiesRouter() { + const ties = useTiesDatabase() + return router({ + compact: procedure.mutation(async () => { + await ties.compact() + }), + all: procedure.query(async () => await ties.all()), + get: procedure.input(DocumentId).query(async ({ input }) => await ties.get(input)), + add: procedure.input(NewTie).mutation(async ({ input }) => await ties.add(input)), + update: procedure.input(TieUpdate).mutation(async ({ input }) => await ties.update(input)), + remove: procedure.input(DocumentId).mutation(async ({ input }) => { + await ties.remove(input) + }), + forSwitch: procedure.input(DocumentId).query(async ({ input }) => await ties.forSwitch(input)), + forSource: procedure.input(DocumentId).query(async ({ input }) => await ties.forSource(input)) + }) +} diff --git a/src/main/routes/ports.ts b/src/main/routes/ports.ts new file mode 100644 index 0000000..7f7d200 --- /dev/null +++ b/src/main/routes/ports.ts @@ -0,0 +1,29 @@ +import { memo } from 'radash' +import useSerialPorts from '../services/ports' +import { procedure, router } from '../services/trpc' + +// // HACK: Workaround legacy TypeDefinition from serialport PortInfo. +// export interface PortInfo { +// path: string +// manufacturer: string | undefined +// serialNumber: string | undefined +// pnpId: string | undefined +// locationId: string | undefined +// productId: string | undefined +// vendorId: string | undefined +// } + +// export interface PortEntry extends PortInfo { +// title: string +// value: string +// } + +const useSerialPortRouter = memo(function useSerialPortRouter() { + const ports = useSerialPorts() + + return router({ + list: procedure.query(ports.list) + }) +}) + +export default useSerialPortRouter diff --git a/src/main/routes/router.ts b/src/main/routes/router.ts new file mode 100644 index 0000000..d98494b --- /dev/null +++ b/src/main/routes/router.ts @@ -0,0 +1,27 @@ +import { memo } from 'radash' +import useAppInfo from '../info/app' +import useUserInfo from '../info/user' +import { createCallerFactory, procedure, router } from '../services/trpc' +import useSourcesRouter from './data/sources' +import useUserStoreRouter from './data/storage' +import useSwitchesRouter from './data/switches' +import useTiesRouter from './data/ties' +import useSerialPortRouter from './ports' + +export const useAppRouter = memo(function useAppRouter() { + return router({ + // Informational routes + appInfo: procedure.query(useAppInfo), + userInfo: procedure.query(useUserInfo), + // Functional service routes + ports: useSerialPortRouter(), + // Data service routes + storage: useUserStoreRouter(), + ties: useTiesRouter(), + switches: useSwitchesRouter(), + sources: useSourcesRouter() + }) +}) + +export type AppRouter = ReturnType +export const createCaller = createCallerFactory(useAppRouter()) diff --git a/src/main/server.ts b/src/main/server.ts new file mode 100644 index 0000000..f9a8784 --- /dev/null +++ b/src/main/server.ts @@ -0,0 +1,42 @@ +import { createHTTPServer } from '@trpc/server/adapters/standalone' +import Logger from 'electron-log' +import useAppConfig from './info/config' +import { useAppRouter } from './routes/router' + +function getServerUrl(url: URL): [host: string, port: number] { + Logger.log(url.pathname) + if (url.protocol !== 'http:') throw new TypeError('Only HTTP is supported') + if (url.pathname.length > 1) throw new TypeError('Server must be at the root') + if (url.search.length > 0) throw new TypeError('Query parameters mean nothing') + if (url.hash.length > 0) throw new TypeError('Query parameters mean nothing') + if (url.username.length > 0) throw new TypeError('Username currently unsupported') + if (url.password.length > 0) throw new TypeError('Password currently unsupported') + if (url.hostname.length === 0) return ['127.0.0.1', 7180] + if (url.port.length === 0) return [url.hostname, 7180] + + const port = Number(url.port) + if (Number.isNaN(port)) throw new TypeError(`${url.port} is not a valid port`) + + return [url.hostname, port] +} + +export default function useApiServer() { + const config = useAppConfig() + const url = new URL(config.rpcUrl) + const [host, port] = getServerUrl(url) + + // TODO: Authentication via the IPC, later we'll implement a proper authentication model. + + const httpServer = createHTTPServer({ + router: useAppRouter() + }) + + httpServer.listen(port, host) + httpServer.server.on('listening', () => { + Logger.info(`RPC server at ${url}`) + }) + + process.on('exit', () => { + httpServer.server.close() + }) +} diff --git a/src/main/services/database.ts b/src/main/services/database.ts new file mode 100644 index 0000000..487cde7 --- /dev/null +++ b/src/main/services/database.ts @@ -0,0 +1,254 @@ +import PouchDb from 'pouchdb-core' +import find from 'pouchdb-find' +import { map, memo } from 'radash' +import { v4 as uuid } from 'uuid' +import { z } from 'zod' +import { useLevelAdapter } from './level' +import type { IterableElement, Simplify } from 'type-fest' +import { Attachment } from '@/attachments' +import { isNotNullish } from '@/basics' + +type IndexFields = string[] +type IndexList = IndexFields[] +type NamedIndices = Record + +export type Indices = IndexList | NamedIndices + +export type DocumentId = z.output +export const DocumentId = z + .string() + .uuid() + .transform((value) => value.toUpperCase()) +export type RevisionId = z.output +export const RevisionId = z.string().min(1) + +export type BaseDocument = Simplify>> +export type Database = ReturnType> +export type DocumentOf = Simplify< + IterableElement['all']>>> +> + +function isFullAttachment(value: PouchDB.Core.Attachment): value is PouchDB.Core.FullAttachment { + return 'data' in value +} + +async function translateAttachment(key: string, attachment: PouchDB.Core.FullAttachment) { + return await Attachment.fromPouchAttachment(key, attachment) +} + +async function prepareAttachment(key: string, attachment: PouchDB.Core.Attachment) { + return isFullAttachment(attachment) ? await translateAttachment(key, attachment) : null +} + +async function prepareAttachments(attachments: PouchDB.Core.Attachment[] | null | undefined) { + if (attachments == null) return [] + return ( + await map(Object.entries(attachments), async ([key, attachment]) => await prepareAttachment(key, attachment)) + ).filter(isNotNullish) +} + +export async function prepareDocument>(doc: T) { + const { _attachments, _conflicts, _revs_info, _revisions, ...document } = doc + const result = { ...document, _attachments: await prepareAttachments(_attachments as never) } + return result as Simplify +} + +PouchDb.plugin(useLevelAdapter()) +PouchDb.plugin(find) + +export type getDocument = Simplify>>> +export function getDocument(schema: Schema) { + return schema.and( + z.object({ + _id: DocumentId, + _rev: RevisionId, + _attachments: z.array(z.instanceof(Attachment)) + }) + ) +} + +export type getInsertable = Simplify>>> +export function getInsertable(schema: Schema) { + return schema +} + +export type getUpdateable = Simplify>>> +export function getUpdateable(schema: Schema) { + type Shape = Schema['shape'] + // HACK: Partial looses the shape in this generic context. + const partial = schema.partial() as z.ZodObject<{ [K in keyof Shape]: z.ZodOptional }, 'strip'> + return partial.and(z.object({ _id: DocumentId })) +} + +function defineDatabaseCore( + name: string, + rawSchema: RawSchema, + ...indicesBlocks: Indices[] +) { + type RawDocument = z.output + type PouchDatabase = InstanceType> + type Insertable = getInsertable + type Updateable = getUpdateable + + const booted = (async function booted() { + const db = new PouchDb(name) + const namedIndices = new Map() + const basicIndices: IndexFields[] = [] + + // Record the indices + for (const indices of indicesBlocks) { + if (Array.isArray(indices)) { + basicIndices.push(...indices) + } else { + for (const [key, value] of Object.entries(indices)) { + namedIndices.set(key, value) + } + } + } + + for (const fields of basicIndices) { + // eslint-disable-next-line no-await-in-loop -- Should be serialized. + await db.createIndex({ index: { fields } }) + } + + for (const [index, fields] of namedIndices) { + // eslint-disable-next-line no-await-in-loop -- Should be serialized. + await db.createIndex({ index: { fields, name: index } }) + } + + return db + })() + + /** + * Compacts the database. + */ + async function compact() { + const db = await booted + + await db.compact() + } + + /** + * Provides a means to tap into the database interface directly. + */ + const query = async (callback: (current: PouchDatabase) => Promise) => await callback(await booted) + + /** + * Defines a database operations. + */ + const defineOperation = + (op: (current: PouchDatabase, ...args: Args) => Promise) => + async (...args: Args) => + await op(await booted, ...args) + + /** + * Gets all document from the database. + */ + const all = defineOperation(async function all(db) { + const response = await db.allDocs({ + include_docs: true, + attachments: true, + binary: true, + // Since we use GUIDs, the first character will be between these values. + startkey: '0', + endkey: 'Z' + }) + + return await map(response.rows.map((row) => row.doc).filter(isNotNullish), prepareDocument) + }) + + /** + * Gets the specified document from the database. + */ + const get = defineOperation(async function get(db, id: DocumentId) { + return await db.get(id.toUpperCase(), { attachments: true, binary: true }).then(prepareDocument) + }) + + /** + * Adds attachments to a document. + */ + const addAttachments = defineOperation(async function addAttachments(db, id: DocumentId, attachments: Attachment[]) { + // Add each attachment one-at-a-time, this must be serial. + for (const attachment of attachments) { + // eslint-disable-next-line no-await-in-loop -- Must be serialized. + const doc = await db.get(id) + // eslint-disable-next-line no-await-in-loop -- Must be serialized. + await db.putAttachment(id, attachment.name, doc._rev, Buffer.from(attachment), attachment.type) + } + }) + + /** + * Adds a document to the database. + */ + const add = defineOperation(async function add(db, document: Insertable, ...attachments: Attachment[]) { + const doc = { ...document, _id: uuid().toUpperCase() } + await db.put(doc) + if (attachments.length > 0) { + await addAttachments(doc._id, attachments) + } + + return await get(doc._id) + }) + + /** + * Updates an existing document in the database. + */ + const update = defineOperation(async function update(db, document: Updateable, ...attachments: Attachment[]) { + const id = document._id + const old = await db.get(id) + const doc = { ...document, _rev: old._rev } + + await db.put(doc) + if (attachments.length > 0) { + await addAttachments(id, attachments) + } + + return await get(id) + }) + + /** + * Removes a document from the database. + */ + const remove = defineOperation(async function remove(db, id: DocumentId) { + const doc = await db.get(id) + await db.remove(doc) + }) + + return { + $name: name, + $schemas: rawSchema, + prepare: prepareDocument>, + defineOperation, + compact, + query, + all, + get, + add, + update, + remove + } +} + +type DatabaseCore = ReturnType> + +interface DefineDatabaseOptions> { + name: string + schema: Schema + indices?: Indices[] + setup: (base: DatabaseCore) => Interface +} + +export const defineDatabase = >( + options: DefineDatabaseOptions +) => + memo(function $defineDatabase() { + const { name, schema, indices = [], setup } = options + + const base = defineDatabaseCore(name, schema, ...indices) + const augment = setup(base) + + return { + ...base, + ...augment + } + }) diff --git a/src/main/services/level.d.ts b/src/main/services/level.d.ts index 81fbe07..1153d00 100644 --- a/src/main/services/level.d.ts +++ b/src/main/services/level.d.ts @@ -1 +1,9 @@ -export default function useLevelServer(): void +import { LevelDown } from 'leveldown' +import { LevelUp } from 'levelup' + +export const useLevelDb: () => { + leveldown: (name: string) => LevelDown + levelup: (name: string) => Promise> +} + +export const useLevelAdapter: () => PouchDB.Plugin diff --git a/src/main/services/level.js b/src/main/services/level.js index 0d99459..c6efdc7 100644 --- a/src/main/services/level.js +++ b/src/main/services/level.js @@ -1,113 +1,100 @@ import { resolve as resolvePath } from 'node:path' -import { Duplex, PassThrough, pipeline, Writable } from 'node:stream' -import { app, ipcMain } from 'electron' -import level from 'level' +import { app } from 'electron' +import levelDown from 'leveldown' +import levelUp from 'levelup' // @ts-expect-error -- No types -import multileveldown from 'multileveldown' +import LevelPouch from 'pouchdb-adapter-leveldb-core' import { memo } from 'radash' -import { ipcHandle, logError } from '../utilities' -import useHandles from './handle' -/** @typedef {`level:${string}`} Channel */ -/** @typedef {import('level').LevelDB} LevelDB */ +// +// NOTE: While PouchDB has a built-in LevelDB adapter, we want to have +// as minimum of an external footprint as possible. This will be +// done by using our own quick-and-dirty adapter that just +// uses the PouchDB built in adapter. +// -const useLevelServer = memo(function useLevelServer() { - const kLevelDatabaseHandle = - /** @type {import('./handle').HandleKey<{ channel: Channel; stream: import('node:stream').Duplex }>} */ - (Symbol.for('@level')) - const { createHandle, openHandle } = useHandles() +/** @template ALD @typedef {import('levelup').LevelUp} LevelUp */ +/** @typedef {(err: Error | undefined) => void} ErrorCallback */ +/** @typedef {import('leveldown').LevelDown} LevelDown */ - /** - * @param {string} path - */ - async function openDatabase(path) { - return await new Promise( - /** - * - * @param {(db: LevelDB) => void} resolve - * @param {(error: Error | undefined) => void} reject - */ - (resolve, reject) => { - const db = level(path, {}, (error) => { - if (error == null) resolve(db) - else reject(error) - }) - } - ) - } - - const open = ipcHandle( +export const useLevelDb = memo(function useLevelDb() { + const leveldown = memo( /** * @param {string} name */ - async function open(event, name) { - if (name.endsWith(':close')) { - throw logError(new SyntaxError("Database names cannot end in ':close'")) - } - - // Don't allow any path separating characters. - if (/[/\\.:]/u.test(name)) { - throw logError(new Error('Only a file name, without extension or relative path, may be specified')) - } - + function leveldown(name) { const path = resolvePath(app.getPath('userData'), name) + const db = levelDown(path) - const sender = event.sender - - const db = await openDatabase(path) - // eslint-disable-next-line -- No types, mo errors. - const host = multileveldown.server(db) - /** @type {Channel} */ - const channel = `level:${name}` - - const readable = new PassThrough() - const writable = new Writable({ - write: (chunk, _, next) => { - sender.send(channel, chunk) - next() - } - }) - - const stream = Duplex.from({ writable, readable }) - /** - * @param {*} _ - * @param {unknown} msg - */ - const receiver = (_, msg) => { - readable.write(msg) - } - - ipcMain.on(channel, receiver) - pipeline(stream, host, stream, () => { - stream.destroy() - }) - - const handle = createHandle(event, kLevelDatabaseHandle, { channel, stream }, async () => { - await db.close() - - // eslint-disable-next-line -- No types, mo errors. - host.destroy() - ipcMain.off(channel, receiver) - if (!event.sender.isDestroyed()) event.sender.send(`${channel}:close`) + app.on('before-quit', () => { + db.close((err) => { + if (err != null) console.error(err) + }) }) - return await Promise.resolve(handle) + return db } ) - const getChannel = ipcHandle( + const levelup = memo( /** - * @param {import('../../preload/api').Handle} handle - * @returns + * @param {string} name */ - async function getChannel(event, handle) { - const { channel } = openHandle(event, kLevelDatabaseHandle, handle) - return await Promise.resolve(channel) + async function levelup(name) { + const db = leveldown(name) + return await new Promise( + /** + * @param {(db: LevelUp) => void} resolve + * @param {(error: Error) => void} reject + */ + (resolve, reject) => { + /** + * @param {Error|undefined} error + */ + const cb = (error) => { + if (error == null) resolve(up) + else reject(error) + } + + const up = levelUp(db, cb) + } + ) } ) - ipcMain.handle('database:open', open) - ipcMain.handle('database:channel', getChannel) + return { + leveldown, + levelup + } }) -export default useLevelServer +export const useLevelAdapter = memo(function useLevelAdapter() { + const { leveldown } = useLevelDb() + + /** @typedef {Record} LevelPouch */ + + /** + * @this {Partial} + * @param {Record} opts + * @param {ErrorCallback} cb + */ + function MainDown(opts, cb) { + // eslint-disable-next-line -- Eveything is messed up with no typings. + LevelPouch.call(this, { ...opts, db: leveldown }, cb) + } + + MainDown.valid = function () { + return true + } + + MainDown.use_prefix = true + + /** @type {PouchDB.Plugin} */ + const plugin = (pouch) => { + // @ts-expect-error -- Not defined in the types. + // eslint-disable-next-line -- Eveything is messed up with no typings. + pouch.adapter('maindb', MainDown, true) + } + + return plugin +}) diff --git a/src/renderer/system/ports.ts b/src/main/services/ports.ts similarity index 69% rename from src/renderer/system/ports.ts rename to src/main/services/ports.ts index f403f6b..0ad9083 100644 --- a/src/renderer/system/ports.ts +++ b/src/main/services/ports.ts @@ -1,25 +1,32 @@ import is from '@sindresorhus/is' -import { createSharedComposable } from '@vueuse/shared' -import { ref, computed, readonly, reactive } from 'vue' -import { trackBusy } from '../utilities/tracking' +import { memo } from 'radash' +import { SerialPort } from 'serialport' -export interface PortData { +// HACK: Workaround legacy TypeDefinition from serialport PortInfo. +export interface PortInfo { + path: string + manufacturer: string | undefined + serialNumber: string | undefined + pnpId: string | undefined + locationId: string | undefined + productId: string | undefined + vendorId: string | undefined +} + +export interface PortEntry extends PortInfo { title: string value: string } -const usePorts = createSharedComposable(function usePorts() { - const tracker = trackBusy() - - const items = ref([]) +const useSerialPorts = memo(() => ({ + list: async () => { + const ports = (await SerialPort.list()) as PortInfo[] - const all = tracker.track(async function all() { - const ports = await services.ports.list() - - items.value = ports.map(function mapPortData(port) { + return ports.map(function parsePortInfo(port) { // If there is no PnP ID, then just use the path. if (!is.nonEmptyString(port.pnpId)) { return { + ...port, title: port.path, value: port.path } @@ -37,6 +44,7 @@ const usePorts = createSharedComposable(function usePorts() { let labelParts = port.pnpId.split('-') if (labelParts.length < 3) { return { + ...port, title: port.path, value: port.path } @@ -47,6 +55,7 @@ const usePorts = createSharedComposable(function usePorts() { const part = labelParts.at(-1) if (part == null) { return { + ...port, title: port.path, value: port.path } @@ -65,6 +74,7 @@ const usePorts = createSharedComposable(function usePorts() { labelParts = labelParts.slice(1) if (labelParts.length === 0) { return { + ...port, title: port.path, value: port.path } @@ -74,18 +84,12 @@ const usePorts = createSharedComposable(function usePorts() { // those were in the friendly name, and // replace underscores with spaces. return { + ...port, title: labelParts.join('-').replace(/_/gu, ' '), value: port.path } }) - }) - - return reactive({ - isBusy: tracker.isBusy, - error: tracker.error, - items: computed(() => readonly(items.value)), - all - }) -}) + } +})) -export default usePorts +export default useSerialPorts diff --git a/src/main/services/trpc.ts b/src/main/services/trpc.ts new file mode 100644 index 0000000..0a83fe2 --- /dev/null +++ b/src/main/services/trpc.ts @@ -0,0 +1,8 @@ +import { initTRPC } from '@trpc/server' +import useSuperJson from '@/rpc' + +const t = initTRPC.create({ + transformer: useSuperJson() +}) + +export const { router, procedure, createCallerFactory } = t diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index c99f3df..17d38e6 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -1,31 +1,26 @@ import type { Dialog, IpcRendererEvent, OpenDialogOptions, SaveDialogOptions } from 'electron' import type { ProgressInfo, UpdateInfo } from 'electron-updater' import type { ArrayTail, ReadonlyDeep, Tagged } from 'type-fest' +import type { AppConfig } from '../main/info/config' // -// Internal parts +// Exposed from main // -/** Defines an event handler callback for a specific type of event. */ -type EventHandlerCallback = (ev: E) => unknown -/** Defines an event handler object for a specific type of event. */ -interface EventHandlerObject { - handleEvent: EventHandlerCallback -} +export type { AppInfo } from '../main/info/app' +export type { UserInfo } from '../main/info/user' +export type { AppConfig } from '../main/info/config' +export type { AppRouter } from '../main/routes/router' +export type { DocumentId } from '../main/services/database' +export type { UserStore } from '../main/dao/storage' +export type { PortEntry } from '../main/services/ports' +export type { Source, NewSource, SourceUpdate } from '../main/dao/sources' +export type { Switch, NewSwitch, SwitchUpdate } from '../main/dao/switches' +export type { Tie, NewTie, TieUpdate } from '../main/dao/ties' -/** Defines an event handler for a specific type of event. */ -type EventHandler = EventHandlerCallback | EventHandlerObject - -/** Defines an event target for a specific type of event. */ -interface EventTargetEx extends EventTarget { - addEventListener(type: E['type'], callback: EventHandler | null): void - addEventListener(type: E['type'], callback: EventHandler | null, useCapture: boolean): void - addEventListener(type: E['type'], callback: EventHandler | null, options: EventListenerOptions): void - removeEventListener(type: E['type'], callback: EventHandler | null): void - removeEventListener(type: E['type'], callback: EventHandler | null, useCapture: boolean): void - removeEventListener(type: E['type'], callback: EventHandler | null, options: EventListenerOptions): void - dispatchEvent(event: E): boolean -} +// +// Internal parts +// /** Internal IPC response structure */ export interface IpcReturnedValue { @@ -129,27 +124,6 @@ export interface DriverApi { ) => Promise } -// -// Serial port API -// - -// HACK: Workaround legacy TypeDefinition from serialport PortInfo. -interface PortInfo { - path: string - manufacturer: string | undefined - serialNumber: string | undefined - pnpId: string | undefined - locationId: string | undefined - productId: string | undefined - vendorId: string | undefined -} - -/** Exposed serial port APIs. */ -export interface PortApi { - /** Lists available serial ports. */ - readonly list: () => Promise -} - // // Session control API // @@ -164,21 +138,6 @@ export interface SystemApi { readonly saveFile: (file: File, options: SaveDialogOptions) => Promise } -// -// LevelDown proxy API -// - -export type LevelKey = string | Buffer -export type LevelValue = string | Buffer - -export type Messanger = (message: unknown) => void - -/** Level RPC API. */ -export interface LevelApi { - readonly open: (name: string) => Promise - readonly activate: (h: Handle, receiver: Messanger) => Promise -} - // // Process data // @@ -208,8 +167,6 @@ export interface ProcessData { export interface MainProcessServices { readonly process: ProcessData readonly driver: DriverApi - readonly level: LevelApi - readonly ports: PortApi readonly startup: StartupApi readonly system: SystemApi readonly updates: AppUpdates @@ -219,18 +176,6 @@ export interface MainProcessServices { readonly freeAllHandles: () => Promise } -/** Basic application information. */ -export interface AppInfo { - readonly name: string - readonly version: `${number}.${number}.${number}` -} - -/** Basic user information. */ -export interface UserInfo { - readonly name: string - readonly locale: string -} - export interface AppUpdater { readonly checkForUpdates: () => Promise readonly downloadUpdate: () => Promise @@ -246,7 +191,5 @@ export interface AppUpdates extends AppUpdater { // The exposed API global structure declare global { var services: MainProcessServices - var application: AppInfo - var system: System - var user: UserInfo + var configuration: AppConfig } diff --git a/src/preload/index.ts b/src/preload/index.ts index 9c89af9..4385dc9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,6 @@ /* eslint-disable n/no-process-exit -- No real way to do this otherwise */ -import useAppInfo from './plugins/info/app' -import useUserInfo from './plugins/info/user' +import useAppConfig from './plugins/info/config' import useServices from './plugins/services' if (!process.contextIsolated) { @@ -18,8 +17,7 @@ try { }) }) - useAppInfo() - useUserInfo() + useAppConfig() } catch (e) { console.error('Preload error', e) process.exit(1) diff --git a/src/preload/plugins/info/app.ts b/src/preload/plugins/info/app.ts deleted file mode 100644 index f277fc4..0000000 --- a/src/preload/plugins/info/app.ts +++ /dev/null @@ -1,20 +0,0 @@ -import is from '@sindresorhus/is' -import { contextBridge } from 'electron' -import { memo } from 'radash' -import type { AppInfo } from '../../api' - -const useAppInfo = memo(function useAppInfo() { - if (!is.nonEmptyString(process.env['app_name_'])) throw new ReferenceError('Missing appInfo.name') - if (!is.nonEmptyString(process.env['app_version_'])) throw new ReferenceError('Missing appInfo.version') - - const appInfo = { - name: process.env['app_name_'], - version: process.env['app_version_'] as AppInfo['version'] - } satisfies AppInfo - - contextBridge.exposeInMainWorld('application', appInfo) - - return appInfo -}) - -export default useAppInfo diff --git a/src/preload/plugins/info/config.ts b/src/preload/plugins/info/config.ts new file mode 100644 index 0000000..8858d84 --- /dev/null +++ b/src/preload/plugins/info/config.ts @@ -0,0 +1,18 @@ +import is from '@sindresorhus/is' +import { contextBridge } from 'electron' +import { memo } from 'radash' +import type { AppConfig } from '../../api' + +const useAppConfig = memo(function useAppConfig() { + if (!is.nonEmptyString(process.env['rpc_url_'])) throw new ReferenceError('Missing appConfig.rpcUrl') + + const appConfig = { + rpcUrl: process.env['rpc_url_'] + } satisfies AppConfig + + contextBridge.exposeInMainWorld('configuration', appConfig) + + return appConfig +}) + +export default useAppConfig diff --git a/src/preload/plugins/info/user.ts b/src/preload/plugins/info/user.ts deleted file mode 100644 index edd024f..0000000 --- a/src/preload/plugins/info/user.ts +++ /dev/null @@ -1,20 +0,0 @@ -import is from '@sindresorhus/is' -import { contextBridge } from 'electron' -import { memo } from 'radash' -import type { UserInfo } from '../../api' - -const useUserInfo = memo(function useUserInfo() { - if (!is.nonEmptyString(process.env['USER'])) throw new ReferenceError('Missing user info') - if (!is.nonEmptyString(process.env['user_locale_'])) throw new ReferenceError('Missing locale info') - - const userInfo = { - name: process.env['USER'], - locale: process.env['user_locale_'] - } satisfies UserInfo - - contextBridge.exposeInMainWorld('user', userInfo) - - return userInfo -}) - -export default useUserInfo diff --git a/src/preload/plugins/level.ts b/src/preload/plugins/level.ts deleted file mode 100644 index bbec04c..0000000 --- a/src/preload/plugins/level.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ipcRenderer } from 'electron' -import { memo } from 'radash' -import { useIpc } from '../support' -import type { Handle, LevelApi, Messanger } from '../api' -import type { IpcRendererEvent } from 'electron' - -const useLevelApi = memo(function useLevelApi() { - const ipc = useIpc() - - type GetChannel = (h: Handle) => Promise<`level:${string}`> - const getChannel: GetChannel = ipc.useInvoke('database:channel') - - const activate = async (handle: Handle, receiver: Messanger): Promise => { - const channel = await getChannel(handle) - - const received = (_: IpcRendererEvent, message: unknown) => { - receiver(message) - } - - ipcRenderer.on(channel, received) - ipcRenderer.once(`${channel}:close`, () => { - ipcRenderer.off(channel, received) - }) - - return (message: unknown) => { - ipcRenderer.send(channel, message) - } - } - - return { - open: ipc.useInvoke('database:open'), - activate - } satisfies LevelApi -}) - -export default useLevelApi diff --git a/src/preload/plugins/ports.ts b/src/preload/plugins/ports.ts deleted file mode 100644 index e3da73d..0000000 --- a/src/preload/plugins/ports.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { memo } from 'radash' -import { useIpc } from '../support' -import type { PortApi } from '../api' - -const usePortsApi = memo(function usePortsApi() { - const ipc = useIpc() - - return { - list: ipc.useInvoke('ports:list') - } satisfies PortApi -}) - -export default usePortsApi diff --git a/src/preload/plugins/services.ts b/src/preload/plugins/services.ts index 954d209..9518ca8 100644 --- a/src/preload/plugins/services.ts +++ b/src/preload/plugins/services.ts @@ -1,13 +1,11 @@ import { contextBridge } from 'electron' import { memo } from 'radash' import { useIpc } from '../support' -import useDriverApi from './driver' -import useProcessData from './info/process' -import useLevelApi from './level' -import usePortsApi from './ports' -import useStartupApi from './startup' -import useSystemApi from './system' -import useAppUpdates from './updates' +import useDriverApi from './services/driver' +import useProcessData from './services/process' +import useStartupApi from './services/startup' +import useSystemApi from './services/system' +import useAppUpdates from './services/updates' import type { MainProcessServices } from '../api' const useServices = memo(function useServices() { @@ -16,8 +14,6 @@ const useServices = memo(function useServices() { const services = { process: useProcessData(), driver: useDriverApi(), - level: useLevelApi(), - ports: usePortsApi(), startup: useStartupApi(), system: useSystemApi(), updates: useAppUpdates(), diff --git a/src/preload/plugins/driver.ts b/src/preload/plugins/services/driver.ts similarity index 87% rename from src/preload/plugins/driver.ts rename to src/preload/plugins/services/driver.ts index 977df99..41404a1 100644 --- a/src/preload/plugins/driver.ts +++ b/src/preload/plugins/services/driver.ts @@ -1,6 +1,6 @@ import { memo } from 'radash' -import { useIpc } from '../support' -import type { DriverApi } from '../api' +import { useIpc } from '../../support' +import type { DriverApi } from '../../api' const useDriverApi = memo(function useDriverApi() { const ipc = useIpc() diff --git a/src/preload/plugins/info/process.ts b/src/preload/plugins/services/process.ts similarity index 100% rename from src/preload/plugins/info/process.ts rename to src/preload/plugins/services/process.ts diff --git a/src/preload/plugins/startup.ts b/src/preload/plugins/services/startup.ts similarity index 79% rename from src/preload/plugins/startup.ts rename to src/preload/plugins/services/startup.ts index 3447706..dc76282 100644 --- a/src/preload/plugins/startup.ts +++ b/src/preload/plugins/services/startup.ts @@ -1,6 +1,6 @@ import { memo } from 'radash' -import { useIpc } from '../support' -import type { StartupApi } from '../api' +import { useIpc } from '../../support' +import type { StartupApi } from '../../api' const useStartupApi = memo(function useStartupApi() { const ipc = useIpc() diff --git a/src/preload/plugins/system.ts b/src/preload/plugins/services/system.ts similarity index 93% rename from src/preload/plugins/system.ts rename to src/preload/plugins/services/system.ts index 959fc1a..d39efc2 100644 --- a/src/preload/plugins/system.ts +++ b/src/preload/plugins/services/system.ts @@ -1,7 +1,7 @@ import { basename } from 'node:path' import { memo } from 'radash' -import { useIpc } from '../support' -import type { SystemApi } from '../api' +import { useIpc } from '../../support' +import type { SystemApi } from '../../api' import type { FileData } from '@/struct' import type { OpenDialogOptions, SaveDialogOptions } from 'electron' diff --git a/src/preload/plugins/updates.ts b/src/preload/plugins/services/updates.ts similarity index 87% rename from src/preload/plugins/updates.ts rename to src/preload/plugins/services/updates.ts index f576d45..c8ef6ab 100644 --- a/src/preload/plugins/updates.ts +++ b/src/preload/plugins/services/updates.ts @@ -1,6 +1,6 @@ import { memo } from 'radash' -import { useIpc } from '../support' -import type { AppUpdates } from '../api' +import { useIpc } from '../../support' +import type { AppUpdates } from '../../api' const useAppUpdates = memo(function useAppUpdates(): AppUpdates { const ipc = useIpc() diff --git a/src/renderer/BridgeCmdr.vue b/src/renderer/BridgeCmdr.vue index 4c368ef..d8b3a24 100644 --- a/src/renderer/BridgeCmdr.vue +++ b/src/renderer/BridgeCmdr.vue @@ -1,15 +1,16 @@