From 4e5eb5247cd01213d72f44e2fbe0c2796a26cd73 Mon Sep 17 00:00:00 2001 From: Matthew Holder Date: Tue, 5 Nov 2024 00:19:42 -0600 Subject: [PATCH] Allowed the back-end to select a port as needed, marked the kind of drivers, and added some security to the tRPC ch annel. --- PLAN.md | 2 - src/main/drivers/extron/sis.ts | 1 + src/main/drivers/shinybow/v2.ts | 1 + src/main/drivers/shinybow/v3.ts | 1 + src/main/drivers/sony/rs485.ts | 1 + src/main/drivers/tesla-smart/kvm.ts | 1 + src/main/drivers/tesla-smart/matrix.ts | 1 + src/main/drivers/tesla-smart/sdi.ts | 1 + src/main/drivers/tesmart/kvm.ts | 1 + src/main/drivers/tesmart/matrix.ts | 1 + src/main/drivers/tesmart/sdi.ts | 1 + src/main/info/config.ts | 15 ------ src/main/main.ts | 49 +++++++++---------- src/main/server.ts | 68 ++++++++++++++------------ src/main/services/drivers.ts | 6 +++ src/main/services/trpc.ts | 46 +++++++++++++++-- src/preload/api.d.ts | 11 +---- src/preload/index.ts | 9 ---- src/preload/plugins/info/config.ts | 18 ------- src/renderer/BridgeCmdr.vue | 1 - src/renderer/locales/en/messages.json | 12 +++-- src/renderer/modals/SwitchDialog.vue | 17 ++++--- src/renderer/modals/TieDialog.vue | 2 +- src/renderer/pages/SettingsPage.vue | 6 +-- src/renderer/pages/SourcePage.vue | 3 +- src/renderer/pages/SwitchList.vue | 30 ++++++------ src/renderer/services/driver.ts | 3 +- src/renderer/services/rpc.ts | 32 +++++++----- src/tests/support/stream.ts | 2 - 29 files changed, 182 insertions(+), 160 deletions(-) delete mode 100644 src/main/info/config.ts delete mode 100644 src/preload/plugins/info/config.ts diff --git a/PLAN.md b/PLAN.md index 52f9c63..6d605b3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,4 @@ - Milestones - - v2.1 - - (#86) Add a means to select a not-in-use port for the tRPC channel. - v2.2 - Move more modules to core. - tRPC over Electron IPC. diff --git a/src/main/drivers/extron/sis.ts b/src/main/drivers/extron/sis.ts index 034bebe..6a6c807 100644 --- a/src/main/drivers/extron/sis.ts +++ b/src/main/drivers/extron/sis.ts @@ -4,6 +4,7 @@ import { useExtronSisProtocol } from '../../services/protocols/extronSis' const extronSisDriver = defineDriver({ enabled: true, experimental: false, + kind: 'switch', guid: '4C8F2838-C91D-431E-84DD-3666D14A6E2C', localized: { en: { diff --git a/src/main/drivers/shinybow/v2.ts b/src/main/drivers/shinybow/v2.ts index 50c56da..c5de8c3 100644 --- a/src/main/drivers/shinybow/v2.ts +++ b/src/main/drivers/shinybow/v2.ts @@ -4,6 +4,7 @@ import { useShinybowV2Protocol } from '../../services/protocols/shinybow' const shinybowV2 = defineDriver({ enabled: true, experimental: true, + kind: 'switch', guid: '75FB7ED2-EE3A-46D5-B11F-7D8C3C208E7C', localized: { en: { diff --git a/src/main/drivers/shinybow/v3.ts b/src/main/drivers/shinybow/v3.ts index c41a416..e2c1b8d 100644 --- a/src/main/drivers/shinybow/v3.ts +++ b/src/main/drivers/shinybow/v3.ts @@ -4,6 +4,7 @@ import { useShinybowV3Protocol } from '../../services/protocols/shinybow' const shinybowV3 = defineDriver({ enabled: true, experimental: true, + kind: 'switch', guid: 'BBED08A1-C749-4733-8F2E-96C9B56C0C41', localized: { en: { diff --git a/src/main/drivers/sony/rs485.ts b/src/main/drivers/sony/rs485.ts index 8dcc32f..9661623 100644 --- a/src/main/drivers/sony/rs485.ts +++ b/src/main/drivers/sony/rs485.ts @@ -4,6 +4,7 @@ import { useSonyBvmProtocol } from '../../services/protocols/sonyBvm' const sonyRs485Driver = defineDriver({ enabled: true, experimental: false, + kind: 'monitor', guid: '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0', localized: { en: { diff --git a/src/main/drivers/tesla-smart/kvm.ts b/src/main/drivers/tesla-smart/kvm.ts index 86e6796..4d87926 100644 --- a/src/main/drivers/tesla-smart/kvm.ts +++ b/src/main/drivers/tesla-smart/kvm.ts @@ -4,6 +4,7 @@ import { useTeslaElecKvmProtocol } from '../../services/protocols/teslaElec' const teslaSmartKvmDriver = defineDriver({ enabled: true, experimental: true, + kind: 'switch', guid: '91D5BC95-A8E2-4F58-BCAC-A77BA1054D61', localized: { en: { diff --git a/src/main/drivers/tesla-smart/matrix.ts b/src/main/drivers/tesla-smart/matrix.ts index 5f1c329..ce03461 100644 --- a/src/main/drivers/tesla-smart/matrix.ts +++ b/src/main/drivers/tesla-smart/matrix.ts @@ -4,6 +4,7 @@ import { useTeslaElecMatrixProtocol } from '../../services/protocols/teslaElec' const teslaSmartMatrixDriver = defineDriver({ enabled: true, experimental: true, + kind: 'switch', guid: '671824ED-0BC4-43A6-85CC-4877890A7722', localized: { en: { diff --git a/src/main/drivers/tesla-smart/sdi.ts b/src/main/drivers/tesla-smart/sdi.ts index a93e6b3..7e54084 100644 --- a/src/main/drivers/tesla-smart/sdi.ts +++ b/src/main/drivers/tesla-smart/sdi.ts @@ -4,6 +4,7 @@ import { useTeslaElecSdiProtocol } from '../../services/protocols/teslaElec' const teslaSmartSdiDriver = defineDriver({ enabled: true, experimental: true, + kind: 'switch', guid: 'DDB13CBC-ABFC-405E-9EA6-4A999F9A16BD', localized: { en: { diff --git a/src/main/drivers/tesmart/kvm.ts b/src/main/drivers/tesmart/kvm.ts index 150a8c7..56a4e93 100644 --- a/src/main/drivers/tesmart/kvm.ts +++ b/src/main/drivers/tesmart/kvm.ts @@ -4,6 +4,7 @@ import { useTeslaElecKvmProtocol } from '../../services/protocols/teslaElec' const tesmartKvmDriver = defineDriver({ enabled: true, experimental: true, + kind: 'switch', guid: '2B4EDB8E-D2D6-4809-BA18-D5B1785DA028', localized: { en: { diff --git a/src/main/drivers/tesmart/matrix.ts b/src/main/drivers/tesmart/matrix.ts index 174fe92..76b85c6 100644 --- a/src/main/drivers/tesmart/matrix.ts +++ b/src/main/drivers/tesmart/matrix.ts @@ -4,6 +4,7 @@ import { useTeslaElecMatrixProtocol } from '../../services/protocols/teslaElec' const tesmartMatrixDriver = defineDriver({ enabled: true, experimental: true, + kind: 'switch', guid: '01B8884C-1D7D-4451-883D-3C8F18E17B14', localized: { en: { diff --git a/src/main/drivers/tesmart/sdi.ts b/src/main/drivers/tesmart/sdi.ts index 4901e54..696d6cc 100644 --- a/src/main/drivers/tesmart/sdi.ts +++ b/src/main/drivers/tesmart/sdi.ts @@ -4,6 +4,7 @@ import { useTeslaElecSdiProtocol } from '../../services/protocols/teslaElec' const tesmartSdiDriver = defineDriver({ enabled: true, experimental: true, + kind: 'switch', guid: '8C524E65-83EF-4AEF-B0DA-29C4582AA4A0', localized: { en: { diff --git a/src/main/info/config.ts b/src/main/info/config.ts deleted file mode 100644 index f54aebf..0000000 --- a/src/main/info/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { memo } from 'radash' -import type { ReadonlyDeep } from 'type-fest' - -export type AppConfig = ReadonlyDeep> -const useAppConfig = memo(function useAppConfig() { - const config = { - rpcUrl: 'ws://127.0.0.1:7180' - } - - process.env['rpc_url_'] = config.rpcUrl - - return config -}) - -export default useAppConfig diff --git a/src/main/main.ts b/src/main/main.ts index 4bf50a0..48d5576 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -5,8 +5,8 @@ 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 useAppConfig from './info/config' import useApiServer from './server' +import { getAuthToken } from './services/trpc' import { logError } from './utilities' import { toError } from '@/error-handling' @@ -18,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) }) @@ -52,28 +49,32 @@ 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 sleep(kWait) } } + /* eslint-enable no-await-in-loop */ throw logError(toError(lastError)) } @@ -101,15 +102,13 @@ process.on('SIGTERM', () => { }) // 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') -useAppConfig() - -useApiServer() - -await createWindow() +const port = useApiServer() +await createWindow(port) diff --git a/src/main/server.ts b/src/main/server.ts index a558b38..66669c8 100644 --- a/src/main/server.ts +++ b/src/main/server.ts @@ -1,12 +1,11 @@ -import { createHTTPServer } from '@trpc/server/adapters/standalone' import { applyWSSHandler } from '@trpc/server/adapters/ws' import Logger from 'electron-log' +import { range } from 'radash' import { WebSocketServer } from 'ws' -import useAppConfig from './info/config' import { useAppRouter } from './routes/router' -import { getServerUrl } from '@/url' +import { createStandaloneContext } from './services/trpc' -function startWebSocketServer(url: URL, host: string, port: number) { +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' @@ -17,7 +16,7 @@ function startWebSocketServer(url: URL, host: string, port: number) { Logger.info(`RPC server at ${url}`) }) - const handler = applyWSSHandler({ wss, router: useAppRouter() }) + const handler = applyWSSHandler({ wss, router: useAppRouter(), createContext: createStandaloneContext }) process.on('exit', () => { handler.broadcastReconnectNotification() @@ -30,32 +29,41 @@ function startWebSocketServer(url: URL, host: string, port: number) { }) } -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() - }) -} +// 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() { - const config = useAppConfig() - const url = new URL(config.rpcUrl) - const [host, port, protocol] = getServerUrl(url, 7180) + 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) + } + } - if (protocol === 'http:') startHttpServer(url, host, port) - if (protocol === 'ws:') startWebSocketServer(url, host, port) + throw new Error('No port available for the server within 7000-8000', { cause }) } diff --git a/src/main/services/drivers.ts b/src/main/services/drivers.ts index f46f27d..eb220ae 100644 --- a/src/main/services/drivers.ts +++ b/src/main/services/drivers.ts @@ -18,6 +18,8 @@ export const kDeviceSupportsMultipleOutputs = 1 export type kDeviceCanDecoupleAudioOutput = typeof kDeviceCanDecoupleAudioOutput export const kDeviceCanDecoupleAudioOutput = 2 +export type DriverKind = 'monitor' | 'switch' + // // Driver definition // @@ -30,6 +32,8 @@ export interface DriverBasicInformation { 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. */ @@ -114,6 +118,7 @@ export function defineDriver(options: DefineDriverOptions) { return { enabled: info.enabled, experimental: info.experimental, + kind: info.kind, guid: info.guid, ...localizedInfo, capabilities: info.capabilities @@ -129,6 +134,7 @@ export function defineDriver(options: DefineDriverOptions) { // Information and informational functionality. enabled: info.enabled, experimental: info.experimental, + kind: info.kind, guid: info.guid, capabilities: info.capabilities, metadata: info, diff --git a/src/main/services/trpc.ts b/src/main/services/trpc.ts index 0a83fe2..8ee9032 100644 --- a/src/main/services/trpc.ts +++ b/src/main/services/trpc.ts @@ -1,8 +1,48 @@ -import { initTRPC } from '@trpc/server' +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' -const t = initTRPC.create({ +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 { router, procedure, createCallerFactory } = t +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/preload/api.d.ts b/src/preload/api.d.ts index a077663..b1d30cb 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -5,7 +5,6 @@ import type { AppConfig } from '../main/info/config' // export type { AppInfo } from '../main/info/app' -export type { AppConfig } from '../main/info/config' export type { UserInfo } from '../main/info/user' export type { AppRouter } from '../main/routes/router' @@ -20,6 +19,7 @@ export type { Tie, NewTie, TieUpdate } from '../main/dao/ties' export type { ApiLocales } from '../main/locale' export type { + DriverKind, DriverBindings, DriverInformation, DriverBasicInformation, @@ -29,12 +29,3 @@ export type { kDeviceHasNoExtraCapabilities, kDeviceSupportsMultipleOutputs } from '../main/services/drivers' - -// -// Exposed APIs -// - -// The exposed API global structure -declare global { - var configuration: AppConfig -} diff --git a/src/preload/index.ts b/src/preload/index.ts index 49d5baf..cdd119f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,15 +1,6 @@ /* eslint-disable n/no-process-exit -- No real way to do this otherwise */ -import useAppConfig from './plugins/info/config' - if (!process.contextIsolated) { console.error('Context isolation is not enabled') process.exit(1) } - -try { - useAppConfig() -} catch (e) { - console.error('Preload error', e) - process.exit(1) -} diff --git a/src/preload/plugins/info/config.ts b/src/preload/plugins/info/config.ts deleted file mode 100644 index 8858d84..0000000 --- a/src/preload/plugins/info/config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import is from '@sindresorhus/is' -import { contextBridge } from 'electron' -import { memo } from 'radash' -import type { AppConfig } from '../../api' - -const useAppConfig = memo(function useAppConfig() { - if (!is.nonEmptyString(process.env['rpc_url_'])) throw new ReferenceError('Missing appConfig.rpcUrl') - - const appConfig = { - rpcUrl: process.env['rpc_url_'] - } satisfies AppConfig - - contextBridge.exposeInMainWorld('configuration', appConfig) - - return appConfig -}) - -export default useAppConfig diff --git a/src/renderer/BridgeCmdr.vue b/src/renderer/BridgeCmdr.vue index d8b3a24..aedf7cb 100644 --- a/src/renderer/BridgeCmdr.vue +++ b/src/renderer/BridgeCmdr.vue @@ -36,7 +36,6 @@ useTitle(() => appInfo.value.name) const appUpdater = useAppUpdates() const progress = ref() useEventListener(appUpdater, 'progress', (ev: UpdateProgressEvent) => { - console.log(ev) progress.value = ev }) diff --git a/src/renderer/locales/en/messages.json b/src/renderer/locales/en/messages.json index 8ac79de..d5aa957 100644 --- a/src/renderer/locales/en/messages.json +++ b/src/renderer/locales/en/messages.json @@ -20,21 +20,23 @@ "zod": "Validation failed" }, "label": { + "backup": "Backup settings", "driver": "Driver", "general": "General", "image": "Image", - "remote": "Remote host", "loading": "Loading...", "name": "Name", "port": "Port", - "backup": "Backup settings", + "remote": "Remote host", "settings": "Settings", - "sources": "Sources", "source": "Source", - "switches": "Switches", + "sources": "Sources", "switch": "Switch", - "ties": "Ties", + "switches": "Switches", + "switchesAndMonitors": "Switches and Monitors", + "switchOrMonitor": "Switch or monitor", "tie": "Tie", + "ties": "Ties", "type": "Type" }, "message": { diff --git a/src/renderer/modals/SwitchDialog.vue b/src/renderer/modals/SwitchDialog.vue index 57883b4..13f346f 100644 --- a/src/renderer/modals/SwitchDialog.vue +++ b/src/renderer/modals/SwitchDialog.vue @@ -33,8 +33,6 @@ const dialogs = useDialogs() const isVisible = useVModel(props, 'visible', emit) -const title = computed(() => (props.editing ? t('label.addSwitch') : t('label.editSwitch'))) - const drivers = useDrivers() onBeforeMount(drivers.all) @@ -54,6 +52,7 @@ const { locationPath, pathTypes, pathType, path } = useLocation(location, () => const driver = computed(() => drivers.items.find((d) => d.guid === target.value.driverId)) const driverSerach = ref('') +const driverKind = computed(() => (driver.value?.kind === 'monitor' ? t('label.monitor') : t('label.switch'))) function confirm() { isVisible.value = false @@ -68,7 +67,7 @@ async function cancelIfConfirmed() { } const yes = await dialogs.confirm({ - message: props.editing ? t('message.discardChanges') : t('message.discardNew'), + message: props.editing ? t('message.discardChanges') : t('message.discardNew', [driverKind.value]), color: 'primary', confirmButton: t('action.discard'), cancelButton: t('common.cancel') @@ -95,6 +94,10 @@ const { dirty, getStatus, submit, v$ } = useValidation(rules, target, confirm) const { cardProps, isFullscreen, body, showDividers } = useSwitchDialog() const isBusy = computed(() => drivers.isBusy || ports.isBusy) + +const title = computed(() => + props.editing ? t('label.addDevice', [driverKind.value]) : t('label.editDevice', [driverKind.value]) +)