diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 59f4f71..7faaf9d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -28,7 +28,7 @@ module.exports = defineConfig(({ useVue, useNode, useTypeScript }) => { } }, { - files: ['src/renderer/**/*.ts', 'src/renderer/**/*.js', 'src/renderer/**/*.vue'], + files: ['src/renderer/**/*.ts', 'src/renderer/**/*.tsx', 'src/renderer/**/*.js', 'src/renderer/**/*.vue'], parserOptions: { project: './tsconfig.web.json' } 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..3ac5ef9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,14 +1,22 @@ - 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. - - More drivers. + - v2.2 - Move more modules to core. + - tRPC over Electron IPC. + - Wrap some Electron APIs as services for easier mocking without electron itself. + - (#74) Rearrangeable dashboard icons. + - Determine which drivers are being using by the public at large to remove the experimental mark; options: + - Add a opt-in telemetry feature to see which drivers are being used, should be an ask to send survey sort of thing. + - Ask users to submit survey somewhere, likely with a pop-up in the app to the direct them to the survey. + - More drivers. + - Monoprice Blackbird + - v3.0 + - (#92) Remote UI support + - Need settings toggle to control it's activation. + - Need security or authentication method, preferrably just a PIN code. + - Need a means to identify it's URL via the local UI. + - May need a way to disable the power-off button in the remote UI. - Drivers - - Shinybow - Monoprice Blackbird - - J-Tech Digital - - ASHATA - - TESmart - - No Hassle AV + - J-Tech Digital -- Need to find actual command list. + - ASHATA -- Now unable to find. + - No Hassle AV -- Need to contact. diff --git a/README.md b/README.md index 4273890..5ac78bb 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,8 @@ AppImage file. ### Releasing -- Start a the build docker conatiner: `docker compose run --build -it --rm build` -- Fresh install the packages: `yarn --force` +- Start the build docker conatiner: `docker compose run --build -it --rm build` +- Fresh install the packages: `yarn --force --frozen-lockfile` - Package the application: `yarn make` - Land and tag the release. - Create a release from the tag and copy the following files to the release assets: diff --git a/package.json b/package.json index 6e7c6b5..9235e8d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bridgecmdr", "productName": "BridgeCmdr", - "version": "2.0.1", + "version": "2.1.0", "description": "Controller for professional A/V monitors and switches", "packageManager": "yarn@1.22.22", "type": "module", @@ -34,10 +34,8 @@ "dev": "electron-vite dev", "build": "run-p -l typecheck build-only", "build-only": "electron-vite build", - "package:linux": "electron-builder --linux --dir --config", - "package": "run-s -l build package:linux", - "make:linux": "electron-builder --linux --config", - "make": "run-s -l build make:linux", + "package": "electron-builder --dir --config", + "make": "electron-builder --config", "ncu:update": "ncu -u", "ncu:check": "ncu", "ncu": "ncu", @@ -60,44 +58,41 @@ }, "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", + "@sixxgate/lint": "^3.3.2", + "@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.6", "@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", + "@types/ws": "^8.5.13", + "@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", + "bufferutil": "^4.0.8", + "electron": "^31.7.3", "electron-builder": "^24.13.3", "electron-unhandled": "^5.0.0", "electron-updater": "^6.3.9", @@ -106,53 +101,51 @@ "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", - "events": "^3.3.0", - "execa": "^9.4.1", + "eslint-plugin-vue": "^9.30.0", + "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.10", + "npm-run-all2": "^7.0.1", + "pinia": "^2.2.6", "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", - "setimmediate": "^1.0.5", - "stream-browserify": "^3.0.0", - "tslib": "^2.7.0", + "sass": "^1.80.6", + "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", + "utf-8-validate": "^6.0.5", "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", + "ws": "^8.18.0", "xdg-basedir": "^5.1.0", "zod": "^3.23.8" }, "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/basics.ts b/src/core/basics.ts index b20db6e..59fc708 100644 --- a/src/core/basics.ts +++ b/src/core/basics.ts @@ -17,11 +17,40 @@ export function toArray(value: T): T extends unknown[] ? T : T[] { } /** - * Wait a specified amount of time. - * @param timeout - The amount of time to wait in milliseconds. + * Creates a new promise with externally accessible fulfillment operations. + * + * This is a polyfill for + * [Promise.withResolver](https://developer.mozilla.org/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers). + * + * @returns An object with a Promise and its fulfillment operations. */ -export async function waitTill(timeout: number) { - await new Promise((resolve) => { - setTimeout(resolve, timeout) +export function withResolvers() { + let resolve: (value: T | PromiseLike) => void = () => undefined + let reject: (reason?: unknown) => void = () => undefined + + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + + return { resolve, reject, promise } +} + +/** + * Run an operation asynchronously as a microtask. + * @param op - The operation to run as a micro-task. + * @returns The result of the operation. + */ +export async function asMicrotask(op: () => MaybePromise) { + return await new Promise((resolve, reject) => { + queueMicrotask(() => { + try { + // eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable -- Proxied + Promise.resolve(op()).then(resolve).catch(reject) + } catch (cause) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Proxied + reject(cause) + } + }) }) } 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/location.ts b/src/core/location.ts new file mode 100644 index 0000000..6da953d --- /dev/null +++ b/src/core/location.ts @@ -0,0 +1,301 @@ +import { z } from 'zod' +import type { Fixed } from './basics' +import type { PortInfo } from '../main/services/ports' + +export type LocationType = 'port' | 'ip' + +export function isIpOrValidPort(value: string, ports: readonly PortInfo[]) { + switch (true) { + case value.startsWith('port:'): + return ports.find((port) => port.path === value.substring(5)) != null + case value.startsWith('ip:') && value[3] !== '/' && !value.slice(3).startsWith('file:'): + return true + default: + return false + } +} + +export function isValidLocation(value: string, ports: readonly PortInfo[]) { + switch (true) { + case value.startsWith('port:'): + return ports.find((port) => port.path === value.substring(5)) != null + case value.startsWith('ip:'): + return isHostWithOptionalPort(value.substring(3)) + default: + return false + } +} + +export function splitPath(value: string | undefined) { + if (value == null) { + return null + } + + switch (true) { + case value.startsWith('port:'): + return ['port' as const, value.substring(5)] satisfies Fixed + case value.startsWith('ip:'): + return ['ip' as const, value.substring(3)] satisfies Fixed + default: + return null + } +} + +// #region Host and Port + +const hostWithOptionalPortPattern = /^((?:\[[A-Fa-f0-9.:]+\])|(?:[\p{N}\p{L}.-]+))(?::([1-9][0-9]*))?$/u + +function zodParseHostWithOptionalPort(value: string) { + const match = hostWithOptionalPortPattern.exec(value) + + if (match == null) { + return undefined + } + + if (match[1]?.startsWith('[') === true) { + match[1] = match[1].slice(1, -1) + } + + const host = hostSchema.safeParse(match[1]) + const port = z.coerce.number().positive().int().optional().safeParse(match[2]) + + return [host, port] satisfies Fixed +} + +export const hostWithOptionalPortSchema = z.string().transform(function splitHostAndPort(value, ctx) { + const result = parseHostWithOptionalPort(value) + if (result == null) { + ctx.addIssue({ + message: 'Not a valid host or host:port combination', + code: 'invalid_string', + validation: 'regex', + fatal: true + }) + + return z.NEVER + } + + return result +}) + +export function parseHostWithOptionalPort(value: string) { + const result = zodParseHostWithOptionalPort(value) + if (result == null) { + return undefined + } + + const [host, port] = result + if (!host.success) { + return undefined + } + + if (!port.success || port.data == null) { + return [host.data] satisfies Fixed + } + + return [host.data, port.data] satisfies Fixed +} + +export function isHostWithOptionalPort(value: string) { + const result = zodParseHostWithOptionalPort(value) + if (result == null) return false + + return result[0].success && result[1].success +} + +// #region Host name + +export function isHost(value: string) { + return isHostName(value) || isIpV4Address(value) || isIpV6Address(value) +} + +export const hostSchema = z.string().refine(isHost) + +// #endregion + +/** + Is it a valid hostname? + + From RFC-952 (with relaxation stated in RFC-1123 2.1) + + ``` + Hostname is + = *["."] + = [*[ = /[\p{N}\p{L}](?:[\p{N}\p{L}-]*[\p{N}\p{L}])?/gu + /^(?:\.)*$/gu + ``` + + Fully rendered in hostNamePattern, with non-capture groups + to capturing converted for better readibility. +*/ +const hostNamePattern = /^[\p{N}\p{L}]([\p{N}\p{L}-]*[\p{N}\p{L}])?(\.[\p{N}\p{L}]([\p{N}\p{L}-]*[\p{N}\p{L}])?)*$/u +/** Determines whether a string is a hostname. */ +export const isHostName = (value: string) => hostNamePattern.test(value) +export const hostNameSchema = z.string().regex(hostNamePattern) + +// #region IPv4 + +/** + Zod's IP pattern allows some invalid address strings, suchs as double-zero, `00`. + These days IPv4 is generally always in decimal, not octal. It seems Zod was + aiming for this. With this in mind, the definition is as follows. + + ``` +
= 3 * ("." ) + = /(25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([1-9][0-9])|[0-9]/ # 0 - 255 + ``` +*/ +const ipV4Pattern = + /^((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([1-9][0-9])|[0-9])(\.((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([1-9][0-9])|[0-9])){3}$/u +/** Determines whether a string is an IPv4 address */ +export const isIpV4Address = (value: string): value is `${number}.${number}.${number}.${number}` => + ipV4Pattern.test(value) +export const ipV4AddressSchema = z.string().regex(ipV4Pattern) + +// #endregion + +// #region IPv6 + +/** + Zod's IPv6 pattern allows a lot of invalid and misses some valid addresses. + See {@link https://github.com/colinhacks/zod/issues/2339}. + The RFCs seems indicate the following battern. + + IPv6 + + ``` + | + + = | + = | + + = (7 * (":" )) + = "::" # Zero address + | ":" 7 * (":" ) + | ":" 1-6 * (":" ) + | 1-2 * ( ":") 1-5 * (":" ) + | 1-3 * ( ":") 1-4 * (":" ) + | 1-4 * ( ":") 1-3 * (":" ) + | 1-5 * ( ":") 1-2 * (":" ) + | 1-6 * ( ":") ":" + | 7 * ( ":") ":" + + = /[0-9A-Fa-f]{1,3}/ | /[0-9A-F]{1,3}/i | /[0-9a-f]{1,3}/i + + = (5 * (":" )) + + = "::" # Zero prefix + | ":" 5 * (":" ) ":" + | ":" 1-4 * (":" ) ":" + | 1-2 * ( ":") 1-3 * (":" ) ":" + | 1-3 * ( ":") 1-2 * (":" ) ":" + | 1-4 * ( ":") ":" ":" + | 5 * ( ":") ":" + + = (3 * ("." )) + + = /(25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([1-9][0-9])|[0-9]/ # 0 - 255 + ``` +*/ +const ipPairPattern = /^[0-9A-Fa-f]{1,4}$/u + +function parsePossibleIpString(value: string) { + // Split on the ':', results in some empty strings with compact. + const parts = value.split(':') + + // Trim if leading empty string, leading zeros in compact. + if (parts[0] === '') { + parts.shift() + } + + // Trim if trailing empty string, trailing zeros in compact. + if (parts[parts.length - 1] === '') { + parts.pop() + } + + // Split if compact. Will only preduce one or two results. + const sep = parts.indexOf('') + if (sep >= 0) { + // We add a zero to the second array to simply compact form logic. + // This is because the extra zero can stand for the at least one + // missing pair in the compact form. + return [parts.slice(0, sep), ['0', ...parts.slice(sep + 1)]] satisfies Fixed + } + + return [parts] satisfies Fixed +} + +function isValidFullIpV6(value: string[]) { + const last = value.pop() + + // IPv4 translation. + if (ipV4Pattern.test(last ?? '')) { + return value.length === 6 && value.every((p) => ipPairPattern.test(p)) + } + + // IPv6, only 7 since we pop'ped the last. + return value.length === 7 && value.every((p) => ipPairPattern.test(p)) && ipPairPattern.test(last ?? '') +} + +function isValidCompactIpV6([left, right]: [string[], string[]]) { + // Undefind if right is empty. + const last = right.pop() + + // IPv4 translation, won't test on an empty right. + if (ipV4Pattern.test(last ?? '')) { + return ( + left.length + right.length <= 6 && + left.every((p) => ipPairPattern.test(p)) && + right.every((p) => ipPairPattern.test(p)) + ) + } + + // IPv6, only 7 since we pop'ed the last. + // Empty arrays won't have anything to + // test and are valid as zero leading. + return ( + left.length + right.length <= 7 && + left.every((p) => ipPairPattern.test(p)) && + right.every((p) => ipPairPattern.test(p)) && + ipPairPattern.test(last ?? '') + ) +} + +export function isIpV6Address(value: string) { + if (value === '::') { + // Zero address short-circuit. + return true + } + + const parts = parsePossibleIpString(value) + if (parts.length === 1) { + return isValidFullIpV6(parts[0]) + } + + return isValidCompactIpV6(parts) +} + +export const ipV6AddressSchema = z.string().refine(isIpV6Address) + +// #endregion + +// #endregion + +// #region Schemas + +// More Zod +export const schemas = { + hostname: hostNameSchema, + ipV4Address: ipV4AddressSchema, + ipV6Address: ipV6AddressSchema, + host: hostSchema, + hostWithOptionalPort: hostWithOptionalPortSchema +} + +// #endregion 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/core/struct.ts b/src/core/struct.ts deleted file mode 100644 index 082749c..0000000 --- a/src/core/struct.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface FileData { - path: string - buffer: Uint8Array - type: string -} diff --git a/src/core/url.ts b/src/core/url.ts new file mode 100644 index 0000000..bf1aafe --- /dev/null +++ b/src/core/url.ts @@ -0,0 +1,25 @@ +const protocol = ['http:', 'ws:'] as const +const support = protocol.join(',') +export type Protocol = (typeof protocol)[number] + +function isSupportedProtocol(value: unknown): value is Protocol { + return protocol.includes(value as never) +} + +export type ServerSettings = [host: string, port: number, protocol: Protocol] + +export function getServerUrl(url: URL, defaultPort: number) { + if (!isSupportedProtocol(url.protocol)) throw new TypeError(`${url.protocol} is not supported; only ${support}`) + 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', defaultPort, url.protocol] satisfies ServerSettings + if (url.port.length === 0) return [url.hostname, defaultPort, url.protocol] satisfies ServerSettings + + const port = Number(url.port) + if (Number.isNaN(port)) throw new TypeError(`${url.port} is not a valid port`) + + return [url.hostname, port, url.protocol] satisfies ServerSettings +} 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..5d76638 --- /dev/null +++ b/src/main/dao/storage.ts @@ -0,0 +1,43 @@ +import { memo } from 'radash' +import { useLevelDb } from '../services/level' + +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..6a6c807 100644 --- a/src/main/drivers/extron/sis.ts +++ b/src/main/drivers/extron/sis.ts @@ -1,9 +1,10 @@ -import Logger from 'electron-log' -import { createCommandStream } from '../../helpers/stream' -import { defineDriver, kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs } from '../../services/driver' +import { defineDriver, kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs } from '../../services/drivers' +import { useExtronSisProtocol } from '../../services/protocols/extronSis' const extronSisDriver = defineDriver({ - enable: true, + enabled: true, + experimental: false, + kind: 'switch', guid: '4C8F2838-C91D-431E-84DD-3666D14A6E2C', localized: { en: { @@ -13,54 +14,7 @@ const extronSisDriver = defineDriver({ } }, capabilities: kDeviceSupportsMultipleOutputs | kDeviceCanDecoupleAudioOutput, - setup: async function setup(uri) { - async function sendCommand(command: string) { - const connection = await createCommandStream(uri, { - baudRate: 9600, - dataBits: 8, - stopBits: 1, - parity: 'none', - timeout: 5000, - keepAlive: true - }) - - // TODO: Other situation handlers... - connection.on('data', (data) => { - Logger.debug(`extronSisDriver: return: ${String(data)}`) - }) - connection.on('error', (error) => { - Logger.error(`extronSisDriver: ${error.message}`) - }) - - await connection.write(command) - await connection.close() - } - - async function activate(inputChannel: number, videoOutputChannel: number, audioOutputChannel: number) { - Logger.log(`extronSisDriver.activate(${inputChannel}, ${videoOutputChannel}, ${audioOutputChannel})`) - const videoCommand = `${inputChannel}*${videoOutputChannel}%` - const audioCommand = `${inputChannel}*${audioOutputChannel}$` - await sendCommand(`${videoCommand}\r\n${audioCommand}\r\n`) - } - - async function powerOn() { - Logger.log('extronSisDriver.powerOn') - Logger.debug('extronSisDriver.powerOn is a no-op') - await Promise.resolve() - } - - async function powerOff() { - Logger.log('extronSisDriver.powerOff') - Logger.debug('extronSisDriver.powerOff is a no-op') - await Promise.resolve() - } - - return await Promise.resolve({ - activate, - powerOn, - powerOff - }) - } + setup: useExtronSisProtocol }) export default extronSisDriver diff --git a/src/main/drivers/shinybow/v2.ts b/src/main/drivers/shinybow/v2.ts new file mode 100644 index 0000000..c5de8c3 --- /dev/null +++ b/src/main/drivers/shinybow/v2.ts @@ -0,0 +1,20 @@ +import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' +import { useShinybowV2Protocol } from '../../services/protocols/shinybow' + +const shinybowV2 = defineDriver({ + enabled: true, + experimental: true, + kind: 'switch', + guid: '75FB7ED2-EE3A-46D5-B11F-7D8C3C208E7C', + localized: { + en: { + title: 'Shinybow v2.0 compatible matrix switch', + company: 'ShinybowUSA', + provider: 'BridgeCmdr contributors' + } + }, + capabilities: kDeviceSupportsMultipleOutputs, + setup: useShinybowV2Protocol +}) + +export default shinybowV2 diff --git a/src/main/drivers/shinybow/v3.ts b/src/main/drivers/shinybow/v3.ts new file mode 100644 index 0000000..e2c1b8d --- /dev/null +++ b/src/main/drivers/shinybow/v3.ts @@ -0,0 +1,20 @@ +import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' +import { useShinybowV3Protocol } from '../../services/protocols/shinybow' + +const shinybowV3 = defineDriver({ + enabled: true, + experimental: true, + kind: 'switch', + guid: 'BBED08A1-C749-4733-8F2E-96C9B56C0C41', + localized: { + en: { + title: 'Shinybow v3.0 compatible matrix switch', + company: 'ShinybowUSA', + provider: 'BridgeCmdr contributors' + } + }, + capabilities: kDeviceSupportsMultipleOutputs, + setup: useShinybowV3Protocol +}) + +export default shinybowV3 diff --git a/src/main/drivers/sony/rs485.ts b/src/main/drivers/sony/rs485.ts index 1bfbbb3..9661623 100644 --- a/src/main/drivers/sony/rs485.ts +++ b/src/main/drivers/sony/rs485.ts @@ -1,11 +1,10 @@ -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 { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' +import { useSonyBvmProtocol } from '../../services/protocols/sonyBvm' const sonyRs485Driver = defineDriver({ - enable: true, + enabled: true, + experimental: false, + kind: 'monitor', guid: '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0', localized: { en: { @@ -15,54 +14,7 @@ const sonyRs485Driver = defineDriver({ } }, capabilities: kDeviceHasNoExtraCapabilities, - setup: async function setup(uri) { - async function sendCommand(command: Command, arg0?: CommandArg, arg1?: CommandArg) { - const source = createAddress(kAddressAll, 0) - const destination = createAddress(kAddressAll, 0) - const packet = createCommand(destination, source, command, arg0, arg1) - - const connection = await createCommandStream(uri, { - baudRate: 38400, - dataBits: 8, - stopBits: 1, - parity: 'odd', - timeout: 5000, - keepAlive: true - }) - - // TODO: Other situation handlers... - connection.on('data', (data) => { - Logger.debug(`sonyRs485Driver: return: ${String(data)}`) - }) - connection.on('error', (error) => { - Logger.error(`sonyRs485Driver: ${error.message}`) - }) - - await connection.write(packet) - await connection.close() - } - - async function activate(inputChannel: number) { - Logger.log(`sonyRs485Driver.activate(${inputChannel})`) - await sendCommand(kSetChannel, 1, inputChannel) - } - - async function powerOn() { - Logger.log('sonyRs485Driver.powerOn') - await sendCommand(kPowerOn) - } - - async function powerOff() { - Logger.log('sonyRs485Driver.powerOff') - await sendCommand(kPowerOff) - } - - return await Promise.resolve({ - activate, - powerOn, - powerOff - }) - } + setup: useSonyBvmProtocol }) export default sonyRs485Driver diff --git a/src/main/drivers/tesla-smart/kvm.ts b/src/main/drivers/tesla-smart/kvm.ts index 80d006b..4d87926 100644 --- a/src/main/drivers/tesla-smart/kvm.ts +++ b/src/main/drivers/tesla-smart/kvm.ts @@ -1,9 +1,10 @@ -import Logger from 'electron-log' -import { createCommandStream } from '../../helpers/stream' -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/driver' +import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' +import { useTeslaElecKvmProtocol } from '../../services/protocols/teslaElec' const teslaSmartKvmDriver = defineDriver({ - enable: true, + enabled: true, + experimental: true, + kind: 'switch', guid: '91D5BC95-A8E2-4F58-BCAC-A77BA1054D61', localized: { en: { @@ -13,52 +14,7 @@ const teslaSmartKvmDriver = defineDriver({ } }, capabilities: kDeviceHasNoExtraCapabilities, - setup: async function setup(uri) { - async function sendCommand(command: Buffer) { - const connection = await createCommandStream(uri, { - baudRate: 9600, - dataBits: 8, - stopBits: 1, - parity: 'none', - timeout: 5000, - keepAlive: true - }) - - // TODO: Other situation handlers... - connection.on('data', (data) => { - Logger.debug(`teslaSmartKvmDriver: return: ${String(data)}`) - }) - connection.on('error', (error) => { - Logger.error(`teslaSmartKvmDriver: ${error.message}`) - }) - - await connection.write(command) - await connection.close() - } - - async function activate(inputChannel: number) { - Logger.log(`teslaSmartKvmDriver.activate(${inputChannel})`) - await sendCommand(Buffer.of(0xaa, 0xbb, 0x03, 0x01, inputChannel, 0xee)) - } - - async function powerOn() { - Logger.log('teslaSmartKvmDriver.powerOn') - Logger.debug('teslaSmartKvmDriver.powerOn is a no-op') - await Promise.resolve() - } - - async function powerOff() { - Logger.log('teslaSmartKvmDriver.powerOff') - Logger.debug('teslaSmartKvmDriver.powerOff is a no-op') - await Promise.resolve() - } - - return await Promise.resolve({ - activate, - powerOn, - powerOff - }) - } + setup: useTeslaElecKvmProtocol }) export default teslaSmartKvmDriver diff --git a/src/main/drivers/tesla-smart/matrix.ts b/src/main/drivers/tesla-smart/matrix.ts index b9e088f..ce03461 100644 --- a/src/main/drivers/tesla-smart/matrix.ts +++ b/src/main/drivers/tesla-smart/matrix.ts @@ -1,9 +1,10 @@ -import Logger from 'electron-log' -import { createCommandStream } from '../../helpers/stream' -import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/driver' +import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' +import { useTeslaElecMatrixProtocol } from '../../services/protocols/teslaElec' const teslaSmartMatrixDriver = defineDriver({ - enable: true, + enabled: true, + experimental: true, + kind: 'switch', guid: '671824ED-0BC4-43A6-85CC-4877890A7722', localized: { en: { @@ -13,57 +14,7 @@ const teslaSmartMatrixDriver = defineDriver({ } }, capabilities: kDeviceSupportsMultipleOutputs, - setup: async function setup(uri) { - const sendCommand = async (command: Buffer) => { - const connection = await createCommandStream(uri, { - baudRate: 9600, - dataBits: 8, - stopBits: 1, - parity: 'none', - timeout: 5000, - keepAlive: true - }) - - // TODO: Other situation handlers... - connection.on('data', (data) => { - Logger.debug(`teslaSmartMatrixDriver: return: ${String(data)}`) - }) - connection.on('error', (error) => { - Logger.error(`teslaSmartMatrixDriver: ${error.message}`) - }) - - await connection.write(command) - await connection.close() - } - - const toChannel = (n: number) => String(n).padStart(2, '0') - - async function activate(inputChannel: number, outputChannel: number) { - Logger.log(`teslaSmartMatrixDriver.activate(${inputChannel}, ${outputChannel})`) - const command = `MT00SW${toChannel(inputChannel)}${toChannel(outputChannel)}NT` - await sendCommand(Buffer.from(command, 'ascii')) - - await Promise.resolve() - } - - async function powerOn() { - Logger.log('teslaSmartMatrixDriver.powerOn') - Logger.debug('teslaSmartMatrixDriver.powerOn is a no-op') - await Promise.resolve() - } - - async function powerOff() { - Logger.log('teslaSmartMatrixDriver.powerOff') - Logger.debug('teslaSmartMatrixDriver.powerOff is a no-op') - await Promise.resolve() - } - - return await Promise.resolve({ - activate, - powerOn, - powerOff - }) - } + setup: useTeslaElecMatrixProtocol }) export default teslaSmartMatrixDriver diff --git a/src/main/drivers/tesla-smart/sdi.ts b/src/main/drivers/tesla-smart/sdi.ts index a82358e..7e54084 100644 --- a/src/main/drivers/tesla-smart/sdi.ts +++ b/src/main/drivers/tesla-smart/sdi.ts @@ -1,9 +1,10 @@ -import Logger from 'electron-log' -import { createCommandStream } from '../../helpers/stream' -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/driver' +import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' +import { useTeslaElecSdiProtocol } from '../../services/protocols/teslaElec' const teslaSmartSdiDriver = defineDriver({ - enable: true, + enabled: true, + experimental: true, + kind: 'switch', guid: 'DDB13CBC-ABFC-405E-9EA6-4A999F9A16BD', localized: { en: { @@ -13,52 +14,7 @@ const teslaSmartSdiDriver = defineDriver({ } }, capabilities: kDeviceHasNoExtraCapabilities, - setup: async function setup(uri) { - async function sendCommand(command: Buffer) { - const connection = await createCommandStream(uri, { - baudRate: 9600, - dataBits: 8, - stopBits: 1, - parity: 'none', - timeout: 5000, - keepAlive: true - }) - - // TODO: Other situation handlers... - connection.on('data', (data) => { - Logger.debug(`teslaSmartSdiDriver: return: ${String(data)}`) - }) - connection.on('error', (error) => { - Logger.error(`teslaSmartSdiDriver: ${error.message}`) - }) - - await connection.write(command) - await connection.close() - } - - async function activate(inputChannel: number) { - Logger.log(`teslaSmartSdiDriver.activate(${inputChannel})`) - await sendCommand(Buffer.of(0xaa, 0xcc, 0x01, inputChannel)) - } - - async function powerOn() { - Logger.log('teslaSmartSdiDriver.powerOn') - Logger.debug('teslaSmartSdiDriver.powerOn is a no-op') - await Promise.resolve() - } - - async function powerOff() { - Logger.log('teslaSmartSdiDriver.powerOff') - Logger.debug('teslaSmartSdiDriver.powerOff is a no-op') - await Promise.resolve() - } - - return await Promise.resolve({ - activate, - powerOn, - powerOff - }) - } + setup: useTeslaElecSdiProtocol }) export default teslaSmartSdiDriver diff --git a/src/main/drivers/tesmart/kvm.ts b/src/main/drivers/tesmart/kvm.ts new file mode 100644 index 0000000..56a4e93 --- /dev/null +++ b/src/main/drivers/tesmart/kvm.ts @@ -0,0 +1,20 @@ +import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' +import { useTeslaElecKvmProtocol } from '../../services/protocols/teslaElec' + +const tesmartKvmDriver = defineDriver({ + enabled: true, + experimental: true, + kind: 'switch', + guid: '2B4EDB8E-D2D6-4809-BA18-D5B1785DA028', + localized: { + en: { + title: 'TESmart KVM-compatible switch', + company: 'Tesla Elec Technology Co.,Ltd', + provider: 'BridgeCmdr contributors' + } + }, + capabilities: kDeviceHasNoExtraCapabilities, + setup: useTeslaElecKvmProtocol +}) + +export default tesmartKvmDriver diff --git a/src/main/drivers/tesmart/matrix.ts b/src/main/drivers/tesmart/matrix.ts new file mode 100644 index 0000000..76b85c6 --- /dev/null +++ b/src/main/drivers/tesmart/matrix.ts @@ -0,0 +1,20 @@ +import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' +import { useTeslaElecMatrixProtocol } from '../../services/protocols/teslaElec' + +const tesmartMatrixDriver = defineDriver({ + enabled: true, + experimental: true, + kind: 'switch', + guid: '01B8884C-1D7D-4451-883D-3C8F18E17B14', + localized: { + en: { + title: 'TESmart matrix-compatible switch', + company: 'Tesla Elec Technology Co.,Ltd', + provider: 'BridgeCmdr contributors' + } + }, + capabilities: kDeviceSupportsMultipleOutputs, + setup: useTeslaElecMatrixProtocol +}) + +export default tesmartMatrixDriver diff --git a/src/main/drivers/tesmart/sdi.ts b/src/main/drivers/tesmart/sdi.ts new file mode 100644 index 0000000..696d6cc --- /dev/null +++ b/src/main/drivers/tesmart/sdi.ts @@ -0,0 +1,20 @@ +import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' +import { useTeslaElecSdiProtocol } from '../../services/protocols/teslaElec' + +const tesmartSdiDriver = defineDriver({ + enabled: true, + experimental: true, + kind: 'switch', + guid: '8C524E65-83EF-4AEF-B0DA-29C4582AA4A0', + localized: { + en: { + title: 'TESmart SDI-compatible switch', + company: 'Tesla Elec Technology Co.,Ltd', + provider: 'BridgeCmdr contributors' + } + }, + capabilities: kDeviceHasNoExtraCapabilities, + setup: useTeslaElecSdiProtocol +}) + +export default tesmartSdiDriver 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/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..48d5576 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3,18 +3,11 @@ import process from 'node:process' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { app, shell, BrowserWindow, nativeTheme } from 'electron' import Logger from 'electron-log' +import { sleep } from 'radash' import appIcon from '../../resources/icon.png?asset&asarUnpack' -import registerDrivers from './plugins/drivers' -import usePorts from './plugins/ports' -import useCrypto from './plugins/webcrypto' -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 useApiServer from './server' +import { getAuthToken } from './services/trpc' import { logError } from './utilities' -import { waitTill } from '@/basics' import { toError } from '@/error-handling' // In this file you can include the rest of your app"s specific main process @@ -25,30 +18,27 @@ Logger.transports.console.format = '{h}:{i}:{s}.{ms} [{level}] › {text}' Logger.transports.file.level = 'debug' Logger.errorHandler.startCatching() -async function createWindow() { +async function createWindow(port: number) { const willStartWithDark = nativeTheme.shouldUseDarkColors || nativeTheme.shouldUseInvertedColorScheme - const main = new BrowserWindow({ + const window = new BrowserWindow({ width: 800, height: 480, backgroundColor: willStartWithDark ? '#121212' : 'white', icon: appIcon, show: true, - useContentSize: true, - webPreferences: { - preload: joinPath(__dirname, '../preload/index.mjs'), - sandbox: false - } + useContentSize: true }) - main.removeMenu() + window.removeMenu() if (import.meta.env.PROD) { - main.setFullScreen(true) + window.setFullScreen(true) } else { - main.webContents.openDevTools({ mode: 'undocked' }) + window.webContents.openDevTools({ mode: 'undocked' }) } - main.webContents.setWindowOpenHandler(function windowOpenHandler(details) { + // Open all new window links in the system browser. + window.webContents.setWindowOpenHandler(function windowOpenHandler(details) { shell.openExternal(details.url).catch((e: unknown) => { Logger.error(e) }) @@ -59,55 +49,42 @@ async function createWindow() { const kWait = 2000 let lastError: unknown + /* eslint-disable no-await-in-loop -- Retry loop must be serial. */ for (let tries = 3; tries > 0; --tries) { try { // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. if (is.dev && process.env.ELECTRON_RENDERER_URL != null) { - // eslint-disable-next-line no-await-in-loop -- Retry loop must be serial. - await main.loadURL(process.env.ELECTRON_RENDERER_URL) + const url = new URL(process.env.ELECTRON_RENDERER_URL) + url.searchParams.set('port', String(port)) + url.searchParams.set('auth', getAuthToken()) + await window.loadURL(url.toString()) } else { - // eslint-disable-next-line no-await-in-loop -- Retry loop must be serial. - await main.loadFile(joinPath(__dirname, '../renderer/index.html')) + await window.loadFile(joinPath(__dirname, '../renderer/index.html'), { + query: { port: String(port), auth: getAuthToken() } + }) } - return main + return window } catch (e) { lastError = e Logger.warn(e) - // eslint-disable-next-line no-await-in-loop -- Retry loop must be serial. - await waitTill(kWait) + await sleep(kWait) } } + /* eslint-enable no-await-in-loop */ 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')) -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. +// Quit when all windows are closed. app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } -}) - -// Make sure all handles have been closed. -const { shutDown } = useHandles() -app.on('will-quit', () => { - process.nextTick(async () => { - await shutDown() - }) + app.quit() }) // Default open or close DevTools by F12 in development @@ -124,31 +101,14 @@ process.on('SIGTERM', () => { } }) -app.on('activate', () => { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) { - createWindow().catch((e: unknown) => { - Logger.error(e) - }) - } -}) - // This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. +// initialization and is ready to create browser +// windows. Some APIs can only be used after +// this event occurs. await app.whenReady() // Set app user model id for windows electronApp.setAppUserModelId('org.sleepingcats.BridgeCmdr') -useCrypto() -usePorts() -useUpdater() -useSystem() -useLevelServer() -useDrivers() -registerDrivers() -await useStartup() - -await createWindow() +const port = useApiServer() +await createWindow(port) diff --git a/src/main/plugins/drivers.ts b/src/main/plugins/drivers.ts deleted file mode 100644 index 782db22..0000000 --- a/src/main/plugins/drivers.ts +++ /dev/null @@ -1,16 +0,0 @@ -import extronSisDriver from '../drivers/extron/sis' -import sonyRs485Driver from '../drivers/sony/rs485' -import teslaSmartKvmDriver from '../drivers/tesla-smart/kvm' -import teslaSmartMatrixDriver from '../drivers/tesla-smart/matrix' -import teslaSmartSdiDriver from '../drivers/tesla-smart/sdi' -import useDrivers from '../services/driver' - -export default function registerDrivers() { - const { register } = useDrivers() - - register(extronSisDriver) - register(sonyRs485Driver) - register(teslaSmartMatrixDriver) - register(teslaSmartKvmDriver) - register(teslaSmartSdiDriver) -} 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/plugins/webcrypto.ts b/src/main/plugins/webcrypto.ts deleted file mode 100644 index 6f9fab4..0000000 --- a/src/main/plugins/webcrypto.ts +++ /dev/null @@ -1,25 +0,0 @@ -/// - -import { webcrypto } from 'node:crypto' -import { memo } from 'radash' - -/* eslint-disable n/no-unsupported-features/node-builtins -- Some modules required this. */ - -declare global { - // eslint-disable-next-line no-var -- Required to augment global. - var crypto: Crypto -} - -/** - * Add Node's webcrypto to the global this. - * - * Needed for any modules that required the Web Crypto API. - */ -const useCrypto = memo(function useCrypto() { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Ensuring it is Polyfilled. - if (globalThis.crypto == null) { - globalThis.crypto = webcrypto as never - } -}) - -export default useCrypto 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/drivers.ts b/src/main/routes/drivers.ts new file mode 100644 index 0000000..ae6fb8f --- /dev/null +++ b/src/main/routes/drivers.ts @@ -0,0 +1,31 @@ +import { memo } from 'radash' +import { z } from 'zod' +import useDrivers from '../services/drivers' +import { procedure, router } from '../services/trpc' + +const Channel = z.number().int().nonnegative().finite() + +const ActivateInputs = z.tuple([z.string().uuid(), z.string().url(), Channel, Channel, Channel]) +const PowerInputs = z.tuple([z.string().uuid(), z.string().url()]) + +const useDriversRouter = memo(function useDriversRoute() { + const drivers = useDrivers() + + return router({ + all: procedure.query(drivers.all), + get: procedure.input(z.string().uuid()).query(async ({ input }) => { + await drivers.get(input) + }), + activate: procedure.input(ActivateInputs).mutation(async ({ input }) => { + await drivers.activate(...input) + }), + powerOn: procedure.input(PowerInputs).mutation(async ({ input }) => { + await drivers.powerOn(...input) + }), + powerOff: procedure.input(PowerInputs).mutation(async ({ input }) => { + await drivers.powerOff(...input) + }) + }) +}) + +export default useDriversRouter diff --git a/src/main/routes/ports.ts b/src/main/routes/ports.ts new file mode 100644 index 0000000..3fd59ec --- /dev/null +++ b/src/main/routes/ports.ts @@ -0,0 +1,15 @@ +import { memo } from 'radash' +import { z } from 'zod' +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.listPorts), + isPort: procedure.input(z.string()).query(async ({ input }) => await ports.isValidPort(input)) + }) +}) + +export default useSerialPortRouter diff --git a/src/main/routes/router.ts b/src/main/routes/router.ts new file mode 100644 index 0000000..1647c41 --- /dev/null +++ b/src/main/routes/router.ts @@ -0,0 +1,35 @@ +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 useDriversRouter from './drivers' +import useSerialPortRouter from './ports' +import useStartupRouter from './startup' +import useSystemRouter from './system' +import useUpdaterRouter from './updater' + +export const useAppRouter = memo(() => + router({ + // Informational routes + appInfo: procedure.query(useAppInfo), + userInfo: procedure.query(useUserInfo), + // Functional service routes + ports: useSerialPortRouter(), + startup: useStartupRouter(), + system: useSystemRouter(), + drivers: useDriversRouter(), + // Data service routes + storage: useUserStoreRouter(), + ties: useTiesRouter(), + switches: useSwitchesRouter(), + sources: useSourcesRouter(), + updates: useUpdaterRouter() + }) +) + +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/routes/system.ts b/src/main/routes/system.ts new file mode 100644 index 0000000..a7c9317 --- /dev/null +++ b/src/main/routes/system.ts @@ -0,0 +1,16 @@ +import { memo } from 'radash' +import { z } from 'zod' +import useSystem from '../services/system' +import { procedure, router } from '../services/trpc' + +const useSystemRouter = memo(function useSystemRouter() { + const system = useSystem() + + return router({ + powerOff: procedure.input(z.boolean().optional()).mutation(async ({ input }) => { + await system.powerOff(input) + }) + }) +}) + +export default useSystemRouter diff --git a/src/main/routes/updater.ts b/src/main/routes/updater.ts new file mode 100644 index 0000000..ab8e23e --- /dev/null +++ b/src/main/routes/updater.ts @@ -0,0 +1,46 @@ +import { observable } from '@trpc/server/observable' +import { memo } from 'radash' +import { procedure, router } from '../services/trpc' +import useUpdater from '../services/updater' +import type { AppUpdaterEventMap } from '../services/updater' + +const useUpdaterRouter = memo(function useUpdaterRouter() { + const updater = useUpdater() + + function defineEvent(name: Name) { + type Args = AppUpdaterEventMap[Name] + // TODO: tRPC 11, use "for await...of on" with AbortSignal + return () => + observable((emit) => { + const proxy = (...args: Args) => { + emit.next(args) + } + + updater.on(name, proxy as never) + + return () => { + updater.off(name, proxy as never) + } + }) + } + + return router({ + onChecking: procedure.subscription(defineEvent('checking')), + onAvailable: procedure.subscription(defineEvent('available')), + onProgress: procedure.subscription(defineEvent('progress')), + onDownloaded: procedure.subscription(defineEvent('downloaded')), + onCancelled: procedure.subscription(defineEvent('cancelled')), + checkForUpdates: procedure.query(async () => await updater.checkForUpdates()), + downloadUpdate: procedure.query(async () => { + await updater.downloadUpdate() + }), + cancelUpdate: procedure.query(async () => { + await updater.cancelUpdate() + }), + installUpdate: procedure.query(async () => { + await updater.installUpdate() + }) + }) +}) + +export default useUpdaterRouter diff --git a/src/main/server.ts b/src/main/server.ts new file mode 100644 index 0000000..66669c8 --- /dev/null +++ b/src/main/server.ts @@ -0,0 +1,69 @@ +import { applyWSSHandler } from '@trpc/server/adapters/ws' +import Logger from 'electron-log' +import { range } from 'radash' +import { WebSocketServer } from 'ws' +import { useAppRouter } from './routes/router' +import { createStandaloneContext } from './services/trpc' + +function startWebSocketServer(url: string, host: string, port: number) { + // TODO: Authentication via the IPC, later we'll implement a proper authentication model. + + process.env['WS_NO_UTF_8_VALIDATE'] = '1' + + const wss = new WebSocketServer({ host, port }) + + wss.on('listening', () => { + Logger.info(`RPC server at ${url}`) + }) + + const handler = applyWSSHandler({ wss, router: useAppRouter(), createContext: createStandaloneContext }) + + process.on('exit', () => { + handler.broadcastReconnectNotification() + wss.close() + }) + + process.on('SIGTERM', () => { + handler.broadcastReconnectNotification() + wss.close() + }) +} + +// TODO: Maybe usable for the remote server one day. +// function startHttpServer(url: URL, host: string, port: number) { +// // TODO: Authentication via the IPC, later we'll implement a proper authentication model. +// +// const server = createHTTPServer({ +// router: useAppRouter() +// }) +// +// server.server.on('listening', () => { +// Logger.info(`RPC server at ${url}`) +// }) +// +// server.listen(port, host) +// process.on('exit', () => { +// server.server.close() +// }) +// +// process.on('SIGTERM', () => { +// server.server.close() +// }) +// } + +export default function useApiServer() { + let cause + const host = '127.0.0.1' + for (const port of range(7000, 8000)) { + const url = `ws://${host}:${port}` + try { + startWebSocketServer(url, host, port) + return port + } catch (err) { + cause = err + console.warn(`Unable to bind server to ${url}`, cause) + } + } + + throw new Error('No port available for the server within 7000-8000', { cause }) +} diff --git a/src/main/services/database.ts b/src/main/services/database.ts new file mode 100644 index 0000000..00523d9 --- /dev/null +++ b/src/main/services/database.ts @@ -0,0 +1,254 @@ +import { randomUUID } from 'node:crypto' +import PouchDb from 'pouchdb-core' +import find from 'pouchdb-find' +import { map, memo } from 'radash' +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: randomUUID().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/driver.ts b/src/main/services/driver.ts deleted file mode 100644 index dce87c0..0000000 --- a/src/main/services/driver.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { ipcMain } from 'electron' -import { memo } from 'radash' -import { ipcHandle, ipcProxy, logError } from '../utilities' -import useHandles from './handle' -import type { HandleKey } from './handle' -import type { DriverData, Handle } from '../../preload/api' - -// -// Device capabilities -// - -/** The device has no extended capabilities. */ -export const kDeviceHasNoExtraCapabilities = 0 -/** The device has multiple output channels. */ -export const kDeviceSupportsMultipleOutputs = 1 -/** The device support sending the audio output to a different channel. */ -export const kDeviceCanDecoupleAudioOutput = 2 - -// -// Driver definition -// - -/** Interacts with a device. */ -export interface DriverBindings { - /** - * Activates input and output ties. - * - * @param inputChannel - The input channel to tie. - * @param videoOutputChannel - The output video channel to tie. - * @param audioOutputChannel - The output audio channel to tie. - */ - readonly activate: (inputChannel: number, videoOutputChannel: number, audioOutputChannel: number) => Promise - - /** Powers on the switch or monitor. */ - readonly powerOn?: () => Promise - - /** Powers off the switch or monitor. */ - readonly powerOff?: () => Promise - - /** Closes the driver to free any used resources. */ - readonly close?: () => Promise -} - -/** Driver to interact with switching devices. */ -export interface Driver extends DriverBindings { - /** The URI to the device being driven. */ - readonly uri: string -} - -/** Provides the basic information to create a driver factory registration. */ -export interface DriverFactory { - /** Provides information about the driver. */ - readonly data: DriverData - /** Loads a driver. */ - readonly load: (uri: string) => Promise -} - -/** - * Initializes a driver. - * - * @param uri A uri to the device. - */ -export type DriverSetup = (uri: string) => Promise - -export interface DriverOptions extends DriverData { - setup: DriverSetup -} - -/** Defines a driver. */ -export function defineDriver(options: DriverOptions): DriverFactory | undefined { - const { setup, ...data } = options - if (!data.enable) { - return undefined - } - - return Object.freeze({ - data, - load: async (uri: string): Promise => - Object.freeze({ - ...(await setup(uri)), - uri - }) - }) -} - -// -// Driver API back-end -// - -interface DriverBackEnd { - register: (factory: DriverFactory | undefined) => void -} - -const useDrivers = memo(function useDrivers() { - const { createHandle, openHandle } = useHandles() - const kDriverHandle = Symbol.for('@driver') as HandleKey - - /** The driver registry. */ - const registry = new Map() - - /** Registers a driver. */ - function register(factory: DriverFactory | undefined) { - if (factory != null) { - registry.set(factory.data.guid, factory) - } - } - - /** Lists available drivers. */ - async function list() { - return await Promise.resolve(Array.from(registry.values()).map((d) => d.data)) - } - - /** Loads a driver registered in the registry. */ - const open = ipcHandle(async function open(event, guid: string, path: string) { - const factory = registry.get(guid) - if (factory == null) { - throw logError(new Error(`No such driver registered as "${guid}"`)) - } - - return createHandle(event, kDriverHandle, await factory.load(path), async (driver) => { - await driver.close?.() - }) - }) - - const powerOn = ipcHandle(async (event, h: Handle) => { - await openHandle(event, kDriverHandle, h).powerOn?.() - }) - - const powerOff = ipcHandle(async (event, h: Handle) => { - await openHandle(event, kDriverHandle, h).powerOff?.() - }) - - const activate = ipcHandle( - async (event, h: Handle, inputChannel: number, videoOutputChannel: number, audioOutputChannel: number) => { - await openHandle(event, kDriverHandle, h).activate(inputChannel, videoOutputChannel, audioOutputChannel) - } - ) - - ipcMain.handle('driver:list', ipcProxy(list)) - ipcMain.handle('driver:open', open) - ipcMain.handle('driver:powerOn', powerOn) - ipcMain.handle('driver:powerOff', powerOff) - ipcMain.handle('driver:activate', activate) - - return { register } satisfies DriverBackEnd -}) - -export default useDrivers diff --git a/src/main/services/drivers.ts b/src/main/services/drivers.ts new file mode 100644 index 0000000..eb220ae --- /dev/null +++ b/src/main/services/drivers.ts @@ -0,0 +1,204 @@ +// +// Device capabilities +// + +import { memo } from 'radash' +import useSerialPorts from './ports' +import type { ApiLocales } from './locale' +import type { MaybePromise } from '@/basics' +import { isIpOrValidPort } from '@/location' + +/** The device has no extended capabilities. */ +export type kDeviceHasNoExtraCapabilities = typeof kDeviceHasNoExtraCapabilities +export const kDeviceHasNoExtraCapabilities = 0 +/** The device has multiple output channels. */ +export type kDeviceSupportsMultipleOutputs = typeof kDeviceSupportsMultipleOutputs +export const kDeviceSupportsMultipleOutputs = 1 +/** The device support sending the audio output to a different channel. */ +export type kDeviceCanDecoupleAudioOutput = typeof kDeviceCanDecoupleAudioOutput +export const kDeviceCanDecoupleAudioOutput = 2 + +export type DriverKind = 'monitor' | 'switch' + +// +// Driver definition +// + +export interface DriverBasicInformation { + /** + * Indicates whether the driver is enabled, this is to allow partially coded drivers to be + * commited, but not usable to the UI or other code. + */ + readonly enabled: boolean + /** Indicates whether the driver is experimental, usually due to lack of testing. */ + readonly experimental: boolean + /** Identifies the kind of device driven by the driver. */ + readonly kind: DriverKind + /** A unique identifier for the driver. */ + readonly guid: string + /** Defines the capabilities of the device driven by the driver. */ + readonly capabilities: number +} + +/** Defines the localized metadata about a driver. */ +export interface LocalizedDriverInformation { + /** Defines the title for the driver in a specific locale. */ + readonly title: string + /** Defines the company for the driver in a specific locale. */ + readonly company: string + /** Defines the provider for the driver in a specific locale. */ + readonly provider: string +} + +/** Defines basic metadata about a device and driver. */ +export interface DriverInformation extends DriverBasicInformation { + /** Defines the localized driver information in all supported locales. */ + readonly localized: Readonly> +} + +/** Interacts with a device. */ +export interface DriverBindings { + /** + * Activates input and output ties. + * + * @param uri - URI identifying the location of the device. + * @param inputChannel - The input channel to tie. + * @param videoOutputChannel - The output video channel to tie. + * @param audioOutputChannel - The output audio channel to tie. + */ + readonly activate: ( + uri: string, + inputChannel: number, + videoOutputChannel: number, + audioOutputChannel: number + ) => Promise + + /** + * Powers on the switch or monitor. + * + * @param uri - URI identifying the location of the device. + */ + readonly powerOn?: (uri: string) => Promise + + /** + * Powers off the switch or monitor. + * + * @param uri - URI identifying the location of the device. + */ + readonly powerOff?: (uri: string) => Promise +} + +export interface DefineDriverOptions extends DriverInformation { + setup: () => DriverBindings +} + +async function noOpBinding() { + /* no-op is not defined in setup */ await Promise.resolve() +} + +const registry = new Map() + +export interface Driver extends DriverBasicInformation, Required { + /** Raw metadata from the registration options. */ + readonly metadata: DriverInformation + /** Gets the localized driver information. */ + getInfo: (locale: ApiLocales) => LocalizedDriverInformation +} + +export function defineDriver(options: DefineDriverOptions) { + let existing = registry.get(options.guid) + if (existing != null) return existing + + const { setup, ...info } = options + const implemented = setup() + + const getInfo = memo(function getInfo(locale: ApiLocales) { + const localizedInfo = info.localized[locale] + + return { + enabled: info.enabled, + experimental: info.experimental, + kind: info.kind, + guid: info.guid, + ...localizedInfo, + capabilities: info.capabilities + } + }) + + existing = Object.freeze({ + // Optional bindings will be no-op. + powerOn: noOpBinding, + powerOff: noOpBinding, + // Provided bindings. + ...implemented, + // Information and informational functionality. + enabled: info.enabled, + experimental: info.experimental, + kind: info.kind, + guid: info.guid, + capabilities: info.capabilities, + metadata: info, + getInfo + }) + + registry.set(options.guid, existing) + return existing +} + +const useDrivers = memo(function useDriver() { + const drivers = import.meta.glob('../drivers/**/*') + const ports = useSerialPorts() + + const booted = Promise.all( + Object.values(drivers).map(async (factory) => { + await factory() + }) + ) + + function defineOperation(op: (...args: Args) => MaybePromise) { + return async (...args: Args) => { + await booted + return await op(...args) + } + } + + const all = defineOperation(() => Array.from(registry.values()).filter((driver) => driver.enabled)) + + const get = defineOperation((guid: string) => registry.get(guid) ?? null) + + function defineDriverOperation( + op: (driver: Driver, uri: string, ...args: Args) => MaybePromise + ) { + return async (guid: string, uri: string, ...args: Args) => { + const driver = await get(guid) + if (driver == null) throw new ReferenceError(`No such driver: "${guid}"`) + const valid = await ports.listPorts() + if (!isIpOrValidPort(uri, valid)) throw new TypeError(`"${uri}" is not a valid location`) + return await op(driver, uri, ...args) + } + } + + const activate = defineDriverOperation( + async (driver, uri: string, input: number, videoOutput: number, audioOutput: number) => { + await driver.activate(uri, input, videoOutput, audioOutput) + } + ) + + const powerOn = defineDriverOperation(async (driver, uri: string) => { + await driver.powerOn(uri) + }) + + const powerOff = defineDriverOperation(async (driver, uri: string) => { + await driver.powerOff(uri) + }) + + return { + all, + get, + activate, + powerOn, + powerOff + } +}) + +export default useDrivers diff --git a/src/main/services/handle.ts b/src/main/services/handle.ts deleted file mode 100644 index bf73d10..0000000 --- a/src/main/services/handle.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { ipcMain } from 'electron' -import { memo } from 'radash' -import { ipcHandle, logError } from '../utilities' -import type { Handle } from '../../preload/api' -import type { SymbolKey } from '@/keys' -import type { IpcMainInvokeEvent, WebContents } from 'electron' -import { warnPromiseFailures } from '@/error-handling' - -export type HandleKey = SymbolKey<'handle', T> - -/** Close routine for a handle. */ -export type Close = (resource: T) => Promise - -/** Handle transparent structure. */ -export interface Descriptor { - key: HandleKey - resource: T - close: Close -} - -async function dummyClose() { - await Promise.resolve() -} - -/** - * Allows the use of an opaque handles for referencing - * resources by the renderer process. - */ -const useHandles = memo(function useHandles() { - /** The maximum number of handles sans 256. */ - const kMaxHandles = 131072 - - /** The NIL/NULL handle. */ - const kNullHandle = 0 as Handle - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Prevent need to cast - type AnyDescriptor = Descriptor - - // The first 256 will be reserved, and used as a - // sanity check for validity. - const handleMap = new Array(kMaxHandles) - let nextFree = 0x100 - - // Initial the map, ensuring all new element - // point to their neighbor as the next free. - // The final entry will point to one past - // the end of the map, which will mean - // it has been exhausted. - for (let i = 0; i !== kMaxHandles; ++i) { - handleMap[i] = i + 1 - } - - /** - * Determines whether a handle is valid; and optionally, of a given type. - * @param handle The handle to check. - * @param key The key to confirm the type of the handle. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function isValidHandle(handle: Handle, key?: HandleKey) { - // Get the handle descriptor - // and check its validity. - const descriptor = handleMap[handle] - if (descriptor == null || typeof descriptor !== 'object') { - return false - } - - return key != null ? key === descriptor.key : true - } - - /** - * Gets the descriptor of a handle. - * @param handle The handle from which to get a descriptor. - */ - function getDescriptor(handle: Handle): AnyDescriptor - /** - * Gets the descriptor of a handle and confirms its type. - * @param handle The handle from which to get a descriptor. - * @param key The key to confirm the handle type. - */ - function getDescriptor(handle: Handle, key: HandleKey): Descriptor - /** Implementation */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function getDescriptor(handle: Handle, key?: HandleKey) { - // Get the handle descriptor - // and check its validity. - const descriptor = handleMap[handle] - if (descriptor == null || typeof descriptor !== 'object') { - throw logError(new ReferenceError('Invalid handle')) - } - - if (key == null) { - return descriptor - } - - if (descriptor.key !== key) { - throw logError(new TypeError('Wrong handle')) - } - - return descriptor - } - - const handleTrackers = new WeakMap>() - - function getHandleTracker(sender: WebContents) { - let handleTracker = handleTrackers.get(sender) - if (handleTracker != null) return handleTracker - handleTracker = new Set() - handleTrackers.set(sender, handleTracker) - return handleTracker - } - - /** - * Creates a new handle, with the given type key, resource, and clean up routine. - * @param event - The event from which the handle is being opened. - * @param key - A key used to identify and validate the type of the handle. - * @param resource - A resource that will be attached to the handle. - * @param close - A callback to clean up the resource attached to the handle when it closes. - */ - function createHandle(event: IpcMainInvokeEvent, key: HandleKey, resource: T, close?: Close) { - const { sender } = event - - // Get the handle value and ensure - // there is no handle exhaustion. - const handle = nextFree - if (handle === kMaxHandles) { - throw logError(new Error('Out of handles')) - } - - // Ensure the handle is not already in use, - // indicating a possible map corruption. - const opaque = handleMap[handle] - if (typeof opaque !== 'number') { - throw logError(new Error(`'Handle map corrupt: tNF!=N' ${handle}`)) - } - - // Record the next free and put the - // descriptor in the map so the - // handle can be closed. - nextFree = opaque - handleMap[handle] = { key, resource, close: close ?? dummyClose } - - // Add the handle to the tracker tree. - getHandleTracker(sender).add(handle as Handle) - - return handle as Handle - } - - /** - * Opens a handle to access its attached resource. - * @param key - The handle key that tags the handle type. - * @param handle - The handle to open. - */ - function openHandle(event: IpcMainInvokeEvent, key: HandleKey, handle: Handle) { - const { sender } = event - - // Check that the handle belongs to this sender and frame. - const handles = getHandleTracker(sender) - if (!handles.has(handle)) { - throw logError(new ReferenceError('Invalid handle')) - } - - // Get the handle descriptor - // and check the resource. - const descriptor = getDescriptor(handle, key) - if (descriptor.resource == null) { - throw logError(new ReferenceError('Invalid handle')) - } - - return descriptor.resource // as T - } - - /** - * Closes a handle and cleans up its resource. - * @param event - The event from which the handle is being closed. - * @param handle - The handle to close. - */ - async function freeHandle(event: IpcMainInvokeEvent, handle: Handle) { - const { sender } = event - - // Check that the handle belongs to this sender and frame. - const handles = getHandleTracker(sender) - if (!handles.has(handle)) { - throw logError(new ReferenceError('Invalid handle')) - } - - // Remove the handle from the tracker tree, - // do this ealier so if any other task - // tries to close this handle, it - // cannot see it. - handles.delete(handle) - - // Get the handle descriptor - // and check the resource. - const descriptor = getDescriptor(handle) - if (descriptor.resource == null) { - throw logError(new ReferenceError('Invalid handle')) - } - - // Now, point the next free to the - // handle just closed, and update - // the next free to point to - // said handle. - handleMap[handle] = nextFree - nextFree = handle - - // Ensure that any asynchronous call out are at the end, - // to ensure all changes to the handle map or tracker - // don't interleave. Attempt to close the resource. - // If it fails to close, it must throw an - // error or be silent. - await descriptor.close(descriptor.resource) - } - - /** - * Closes all handles in a frame. - */ - async function freeAllHandle(event: IpcMainInvokeEvent) { - const { sender } = event - - const handles = getHandleTracker(sender) - while (handles.size > 0) { - // Right now, must use a new iterator to always pull the first item. - // This is to attempt to make this as synchronous as possible - // until the actual close, allowing the item to be removed - // before any coroutine pauses for this task cycle. - const next = handles.values().next() - if (next.done === true) return - - // eslint-disable-next-line no-await-in-loop -- Must be serialized to prevent issues. - await freeHandle(event, next.value) - } - } - - /** - * Closes all handles on shutdown. - * - * There is no need to handle the free-list or ownership. - */ - async function shutDown() { - warnPromiseFailures( - 'closing handle failure', - await Promise.allSettled( - handleMap.map(async function closeHandle(handle) { - if (typeof handle === 'object') { - await handle.close(handle.resource) - } else { - await Promise.resolve() - } - }) - ) - ) - } - - ipcMain.handle('handle:free', ipcHandle(freeHandle)) - ipcMain.handle('handle:clean', ipcHandle(freeAllHandle)) - - return { - kNullHandle, - isValidHandle, - createHandle, - openHandle, - freeHandle, - freeAllHandle, - shutDown - } -}) - -export default useHandles 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..83df748 100644 --- a/src/main/services/level.js +++ b/src/main/services/level.js @@ -1,113 +1,99 @@ 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 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`) + const db = levelDown(path) + app.on('will-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/main/services/locale.ts b/src/main/services/locale.ts new file mode 100644 index 0000000..cd01773 --- /dev/null +++ b/src/main/services/locale.ts @@ -0,0 +1,2 @@ +/** Supported API locales. */ +export type ApiLocales = 'en' diff --git a/src/main/services/ports.ts b/src/main/services/ports.ts new file mode 100644 index 0000000..bd8443e --- /dev/null +++ b/src/main/services/ports.ts @@ -0,0 +1,119 @@ +import is from '@sindresorhus/is' +import { memo } from 'radash' +import { SerialPort } from 'serialport' + +// 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 +} + +type BadPnpId = typeof BadPnpId +const BadPnpId = Symbol('UsePath') + +const useSerialPorts = memo(function useSerialPorts() { + // TODO: Determine a way to test the title caching. + + const titled = new Map() + + function getPortTitle(info: PortInfo) { + const { manufacturer, pnpId, path } = info + + if (!is.nonEmptyStringAndNotWhitespace(pnpId)) { + // If there is no pnpId; don't cache the name, + // it's less costly by avoiding the map + // lookup. We will still key on the + // manufacturer for wierd PnP IDs. + return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path + } + + let title = titled.get(pnpId) + if (title === BadPnpId) { + return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path + } + + if (title != null) return title + + // The PnP ID seems to be based on this format; + // ${bus}-${snake_style_label}-${positions.join('-')}, + // where: + // - bus: 'usb' | 'tty' | etc. + // - snake_style_label: 'Friendly_name_in_snake_style' + // - positions: Array<`port{number}` | `if{number}`> + + // First, split by hyphen, this should produce + // the bus/label/position combo. + let labelParts = pnpId.split('-') + if (labelParts.length < 3) { + titled.set(pnpId, BadPnpId) + return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path + } + + // Pop any positions off the end. + for (;;) { + const part = labelParts.at(-1) + if (part == null) { + titled.set(pnpId, BadPnpId) + return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path + } + + if (/^port\d+$/u.test(part)) { + labelParts.pop() + } else if (/^if\d+$/u.test(part)) { + labelParts.pop() + } else { + break + } + } + + // Slice off the bus. + labelParts = labelParts.slice(1) + if (labelParts.length === 0) { + titled.set(pnpId, BadPnpId) + return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path + } + + // Now, rejoin the label by hypens, in case + // those were in the friendly name, and + // replace underscores with spaces. + title = labelParts.join('-').replace(/_/gu, ' ') + titled.set(pnpId, title) + return title + } + + async function listRawPorts() { + return (await SerialPort.list()) as PortInfo[] + } + + async function listPorts() { + const ports = await listRawPorts() + + return ports.map(function parsePortInfo(port) { + return { + ...port, + title: getPortTitle(port) + } + }) + } + + async function isValidPort(path: string) { + const ports = await listRawPorts() + return ports.find((port) => port.path === path) != null + } + + return { + listPorts, + isValidPort + } +}) + +export default useSerialPorts diff --git a/src/main/services/protocols/extronSis.ts b/src/main/services/protocols/extronSis.ts new file mode 100644 index 0000000..23dfca4 --- /dev/null +++ b/src/main/services/protocols/extronSis.ts @@ -0,0 +1,32 @@ +import Logger from 'electron-log' +import { memo } from 'radash' +import { useProtocol } from './protocols' + +export const useExtronSisProtocol = memo(function useExtronSisProtocol() { + const kProtocol = 'extron/sis' + + const { sendCommand } = useProtocol(kProtocol) + + async function activate(uri: string, input: number, videoOutput: number, audioOutput: number) { + Logger.log(`${kProtocol}/tie(${input}, ${videoOutput}, ${audioOutput})`) + const videoCommand = `${input}*${videoOutput}%` + const audioCommand = `${input}*${audioOutput}$` + await sendCommand(uri, `${videoCommand}\r\n${audioCommand}\r\n`) + } + + async function powerOn() { + Logger.log(`${kProtocol}/powerOn; no-op`) + await Promise.resolve() + } + + async function powerOff() { + Logger.log(`${kProtocol}/powerOff; no-op`) + await Promise.resolve() + } + + return { + activate, + powerOn, + powerOff + } +}) diff --git a/src/main/services/protocols/protocols.ts b/src/main/services/protocols/protocols.ts new file mode 100644 index 0000000..571cba8 --- /dev/null +++ b/src/main/services/protocols/protocols.ts @@ -0,0 +1,48 @@ +import Logger from 'electron-log' +import { memo } from 'radash' +import { createCommandStream } from '../stream' + +interface ProtocolOptions { + baudRate?: number + dataBits?: 5 | 6 | 7 | 8 + stopBits?: 1 | 1.5 | 2 + parity?: 'none' | 'odd' | 'even' + timeout?: number + // TODO: onError? + // TODO: onData? + // TODO: Other situation handlers... +} + +export const useProtocol = memo(function useProtocol(name: string, options: ProtocolOptions = {}) { + // Options and defaults. + const { baudRate = 9600, dataBits = 8, stopBits = 1, parity = 'none', timeout = 5000 } = options + + async function sendCommand(uri: string, command: Buffer): Promise + async function sendCommand(uri: string, command: string, encoding?: BufferEncoding): Promise + async function sendCommand(uri: string, command: Buffer | string, encoding?: BufferEncoding) { + const connection = await createCommandStream(uri, { + baudRate, + dataBits, + stopBits, + parity, + timeout, + keepAlive: true + }) + + connection.on('data', (data) => { + Logger.debug(`${name}; returned ${String(data)}`) + }) + connection.on('error', (error) => { + Logger.error(`${name}; ${error.message}`) + }) + + if (typeof command === 'string') { + command = Buffer.from(command, encoding ?? 'ascii') + } + + await connection.write(command) + await connection.close() + } + + return { sendCommand } +}) diff --git a/src/main/services/protocols/shinybow.ts b/src/main/services/protocols/shinybow.ts new file mode 100644 index 0000000..9200530 --- /dev/null +++ b/src/main/services/protocols/shinybow.ts @@ -0,0 +1,59 @@ +import Logger from 'electron-log' +import { memo } from 'radash' +import { useProtocol } from './protocols' + +export const useShinybowV2Protocol = memo(function useShinybowV2Protocol() { + const kProtocol = 'shinybow/v2.0' + const { sendCommand } = useProtocol(kProtocol) + + const toChannel = (n: number) => String(n).padStart(2, '0') + + async function activate(uri: string, input: number, output: number) { + Logger.log(`${kProtocol}/tie(${input}, ${output})`) + await sendCommand(uri, `OUTPUT${toChannel(output)} ${toChannel(input)};\r\n`) + } + + async function powerOn(uri: string) { + Logger.log(`${kProtocol}/powerOn`) + await sendCommand(uri, 'POWER 01;\r\n') + } + + async function powerOff(uri: string) { + Logger.log(`${kProtocol}/powerOff`) + await sendCommand(uri, 'POWER 00;\r\n') + } + + return { + activate, + powerOn, + powerOff + } +}) + +export const useShinybowV3Protocol = memo(function useShinybowV3Protocol() { + const kProtocol = 'shinybow/v3.0' + const { sendCommand } = useProtocol(kProtocol) + + const toChannel = (n: number) => String(n).padStart(3, '0') + + async function activate(uri: string, input: number, output: number) { + Logger.log(`${kProtocol}/tie(${input}, ${output})`) + await sendCommand(uri, `OUTPUT${toChannel(output)} ${toChannel(input)};\r\n`) + } + + async function powerOn(uri: string) { + Logger.log(`${kProtocol}/powerOn`) + await sendCommand(uri, 'POWER 001;\r\n') + } + + async function powerOff(uri: string) { + Logger.log(`${kProtocol}/powerOff`) + await sendCommand(uri, 'POWER 000;\r\n') + } + + return { + activate, + powerOn, + powerOff + } +}) diff --git a/src/main/services/protocols/sonyBvm.ts b/src/main/services/protocols/sonyBvm.ts new file mode 100644 index 0000000..5719ab1 --- /dev/null +++ b/src/main/services/protocols/sonyBvm.ts @@ -0,0 +1,46 @@ +import Logger from 'electron-log' +import { memo } from 'radash' +import { useProtocol } from './protocols' +import { createAddress, createCommand, kAddressAll, kPowerOff, kPowerOn, kSetChannel } from './sonyRs485' +import type { Command, CommandArg } from './sonyRs485' + +export const useSonyBvmProtocol = memo(function useSonyBvmProtocol() { + const kProtocol = 'sony/bvm' + + const { sendCommand: sendRawCommand } = useProtocol(kProtocol, { + baudRate: 38400, + dataBits: 8, + stopBits: 1, + parity: 'odd', + timeout: 5000 + }) + + async function sendCommand(uri: string, command: Command, arg0?: CommandArg, arg1?: CommandArg) { + const source = createAddress(kAddressAll, 0) + const destination = createAddress(kAddressAll, 0) + const packet = createCommand(destination, source, command, arg0, arg1) + + await sendRawCommand(uri, packet) + } + + async function activate(uri: string, input: number) { + Logger.log(`${kProtocol}/channel(${input})`) + await sendCommand(uri, kSetChannel, 1, input) + } + + async function powerOn(uri: string) { + Logger.log(`${kProtocol}/powerOn`) + await sendCommand(uri, kPowerOn) + } + + async function powerOff(uri: string) { + Logger.log(`${kProtocol}/powerOff`) + await sendCommand(uri, kPowerOff) + } + + return { + activate, + powerOn, + powerOff + } +}) diff --git a/src/main/helpers/sonyRs485.ts b/src/main/services/protocols/sonyRs485.ts similarity index 100% rename from src/main/helpers/sonyRs485.ts rename to src/main/services/protocols/sonyRs485.ts diff --git a/src/main/services/protocols/teslaElec.ts b/src/main/services/protocols/teslaElec.ts new file mode 100644 index 0000000..867e931 --- /dev/null +++ b/src/main/services/protocols/teslaElec.ts @@ -0,0 +1,85 @@ +import Logger from 'electron-log' +import { memo } from 'radash' +import { useProtocol } from './protocols' + +export const useTeslaElecKvmProtocol = memo(function useTeslaElecKvmProtocol() { + const kProtocol = 'teslaElec/kvm' + const { sendCommand } = useProtocol(kProtocol) + + async function activate(uri: string, input: number) { + Logger.log(`${kProtocol} << channel(${input})`) + await sendCommand(uri, Buffer.of(0xaa, 0xbb, 0x03, 0x01, input, 0xee)) + } + + async function powerOn() { + Logger.log(`${kProtocol}/powerOn; no-op`) + await Promise.resolve() + } + + async function powerOff() { + Logger.log(`${kProtocol}/powerOff; no-op`) + await Promise.resolve() + } + + return { + activate, + powerOn, + powerOff + } +}) + +export const useTeslaElecMatrixProtocol = memo(function useTeslaElecMatrixProtocol() { + const kProtocol = 'teslaElec/matrix' + const { sendCommand } = useProtocol(kProtocol) + + const toChannel = (n: number) => String(n).padStart(2, '0') + + async function activate(uri: string, input: number, output: number) { + Logger.log(`${kProtocol} << tie(${input}, ${output})`) + await sendCommand(uri, `MT00SW${toChannel(input)}${toChannel(output)}NT\r\n`) + + await Promise.resolve() + } + + async function powerOn() { + Logger.log(`${kProtocol}/powerOn; no-op`) + await Promise.resolve() + } + + async function powerOff() { + Logger.log(`${kProtocol}/powerOff; no-op`) + await Promise.resolve() + } + + return { + activate, + powerOn, + powerOff + } +}) + +export const useTeslaElecSdiProtocol = memo(function useTeslaElecSdiProtocol() { + const kProtocol = 'teslaElec/sdi' + const { sendCommand } = useProtocol(kProtocol) + + async function activate(uri: string, input: number) { + Logger.log(`${kProtocol} << channel(${input})`) + await sendCommand(uri, Buffer.of(0xaa, 0xcc, 0x01, input)) + } + + async function powerOn() { + Logger.log(`${kProtocol}/powerOn; no-op`) + await Promise.resolve() + } + + async function powerOff() { + Logger.log(`${kProtocol}/powerOff; no-op`) + await Promise.resolve() + } + + return { + activate, + powerOn, + powerOff + } +}) 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 89% rename from src/main/helpers/stream.ts rename to src/main/services/stream.ts index 49ed39c..a1b7663 100644 --- a/src/main/helpers/stream.ts +++ b/src/main/services/stream.ts @@ -2,13 +2,11 @@ import { createConnection } from 'node:net' import { pipeline, finished } from 'node:stream/promises' import { SerialPort } from 'serialport' import { z } from 'zod' -import type { Socket, NetConnectOpts, IpcNetConnectOpts, TcpNetConnectOpts } from 'node:net' +import type { Socket, NetConnectOpts, TcpNetConnectOpts } from 'node:net' import type { Duplex } from 'node:stream' import type { Simplify } from 'type-fest' import { toError } from '@/error-handling' -export type IpcStreamOptions = Omit - export type NetStreamOptions = Omit type CommonPortStreamOptions = ConstructorParameters[0] @@ -86,10 +84,6 @@ async function createSocketStream(options: NetConnectOpts) { return createStream(socket, { close }) } -async function createIpcStream(path: string, options: IpcStreamOptions) { - return await createSocketStream({ path, ...options }) -} - const kHostWithOptionalPort = /^((?:\[[A-Fa-f0-9.:]+\])|(?:[\p{N}\p{L}.-]+))(?::([1-9][0-9]*))?$/u async function createNetStream(target: string, options: NetStreamOptions) { @@ -130,7 +124,7 @@ async function createPortStream(path: string, options: PortStreamOptions) { return createStream(port, { close }) } -export type CommandStreamOptions = IpcStreamOptions | NetStreamOptions | PortStreamOptions +export type CommandStreamOptions = NetStreamOptions | PortStreamOptions export type CommandStream = ReturnType @@ -143,5 +137,5 @@ export async function createCommandStream(path: string, options: CommandStreamOp return await createNetStream(path.substring(3), options as NetStreamOptions) } - return await createIpcStream(path, options as IpcStreamOptions) + throw new TypeError('Unsupport stream address') } diff --git a/src/main/services/system.ts b/src/main/services/system.ts index 564eaef..cdc3f8e 100644 --- a/src/main/services/system.ts +++ b/src/main/services/system.ts @@ -1,13 +1,5 @@ -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 type { SystemApi } from '../../preload/api' -import type { FileData } from '@/struct' -import type { OpenDialogOptions, SaveDialogOptions } from 'electron' -import { raiseError } from '@/error-handling' +import useDbus from './dbus' const useSystem = memo(function useSystem() { const { dbusBind } = useDbus() @@ -25,48 +17,9 @@ const useSystem = memo(function useSystem() { await powerOffByDbus(interactive) } - const openFile = ipcHandle(async function openFile(ev, options: OpenDialogOptions) { - const result = await dialog.showOpenDialog( - BrowserWindow.fromWebContents(ev.sender) ?? raiseError(() => new ReferenceError('Invalid window')), - options - ) - - if (result.canceled) { - return null - } - - return await Promise.all( - result.filePaths.map(async (path) => { - await using file = await open(path, 'r') - const buffer = await file.readFile() - const type = mime.getType(path) ?? 'application/octet-stream' - return { path, buffer, type } satisfies FileData - }) - ) - }) - - const saveFile = ipcHandle(async function saveFile(ev, source: FileData, options: SaveDialogOptions) { - options.defaultPath = options.defaultPath ?? source.path - - const result = await dialog.showSaveDialog( - BrowserWindow.fromWebContents(ev.sender) ?? raiseError(() => new ReferenceError('Invalid window')), - options - ) - - if (result.canceled) return false - - await using file = await open(result.filePath, 'w') - await file.writeFile(source.buffer) - return true - }) - - ipcMain.handle('system:powerOff', ipcProxy(powerOff)) - ipcMain.handle('system:openFile', openFile) - ipcMain.handle('system:saveFile', saveFile) - return { powerOff - } satisfies Omit + } }) export default useSystem diff --git a/src/main/services/trpc.ts b/src/main/services/trpc.ts new file mode 100644 index 0000000..8ee9032 --- /dev/null +++ b/src/main/services/trpc.ts @@ -0,0 +1,48 @@ +import { randomBytes } from 'crypto' +import { initTRPC, TRPCError } from '@trpc/server' +import { memo } from 'radash' +import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone' +import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws' +import type { TRPC_ERROR_CODE_KEY } from '@trpc/server/rpc' +import useSuperJson from '@/rpc' + +export type Context = Awaited> + +function createContext(path: string | null) { + if (path == null) return { auth: undefined } + const url = new URL(`ws://127.0.0.1${path}`) + const auth = url.searchParams.get('auth') ?? undefined + + return { auth } +} + +export function createStandaloneContext(opts: CreateHTTPContextOptions | CreateWSSContextFnOptions) { + return createContext(opts.req.url ?? null) +} + +const t = initTRPC.context().create({ + transformer: useSuperJson() +}) + +export const getAuthToken = memo(function getAuthToken() { + return randomBytes(16).toString('base64url') +}) + +function error(code: TRPC_ERROR_CODE_KEY, message?: string, cause?: unknown): never { + throw new TRPCError({ + code, + ...(message ? { message } : {}), + ...(cause != null ? { cause } : {}) + }) +} + +export const { router, createCallerFactory } = t +export const procedure = t.procedure.use(async function checkAuth(opts) { + const { ctx } = opts + const { auth } = ctx + + if (auth == null) error('UNAUTHORIZED') + if (auth !== getAuthToken()) error('UNAUTHORIZED') + + return await opts.next() +}) diff --git a/src/main/services/updater.ts b/src/main/services/updater.ts index feb196d..2f5591d 100644 --- a/src/main/services/updater.ts +++ b/src/main/services/updater.ts @@ -2,19 +2,23 @@ import EventEmitter from 'node:events' import { writeFile } from 'node:fs/promises' import { resolve as resolvePath } from 'node:path' import autoBind from 'auto-bind' -import { app, ipcMain } from 'electron' +import { app } from 'electron' import { autoUpdater } from 'electron-updater' import { memo } from 'radash' -import { ipcHandle, ipcProxy, isNodeError, logError } from '../utilities' -import type { AppUpdater } from '../../preload/api' -import type { WebContents } from 'electron' -import type { UpdateCheckResult, ProgressInfo, CancellationToken } from 'electron-updater' +import { isNodeError, logError } from '../utilities' +import type { UpdateCheckResult, ProgressInfo, CancellationToken, UpdateInfo } from 'electron-updater' -interface AppAutoUpdaterEventMap { +export type { UpdateInfo, ProgressInfo } from 'electron-updater' + +export interface AppUpdaterEventMap { + checking: [] + available: [info: UpdateInfo | null] progress: [progress: ProgressInfo] + downloaded: [info: UpdateInfo] + cancelled: [] } -type ProgressHandler = (...args: AppAutoUpdaterEventMap['progress']) => void +export type AppUpdater = ReturnType const useUpdater = memo(function useUpdater() { /** The internal application updater for AppImage. */ @@ -28,14 +32,33 @@ const useUpdater = memo(function useUpdater() { /** * Application auto update. * - * We are using a class for EventEmitter's sake, it wants to return this which must be compatible with the API. + * We are using a class for EventEmitter's sake. */ - class AppAutoUpdater extends EventEmitter implements AppUpdater { + class AppUpdater extends EventEmitter { #checkPromise: Promise | undefined = undefined #cancelToken: CancellationToken | undefined = undefined #donwloadPromise: Promise | undefined = undefined - async #getUpdateInfo() { + constructor() { + super() + autoUpdater.on('checking-for-update', () => { + this.emit('checking') + }) + autoUpdater.on('update-available', (info) => { + this.emit('available', info) + }) + autoUpdater.on('update-not-available', () => { + this.emit('available', null) + }) + autoUpdater.on('update-downloaded', (info) => { + this.emit('downloaded', info) + }) + autoUpdater.on('update-cancelled', () => { + this.emit('cancelled') + }) + } + + private async getUpdateInfo() { if (this.#checkPromise == null) { throw logError(new ReferenceError('Cannot get update information, no check in progress')) } @@ -45,7 +68,7 @@ const useUpdater = memo(function useUpdater() { result = await this.#checkPromise } catch (cause) { if (isNodeError(cause) && cause.code === 'ENOENT') { - return undefined + return null } throw cause @@ -53,7 +76,7 @@ const useUpdater = memo(function useUpdater() { // If the result is null or the cancel token is null, no update is avilable. if (result?.cancellationToken == null) { - return undefined + return null } this.#cancelToken = result.cancellationToken @@ -61,7 +84,7 @@ const useUpdater = memo(function useUpdater() { return result.updateInfo } - async #checkForUpdates() { + private async attemptCheckForUpdates() { if (this.#donwloadPromise != null) { throw logError(new ReferenceError('Update download already in progress')) } @@ -76,7 +99,7 @@ const useUpdater = memo(function useUpdater() { this.#checkPromise = autoUpdater.checkForUpdates() - return await this.#getUpdateInfo() + return await this.getUpdateInfo() } finally { this.#checkPromise = undefined } @@ -84,13 +107,13 @@ const useUpdater = memo(function useUpdater() { async checkForUpdates() { if (this.#checkPromise != null) { - return await this.#getUpdateInfo() + return await this.getUpdateInfo() } - return await this.#checkForUpdates() + return await this.attemptCheckForUpdates() } - async #downloadUpdate() { + private async attemptDownloadUpdate() { if (this.#cancelToken == null) { throw logError(new ReferenceError('No update available for download, check first')) } @@ -112,7 +135,7 @@ const useUpdater = memo(function useUpdater() { if (this.#donwloadPromise != null) { await this.#donwloadPromise } else { - await this.#downloadUpdate() + await this.attemptDownloadUpdate() } } @@ -133,33 +156,7 @@ const useUpdater = memo(function useUpdater() { } } - const updater = autoBind(new AppAutoUpdater()) - - const downloadWaiters = new WeakMap() - const remoteDownloadUpdate = ipcHandle(async function remoteDownloadUpdate(ev) { - let handler = downloadWaiters.get(ev.sender) - if (handler == null) { - handler = (progress) => { - ev.sender.send('update:download:progress', progress) - } - downloadWaiters.set(ev.sender, handler) - } - - try { - updater.on('progress', handler) - await updater.downloadUpdate() - } finally { - updater.off('progress', handler) - downloadWaiters.delete(ev.sender) - } - }) - - ipcMain.handle('update:check', ipcProxy(updater.checkForUpdates.bind(updater))) - ipcMain.handle('update:download', remoteDownloadUpdate) - ipcMain.handle('update:cancel', ipcProxy(updater.cancelUpdate.bind(updater))) - ipcMain.handle('update:install', ipcProxy(updater.installUpdate.bind(updater))) - - return updater + return autoBind(new AppUpdater()) }) export default useUpdater diff --git a/src/main/utilities.ts b/src/main/utilities.ts index 313c376..bff8c4b 100644 --- a/src/main/utilities.ts +++ b/src/main/utilities.ts @@ -1,28 +1,4 @@ import Logger from 'electron-log' -import type { IpcResponse } from '../preload/api' -import type { MaybePromise } from '@/basics' -import type { IpcMainInvokeEvent } from 'electron' -import { toError } from '@/error-handling' - -export const ipcProxy = (fn: (...args: Args) => MaybePromise) => - async function ipcProxied(...[, ...args]: [IpcMainInvokeEvent, ...Args]): Promise> { - try { - return { value: await fn(...args) } - } catch (e) { - return { error: toError(e) } - } - } - -export const ipcHandle = ( - fn: (ev: IpcMainInvokeEvent, ...args: Args) => MaybePromise -) => - async function ipcHandled(ev: IpcMainInvokeEvent, ...args: Args): Promise> { - try { - return { value: await fn(ev, ...args) } - } catch (e) { - return { error: toError(e) } - } - } export function logError(e: E) { Logger.error(e) diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index c99f3df..b1d30cb 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -1,252 +1,31 @@ -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 via tRPC // -/** 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' -/** Defines an event handler for a specific type of event. */ -type EventHandler = EventHandlerCallback | EventHandlerObject +export type { AppRouter } from '../main/routes/router' +export type { DocumentId } from '../main/services/database' +export type { ApiLocales } from '../main/services/locale' +export type { PortEntry } from '../main/services/ports' +export type { UpdateInfo, ProgressInfo } from '../main/services/updater' -/** 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 -} +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' +export type { ApiLocales } from '../main/locale' -/** Internal IPC response structure */ -export interface IpcReturnedValue { - error?: undefined - value: T -} - -/** Internal IPC error structure */ -export interface IpcThrownError { - error: Error - value?: undefined -} - -/** Internal IPC response structure */ -export type IpcResponse = IpcReturnedValue | IpcThrownError - -// -// Common parts -// - -/** Supported API locales. */ -export type ApiLocales = 'en' - -/** Opaque handles. */ -export type Handle = Tagged - -/** Event listener attachment options */ -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 -// - -/** Defines the localized metadata about a driver. */ -export interface LocalizedDriverDescriptor { - /** Defines the title for the driver in a specific locale. */ - readonly title: string - /** Defines the company for the driver in a specific locale. */ - readonly company: string - /** Defines the provider for the driver in a specific locale. */ - readonly provider: string -} - -/** Defines basic metadata about a device and driver. */ -export interface DriverData { - /** - * Indicates whether the driver is enabled, this is to allow partially coded drivers to be - * commited, but not usable to the UI or other code. - */ - readonly enable: boolean - /** A unique identifier for the driver. */ - readonly guid: string - /** Defines the localized driver information in all supported locales. */ - readonly localized: { - /** Defines the localized driver information in a specific locale. */ - readonly [locale in ApiLocales]: LocalizedDriverDescriptor - } - /** Defines the capabilities of the device driven by the driver. */ - readonly capabilities: number -} - -export interface DriverApi { - readonly capabilities: { - readonly kDeviceHasNoExtraCapabilities: 0 - readonly kDeviceSupportsMultipleOutputs: 1 - readonly kDeviceCanDecoupleAudioOutput: 2 - } - /** Lists registered drivers. */ - readonly list: () => Promise - /** Loads a driver. */ - readonly open: (guid: string, uri: string) => Promise - /** Powers on the switch or monitor. */ - readonly powerOn: (h: Handle) => Promise - /** Closes the device to which the driver is attached. */ - readonly powerOff: (h: Handle) => Promise - /** - * Sets input and output ties. - * - * @param inputChannel The input channel to tie. - * @param videoOutputChannel The output video channel to tie. - * @param audioOutputChannel The output audio channel to tie. - */ - readonly activate: ( - h: Handle, - inputChannel: number, - videoOutputChannel: number, - audioOutputChannel: number - ) => 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 -// - -/** Exposed session control APIs. */ -export interface SystemApi { - /** Powers off the system. */ - readonly powerOff: (interactive?: boolean) => Promise - /** Shows the open file dialog. */ - readonly openFile: (options: OpenDialogOptions) => Promise - /** Shows the save file dialog to save a file. */ - 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 -// - -type ProcessType = 'browser' | 'renderer' | 'worker' | 'utility' - -export interface ProcessData { - readonly appleStore: true | undefined - readonly arch: NodeJS.Architecture - readonly argv: readonly string[] - readonly argv0: string - readonly env: ReadonlyDeep> - readonly execPath: string - readonly platform: NodeJS.Platform - readonly resourcesPath: string - readonly sandboxed: true | undefined - readonly type: ProcessType - readonly version: string - readonly versions: NodeJS.ProcessVersions - readonly windowsStore: true | undefined -} -// -// Exposed APIs -// - -/** Functional APIs */ -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. */ - readonly freeHandle: (h: Handle) => Promise - /** Closes all handles for a page. */ - 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 - readonly cancelUpdate: () => Promise - readonly installUpdate: () => Promise -} - -export interface AppUpdates extends AppUpdater { - readonly onDownloadProgress: (fn: (progress: ProgressInfo) => void) => void - readonly offDownloadProgress: (fn: (progress: ProgressInfo) => void) => void -} - -// The exposed API global structure -declare global { - var services: MainProcessServices - var application: AppInfo - var system: System - var user: UserInfo -} +export type { + DriverKind, + DriverBindings, + DriverInformation, + DriverBasicInformation, + LocalizedDriverInformation, + // Cannot be exported as values, but they are literals. + kDeviceCanDecoupleAudioOutput, + kDeviceHasNoExtraCapabilities, + kDeviceSupportsMultipleOutputs +} from '../main/services/drivers' diff --git a/src/preload/index.ts b/src/preload/index.ts index 9c89af9..cdd119f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,26 +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 useServices from './plugins/services' - if (!process.contextIsolated) { console.error('Context isolation is not enabled') process.exit(1) } - -try { - // Register services and setup to free all handles when the window closes. - const services = useServices() - globalThis.addEventListener('beforeunload', () => { - services.freeAllHandles().catch((e: unknown) => { - console.error(e) - }) - }) - - useAppInfo() - useUserInfo() -} catch (e) { - console.error('Preload error', e) - process.exit(1) -} diff --git a/src/preload/plugins/driver.ts b/src/preload/plugins/driver.ts deleted file mode 100644 index 977df99..0000000 --- a/src/preload/plugins/driver.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { memo } from 'radash' -import { useIpc } from '../support' -import type { DriverApi } from '../api' - -const useDriverApi = memo(function useDriverApi() { - const ipc = useIpc() - - return { - capabilities: Object.freeze({ - kDeviceHasNoExtraCapabilities: 0, - kDeviceSupportsMultipleOutputs: 1, - kDeviceCanDecoupleAudioOutput: 2 - }), - list: ipc.useInvoke('driver:list'), - open: ipc.useInvoke('driver:open'), - activate: ipc.useInvoke('driver:activate'), - powerOn: ipc.useInvoke('driver:powerOn'), - powerOff: ipc.useInvoke('driver:powerOff') - } satisfies DriverApi -}) - -export default useDriverApi 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/process.ts b/src/preload/plugins/info/process.ts deleted file mode 100644 index a3e54fd..0000000 --- a/src/preload/plugins/info/process.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { memo } from 'radash' -import type { ProcessData } from '../../api' - -const useProcessData = memo(function useProcessData() { - const data = { - appleStore: process.mas || undefined, - arch: process.arch, - argv: Object.freeze(process.argv), - argv0: process.argv[0] ?? '', - env: { ...process.env }, - execPath: process.execPath, - platform: process.platform, - resourcesPath: process.resourcesPath, - sandboxed: process.sandboxed || undefined, - type: process.type, - version: process.version, - versions: process.versions, - windowsStore: process.windowsStore || undefined - } satisfies ProcessData - - return data -}) - -export default useProcessData 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 deleted file mode 100644 index 954d209..0000000 --- a/src/preload/plugins/services.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 type { MainProcessServices } from '../api' - -const useServices = memo(function useServices() { - const ipc = useIpc() - - const services = { - process: useProcessData(), - driver: useDriverApi(), - level: useLevelApi(), - ports: usePortsApi(), - startup: useStartupApi(), - system: useSystemApi(), - updates: useAppUpdates(), - freeHandle: ipc.useInvoke('handle:free'), - freeAllHandles: ipc.useInvoke('handle:clean') - } satisfies MainProcessServices - - contextBridge.exposeInMainWorld('services', services) - - return services -}) - -export default useServices 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/preload/plugins/system.ts b/src/preload/plugins/system.ts deleted file mode 100644 index 959fc1a..0000000 --- a/src/preload/plugins/system.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { basename } from 'node:path' -import { memo } from 'radash' -import { useIpc } from '../support' -import type { SystemApi } from '../api' -import type { FileData } from '@/struct' -import type { OpenDialogOptions, SaveDialogOptions } from 'electron' - -type ShowOpenDialogMain = (options: OpenDialogOptions) => Promise -type SaveFileMain = (file: FileData, options: SaveDialogOptions) => Promise - -const useSystemApi = memo(function useSystemApi() { - const ipc = useIpc() - - const openFileMain: ShowOpenDialogMain = ipc.useInvoke('system:openFile') - async function openFile(options: OpenDialogOptions) { - const files = await openFileMain(options) - if (files == null) return null - - return files.map(({ path, buffer, type }) => new File([buffer], basename(path), { type })) - } - - const saveFileMain: SaveFileMain = ipc.useInvoke('system:saveFile') - async function saveFile(file: File, options: SaveDialogOptions) { - const source = { - path: file.name, - buffer: new Uint8Array(await file.arrayBuffer()), - type: file.type - } satisfies FileData - - return await saveFileMain(source, options) - } - - return { - powerOff: ipc.useInvoke('system:powerOff'), - openFile, - saveFile - } satisfies SystemApi -}) - -export default useSystemApi diff --git a/src/preload/plugins/updates.ts b/src/preload/plugins/updates.ts deleted file mode 100644 index f576d45..0000000 --- a/src/preload/plugins/updates.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { memo } from 'radash' -import { useIpc } from '../support' -import type { AppUpdates } from '../api' - -const useAppUpdates = memo(function useAppUpdates(): AppUpdates { - const ipc = useIpc() - - const appUpdates = { - checkForUpdates: ipc.useInvoke('update:check'), - downloadUpdate: ipc.useInvoke('update:download'), - cancelUpdate: ipc.useInvoke('update:cancel'), - installUpdate: ipc.useInvoke('update:install'), - onDownloadProgress: ipc.useAddListener('update:download:progress'), - offDownloadProgress: ipc.useRemoveListener('update:download:progress') - } satisfies AppUpdates - - return appUpdates -}) - -export default useAppUpdates diff --git a/src/preload/support.ts b/src/preload/support.ts deleted file mode 100644 index cb928fb..0000000 --- a/src/preload/support.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ipcRenderer } from 'electron' -import { memo } from 'radash' -import type { IpcResponse, ListenerOptions } from './api' -import type { IpcRendererEvent } from 'electron' - -export const useIpc = memo(function useIpc() { - function useInvoke(id: Id) { - return async (...args: Args) => { - const response = (await ipcRenderer.invoke(id, ...args)) as IpcResponse - if (response.error != null) throw response.error - return response.value - } - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Supports any function type - const listeners = new WeakMap() - - function useAddListener(id: Id) { - return (fn: (...args: Args) => unknown, options?: ListenerOptions) => { - const listener = listeners.get(fn) - if (listener != null) { - // Already added. - return - } - - const wrapper = (...[, ...args]: [ev: IpcRendererEvent, ...args: Args]) => { - fn(...args) - } - - listeners.set(fn, wrapper) - - if (options?.once === true) ipcRenderer.once(id, wrapper as never) - else ipcRenderer.on(id, wrapper as never) - } - } - - function useRemoveListener(id: Id) { - return (fn: (...args: Args) => unknown) => { - const listener = listeners.get(fn) - if (listener == null) { - // Already removed or never added. - return - } - - ipcRenderer.off(id, listener as never) - } - } - - return { - useInvoke, - useAddListener, - useRemoveListener - } -}) diff --git a/src/renderer/BridgeCmdr.vue b/src/renderer/BridgeCmdr.vue index 4c368ef..aedf7cb 100644 --- a/src/renderer/BridgeCmdr.vue +++ b/src/renderer/BridgeCmdr.vue @@ -1,15 +1,16 @@