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

2350
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,14 @@
{ {
"name": "streamwall", "name": "streamwall",
"scripts": {
"app:start": "npm -w packages/streamwall start",
"server:start": "npm -w packages/streamwall-control-client run build && npm -w packages/streamwall-control-server start"
},
"workspaces": [ "workspaces": [
"packages/streamwall", "packages/streamwall",
"packages/streamwall-shared", "packages/streamwall-shared",
"packages/streamwall-control-client",
"packages/streamwall-control-server",
"packages/streamwall-control-ui" "packages/streamwall-control-ui"
], ],
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Streamwall Control</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'"
/>
</head>
<body>
<script src="src/index.tsx" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "streamwall-control-client",
"version": "1.0.0",
"description": "Multiplayer Streamwall: frontend",
"main": "src/index.tsx",
"type": "module",
"scripts": {
"build": "vite build"
},
"repository": "github:streamwall/streamwall",
"author": "Max Goodhart <c@chromakode.com>",
"license": "MIT",
"dependencies": {
"@preact/preset-vite": "^2.10.1",
"jsondiffpatch": "^0.7.3",
"reconnecting-websocket": "^4.4.0",
"typescript": "~4.5.4",
"vite": "^5.4.14",
"yjs": "^13.6.21"
},
"devDependencies": {
"@preact/preset-vite": "^2.9.3",
"@tsconfig/recommended": "^1.0.8",
"@tsconfig/vite-react": "^6.3.5"
}
}

View File

@@ -0,0 +1,152 @@
import { render } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import ReconnectingWebSocket from 'reconnecting-websocket'
import {
type CollabData,
ControlUI,
GlobalStyle,
type StreamwallConnection,
useStreamwallState,
useYDoc,
} from 'streamwall-control-ui'
import {
type ControlCommand,
stateDiff,
type StreamwallState,
} from 'streamwall-shared'
import * as Y from 'yjs'
function useStreamwallWebsocketConnection(
wsEndpoint: string,
): StreamwallConnection {
const wsRef = useRef<{
ws: ReconnectingWebSocket
msgId: number
responseMap: Map<number, (msg: object) => void>
}>()
const [isConnected, setIsConnected] = useState(false)
const {
docValue: sharedState,
doc: stateDoc,
setDoc: setStateDoc,
} = useYDoc<CollabData>(['views'])
const [streamwallState, setStreamwallState] = useState<StreamwallState>()
const appState = useStreamwallState(streamwallState)
useEffect(() => {
let lastStateData: StreamwallState | undefined
const ws = new ReconnectingWebSocket(wsEndpoint, [], {
maxReconnectionDelay: 5000,
minReconnectionDelay: 1000 + Math.random() * 500,
reconnectionDelayGrowFactor: 1.1,
})
ws.binaryType = 'arraybuffer'
ws.addEventListener('open', () => setIsConnected(true))
ws.addEventListener('close', () => {
setStateDoc(new Y.Doc())
setIsConnected(false)
})
ws.addEventListener('message', (ev) => {
if (ev.data instanceof ArrayBuffer) {
return
}
const msg = JSON.parse(ev.data)
if (msg.response && wsRef.current != null) {
const { responseMap } = wsRef.current
const responseCb = responseMap.get(msg.id)
if (responseCb) {
responseMap.delete(msg.id)
responseCb(msg)
}
} else if (msg.type === 'state' || msg.type === 'state-delta') {
let state: StreamwallState
if (msg.type === 'state') {
state = msg.state
} else {
// Clone so updated object triggers React renders
state = stateDiff.clone(
stateDiff.patch(lastStateData, msg.delta),
) as StreamwallState
}
lastStateData = state
setStreamwallState(state)
} else {
console.warn('unexpected ws message', msg)
}
})
wsRef.current = { ws, msgId: 0, responseMap: new Map() }
}, [])
const send = useCallback(
(msg: ControlCommand, cb?: (msg: unknown) => void) => {
if (!wsRef.current) {
throw new Error('Websocket not initialized')
}
const { ws, msgId, responseMap } = wsRef.current
ws.send(
JSON.stringify({
...msg,
id: msgId,
}),
)
if (cb) {
responseMap.set(msgId, cb)
}
wsRef.current.msgId++
},
[],
)
useEffect(() => {
if (!wsRef.current) {
throw new Error('Websocket not initialized')
}
const { ws } = wsRef.current
function sendUpdate(update: Uint8Array, origin: string) {
if (origin === 'server') {
return
}
wsRef.current?.ws.send(update)
}
function receiveUpdate(ev: MessageEvent) {
if (!(ev.data instanceof ArrayBuffer)) {
return
}
Y.applyUpdate(stateDoc, new Uint8Array(ev.data), 'server')
}
stateDoc.on('update', sendUpdate)
ws.addEventListener('message', receiveUpdate)
return () => {
stateDoc.off('update', sendUpdate)
ws.removeEventListener('message', receiveUpdate)
}
}, [stateDoc])
return {
...appState,
isConnected,
send,
sharedState,
stateDoc,
}
}
function App() {
const { BASE_URL } = import.meta.env
const connection = useStreamwallWebsocketConnection(
(BASE_URL === '/' ? `ws://${location.host}` : BASE_URL) + '/client/ws',
)
return (
<>
<GlobalStyle />
<ControlUI connection={connection} />
</>
)
}
render(<App />, document.body)

View File

@@ -0,0 +1,13 @@
{
"extends": ["@tsconfig/recommended/tsconfig", "@tsconfig/vite-react"],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"types": ["vite/client"],
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,25 @@
import preact from '@preact/preset-vite'
import { resolve } from 'path'
import { defineConfig } from 'vite'
// https://vitejs.dev/config
export default defineConfig({
base: process.env.STREAMWALL_CONTROL_URL ?? '/',
build: {
sourcemap: true,
},
resolve: {
alias: {
// Necessary for vite to watch the package dir
'streamwall-control-ui': resolve(__dirname, '../streamwall-control-ui'),
'streamwall-shared': resolve(__dirname, '../streamwall-shared'),
},
},
plugins: [
// FIXME: working around TS error: "Type 'Plugin<any>' is not assignable to type 'PluginOption'"
...(preact() as Plugin[]),
],
})

View File

@@ -0,0 +1 @@
storage.json

View File

@@ -0,0 +1,31 @@
{
"name": "streamwall-control-server",
"version": "1.0.0",
"description": "Multiplayer Streamwall: backend",
"main": "src/index.ts",
"type": "module",
"scripts": {
"start": "tsx ./src/index.ts"
},
"repository": "github:streamwall/streamwall",
"author": "Max Goodhart <c@chromakode.com>",
"license": "MIT",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.2.0",
"@fastify/websocket": "^11.1.0",
"base-x": "^5.0.1",
"fastify": "^5.4.0",
"jsondiffpatch": "^0.7.3",
"lowdb": "^7.0.1",
"tsx": "^4.20.2",
"typescript": "~4.5.4",
"ws": "^8.18.2",
"yjs": "^13.6.21"
},
"devDependencies": {
"@tsconfig/node-ts": "^23.6.1",
"@tsconfig/node22": "^22.0.2",
"@tsconfig/recommended": "^1.0.8"
}
}

View File

@@ -0,0 +1,196 @@
import baseX from 'base-x'
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'crypto'
import EventEmitter from 'events'
import {
type AuthTokenInfo,
type StreamwallRole,
type StreamwallState,
validRolesSet,
} from 'streamwall-shared'
import { promisify } from 'util'
import type { StoredData } from './storage.ts'
export interface AuthToken extends AuthTokenInfo {
tokenHash: string
}
export interface AuthState {
invites: AuthTokenInfo[]
sessions: AuthTokenInfo[]
}
interface AuthEvents {
state: [AuthState]
}
const scrypt = promisify(scryptCb)
const base62 = baseX(
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
)
function rand62(len: number) {
return base62.encode(randomBytes(len))
}
async function hashToken62(token: string, salt: string) {
const hashBuffer = await scrypt(token, salt, 24)
return base62.encode(hashBuffer as Buffer)
}
// Wrapper for state data to facilitate role-scoped data access.
export class StateWrapper extends EventEmitter {
_value: StreamwallState
constructor(value: StreamwallState) {
super()
this._value = value
}
toJSON() {
return '<state data>'
}
view(role: StreamwallRole) {
const { config, auth, streams, customStreams, views, streamdelay } =
this._value
const state: StreamwallState = {
identity: {
role,
},
config,
streams,
customStreams,
views,
streamdelay,
}
if (role === 'admin') {
state.auth = auth
}
return state
}
update(value: StreamwallState) {
this._value = { ...this._value, ...value }
this.emit('state', this)
}
// Unprivileged getter
get info() {
return this.view('monitor')
}
}
export class Auth extends EventEmitter<AuthEvents> {
salt: string
tokensById: Map<string, AuthToken>
constructor({ salt, tokens = [] }: Partial<StoredData['auth']> = {}) {
super()
this.salt = salt ?? rand62(24)
this.tokensById = new Map()
for (const token of tokens) {
this.tokensById.set(token.tokenId, token)
}
}
getStoredData() {
return {
salt: this.salt,
tokens: [...this.tokensById.values()],
}
}
getState() {
const toTokenInfo = ({ tokenId, name, kind, role }: AuthTokenInfo) => ({
tokenId,
name,
kind,
role,
})
return {
invites: this.tokensById
.values()
.filter((t) => t.kind === 'invite')
.map(toTokenInfo)
.toArray(),
sessions: this.tokensById
.values()
.filter((t) => t.kind === 'session')
.map(toTokenInfo)
.toArray(),
}
}
emitState() {
this.emit('state', this.getState())
}
async validateToken(
id: string,
secret: string,
): Promise<AuthTokenInfo | null> {
const tokenHash = await hashToken62(secret, this.salt)
const tokenData = this.tokensById.get(id)
if (!tokenData) {
return null
}
const providedTokenHashBuf = Buffer.from(tokenHash)
const expectedTokenHashBuf = Buffer.from(tokenData.tokenHash)
const isTokenMatch = timingSafeEqual(
providedTokenHashBuf,
expectedTokenHashBuf,
)
if (!isTokenMatch) {
return null
}
return {
tokenId: tokenData.tokenId,
kind: tokenData.kind,
role: tokenData.role,
name: tokenData.name,
}
}
async createToken({ kind, role, name }: Omit<AuthTokenInfo, 'tokenId'>) {
if (!validRolesSet.has(role)) {
throw new Error(`invalid role: ${role}`)
}
let tokenId = rand62(8)
while (this.tokensById.has(tokenId)) {
// Regenerate in case of an id collision
tokenId = rand62(8)
}
const secret = rand62(24)
const tokenHash = await hashToken62(secret, this.salt)
const tokenData = {
tokenId,
tokenHash,
kind,
role,
name,
}
this.tokensById.set(tokenId, tokenData)
this.emitState()
console.log(`Created ${kind} token:`, { tokenId, role, name })
return { tokenId, secret }
}
deleteToken(tokenId: string) {
const tokenData = this.tokensById.get(tokenId)
if (!tokenData) {
return
}
this.tokensById.delete(tokenData.tokenId)
this.emitState()
}
}

View File

@@ -0,0 +1,486 @@
import fastifyCookie from '@fastify/cookie'
import fastifyStatic from '@fastify/static'
import fastifyWebsocket from '@fastify/websocket'
import Fastify from 'fastify'
import process from 'node:process'
import WebSocket from 'ws'
import * as Y from 'yjs'
import path from 'node:path'
import {
type AuthTokenInfo,
type ControlCommandMessage,
type ControlUpdateMessage,
roleCan,
stateDiff,
type StreamwallRole,
} from 'streamwall-shared'
import { Auth, StateWrapper } from './auth.ts'
import { loadStorage, type StorageDB } from './storage.ts'
export const SESSION_COOKIE_NAME = 's'
interface Client {
ws: WebSocket
lastStateSent: any
identity: AuthTokenInfo
}
interface StreamwallConnection {
ws: WebSocket
clientState: StateWrapper
stateDoc: Y.Doc
}
interface AppOptions {
baseURL: string
clientStaticPath: string
}
declare module 'fastify' {
interface FastifyRequest {
identity?: AuthTokenInfo
}
}
/**
* Helper to immediately watch for and queue incoming websocket messages.
* This is useful for async validation of the connection before handling messages,
* because awaiting before adding a message event listener can drop messages.
*/
function queueWebSocketMessages(ws: WebSocket) {
let queue: WebSocket.Data[] = []
let messageHandler: ((rawData: WebSocket.Data) => void) | null = null
const processQueue = () => {
if (messageHandler !== null) {
let queuedData
while ((queuedData = queue.shift())) {
messageHandler(queuedData)
}
}
}
const setMessageHandler = (handler: typeof messageHandler) => {
messageHandler = handler
processQueue()
}
ws.on('message', (rawData) => {
queue.push(rawData)
processQueue()
})
ws.on('close', () => {
queue = []
messageHandler = null
})
return setMessageHandler
}
async function initApp({ baseURL, clientStaticPath }: AppOptions) {
const expectedOrigin = new URL(baseURL).origin
const clients = new Map<string, Client>()
const isSecure = baseURL.startsWith('https')
let currentStreamwallWs: WebSocket | null = null
let currentStreamwallConn: StreamwallConnection | null = null
const db = await loadStorage()
const auth = new Auth(db.data.auth)
const app = Fastify()
await app.register(fastifyCookie)
await app.register(fastifyWebsocket, {
errorHandler: (err) => {
console.warn('Error handling socket request', err)
},
})
app.get<{ Params: { id: string }; Querystring: { token?: string } }>(
'/invite/:id',
async (request, reply) => {
const { id } = request.params
const { token } = request.query
if (!token || typeof token !== 'string') {
return reply.code(403).send()
}
const tokenInfo = await auth.validateToken(id, token)
if (!tokenInfo || tokenInfo.kind !== 'invite') {
return reply.code(403).send()
}
const sessionToken = await auth.createToken({
kind: 'session',
name: tokenInfo.name,
role: tokenInfo.role,
})
reply.setCookie(
SESSION_COOKIE_NAME,
`${sessionToken.tokenId}:${sessionToken.secret}`,
{
path: '/',
httpOnly: true,
secure: isSecure,
maxAge: 1 * 365 * 24 * 60 * 60 * 1000,
},
)
await auth.deleteToken(tokenInfo.tokenId)
return reply.redirect('/')
},
)
app.get<{ Params: { id: string }; Querystring: { token?: string } }>(
'/streamwall/:id/ws',
{ websocket: true },
async (ws, request) => {
ws.binaryType = 'arraybuffer'
const handleMessage = queueWebSocketMessages(ws)
const { id } = request.params
const { token } = request.query
if (!token || typeof token !== 'string') {
ws.send(JSON.stringify({ error: 'unauthorized' }))
ws.close()
return
}
const tokenInfo = await auth.validateToken(id, token)
if (!tokenInfo || tokenInfo.kind !== 'streamwall') {
ws.send(JSON.stringify({ error: 'unauthorized' }))
ws.close()
return
}
if (currentStreamwallWs != null) {
ws.send(JSON.stringify({ error: 'streamwall already connected' }))
ws.close()
return
}
currentStreamwallWs = ws
const pingInterval = setInterval(() => {
ws.ping()
}, 5 * 1000)
ws.on('close', () => {
console.log('Streamwall disconnected')
currentStreamwallWs = null
currentStreamwallConn = null
clearInterval(pingInterval)
for (const client of clients.values()) {
client.ws.close()
}
})
let clientState: StateWrapper | null = null
const stateDoc = new Y.Doc()
console.log('Streamwall connecting from', request.ip, tokenInfo)
handleMessage((rawData) => {
if (rawData instanceof ArrayBuffer) {
Y.applyUpdate(stateDoc, new Uint8Array(rawData))
return
}
let msg: ControlUpdateMessage
try {
msg = JSON.parse(rawData.toString())
} catch (err) {
console.warn('Received unexpected ws data: ', rawData.length, 'bytes')
return
}
try {
if (msg.type === 'state') {
if (clientState === null) {
clientState = new StateWrapper(msg.state)
currentStreamwallConn = {
ws,
clientState,
stateDoc,
}
console.log('Streamwall connected from', request.ip, tokenInfo)
} else {
clientState.update(msg.state)
}
for (const client of clients.values()) {
try {
if (client.ws.readyState !== WebSocket.OPEN) {
continue
}
const stateView = clientState.view(client.identity.role)
const delta = stateDiff.diff(client.lastStateSent, stateView)
if (!delta) {
continue
}
client.ws.send(JSON.stringify({ type: 'state-delta', delta }))
client.lastStateSent = stateView
} catch (err) {
console.error('failed to send client state delta', client)
}
}
}
} catch (err) {
console.error('Failed to handle ws message:', rawData, err)
}
})
stateDoc.on('update', (update, origin) => {
try {
ws.send(update)
} catch (err) {
console.error('Failed to send Streamwall doc update')
}
for (const client of clients.values()) {
if (client.identity.tokenId === origin) {
continue
}
try {
client.ws.send(update)
} catch (err) {
console.error('Failed to send client doc update:', client)
}
}
})
},
)
// Authenticated client routes
app.register(async function (fastify) {
fastify.addHook('preHandler', async (request) => {
const sessionCookie = request.cookies[SESSION_COOKIE_NAME]
if (sessionCookie) {
const [tokenId, tokenSecret] = sessionCookie.split(':', 2)
const tokenInfo = await auth.validateToken(tokenId, tokenSecret)
if (tokenInfo && tokenInfo.kind === 'session') {
request.identity = tokenInfo
}
}
})
// Serve frontend assets
await fastify.register(fastifyStatic, {
root: clientStaticPath,
})
// Client WebSocket connection
fastify.get('/client/ws', { websocket: true }, async (ws, request) => {
ws.binaryType = 'arraybuffer'
const handleMessage = queueWebSocketMessages(ws)
const { identity } = request
if (request.headers.origin !== expectedOrigin || !identity) {
ws.send(JSON.stringify({ error: 'unauthorized' }))
ws.close()
return
}
const streamwallConn = currentStreamwallConn
if (!streamwallConn) {
ws.send(JSON.stringify({ error: 'streamwall disconnected' }))
ws.close()
return
}
const client: Client = {
ws,
lastStateSent: null,
identity,
}
clients.set(identity.tokenId, client)
const pingInterval = setInterval(() => {
ws.ping()
}, 20 * 1000)
ws.on('close', () => {
clients.delete(identity.tokenId)
clearInterval(pingInterval)
console.log('Client disconnected from', request.ip, client.identity)
})
console.log('Client connected from', request.ip, client.identity)
handleMessage(async (rawData) => {
let msg: ControlCommandMessage
const respond = (responseData: any) => {
if (ws.readyState !== WebSocket.OPEN) {
return
}
ws.send(
JSON.stringify({
...responseData,
response: true,
id: msg && msg.id,
}),
)
}
if (!currentStreamwallConn) {
respond({ error: 'streamwall disconnected' })
return
}
if (rawData instanceof ArrayBuffer) {
if (!roleCan(identity.role, 'mutate-state-doc')) {
console.warn(
`Unauthorized attempt to edit state doc by "${identity.name}"`,
)
respond({ error: 'unauthorized' })
return
}
Y.applyUpdate(
streamwallConn.stateDoc,
new Uint8Array(rawData),
identity.tokenId,
)
return
}
try {
msg = JSON.parse(rawData.toString())
} catch (err) {
console.warn('Received unexpected ws data: ', rawData.length, 'bytes')
return
}
try {
if (!roleCan(identity.role, msg.type)) {
console.warn(
`Unauthorized attempt to "${msg.type}" by "${identity.name}"`,
)
respond({ error: 'unauthorized' })
return
}
if (msg.type === 'create-invite') {
console.debug('Creating invite for role:', msg.role)
const { secret } = await auth.createToken({
kind: 'invite',
role: msg.role as StreamwallRole,
name: msg.name,
})
respond({ name: msg.name, secret })
} else if (msg.type === 'delete-token') {
console.debug('Deleting token:', msg.tokenId)
auth.deleteToken(msg.tokenId)
} else {
streamwallConn.ws.send(
JSON.stringify({ ...msg, clientId: identity.tokenId }),
)
}
} catch (err) {
console.error('Failed to handle ws message:', rawData, err)
}
})
const state = streamwallConn.clientState.view(identity.role)
ws.send(JSON.stringify({ type: 'state', state }))
ws.send(Y.encodeStateAsUpdate(streamwallConn.stateDoc))
client.lastStateSent = state
})
})
auth.on('state', (state) => {
db.update((data) => {
data.auth = auth.getStoredData()
})
const tokenIds = new Set(state.sessions.map((t) => t.tokenId))
for (const client of clients.values()) {
if (!tokenIds.has(client.identity.tokenId)) {
client.ws.close()
}
}
})
return { app, db, auth }
}
async function initialInviteCodes({
db,
auth,
baseURL,
}: {
db: StorageDB
auth: Auth
baseURL: string
}) {
// Create a token for streamwall uplink (if not existing):
let streamwallToken = db.data.streamwallToken
if (!streamwallToken) {
streamwallToken = await auth.createToken({
kind: 'streamwall',
role: 'admin',
name: 'Streamwall',
})
db.update((data) => {
data.streamwallToken = streamwallToken
})
}
// Invalidate any existing admin invites and create a new one:
for (const adminToken of auth
.getState()
.invites.filter(({ role }) => role === 'admin')) {
auth.deleteToken(adminToken.tokenId)
}
const adminToken = await auth.createToken({
kind: 'invite',
role: 'admin',
name: 'Server admin',
})
console.log(
'🔌 Streamwall endpoint:',
`${baseURL.replace(/^http/, 'ws')}/streamwall/${streamwallToken.tokenId}/ws?token=${streamwallToken.secret}`,
)
console.log(
'🔑 Admin invite:',
`${baseURL}/invite/${adminToken.tokenId}?token=${adminToken.secret}`,
)
}
export default async function runServer({
baseURL,
clientStaticPath,
}: AppOptions) {
const url = new URL(baseURL)
const { hostname } = url
const port = url.port !== '' ? Number(url.port) : 80
console.debug('Initializing web server:', { hostname, port })
const { app, db, auth } = await initApp({
baseURL,
clientStaticPath,
})
await initialInviteCodes({ db, auth, baseURL })
await app.listen({ port, host: hostname })
return { server: app.server }
}
runServer({
baseURL: process.env.STREAMWALL_CONTROL_URL ?? 'http://localhost:3000',
clientStaticPath:
process.env.STREAMWALL_CONTROL_STATIC ??
path.join(import.meta.dirname, '../../streamwall-control-client/dist'),
})

View File

@@ -0,0 +1,31 @@
import type { Low } from 'lowdb'
import { JSONFilePreset } from 'lowdb/node'
import process from 'node:process'
import type { AuthToken } from './auth.ts'
export interface StoredData {
auth: {
salt: string | null
tokens: AuthToken[]
}
streamwallToken: null | {
tokenId: string
secret: string
}
}
const defaultData: StoredData = {
auth: {
salt: null,
tokens: [],
},
streamwallToken: null,
}
export type StorageDB = Low<StoredData>
export async function loadStorage() {
const dbPath = process.env.DB_PATH || 'storage.json'
const db = await JSONFilePreset<StoredData>(dbPath, defaultData)
return db
}

View File

@@ -0,0 +1,8 @@
{
"extends": [
"@tsconfig/recommended/tsconfig",
"@tsconfig/node22/tsconfig",
"@tsconfig/node-ts/tsconfig"
],
"include": ["src"]
}

View File

@@ -1,5 +1,5 @@
import '@fontsource/noto-sans'
import Color from 'color' import Color from 'color'
import { patch as patchJSON } from 'jsondiffpatch'
import { range, sortBy, truncate } from 'lodash-es' import { range, sortBy, truncate } from 'lodash-es'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { JSX } from 'preact' import { JSX } from 'preact'
@@ -7,7 +7,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useRef, useMemo,
useState, useState,
} from 'preact/hooks' } from 'preact/hooks'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
@@ -20,7 +20,6 @@ import {
FaVideoSlash, FaVideoSlash,
FaVolumeUp, FaVolumeUp,
} from 'react-icons/fa' } from 'react-icons/fa'
import ReconnectingWebSocket from 'reconnecting-websocket'
import { import {
ContentKind, ContentKind,
ControlCommand, ControlCommand,
@@ -38,6 +37,7 @@ import {
import { createGlobalStyle, styled } from 'styled-components' import { createGlobalStyle, styled } from 'styled-components'
import { matchesState } from 'xstate' import { matchesState } from 'xstate'
import * as Y from 'yjs' import * as Y from 'yjs'
import './index.css'
export interface ViewInfo { export interface ViewInfo {
state: ViewState state: ViewState
@@ -144,33 +144,35 @@ export interface StreamwallConnection {
views: ViewInfo[] views: ViewInfo[]
stateIdxMap: Map<number, ViewInfo> stateIdxMap: Map<number, ViewInfo>
delayState: StreamDelayStatus | null | undefined delayState: StreamDelayStatus | null | undefined
//authState?: ... authState?: StreamwallState['auth']
} }
export function useStreamwallState(state: StreamwallState | undefined) { export function useStreamwallState(state: StreamwallState | undefined) {
const [config, setConfig] = useState<StreamWindowConfig>() return useMemo(() => {
const [streams, setStreams] = useState<StreamData[]>([]) if (state === undefined) {
const [customStreams, setCustomStreams] = useState<StreamData[]>([]) return {
const [views, setViews] = useState<ViewInfo[]>([]) role: null,
const [stateIdxMap, setStateIdxMap] = useState(new Map<number, ViewInfo>()) config: undefined,
const [delayState, setDelayState] = useState<StreamDelayStatus | null>() streams: [],
//const [authState, setAuthState] = useState() customStreams: [],
views: [],
useEffect(() => { stateIdxMap: new Map(),
if (state == null) { delayState: undefined,
return authState: undefined,
}
} }
const { const {
config: newConfig, identity: { role },
streams: newStreams, auth,
views: incomingViews, config,
streams: stateStreams,
views: stateViews,
streamdelay, streamdelay,
//auth,
} = state } = state
const newStateIdxMap = new Map() const stateIdxMap = new Map()
const newViews = [] const views = []
for (const viewState of incomingViews) { for (const viewState of stateViews) {
const { pos } = viewState.context const { pos } = viewState.context
const isListening = matchesState( const isListening = matchesState(
'displaying.running.audio.listening', 'displaying.running.audio.listening',
@@ -192,141 +194,29 @@ export function useStreamwallState(state: StreamwallState | undefined) {
isBlurred, isBlurred,
spaces, spaces,
} }
newViews.push(viewInfo) views.push(viewInfo)
for (const space of spaces) { for (const space of spaces) {
if (!newStateIdxMap.has(space)) { if (!stateIdxMap.has(space)) {
newStateIdxMap.set(space, {}) stateIdxMap.set(space, {})
} }
Object.assign(newStateIdxMap.get(space), viewInfo) Object.assign(stateIdxMap.get(space), viewInfo)
} }
} }
setConfig(newConfig)
setStateIdxMap(newStateIdxMap)
setStreams(sortBy(newStreams, ['_id']))
setViews(newViews)
setCustomStreams(newStreams.filter((s) => s._dataSource === 'custom'))
setDelayState(streamdelay)
//setAuthState(auth)
}, [state])
return { views, config, streams, customStreams, stateIdxMap, delayState }
}
function useStreamwallWebsocketConnection(
wsEndpoint: string,
role: StreamwallRole,
): StreamwallConnection {
const wsRef = useRef<{
ws: ReconnectingWebSocket
msgId: number
responseMap: Map<number, (msg: object) => void>
}>()
const [isConnected, setIsConnected] = useState(false)
const {
docValue: sharedState,
doc: stateDoc,
setDoc: setStateDoc,
} = useYDoc<CollabData>(['views'])
const [streamwallState, setStreamwallState] = useState<StreamwallState>()
const appState = useStreamwallState(streamwallState)
useEffect(() => {
let lastStateData: StreamwallState | undefined
const ws = new ReconnectingWebSocket(wsEndpoint, [], {
maxReconnectionDelay: 5000,
minReconnectionDelay: 1000 + Math.random() * 500,
reconnectionDelayGrowFactor: 1.1,
})
ws.binaryType = 'arraybuffer'
ws.addEventListener('open', () => setIsConnected(true))
ws.addEventListener('close', () => {
setStateDoc(new Y.Doc())
setIsConnected(false)
})
ws.addEventListener('message', (ev) => {
if (ev.data instanceof ArrayBuffer) {
return
}
const msg = JSON.parse(ev.data)
if (msg.response && wsRef.current != null) {
const { responseMap } = wsRef.current
const responseCb = responseMap.get(msg.id)
if (responseCb) {
responseMap.delete(msg.id)
responseCb(msg)
}
} else if (msg.type === 'state' || msg.type === 'state-delta') {
let state: StreamwallState
if (msg.type === 'state') {
state = msg.state
} else {
state = patchJSON(lastStateData, msg.delta) as StreamwallState
}
lastStateData = state
setStreamwallState(state)
} else {
console.warn('unexpected ws message', msg)
}
})
wsRef.current = { ws, msgId: 0, responseMap: new Map() }
}, [])
const send = useCallback(
(msg: ControlCommand, cb?: (msg: unknown) => void) => {
if (!wsRef.current) {
throw new Error('Websocket not initialized')
}
const { ws, msgId, responseMap } = wsRef.current
ws.send(
JSON.stringify({
...msg,
id: msgId,
}),
)
if (cb) {
responseMap.set(msgId, cb)
}
wsRef.current.msgId++
},
[],
)
useEffect(() => {
if (!wsRef.current) {
throw new Error('Websocket not initialized')
}
const { ws } = wsRef.current
function sendUpdate(update: Uint8Array, origin: string) {
if (origin === 'server') {
return
}
wsRef.current?.ws.send(update)
}
function receiveUpdate(ev: MessageEvent) { const streams = sortBy(stateStreams, ['_id'])
if (!(ev.data instanceof ArrayBuffer)) { const customStreams = stateStreams.filter((s) => s._dataSource === 'custom')
return
}
Y.applyUpdate(stateDoc, new Uint8Array(ev.data), 'server')
}
stateDoc.on('update', sendUpdate)
ws.addEventListener('message', receiveUpdate)
return () => {
stateDoc.off('update', sendUpdate)
ws.removeEventListener('message', receiveUpdate)
}
}, [stateDoc])
return { return {
...appState,
isConnected,
role, role,
send, authState: auth,
sharedState, delayState: streamdelay,
stateDoc, views,
config,
streams,
customStreams,
stateIdxMap,
} }
}, [state])
} }
export function ControlUI({ export function ControlUI({
@@ -336,7 +226,6 @@ export function ControlUI({
}) { }) {
const { const {
isConnected, isConnected,
role,
send, send,
sharedState, sharedState,
stateDoc, stateDoc,
@@ -346,7 +235,8 @@ export function ControlUI({
views, views,
stateIdxMap, stateIdxMap,
delayState, delayState,
//authState, authState,
role,
} = connection } = connection
const { const {
gridCount, gridCount,
@@ -623,7 +513,6 @@ export function ControlUI({
[send], [send],
) )
/*
const [newInvite, setNewInvite] = useState<Invite>() const [newInvite, setNewInvite] = useState<Invite>()
const handleCreateInvite = useCallback( const handleCreateInvite = useCallback(
@@ -652,7 +541,6 @@ export function ControlUI({
const preventLinkClick = useCallback((ev: Event) => { const preventLinkClick = useCallback((ev: Event) => {
ev.preventDefault() ev.preventDefault()
}, []) }, [])
*/
// Set up keyboard shortcuts. // Set up keyboard shortcuts.
useHotkeys( useHotkeys(
@@ -903,7 +791,8 @@ export function ControlUI({
</div> </div>
</> </>
)} )}
{/*roleCan(role, 'edit-tokens') && authState && ( {(roleCan(role, 'create-invite') || roleCan(role, 'delete-token')) &&
authState && (
<> <>
<h2>Access</h2> <h2>Access</h2>
<div> <div>
@@ -920,18 +809,18 @@ export function ControlUI({
</a> </a>
</StyledNewInviteBox> </StyledNewInviteBox>
)} )}
{authState.invites.map(({ id, name, role }) => ( {authState.invites.map(({ tokenId, name, role }) => (
<AuthTokenLine <AuthTokenLine
id={id} id={tokenId}
name={name} name={name}
role={role} role={role}
onDelete={handleDeleteToken} onDelete={handleDeleteToken}
/> />
))} ))}
<h3>Sessions</h3> <h3>Sessions</h3>
{authState.sessions.map(({ id, name, role }) => ( {authState.sessions.map(({ tokenId, name, role }) => (
<AuthTokenLine <AuthTokenLine
id={id} id={tokenId}
name={name} name={name}
role={role} role={role}
onDelete={handleDeleteToken} onDelete={handleDeleteToken}
@@ -939,7 +828,7 @@ export function ControlUI({
))} ))}
</div> </div>
</> </>
)*/} )}
</StyledDataContainer> </StyledDataContainer>
</Stack> </Stack>
</Stack> </Stack>

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [preact()],
})

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
"dependencies": { "dependencies": {
"color": "^5.0.0" "color": "^5.0.0",
"jsondiffpatch": "^0.7.3"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.6.2" "typescript": "~5.6.2"

View File

@@ -1,6 +1,6 @@
import { Rectangle } from 'electron' import type { Rectangle } from 'electron'
import { isEqual } from 'lodash-es' import { isEqual } from 'lodash-es'
import { ContentKind } from './types' import type { ContentKind } from './types.ts'
export interface ViewPos extends Rectangle { export interface ViewPos extends Rectangle {
/** /**

View File

@@ -1,4 +1,5 @@
export * from './colors' export * from './colors.ts'
export * from './geometry' export * from './geometry.ts'
export * from './roles' export * from './roles.ts'
export * from './types' export * from './stateDiff.ts'
export * from './types.ts'

View File

@@ -1,6 +1,12 @@
export const validRoles = ['local', 'admin', 'operator', 'monitor'] as const export const validRoles = ['local', 'admin', 'operator', 'monitor'] as const
export const validRolesSet = new Set(validRoles)
const adminActions = ['dev-tools', 'browse', 'edit-tokens'] as const const adminActions = [
'dev-tools',
'browse',
'create-invite',
'delete-token',
] as const
const operatorActions = [ const operatorActions = [
'set-listening-view', 'set-listening-view',

View File

@@ -0,0 +1,6 @@
import * as jsondiffpatch from 'jsondiffpatch'
export const stateDiff = jsondiffpatch.create({
objectHash: (obj: any, idx) => obj._id || `$$index:${idx}`,
omitRemovedValues: true,
})

View File

@@ -1,4 +1,5 @@
import { ViewContent, ViewPos } from './geometry' import type { ViewContent, ViewPos } from './geometry.ts'
import type { StreamwallRole } from './roles.ts'
export interface StreamWindowConfig { export interface StreamWindowConfig {
gridCount: number gridCount: number
@@ -75,13 +76,35 @@ export interface StreamDelayStatus {
state: string state: string
} }
export type AuthTokenKind = 'invite' | 'session' | 'streamwall'
export interface AuthTokenInfo {
tokenId: string
kind: AuthTokenKind
role: StreamwallRole
name: string
}
export interface StreamwallState { export interface StreamwallState {
identity: {
role: StreamwallRole
}
auth?: {
invites: AuthTokenInfo[]
sessions: AuthTokenInfo[]
}
config: StreamWindowConfig config: StreamWindowConfig
streams: StreamList streams: StreamList
customStreams: StreamList
views: ViewState[] views: ViewState[]
streamdelay: StreamDelayStatus | null streamdelay: StreamDelayStatus | null
} }
type MessageMeta = {
id: number
clientId: string
}
export type ControlCommand = export type ControlCommand =
| { type: 'set-listening-view'; viewIdx: number | null } | { type: 'set-listening-view'; viewIdx: number | null }
| { | {
@@ -100,3 +123,12 @@ export type ControlCommand =
| { type: 'set-stream-running'; isStreamRunning: boolean } | { type: 'set-stream-running'; isStreamRunning: boolean }
| { type: 'create-invite'; role: string; name: string } | { type: 'create-invite'; role: string; name: string }
| { type: 'delete-token'; tokenId: string } | { type: 'delete-token'; tokenId: string }
export type ControlUpdate = {
type: 'state'
state: StreamwallState
}
export type ControlCommandMessage = MessageMeta & ControlCommand
export type ControlUpdateMessage = MessageMeta & ControlUpdate

View File

@@ -1,31 +1,8 @@
{ {
"compilerOptions": { "extends": [
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "@tsconfig/recommended/tsconfig",
"target": "ES2020", "@tsconfig/node22/tsconfig",
"useDefineForClassFields": true, "@tsconfig/node-ts/tsconfig"
"module": "ESNext", ],
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"] "include": ["src"]
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "streamwall", "name": "streamwall",
"productName": "Streamwall", "productName": "Streamwall",
"version": "2.0.0-pre1", "version": "2.0.0-pre2",
"description": "Watch streams in a grid layout", "description": "Watch streams in a grid layout",
"main": ".vite/build/index.js", "main": ".vite/build/index.js",
"repository": "github:streamwall/streamwall", "repository": "github:streamwall/streamwall",
@@ -42,7 +42,6 @@
"author": "Max Goodhart <c@chromakode.com>", "author": "Max Goodhart <c@chromakode.com>",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fontsource/noto-sans": "^5.1.1",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@repeaterjs/repeater": "^3.0.6", "@repeaterjs/repeater": "^3.0.6",
"@sentry/electron": "^5.9.0", "@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 { BrowserWindow, app, session } from 'electron'
import started from 'electron-squirrel-startup' import started from 'electron-squirrel-startup'
import fs from 'fs' 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 'source-map-support/register'
import { ControlCommand, StreamwallState } from 'streamwall-shared' import { ControlCommand, StreamwallState } from 'streamwall-shared'
import { updateElectronApp } from 'update-electron-app' import { updateElectronApp } from 'update-electron-app'
import WebSocket from 'ws'
import yargs from 'yargs' import yargs from 'yargs'
import * as Y from 'yjs' import * as Y from 'yjs'
import { ensureValidURL } from '../util' import { ensureValidURL } from '../util'
@@ -47,14 +51,31 @@ export interface StreamwallConfig {
endpoint: string endpoint: string
key: string | null key: string | null
} }
control: {
endpoint: string
}
telemetry: { telemetry: {
sentry: boolean sentry: boolean
} }
} }
function parseArgs(): StreamwallConfig { 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 ( return (
yargs() yargs()
.config(configText ? TOML.parse(configText) : {})
.config('config', (configPath) => { .config('config', (configPath) => {
return TOML.parse(fs.readFileSync(configPath, 'utf-8')) return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
}) })
@@ -122,57 +143,6 @@ function parseArgs(): StreamwallConfig {
array: true, array: true,
default: [], 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') .group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay')
.option('streamdelay.endpoint', { .option('streamdelay.endpoint', {
describe: 'URL of Streamdelay endpoint', describe: 'URL of Streamdelay endpoint',
@@ -182,6 +152,11 @@ function parseArgs(): StreamwallConfig {
describe: 'Streamdelay API key', describe: 'Streamdelay API key',
default: null, default: null,
}) })
.group(['control'], 'Remote Control')
.option('control.endpoint', {
describe: 'URL of control server endpoint',
default: null,
})
.group(['telemetry.sentry'], 'Telemetry') .group(['telemetry.sentry'], 'Telemetry')
.option('telemetry.sentry', { .option('telemetry.sentry', {
describe: 'Enable error reporting to Sentry', describe: 'Enable error reporting to Sentry',
@@ -225,8 +200,12 @@ async function main(argv: ReturnType<typeof parseArgs>) {
console.debug('Creating initial state...') console.debug('Creating initial state...')
let clientState: StreamwallState = { let clientState: StreamwallState = {
identity: {
role: 'local',
},
config: streamWindowConfig, config: streamWindowConfig,
streams: [], streams: [],
customStreams: [],
views: [], views: [],
streamdelay: null, streamdelay: null,
} }
@@ -328,26 +307,16 @@ async function main(argv: ReturnType<typeof parseArgs>) {
} else if (msg.type === 'set-stream-running' && streamdelayClient) { } else if (msg.type === 'set-stream-running' && streamdelayClient) {
console.debug('Setting stream running:', msg.isStreamRunning) console.debug('Setting stream running:', msg.isStreamRunning)
streamdelayClient.setStreamRunning(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>) { function updateState(newState: Partial<StreamwallState>) {
clientState = { ...clientState, ...newState } clientState = { ...clientState, ...newState }
streamWindow.onState(clientState) streamWindow.onState(clientState)
controlWindow.onState(clientState) controlWindow.onState(clientState)
stateEmitter.emit('state', clientState)
} }
// Wire up IPC: // Wire up IPC:
@@ -382,6 +351,45 @@ async function main(argv: ReturnType<typeof parseArgs>) {
process.exit(0) 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) { if (argv.streamdelay.key) {
console.debug('Setting up Streamdelay client...') console.debug('Setting up Streamdelay client...')
streamdelayClient = new StreamdelayClient({ streamdelayClient = new StreamdelayClient({
@@ -394,30 +402,6 @@ async function main(argv: ReturnType<typeof parseArgs>) {
streamdelayClient.connect() 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 = [ const dataSources = [
...argv.data['json-url'].map((url) => { ...argv.data['json-url'].map((url) => {
console.debug('Setting data source from json-url:', url) console.debug('Setting data source from json-url:', url)

View File

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

View File

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

View File

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