Implement standalone control server

This commit is contained in:
Max Goodhart
2025-06-14 06:46:27 +00:00
parent ec6b7bd360
commit 9ded048667
29 changed files with 3414 additions and 415 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "streamwall",
"productName": "Streamwall",
"version": "2.0.0-pre1",
"version": "2.0.0-pre2",
"description": "Watch streams in a grid layout",
"main": ".vite/build/index.js",
"repository": "github:streamwall/streamwall",
@@ -42,7 +42,6 @@
"author": "Max Goodhart <c@chromakode.com>",
"license": "MIT",
"dependencies": {
"@fontsource/noto-sans": "^5.1.1",
"@iarna/toml": "^2.2.5",
"@repeaterjs/repeater": "^3.0.6",
"@sentry/electron": "^5.9.0",

View File

@@ -3,9 +3,13 @@ import * as Sentry from '@sentry/electron/main'
import { BrowserWindow, app, session } from 'electron'
import started from 'electron-squirrel-startup'
import fs from 'fs'
import EventEmitter from 'node:events'
import { join } from 'node:path'
import ReconnectingWebSocket from 'reconnecting-websocket'
import 'source-map-support/register'
import { ControlCommand, StreamwallState } from 'streamwall-shared'
import { updateElectronApp } from 'update-electron-app'
import WebSocket from 'ws'
import yargs from 'yargs'
import * as Y from 'yjs'
import { ensureValidURL } from '../util'
@@ -47,14 +51,31 @@ export interface StreamwallConfig {
endpoint: string
key: string | null
}
control: {
endpoint: string
}
telemetry: {
sentry: boolean
}
}
function parseArgs(): StreamwallConfig {
// Load config from user data dir, if it exists
const configPath = join(app.getPath('userData'), 'config.toml')
console.debug('Reading config from ', configPath)
let configText: string | null = null
try {
configText = fs.readFileSync(configPath, 'utf-8')
} catch (err) {
if (err.code !== 'ENOENT') {
throw err
}
}
return (
yargs()
.config(configText ? TOML.parse(configText) : {})
.config('config', (configPath) => {
return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
})
@@ -122,57 +143,6 @@ function parseArgs(): StreamwallConfig {
array: true,
default: [],
})
/*
.group(
[
'control.username',
'control.password',
'control.address',
'control.hostname',
'control.port',
'control.open',
],
'Control Webserver',
)
.option('control.username', {
describe: 'Web control server username',
})
.option('control.password', {
describe: 'Web control server password',
})
.option('control.open', {
describe: 'After launching, open the control website in a browser',
boolean: true,
default: true,
})
.option('control.address', {
describe: 'Enable control webserver and specify the URL',
implies: ['control.username', 'control.password'],
string: true,
})
.option('control.hostname', {
describe: 'Override hostname the control server listens on',
})
.option('control.port', {
describe: 'Override port the control server listens on',
number: true,
})
.group(
['cert.dir', 'cert.production', 'cert.email'],
'Automatic SSL Certificate',
)
.option('cert.dir', {
describe: 'Private directory to store SSL certificate in',
implies: ['email'],
default: null,
})
.option('cert.production', {
describe: 'Obtain a real SSL certificate using production servers',
})
.option('cert.email', {
describe: 'Email for owner of SSL certificate',
})
*/
.group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay')
.option('streamdelay.endpoint', {
describe: 'URL of Streamdelay endpoint',
@@ -182,6 +152,11 @@ function parseArgs(): StreamwallConfig {
describe: 'Streamdelay API key',
default: null,
})
.group(['control'], 'Remote Control')
.option('control.endpoint', {
describe: 'URL of control server endpoint',
default: null,
})
.group(['telemetry.sentry'], 'Telemetry')
.option('telemetry.sentry', {
describe: 'Enable error reporting to Sentry',
@@ -225,8 +200,12 @@ async function main(argv: ReturnType<typeof parseArgs>) {
console.debug('Creating initial state...')
let clientState: StreamwallState = {
identity: {
role: 'local',
},
config: streamWindowConfig,
streams: [],
customStreams: [],
views: [],
streamdelay: null,
}
@@ -328,26 +307,16 @@ async function main(argv: ReturnType<typeof parseArgs>) {
} else if (msg.type === 'set-stream-running' && streamdelayClient) {
console.debug('Setting stream running:', msg.isStreamRunning)
streamdelayClient.setStreamRunning(msg.isStreamRunning)
// TODO: Move to control server
/*} else if (msg.type === 'create-invite') {
console.debug('Creating invite for role:', msg.role)
const { secret } = await auth.createToken({
kind: 'invite',
role: msg.role,
name: msg.name,
})
respond({ name: msg.name, secret })
} else if (msg.type === 'delete-token') {
console.debug('Deleting token:', msg.tokenId)
auth.deleteToken(msg.tokenId)
*/
}
}
const stateEmitter = new EventEmitter<{ state: [StreamwallState] }>()
function updateState(newState: Partial<StreamwallState>) {
clientState = { ...clientState, ...newState }
streamWindow.onState(clientState)
controlWindow.onState(clientState)
stateEmitter.emit('state', clientState)
}
// Wire up IPC:
@@ -382,6 +351,45 @@ async function main(argv: ReturnType<typeof parseArgs>) {
process.exit(0)
})
if (argv.control.endpoint) {
console.debug('Connecting to control server...')
const ws = new ReconnectingWebSocket(argv.control.endpoint, [], {
WebSocket,
maxReconnectionDelay: 5000,
minReconnectionDelay: 1000 + Math.random() * 500,
reconnectionDelayGrowFactor: 1.1,
})
ws.binaryType = 'arraybuffer'
ws.addEventListener('open', () => {
console.debug('Control WebSocket connected.')
ws.send(JSON.stringify({ type: 'state', state: clientState }))
ws.send(Y.encodeStateAsUpdate(stateDoc))
})
ws.addEventListener('close', () => {
console.debug('Control WebSocket disconnected.')
})
ws.addEventListener('message', (ev) => {
if (ev.data instanceof ArrayBuffer) {
Y.applyUpdate(stateDoc, new Uint8Array(ev.data))
} else {
let msg
try {
msg = JSON.parse(ev.data)
} catch (err) {
console.warn('Failed to parse control WebSocket message:', err)
}
onCommand(msg)
}
})
stateEmitter.on('state', () => {
ws.send(JSON.stringify({ type: 'state', state: clientState }))
})
stateDoc.on('update', (update) => {
ws.send(update)
})
}
if (argv.streamdelay.key) {
console.debug('Setting up Streamdelay client...')
streamdelayClient = new StreamdelayClient({
@@ -394,30 +402,6 @@ async function main(argv: ReturnType<typeof parseArgs>) {
streamdelayClient.connect()
}
/*
if (argv.control.address) {
console.debug('Initializing web server...')
const webDistPath = path.join(app.getAppPath(), 'web')
await initWebServer({
certDir: argv.cert.dir,
certProduction: argv.cert.production,
email: argv.cert.email,
url: argv.control.address,
hostname: argv.control.hostname,
port: argv.control.port,
logEnabled: true,
webDistPath,
auth,
clientState,
onMessage,
stateDoc,
})
if (argv.control.open) {
shell.openExternal(argv.control.address)
}
}
*/
const dataSources = [
...argv.data['json-url'].map((url) => {
console.debug('Setting data source from json-url:', url)

View File

@@ -1,5 +1,5 @@
import '@fontsource/noto-sans'
import './index.css'
import 'streamwall-control-ui/src/index.css'
import { render } from 'preact'
import { useEffect, useState } from 'preact/hooks'

View File

@@ -1,6 +1,3 @@
import '@fontsource/noto-sans'
import './index.css'
import { render } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks'
import { useHotkeys } from 'react-hotkeys-hook'
@@ -73,7 +70,6 @@ function useStreamwallIPCConnection(): StreamwallConnection {
return {
...appState,
isConnected: true,
role: 'local',
send,
sharedState,
stateDoc,

View File

@@ -1,3 +0,0 @@
body {
font-family: 'Noto Sans';
}

View File

@@ -17,7 +17,7 @@ import { matchesState } from 'xstate'
import { StreamwallLayerGlobal } from '../preload/layerPreload'
import '@fontsource/noto-sans'
import './index.css'
import 'streamwall-control-ui/src/index.css'
declare global {
interface Window {