From 75a27ea64869265666b9a7861015ec439b8eb0aa Mon Sep 17 00:00:00 2001 From: Matthew Holder <6XGate@users.noreply.github.com> Date: Thu, 31 Oct 2024 23:55:37 -0500 Subject: [PATCH 1/8] Added tRPC and moved many IPC system to tRPC (#81) - Added tRPC and moved many IPC system to tRPC. - Updated dependencies. - Added check actions. --- .github/workflows/coverage.yml | 34 + .github/workflows/lint.yml | 34 + .github/workflows/typecheck.yml | 36 + PLAN.md | 5 +- package.json | 61 +- src/core/attachments.ts | 27 + src/core/error-handling.ts | 2 +- 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/drivers/extron/sis.ts | 2 +- src/main/drivers/sony/rs485.ts | 6 +- src/main/drivers/tesla-smart/kvm.ts | 2 +- src/main/drivers/tesla-smart/matrix.ts | 2 +- src/main/drivers/tesla-smart/sdi.ts | 2 +- src/main/info/app.ts | 12 + src/main/info/config.ts | 15 + src/main/info/user.ts | 13 + src/main/main.ts | 20 +- 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 | 13 + src/main/routes/router.ts | 29 + src/main/routes/startup.ts | 18 + src/main/server.ts | 42 + src/main/services/database.ts | 254 ++++ src/main/{helpers => services}/dbus.ts | 2 +- src/main/{support => services}/desktop.ts | 0 src/main/services/level.d.ts | 10 +- src/main/services/level.js | 167 ++- .../system => main/services}/ports.ts | 48 +- src/main/{helpers => services}/sonyRs485.ts | 0 src/main/services/startup.ts | 13 +- src/main/{helpers => services}/stream.ts | 0 src/main/services/system.ts | 2 +- src/main/services/trpc.ts | 8 + src/preload/api.d.ts | 101 +- 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}/system.ts | 4 +- src/preload/plugins/{ => services}/updates.ts | 4 +- src/preload/plugins/startup.ts | 15 - 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 | 27 +- 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 | 11 +- src/renderer/services/sources.ts | 59 + src/renderer/services/startup.ts | 21 + src/renderer/{data => services}/storage.ts | 97 +- src/renderer/services/store.ts | 131 ++ src/renderer/services/switches.ts | 54 + src/renderer/services/ties.ts | 64 + .../{utilities => services}/tracking.ts | 0 src/renderer/system/source.ts | 57 - src/renderer/system/switch.ts | 59 - src/renderer/system/tie.ts | 73 -- src/tests/drivers/extron/sis.test.ts | 12 +- src/tests/drivers/sony/rs485.test.ts | 12 +- src/tests/drivers/tesla-smart/kvm.test.ts | 12 +- src/tests/drivers/tesla-smart/matrix.test.ts | 12 +- src/tests/drivers/tesla-smart/sdi.test.ts | 12 +- src/tests/env.test.ts | 70 +- src/tests/level.test.ts | 18 +- src/tests/ports.test.ts | 194 +-- src/tests/support/mock.ts | 13 +- src/tests/support/serial.ts | 4 - src/tests/support/stream.ts | 4 +- vite.config.ts | 1 + yarn.lock | 1086 ++++++++--------- 109 files changed, 2262 insertions(+), 2185 deletions(-) create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/typecheck.yml 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/routes/startup.ts create mode 100644 src/main/server.ts create mode 100644 src/main/services/database.ts rename src/main/{helpers => services}/dbus.ts (99%) rename src/main/{support => services}/desktop.ts (100%) rename src/{renderer/system => main/services}/ports.ts (69%) rename src/main/{helpers => services}/sonyRs485.ts (100%) rename src/main/{helpers => services}/stream.ts (100%) 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}/system.ts (93%) rename src/preload/plugins/{ => services}/updates.ts (87%) delete mode 100644 src/preload/plugins/startup.ts 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 (92%) create mode 100644 src/renderer/services/sources.ts create mode 100644 src/renderer/services/startup.ts rename src/renderer/{data => services}/storage.ts (52%) create mode 100644 src/renderer/services/store.ts create mode 100644 src/renderer/services/switches.ts create mode 100644 src/renderer/services/ties.ts rename src/renderer/{utilities => services}/tracking.ts (100%) delete mode 100644 src/renderer/system/source.ts delete mode 100644 src/renderer/system/switch.ts delete mode 100644 src/renderer/system/tie.ts diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..8664e60 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,34 @@ +name: Test and coverage checks + +on: + push: + branches: [$default-branch, $protected-branches] + pull_request: {} + +jobs: + eslint: + name: Run coverage scanning + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: 'Install Node' + uses: actions/setup-node@v4 + with: + node-version: 20 + check-latest: true + - name: Setup yarn + run: | + corepack enable yarn + corepack install + - name: Install dependencies + run: | + yarn --frozen-lockfile + - name: Check test coverage + run: | + mkdir logs + yarn coverage diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..d7b90f6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,34 @@ +name: Style and form checks + +on: + push: + branches: [$default-branch, $protected-branches] + pull_request: {} + +jobs: + eslint: + name: Run eslint scanning + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: 'Install Node' + uses: actions/setup-node@v4 + with: + node-version: 20 + check-latest: true + - name: Setup yarn + run: | + corepack enable yarn + corepack install + - name: Install dependencies + run: | + yarn --frozen-lockfile --ignore-scripts + - name: Check code style + run: | + yarn check:prettier + yarn check:lint diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..15a1f5f --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,36 @@ +name: TypeScript checks + +on: + push: + branches: [$default-branch, $protected-branches] + pull_request: {} + +jobs: + eslint: + name: Run type scanning + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: 'Install Node' + uses: actions/setup-node@v4 + with: + node-version: 20 + check-latest: true + - name: Setup yarn + run: | + corepack enable yarn + corepack install + - name: Install dependencies + run: | + yarn --frozen-lockfile --ignore-scripts + - name: Check types + run: | + yarn typecheck:config + yarn typecheck:node + yarn typecheck:web + yarn typecheck:test 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..54434a1 100644 --- a/package.json +++ b/package.json @@ -60,44 +60,44 @@ }, "homepage": "https://github.com/6XGate/bridgecmdr#readme", "devDependencies": { - "@intlify/core-base": "^9.14.1", - "@intlify/unplugin-vue-i18n": "^4.0.0", + "@intlify/core-base": "^10.0.4", + "@intlify/unplugin-vue-i18n": "^5.2.0", "@mdi/js": "^7.4.47", "@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/node": "^20.17.5", "@types/pouchdb-core": "^7.0.15", "@types/pouchdb-find": "^7.3.3", "@types/setimmediate": "^1.0.4", "@types/uuid": "^10.0.0", - "@typescript-eslint/eslint-plugin": "^8.7.0", - "@typescript-eslint/parser": "^8.7.0", + "@typescript-eslint/eslint-plugin": "^8.12.2", + "@typescript-eslint/parser": "^8.12.2", "@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", "@vuelidate/core": "^2.0.3", "@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", + "@vueuse/core": "^11.2.0", + "@vueuse/shared": "^11.2.0", + "@zip.js/zip.js": "^2.7.53", "assert": "^2.1.0", "auto-bind": "^5.0.1", "buffer": "^6.0.3", "duplexify": "^4.1.3", - "electron": "^31.6.0", + "electron": "^31.7.3", "electron-builder": "^24.13.3", "electron-unhandled": "^5.0.0", "electron-updater": "^6.3.9", @@ -106,45 +106,46 @@ "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-import-resolver-typescript": "^3.6.3", - "eslint-plugin-import": "^2.30.0", - "eslint-plugin-n": "^17.10.3", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-n": "^17.12.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-promise": "^7.1.0", - "eslint-plugin-vue": "^9.28.0", + "eslint-plugin-vue": "^9.30.0", "events": "^3.3.0", - "execa": "^9.4.1", + "execa": "^9.5.1", "husky": "^9.1.6", - "ini": "^4.1.3", + "ini": "^5.0.0", + "js-base64": "^3.7.7", "levelup": "^5.1.1", "mime": "^4.0.4", - "multileveldown": "^5.0.1", - "npm-check-updates": "^17.1.6", - "npm-run-all2": "^6.2.6", - "pinia": "^2.2.4", + "npm-check-updates": "^17.1.9", + "npm-run-all2": "^7.0.1", + "pinia": "^2.2.5", "pouchdb-adapter-leveldb-core": "^9.0.0", "pouchdb-core": "^9.0.0", "pouchdb-find": "^9.0.0", "prettier": "^3.3.3", "radash": "^12.1.0", - "sass": "^1.79.6", + "sass": "^1.80.5", "setimmediate": "^1.0.5", "stream-browserify": "^3.0.0", - "tslib": "^2.7.0", + "superjson": "^2.2.1", + "tslib": "^2.8.1", "type-fest": "^4.26.1", "typescript": "^5.6.3", "typescript-eslint-parser-for-extra-files": "^0.7.0", "util": "^0.12.5", - "uuid": "^10.0.0", + "uuid": "^11.0.2", "vite": "^5.4.10", - "vite-plugin-vue-devtools": "^7.4.6", + "vite-plugin-vue-devtools": "^7.6.2", "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-i18n": "^10.0.4", "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/error-handling.ts b/src/core/error-handling.ts index 4db237a..2067250 100644 --- a/src/core/error-handling.ts +++ b/src/core/error-handling.ts @@ -25,7 +25,7 @@ export function getMessage(cause: unknown) { if (cause instanceof Error) return cause.message if (cause == null) return `BadError: ${cause}` if (typeof cause === 'string') return cause - if (typeof cause !== 'object') return String(cause) + if (typeof cause !== 'object') return String(cause as never) if (!('message' in cause)) return `BadError: ${Object.prototype.toString.call(cause)}` if (typeof cause.message !== 'string') return String(cause.message) return cause.message 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/drivers/extron/sis.ts b/src/main/drivers/extron/sis.ts index 3868c66..2c7fae5 100644 --- a/src/main/drivers/extron/sis.ts +++ b/src/main/drivers/extron/sis.ts @@ -1,6 +1,6 @@ import Logger from 'electron-log' -import { createCommandStream } from '../../helpers/stream' import { defineDriver, kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs } from '../../services/driver' +import { createCommandStream } from '../../services/stream' const extronSisDriver = defineDriver({ enable: true, diff --git a/src/main/drivers/sony/rs485.ts b/src/main/drivers/sony/rs485.ts index 1bfbbb3..4f81975 100644 --- a/src/main/drivers/sony/rs485.ts +++ b/src/main/drivers/sony/rs485.ts @@ -1,8 +1,8 @@ import Logger from 'electron-log' -import { createAddress, createCommand, kAddressAll, kPowerOff, kPowerOn, kSetChannel } from '../../helpers/sonyRs485' -import { createCommandStream } from '../../helpers/stream' import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/driver' -import type { Command, CommandArg } from '../../helpers/sonyRs485' +import { createAddress, createCommand, kAddressAll, kPowerOff, kPowerOn, kSetChannel } from '../../services/sonyRs485' +import { createCommandStream } from '../../services/stream' +import type { Command, CommandArg } from '../../services/sonyRs485' const sonyRs485Driver = defineDriver({ enable: true, diff --git a/src/main/drivers/tesla-smart/kvm.ts b/src/main/drivers/tesla-smart/kvm.ts index 80d006b..56b1413 100644 --- a/src/main/drivers/tesla-smart/kvm.ts +++ b/src/main/drivers/tesla-smart/kvm.ts @@ -1,6 +1,6 @@ import Logger from 'electron-log' -import { createCommandStream } from '../../helpers/stream' import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/driver' +import { createCommandStream } from '../../services/stream' const teslaSmartKvmDriver = defineDriver({ enable: true, diff --git a/src/main/drivers/tesla-smart/matrix.ts b/src/main/drivers/tesla-smart/matrix.ts index b9e088f..a9369db 100644 --- a/src/main/drivers/tesla-smart/matrix.ts +++ b/src/main/drivers/tesla-smart/matrix.ts @@ -1,6 +1,6 @@ import Logger from 'electron-log' -import { createCommandStream } from '../../helpers/stream' import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/driver' +import { createCommandStream } from '../../services/stream' const teslaSmartMatrixDriver = defineDriver({ enable: true, diff --git a/src/main/drivers/tesla-smart/sdi.ts b/src/main/drivers/tesla-smart/sdi.ts index a82358e..38bc5fd 100644 --- a/src/main/drivers/tesla-smart/sdi.ts +++ b/src/main/drivers/tesla-smart/sdi.ts @@ -1,6 +1,6 @@ import Logger from 'electron-log' -import { createCommandStream } from '../../helpers/stream' import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/driver' +import { createCommandStream } from '../../services/stream' const teslaSmartSdiDriver = defineDriver({ enable: true, 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..b244c17 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -4,13 +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' import { logError } from './utilities' @@ -37,7 +36,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 +85,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,13 +138,13 @@ 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() await createWindow() 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..c74454a --- /dev/null +++ b/src/main/routes/ports.ts @@ -0,0 +1,13 @@ +import { memo } from 'radash' +import useSerialPorts from '../services/ports' +import { procedure, router } from '../services/trpc' + +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..ed86d32 --- /dev/null +++ b/src/main/routes/router.ts @@ -0,0 +1,29 @@ +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' +import useStartupRouter from './startup' + +export const useAppRouter = memo(() => + router({ + // Informational routes + appInfo: procedure.query(useAppInfo), + userInfo: procedure.query(useUserInfo), + // Functional service routes + ports: useSerialPortRouter(), + startup: useStartupRouter(), + // 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/routes/startup.ts b/src/main/routes/startup.ts new file mode 100644 index 0000000..f91bd8e --- /dev/null +++ b/src/main/routes/startup.ts @@ -0,0 +1,18 @@ +import { memo } from 'radash' +import useStartup from '../services/startup' +import { procedure, router } from '../services/trpc' + +const useStartupRouter = memo(function useStartupRouter() { + const startup = useStartup() + return router({ + checkEnabled: procedure.query(async () => await (await startup).checkEnabled()), + enable: procedure.mutation(async () => { + await (await startup).enable() + }), + disable: procedure.mutation(async () => { + await (await startup).disable() + }) + }) +}) + +export default useStartupRouter 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/helpers/dbus.ts b/src/main/services/dbus.ts similarity index 99% rename from src/main/helpers/dbus.ts rename to src/main/services/dbus.ts index 8ffe4d5..930d54c 100644 --- a/src/main/helpers/dbus.ts +++ b/src/main/services/dbus.ts @@ -260,7 +260,7 @@ const useDbus = memo(function useDbus() { return `${type}:"${arg as string}"` } - return `${type}:${String(arg)}` + return `${type}:${String(arg as never)}` }) } diff --git a/src/main/support/desktop.ts b/src/main/services/desktop.ts similarity index 100% rename from src/main/support/desktop.ts rename to src/main/services/desktop.ts 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/helpers/sonyRs485.ts b/src/main/services/sonyRs485.ts similarity index 100% rename from src/main/helpers/sonyRs485.ts rename to src/main/services/sonyRs485.ts diff --git a/src/main/services/startup.ts b/src/main/services/startup.ts index 429d351..4235ba4 100644 --- a/src/main/services/startup.ts +++ b/src/main/services/startup.ts @@ -1,14 +1,12 @@ import { unlink as deleteFile, mkdir, stat, writeFile, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { resolve as resolvePath, join as joinPath } from 'node:path' -import { app, ipcMain } from 'electron' +import { app } from 'electron' import Logger from 'electron-log' import * as INI from 'ini' import { memo } from 'radash' import { xdgConfig } from 'xdg-basedir' -import { DesktopEntryFile, readyEntry } from '../support/desktop' -import { ipcProxy } from '../utilities' -import type { StartupApi } from '../../preload/api' +import { DesktopEntryFile, readyEntry } from './desktop' const useStartup = memo(async function useStartup() { const configPath = xdgConfig != null ? resolvePath(xdgConfig) : resolvePath(homedir(), '.config') @@ -47,7 +45,6 @@ const useStartup = memo(async function useStartup() { await deleteFile(autoStartPath) } - // /** Ensure that the file is valid and still points to the right location. */ async function checkUp() { const enabled = await checkEnabled() @@ -77,15 +74,11 @@ const useStartup = memo(async function useStartup() { await checkUp() - ipcMain.handle('startup:checkEnabled', ipcProxy(checkEnabled)) - ipcMain.handle('startup:enable', ipcProxy(enable)) - ipcMain.handle('startup:disable', ipcProxy(disable)) - return { checkEnabled, enable, disable - } satisfies StartupApi + } }) export default useStartup diff --git a/src/main/helpers/stream.ts b/src/main/services/stream.ts similarity index 100% rename from src/main/helpers/stream.ts rename to src/main/services/stream.ts diff --git a/src/main/services/system.ts b/src/main/services/system.ts index 564eaef..baa8c32 100644 --- a/src/main/services/system.ts +++ b/src/main/services/system.ts @@ -2,8 +2,8 @@ import { open } from 'node:fs/promises' import { BrowserWindow, dialog, ipcMain } from 'electron' import mime from 'mime' import { memo } from 'radash' -import useDbus from '../helpers/dbus' import { ipcHandle, ipcProxy } from '../utilities' +import useDbus from './dbus' import type { SystemApi } from '../../preload/api' import type { FileData } from '@/struct' import type { OpenDialogOptions, SaveDialogOptions } from 'electron' 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..b0dfa82 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 { @@ -57,17 +52,6 @@ export interface ListenerOptions { once?: boolean | undefined } -// -// Login start API -// - -/** Login start API. */ -export interface StartupApi { - readonly checkEnabled: () => Promise - readonly enable: () => Promise - readonly disable: () => Promise -} - // // Driver API // @@ -129,27 +113,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 +127,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,9 +156,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 /** Closes a handle, freeing its resources. */ @@ -219,18 +164,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 +179,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..8ccb297 100644 --- a/src/preload/plugins/services.ts +++ b/src/preload/plugins/services.ts @@ -1,13 +1,10 @@ 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 useSystemApi from './services/system' +import useAppUpdates from './services/updates' import type { MainProcessServices } from '../api' const useServices = memo(function useServices() { @@ -16,9 +13,6 @@ const useServices = memo(function useServices() { const services = { process: useProcessData(), driver: useDriverApi(), - level: useLevelApi(), - ports: usePortsApi(), - startup: useStartupApi(), system: useSystemApi(), updates: useAppUpdates(), freeHandle: ipc.useInvoke('handle:free'), 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/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/preload/plugins/startup.ts b/src/preload/plugins/startup.ts deleted file mode 100644 index 3447706..0000000 --- a/src/preload/plugins/startup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { memo } from 'radash' -import { useIpc } from '../support' -import type { StartupApi } from '../api' - -const useStartupApi = memo(function useStartupApi() { - const ipc = useIpc() - - return { - checkEnabled: ipc.useInvoke('startup:checkEnabled'), - enable: ipc.useInvoke('startup:enable'), - disable: ipc.useInvoke('startup:disable') - } satisfies StartupApi -}) - -export default useStartupApi 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 @@