Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allowed the back-end to select a port as needed, marked the kind of drivers, and added some security to the tRPC channel. #91

Merged
merged 1 commit into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
- Milestones
- v2.1
- (#86) Add a means to select a not-in-use port for the tRPC channel.
- v2.2
- Move more modules to core.
- tRPC over Electron IPC.
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/extron/sis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useExtronSisProtocol } from '../../services/protocols/extronSis'
const extronSisDriver = defineDriver({
enabled: true,
experimental: false,
kind: 'switch',
guid: '4C8F2838-C91D-431E-84DD-3666D14A6E2C',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/shinybow/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useShinybowV2Protocol } from '../../services/protocols/shinybow'
const shinybowV2 = defineDriver({
enabled: true,
experimental: true,
kind: 'switch',
guid: '75FB7ED2-EE3A-46D5-B11F-7D8C3C208E7C',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/shinybow/v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useShinybowV3Protocol } from '../../services/protocols/shinybow'
const shinybowV3 = defineDriver({
enabled: true,
experimental: true,
kind: 'switch',
guid: 'BBED08A1-C749-4733-8F2E-96C9B56C0C41',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/sony/rs485.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useSonyBvmProtocol } from '../../services/protocols/sonyBvm'
const sonyRs485Driver = defineDriver({
enabled: true,
experimental: false,
kind: 'monitor',
guid: '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/tesla-smart/kvm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTeslaElecKvmProtocol } from '../../services/protocols/teslaElec'
const teslaSmartKvmDriver = defineDriver({
enabled: true,
experimental: true,
kind: 'switch',
guid: '91D5BC95-A8E2-4F58-BCAC-A77BA1054D61',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/tesla-smart/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTeslaElecMatrixProtocol } from '../../services/protocols/teslaElec'
const teslaSmartMatrixDriver = defineDriver({
enabled: true,
experimental: true,
kind: 'switch',
guid: '671824ED-0BC4-43A6-85CC-4877890A7722',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/tesla-smart/sdi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTeslaElecSdiProtocol } from '../../services/protocols/teslaElec'
const teslaSmartSdiDriver = defineDriver({
enabled: true,
experimental: true,
kind: 'switch',
guid: 'DDB13CBC-ABFC-405E-9EA6-4A999F9A16BD',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/tesmart/kvm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTeslaElecKvmProtocol } from '../../services/protocols/teslaElec'
const tesmartKvmDriver = defineDriver({
enabled: true,
experimental: true,
kind: 'switch',
guid: '2B4EDB8E-D2D6-4809-BA18-D5B1785DA028',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/tesmart/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTeslaElecMatrixProtocol } from '../../services/protocols/teslaElec'
const tesmartMatrixDriver = defineDriver({
enabled: true,
experimental: true,
kind: 'switch',
guid: '01B8884C-1D7D-4451-883D-3C8F18E17B14',
localized: {
en: {
Expand Down
1 change: 1 addition & 0 deletions src/main/drivers/tesmart/sdi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTeslaElecSdiProtocol } from '../../services/protocols/teslaElec'
const tesmartSdiDriver = defineDriver({
enabled: true,
experimental: true,
kind: 'switch',
guid: '8C524E65-83EF-4AEF-B0DA-29C4582AA4A0',
localized: {
en: {
Expand Down
15 changes: 0 additions & 15 deletions src/main/info/config.ts

This file was deleted.

49 changes: 24 additions & 25 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { app, shell, BrowserWindow, nativeTheme } from 'electron'
import Logger from 'electron-log'
import { sleep } from 'radash'
import appIcon from '../../resources/icon.png?asset&asarUnpack'
import useAppConfig from './info/config'
import useApiServer from './server'
import { getAuthToken } from './services/trpc'
import { logError } from './utilities'
import { toError } from '@/error-handling'

Expand All @@ -18,30 +18,27 @@ Logger.transports.console.format = '{h}:{i}:{s}.{ms} [{level}] › {text}'
Logger.transports.file.level = 'debug'
Logger.errorHandler.startCatching()

async function createWindow() {
async function createWindow(port: number) {
const willStartWithDark = nativeTheme.shouldUseDarkColors || nativeTheme.shouldUseInvertedColorScheme

const main = new BrowserWindow({
const window = new BrowserWindow({
width: 800,
height: 480,
backgroundColor: willStartWithDark ? '#121212' : 'white',
icon: appIcon,
show: true,
useContentSize: true,
webPreferences: {
preload: joinPath(__dirname, '../preload/index.mjs'),
sandbox: false
}
useContentSize: true
})

main.removeMenu()
window.removeMenu()
if (import.meta.env.PROD) {
main.setFullScreen(true)
window.setFullScreen(true)
} else {
main.webContents.openDevTools({ mode: 'undocked' })
window.webContents.openDevTools({ mode: 'undocked' })
}

main.webContents.setWindowOpenHandler(function windowOpenHandler(details) {
// Open all new window links in the system browser.
window.webContents.setWindowOpenHandler(function windowOpenHandler(details) {
shell.openExternal(details.url).catch((e: unknown) => {
Logger.error(e)
})
Expand All @@ -52,28 +49,32 @@ async function createWindow() {
const kWait = 2000
let lastError: unknown

/* eslint-disable no-await-in-loop -- Retry loop must be serial. */
for (let tries = 3; tries > 0; --tries) {
try {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env.ELECTRON_RENDERER_URL != null) {
// eslint-disable-next-line no-await-in-loop -- Retry loop must be serial.
await main.loadURL(process.env.ELECTRON_RENDERER_URL)
const url = new URL(process.env.ELECTRON_RENDERER_URL)
url.searchParams.set('port', String(port))
url.searchParams.set('auth', getAuthToken())
await window.loadURL(url.toString())
} else {
// eslint-disable-next-line no-await-in-loop -- Retry loop must be serial.
await main.loadFile(joinPath(__dirname, '../renderer/index.html'))
await window.loadFile(joinPath(__dirname, '../renderer/index.html'), {
query: { port: String(port), auth: getAuthToken() }
})
}

return main
return window
} catch (e) {
lastError = e
Logger.warn(e)

// eslint-disable-next-line no-await-in-loop -- Retry loop must be serial.
await sleep(kWait)
}
}

/* eslint-enable no-await-in-loop */
throw logError(toError(lastError))
}

Expand Down Expand Up @@ -101,15 +102,13 @@ process.on('SIGTERM', () => {
})

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
// initialization and is ready to create browser
// windows. Some APIs can only be used after
// this event occurs.
await app.whenReady()

// Set app user model id for windows
electronApp.setAppUserModelId('org.sleepingcats.BridgeCmdr')

useAppConfig()

useApiServer()

await createWindow()
const port = useApiServer()
await createWindow(port)
68 changes: 38 additions & 30 deletions src/main/server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { createHTTPServer } from '@trpc/server/adapters/standalone'
import { applyWSSHandler } from '@trpc/server/adapters/ws'
import Logger from 'electron-log'
import { range } from 'radash'
import { WebSocketServer } from 'ws'
import useAppConfig from './info/config'
import { useAppRouter } from './routes/router'
import { getServerUrl } from '@/url'
import { createStandaloneContext } from './services/trpc'

function startWebSocketServer(url: URL, host: string, port: number) {
function startWebSocketServer(url: string, host: string, port: number) {
// TODO: Authentication via the IPC, later we'll implement a proper authentication model.

process.env['WS_NO_UTF_8_VALIDATE'] = '1'
Expand All @@ -17,7 +16,7 @@ function startWebSocketServer(url: URL, host: string, port: number) {
Logger.info(`RPC server at ${url}`)
})

const handler = applyWSSHandler({ wss, router: useAppRouter() })
const handler = applyWSSHandler({ wss, router: useAppRouter(), createContext: createStandaloneContext })

process.on('exit', () => {
handler.broadcastReconnectNotification()
Expand All @@ -30,32 +29,41 @@ function startWebSocketServer(url: URL, host: string, port: number) {
})
}

function startHttpServer(url: URL, host: string, port: number) {
// TODO: Authentication via the IPC, later we'll implement a proper authentication model.

const server = createHTTPServer({
router: useAppRouter()
})

server.server.on('listening', () => {
Logger.info(`RPC server at ${url}`)
})

server.listen(port, host)
process.on('exit', () => {
server.server.close()
})

process.on('SIGTERM', () => {
server.server.close()
})
}
// TODO: Maybe usable for the remote server one day.
// function startHttpServer(url: URL, host: string, port: number) {
// // TODO: Authentication via the IPC, later we'll implement a proper authentication model.
//
// const server = createHTTPServer({
// router: useAppRouter()
// })
//
// server.server.on('listening', () => {
// Logger.info(`RPC server at ${url}`)
// })
//
// server.listen(port, host)
// process.on('exit', () => {
// server.server.close()
// })
//
// process.on('SIGTERM', () => {
// server.server.close()
// })
// }

export default function useApiServer() {
const config = useAppConfig()
const url = new URL(config.rpcUrl)
const [host, port, protocol] = getServerUrl(url, 7180)
let cause
const host = '127.0.0.1'
for (const port of range(7000, 8000)) {
const url = `ws://${host}:${port}`
try {
startWebSocketServer(url, host, port)
return port
} catch (err) {
cause = err
console.warn(`Unable to bind server to ${url}`, cause)
}
}

if (protocol === 'http:') startHttpServer(url, host, port)
if (protocol === 'ws:') startWebSocketServer(url, host, port)
throw new Error('No port available for the server within 7000-8000', { cause })
}
6 changes: 6 additions & 0 deletions src/main/services/drivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const kDeviceSupportsMultipleOutputs = 1
export type kDeviceCanDecoupleAudioOutput = typeof kDeviceCanDecoupleAudioOutput
export const kDeviceCanDecoupleAudioOutput = 2

export type DriverKind = 'monitor' | 'switch'

//
// Driver definition
//
Expand All @@ -30,6 +32,8 @@ export interface DriverBasicInformation {
readonly enabled: boolean
/** Indicates whether the driver is experimental, usually due to lack of testing. */
readonly experimental: boolean
/** Identifies the kind of device driven by the driver. */
readonly kind: DriverKind
/** A unique identifier for the driver. */
readonly guid: string
/** Defines the capabilities of the device driven by the driver. */
Expand Down Expand Up @@ -114,6 +118,7 @@ export function defineDriver(options: DefineDriverOptions) {
return {
enabled: info.enabled,
experimental: info.experimental,
kind: info.kind,
guid: info.guid,
...localizedInfo,
capabilities: info.capabilities
Expand All @@ -129,6 +134,7 @@ export function defineDriver(options: DefineDriverOptions) {
// Information and informational functionality.
enabled: info.enabled,
experimental: info.experimental,
kind: info.kind,
guid: info.guid,
capabilities: info.capabilities,
metadata: info,
Expand Down
46 changes: 43 additions & 3 deletions src/main/services/trpc.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
import { initTRPC } from '@trpc/server'
import { randomBytes } from 'crypto'
import { initTRPC, TRPCError } from '@trpc/server'
import { memo } from 'radash'
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone'
import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws'
import type { TRPC_ERROR_CODE_KEY } from '@trpc/server/rpc'
import useSuperJson from '@/rpc'

const t = initTRPC.create({
export type Context = Awaited<ReturnType<typeof createContext>>

function createContext(path: string | null) {
if (path == null) return { auth: undefined }
const url = new URL(`ws://127.0.0.1${path}`)
const auth = url.searchParams.get('auth') ?? undefined

return { auth }
}

export function createStandaloneContext(opts: CreateHTTPContextOptions | CreateWSSContextFnOptions) {
return createContext(opts.req.url ?? null)
}

const t = initTRPC.context<Context>().create({
transformer: useSuperJson()
})

export const { router, procedure, createCallerFactory } = t
export const getAuthToken = memo(function getAuthToken() {
return randomBytes(16).toString('base64url')
})

function error(code: TRPC_ERROR_CODE_KEY, message?: string, cause?: unknown): never {
throw new TRPCError({
code,
...(message ? { message } : {}),
...(cause != null ? { cause } : {})
})
}

export const { router, createCallerFactory } = t
export const procedure = t.procedure.use(async function checkAuth(opts) {
const { ctx } = opts
const { auth } = ctx

if (auth == null) error('UNAUTHORIZED')
if (auth !== getAuthToken()) error('UNAUTHORIZED')

return await opts.next()
})
Loading