diff --git a/src/core/error-handling.ts b/src/core/error-handling.ts index 2067250..b62e622 100644 --- a/src/core/error-handling.ts +++ b/src/core/error-handling.ts @@ -1,7 +1,6 @@ +import type { IsAny } from 'type-fest' import type { z } from 'zod' -export type ToError = E extends Error ? E : Error - const toPath = (path: (number | string)[]) => path .map((segment) => (typeof segment === 'number' ? `[${segment}]` : segment)) @@ -31,9 +30,12 @@ export function getMessage(cause: unknown) { return cause.message } -export function toError(cause: Cause) { - if (cause instanceof Error) return cause as ToError - return new Error(String(cause)) as ToError +export type AsError = IsAny extends true ? Error : T extends Error ? T : Error + +export function toError(cause: Cause): AsError +export function toError(cause: unknown) { + if (cause instanceof Error) return cause + return new Error(getMessage(cause)) } export function raiseError(factory: () => Error): never { diff --git a/src/core/rpc.ts b/src/core/rpc.ts deleted file mode 100644 index c0abf0d..0000000 --- a/src/core/rpc.ts +++ /dev/null @@ -1,21 +0,0 @@ -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/rpc/ipc.ts b/src/core/rpc/ipc.ts new file mode 100644 index 0000000..0d20238 --- /dev/null +++ b/src/core/rpc/ipc.ts @@ -0,0 +1,7 @@ +import type { IpcMainInvokeEvent } from 'electron' + +export const theRpcChannel = 'trpc:msg' + +export interface CreateContextOptions { + event: IpcMainInvokeEvent +} diff --git a/src/core/rpc/transformer.ts b/src/core/rpc/transformer.ts new file mode 100644 index 0000000..9bc0446 --- /dev/null +++ b/src/core/rpc/transformer.ts @@ -0,0 +1,36 @@ +import { Base64 } from 'js-base64' +import { SuperJSON } from 'superjson' +import { Attachment } from '../attachments' +import { raiseError } from '../error-handling' + +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' + ) + + // HACK: tRPC won't serialize functions; but doesn't filter + // them out, so IPC throws an error if they are passed. + // This can also ensure any types with functions + // won't be returned as seen in the return + // type information for the router. + SuperJSON.registerCustom( + { + isApplicable: (v): v is () => undefined => typeof v === 'function', + serialize: (_: () => undefined) => raiseError(() => new TypeError('Functions may not be serialized')), + deserialize: (_: never) => () => undefined + }, + 'Function' + ) + + return SuperJSON +} diff --git a/src/main/main.ts b/src/main/main.ts index 48d5576..c0c35c4 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 useApiServer from './server' -import { getAuthToken } from './services/trpc' +import { useAppRouter } from './routes/router' +import { createIpcHandler } from './services/rpc/ipc' import { logError } from './utilities' import { toError } from '@/error-handling' @@ -18,7 +18,7 @@ Logger.transports.console.format = '{h}:{i}:{s}.{ms} [{level}] › {text}' Logger.transports.file.level = 'debug' Logger.errorHandler.startCatching() -async function createWindow(port: number) { +async function createWindow() { const willStartWithDark = nativeTheme.shouldUseDarkColors || nativeTheme.shouldUseInvertedColorScheme const window = new BrowserWindow({ @@ -27,7 +27,11 @@ async function createWindow(port: number) { backgroundColor: willStartWithDark ? '#121212' : 'white', icon: appIcon, show: true, - useContentSize: true + useContentSize: true, + webPreferences: { + preload: joinPath(__dirname, '../preload/index.mjs'), + sandbox: false + } }) window.removeMenu() @@ -55,14 +59,9 @@ async function createWindow(port: number) { // 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) { - 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()) + await window.loadURL(process.env.ELECTRON_RENDERER_URL) } else { - await window.loadFile(joinPath(__dirname, '../renderer/index.html'), { - query: { port: String(port), auth: getAuthToken() } - }) + await window.loadFile(joinPath(__dirname, '../renderer/index.html')) } return window @@ -110,5 +109,7 @@ await app.whenReady() // Set app user model id for windows electronApp.setAppUserModelId('org.sleepingcats.BridgeCmdr') -const port = useApiServer() -await createWindow(port) +// If macOS close vs quit behavior is reimplemented, we will have +// to make sure port and handler are accessible earlier. +const handler = createIpcHandler({ router: useAppRouter() }) +handler.attachWindow(await createWindow()) diff --git a/src/main/routes/data/sources.ts b/src/main/routes/data/sources.ts index 53099dd..612a9ef 100644 --- a/src/main/routes/data/sources.ts +++ b/src/main/routes/data/sources.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { NewSource, SourceUpdate, useSourcesDatabase } from '../../dao/sources' import { DocumentId } from '../../services/database' -import { procedure, router } from '../../services/trpc' +import { procedure, router } from '../../services/rpc/trpc' import { Attachment } from '@/attachments' export type { Source, NewSource, SourceUpdate } from '../../dao/sources' diff --git a/src/main/routes/data/storage.ts b/src/main/routes/data/storage.ts index 5ce4569..a91bc0c 100644 --- a/src/main/routes/data/storage.ts +++ b/src/main/routes/data/storage.ts @@ -1,7 +1,7 @@ import { memo } from 'radash' import { z } from 'zod' import useUserStore from '../../dao/storage' -import { procedure, router } from '../../services/trpc' +import { procedure, router } from '../../services/rpc/trpc' const useUserStoreRouter = memo(function useUserStoreRouter() { const storage = useUserStore() diff --git a/src/main/routes/data/switches.ts b/src/main/routes/data/switches.ts index d282b49..9209a2f 100644 --- a/src/main/routes/data/switches.ts +++ b/src/main/routes/data/switches.ts @@ -1,6 +1,6 @@ import { NewSwitch, SwitchUpdate, useSwitchesDatabase } from '../../dao/switches' import { DocumentId } from '../../services/database' -import { procedure, router } from '../../services/trpc' +import { procedure, router } from '../../services/rpc/trpc' export { Switch, NewSwitch, SwitchUpdate } from '../../dao/switches' diff --git a/src/main/routes/data/ties.ts b/src/main/routes/data/ties.ts index cf1d821..b8d9a17 100644 --- a/src/main/routes/data/ties.ts +++ b/src/main/routes/data/ties.ts @@ -1,6 +1,6 @@ import useTiesDatabase, { NewTie, TieUpdate } from '../../dao/ties' import { DocumentId } from '../../services/database' -import { procedure, router } from '../../services/trpc' +import { procedure, router } from '../../services/rpc/trpc' export type { Tie, NewTie, TieUpdate } from '../../dao/ties' diff --git a/src/main/routes/drivers.ts b/src/main/routes/drivers.ts index ae6fb8f..4d0c409 100644 --- a/src/main/routes/drivers.ts +++ b/src/main/routes/drivers.ts @@ -1,7 +1,7 @@ import { memo } from 'radash' import { z } from 'zod' import useDrivers from '../services/drivers' -import { procedure, router } from '../services/trpc' +import { procedure, router } from '../services/rpc/trpc' const Channel = z.number().int().nonnegative().finite() @@ -12,7 +12,7 @@ const useDriversRouter = memo(function useDriversRoute() { const drivers = useDrivers() return router({ - all: procedure.query(drivers.all), + all: procedure.query(drivers.allInfo), get: procedure.input(z.string().uuid()).query(async ({ input }) => { await drivers.get(input) }), diff --git a/src/main/routes/ports.ts b/src/main/routes/ports.ts index 3fd59ec..a9541e1 100644 --- a/src/main/routes/ports.ts +++ b/src/main/routes/ports.ts @@ -1,7 +1,7 @@ import { memo } from 'radash' import { z } from 'zod' import useSerialPorts from '../services/ports' -import { procedure, router } from '../services/trpc' +import { procedure, router } from '../services/rpc/trpc' const useSerialPortRouter = memo(function useSerialPortRouter() { const ports = useSerialPorts() diff --git a/src/main/routes/router.ts b/src/main/routes/router.ts index 1647c41..a6431e4 100644 --- a/src/main/routes/router.ts +++ b/src/main/routes/router.ts @@ -1,7 +1,7 @@ import { memo } from 'radash' import useAppInfo from '../info/app' import useUserInfo from '../info/user' -import { createCallerFactory, procedure, router } from '../services/trpc' +import { procedure, router } from '../services/rpc/trpc' import useSourcesRouter from './data/sources' import useUserStoreRouter from './data/storage' import useSwitchesRouter from './data/switches' @@ -32,4 +32,3 @@ export const useAppRouter = memo(() => ) export type AppRouter = ReturnType -export const createCaller = createCallerFactory(useAppRouter()) diff --git a/src/main/routes/startup.ts b/src/main/routes/startup.ts index f91bd8e..76fed02 100644 --- a/src/main/routes/startup.ts +++ b/src/main/routes/startup.ts @@ -1,6 +1,6 @@ import { memo } from 'radash' +import { procedure, router } from '../services/rpc/trpc' import useStartup from '../services/startup' -import { procedure, router } from '../services/trpc' const useStartupRouter = memo(function useStartupRouter() { const startup = useStartup() diff --git a/src/main/routes/system.ts b/src/main/routes/system.ts index a7c9317..fd8b41f 100644 --- a/src/main/routes/system.ts +++ b/src/main/routes/system.ts @@ -1,7 +1,7 @@ import { memo } from 'radash' import { z } from 'zod' +import { procedure, router } from '../services/rpc/trpc' import useSystem from '../services/system' -import { procedure, router } from '../services/trpc' const useSystemRouter = memo(function useSystemRouter() { const system = useSystem() diff --git a/src/main/routes/updater.ts b/src/main/routes/updater.ts index ab8e23e..1b8ea29 100644 --- a/src/main/routes/updater.ts +++ b/src/main/routes/updater.ts @@ -1,6 +1,6 @@ import { observable } from '@trpc/server/observable' import { memo } from 'radash' -import { procedure, router } from '../services/trpc' +import { procedure, router } from '../services/rpc/trpc' import useUpdater from '../services/updater' import type { AppUpdaterEventMap } from '../services/updater' diff --git a/src/main/server.ts b/src/main/server.ts deleted file mode 100644 index 66669c8..0000000 --- a/src/main/server.ts +++ /dev/null @@ -1,69 +0,0 @@ -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/drivers.ts b/src/main/services/drivers.ts index eb220ae..4fbccef 100644 --- a/src/main/services/drivers.ts +++ b/src/main/services/drivers.ts @@ -162,7 +162,13 @@ const useDrivers = memo(function useDriver() { } } - const all = defineOperation(() => Array.from(registry.values()).filter((driver) => driver.enabled)) + const registered = defineOperation(() => Array.from(registry.values()).filter((driver) => driver.enabled)) + + const allInfo = defineOperation(() => + Array.from(registry.values()) + .filter((driver) => driver.enabled) + .map((d) => d.metadata) + ) const get = defineOperation((guid: string) => registry.get(guid) ?? null) @@ -193,7 +199,8 @@ const useDrivers = memo(function useDriver() { }) return { - all, + registered, + allInfo, get, activate, powerOn, diff --git a/src/main/services/rpc/ipc.ts b/src/main/services/rpc/ipc.ts new file mode 100644 index 0000000..08d69a6 --- /dev/null +++ b/src/main/services/rpc/ipc.ts @@ -0,0 +1,176 @@ +import { callProcedure, getDataTransformer, getTRPCErrorFromUnknown, TRPCError } from '@trpc/server' +import { isObservable } from '@trpc/server/observable' +import { getErrorShape, transformTRPCResponse } from '@trpc/server/shared' +import { ipcMain } from 'electron' +import Logger from 'electron-log' +import type { MaybePromise } from '@/basics' +import type { CreateContextOptions } from '@/rpc/ipc' +import type { AnyRouter, inferRouterContext } from '@trpc/server' +import type { Unsubscribable } from '@trpc/server/observable' +import type { TRPCClientOutgoingMessage, TRPCResponseMessage } from '@trpc/server/rpc' +import type { BrowserWindow, IpcMainEvent } from 'electron' +import { theRpcChannel } from '@/rpc/ipc' + +interface CreateIpcHandlerOptions { + router: Router + createContext?: (opts: CreateContextOptions) => MaybePromise> + windows?: BrowserWindow[] +} + +/** + * Creates a tRPC handler using the Electron IPC. + * + * This handler is modelled after the web-socket handler in tRPC. + * + * @param options - Handler options + * @returns Return a tRPC handler for Electron IPC. + */ +export function createIpcHandler(options: CreateIpcHandlerOptions) { + const windows = new Set() + const subscriptions = new Map() + const { router, createContext } = options + const config = router._def._config + + const transformer = getDataTransformer(config.transformer as never) + + function getSubscriptionId(id: number, frameId: number | undefined) { + return frameId != null ? `${id}-${frameId}` : `${id}-` + } + + function cleanUpSubscriptions(id: number, frameId?: number) { + const removing = new Set() + for (const [key, subscription] of subscriptions) { + if (key.startsWith(getSubscriptionId(id, frameId))) { + subscription.unsubscribe() + removing.add(key) + } + } + for (const key of removing) { + subscriptions.delete(key) + } + } + + function setupSubscriptionCleanup(window: BrowserWindow) { + window.webContents.on('did-start-navigation', (event) => { + cleanUpSubscriptions(window.webContents.id, event.frame.routingId) + }) + window.webContents.on('destroyed', () => { + detachWindow(window) + }) + } + + function attachWindow(window: BrowserWindow) { + if (windows.has(window)) return + + windows.add(window) + setupSubscriptionCleanup(window) + } + + function detachWindow(window: BrowserWindow) { + if (!windows.has(window)) return + + windows.delete(window) + cleanUpSubscriptions(window.webContents.id) + } + + function unsubscribe(id: string) { + const subscription = subscriptions.get(id) + if (subscription == null) return + + subscription.unsubscribe() + subscriptions.delete(id) + } + + async function handleMessage(event: IpcMainEvent, msg: TRPCClientOutgoingMessage) { + if (msg.id == null) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '`id` is required' }) + } + + const { id, jsonrpc = '2.0' } = msg + const subscriptionId = `${event.sender.id}-${event.senderFrame.routingId}:${id}` + function respond(response: TRPCResponseMessage) { + if (event.sender.isDestroyed()) return + const rpcResponse = transformTRPCResponse(config, response) + event.reply(theRpcChannel, rpcResponse) + } + + function stopSubscription() { + unsubscribe(subscriptionId) + respond({ id, jsonrpc, result: { type: 'stopped' } }) + } + + if (msg.method === 'subscription.stop') { + stopSubscription() + return + } + + const path = msg.params.path + const input = transformer.input.deserialize(msg.params.input) as unknown + const type = msg.method + const ctx = (await createContext?.({ event })) ?? {} + + try { + const result = await callProcedure({ + procedures: router._def.procedures as never, + path, + rawInput: input, + ctx, + type + }) + + if (type !== 'subscription') { + respond({ id, jsonrpc, result: { type: 'data', data: result } }) + return + } else if (!isObservable(result)) { + throw new TRPCError({ + message: `Subscription ${path} did not return an observable`, + code: 'INTERNAL_SERVER_ERROR' + }) + } + + const observable = result + const sub = observable.subscribe({ + next(data) { + respond({ id, jsonrpc, result: { type: 'data', data } }) + }, + error(err) { + const error = getTRPCErrorFromUnknown(err) + respond({ id, jsonrpc, error: getErrorShape({ config, error, type, path, input, ctx }) as never }) + }, + complete() { + respond({ id, jsonrpc, result: { type: 'stopped' } }) + } + }) + + if (subscriptions.has(subscriptionId)) { + stopSubscription() + throw new TRPCError({ message: `Duplicate id ${id}`, code: 'BAD_REQUEST' }) + } + + subscriptions.set(subscriptionId, sub) + + respond({ id, jsonrpc, result: { type: 'started' } }) + } catch (err) { + const error = getTRPCErrorFromUnknown(err) + respond({ id, jsonrpc, error: getErrorShape({ config, error, type, path, input, ctx }) as never }) + } + } + + // Initialization + + for (const window of options.windows ?? []) { + attachWindow(window) + } + + ipcMain.on(theRpcChannel, (event, message: TRPCClientOutgoingMessage) => { + handleMessage(event, message).catch((err: unknown) => { + Logger.error('Fatal error in IPC', err, message) + throw err + }) + }) + + return { + attachWindow, + detachWindow + } +} diff --git a/src/main/services/rpc/trpc.ts b/src/main/services/rpc/trpc.ts new file mode 100644 index 0000000..f0395ad --- /dev/null +++ b/src/main/services/rpc/trpc.ts @@ -0,0 +1,6 @@ +import { initTRPC } from '@trpc/server' +import useSuperJson from '@/rpc/transformer' + +const t = initTRPC.create({ transformer: useSuperJson() }) + +export const { router, procedure } = t diff --git a/src/main/services/trpc.ts b/src/main/services/trpc.ts deleted file mode 100644 index 8ee9032..0000000 --- a/src/main/services/trpc.ts +++ /dev/null @@ -1,48 +0,0 @@ -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/preload/api.d.ts b/src/preload/api.d.ts index b1d30cb..d83a253 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -1,4 +1,5 @@ import type { AppConfig } from '../main/info/config' +import type { RpcInterface } from './index' // // Exposed via tRPC @@ -29,3 +30,7 @@ export type { kDeviceHasNoExtraCapabilities, kDeviceSupportsMultipleOutputs } from '../main/services/drivers' + +declare global { + var rpc: RpcInterface +} diff --git a/src/preload/index.ts b/src/preload/index.ts index cdd119f..3100738 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,29 @@ +import { contextBridge, ipcRenderer } from 'electron' +import type { TRPCClientIncomingMessage, TRPCClientOutgoingMessage } from '@trpc/server/rpc' +import { theRpcChannel } from '@/rpc/ipc' + /* eslint-disable n/no-process-exit -- No real way to do this otherwise */ +export type RpcInterface = ReturnType + +function exposeRpc() { + const rpc = { + sendMessage(operation: TRPCClientOutgoingMessage) { + ipcRenderer.send(theRpcChannel, operation) + }, + onMessage(callback: (args: TRPCClientIncomingMessage) => void) { + ipcRenderer.on(theRpcChannel, (_, args: TRPCClientIncomingMessage) => { + callback(args) + }) + } + } + + contextBridge.exposeInMainWorld('rpc', rpc) + return rpc +} + +exposeRpc() + if (!process.contextIsolated) { console.error('Context isolation is not enabled') process.exit(1) diff --git a/src/renderer/BridgeCmdr.vue b/src/renderer/BridgeCmdr.vue index aedf7cb..fabba9a 100644 --- a/src/renderer/BridgeCmdr.vue +++ b/src/renderer/BridgeCmdr.vue @@ -7,7 +7,7 @@ import AlertModal from './modals/AlertModal.vue' import ConfirmModal from './modals/ConfirmModal.vue' import { useDialogs } from './modals/dialogs' import useAppUpdates from './services/appUpdates' -import { useClient } from './services/rpc' +import { useClient } from './services/rpc/trpc' import useSettings from './services/settings' import type { I18nSchema } from './locales/locales' import type { UpdateProgressEvent } from './services/appUpdates' diff --git a/src/renderer/pages/FirstRunLogic.vue b/src/renderer/pages/FirstRunLogic.vue index fc789db..11b2228 100644 --- a/src/renderer/pages/FirstRunLogic.vue +++ b/src/renderer/pages/FirstRunLogic.vue @@ -3,7 +3,7 @@ import { useAsyncState, useLocalStorage, watchOnce } from '@vueuse/core' import { useI18n } from 'vue-i18n' import { trackBusy } from '../hooks/tracking' import { useDialogs } from '../modals/dialogs' -import { useClient } from '../services/rpc' +import { useClient } from '../services/rpc/trpc' import useStartup from '../services/startup' import type { I18nSchema } from '../locales/locales' diff --git a/src/renderer/pages/GeneralPage.vue b/src/renderer/pages/GeneralPage.vue index d96efa1..aa69a24 100644 --- a/src/renderer/pages/GeneralPage.vue +++ b/src/renderer/pages/GeneralPage.vue @@ -16,7 +16,7 @@ import OptionDialog from '../components/OptionDialog.vue' import Page from '../components/Page.vue' import { trackBusy } from '../hooks/tracking' import { useGuardedAsyncOp } from '../hooks/utilities' -import { useClient } from '../services/rpc' +import { useClient } from '../services/rpc/trpc' import useSettings from '../services/settings' import type { I18nSchema } from '../locales/locales' diff --git a/src/renderer/pages/MainDashboard.vue b/src/renderer/pages/MainDashboard.vue index a049a24..c993344 100644 --- a/src/renderer/pages/MainDashboard.vue +++ b/src/renderer/pages/MainDashboard.vue @@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n' import { useGuardedAsyncOp } from '../hooks/utilities' import { useDialogs } from '../modals/dialogs' import { useDashboard } from '../services/dashboard' -import { useClient } from '../services/rpc' +import { useClient } from '../services/rpc/trpc' import useSettings from '../services/settings' import FirstRunLogic from './FirstRunLogic.vue' import type { I18nSchema } from '../locales/locales' diff --git a/src/renderer/pages/SettingsPage.vue b/src/renderer/pages/SettingsPage.vue index 752a567..556b921 100644 --- a/src/renderer/pages/SettingsPage.vue +++ b/src/renderer/pages/SettingsPage.vue @@ -14,7 +14,7 @@ import { useRouter } from 'vue-router' import Page from '../components/Page.vue' import { trackBusy } from '../hooks/tracking' import { useGuardedAsyncOp } from '../hooks/utilities' -import { useClient } from '../services/rpc' +import { useClient } from '../services/rpc/trpc' import { useSources } from '../services/sources' import { useSwitches } from '../services/switches' import type { I18nSchema } from '../locales/locales' diff --git a/src/renderer/services/appUpdates.ts b/src/renderer/services/appUpdates.ts index 0f14734..1aa23b2 100644 --- a/src/renderer/services/appUpdates.ts +++ b/src/renderer/services/appUpdates.ts @@ -1,6 +1,6 @@ import { createSharedComposable, tryOnScopeDispose } from '@vueuse/core' import useTypedEventTarget from '../support/events' -import { useClient } from './rpc' +import { useClient } from './rpc/trpc' import type { ProgressInfo } from 'electron-updater' export class UpdateProgressEvent extends Event implements ProgressInfo { diff --git a/src/renderer/services/driver.ts b/src/renderer/services/driver.ts index 913c1d8..dad7b21 100644 --- a/src/renderer/services/driver.ts +++ b/src/renderer/services/driver.ts @@ -2,7 +2,7 @@ import { createSharedComposable } from '@vueuse/shared' import { computed, reactive, readonly, ref, shallowReadonly } from 'vue' import { trackBusy } from '../hooks/tracking' import i18n from '../plugins/i18n' -import { useClient } from './rpc' +import { useClient } from './rpc/trpc' import type { kDeviceHasNoExtraCapabilities as HasNoExtraCapabilities, kDeviceSupportsMultipleOutputs as SupportsMultipleOutputs, @@ -58,9 +58,7 @@ const useDrivers = createSharedComposable(function useDrivers() { async function all() { if (items.value.length > 0) return items.value const drivers = await tracker.wait(client.drivers.all.query()) - for (const { - metadata: { enabled, experimental, kind, guid, localized, capabilities } - } of drivers) { + for (const { enabled, experimental, kind, guid, localized, capabilities } of drivers) { /** The localized driver information made i18n compatible. */ for (const [locale, description] of Object.entries(localized)) { i18n.global.mergeLocaleMessage(locale as never, { diff --git a/src/renderer/services/ports.ts b/src/renderer/services/ports.ts index f278617..b269af6 100644 --- a/src/renderer/services/ports.ts +++ b/src/renderer/services/ports.ts @@ -1,7 +1,7 @@ import { createSharedComposable } from '@vueuse/shared' import { ref, computed, readonly, reactive } from 'vue' import { trackBusy } from '../hooks/tracking' -import { useClient } from './rpc' +import { useClient } from './rpc/trpc' import type { PortEntry } from '../../preload/api' export type { PortEntry } from '../../preload/api' diff --git a/src/renderer/services/rpc.ts b/src/renderer/services/rpc.ts deleted file mode 100644 index eccd5ec..0000000 --- a/src/renderer/services/rpc.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createTRPCProxyClient, createWSClient, wsLink } from '@trpc/client' -import { createGlobalState } from '@vueuse/shared' -import type { AppRouter } from '../../preload/api' -import useSuperJson from '@/rpc' - -export const useClient = createGlobalState(function useClient() { - // TODO: May be needed for the remove server one day. - // function useHttpClient() { - // return createTRPCProxyClient({ - // transformer: useSuperJson(), - // links: [httpLink({ url: rpcUrl })] - // }) - // } - - function useWsClient() { - const location = new URL(globalThis.location.href) - const port = Number(location.searchParams.get('port')) - if (Number.isNaN(port)) throw new ReferenceError('No RPC port available') - - const auth = location.searchParams.get('auth') - if (!auth) throw new ReferenceError('No auth token available') - - const url = `ws://127.0.0.1:${port}/?auth=${auth}` - console.log(`Connecting to RPC server at ${url}`) - const client = createWSClient({ url }) - return createTRPCProxyClient({ - transformer: useSuperJson(), - links: [wsLink({ client })] - }) - } - - return useWsClient() -}) diff --git a/src/renderer/services/rpc/link.ts b/src/renderer/services/rpc/link.ts new file mode 100644 index 0000000..f635c4d --- /dev/null +++ b/src/renderer/services/rpc/link.ts @@ -0,0 +1,105 @@ +import { TRPCClientError } from '@trpc/client' +import { observable } from '@trpc/server/observable' +import { transformResults } from './utilities' +import type { Operation, TRPCLink } from '@trpc/client' +import type { AnyRouter, inferRouterContext, ProcedureType } from '@trpc/server' +import type { Observer } from '@trpc/server/observable' +import type { TRPCClientIncomingRequest, TRPCRequestMessage, TRPCResponseMessage } from '@trpc/server/rpc' + +type CallbackResult = TRPCResponseMessage< + Output, + inferRouterContext +> + +type CallbackObserver = Observer< + CallbackResult, + TRPCClientError +> + +interface Request { + type: ProcedureType + callbacks: CallbackObserver + op: Operation +} + +export function useIpcLink(): TRPCLink { + const pendingRequests = new Map>() + const rpc = globalThis.rpc + + function handleIncomingRequest(_req: TRPCClientIncomingRequest) { + console.debug('handleIncomingRequest is unnecessary') + } + + function handleIncomingResponse(data: TRPCResponseMessage) { + const req = data.id != null ? pendingRequests.get(data.id) : null + if (req == null) return + + req.callbacks.next(data) + if ('result' in data && data.result.type === 'stopped') { + req.callbacks.complete() + } + } + + rpc.onMessage(function handleMessage(msg) { + if ('method' in msg) { + handleIncomingRequest(msg) + } else { + handleIncomingResponse(msg) + } + }) + + function request(op: Operation, callbacks: CallbackObserver) { + const { type, input, path, id } = op + const envelope = { id, method: type, params: { input, path } } satisfies TRPCRequestMessage + pendingRequests.set(id, { type, callbacks, op }) + + rpc.sendMessage(envelope) + + return () => { + const cbs = pendingRequests.get(id)?.callbacks + pendingRequests.delete(id) + + cbs?.complete() + if (type === 'subscription') { + rpc.sendMessage({ id, method: 'subscription.stop' }) + } + } + } + + return function ipcLink(runtime) { + return function ipcProcess({ op }) { + return observable(function ipcObserve(observer) { + const { type, path, id, context } = op + const input = runtime.transformer.serialize(op.input) as unknown + + const unsub = request( + { type, path, input, id, context }, + { + error(err) { + observer.error(err) + unsub() + }, + complete() { + observer.complete() + }, + next(response) { + const transformed = transformResults(response, runtime) + if (!transformed.ok) { + observer.error(TRPCClientError.from(transformed.error)) + return + } + + observer.next({ result: transformed.result }) + if (op.type !== 'subscription') { + unsub() + observer.complete() + } + } + } + ) + + return unsub + }) + } + } +} diff --git a/src/renderer/services/rpc/trpc.ts b/src/renderer/services/rpc/trpc.ts new file mode 100644 index 0000000..ed454fb --- /dev/null +++ b/src/renderer/services/rpc/trpc.ts @@ -0,0 +1,9 @@ +import { createTRPCProxyClient } from '@trpc/client' +import { createGlobalState } from '@vueuse/shared' +import { useIpcLink } from './link' +import type { AppRouter } from '../../../preload/api' +import useSuperJson from '@/rpc/transformer' + +export const useClient = createGlobalState(function useClient() { + return createTRPCProxyClient({ transformer: useSuperJson(), links: [useIpcLink()] }) +}) diff --git a/src/renderer/services/rpc/utilities.ts b/src/renderer/services/rpc/utilities.ts new file mode 100644 index 0000000..df9eb3f --- /dev/null +++ b/src/renderer/services/rpc/utilities.ts @@ -0,0 +1,23 @@ +import type { TRPCClientRuntime } from '@trpc/client' +import type { AnyRouter, inferRouterContext, inferRouterError } from '@trpc/server' +import type { TRPCResponse, TRPCResponseMessage } from '@trpc/server/rpc' + +export function transformResults( + response: TRPCResponseMessage> | TRPCResponse>, + runtime: TRPCClientRuntime +) { + if ('error' in response) { + const error = runtime.transformer.deserialize(response.error) as inferRouterError + return { ok: false, error: { ...response, error } } as const + } + + const result = + !response.result.type || response.result.type === 'data' + ? { + ...response.result, + type: 'data' as const, + data: runtime.transformer.deserialize(response.result.data) as Output + } + : { ...response.result } + return { ok: true, result } as const +} diff --git a/src/renderer/services/sources.ts b/src/renderer/services/sources.ts index f703289..754ec80 100644 --- a/src/renderer/services/sources.ts +++ b/src/renderer/services/sources.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { forceUndefined } from '../hooks/utilities' -import { useClient } from './rpc' +import { useClient } from './rpc/trpc' import { useDataStore } from './store' import type { DocumentId } from './store' import type { NewSource, Source, SourceUpdate } from '../../preload/api' diff --git a/src/renderer/services/startup.ts b/src/renderer/services/startup.ts index 83c3ab5..38121a4 100644 --- a/src/renderer/services/startup.ts +++ b/src/renderer/services/startup.ts @@ -1,5 +1,5 @@ import { createSharedComposable } from '@vueuse/shared' -import { useClient } from './rpc' +import { useClient } from './rpc/trpc' const useStartup = createSharedComposable(function useStartup() { const client = useClient() diff --git a/src/renderer/services/storage.ts b/src/renderer/services/storage.ts index 2a6b3ca..50eda03 100644 --- a/src/renderer/services/storage.ts +++ b/src/renderer/services/storage.ts @@ -1,5 +1,5 @@ import { createSharedComposable, useStorageAsync } from '@vueuse/core' -import { useClient } from './rpc' +import { useClient } from './rpc/trpc' import type { RemovableRef, UseStorageAsyncOptions } from '@vueuse/core' import type { MaybeRefOrGetter } from 'vue' diff --git a/src/renderer/services/switches.ts b/src/renderer/services/switches.ts index ef68500..6b968a0 100644 --- a/src/renderer/services/switches.ts +++ b/src/renderer/services/switches.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { forceUndefined } from '../hooks/utilities' -import { useClient } from './rpc' +import { useClient } from './rpc/trpc' import { useDataStore } from './store' import type { DocumentId } from './store' import type { NewSwitch, Switch, SwitchUpdate } from '../../preload/api' diff --git a/src/renderer/services/ties.ts b/src/renderer/services/ties.ts index dc85689..d9b8a8f 100644 --- a/src/renderer/services/ties.ts +++ b/src/renderer/services/ties.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { forceUndefined } from '../hooks/utilities' -import { useClient } from './rpc' +import { useClient } from './rpc/trpc' import { useDataStore } from './store' import type { DocumentId } from './store' import type { NewTie, TieUpdate, Tie } from '../../preload/api' diff --git a/src/tests/drivers/extron/sis.test.ts b/src/tests/drivers/extron/sis.test.ts index d255481..02d613c 100644 --- a/src/tests/drivers/extron/sis.test.ts +++ b/src/tests/drivers/extron/sis.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/shinybow/v2.test.ts b/src/tests/drivers/shinybow/v2.test.ts index f854162..3bee7dd 100644 --- a/src/tests/drivers/shinybow/v2.test.ts +++ b/src/tests/drivers/shinybow/v2.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/shinybow/v3.test.ts b/src/tests/drivers/shinybow/v3.test.ts index b77f5b4..85eba3c 100644 --- a/src/tests/drivers/shinybow/v3.test.ts +++ b/src/tests/drivers/shinybow/v3.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/sony/rs485.test.ts b/src/tests/drivers/sony/rs485.test.ts index cfea341..2db5b6a 100644 --- a/src/tests/drivers/sony/rs485.test.ts +++ b/src/tests/drivers/sony/rs485.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/tesla-smart/kvm.test.ts b/src/tests/drivers/tesla-smart/kvm.test.ts index d72028d..5e0847a 100644 --- a/src/tests/drivers/tesla-smart/kvm.test.ts +++ b/src/tests/drivers/tesla-smart/kvm.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/tesla-smart/matrix.test.ts b/src/tests/drivers/tesla-smart/matrix.test.ts index 908c673..88eddf5 100644 --- a/src/tests/drivers/tesla-smart/matrix.test.ts +++ b/src/tests/drivers/tesla-smart/matrix.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/tesla-smart/sdi.test.ts b/src/tests/drivers/tesla-smart/sdi.test.ts index 612fdcb..b896609 100644 --- a/src/tests/drivers/tesla-smart/sdi.test.ts +++ b/src/tests/drivers/tesla-smart/sdi.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/tesmart/kvm.test.ts b/src/tests/drivers/tesmart/kvm.test.ts index e432fc2..fb5d10c 100644 --- a/src/tests/drivers/tesmart/kvm.test.ts +++ b/src/tests/drivers/tesmart/kvm.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/tesmart/matrix.test.ts b/src/tests/drivers/tesmart/matrix.test.ts index fbba18d..2fad5ca 100644 --- a/src/tests/drivers/tesmart/matrix.test.ts +++ b/src/tests/drivers/tesmart/matrix.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) diff --git a/src/tests/drivers/tesmart/sdi.test.ts b/src/tests/drivers/tesmart/sdi.test.ts index 52ab2a5..9d3f188 100644 --- a/src/tests/drivers/tesmart/sdi.test.ts +++ b/src/tests/drivers/tesmart/sdi.test.ts @@ -26,7 +26,7 @@ test('available', async () => { const drivers = useDrivers() - await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.registered()).resolves.toContainEqual(driver) await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) })