From d6250c72962c8715fd97e9b6841e11b741fddd78 Mon Sep 17 00:00:00 2001 From: Matthew Holder <sixxgate@hotmail.com> Date: Fri, 1 Nov 2024 18:22:27 -0500 Subject: [PATCH 1/3] Ported the drivers over to tRPC and removed the handle services --- PLAN.md | 9 +- src/main/drivers/extron/sis.ts | 22 +- src/main/drivers/sony/rs485.ts | 37 +-- src/main/drivers/tesla-smart/kvm.ts | 18 +- src/main/drivers/tesla-smart/matrix.ts | 20 +- src/main/drivers/tesla-smart/sdi.ts | 18 +- src/main/main.ts | 13 - src/main/plugins/drivers.ts | 16 -- src/main/routes/drivers.ts | 31 +++ src/main/routes/router.ts | 2 + src/main/services/driver.ts | 148 ---------- src/main/services/drivers.ts | 219 +++++++++++++++ src/main/services/handle.ts | 268 ------------------- src/main/services/level.js | 3 +- src/main/services/locale.ts | 2 + src/main/services/{ => support}/sonyRs485.ts | 0 src/preload/api.d.ts | 84 +----- src/preload/index.ts | 7 +- src/preload/plugins/services.ts | 9 +- src/preload/plugins/services/driver.ts | 22 -- src/renderer/modals/SwitchDialog.vue | 2 +- src/renderer/modals/TieDialog.vue | 2 +- src/renderer/pages/SourcePage.vue | 3 +- src/renderer/pages/SwitchList.vue | 3 +- src/renderer/services/backup/import.ts | 2 +- src/renderer/services/dashboard.ts | 18 +- src/renderer/services/driver.ts | 127 ++++----- src/tests/drivers/extron/sis.test.ts | 68 ++--- src/tests/drivers/sony/rs485.test.ts | 66 ++--- src/tests/drivers/tesla-smart/kvm.test.ts | 66 ++--- src/tests/drivers/tesla-smart/matrix.test.ts | 66 ++--- src/tests/drivers/tesla-smart/sdi.test.ts | 66 ++--- src/tests/level.test.ts | 7 +- src/tests/support/mock.ts | 42 --- 34 files changed, 481 insertions(+), 1005 deletions(-) delete mode 100644 src/main/plugins/drivers.ts create mode 100644 src/main/routes/drivers.ts delete mode 100644 src/main/services/driver.ts create mode 100644 src/main/services/drivers.ts delete mode 100644 src/main/services/handle.ts create mode 100644 src/main/services/locale.ts rename src/main/services/{ => support}/sonyRs485.ts (100%) delete mode 100644 src/preload/plugins/services/driver.ts diff --git a/PLAN.md b/PLAN.md index 8cf22e7..41658be 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,10 +6,11 @@ - Drivers will require an overhaul to no longer need handles. - More drivers. - Move more modules to core. + - Wrap some Electron APIs as services for easier mocking without electron itself. - 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. + - TESmart -- Brand of Tesla Elec (like TelsaSmart) + - No Hassle AV -- Need to contact. diff --git a/src/main/drivers/extron/sis.ts b/src/main/drivers/extron/sis.ts index 2c7fae5..5d2fa34 100644 --- a/src/main/drivers/extron/sis.ts +++ b/src/main/drivers/extron/sis.ts @@ -1,9 +1,9 @@ import Logger from 'electron-log' -import { defineDriver, kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs } from '../../services/driver' +import { defineDriver, kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs } from '../../services/drivers' import { createCommandStream } from '../../services/stream' const extronSisDriver = defineDriver({ - enable: true, + enabled: true, guid: '4C8F2838-C91D-431E-84DD-3666D14A6E2C', localized: { en: { @@ -13,8 +13,8 @@ const extronSisDriver = defineDriver({ } }, capabilities: kDeviceSupportsMultipleOutputs | kDeviceCanDecoupleAudioOutput, - setup: async function setup(uri) { - async function sendCommand(command: string) { + setup: () => { + async function sendCommand(uri: string, command: string) { const connection = await createCommandStream(uri, { baudRate: 9600, dataBits: 8, @@ -36,11 +36,11 @@ const extronSisDriver = defineDriver({ 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 activate(uri: string, input: number, videoOutput: number, audioOutput: number) { + Logger.log(`extronSisDriver.activate(${input}, ${videoOutput}, ${audioOutput})`) + const videoCommand = `${input}*${videoOutput}%` + const audioCommand = `${input}*${audioOutput}$` + await sendCommand(uri, `${videoCommand}\r\n${audioCommand}\r\n`) } async function powerOn() { @@ -55,11 +55,11 @@ const extronSisDriver = defineDriver({ await Promise.resolve() } - return await Promise.resolve({ + return { activate, powerOn, powerOff - }) + } } }) diff --git a/src/main/drivers/sony/rs485.ts b/src/main/drivers/sony/rs485.ts index 4f81975..ff8970c 100644 --- a/src/main/drivers/sony/rs485.ts +++ b/src/main/drivers/sony/rs485.ts @@ -1,11 +1,18 @@ import Logger from 'electron-log' -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/driver' -import { createAddress, createCommand, kAddressAll, kPowerOff, kPowerOn, kSetChannel } from '../../services/sonyRs485' +import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' import { createCommandStream } from '../../services/stream' -import type { Command, CommandArg } from '../../services/sonyRs485' +import { + createAddress, + createCommand, + kAddressAll, + kPowerOff, + kPowerOn, + kSetChannel +} from '../../services/support/sonyRs485' +import type { Command, CommandArg } from '../../services/support/sonyRs485' const sonyRs485Driver = defineDriver({ - enable: true, + enabled: true, guid: '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0', localized: { en: { @@ -15,8 +22,8 @@ const sonyRs485Driver = defineDriver({ } }, capabilities: kDeviceHasNoExtraCapabilities, - setup: async function setup(uri) { - async function sendCommand(command: Command, arg0?: CommandArg, arg1?: CommandArg) { + setup: () => { + 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) @@ -42,26 +49,26 @@ const sonyRs485Driver = defineDriver({ await connection.close() } - async function activate(inputChannel: number) { - Logger.log(`sonyRs485Driver.activate(${inputChannel})`) - await sendCommand(kSetChannel, 1, inputChannel) + async function activate(uri: string, input: number) { + Logger.log(`sonyRs485Driver.activate(${input})`) + await sendCommand(uri, kSetChannel, 1, input) } - async function powerOn() { + async function powerOn(uri: string) { Logger.log('sonyRs485Driver.powerOn') - await sendCommand(kPowerOn) + await sendCommand(uri, kPowerOn) } - async function powerOff() { + async function powerOff(uri: string) { Logger.log('sonyRs485Driver.powerOff') - await sendCommand(kPowerOff) + await sendCommand(uri, kPowerOff) } - return await Promise.resolve({ + return { activate, powerOn, powerOff - }) + } } }) diff --git a/src/main/drivers/tesla-smart/kvm.ts b/src/main/drivers/tesla-smart/kvm.ts index 56b1413..ccf874d 100644 --- a/src/main/drivers/tesla-smart/kvm.ts +++ b/src/main/drivers/tesla-smart/kvm.ts @@ -1,9 +1,9 @@ import Logger from 'electron-log' -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/driver' +import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' import { createCommandStream } from '../../services/stream' const teslaSmartKvmDriver = defineDriver({ - enable: true, + enabled: true, guid: '91D5BC95-A8E2-4F58-BCAC-A77BA1054D61', localized: { en: { @@ -13,8 +13,8 @@ const teslaSmartKvmDriver = defineDriver({ } }, capabilities: kDeviceHasNoExtraCapabilities, - setup: async function setup(uri) { - async function sendCommand(command: Buffer) { + setup: () => { + async function sendCommand(uri: string, command: Buffer) { const connection = await createCommandStream(uri, { baudRate: 9600, dataBits: 8, @@ -36,9 +36,9 @@ const teslaSmartKvmDriver = defineDriver({ 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 activate(uri: string, input: number) { + Logger.log(`teslaSmartKvmDriver.activate(${input})`) + await sendCommand(uri, Buffer.of(0xaa, 0xbb, 0x03, 0x01, input, 0xee)) } async function powerOn() { @@ -53,11 +53,11 @@ const teslaSmartKvmDriver = defineDriver({ await Promise.resolve() } - return await Promise.resolve({ + return { activate, powerOn, powerOff - }) + } } }) diff --git a/src/main/drivers/tesla-smart/matrix.ts b/src/main/drivers/tesla-smart/matrix.ts index a9369db..47eda42 100644 --- a/src/main/drivers/tesla-smart/matrix.ts +++ b/src/main/drivers/tesla-smart/matrix.ts @@ -1,9 +1,9 @@ import Logger from 'electron-log' -import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/driver' +import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' import { createCommandStream } from '../../services/stream' const teslaSmartMatrixDriver = defineDriver({ - enable: true, + enabled: true, guid: '671824ED-0BC4-43A6-85CC-4877890A7722', localized: { en: { @@ -13,8 +13,8 @@ const teslaSmartMatrixDriver = defineDriver({ } }, capabilities: kDeviceSupportsMultipleOutputs, - setup: async function setup(uri) { - const sendCommand = async (command: Buffer) => { + setup: () => { + const sendCommand = async (uri: string, command: Buffer) => { const connection = await createCommandStream(uri, { baudRate: 9600, dataBits: 8, @@ -38,10 +38,10 @@ const teslaSmartMatrixDriver = defineDriver({ 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')) + async function activate(uri: string, input: number, output: number) { + Logger.log(`teslaSmartMatrixDriver.activate(${input}, ${output})`) + const command = `MT00SW${toChannel(input)}${toChannel(output)}NT` + await sendCommand(uri, Buffer.from(command, 'ascii')) await Promise.resolve() } @@ -58,11 +58,11 @@ const teslaSmartMatrixDriver = defineDriver({ await Promise.resolve() } - return await Promise.resolve({ + return { activate, powerOn, powerOff - }) + } } }) diff --git a/src/main/drivers/tesla-smart/sdi.ts b/src/main/drivers/tesla-smart/sdi.ts index 38bc5fd..4b66e7b 100644 --- a/src/main/drivers/tesla-smart/sdi.ts +++ b/src/main/drivers/tesla-smart/sdi.ts @@ -1,9 +1,9 @@ import Logger from 'electron-log' -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/driver' +import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' import { createCommandStream } from '../../services/stream' const teslaSmartSdiDriver = defineDriver({ - enable: true, + enabled: true, guid: 'DDB13CBC-ABFC-405E-9EA6-4A999F9A16BD', localized: { en: { @@ -13,8 +13,8 @@ const teslaSmartSdiDriver = defineDriver({ } }, capabilities: kDeviceHasNoExtraCapabilities, - setup: async function setup(uri) { - async function sendCommand(command: Buffer) { + setup: () => { + async function sendCommand(uri: string, command: Buffer) { const connection = await createCommandStream(uri, { baudRate: 9600, dataBits: 8, @@ -36,9 +36,9 @@ const teslaSmartSdiDriver = defineDriver({ await connection.close() } - async function activate(inputChannel: number) { - Logger.log(`teslaSmartSdiDriver.activate(${inputChannel})`) - await sendCommand(Buffer.of(0xaa, 0xcc, 0x01, inputChannel)) + async function activate(uri: string, input: number) { + Logger.log(`teslaSmartSdiDriver.activate(${input})`) + await sendCommand(uri, Buffer.of(0xaa, 0xcc, 0x01, input)) } async function powerOn() { @@ -53,11 +53,11 @@ const teslaSmartSdiDriver = defineDriver({ await Promise.resolve() } - return await Promise.resolve({ + return { activate, powerOn, powerOff - }) + } } }) diff --git a/src/main/main.ts b/src/main/main.ts index b244c17..ac1637e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -5,11 +5,8 @@ import { app, shell, BrowserWindow, nativeTheme } from 'electron' import Logger from 'electron-log' import appIcon from '../../resources/icon.png?asset&asarUnpack' import useAppConfig from './info/config' -import registerDrivers from './plugins/drivers' import useCrypto from './plugins/webcrypto' import useApiServer from './server' -import useDrivers from './services/driver' -import useHandles from './services/handle' import useSystem from './services/system' import useUpdater from './services/updater' import { logError } from './utilities' @@ -98,14 +95,6 @@ app.on('window-all-closed', () => { } }) -// Make sure all handles have been closed. -const { shutDown } = useHandles() -app.on('will-quit', () => { - process.nextTick(async () => { - await shutDown() - }) -}) - // Default open or close DevTools by F12 in development // and ignore CommandOrControl + R in production. // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils @@ -144,7 +133,5 @@ useApiServer() useCrypto() useUpdater() useSystem() -useDrivers() -registerDrivers() await createWindow() 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/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/router.ts b/src/main/routes/router.ts index ed86d32..1329e95 100644 --- a/src/main/routes/router.ts +++ b/src/main/routes/router.ts @@ -6,6 +6,7 @@ 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' @@ -17,6 +18,7 @@ export const useAppRouter = memo(() => // Functional service routes ports: useSerialPortRouter(), startup: useStartupRouter(), + drivers: useDriversRouter(), // Data service routes storage: useUserStoreRouter(), ties: useTiesRouter(), 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<void> - - /** Powers on the switch or monitor. */ - readonly powerOn?: () => Promise<void> - - /** Powers off the switch or monitor. */ - readonly powerOff?: () => Promise<void> - - /** Closes the driver to free any used resources. */ - readonly close?: () => Promise<void> -} - -/** 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<Driver> -} - -/** - * Initializes a driver. - * - * @param uri A uri to the device. - */ -export type DriverSetup = (uri: string) => Promise<DriverBindings> - -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<Driver> => - 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<Driver> - - /** The driver registry. */ - const registry = new Map<string, DriverFactory>() - - /** 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..c97aa17 --- /dev/null +++ b/src/main/services/drivers.ts @@ -0,0 +1,219 @@ +// +// Device capabilities +// + +import { memo } from 'radash' +import type { ApiLocales } from './locale' +import type { MaybePromise } from '@/basics' + +/** 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 + +// +// Driver definition +// + +/** 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 enabled: boolean + /** A unique identifier for the driver. */ + readonly guid: string + /** Defines the localized driver information in all supported locales. */ + readonly localized: Readonly<Record<ApiLocales, LocalizedDriverDescriptor>> + /** Defines the capabilities of the device driven by the driver. */ + readonly capabilities: number +} + +/** 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<void> + + /** + * Powers on the switch or monitor. + * + * @param uri - URI identifying the location of the device. + */ + readonly powerOn?: (uri: string) => Promise<void> + + /** + * Powers off the switch or monitor. + * + * @param uri - URI identifying the location of the device. + */ + readonly powerOff?: (uri: string) => Promise<void> +} + +export interface DefineDriverOptions extends DriverData { + setup: () => DriverBindings +} + +async function noOpPower() { + /* no-op is not defined in setup */ await Promise.resolve() +} + +const registry = new Map<string, Driver>() + +export interface Driver { + /** + * 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 + /** A unique identifier for the driver. */ + readonly guid: string + /** Defines the capabilities of the device driven by the driver. */ + readonly capabilities: number + /** Raw metadata from the registration options. */ + readonly metadata: DriverData + /** Gets the localized driver information. */ + getInfo: (locale: ApiLocales) => LocalizedDriverDescriptor + /** + * 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<void> + + /** + * Powers on the switch or monitor. + * + * @param uri - URI identifying the location of the device. + */ + readonly powerOn: (uri: string) => Promise<void> + + /** + * Powers off the switch or monitor. + * + * @param uri - URI identifying the location of the device. + */ + readonly powerOff: (uri: string) => Promise<void> +} + +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, + guid: info.guid, + ...localizedInfo, + capabilities: info.capabilities + } + }) + + existing = Object.freeze({ + powerOn: noOpPower, + powerOff: noOpPower, + ...implemented, + enabled: info.enabled, + 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 booted = Promise.all( + Object.values(drivers).map(async (factory) => { + await factory() + }) + ) + + function defineOperation<Args extends unknown[], Result>(op: (...args: Args) => MaybePromise<Result>) { + return async (...args: Args) => { + await booted + return await op(...args) + } + } + + const all = defineOperation(() => Array.from(registry.values())) + + const get = defineOperation((guid: string) => registry.get(guid) ?? null) + + function defineDriverOperation<Args extends unknown[], Result>( + op: (driver: Driver, ...args: Args) => MaybePromise<Result> + ) { + return async (guid: string, ...args: Args) => { + const driver = await get(guid) + if (driver == null) throw new ReferenceError(`No such driver: "${guid}"`) + return await op(driver, ...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<T> = SymbolKey<'handle', T> - -/** Close routine for a handle. */ -export type Close<T> = (resource: T) => Promise<unknown> - -/** Handle transparent structure. */ -export interface Descriptor<T> { - key: HandleKey<T> - resource: T - close: Close<T> -} - -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<any> - - // The first 256 will be reserved, and used as a - // sanity check for validity. - const handleMap = new Array<number | AnyDescriptor>(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<T = any>(handle: Handle, key?: HandleKey<T>) { - // 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<T>(handle: Handle, key: HandleKey<T>): Descriptor<T> - /** Implementation */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function getDescriptor<T = any>(handle: Handle, key?: HandleKey<T>) { - // 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<WebContents, Set<Handle>>() - - function getHandleTracker(sender: WebContents) { - let handleTracker = handleTrackers.get(sender) - if (handleTracker != null) return handleTracker - handleTracker = new Set<Handle>() - 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<T>(event: IpcMainInvokeEvent, key: HandleKey<T>, resource: T, close?: Close<T>) { - 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<T>(event: IpcMainInvokeEvent, key: HandleKey<T>, 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.js b/src/main/services/level.js index c6efdc7..83df748 100644 --- a/src/main/services/level.js +++ b/src/main/services/level.js @@ -25,8 +25,7 @@ export const useLevelDb = memo(function useLevelDb() { function leveldown(name) { const path = resolvePath(app.getPath('userData'), name) const db = levelDown(path) - - app.on('before-quit', () => { + app.on('will-quit', () => { db.close((err) => { if (err != null) console.error(err) }) 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/sonyRs485.ts b/src/main/services/support/sonyRs485.ts similarity index 100% rename from src/main/services/sonyRs485.ts rename to src/main/services/support/sonyRs485.ts diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index b0dfa82..0882e79 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -12,11 +12,23 @@ export type { UserInfo } from '../main/info/user' export type { AppConfig } from '../main/info/config' export type { AppRouter } from '../main/routes/router' export type { DocumentId } from '../main/services/database' +export type { ApiLocales } from '../main/services/locale' export type { UserStore } from '../main/dao/storage' export type { PortEntry } from '../main/services/ports' export type { Source, NewSource, SourceUpdate } from '../main/dao/sources' export type { Switch, NewSwitch, SwitchUpdate } from '../main/dao/switches' export type { Tie, NewTie, TieUpdate } from '../main/dao/ties' +export type { ApiLocales } from '../main/locale' + +export type { + Driver, + DriverData, + LocalizedDriverDescriptor, + // Cannot be exported as values, but they are literals. + kDeviceCanDecoupleAudioOutput, + kDeviceHasNoExtraCapabilities, + kDeviceSupportsMultipleOutputs +} from '../main/services/drivers' // // Internal parts @@ -41,78 +53,11 @@ export type IpcResponse<T> = IpcReturnedValue<T> | IpcThrownError // Common parts // -/** Supported API locales. */ -export type ApiLocales = 'en' - -/** Opaque handles. */ -export type Handle = Tagged<number, 'Handle'> - /** Event listener attachment options */ export interface ListenerOptions { once?: boolean | undefined } -// -// 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<DriverData[]> - /** Loads a driver. */ - readonly open: (guid: string, uri: string) => Promise<Handle> - /** Powers on the switch or monitor. */ - readonly powerOn: (h: Handle) => Promise<void> - /** Closes the device to which the driver is attached. */ - readonly powerOff: (h: Handle) => Promise<void> - /** - * 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<void> -} - // // Session control API // @@ -155,13 +100,8 @@ export interface ProcessData { /** Functional APIs */ export interface MainProcessServices { readonly process: ProcessData - readonly driver: DriverApi readonly system: SystemApi readonly updates: AppUpdates - /** Closes a handle, freeing its resources. */ - readonly freeHandle: (h: Handle) => Promise<void> - /** Closes all handles for a page. */ - readonly freeAllHandles: () => Promise<void> } export interface AppUpdater { diff --git a/src/preload/index.ts b/src/preload/index.ts index 4385dc9..11ad616 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,12 +10,7 @@ if (!process.contextIsolated) { 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) - }) - }) + useServices() useAppConfig() } catch (e) { diff --git a/src/preload/plugins/services.ts b/src/preload/plugins/services.ts index 8ccb297..b47813d 100644 --- a/src/preload/plugins/services.ts +++ b/src/preload/plugins/services.ts @@ -1,22 +1,15 @@ import { contextBridge } from 'electron' import { memo } from 'radash' -import { useIpc } from '../support' -import useDriverApi from './services/driver' import useProcessData from './services/process' import useSystemApi from './services/system' import useAppUpdates from './services/updates' import type { MainProcessServices } from '../api' const useServices = memo(function useServices() { - const ipc = useIpc() - const services = { process: useProcessData(), - driver: useDriverApi(), system: useSystemApi(), - updates: useAppUpdates(), - freeHandle: ipc.useInvoke('handle:free'), - freeAllHandles: ipc.useInvoke('handle:clean') + updates: useAppUpdates() } satisfies MainProcessServices contextBridge.exposeInMainWorld('services', services) diff --git a/src/preload/plugins/services/driver.ts b/src/preload/plugins/services/driver.ts deleted file mode 100644 index 41404a1..0000000 --- a/src/preload/plugins/services/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/renderer/modals/SwitchDialog.vue b/src/renderer/modals/SwitchDialog.vue index 2e47760..809f06d 100644 --- a/src/renderer/modals/SwitchDialog.vue +++ b/src/renderer/modals/SwitchDialog.vue @@ -5,7 +5,7 @@ import { computed, ref, reactive, onBeforeMount } from 'vue' import { useI18n } from 'vue-i18n' import { useLocation } from '../helpers/location' import { useRules, useValidation } from '../helpers/validation' -import { useDrivers } from '../services/driver' +import useDrivers from '../services/driver' import usePorts from '../services/ports' import { useDialogs, useSwitchDialog } from './dialogs' import type { I18nSchema } from '../locales/locales' diff --git a/src/renderer/modals/TieDialog.vue b/src/renderer/modals/TieDialog.vue index 2c45b9e..0e18340 100644 --- a/src/renderer/modals/TieDialog.vue +++ b/src/renderer/modals/TieDialog.vue @@ -5,7 +5,7 @@ import { computed, ref, reactive, watch, onBeforeMount } from 'vue' import { useI18n } from 'vue-i18n' import NumberInput from '../components/NumberInput.vue' import { useRules, useValidation } from '../helpers/validation' -import { kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs, useDrivers } from '../services/driver' +import useDrivers, { kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs } from '../services/driver' import { useSources } from '../services/sources' import { useSwitches } from '../services/switches' import { useDialogs, useTieDialog } from './dialogs' diff --git a/src/renderer/pages/SourcePage.vue b/src/renderer/pages/SourcePage.vue index 9e6c9c5..676e124 100644 --- a/src/renderer/pages/SourcePage.vue +++ b/src/renderer/pages/SourcePage.vue @@ -10,7 +10,7 @@ import { filesToAttachment, toFiles } from '../helpers/attachment' import { useGuardedAsyncOp } from '../helpers/utilities' import TieDialog from '../modals/TieDialog.vue' import { useDialogs, useTieDialog } from '../modals/dialogs' -import { useDrivers } from '../services/driver' +import useDrivers from '../services/driver' import { useSources } from '../services/sources' import { useSwitches } from '../services/switches' import { useTies } from '../services/ties' @@ -113,6 +113,7 @@ const loadTies = useGuardedAsyncOp(async function loadTies() { }) onBeforeMount(loadTies) +onBeforeMount(drivers.all) async function addTie(target: NewTie) { try { diff --git a/src/renderer/pages/SwitchList.vue b/src/renderer/pages/SwitchList.vue index f3b47f2..110d168 100644 --- a/src/renderer/pages/SwitchList.vue +++ b/src/renderer/pages/SwitchList.vue @@ -7,7 +7,7 @@ import Page from '../components/Page.vue' import { useGuardedAsyncOp } from '../helpers/utilities' import SwitchDialog from '../modals/SwitchDialog.vue' import { useDialogs, useSwitchDialog } from '../modals/dialogs' -import { useDrivers } from '../services/driver' +import useDrivers from '../services/driver' import { useSwitches } from '../services/switches' import type { I18nSchema } from '../locales/locales' import type { DriverInformation } from '../services/driver' @@ -40,6 +40,7 @@ const items = computed(() => ) const refresh = useGuardedAsyncOp(async () => { + // TODO: await drivers.all(), with busy tracking await switches.all() }) diff --git a/src/renderer/services/backup/import.ts b/src/renderer/services/backup/import.ts index 2ca7a4c..d0fa602 100644 --- a/src/renderer/services/backup/import.ts +++ b/src/renderer/services/backup/import.ts @@ -2,7 +2,7 @@ import { BlobReader, BlobWriter, TextWriter, ZipReader } from '@zip.js/zip.js' import mime from 'mime' import { z } from 'zod' import { fileToAttachment } from '../../helpers/attachment' -import { useDrivers } from '../driver' +import useDrivers from '../driver' import useSettings from '../settings' import { useSources } from '../sources' import { useSwitches } from '../switches' diff --git a/src/renderer/services/dashboard.ts b/src/renderer/services/dashboard.ts index 4f1530d..cbfd1af 100644 --- a/src/renderer/services/dashboard.ts +++ b/src/renderer/services/dashboard.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { computed, readonly, ref } from 'vue' import { toFiles, useImages } from '../helpers/attachment' -import { useDrivers } from './driver' +import useDrivers from './driver' import useSettings from './settings' import { useSources } from './sources' import { useSwitches } from './switches' @@ -58,27 +58,17 @@ export const useDashboard = defineStore('dashboard', function defineDashboard() } } - warnPromiseFailures( - 'driver close failure', - await Promise.allSettled( - closing.map(async (driver) => { - await driver.close() - }) - ) - ) - // Load any drivers that are new or are being replaced. - const loading: Promise<[DocumentId, Driver]>[] = [] + const loading = new Array<[string, Driver]>() for (const switcher of switches.items) { const driver = loadedDrivers.get(switcher._id) if (driver == null || driver.uri !== switcher.path) { - loading.push(drivers.load(switcher.driverId, switcher.path).then((loaded) => [switcher._id, loaded])) + loading.push([switcher._id, drivers.load(switcher.driverId, switcher.path)]) } } // Add the replaced and new driver to the loaded registry. - const loaded = await Promise.all(loading) - for (const [guid, driver] of loaded) { + for (const [guid, driver] of loading) { loadedDrivers.set(guid, driver) } } diff --git a/src/renderer/services/driver.ts b/src/renderer/services/driver.ts index 05ab39a..5228049 100644 --- a/src/renderer/services/driver.ts +++ b/src/renderer/services/driver.ts @@ -1,68 +1,72 @@ -import { createSharedComposable } from '@vueuse/core' -import { readonly, computed, reactive } from 'vue' +import { memo } from 'radash' +import { computed, reactive, readonly, ref, shallowReadonly } from 'vue' import i18n from '../plugins/i18n' +import { useClient } from './rpc' import { trackBusy } from './tracking' +import type { + kDeviceHasNoExtraCapabilities as HasNoExtraCapabilities, + kDeviceSupportsMultipleOutputs as SupportsMultipleOutputs, + kDeviceCanDecoupleAudioOutput as CanDecoupleAudioOutput, + LocalizedDriverDescriptor +} from '../../preload/api' /** The device has no extended capabilities. */ -export const kDeviceHasNoExtraCapabilities = services.driver.capabilities.kDeviceHasNoExtraCapabilities +export type kDeviceHasNoExtraCapabilities = HasNoExtraCapabilities +export const kDeviceHasNoExtraCapabilities: HasNoExtraCapabilities = 0 /** The device has multiple output channels. */ -export const kDeviceSupportsMultipleOutputs = services.driver.capabilities.kDeviceSupportsMultipleOutputs +export type kDeviceSupportsMultipleOutputs = SupportsMultipleOutputs +export const kDeviceSupportsMultipleOutputs: SupportsMultipleOutputs = 1 /** The device support sending the audio output to a different channel. */ -export const kDeviceCanDecoupleAudioOutput = services.driver.capabilities.kDeviceCanDecoupleAudioOutput +export type kDeviceCanDecoupleAudioOutput = CanDecoupleAudioOutput +export const kDeviceCanDecoupleAudioOutput: CanDecoupleAudioOutput = 2 /** Informational metadata about a device and driver. */ -export interface DriverInformation { +export interface DriverInformation extends LocalizedDriverDescriptor { /** A unique identifier for the driver. */ readonly guid: string - /** 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 the capabilities of the device driven by the driver. */ readonly capabilities: number } /** Driver to interact with switching devices. */ -export interface Driver { +export interface Driver extends DriverInformation { /** * 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. + * @param input The input channel to tie. + * @param videoOutput The output video channel to tie. + * @param audioOutput The output audio channel to tie. */ - readonly activate: (inputChannel: number, videoOutputChannel: number, audioOutputChannel: number) => Promise<void> + readonly activate: (input: number, videoOutput: number, audioOutput: number) => Promise<void> /** Powers on the switch or monitor. */ readonly powerOn: () => Promise<void> /** Powers on the switch or monitor and closes the driver. */ readonly powerOff: () => Promise<void> - /** Closes the driver. */ - readonly close: () => Promise<void> /** The URI to the device being driven. */ readonly uri: string } -/** Core parts of the drive system, so we don't hold any component's effect scope in memory. */ -const useDriverCore = createSharedComposable(function useDriverCore() { - const registry = reactive(new Map<string, DriverInformation>()) +const useDrivers = memo(function useDrivers() { + const client = useClient() - /** Loads the drivers, using the first i18n we can get. */ - async function loadList() { - if (registry.size > 0) { - // Already loaded... - return - } + /** Busy tracking. */ + const tracker = trackBusy() + + /** The registered drivers. */ + const items = ref(new Array<DriverInformation>()) + + const registry = new Map<string, DriverInformation>() - const drivers = await services.driver.list() - for (const { guid, localized, capabilities } of drivers) { + async function all() { + if (items.value.length > 0) return items.value + const drivers = await tracker.wait(client.drivers.all.query()) + for (const { + metadata: { 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, { - $driver: { - [guid]: { ...description } - } + $driver: { [guid]: { ...description } } }) } @@ -83,66 +87,49 @@ const useDriverCore = createSharedComposable(function useDriverCore() { }) ) } - } - - return { registry, loadList } -}) -/** Use drivers. */ -export function useDrivers() { - const { registry, loadList } = useDriverCore() - - /** Busy tracking. */ - const tracker = trackBusy() - - /** The registered drivers. */ - const items = computed(() => Array.from(registry.values())) - - /** Loads information about all the drivers. */ - const all = tracker.track(async function all() { - await loadList() - }) + items.value = Array.from(registry.values()) + return items.value + } - /** Loads a driver registered in the registry. */ - async function load(guid: string, path: string): Promise<Driver> { - await loadList() - if (!registry.has(guid)) { - throw new Error(`No such driver registered as "${guid}"`) + function load(guid: string, uri: string): Driver { + if (items.value.length === 0) { + throw new ReferenceError(`No driver "${guid}"`) } - const h = await services.driver.open(guid, path) + const driver = registry.get(guid) + if (driver == null) { + throw new ReferenceError(`No driver "${guid}"`) + } - async function activate(inputChannel: number, videoOutputChannel: number, audioOutputChannel: number) { - await services.driver.activate(h, inputChannel, videoOutputChannel, audioOutputChannel) + async function activate(input: number, videoOutput: number, audioOutput: number) { + await client.drivers.activate.mutate([guid, uri, input, videoOutput, audioOutput]) } async function powerOn() { - await services.driver.powerOn(h) + await client.drivers.powerOn.mutate([guid, uri]) } async function powerOff() { - await services.driver.powerOff(h) - await services.freeHandle(h) - } - - async function close() { - await services.freeHandle(h) + await client.drivers.powerOff.mutate([guid, uri]) } return readonly({ + ...driver, activate, powerOn, powerOff, - close, - uri: path + uri }) } return reactive({ isBusy: tracker.isBusy, error: tracker.error, - items: computed(() => readonly(items.value)), + items: computed(() => shallowReadonly(items.value)), all, load }) -} +}) + +export default useDrivers diff --git a/src/tests/drivers/extron/sis.test.ts b/src/tests/drivers/extron/sis.test.ts index 41bc564..3a0769b 100644 --- a/src/tests/drivers/extron/sis.test.ts +++ b/src/tests/drivers/extron/sis.test.ts @@ -6,63 +6,28 @@ const port = await vi.hoisted(async () => await import('../../support/serial')) const stream = await vi.hoisted(async () => await import('../../support/stream')) beforeEach<MockStreamContext>(async (context) => { - vi.mock(import('electron'), mock.electronModule) vi.mock(import('electron-log')) vi.mock(import('serialport'), port.serialPortModule) vi.doMock(import('../../../main/services/stream'), stream.commandStreamModule(context)) - await mock.bridgeCmdrBasics() await port.createMockPorts() - const { default: useDrivers } = await import('../../../main/services/driver') - const { default: registerDrivers } = await import('../../../main/plugins/drivers') - useDrivers() - registerDrivers() }) afterEach(async () => { - await globalThis.services.freeAllHandles() await port.resetMockPorts() + vi.restoreAllMocks() vi.resetModules() }) const kDriverGuid = '4C8F2838-C91D-431E-84DD-3666D14A6E2C' test('available', async () => { - const { kDeviceSupportsMultipleOutputs, kDeviceCanDecoupleAudioOutput } = await import( - '../../../main/services/driver' - ) - - // Raw list. - await expect(globalThis.services.driver.list()).resolves.toContainEqual({ - enable: true, - guid: kDriverGuid, - localized: { - en: { - title: 'Extron SIS-compatible matrix switch', - company: 'Extron Electronics', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceSupportsMultipleOutputs | kDeviceCanDecoupleAudioOutput - }) - - // Localized list. - const { useDrivers } = await import('../../../renderer/services/driver') - const drivers = useDrivers() - await expect(drivers.all()).resolves.toBeUndefined() - expect(drivers.items).toContainEqual({ - guid: kDriverGuid, - title: 'Extron SIS-compatible matrix switch', - company: 'Extron Electronics', - provider: 'BridgeCmdr contributors', - capabilities: kDeviceSupportsMultipleOutputs | kDeviceCanDecoupleAudioOutput - }) -}) + const { default: driver } = await import('../../../main/drivers/extron/sis') + const { default: useDrivers } = await import('../../../main/services/drivers') -test('connect', async () => { - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const drivers = useDrivers() - await expect(load(kDriverGuid, 'port:/dev/ttyS0')).resolves.not.toBeNull() + await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) test<MockStreamContext>('power on', async (context) => { @@ -70,15 +35,14 @@ test<MockStreamContext>('power on', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() // Power on is a no-op context.stream.withSequence() - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOn()).resolves.toBeUndefined() + await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -87,15 +51,14 @@ test<MockStreamContext>('power off', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() // Power off is a no-op context.stream.withSequence() - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOff()).resolves.toBeUndefined() + await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -104,8 +67,8 @@ test<MockStreamContext>('activate tie', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() const input = 1 const video = 2 @@ -118,7 +81,6 @@ test<MockStreamContext>('activate tie', async (context) => { context.stream.withSequence().on(Buffer.from(command, 'ascii'), () => Buffer.from(response)) - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.activate(input, video, audio)).resolves.toBeUndefined() + await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, video, audio)).resolves.toBeUndefined() context.stream.sequence.expectDone() }) diff --git a/src/tests/drivers/sony/rs485.test.ts b/src/tests/drivers/sony/rs485.test.ts index f2e5419..ba9870c 100644 --- a/src/tests/drivers/sony/rs485.test.ts +++ b/src/tests/drivers/sony/rs485.test.ts @@ -6,61 +6,28 @@ const port = await vi.hoisted(async () => await import('../../support/serial')) const stream = await vi.hoisted(async () => await import('../../support/stream')) beforeEach<MockStreamContext>(async (context) => { - vi.mock(import('electron'), mock.electronModule) vi.mock(import('electron-log')) vi.mock(import('serialport'), port.serialPortModule) vi.doMock(import('../../../main/services/stream'), stream.commandStreamModule(context)) - await mock.bridgeCmdrBasics() await port.createMockPorts() - const { default: useDrivers } = await import('../../../main/services/driver') - const { default: registerDrivers } = await import('../../../main/plugins/drivers') - useDrivers() - registerDrivers() }) afterEach(async () => { - await globalThis.services.freeAllHandles() await port.resetMockPorts() + vi.restoreAllMocks() vi.resetModules() }) const kDriverGuid = '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0' test('available', async () => { - const { kDeviceHasNoExtraCapabilities } = await import('../../../main/services/driver') - - // Raw list. - await expect(globalThis.services.driver.list()).resolves.toContainEqual({ - enable: true, - guid: kDriverGuid, - localized: { - en: { - title: 'Sony RS-485 controllable monitor', - company: 'Sony Corporation', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceHasNoExtraCapabilities - }) - - // Localized list. - const { useDrivers } = await import('../../../renderer/services/driver') - const drivers = useDrivers() - await expect(drivers.all()).resolves.toBeUndefined() - expect(drivers.items).toContainEqual({ - guid: kDriverGuid, - title: 'Sony RS-485 controllable monitor', - company: 'Sony Corporation', - provider: 'BridgeCmdr contributors', - capabilities: kDeviceHasNoExtraCapabilities - }) -}) + const { default: driver } = await import('../../../main/drivers/sony/rs485') + const { default: useDrivers } = await import('../../../main/services/drivers') -test('connect', async () => { - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const drivers = useDrivers() - await expect(load(kDriverGuid, 'port:/dev/ttyS0')).resolves.not.toBeNull() + await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) test<MockStreamContext>('power on', async (context) => { @@ -68,8 +35,8 @@ test<MockStreamContext>('power on', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() const command = Buffer.of(0x02, 4, 0xc0, 0xc0, 0x29, 0x3e, 0x15) // Packet type: Command == 0x02 @@ -82,8 +49,7 @@ test<MockStreamContext>('power on', async (context) => { context.stream.withSequence().on(command, () => Buffer.from('Good')) - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOn()).resolves.toBeUndefined() + await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -92,8 +58,8 @@ test<MockStreamContext>('power off', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() const commnad = Buffer.of(0x02, 4, 0xc0, 0xc0, 0x2a, 0x3e, 0x14) // Packet type: Command == 0x02 @@ -106,8 +72,7 @@ test<MockStreamContext>('power off', async (context) => { context.stream.withSequence().on(commnad, () => Buffer.from('Good')) - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOff()).resolves.toBeUndefined() + await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -116,8 +81,8 @@ test<MockStreamContext>('activate tie', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() const input = 1 const command = Buffer.of(0x02, 6, 0xc0, 0xc0, 0x21, 0x00, input, 0x01, 0x57) @@ -133,7 +98,6 @@ test<MockStreamContext>('activate tie', async (context) => { context.stream.withSequence().on(command, () => Buffer.from('Good')) - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.activate(input, 0, 0)).resolves.toBeUndefined() + await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, 0, 0)).resolves.toBeUndefined() context.stream.sequence.expectDone() }) diff --git a/src/tests/drivers/tesla-smart/kvm.test.ts b/src/tests/drivers/tesla-smart/kvm.test.ts index 0c458e9..37336b7 100644 --- a/src/tests/drivers/tesla-smart/kvm.test.ts +++ b/src/tests/drivers/tesla-smart/kvm.test.ts @@ -6,61 +6,28 @@ const port = await vi.hoisted(async () => await import('../../support/serial')) const stream = await vi.hoisted(async () => await import('../../support/stream')) beforeEach<MockStreamContext>(async (context) => { - vi.mock(import('electron'), mock.electronModule) vi.mock(import('electron-log')) vi.mock(import('serialport'), port.serialPortModule) vi.doMock(import('../../../main/services/stream'), stream.commandStreamModule(context)) - await mock.bridgeCmdrBasics() await port.createMockPorts() - const { default: useDrivers } = await import('../../../main/services/driver') - const { default: registerDrivers } = await import('../../../main/plugins/drivers') - useDrivers() - registerDrivers() }) afterEach(async () => { - await globalThis.services.freeAllHandles() await port.resetMockPorts() + vi.restoreAllMocks() vi.resetModules() }) const kDriverGuid = '91D5BC95-A8E2-4F58-BCAC-A77BA1054D61' test('available', async () => { - const { kDeviceHasNoExtraCapabilities } = await import('../../../main/services/driver') - - // Raw list. - await expect(globalThis.services.driver.list()).resolves.toContainEqual({ - enable: true, - guid: kDriverGuid, - localized: { - en: { - title: 'Tesla smart KVM-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceHasNoExtraCapabilities - }) - - // Localized list. - const { useDrivers } = await import('../../../renderer/services/driver') - const drivers = useDrivers() - await expect(drivers.all()).resolves.toBeUndefined() - expect(drivers.items).toContainEqual({ - guid: kDriverGuid, - title: 'Tesla smart KVM-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors', - capabilities: kDeviceHasNoExtraCapabilities - }) -}) + const { default: driver } = await import('../../../main/drivers/tesla-smart/kvm') + const { default: useDrivers } = await import('../../../main/services/drivers') -test('connect', async () => { - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const drivers = useDrivers() - await expect(load(kDriverGuid, 'port:/dev/ttyS0')).resolves.not.toBeNull() + await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) test<MockStreamContext>('power on', async (context) => { @@ -68,15 +35,14 @@ test<MockStreamContext>('power on', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() // Power on is a no-op context.stream.withSequence() - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOn()).resolves.toBeUndefined() + await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -85,15 +51,14 @@ test<MockStreamContext>('power off', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() // Power off is a no-op context.stream.withSequence() - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOff()).resolves.toBeUndefined() + await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -102,15 +67,14 @@ test<MockStreamContext>('activate tie', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() const input = 1 const command = Buffer.of(0xaa, 0xbb, 0x03, 0x01, input, 0xee) context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.activate(input, 0, 0)).resolves.toBeUndefined() + await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, 0, 0)).resolves.toBeUndefined() context.stream.sequence.expectDone() }) diff --git a/src/tests/drivers/tesla-smart/matrix.test.ts b/src/tests/drivers/tesla-smart/matrix.test.ts index 888cd5a..413c646 100644 --- a/src/tests/drivers/tesla-smart/matrix.test.ts +++ b/src/tests/drivers/tesla-smart/matrix.test.ts @@ -6,61 +6,28 @@ const port = await vi.hoisted(async () => await import('../../support/serial')) const stream = await vi.hoisted(async () => await import('../../support/stream')) beforeEach<MockStreamContext>(async (context) => { - vi.mock(import('electron'), mock.electronModule) vi.mock(import('electron-log')) vi.mock(import('serialport'), port.serialPortModule) vi.doMock(import('../../../main/services/stream'), stream.commandStreamModule(context)) - await mock.bridgeCmdrBasics() await port.createMockPorts() - const { default: useDrivers } = await import('../../../main/services/driver') - const { default: registerDrivers } = await import('../../../main/plugins/drivers') - useDrivers() - registerDrivers() }) afterEach(async () => { - await globalThis.services.freeAllHandles() await port.resetMockPorts() + vi.restoreAllMocks() vi.resetModules() }) const kDriverGuid = '671824ED-0BC4-43A6-85CC-4877890A7722' test('available', async () => { - const { kDeviceSupportsMultipleOutputs } = await import('../../../main/services/driver') - - // Raw list. - await expect(globalThis.services.driver.list()).resolves.toContainEqual({ - enable: true, - guid: kDriverGuid, - localized: { - en: { - title: 'Tesla smart matrix-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceSupportsMultipleOutputs - }) - - // Localized list. - const { useDrivers } = await import('../../../renderer/services/driver') - const drivers = useDrivers() - await expect(drivers.all()).resolves.toBeUndefined() - expect(drivers.items).toContainEqual({ - guid: kDriverGuid, - title: 'Tesla smart matrix-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors', - capabilities: kDeviceSupportsMultipleOutputs - }) -}) + const { default: driver } = await import('../../../main/drivers/tesla-smart/matrix') + const { default: useDrivers } = await import('../../../main/services/drivers') -test('connect', async () => { - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const drivers = useDrivers() - await expect(load(kDriverGuid, 'port:/dev/ttyS0')).resolves.not.toBeNull() + await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) test<MockStreamContext>('power on', async (context) => { @@ -68,15 +35,14 @@ test<MockStreamContext>('power on', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() // Power on is a no-op context.stream.withSequence() - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOn()).resolves.toBeUndefined() + await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -85,15 +51,14 @@ test<MockStreamContext>('power off', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() // Power off is a no-op context.stream.withSequence() - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOff()).resolves.toBeUndefined() + await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -102,8 +67,8 @@ test<MockStreamContext>('activate tie', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() const input = 1 const output = 2 @@ -113,7 +78,6 @@ test<MockStreamContext>('activate tie', async (context) => { context.stream.withSequence().on(Buffer.from(command, 'ascii'), () => Buffer.from('Good')) - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.activate(input, output, 0)).resolves.toBeUndefined() + await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, output, 0)).resolves.toBeUndefined() context.stream.sequence.expectDone() }) diff --git a/src/tests/drivers/tesla-smart/sdi.test.ts b/src/tests/drivers/tesla-smart/sdi.test.ts index 8820907..7eac0a7 100644 --- a/src/tests/drivers/tesla-smart/sdi.test.ts +++ b/src/tests/drivers/tesla-smart/sdi.test.ts @@ -6,61 +6,28 @@ const port = await vi.hoisted(async () => await import('../../support/serial')) const stream = await vi.hoisted(async () => await import('../../support/stream')) beforeEach<MockStreamContext>(async (context) => { - vi.mock(import('electron'), mock.electronModule) vi.mock(import('electron-log')) vi.mock(import('serialport'), port.serialPortModule) vi.doMock(import('../../../main/services/stream'), stream.commandStreamModule(context)) - await mock.bridgeCmdrBasics() await port.createMockPorts() - const { default: useDrivers } = await import('../../../main/services/driver') - const { default: registerDrivers } = await import('../../../main/plugins/drivers') - useDrivers() - registerDrivers() }) afterEach(async () => { - await globalThis.services.freeAllHandles() await port.resetMockPorts() + vi.restoreAllMocks() vi.resetModules() }) const kDriverGuid = 'DDB13CBC-ABFC-405E-9EA6-4A999F9A16BD' test('available', async () => { - const { kDeviceHasNoExtraCapabilities } = await import('../../../main/services/driver') - - // Raw list. - await expect(globalThis.services.driver.list()).resolves.toContainEqual({ - enable: true, - guid: kDriverGuid, - localized: { - en: { - title: 'Tesla smart SDI-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceHasNoExtraCapabilities - }) - - // Localized list. - const { useDrivers } = await import('../../../renderer/services/driver') - const drivers = useDrivers() - await expect(drivers.all()).resolves.toBeUndefined() - expect(drivers.items).toContainEqual({ - guid: kDriverGuid, - title: 'Tesla smart SDI-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors', - capabilities: kDeviceHasNoExtraCapabilities - }) -}) + const { default: driver } = await import('../../../main/drivers/tesla-smart/sdi') + const { default: useDrivers } = await import('../../../main/services/drivers') -test('connect', async () => { - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const drivers = useDrivers() - await expect(load(kDriverGuid, 'port:/dev/ttyS0')).resolves.not.toBeNull() + await expect(drivers.all()).resolves.toContainEqual(driver) + await expect(drivers.get(kDriverGuid)).resolves.toEqual(driver) }) test<MockStreamContext>('power on', async (context) => { @@ -68,15 +35,14 @@ test<MockStreamContext>('power on', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() // Power on is a no-op context.stream.withSequence() - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOn()).resolves.toBeUndefined() + await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -85,15 +51,14 @@ test<MockStreamContext>('power off', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() // Power off is a no-op context.stream.withSequence() - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.powerOff()).resolves.toBeUndefined() + await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() context.stream.sequence.expectDone() }) @@ -102,15 +67,14 @@ test<MockStreamContext>('activate tie', async (context) => { context.stream = new stream.MockCommandStream() - const { useDrivers } = await import('../../../renderer/services/driver') - const { load } = useDrivers() + const { default: useDrivers } = await import('../../../main/services/drivers') + const drivers = useDrivers() const input = 1 const command = Buffer.of(0xaa, 0xcc, 0x01, input) context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - const driver = await load(kDriverGuid, 'port:/dev/ttyS0') - await expect(driver.activate(input, 0, 0)).resolves.toBeUndefined() + await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, 0, 0)).resolves.toBeUndefined() context.stream.sequence.expectDone() }) diff --git a/src/tests/level.test.ts b/src/tests/level.test.ts index c9bd4b5..7f9b44e 100644 --- a/src/tests/level.test.ts +++ b/src/tests/level.test.ts @@ -2,14 +2,13 @@ import { test, expect, vi, beforeEach, afterEach } from 'vitest' const mock = await vi.hoisted(async () => await import('./support/mock')) -beforeEach(async () => { +beforeEach(() => { vi.mock(import('electron'), mock.electronModule) vi.mock(import('electron-log')) - await mock.bridgeCmdrBasics() }) -afterEach(async () => { - await globalThis.services.freeAllHandles() +afterEach(() => { + vi.restoreAllMocks() vi.resetModules() }) diff --git a/src/tests/support/mock.ts b/src/tests/support/mock.ts index 0161142..51db37c 100644 --- a/src/tests/support/mock.ts +++ b/src/tests/support/mock.ts @@ -67,24 +67,6 @@ export async function electronModule(original: () => Promise<typeof import('elec // } // } -export function electronProcess() { - vi.stubGlobal('process', { - ...globalThis.process, - get browser() { - return true - }, - get contextIsolated() { - return true - }, - get versions() { - return { - chrome: '126.0.6478.185', - electron: '31.3.1' - } - } - }) -} - export function console() { const error = vi.spyOn(globalThis.console, 'error').mockReturnValue() const warn = vi.spyOn(globalThis.console, 'warn').mockReturnValue() @@ -100,27 +82,3 @@ export function console() { debug } } - -/** Mocks DOM global EventTarget. */ -export async function globalEventTarget() { - const { default: autoBind } = await import('auto-bind') - - for (const [prop, value] of Object.entries(autoBind(new EventTarget()))) { - vi.stubGlobal(prop, value) - } -} - -export function bridgeCmdrEnv() { - vi.stubEnv('rpc_url_', 'http://127.0.0.1:7180') -} - -export async function bridgeCmdrBasics() { - electronProcess() - await globalEventTarget() - bridgeCmdrEnv() - - await import('../../preload/index') - - const { default: useHandles } = await import('../../main/services/handle') - useHandles() -} From 0730caf2897c8f4f2a19688197844eddabcc25fc Mon Sep 17 00:00:00 2001 From: Matthew Holder <sixxgate@hotmail.com> Date: Sat, 2 Nov 2024 01:18:49 -0500 Subject: [PATCH 2/3] Added websocket support for tRPC and ported updated system over --- PLAN.md | 3 +- package.json | 4 ++ src/core/url.ts | 25 +++++++ src/main/dao/storage.ts | 1 - src/main/info/config.ts | 2 +- src/main/main.ts | 2 - src/main/routes/router.ts | 4 +- src/main/routes/updater.ts | 46 +++++++++++++ src/main/server.ts | 67 ++++++++++++------- src/main/services/updater.ts | 87 ++++++++++++------------- src/preload/api.d.ts | 22 ++----- src/preload/plugins/services.ts | 4 +- src/preload/plugins/services/updates.ts | 20 ------ src/renderer/services/appUpdates.ts | 41 ++++++++++-- src/renderer/services/rpc.ts | 30 +++++++-- yarn.lock | 26 ++++++++ 16 files changed, 255 insertions(+), 129 deletions(-) create mode 100644 src/core/url.ts create mode 100644 src/main/routes/updater.ts delete mode 100644 src/preload/plugins/services/updates.ts diff --git a/PLAN.md b/PLAN.md index 41658be..a5fd4f1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -3,10 +3,9 @@ - Switch the majority of the IPC using tRPC. - Updater will require websocket for subscriptions. - System will require moving the open and save file support to DOM APIs. - - Drivers will require an overhaul to no longer need handles. + - Wrap some Electron APIs as services for easier mocking without electron itself. - More drivers. - Move more modules to core. - - Wrap some Electron APIs as services for easier mocking without electron itself. - Drivers - Shinybow - Monoprice Blackbird diff --git a/package.json b/package.json index 54434a1..937c1d5 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@types/pouchdb-find": "^7.3.3", "@types/setimmediate": "^1.0.4", "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^8.12.2", "@typescript-eslint/parser": "^8.12.2", "@vitejs/plugin-vue": "^5.1.4", @@ -96,6 +97,7 @@ "assert": "^2.1.0", "auto-bind": "^5.0.1", "buffer": "^6.0.3", + "bufferutil": "^4.0.8", "duplexify": "^4.1.3", "electron": "^31.7.3", "electron-builder": "^24.13.3", @@ -134,6 +136,7 @@ "type-fest": "^4.26.1", "typescript": "^5.6.3", "typescript-eslint-parser-for-extra-files": "^0.7.0", + "utf-8-validate": "^6.0.5", "util": "^0.12.5", "uuid": "^11.0.2", "vite": "^5.4.10", @@ -147,6 +150,7 @@ "vue-router": "^4.4.5", "vue-tsc": "^2.1.10", "vuetify": "^3.7.3", + "ws": "^8.18.0", "xdg-basedir": "^5.1.0", "zod": "^3.23.8" }, 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/storage.ts b/src/main/dao/storage.ts index 5e84a9a..5d76638 100644 --- a/src/main/dao/storage.ts +++ b/src/main/dao/storage.ts @@ -1,7 +1,6 @@ import { memo } from 'radash' import { useLevelDb } from '../services/level' -export type UserStore = ReturnType<typeof useUserStore> const useUserStore = memo(function useUserStore() { const { levelup } = useLevelDb() diff --git a/src/main/info/config.ts b/src/main/info/config.ts index 19bb043..f54aebf 100644 --- a/src/main/info/config.ts +++ b/src/main/info/config.ts @@ -4,7 +4,7 @@ import type { ReadonlyDeep } from 'type-fest' export type AppConfig = ReadonlyDeep<ReturnType<typeof useAppConfig>> const useAppConfig = memo(function useAppConfig() { const config = { - rpcUrl: 'http://127.0.0.1:7180' + rpcUrl: 'ws://127.0.0.1:7180' } process.env['rpc_url_'] = config.rpcUrl diff --git a/src/main/main.ts b/src/main/main.ts index ac1637e..fe9e057 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -8,7 +8,6 @@ import useAppConfig from './info/config' import useCrypto from './plugins/webcrypto' import useApiServer from './server' import useSystem from './services/system' -import useUpdater from './services/updater' import { logError } from './utilities' import { waitTill } from '@/basics' import { toError } from '@/error-handling' @@ -131,7 +130,6 @@ useAppConfig() useApiServer() useCrypto() -useUpdater() useSystem() await createWindow() diff --git a/src/main/routes/router.ts b/src/main/routes/router.ts index 1329e95..fde0e64 100644 --- a/src/main/routes/router.ts +++ b/src/main/routes/router.ts @@ -9,6 +9,7 @@ import useTiesRouter from './data/ties' import useDriversRouter from './drivers' import useSerialPortRouter from './ports' import useStartupRouter from './startup' +import useUpdaterRouter from './updater' export const useAppRouter = memo(() => router({ @@ -23,7 +24,8 @@ export const useAppRouter = memo(() => storage: useUserStoreRouter(), ties: useTiesRouter(), switches: useSwitchesRouter(), - sources: useSourcesRouter() + sources: useSourcesRouter(), + updates: useUpdaterRouter() }) ) 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 extends keyof AppUpdaterEventMap>(name: Name) { + type Args = AppUpdaterEventMap[Name] + // TODO: tRPC 11, use "for await...of on" with AbortSignal + return () => + observable<Args>((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 index f9a8784..a558b38 100644 --- a/src/main/server.ts +++ b/src/main/server.ts @@ -1,42 +1,61 @@ import { createHTTPServer } from '@trpc/server/adapters/standalone' +import { applyWSSHandler } from '@trpc/server/adapters/ws' import Logger from 'electron-log' +import { WebSocketServer } from 'ws' import useAppConfig from './info/config' import { useAppRouter } from './routes/router' +import { getServerUrl } from '@/url' -function getServerUrl(url: URL): [host: string, port: number] { - Logger.log(url.pathname) - if (url.protocol !== 'http:') throw new TypeError('Only HTTP is supported') - if (url.pathname.length > 1) throw new TypeError('Server must be at the root') - if (url.search.length > 0) throw new TypeError('Query parameters mean nothing') - if (url.hash.length > 0) throw new TypeError('Query parameters mean nothing') - if (url.username.length > 0) throw new TypeError('Username currently unsupported') - if (url.password.length > 0) throw new TypeError('Password currently unsupported') - if (url.hostname.length === 0) return ['127.0.0.1', 7180] - if (url.port.length === 0) return [url.hostname, 7180] - - const port = Number(url.port) - if (Number.isNaN(port)) throw new TypeError(`${url.port} is not a valid port`) - - return [url.hostname, port] -} +function startWebSocketServer(url: URL, host: string, port: number) { + // TODO: Authentication via the IPC, later we'll implement a proper authentication model. -export default function useApiServer() { - const config = useAppConfig() - const url = new URL(config.rpcUrl) - const [host, port] = getServerUrl(url) + 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() }) + + process.on('exit', () => { + handler.broadcastReconnectNotification() + wss.close() + }) + + process.on('SIGTERM', () => { + handler.broadcastReconnectNotification() + wss.close() + }) +} +function startHttpServer(url: URL, host: string, port: number) { // TODO: Authentication via the IPC, later we'll implement a proper authentication model. - const httpServer = createHTTPServer({ + const server = createHTTPServer({ router: useAppRouter() }) - httpServer.listen(port, host) - httpServer.server.on('listening', () => { + server.server.on('listening', () => { Logger.info(`RPC server at ${url}`) }) + server.listen(port, host) process.on('exit', () => { - httpServer.server.close() + 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) + + if (protocol === 'http:') startHttpServer(url, host, port) + if (protocol === 'ws:') startWebSocketServer(url, host, port) +} 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<typeof useUpdater> 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<AppAutoUpdaterEventMap> implements AppUpdater { + class AppUpdater extends EventEmitter<AppUpdaterEventMap> { #checkPromise: Promise<UpdateCheckResult | null> | undefined = undefined #cancelToken: CancellationToken | undefined = undefined #donwloadPromise: Promise<string[]> | 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<WebContents, ProgressHandler>() - 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/preload/api.d.ts b/src/preload/api.d.ts index 0882e79..86aae18 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -4,24 +4,25 @@ import type { ArrayTail, ReadonlyDeep, Tagged } from 'type-fest' import type { AppConfig } from '../main/info/config' // -// Exposed from main +// Exposed via tRPC // export type { AppInfo } from '../main/info/app' -export type { UserInfo } from '../main/info/user' export type { AppConfig } from '../main/info/config' +export type { UserInfo } from '../main/info/user' + export type { AppRouter } from '../main/routes/router' export type { DocumentId } from '../main/services/database' export type { ApiLocales } from '../main/services/locale' -export type { UserStore } from '../main/dao/storage' export type { PortEntry } from '../main/services/ports' +export type { UpdateInfo, ProgressInfo } from '../main/services/updater' + 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' export type { - Driver, DriverData, LocalizedDriverDescriptor, // Cannot be exported as values, but they are literals. @@ -101,19 +102,6 @@ export interface ProcessData { export interface MainProcessServices { readonly process: ProcessData readonly system: SystemApi - readonly updates: AppUpdates -} - -export interface AppUpdater { - readonly checkForUpdates: () => Promise<UpdateInfo | undefined> - readonly downloadUpdate: () => Promise<void> - readonly cancelUpdate: () => Promise<void> - readonly installUpdate: () => Promise<void> -} - -export interface AppUpdates extends AppUpdater { - readonly onDownloadProgress: (fn: (progress: ProgressInfo) => void) => void - readonly offDownloadProgress: (fn: (progress: ProgressInfo) => void) => void } // The exposed API global structure diff --git a/src/preload/plugins/services.ts b/src/preload/plugins/services.ts index b47813d..3626d75 100644 --- a/src/preload/plugins/services.ts +++ b/src/preload/plugins/services.ts @@ -2,14 +2,12 @@ import { contextBridge } from 'electron' import { memo } from 'radash' import useProcessData from './services/process' import useSystemApi from './services/system' -import useAppUpdates from './services/updates' import type { MainProcessServices } from '../api' const useServices = memo(function useServices() { const services = { process: useProcessData(), - system: useSystemApi(), - updates: useAppUpdates() + system: useSystemApi() } satisfies MainProcessServices contextBridge.exposeInMainWorld('services', services) diff --git a/src/preload/plugins/services/updates.ts b/src/preload/plugins/services/updates.ts deleted file mode 100644 index c8ef6ab..0000000 --- a/src/preload/plugins/services/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/renderer/services/appUpdates.ts b/src/renderer/services/appUpdates.ts index 969f6b1..26e4a5f 100644 --- a/src/renderer/services/appUpdates.ts +++ b/src/renderer/services/appUpdates.ts @@ -1,5 +1,6 @@ import { tryOnScopeDispose } from '@vueuse/core' import useTypedEventTarget from '../support/events' +import { useClient } from './rpc' import type { ProgressInfo } from 'electron-updater' export class UpdateProgressEvent extends Event implements ProgressInfo { @@ -16,6 +17,8 @@ export class UpdateProgressEvent extends Event implements ProgressInfo { } function useAppUpdates() { + const client = useClient() + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Inapropriate, type !== interface type Events = { progress: (ev: UpdateProgressEvent) => void @@ -25,19 +28,45 @@ function useAppUpdates() { const appUpdater = { ...target, - checkForUpdates: globalThis.services.updates.checkForUpdates, - downloadUpdate: globalThis.services.updates.downloadUpdate, - cancelUpdate: globalThis.services.updates.cancelUpdate, - installUpdate: globalThis.services.updates.installUpdate + checkForUpdates: async () => await client.updates.checkForUpdates.query(), + downloadUpdate: async () => { + await client.updates.downloadUpdate.query() + }, + cancelUpdate: async () => { + await client.updates.cancelUpdate.query() + }, + installUpdate: async () => { + await client.updates.installUpdate.query() + } } function progressProxy(info: ProgressInfo) { appUpdater.dispatchEvent(new UpdateProgressEvent(info)) } - globalThis.services.updates.onDownloadProgress(progressProxy) + const checking = client.updates.onChecking.subscribe(undefined, { + onData() { + console.log('Checking for updates...') + } + }) + + const available = client.updates.onAvailable.subscribe(undefined, { + onData([info]) { + if (info != null) console.log(`Update available to ${info.version}`) + else console.log('No updates available.') + } + }) + + const progres = client.updates.onProgress.subscribe(undefined, { + onData([info]) { + progressProxy(info) + } + }) + tryOnScopeDispose(() => { - globalThis.services.updates.offDownloadProgress(progressProxy) + checking.unsubscribe() + available.unsubscribe() + progres.unsubscribe() }) return appUpdater diff --git a/src/renderer/services/rpc.ts b/src/renderer/services/rpc.ts index e17094e..74f5456 100644 --- a/src/renderer/services/rpc.ts +++ b/src/renderer/services/rpc.ts @@ -1,11 +1,27 @@ -import { createTRPCProxyClient, httpLink } from '@trpc/client' +import { createTRPCProxyClient, createWSClient, httpLink, wsLink } from '@trpc/client' import { memo } from 'radash' import type { AppRouter } from '../../preload/api' import useSuperJson from '@/rpc' +import { getServerUrl } from '@/url' -export const useClient = memo(() => - createTRPCProxyClient<AppRouter>({ - transformer: useSuperJson(), - links: [httpLink({ url: globalThis.configuration.rpcUrl })] - }) -) +export const useClient = memo(function useClient() { + function useHttpClient() { + return createTRPCProxyClient<AppRouter>({ + transformer: useSuperJson(), + links: [httpLink({ url: globalThis.configuration.rpcUrl })] + }) + } + + function useWsClient() { + const client = createWSClient({ url: globalThis.configuration.rpcUrl }) + return createTRPCProxyClient<AppRouter>({ + transformer: useSuperJson(), + links: [wsLink({ client })] + }) + } + + const url = new URL(globalThis.configuration.rpcUrl) + const [, , protocol] = getServerUrl(url, 7180) + + return protocol === 'http:' ? useHttpClient() : useWsClient() +}) diff --git a/yarn.lock b/yarn.lock index 9a7d3a5..bf06161 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1241,6 +1241,13 @@ resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== +"@types/ws@^8.5.12": + version "8.5.12" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.12.tgz#619475fe98f35ccca2a2f6c137702d85ec247b7e" + integrity sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ== + dependencies: + "@types/node" "*" + "@types/yauzl@^2.9.1": version "2.10.3" resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" @@ -2127,6 +2134,13 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +bufferutil@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.8.tgz#1de6a71092d65d7766c4d8a522b261a6e787e8ea" + integrity sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw== + dependencies: + node-gyp-build "^4.3.0" + builder-util-runtime@9.2.10: version "9.2.10" resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz#a0f7d9e214158402e78b74a745c8d9f870c604bc" @@ -6191,6 +6205,13 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +utf-8-validate@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-6.0.5.tgz#8087d39902be2cc15bdb21a426697ff256d65aab" + integrity sha512-EYZR+OpIXp9Y1eG1iueg8KRsY8TuT8VNgnanZ0uA3STqhHQTLwbl+WX76/9X5OY12yQubymBpaBSmMPkSTQcKA== + dependencies: + node-gyp-build "^4.3.0" + utf8-byte-length@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz#f9f63910d15536ee2b2d5dd4665389715eac5c1e" @@ -6525,6 +6546,11 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@^8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + xdg-basedir@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-5.1.0.tgz#1efba19425e73be1bc6f2a6ceb52a3d2c884c0c9" From cb96896ae682810ea2fc99a0070ab4da8e347abc Mon Sep 17 00:00:00 2001 From: Matthew Holder <sixxgate@hotmail.com> Date: Sat, 2 Nov 2024 11:47:45 -0500 Subject: [PATCH 3/3] Moved last remaining IPC APIs to tRPC --- PLAN.md | 23 ++- package.json | 16 +- src/core/basics.ts | 39 ++++- src/core/struct.ts | 5 - src/main/main.ts | 8 +- src/main/plugins/webcrypto.ts | 25 --- src/main/routes/router.ts | 2 + src/main/routes/system.ts | 16 ++ src/main/services/database.ts | 4 +- src/main/services/system.ts | 49 +----- src/main/utilities.ts | 24 --- src/preload/api.d.ts | 73 --------- src/preload/index.ts | 4 - src/preload/plugins/services.ts | 18 --- src/preload/plugins/services/process.ts | 24 --- src/preload/plugins/services/system.ts | 40 ----- src/preload/support.ts | 54 ------- src/renderer/components/DropMenu.vue | 2 +- src/renderer/components/InputDialog.vue | 2 +- src/renderer/components/OptionDialog.vue | 4 +- src/renderer/components/ReplacableImage.vue | 12 +- .../attachment.ts => hooks/assets.ts} | 31 +--- src/renderer/{helpers => hooks}/element.ts | 3 +- src/renderer/{helpers => hooks}/errors.ts | 0 src/renderer/{helpers => hooks}/location.ts | 6 +- src/renderer/{services => hooks}/tracking.ts | 5 +- src/renderer/{helpers => hooks}/utilities.ts | 0 src/renderer/{helpers => hooks}/validation.ts | 0 src/renderer/{helpers => hooks}/vue.ts | 0 src/renderer/{helpers => hooks}/vuetify.ts | 6 +- src/renderer/index.ts | 28 ---- src/renderer/locales/locales.ts | 2 +- src/renderer/modals/SourceDialog.vue | 7 +- src/renderer/modals/SwitchDialog.vue | 4 +- src/renderer/modals/TieDialog.vue | 2 +- src/renderer/modals/dialogs.ts | 4 +- src/renderer/pages/FirstRunLogic.vue | 2 +- src/renderer/pages/GeneralPage.vue | 4 +- src/renderer/pages/MainDashboard.vue | 7 +- src/renderer/pages/SettingsBackupPage.vue | 17 +- src/renderer/pages/SettingsPage.vue | 4 +- src/renderer/pages/SourceList.vue | 5 +- src/renderer/pages/SourcePage.vue | 8 +- src/renderer/pages/SwitchList.vue | 2 +- src/renderer/services/appUpdates.ts | 6 +- src/renderer/services/backup/export.ts | 2 +- src/renderer/services/backup/import.ts | 6 +- src/renderer/services/dashboard.ts | 5 +- src/renderer/services/driver.ts | 6 +- src/renderer/services/ports.ts | 2 +- src/renderer/services/rpc.ts | 4 +- src/renderer/services/sources.ts | 2 +- src/renderer/services/startup.ts | 4 +- src/renderer/services/storage.ts | 8 +- src/renderer/services/store.ts | 2 +- src/renderer/services/switches.ts | 2 +- src/renderer/services/ties.ts | 2 +- src/renderer/support/files.ts | 102 ++++++++++++ src/renderer/{helpers => support}/i18n.ts | 0 yarn.lock | 146 ++++++++---------- 60 files changed, 318 insertions(+), 572 deletions(-) delete mode 100644 src/core/struct.ts delete mode 100644 src/main/plugins/webcrypto.ts create mode 100644 src/main/routes/system.ts delete mode 100644 src/preload/plugins/services.ts delete mode 100644 src/preload/plugins/services/process.ts delete mode 100644 src/preload/plugins/services/system.ts delete mode 100644 src/preload/support.ts rename src/renderer/{helpers/attachment.ts => hooks/assets.ts} (56%) rename src/renderer/{helpers => hooks}/element.ts (94%) rename src/renderer/{helpers => hooks}/errors.ts (100%) rename src/renderer/{helpers => hooks}/location.ts (96%) rename src/renderer/{services => hooks}/tracking.ts (89%) rename src/renderer/{helpers => hooks}/utilities.ts (100%) rename src/renderer/{helpers => hooks}/validation.ts (100%) rename src/renderer/{helpers => hooks}/vue.ts (100%) rename src/renderer/{helpers => hooks}/vuetify.ts (92%) create mode 100644 src/renderer/support/files.ts rename src/renderer/{helpers => support}/i18n.ts (100%) diff --git a/PLAN.md b/PLAN.md index a5fd4f1..35bb915 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,11 +1,26 @@ - Milestones - v2.1 - - Switch the majority of the IPC using tRPC. - - Updater will require websocket for subscriptions. - - System will require moving the open and save file support to DOM APIs. - - Wrap some Electron APIs as services for easier mocking without electron itself. + - (#86) Add a means to select a not-in-use port for the tRPC channel. + - (#85) Attempt to secure `serialport` interactione. + - (#78) Switch the majority of the IPC using tRPC. + - (#87) Attempt to secure local RPC channel. - More drivers. + - (#88) Add means to mark and label experimental drivers. + - (#83) Shinybow + - (#84) TESmart + - v2.2 - Move more modules to core. + - tRPC over Electron IPC. + - Wrap some Electron APIs as services for easier mocking without electron itself. + - Rearrangeable dashboard icons. + - More drivers. + - Monoprice Blackbird + - v3.0 + - 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 diff --git a/package.json b/package.json index 937c1d5..45fe349 100644 --- a/package.json +++ b/package.json @@ -65,12 +65,11 @@ "@mdi/js": "^7.4.47", "@mdi/svg": "^7.4.47", "@sindresorhus/is": "^7.0.1", - "@sixxgate/lint": "^3.2.1", + "@sixxgate/lint": "^3.3.0", "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", "@tsconfig/node20": "^20.1.4", "@tsconfig/strictest": "^2.0.5", - "@types/duplexify": "^3.6.4", "@types/eslint": "^8.56.12", "@types/ini": "^4.1.1", "@types/leveldown": "^4.0.6", @@ -78,9 +77,7 @@ "@types/node": "^20.17.5", "@types/pouchdb-core": "^7.0.15", "@types/pouchdb-find": "^7.3.3", - "@types/setimmediate": "^1.0.4", - "@types/uuid": "^10.0.0", - "@types/ws": "^8.5.12", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8.12.2", "@typescript-eslint/parser": "^8.12.2", "@vitejs/plugin-vue": "^5.1.4", @@ -96,9 +93,7 @@ "@zip.js/zip.js": "^2.7.53", "assert": "^2.1.0", "auto-bind": "^5.0.1", - "buffer": "^6.0.3", "bufferutil": "^4.0.8", - "duplexify": "^4.1.3", "electron": "^31.7.3", "electron-builder": "^24.13.3", "electron-unhandled": "^5.0.0", @@ -113,7 +108,6 @@ "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-promise": "^7.1.0", "eslint-plugin-vue": "^9.30.0", - "events": "^3.3.0", "execa": "^9.5.1", "husky": "^9.1.6", "ini": "^5.0.0", @@ -128,17 +122,13 @@ "pouchdb-find": "^9.0.0", "prettier": "^3.3.3", "radash": "^12.1.0", - "sass": "^1.80.5", - "setimmediate": "^1.0.5", - "stream-browserify": "^3.0.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", "utf-8-validate": "^6.0.5", - "util": "^0.12.5", - "uuid": "^11.0.2", "vite": "^5.4.10", "vite-plugin-vue-devtools": "^7.6.2", "vite-plugin-vuetify": "^2.0.4", 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<T>(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<void>((resolve) => { - setTimeout(resolve, timeout) +export function withResolvers<T>() { + let resolve: (value: T | PromiseLike<T>) => void = () => undefined + let reject: (reason?: unknown) => void = () => undefined + + const promise = new Promise<T>((_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<Result>(op: () => MaybePromise<Result>) { + return await new Promise<Result>((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/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/main/main.ts b/src/main/main.ts index fe9e057..ca2c0c4 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3,13 +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 useAppConfig from './info/config' -import useCrypto from './plugins/webcrypto' import useApiServer from './server' -import useSystem from './services/system' 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 @@ -74,7 +72,7 @@ async function createWindow() { Logger.warn(e) // eslint-disable-next-line no-await-in-loop -- Retry loop must be serial. - await waitTill(kWait) + await sleep(kWait) } } @@ -129,7 +127,5 @@ electronApp.setAppUserModelId('org.sleepingcats.BridgeCmdr') useAppConfig() useApiServer() -useCrypto() -useSystem() await createWindow() 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 @@ -/// <reference lib="DOM" /> - -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/router.ts b/src/main/routes/router.ts index fde0e64..1647c41 100644 --- a/src/main/routes/router.ts +++ b/src/main/routes/router.ts @@ -9,6 +9,7 @@ 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(() => @@ -19,6 +20,7 @@ export const useAppRouter = memo(() => // Functional service routes ports: useSerialPortRouter(), startup: useStartupRouter(), + system: useSystemRouter(), drivers: useDriversRouter(), // Data service routes storage: useUserStoreRouter(), 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/services/database.ts b/src/main/services/database.ts index 487cde7..00523d9 100644 --- a/src/main/services/database.ts +++ b/src/main/services/database.ts @@ -1,7 +1,7 @@ +import { randomUUID } from 'node:crypto' import PouchDb from 'pouchdb-core' import find from 'pouchdb-find' import { map, memo } from 'radash' -import { v4 as uuid } from 'uuid' import { z } from 'zod' import { useLevelAdapter } from './level' import type { IterableElement, Simplify } from 'type-fest' @@ -181,7 +181,7 @@ function defineDatabaseCore<RawSchema extends z.AnyZodObject>( * Adds a document to the database. */ const add = defineOperation(async function add(db, document: Insertable, ...attachments: Attachment[]) { - const doc = { ...document, _id: uuid().toUpperCase() } + const doc = { ...document, _id: randomUUID().toUpperCase() } await db.put(doc) if (attachments.length > 0) { await addAttachments(doc._id, attachments) diff --git a/src/main/services/system.ts b/src/main/services/system.ts index baa8c32..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 { ipcHandle, ipcProxy } from '../utilities' import useDbus from './dbus' -import type { SystemApi } from '../../preload/api' -import type { FileData } from '@/struct' -import type { OpenDialogOptions, SaveDialogOptions } from 'electron' -import { raiseError } from '@/error-handling' 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<SystemApi, 'openFile' | 'saveFile'> + } }) export default useSystem 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 = <Args extends unknown[], Result>(fn: (...args: Args) => MaybePromise<Result>) => - async function ipcProxied(...[, ...args]: [IpcMainInvokeEvent, ...Args]): Promise<IpcResponse<Result>> { - try { - return { value: await fn(...args) } - } catch (e) { - return { error: toError(e) } - } - } - -export const ipcHandle = <Args extends unknown[], Result>( - fn: (ev: IpcMainInvokeEvent, ...args: Args) => MaybePromise<Result> -) => - async function ipcHandled(ev: IpcMainInvokeEvent, ...args: Args): Promise<IpcResponse<Result>> { - try { - return { value: await fn(ev, ...args) } - } catch (e) { - return { error: toError(e) } - } - } export function logError<E extends Error>(e: E) { Logger.error(e) diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 86aae18..18b55d1 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -1,6 +1,3 @@ -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' // @@ -31,81 +28,11 @@ export type { kDeviceSupportsMultipleOutputs } from '../main/services/drivers' -// -// Internal parts -// - -/** Internal IPC response structure */ -export interface IpcReturnedValue<T> { - error?: undefined - value: T -} - -/** Internal IPC error structure */ -export interface IpcThrownError { - error: Error - value?: undefined -} - -/** Internal IPC response structure */ -export type IpcResponse<T> = IpcReturnedValue<T> | IpcThrownError - -// -// Common parts -// - -/** Event listener attachment options */ -export interface ListenerOptions { - once?: boolean | undefined -} - -// -// Session control API -// - -/** Exposed session control APIs. */ -export interface SystemApi { - /** Powers off the system. */ - readonly powerOff: (interactive?: boolean) => Promise<void> - /** Shows the open file dialog. */ - readonly openFile: (options: OpenDialogOptions) => Promise<File[] | null> - /** Shows the save file dialog to save a file. */ - readonly saveFile: (file: File, options: SaveDialogOptions) => Promise<boolean> -} - -// -// 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<Record<string, string | undefined>> - 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 system: SystemApi -} - // The exposed API global structure declare global { - var services: MainProcessServices var configuration: AppConfig } diff --git a/src/preload/index.ts b/src/preload/index.ts index 11ad616..49d5baf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,6 @@ /* eslint-disable n/no-process-exit -- No real way to do this otherwise */ import useAppConfig from './plugins/info/config' -import useServices from './plugins/services' if (!process.contextIsolated) { console.error('Context isolation is not enabled') @@ -9,9 +8,6 @@ if (!process.contextIsolated) { } try { - // Register services and setup to free all handles when the window closes. - useServices() - useAppConfig() } catch (e) { console.error('Preload error', e) diff --git a/src/preload/plugins/services.ts b/src/preload/plugins/services.ts deleted file mode 100644 index 3626d75..0000000 --- a/src/preload/plugins/services.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { contextBridge } from 'electron' -import { memo } from 'radash' -import useProcessData from './services/process' -import useSystemApi from './services/system' -import type { MainProcessServices } from '../api' - -const useServices = memo(function useServices() { - const services = { - process: useProcessData(), - system: useSystemApi() - } satisfies MainProcessServices - - contextBridge.exposeInMainWorld('services', services) - - return services -}) - -export default useServices diff --git a/src/preload/plugins/services/process.ts b/src/preload/plugins/services/process.ts deleted file mode 100644 index a3e54fd..0000000 --- a/src/preload/plugins/services/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/services/system.ts b/src/preload/plugins/services/system.ts deleted file mode 100644 index d39efc2..0000000 --- a/src/preload/plugins/services/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<FileData[] | null> -type SaveFileMain = (file: FileData, options: SaveDialogOptions) => Promise<boolean> - -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/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 extends string, Args extends unknown[], Result>(id: Id) { - return async (...args: Args) => { - const response = (await ipcRenderer.invoke(id, ...args)) as IpcResponse<Result> - 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, Function>() - - function useAddListener<Id extends string, Args extends unknown[]>(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 extends string, Args extends unknown[]>(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/components/DropMenu.vue b/src/renderer/components/DropMenu.vue index a1e8eb6..93890e3 100644 --- a/src/renderer/components/DropMenu.vue +++ b/src/renderer/components/DropMenu.vue @@ -2,7 +2,7 @@ import { useVModel } from '@vueuse/core' import { get } from 'radash' import { computed, ref, watch, unref } from 'vue' -import type { Anchor, Origin, SelectItemKey } from '../helpers/vuetify' +import type { Anchor, Origin, SelectItemKey } from '../hooks/vuetify' import type { ComponentPublicInstance } from 'vue' interface Props { diff --git a/src/renderer/components/InputDialog.vue b/src/renderer/components/InputDialog.vue index 2656421..b9a9feb 100644 --- a/src/renderer/components/InputDialog.vue +++ b/src/renderer/components/InputDialog.vue @@ -3,7 +3,7 @@ import is from '@sindresorhus/is' import { useVModel } from '@vueuse/core' import { ref, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' -import type { Anchor, Origin } from '../helpers/vuetify' +import type { Anchor, Origin } from '../hooks/vuetify' import type { I18nSchema } from '../locales/locales' import type { ComponentPublicInstance } from 'vue' diff --git a/src/renderer/components/OptionDialog.vue b/src/renderer/components/OptionDialog.vue index 09e7d1b..7c4a017 100644 --- a/src/renderer/components/OptionDialog.vue +++ b/src/renderer/components/OptionDialog.vue @@ -4,8 +4,8 @@ import { useVModel } from '@vueuse/core' import { get } from 'radash' import { watch, ref, computed } from 'vue' import { useI18n } from 'vue-i18n' -import { useElementScrollingBounds } from '../helpers/element' -import type { Anchor, Origin, SelectItemKey } from '../helpers/vuetify' +import { useElementScrollingBounds } from '../hooks/element' +import type { Anchor, Origin, SelectItemKey } from '../hooks/vuetify' import type { I18nSchema } from '../locales/locales' import type { ComponentPublicInstance } from 'vue' import { toArray } from '@/basics' diff --git a/src/renderer/components/ReplacableImage.vue b/src/renderer/components/ReplacableImage.vue index 49532d6..f53af57 100644 --- a/src/renderer/components/ReplacableImage.vue +++ b/src/renderer/components/ReplacableImage.vue @@ -4,6 +4,7 @@ import videoInputHdmiIcon from '@mdi/svg/svg/video-input-hdmi.svg' import { useObjectUrl } from '@vueuse/core' import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import { openFile } from '../support/files' const props = defineProps<{ image?: File | undefined @@ -20,16 +21,7 @@ const file = computed(() => props.image) const url = useObjectUrl(file) async function selectImage() { - const results = await globalThis.services.system.openFile({ - title: t('label.select'), - filters: [ - { extensions: ['svg', 'png', 'gif', 'jpg', 'jpeg'], name: t('filter.all') }, - { extensions: ['jpg', 'jpeg'], name: t('filter.jpg') }, - { extensions: ['svg'], name: t('filter.svg') }, - { extensions: ['png'], name: t('filter.png') }, - { extensions: ['gif'], name: t('filter.gif') } - ] - }) + const results = await openFile({ accepts: 'image/*' }) const newFile = results?.at(0) ?? null if (newFile != null) { diff --git a/src/renderer/helpers/attachment.ts b/src/renderer/hooks/assets.ts similarity index 56% rename from src/renderer/helpers/attachment.ts rename to src/renderer/hooks/assets.ts index ec8fc10..aea0873 100644 --- a/src/renderer/helpers/attachment.ts +++ b/src/renderer/hooks/assets.ts @@ -1,32 +1,9 @@ import { tryOnScopeDispose } from '@vueuse/core' -import { map } from 'radash' -import { computed, readonly, ref, unref, watch } from 'vue' -import type { MaybeRef } from '@vueuse/core' -import { Attachment } from '@/attachments' -import { isNotNullish } from '@/basics' +import { computed, readonly, ref, toValue, unref, watch } from 'vue' +import type { MaybeRefOrGetter } from 'vue' /* eslint-disable n/no-unsupported-features/node-builtins -- In browser environment. */ -export function toFile(attachment: Attachment) { - return new File([attachment], attachment.name, { type: attachment.type }) -} - -export function toFiles(attachments?: Attachment[] | null) { - if (attachments == null) { - return [] - } - - return attachments.map(toFile).filter(isNotNullish) -} - -export async function fileToAttachment(file: File) { - return new Attachment(file.name, file.type, await file.arrayBuffer()) -} - -export async function filesToAttachment(files: File[]) { - return await map(files, fileToAttachment) -} - export function useImages() { const images = ref<(string | undefined)[]>([]) @@ -48,7 +25,7 @@ export function useImages() { return { images, loadImages } } -export function useObjectUrls(sources: MaybeRef<(Blob | MediaSource | undefined)[]>) { +export function useObjectUrls(sources: MaybeRefOrGetter<(Blob | MediaSource | undefined)[]>) { const urls = ref<(string | undefined)[]>([]) function release() { for (const url of urls.value) { @@ -64,7 +41,7 @@ export function useObjectUrls(sources: MaybeRef<(Blob | MediaSource | undefined) () => unref(sources), function handleSourceChange(files) { release() - for (const file of files) { + for (const file of toValue(files)) { urls.value.push(file != null ? URL.createObjectURL(file) : undefined) } }, diff --git a/src/renderer/helpers/element.ts b/src/renderer/hooks/element.ts similarity index 94% rename from src/renderer/helpers/element.ts rename to src/renderer/hooks/element.ts index e8191ed..96e2a45 100644 --- a/src/renderer/helpers/element.ts +++ b/src/renderer/hooks/element.ts @@ -1,6 +1,5 @@ import { useScroll, useResizeObserver } from '@vueuse/core' -import { toValue } from '@vueuse/shared' -import { watch, computed, ref } from 'vue' +import { toValue, watch, computed, ref } from 'vue' import type { MaybeComputedElementRef } from '@vueuse/core' interface ScrollingBoundsEntry { diff --git a/src/renderer/helpers/errors.ts b/src/renderer/hooks/errors.ts similarity index 100% rename from src/renderer/helpers/errors.ts rename to src/renderer/hooks/errors.ts diff --git a/src/renderer/helpers/location.ts b/src/renderer/hooks/location.ts similarity index 96% rename from src/renderer/helpers/location.ts rename to src/renderer/hooks/location.ts index 2f05977..b793534 100644 --- a/src/renderer/helpers/location.ts +++ b/src/renderer/hooks/location.ts @@ -1,6 +1,5 @@ import { helpers } from '@vuelidate/validators' -import { toValue } from '@vueuse/shared' -import { computed, readonly } from 'vue' +import { toValue, computed, readonly } from 'vue' import { useI18n } from 'vue-i18n' import { isHostWithOptionalPort } from './validation' import type { I18nSchema } from '../locales/locales' @@ -8,8 +7,7 @@ import type { PortEntry } from '../services/ports' import type { NewSwitch } from '../services/switches' import type { Fixed } from '@/basics' import type { MessageProps } from '@vuelidate/validators' -import type { MaybeRefOrGetter } from '@vueuse/shared' -import type { Ref } from 'vue' +import type { MaybeRefOrGetter, Ref } from 'vue' export type PathType = 'port' | 'ip' | 'path' diff --git a/src/renderer/services/tracking.ts b/src/renderer/hooks/tracking.ts similarity index 89% rename from src/renderer/services/tracking.ts rename to src/renderer/hooks/tracking.ts index 8bfd31a..420b791 100644 --- a/src/renderer/services/tracking.ts +++ b/src/renderer/hooks/tracking.ts @@ -1,6 +1,5 @@ -import { toValue } from '@vueuse/core' -import { computed, ref, shallowRef } from 'vue' -import type { MaybeRefOrGetter } from '@vueuse/core' +import { toValue, computed, ref, shallowRef } from 'vue' +import type { MaybeRefOrGetter } from 'vue' import { toError } from '@/error-handling' type Trackable<T> = (() => PromiseLike<T>) | (() => Promise<T>) | (() => T) | PromiseLike<T> | Promise<T> diff --git a/src/renderer/helpers/utilities.ts b/src/renderer/hooks/utilities.ts similarity index 100% rename from src/renderer/helpers/utilities.ts rename to src/renderer/hooks/utilities.ts diff --git a/src/renderer/helpers/validation.ts b/src/renderer/hooks/validation.ts similarity index 100% rename from src/renderer/helpers/validation.ts rename to src/renderer/hooks/validation.ts diff --git a/src/renderer/helpers/vue.ts b/src/renderer/hooks/vue.ts similarity index 100% rename from src/renderer/helpers/vue.ts rename to src/renderer/hooks/vue.ts diff --git a/src/renderer/helpers/vuetify.ts b/src/renderer/hooks/vuetify.ts similarity index 92% rename from src/renderer/helpers/vuetify.ts rename to src/renderer/hooks/vuetify.ts index d20ee39..0278bff 100644 --- a/src/renderer/helpers/vuetify.ts +++ b/src/renderer/hooks/vuetify.ts @@ -1,9 +1,7 @@ -import { toValue } from '@vueuse/shared' -import { computed, ref } from 'vue' +import { toValue, computed, ref } from 'vue' import { z } from 'zod' import { useElementScrollingBounds } from './element' -import type { MaybeRefOrGetter } from '@vueuse/shared' -import type { ComponentPublicInstance } from 'vue' +import type { MaybeRefOrGetter, ComponentPublicInstance } from 'vue' const Block = ['top', 'bottom'] as const type Block = (typeof Block)[number] diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 7b3f7d1..56ec1d0 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -1,6 +1,4 @@ -import { Buffer } from 'buffer' import log from 'electron-log' -import 'setimmediate' // // Earliest to enable logger. @@ -10,32 +8,6 @@ if (import.meta.env.PROD) { log.errorHandler.startCatching() } -// -// Polyfills -// -// These polyfills will allow some dependencies we use to function as -// if under node due to needing certain things in the globalThis. -// - -globalThis.global = globalThis -globalThis.Buffer = Buffer -Object.defineProperties((globalThis.process = {} as never), { - arch: { value: globalThis.services.process.arch }, - argv: { value: globalThis.services.process.argv }, - argv0: { value: globalThis.services.process.argv0 }, - env: { value: globalThis.services.process.env }, - execPath: { value: globalThis.services.process.execPath }, - mas: { value: globalThis.services.process.appleStore }, - platform: { value: globalThis.services.process.platform }, - resourcesPath: { value: globalThis.services.process.resourcesPath }, - sandboxed: { value: globalThis.services.process.sandboxed }, - type: { value: globalThis.services.process.type }, - version: { value: globalThis.services.process.version }, - versions: { value: globalThis.services.process.versions }, - windowsStore: { value: globalThis.services.process.windowsStore }, - nextTick: { value: setImmediate } -}) - // // Dynamic load the main module. // diff --git a/src/renderer/locales/locales.ts b/src/renderer/locales/locales.ts index 0437c86..309505d 100644 --- a/src/renderer/locales/locales.ts +++ b/src/renderer/locales/locales.ts @@ -1,7 +1,7 @@ import type enDateTimes from './en/datetimes' import type en from './en/messages.json' import type enNumbers from './en/numbers' -import type { DefineDateTimeFormatSchema, DefineMessageSchema, DefineNumberFormatSchema } from '../helpers/i18n' +import type { DefineDateTimeFormatSchema, DefineMessageSchema, DefineNumberFormatSchema } from '../support/i18n' export type Locales = 'en' export type MessageSchema = DefineMessageSchema<typeof en> diff --git a/src/renderer/modals/SourceDialog.vue b/src/renderer/modals/SourceDialog.vue index 7a90ab2..02dcdc0 100644 --- a/src/renderer/modals/SourceDialog.vue +++ b/src/renderer/modals/SourceDialog.vue @@ -4,13 +4,12 @@ import { useVModel } from '@vueuse/core' import { reactive, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import ReplacableImage from '../components/ReplacableImage.vue' -import { filesToAttachment } from '../helpers/attachment' -import { useRules, useValidation } from '../helpers/validation' +import { useRules, useValidation } from '../hooks/validation' import { useSources } from '../services/sources' +import { toAttachments } from '../support/files' import { useDialogs, useSourceDialog } from './dialogs' import type { I18nSchema } from '../locales/locales' import type { NewSource, Source } from '../services/sources' -import { isNotNullish } from '@/basics' import { toError } from '@/error-handling' const props = defineProps<{ @@ -42,7 +41,7 @@ const isVisible = useVModel(props, 'visible', emit) async function confirm() { try { - const result = await sources.add(source.value, ...(await filesToAttachment([file.value].filter(isNotNullish)))) + const result = await sources.add(source.value, ...(await toAttachments([file.value]))) isVisible.value = false emit('confirm', result) } catch (e) { diff --git a/src/renderer/modals/SwitchDialog.vue b/src/renderer/modals/SwitchDialog.vue index 809f06d..804ab4f 100644 --- a/src/renderer/modals/SwitchDialog.vue +++ b/src/renderer/modals/SwitchDialog.vue @@ -3,8 +3,8 @@ import { mdiClose } from '@mdi/js' import { useVModel } from '@vueuse/core' import { computed, ref, reactive, onBeforeMount } from 'vue' import { useI18n } from 'vue-i18n' -import { useLocation } from '../helpers/location' -import { useRules, useValidation } from '../helpers/validation' +import { useLocation } from '../hooks/location' +import { useRules, useValidation } from '../hooks/validation' import useDrivers from '../services/driver' import usePorts from '../services/ports' import { useDialogs, useSwitchDialog } from './dialogs' diff --git a/src/renderer/modals/TieDialog.vue b/src/renderer/modals/TieDialog.vue index 0e18340..1eb4f7b 100644 --- a/src/renderer/modals/TieDialog.vue +++ b/src/renderer/modals/TieDialog.vue @@ -4,7 +4,7 @@ import { useVModel } from '@vueuse/core' import { computed, ref, reactive, watch, onBeforeMount } from 'vue' import { useI18n } from 'vue-i18n' import NumberInput from '../components/NumberInput.vue' -import { useRules, useValidation } from '../helpers/validation' +import { useRules, useValidation } from '../hooks/validation' import useDrivers, { kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs } from '../services/driver' import { useSources } from '../services/sources' import { useSwitches } from '../services/switches' diff --git a/src/renderer/modals/dialogs.ts b/src/renderer/modals/dialogs.ts index 157e0ab..c992915 100644 --- a/src/renderer/modals/dialogs.ts +++ b/src/renderer/modals/dialogs.ts @@ -2,8 +2,8 @@ import { createSharedComposable, useConfirmDialog } from '@vueuse/core' import { computed } from 'vue' import { useDisplay } from 'vuetify' import { z } from 'zod' -import { useErrors } from '../helpers/errors' -import { useResponsiveModal } from '../helpers/vuetify' +import { useErrors } from '../hooks/errors' +import { useResponsiveModal } from '../hooks/vuetify' export const AlertModalOptions = z .string() diff --git a/src/renderer/pages/FirstRunLogic.vue b/src/renderer/pages/FirstRunLogic.vue index 48e4b25..fc789db 100644 --- a/src/renderer/pages/FirstRunLogic.vue +++ b/src/renderer/pages/FirstRunLogic.vue @@ -1,10 +1,10 @@ <script setup lang="ts"> 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 useStartup from '../services/startup' -import { trackBusy } from '../services/tracking' import type { I18nSchema } from '../locales/locales' const { t } = useI18n<I18nSchema>() diff --git a/src/renderer/pages/GeneralPage.vue b/src/renderer/pages/GeneralPage.vue index fa86111..d96efa1 100644 --- a/src/renderer/pages/GeneralPage.vue +++ b/src/renderer/pages/GeneralPage.vue @@ -14,10 +14,10 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import OptionDialog from '../components/OptionDialog.vue' import Page from '../components/Page.vue' -import { useGuardedAsyncOp } from '../helpers/utilities' +import { trackBusy } from '../hooks/tracking' +import { useGuardedAsyncOp } from '../hooks/utilities' import { useClient } from '../services/rpc' import useSettings from '../services/settings' -import { trackBusy } from '../services/tracking' import type { I18nSchema } from '../locales/locales' const { t } = useI18n<I18nSchema>() diff --git a/src/renderer/pages/MainDashboard.vue b/src/renderer/pages/MainDashboard.vue index e1e776b..a049a24 100644 --- a/src/renderer/pages/MainDashboard.vue +++ b/src/renderer/pages/MainDashboard.vue @@ -2,9 +2,10 @@ import { mdiPower, mdiVideoInputHdmi, mdiWrench } from '@mdi/js' import { computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' -import { useGuardedAsyncOp } from '../helpers/utilities' +import { useGuardedAsyncOp } from '../hooks/utilities' import { useDialogs } from '../modals/dialogs' import { useDashboard } from '../services/dashboard' +import { useClient } from '../services/rpc' import useSettings from '../services/settings' import FirstRunLogic from './FirstRunLogic.vue' import type { I18nSchema } from '../locales/locales' @@ -23,10 +24,12 @@ const dialogs = useDialogs() const dashboard = useDashboard() +const client = useClient() + async function powerOff() { try { await dashboard.powerOff() - await services.system.powerOff() + await client.system.powerOff.mutate() } catch (e) { await dialogs.error(e) } diff --git a/src/renderer/pages/SettingsBackupPage.vue b/src/renderer/pages/SettingsBackupPage.vue index 748be6c..69bb275 100644 --- a/src/renderer/pages/SettingsBackupPage.vue +++ b/src/renderer/pages/SettingsBackupPage.vue @@ -3,10 +3,11 @@ import { mdiArrowLeft, mdiExport, mdiImport } from '@mdi/js' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import Page from '../components/Page.vue' +import { trackBusy } from '../hooks/tracking' import { useDialogs } from '../modals/dialogs' import { exportSettings } from '../services/backup/export' import { importSettings } from '../services/backup/import' -import { trackBusy } from '../services/tracking' +import { openFile, saveFile } from '../support/files' import type { I18nSchema } from '../locales/locales' // @@ -22,16 +23,10 @@ const router = useRouter() // Functionality. // -const kFilters = [{ extensions: ['zip'], name: t('description.archive') }] - async function exportToFile() { try { const file = await wait(exportSettings()) - await globalThis.services.system.saveFile(file, { - title: t('label.export'), - filters: kFilters, - properties: ['showOverwriteConfirmation'] - }) + await saveFile(file) } catch (e) { await dialogs.error(e, { title: t('error.export') @@ -41,11 +36,7 @@ async function exportToFile() { async function importFromFile() { try { - const files = await globalThis.services.system.openFile({ - title: t('label.import'), - filters: kFilters, - properties: ['openFile', 'dontAddToRecent'] - }) + const files = await openFile({ accepts: '.zip' }) const file = files?.at(0) if (file == null) return diff --git a/src/renderer/pages/SettingsPage.vue b/src/renderer/pages/SettingsPage.vue index 1a9b47e..a63cfaa 100644 --- a/src/renderer/pages/SettingsPage.vue +++ b/src/renderer/pages/SettingsPage.vue @@ -12,11 +12,11 @@ import { ref, onMounted, onUnmounted } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import Page from '../components/Page.vue' -import { useGuardedAsyncOp } from '../helpers/utilities' +import { trackBusy } from '../hooks/tracking' +import { useGuardedAsyncOp } from '../hooks/utilities' import { useClient } from '../services/rpc' import { useSources } from '../services/sources' import { useSwitches } from '../services/switches' -import { trackBusy } from '../services/tracking' import type { I18nSchema } from '../locales/locales' // diff --git a/src/renderer/pages/SourceList.vue b/src/renderer/pages/SourceList.vue index 2d5a230..01f4e41 100644 --- a/src/renderer/pages/SourceList.vue +++ b/src/renderer/pages/SourceList.vue @@ -4,11 +4,12 @@ import { computed, onBeforeMount } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import Page from '../components/Page.vue' -import { toFiles, useObjectUrls } from '../helpers/attachment' -import { useGuardedAsyncOp } from '../helpers/utilities' +import { useObjectUrls } from '../hooks/assets' +import { useGuardedAsyncOp } from '../hooks/utilities' import SourceDialog from '../modals/SourceDialog.vue' import { useDialogs, useSourceDialog } from '../modals/dialogs' import { useSources } from '../services/sources' +import { toFiles } from '../support/files' import type { I18nSchema } from '../locales/locales' import type { Source } from '../services/sources' import type { DocumentId } from '../services/store' diff --git a/src/renderer/pages/SourcePage.vue b/src/renderer/pages/SourcePage.vue index 676e124..a25c03f 100644 --- a/src/renderer/pages/SourcePage.vue +++ b/src/renderer/pages/SourcePage.vue @@ -6,15 +6,15 @@ import { useRouter } from 'vue-router' import InputDialog from '../components/InputDialog.vue' import Page from '../components/Page.vue' import ReplacableImage from '../components/ReplacableImage.vue' -import { filesToAttachment, toFiles } from '../helpers/attachment' -import { useGuardedAsyncOp } from '../helpers/utilities' +import { trackBusy } from '../hooks/tracking' +import { useGuardedAsyncOp } from '../hooks/utilities' import TieDialog from '../modals/TieDialog.vue' import { useDialogs, useTieDialog } from '../modals/dialogs' import useDrivers from '../services/driver' import { useSources } from '../services/sources' import { useSwitches } from '../services/switches' import { useTies } from '../services/ties' -import { trackBusy } from '../services/tracking' +import { toAttachments, toFiles } from '../support/files' import type { I18nSchema } from '../locales/locales' import type { DriverInformation } from '../services/driver' import type { Source } from '../services/sources' @@ -65,7 +65,7 @@ async function save() { try { source.value.image = file.value.name source.value = { - ...(await sources.update(source.value, ...(await filesToAttachment([file.value].filter(isNotNullish))))) + ...(await sources.update(source.value, ...(await toAttachments([file.value])))) } file.value = toFiles(source.value._attachments).find( (f) => source.value?.image != null && f.name === source.value.image diff --git a/src/renderer/pages/SwitchList.vue b/src/renderer/pages/SwitchList.vue index 110d168..da52c70 100644 --- a/src/renderer/pages/SwitchList.vue +++ b/src/renderer/pages/SwitchList.vue @@ -4,7 +4,7 @@ import { computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import Page from '../components/Page.vue' -import { useGuardedAsyncOp } from '../helpers/utilities' +import { useGuardedAsyncOp } from '../hooks/utilities' import SwitchDialog from '../modals/SwitchDialog.vue' import { useDialogs, useSwitchDialog } from '../modals/dialogs' import useDrivers from '../services/driver' diff --git a/src/renderer/services/appUpdates.ts b/src/renderer/services/appUpdates.ts index 26e4a5f..0f14734 100644 --- a/src/renderer/services/appUpdates.ts +++ b/src/renderer/services/appUpdates.ts @@ -1,4 +1,4 @@ -import { tryOnScopeDispose } from '@vueuse/core' +import { createSharedComposable, tryOnScopeDispose } from '@vueuse/core' import useTypedEventTarget from '../support/events' import { useClient } from './rpc' import type { ProgressInfo } from 'electron-updater' @@ -16,7 +16,7 @@ export class UpdateProgressEvent extends Event implements ProgressInfo { } } -function useAppUpdates() { +const useAppUpdates = createSharedComposable(function useAppUpdates() { const client = useClient() // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Inapropriate, type !== interface @@ -70,6 +70,6 @@ function useAppUpdates() { }) return appUpdater -} +}) export default useAppUpdates diff --git a/src/renderer/services/backup/export.ts b/src/renderer/services/backup/export.ts index bb57c5e..3f4d486 100644 --- a/src/renderer/services/backup/export.ts +++ b/src/renderer/services/backup/export.ts @@ -1,6 +1,6 @@ import { BlobReader, BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js' import { pick } from 'radash' -import { toFiles } from '../../helpers/attachment' +import { toFiles } from '../../support/files' import useSettings from '../settings' import { useSources } from '../sources' import { useSwitches } from '../switches' diff --git a/src/renderer/services/backup/import.ts b/src/renderer/services/backup/import.ts index d0fa602..9346777 100644 --- a/src/renderer/services/backup/import.ts +++ b/src/renderer/services/backup/import.ts @@ -1,7 +1,7 @@ import { BlobReader, BlobWriter, TextWriter, ZipReader } from '@zip.js/zip.js' import mime from 'mime' import { z } from 'zod' -import { fileToAttachment } from '../../helpers/attachment' +import { toAttachment } from '../../support/files' import useDrivers from '../driver' import useSettings from '../settings' import { useSources } from '../sources' @@ -58,7 +58,7 @@ export async function importSettings(file: File) { const type = mime.getType(item.image) ?? 'application/octet-stream' let imageAttachment = imageCache.get(item.image) if (imageAttachment != null) { - await sources.add(item, await fileToAttachment(imageAttachment)) + await sources.add(item, await toAttachment(imageAttachment)) return } @@ -75,7 +75,7 @@ export async function importSettings(file: File) { const imageData = await imageFile.getData() imageAttachment = new File([imageData], item.image, { type }) imageCache.set(item.image, imageAttachment) - await sources.add(item, await fileToAttachment(imageAttachment)) + await sources.add(item, await toAttachment(imageAttachment)) }) ), Promise.all( diff --git a/src/renderer/services/dashboard.ts b/src/renderer/services/dashboard.ts index cbfd1af..9b24b90 100644 --- a/src/renderer/services/dashboard.ts +++ b/src/renderer/services/dashboard.ts @@ -1,12 +1,13 @@ import { defineStore } from 'pinia' import { computed, readonly, ref } from 'vue' -import { toFiles, useImages } from '../helpers/attachment' +import { useImages } from '../hooks/assets' +import { trackBusy } from '../hooks/tracking' +import { toFiles } from '../support/files' import useDrivers from './driver' import useSettings from './settings' import { useSources } from './sources' import { useSwitches } from './switches' import { useTies } from './ties' -import { trackBusy } from './tracking' import type { Driver } from './driver' import type { Source } from './sources' import type { DocumentId } from './store' diff --git a/src/renderer/services/driver.ts b/src/renderer/services/driver.ts index 5228049..9ec3cb4 100644 --- a/src/renderer/services/driver.ts +++ b/src/renderer/services/driver.ts @@ -1,8 +1,8 @@ -import { memo } from 'radash' +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 { trackBusy } from './tracking' import type { kDeviceHasNoExtraCapabilities as HasNoExtraCapabilities, kDeviceSupportsMultipleOutputs as SupportsMultipleOutputs, @@ -46,7 +46,7 @@ export interface Driver extends DriverInformation { readonly uri: string } -const useDrivers = memo(function useDrivers() { +const useDrivers = createSharedComposable(function useDrivers() { const client = useClient() /** Busy tracking. */ diff --git a/src/renderer/services/ports.ts b/src/renderer/services/ports.ts index 22c44f8..f278617 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 { trackBusy } from './tracking' 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 index 74f5456..3f18f26 100644 --- a/src/renderer/services/rpc.ts +++ b/src/renderer/services/rpc.ts @@ -1,10 +1,10 @@ import { createTRPCProxyClient, createWSClient, httpLink, wsLink } from '@trpc/client' -import { memo } from 'radash' +import { createGlobalState } from '@vueuse/shared' import type { AppRouter } from '../../preload/api' import useSuperJson from '@/rpc' import { getServerUrl } from '@/url' -export const useClient = memo(function useClient() { +export const useClient = createGlobalState(function useClient() { function useHttpClient() { return createTRPCProxyClient<AppRouter>({ transformer: useSuperJson(), diff --git a/src/renderer/services/sources.ts b/src/renderer/services/sources.ts index 736ce7f..f703289 100644 --- a/src/renderer/services/sources.ts +++ b/src/renderer/services/sources.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { forceUndefined } from '../helpers/utilities' +import { forceUndefined } from '../hooks/utilities' import { useClient } from './rpc' import { useDataStore } from './store' import type { DocumentId } from './store' diff --git a/src/renderer/services/startup.ts b/src/renderer/services/startup.ts index 2285232..83c3ab5 100644 --- a/src/renderer/services/startup.ts +++ b/src/renderer/services/startup.ts @@ -1,7 +1,7 @@ -import { memo } from 'radash' +import { createSharedComposable } from '@vueuse/shared' import { useClient } from './rpc' -const useStartup = memo(function useStartup() { +const useStartup = createSharedComposable(function useStartup() { const client = useClient() const checkEnabled = async () => await client.startup.checkEnabled.query() const enable = async () => { diff --git a/src/renderer/services/storage.ts b/src/renderer/services/storage.ts index 7caf1ef..2a6b3ca 100644 --- a/src/renderer/services/storage.ts +++ b/src/renderer/services/storage.ts @@ -1,7 +1,7 @@ -import { useStorageAsync } from '@vueuse/core' -import { memo } from 'radash' +import { createSharedComposable, useStorageAsync } from '@vueuse/core' import { useClient } from './rpc' -import type { MaybeRefOrGetter, UseStorageAsyncOptions, RemovableRef } from '@vueuse/core' +import type { RemovableRef, UseStorageAsyncOptions } from '@vueuse/core' +import type { MaybeRefOrGetter } from 'vue' // To make this work well, we will require async methods. export interface StorageLikeAsync { @@ -21,7 +21,7 @@ interface AsyncStorageEventInitDict { readonly storageArea: StorageLikeAsync | null } -const useUserStore = memo(function useUserStore() { +const useUserStore = createSharedComposable(function useUserStore() { const client = useClient() function emit(data: AsyncStorageEventInitDict) { diff --git a/src/renderer/services/store.ts b/src/renderer/services/store.ts index 326b171..7bd93f2 100644 --- a/src/renderer/services/store.ts +++ b/src/renderer/services/store.ts @@ -1,5 +1,5 @@ import { computed, nextTick, ref, shallowReadonly } from 'vue' -import { trackBusy } from './tracking' +import { trackBusy } from '../hooks/tracking' import type { DocumentId } from '../../preload/api' import type { Ref } from 'vue' diff --git a/src/renderer/services/switches.ts b/src/renderer/services/switches.ts index dbe475d..ef68500 100644 --- a/src/renderer/services/switches.ts +++ b/src/renderer/services/switches.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { forceUndefined } from '../helpers/utilities' +import { forceUndefined } from '../hooks/utilities' import { useClient } from './rpc' import { useDataStore } from './store' import type { DocumentId } from './store' diff --git a/src/renderer/services/ties.ts b/src/renderer/services/ties.ts index 2d96e23..dc85689 100644 --- a/src/renderer/services/ties.ts +++ b/src/renderer/services/ties.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { forceUndefined } from '../helpers/utilities' +import { forceUndefined } from '../hooks/utilities' import { useClient } from './rpc' import { useDataStore } from './store' import type { DocumentId } from './store' diff --git a/src/renderer/support/files.ts b/src/renderer/support/files.ts new file mode 100644 index 0000000..9494268 --- /dev/null +++ b/src/renderer/support/files.ts @@ -0,0 +1,102 @@ +import { defer } from 'radash' +import { Attachment } from '@/attachments' +import { asMicrotask, isNotNullish, withResolvers } from '@/basics' +import { raiseError } from '@/error-handling' + +/* eslint-disable n/no-unsupported-features/node-builtins -- In browser environment. */ + +export async function saveFile(source: File): Promise<void> +export async function saveFile(source: Blob, defaultName: string): Promise<void> +export async function saveFile(source: File | (Blob & { name?: undefined }), defaultName?: string) { + await defer(async (cleanup) => { + const saver = globalThis.document.createElement('a') + saver.style.display = 'none' + saver.href = URL.createObjectURL(source) + saver.download = source.name ?? defaultName ?? raiseError(() => new TypeError('No file name specified')) + cleanup(() => { + URL.revokeObjectURL(saver.href) + }) + + await asMicrotask(() => { + globalThis.document.body.appendChild(saver) + cleanup(() => { + globalThis.document.body.removeChild(saver) + }) + }) + + await asMicrotask(() => { + saver.click() + }) + }) +} + +interface OpenFileOptions { + accepts?: string | undefined + multiple?: true | undefined +} + +export async function openFile(options: OpenFileOptions = {}) { + return await defer(async (cleanup) => { + const opener = globalThis.document.createElement('input') + opener.style.display = 'none' + opener.type = 'file' + opener.accept = options.accepts ?? '' + opener.multiple = options.multiple ?? false + + await asMicrotask(() => { + globalThis.document.body.appendChild(opener) + cleanup(() => { + globalThis.document.body.removeChild(opener) + }) + }) + + return await asMicrotask(async () => { + const { resolve, promise } = withResolvers<File[] | null>() + opener.addEventListener( + 'cancel', + () => { + resolve(null) + }, + { once: true } + ) + opener.addEventListener( + 'change', + () => { + resolve([...(opener.files ?? [])].filter(isNotNullish)) + }, + { once: true } + ) + + opener.click() + + return await promise + }) + }) +} + +type MaybeAttachment = Attachment | null | undefined + +export function toFile(attachment: Attachment): File +export function toFile(attachment: MaybeAttachment): File | null +export function toFile(attachment: MaybeAttachment) { + return attachment != null ? new File([attachment], attachment.name, { type: attachment.type }) : null +} + +export function toFiles(attachments: MaybeAttachment[] | null | undefined) { + if (attachments == null) return [] + return attachments.map(toFile).filter(isNotNullish) +} + +type MaybeFile = File | null | undefined + +export function toAttachment(file: File): Promise<Attachment> +export function toAttachment(file: MaybeFile): Promise<Attachment | null> +export async function toAttachment(file: MaybeFile) { + return file != null ? new Attachment(file.name, file.type, await file.arrayBuffer()) : null +} + +export async function toAttachments(files: MaybeFile[] | null | undefined) { + if (files == null) return [] + const attachments = await Promise.all(files.map(toAttachment)) + return attachments.filter(isNotNullish) +} diff --git a/src/renderer/helpers/i18n.ts b/src/renderer/support/i18n.ts similarity index 100% rename from src/renderer/helpers/i18n.ts rename to src/renderer/support/i18n.ts diff --git a/yarn.lock b/yarn.lock index bf06161..7fb3556 100644 --- a/yarn.lock +++ b/yarn.lock @@ -491,7 +491,22 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.57.1", "@eslint/js@^8.57.0": +"@eslint/eslintrc@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6" + integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.57.1", "@eslint/js@^8.57.1": version "8.57.1" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== @@ -1016,25 +1031,25 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339" integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ== -"@sixxgate/lint@^3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@sixxgate/lint/-/lint-3.2.1.tgz#95b797593d3e1958c2e613c8cb75e01d9e0c9eef" - integrity sha512-M74rx8kFJ9CvNIpC23rx3Si6HBJHRDtAUm3WfbiDhXc4IPNJs1b85tlzLlj5jJXAvNdXrWx+K1/P0aKV+GmEZQ== +"@sixxgate/lint@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@sixxgate/lint/-/lint-3.3.0.tgz#3936f31f24a0ef9791c9a64c77b85cb6a7ec699c" + integrity sha512-MCqIVWd/EJTG/GKE+hh2K4RsmHrf1GGYSvpPxRQWN6PSUop8QNDDvUX4/3H8+9A6e51CSDV6Jh3mb/p4az44ow== dependencies: - "@eslint/eslintrc" "^2.1.4" - "@eslint/js" "^8.57.0" - "@types/eslint" "^8.56.11" + "@eslint/eslintrc" "^3.1.0" + "@eslint/js" "^8.57.1" + "@types/eslint" "^8.56.12" chalk "^4.1.2" - debug "^4.3.6" - eslint "^8.57.0" + debug "^4.3.7" + eslint "^8.57.1" execa "^5.1.1" is-interactive "^1.0.0" lodash "^4.17.21" pkg-dir "^5.0.0" radash "^12.1.0" read-pkg "^5.2.0" - tslib "^2.6.3" - type-fest "^4.24.0" + tslib "^2.8.1" + type-fest "^4.26.1" zod "^3.23.8" "@szmarczak/http-timer@^4.0.5": @@ -1091,14 +1106,7 @@ dependencies: "@types/ms" "*" -"@types/duplexify@^3.6.4": - version "3.6.4" - resolved "https://registry.yarnpkg.com/@types/duplexify/-/duplexify-3.6.4.tgz#aa7e916c33fcc05be8769546fd0441d9b368613e" - integrity sha512-2eahVPsd+dy3CL6FugAzJcxoraWhUghZGEQJns1kTKfCXWKJ5iG/VkaB05wRVrDKHfOFKqb0X0kXh91eE99RZg== - dependencies: - "@types/node" "*" - -"@types/eslint@^8.56.11", "@types/eslint@^8.56.12": +"@types/eslint@^8.56.12": version "8.56.12" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.12.tgz#1657c814ffeba4d2f84c0d4ba0f44ca7ea1ca53a" integrity sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g== @@ -1221,16 +1229,6 @@ dependencies: "@types/node" "*" -"@types/setimmediate@^1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@types/setimmediate/-/setimmediate-1.0.4.tgz#c863c763c284ceaf8d31ec1b29362a889864da4f" - integrity sha512-rWPw1drMVf5zInxNpgH3nn/h6KkWqwgLT2y/ciAYQ16RAsbXOXe0AmtZ/HyzwPNw+r4GMJuI7IV7YNKO7Fs/xA== - -"@types/uuid@^10.0.0": - version "10.0.0" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" - integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== - "@types/verror@^1.10.3": version "1.10.10" resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.10.tgz#d5a4b56abac169bfbc8b23d291363a682e6fa087" @@ -1241,10 +1239,10 @@ resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== -"@types/ws@^8.5.12": - version "8.5.12" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.12.tgz#619475fe98f35ccca2a2f6c137702d85ec247b7e" - integrity sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ== +"@types/ws@^8.5.13": + version "8.5.13" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20" + integrity sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA== dependencies: "@types/node" "*" @@ -2621,16 +2619,6 @@ double-ended-queue@2.1.0-0: resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c" integrity sha512-+BNfZ+deCo8hMNpDqDnvT+c0XpJ5cUa6mqYq89bho2Ifze4URTqRkcwR399hWoTrTkbZ/XJYDgP6rc7pRgffEQ== -duplexify@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.3.tgz#a07e1c0d0a2c001158563d32592ba58bddb0236f" - integrity sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA== - dependencies: - end-of-stream "^1.4.1" - inherits "^2.0.3" - readable-stream "^3.1.1" - stream-shift "^1.0.2" - eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -2744,7 +2732,7 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -end-of-stream@^1.1.0, end-of-stream@^1.4.1: +end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -3084,7 +3072,12 @@ eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4 resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@^8.57.0, eslint@^8.57.1: +eslint-visitor-keys@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" + integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== + +eslint@^8.57.1: version "8.57.1" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== @@ -3128,6 +3121,15 @@ eslint@^8.57.0, eslint@^8.57.1: strip-ansi "^6.0.1" text-table "^0.2.0" +espree@^10.0.1: + version "10.3.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.3.0.tgz#29267cf5b0cb98735b65e64ba07e0ed49d1eed8a" + integrity sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg== + dependencies: + acorn "^8.14.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.0" + espree@^9.0.0, espree@^9.3.1, espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" @@ -3178,11 +3180,6 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -events@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -3577,6 +3574,11 @@ globals@^13.19.0, globals@^13.24.0: dependencies: type-fest "^0.20.2" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + globals@^15.11.0: version "15.11.0" resolved "https://registry.yarnpkg.com/globals/-/globals-15.11.0.tgz#b96ed4c6998540c6fb824b24b5499216d2438d6e" @@ -3820,7 +3822,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.4: +inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -5283,7 +5285,7 @@ readable-stream@1.1.14: isarray "0.0.1" string_decoder "~0.10.x" -"readable-stream@2 || 3", readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0: +"readable-stream@2 || 3", readable-stream@^3.4.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -5457,15 +5459,16 @@ sanitize-filename@^1.6.3: dependencies: truncate-utf8-bytes "^1.0.0" -sass@^1.80.5: - version "1.80.5" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.5.tgz#0ba965223d44df22497f2966b498cf5c453fae8f" - integrity sha512-TQd2aoQl/+zsxRMEDSxVdpPIqeq9UFc6pr7PzkugiTx3VYCFPUaa3P4RrBQsqok4PO200Vkz0vXQBNlg7W907g== +sass@^1.80.6: + version "1.80.6" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.6.tgz#5d0aa55763984effe41e40019c9571ab73e6851f" + integrity sha512-ccZgdHNiBF1NHBsWvacvT5rju3y1d/Eu+8Ex6c21nHp2lZGLBEtuwc415QfiI1PJa1TpCo3iXwwSRjRpn2Ckjg== dependencies: - "@parcel/watcher" "^2.4.1" chokidar "^4.0.0" immutable "^4.0.0" source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" sax@^1.2.4: version "1.4.1" @@ -5553,11 +5556,6 @@ set-function-name@^2.0.2: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" -setimmediate@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -5718,19 +5716,6 @@ std-env@^3.7.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== -stream-browserify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" - integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== - dependencies: - inherits "~2.0.4" - readable-stream "^3.5.0" - -stream-shift@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" - integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== - "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -6035,7 +6020,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.6.2, tslib@^2.6.3, tslib@^2.8.1: +tslib@^2.6.2, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -6067,7 +6052,7 @@ type-fest@^2.12.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== -type-fest@^4.24.0, type-fest@^4.26.1: +type-fest@^4.26.1: version "4.26.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.26.1.tgz#a4a17fa314f976dd3e6d6675ef6c775c16d7955e" integrity sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg== @@ -6238,11 +6223,6 @@ uuid@8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^11.0.2: - version "11.0.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.2.tgz#a8d68ba7347d051e7ea716cc8dcbbab634d66875" - integrity sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ== - validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"