mirror of
https://github.com/streamwall/streamwall.git
synced 2025-12-06 01:45:37 -05:00
Implement standalone control server
This commit is contained in:
2350
package-lock.json
generated
2350
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"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": [
|
||||
"packages/streamwall",
|
||||
"packages/streamwall-shared",
|
||||
"packages/streamwall-control-client",
|
||||
"packages/streamwall-control-server",
|
||||
"packages/streamwall-control-ui"
|
||||
],
|
||||
"devDependencies": {
|
||||
|
||||
2
packages/streamwall-control-client/.gitignore
vendored
Normal file
2
packages/streamwall-control-client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
14
packages/streamwall-control-client/index.html
Normal file
14
packages/streamwall-control-client/index.html
Normal 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>
|
||||
26
packages/streamwall-control-client/package.json
Normal file
26
packages/streamwall-control-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
152
packages/streamwall-control-client/src/index.tsx
Normal file
152
packages/streamwall-control-client/src/index.tsx
Normal 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)
|
||||
13
packages/streamwall-control-client/tsconfig.json
Normal file
13
packages/streamwall-control-client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
25
packages/streamwall-control-client/vite.config.ts
Normal file
25
packages/streamwall-control-client/vite.config.ts
Normal 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[]),
|
||||
],
|
||||
})
|
||||
1
packages/streamwall-control-server/.gitignore
vendored
Normal file
1
packages/streamwall-control-server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
storage.json
|
||||
31
packages/streamwall-control-server/package.json
Normal file
31
packages/streamwall-control-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
196
packages/streamwall-control-server/src/auth.ts
Normal file
196
packages/streamwall-control-server/src/auth.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
486
packages/streamwall-control-server/src/index.ts
Normal file
486
packages/streamwall-control-server/src/index.ts
Normal 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'),
|
||||
})
|
||||
31
packages/streamwall-control-server/src/storage.ts
Normal file
31
packages/streamwall-control-server/src/storage.ts
Normal 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
|
||||
}
|
||||
8
packages/streamwall-control-server/tsconfig.json
Normal file
8
packages/streamwall-control-server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": [
|
||||
"@tsconfig/recommended/tsconfig",
|
||||
"@tsconfig/node22/tsconfig",
|
||||
"@tsconfig/node-ts/tsconfig"
|
||||
],
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import '@fontsource/noto-sans'
|
||||
import Color from 'color'
|
||||
import { patch as patchJSON } from 'jsondiffpatch'
|
||||
import { range, sortBy, truncate } from 'lodash-es'
|
||||
import { DateTime } from 'luxon'
|
||||
import { JSX } from 'preact'
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'preact/hooks'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
FaVideoSlash,
|
||||
FaVolumeUp,
|
||||
} from 'react-icons/fa'
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||
import {
|
||||
ContentKind,
|
||||
ControlCommand,
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
import { createGlobalStyle, styled } from 'styled-components'
|
||||
import { matchesState } from 'xstate'
|
||||
import * as Y from 'yjs'
|
||||
import './index.css'
|
||||
|
||||
export interface ViewInfo {
|
||||
state: ViewState
|
||||
@@ -144,33 +144,35 @@ export interface StreamwallConnection {
|
||||
views: ViewInfo[]
|
||||
stateIdxMap: Map<number, ViewInfo>
|
||||
delayState: StreamDelayStatus | null | undefined
|
||||
//authState?: ...
|
||||
authState?: StreamwallState['auth']
|
||||
}
|
||||
|
||||
export function useStreamwallState(state: StreamwallState | undefined) {
|
||||
const [config, setConfig] = useState<StreamWindowConfig>()
|
||||
const [streams, setStreams] = useState<StreamData[]>([])
|
||||
const [customStreams, setCustomStreams] = useState<StreamData[]>([])
|
||||
const [views, setViews] = useState<ViewInfo[]>([])
|
||||
const [stateIdxMap, setStateIdxMap] = useState(new Map<number, ViewInfo>())
|
||||
const [delayState, setDelayState] = useState<StreamDelayStatus | null>()
|
||||
//const [authState, setAuthState] = useState()
|
||||
|
||||
useEffect(() => {
|
||||
if (state == null) {
|
||||
return
|
||||
return useMemo(() => {
|
||||
if (state === undefined) {
|
||||
return {
|
||||
role: null,
|
||||
config: undefined,
|
||||
streams: [],
|
||||
customStreams: [],
|
||||
views: [],
|
||||
stateIdxMap: new Map(),
|
||||
delayState: undefined,
|
||||
authState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
config: newConfig,
|
||||
streams: newStreams,
|
||||
views: incomingViews,
|
||||
identity: { role },
|
||||
auth,
|
||||
config,
|
||||
streams: stateStreams,
|
||||
views: stateViews,
|
||||
streamdelay,
|
||||
//auth,
|
||||
} = state
|
||||
const newStateIdxMap = new Map()
|
||||
const newViews = []
|
||||
for (const viewState of incomingViews) {
|
||||
const stateIdxMap = new Map()
|
||||
const views = []
|
||||
for (const viewState of stateViews) {
|
||||
const { pos } = viewState.context
|
||||
const isListening = matchesState(
|
||||
'displaying.running.audio.listening',
|
||||
@@ -192,141 +194,29 @@ export function useStreamwallState(state: StreamwallState | undefined) {
|
||||
isBlurred,
|
||||
spaces,
|
||||
}
|
||||
newViews.push(viewInfo)
|
||||
views.push(viewInfo)
|
||||
for (const space of spaces) {
|
||||
if (!newStateIdxMap.has(space)) {
|
||||
newStateIdxMap.set(space, {})
|
||||
if (!stateIdxMap.has(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) {
|
||||
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])
|
||||
const streams = sortBy(stateStreams, ['_id'])
|
||||
const customStreams = stateStreams.filter((s) => s._dataSource === 'custom')
|
||||
|
||||
return {
|
||||
...appState,
|
||||
isConnected,
|
||||
role,
|
||||
send,
|
||||
sharedState,
|
||||
stateDoc,
|
||||
authState: auth,
|
||||
delayState: streamdelay,
|
||||
views,
|
||||
config,
|
||||
streams,
|
||||
customStreams,
|
||||
stateIdxMap,
|
||||
}
|
||||
}, [state])
|
||||
}
|
||||
|
||||
export function ControlUI({
|
||||
@@ -336,7 +226,6 @@ export function ControlUI({
|
||||
}) {
|
||||
const {
|
||||
isConnected,
|
||||
role,
|
||||
send,
|
||||
sharedState,
|
||||
stateDoc,
|
||||
@@ -346,7 +235,8 @@ export function ControlUI({
|
||||
views,
|
||||
stateIdxMap,
|
||||
delayState,
|
||||
//authState,
|
||||
authState,
|
||||
role,
|
||||
} = connection
|
||||
const {
|
||||
gridCount,
|
||||
@@ -623,7 +513,6 @@ export function ControlUI({
|
||||
[send],
|
||||
)
|
||||
|
||||
/*
|
||||
const [newInvite, setNewInvite] = useState<Invite>()
|
||||
|
||||
const handleCreateInvite = useCallback(
|
||||
@@ -652,7 +541,6 @@ export function ControlUI({
|
||||
const preventLinkClick = useCallback((ev: Event) => {
|
||||
ev.preventDefault()
|
||||
}, [])
|
||||
*/
|
||||
|
||||
// Set up keyboard shortcuts.
|
||||
useHotkeys(
|
||||
@@ -903,7 +791,8 @@ export function ControlUI({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/*roleCan(role, 'edit-tokens') && authState && (
|
||||
{(roleCan(role, 'create-invite') || roleCan(role, 'delete-token')) &&
|
||||
authState && (
|
||||
<>
|
||||
<h2>Access</h2>
|
||||
<div>
|
||||
@@ -920,18 +809,18 @@ export function ControlUI({
|
||||
</a>
|
||||
</StyledNewInviteBox>
|
||||
)}
|
||||
{authState.invites.map(({ id, name, role }) => (
|
||||
{authState.invites.map(({ tokenId, name, role }) => (
|
||||
<AuthTokenLine
|
||||
id={id}
|
||||
id={tokenId}
|
||||
name={name}
|
||||
role={role}
|
||||
onDelete={handleDeleteToken}
|
||||
/>
|
||||
))}
|
||||
<h3>Sessions</h3>
|
||||
{authState.sessions.map(({ id, name, role }) => (
|
||||
{authState.sessions.map(({ tokenId, name, role }) => (
|
||||
<AuthTokenLine
|
||||
id={id}
|
||||
id={tokenId}
|
||||
name={name}
|
||||
role={role}
|
||||
onDelete={handleDeleteToken}
|
||||
@@ -939,7 +828,7 @@ export function ControlUI({
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)*/}
|
||||
)}
|
||||
</StyledDataContainer>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import preact from '@preact/preset-vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
})
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"color": "^5.0.0"
|
||||
"color": "^5.0.0",
|
||||
"jsondiffpatch": "^0.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.6.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Rectangle } from 'electron'
|
||||
import type { Rectangle } from 'electron'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { ContentKind } from './types'
|
||||
import type { ContentKind } from './types.ts'
|
||||
|
||||
export interface ViewPos extends Rectangle {
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './colors'
|
||||
export * from './geometry'
|
||||
export * from './roles'
|
||||
export * from './types'
|
||||
export * from './colors.ts'
|
||||
export * from './geometry.ts'
|
||||
export * from './roles.ts'
|
||||
export * from './stateDiff.ts'
|
||||
export * from './types.ts'
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 = [
|
||||
'set-listening-view',
|
||||
|
||||
6
packages/streamwall-shared/src/stateDiff.ts
Normal file
6
packages/streamwall-shared/src/stateDiff.ts
Normal 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,
|
||||
})
|
||||
@@ -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 {
|
||||
gridCount: number
|
||||
@@ -75,13 +76,35 @@ export interface StreamDelayStatus {
|
||||
state: string
|
||||
}
|
||||
|
||||
export type AuthTokenKind = 'invite' | 'session' | 'streamwall'
|
||||
|
||||
export interface AuthTokenInfo {
|
||||
tokenId: string
|
||||
kind: AuthTokenKind
|
||||
role: StreamwallRole
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface StreamwallState {
|
||||
identity: {
|
||||
role: StreamwallRole
|
||||
}
|
||||
auth?: {
|
||||
invites: AuthTokenInfo[]
|
||||
sessions: AuthTokenInfo[]
|
||||
}
|
||||
config: StreamWindowConfig
|
||||
streams: StreamList
|
||||
customStreams: StreamList
|
||||
views: ViewState[]
|
||||
streamdelay: StreamDelayStatus | null
|
||||
}
|
||||
|
||||
type MessageMeta = {
|
||||
id: number
|
||||
clientId: string
|
||||
}
|
||||
|
||||
export type ControlCommand =
|
||||
| { type: 'set-listening-view'; viewIdx: number | null }
|
||||
| {
|
||||
@@ -100,3 +123,12 @@ export type ControlCommand =
|
||||
| { type: 'set-stream-running'; isStreamRunning: boolean }
|
||||
| { type: 'create-invite'; role: string; name: string }
|
||||
| { type: 'delete-token'; tokenId: string }
|
||||
|
||||
export type ControlUpdate = {
|
||||
type: 'state'
|
||||
state: StreamwallState
|
||||
}
|
||||
|
||||
export type ControlCommandMessage = MessageMeta & ControlCommand
|
||||
|
||||
export type ControlUpdateMessage = MessageMeta & ControlUpdate
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"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
|
||||
},
|
||||
"extends": [
|
||||
"@tsconfig/recommended/tsconfig",
|
||||
"@tsconfig/node22/tsconfig",
|
||||
"@tsconfig/node-ts/tsconfig"
|
||||
],
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user