mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-26 23:12:48 -05:00
Add web-based remote control with HTTPS support
This commit is contained in:
166
src/node/StreamWindow.js
Normal file
166
src/node/StreamWindow.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import EventEmitter from 'events'
|
||||
import { BrowserView, BrowserWindow, ipcMain } from 'electron'
|
||||
import { interpret } from 'xstate'
|
||||
|
||||
import viewStateMachine from './viewStateMachine'
|
||||
import { boxesFromViewURLMap } from './geometry'
|
||||
|
||||
import {
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
GRID_COUNT,
|
||||
SPACE_WIDTH,
|
||||
SPACE_HEIGHT,
|
||||
} from '../constants'
|
||||
|
||||
export default class StreamWindow extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.win = null
|
||||
this.overlayView = null
|
||||
this.views = []
|
||||
this.viewStates = new Map()
|
||||
}
|
||||
|
||||
init() {
|
||||
const win = new BrowserWindow({
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
backgroundColor: '#000',
|
||||
useContentSize: true,
|
||||
show: false,
|
||||
})
|
||||
win.removeMenu()
|
||||
win.loadURL('about:blank')
|
||||
|
||||
// Work around https://github.com/electron/electron/issues/14308
|
||||
// via https://github.com/lutzroeder/netron/commit/910ce67395130690ad76382c094999a4f5b51e92
|
||||
win.once('ready-to-show', () => {
|
||||
win.resizable = false
|
||||
win.show()
|
||||
})
|
||||
this.win = win
|
||||
|
||||
const overlayView = new BrowserView({
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
},
|
||||
})
|
||||
win.addBrowserView(overlayView)
|
||||
overlayView.setBounds({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
})
|
||||
overlayView.webContents.loadFile('overlay.html')
|
||||
this.overlayView = overlayView
|
||||
|
||||
const actions = {
|
||||
hideView: (context, event) => {
|
||||
const { view } = context
|
||||
win.removeBrowserView(view)
|
||||
},
|
||||
positionView: (context, event) => {
|
||||
const { pos, view } = context
|
||||
win.addBrowserView(view)
|
||||
|
||||
// It's necessary to remove and re-add the overlay view to ensure it's on top.
|
||||
win.removeBrowserView(overlayView)
|
||||
win.addBrowserView(overlayView)
|
||||
|
||||
view.setBounds(pos)
|
||||
},
|
||||
}
|
||||
|
||||
const views = []
|
||||
for (let idx = 0; idx <= 9; idx++) {
|
||||
const view = new BrowserView()
|
||||
view.setBackgroundColor('#000')
|
||||
|
||||
const machine = viewStateMachine
|
||||
.withContext({
|
||||
...viewStateMachine.context,
|
||||
view,
|
||||
parentWin: win,
|
||||
overlayView,
|
||||
})
|
||||
.withConfig({ actions })
|
||||
const service = interpret(machine).start()
|
||||
service.onTransition((state) => this.handleViewTransition(idx, state))
|
||||
|
||||
views.push(service)
|
||||
}
|
||||
this.views = views
|
||||
|
||||
ipcMain.on('devtools-overlay', () => {
|
||||
overlayView.webContents.openDevTools()
|
||||
})
|
||||
}
|
||||
|
||||
handleViewTransition(idx, state) {
|
||||
const viewState = {
|
||||
state: state.value,
|
||||
context: {
|
||||
url: state.context.url,
|
||||
info: state.context.info,
|
||||
pos: state.context.pos,
|
||||
},
|
||||
}
|
||||
this.viewStates.set(idx, viewState)
|
||||
this.emit('state', [...this.viewStates.values()])
|
||||
}
|
||||
|
||||
setViews(viewURLMap) {
|
||||
const { views } = this
|
||||
const boxes = boxesFromViewURLMap(GRID_COUNT, GRID_COUNT, viewURLMap)
|
||||
|
||||
const unusedViews = new Set(views)
|
||||
for (const box of boxes) {
|
||||
const { url, x, y, w, h, spaces } = box
|
||||
|
||||
if (!url) {
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: prefer fully loaded views
|
||||
let space = views.find(
|
||||
(s) => unusedViews.has(s) && s.state.context.url === url,
|
||||
)
|
||||
if (!space) {
|
||||
space = views.find(
|
||||
(s) => unusedViews.has(s) && !s.state.matches('displaying'),
|
||||
)
|
||||
}
|
||||
const pos = {
|
||||
x: SPACE_WIDTH * x,
|
||||
y: SPACE_HEIGHT * y,
|
||||
width: SPACE_WIDTH * w,
|
||||
height: SPACE_HEIGHT * h,
|
||||
spaces,
|
||||
}
|
||||
space.send({ type: 'DISPLAY', pos, url })
|
||||
unusedViews.delete(space)
|
||||
}
|
||||
|
||||
for (const space of unusedViews) {
|
||||
space.send('CLEAR')
|
||||
}
|
||||
}
|
||||
|
||||
setListeningView(viewIdx) {
|
||||
const { views } = this
|
||||
for (const view of views) {
|
||||
if (!view.state.matches('displaying')) {
|
||||
continue
|
||||
}
|
||||
const { context } = view.state
|
||||
const isSelectedView = context.pos.spaces.includes(viewIdx)
|
||||
view.send(isSelectedView ? 'UNMUTE' : 'MUTE')
|
||||
}
|
||||
}
|
||||
|
||||
send(...args) {
|
||||
this.overlayView.webContents.send(...args)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,10 @@
|
||||
import fs from 'fs'
|
||||
import yargs from 'yargs'
|
||||
import { app, BrowserWindow, BrowserView, ipcMain, shell } from 'electron'
|
||||
import { interpret } from 'xstate'
|
||||
import { app, shell } from 'electron'
|
||||
|
||||
import { pollPublicData, pollSpreadsheetData, processData } from './data'
|
||||
import viewStateMachine from './viewStateMachine'
|
||||
import { boxesFromViewURLMap } from './geometry'
|
||||
|
||||
import {
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
GRID_COUNT,
|
||||
SPACE_WIDTH,
|
||||
SPACE_HEIGHT,
|
||||
} from '../constants'
|
||||
import StreamWindow from './StreamWindow'
|
||||
import initWebServer from './server'
|
||||
|
||||
async function main() {
|
||||
const argv = yargs
|
||||
@@ -31,143 +22,66 @@ async function main() {
|
||||
.option('gs-tab', {
|
||||
describe: 'Google Spreadsheet tab name',
|
||||
})
|
||||
.group(
|
||||
['webserver', 'cert-dir', 'cert-email', 'hostname', 'port'],
|
||||
'Web Server Configuration',
|
||||
)
|
||||
.option('webserver', {
|
||||
describe: 'Enable control webserver and specify the URL',
|
||||
implies: ['cert-dir', 'email', 'username', 'password'],
|
||||
})
|
||||
.option('cert-dir', {
|
||||
describe: 'Private directory to store SSL certificate in',
|
||||
})
|
||||
.option('email', {
|
||||
describe: 'Email for owner of SSL certificate',
|
||||
})
|
||||
.option('username', {
|
||||
describe: 'Web control server username',
|
||||
})
|
||||
.option('password', {
|
||||
describe: 'Web control server password',
|
||||
})
|
||||
.option('open-control', {
|
||||
describe: 'After launching, open the control website in a browser',
|
||||
boolean: true,
|
||||
default: true,
|
||||
})
|
||||
.help().argv
|
||||
|
||||
const mainWin = new BrowserWindow({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
},
|
||||
})
|
||||
await mainWin.loadFile('control.html')
|
||||
mainWin.webContents.on('will-navigate', (ev, url) => {
|
||||
ev.preventDefault()
|
||||
shell.openExternal(url)
|
||||
})
|
||||
const streamWindow = new StreamWindow()
|
||||
streamWindow.init()
|
||||
|
||||
const streamWin = new BrowserWindow({
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
backgroundColor: '#000',
|
||||
useContentSize: true,
|
||||
show: false,
|
||||
})
|
||||
streamWin.removeMenu()
|
||||
streamWin.loadURL('about:blank')
|
||||
|
||||
// Work around https://github.com/electron/electron/issues/14308
|
||||
// via https://github.com/lutzroeder/netron/commit/910ce67395130690ad76382c094999a4f5b51e92
|
||||
streamWin.once('ready-to-show', () => {
|
||||
streamWin.resizable = false
|
||||
streamWin.show()
|
||||
})
|
||||
|
||||
const overlayView = new BrowserView({
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
},
|
||||
})
|
||||
streamWin.addBrowserView(overlayView)
|
||||
overlayView.setBounds({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
})
|
||||
overlayView.webContents.loadFile('overlay.html')
|
||||
|
||||
const actions = {
|
||||
hideView: (context, event) => {
|
||||
const { view } = context
|
||||
streamWin.removeBrowserView(view)
|
||||
},
|
||||
positionView: (context, event) => {
|
||||
const { pos, view } = context
|
||||
streamWin.addBrowserView(view)
|
||||
|
||||
// It's necessary to remove and re-add the overlay view to ensure it's on top.
|
||||
streamWin.removeBrowserView(overlayView)
|
||||
streamWin.addBrowserView(overlayView)
|
||||
|
||||
view.setBounds(pos)
|
||||
},
|
||||
const clientState = {}
|
||||
const getInitialState = () => clientState
|
||||
let broadcastState = () => {}
|
||||
const onMessage = (msg) => {
|
||||
if (msg.type === 'set-views') {
|
||||
streamWindow.setViews(new Map(msg.views))
|
||||
} else if (msg.type === 'set-listening-view') {
|
||||
streamWindow.setListeningView(msg.viewIdx)
|
||||
}
|
||||
}
|
||||
|
||||
const views = []
|
||||
for (let idx = 0; idx <= 9; idx++) {
|
||||
const view = new BrowserView()
|
||||
view.setBackgroundColor('#000')
|
||||
|
||||
const machine = viewStateMachine
|
||||
.withContext({
|
||||
...viewStateMachine.context,
|
||||
view,
|
||||
parentWin: streamWin,
|
||||
overlayView,
|
||||
})
|
||||
.withConfig({ actions })
|
||||
const service = interpret(machine).start()
|
||||
service.onTransition((state) => {
|
||||
overlayView.webContents.send('space-state', idx, {
|
||||
state: state.value,
|
||||
context: {
|
||||
url: state.context.url,
|
||||
info: state.context.info,
|
||||
bounds: state.context.pos,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
views.push(service)
|
||||
if (argv.webserver) {
|
||||
;({ broadcastState } = await initWebServer({
|
||||
certDir: argv.certDir,
|
||||
email: argv.email,
|
||||
url: argv.webserver,
|
||||
username: argv.username,
|
||||
password: argv.password,
|
||||
getInitialState,
|
||||
onMessage,
|
||||
}))
|
||||
if (argv.openControl) {
|
||||
shell.openExternal(argv.webserver)
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on('set-videos', async (ev, viewURLMap) => {
|
||||
const boxes = boxesFromViewURLMap(GRID_COUNT, GRID_COUNT, viewURLMap)
|
||||
|
||||
const unusedViews = new Set(views)
|
||||
for (const box of boxes) {
|
||||
const { url, x, y, w, h, spaces } = box
|
||||
// TODO: prefer fully loaded views
|
||||
let space = views.find(
|
||||
(s) => unusedViews.has(s) && s.state.context.url === url,
|
||||
)
|
||||
if (!space) {
|
||||
space = views.find(
|
||||
(s) => unusedViews.has(s) && !s.state.matches('displaying'),
|
||||
)
|
||||
}
|
||||
const pos = {
|
||||
x: SPACE_WIDTH * x,
|
||||
y: SPACE_HEIGHT * y,
|
||||
width: SPACE_WIDTH * w,
|
||||
height: SPACE_HEIGHT * h,
|
||||
spaces,
|
||||
}
|
||||
space.send({ type: 'DISPLAY', pos, url })
|
||||
unusedViews.delete(space)
|
||||
}
|
||||
|
||||
for (const space of unusedViews) {
|
||||
space.send('CLEAR')
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('set-sound-source', async (ev, spaceIdx) => {
|
||||
for (const view of views) {
|
||||
if (!view.state.matches('displaying')) {
|
||||
continue
|
||||
}
|
||||
const { context } = view.state
|
||||
const isSelectedView = context.pos.spaces.includes(spaceIdx)
|
||||
view.send(isSelectedView ? 'UNMUTE' : 'MUTE')
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('devtools-overlay', () => {
|
||||
overlayView.webContents.openDevTools()
|
||||
streamWindow.on('state', (viewStates) => {
|
||||
streamWindow.send('view-states', viewStates)
|
||||
clientState.views = viewStates
|
||||
broadcastState(clientState)
|
||||
})
|
||||
|
||||
let dataGen
|
||||
@@ -177,9 +91,10 @@ async function main() {
|
||||
dataGen = pollPublicData()
|
||||
}
|
||||
|
||||
for await (const data of processData(dataGen)) {
|
||||
mainWin.webContents.send('stream-data', data)
|
||||
overlayView.webContents.send('stream-data', data)
|
||||
for await (const streams of processData(dataGen)) {
|
||||
streamWindow.send('stream-data', streams)
|
||||
clientState.streams = streams
|
||||
broadcastState(clientState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +103,7 @@ if (require.main === module) {
|
||||
.whenReady()
|
||||
.then(main)
|
||||
.catch((err) => {
|
||||
console.error(err.toString())
|
||||
console.trace(err.toString())
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
120
src/node/server.js
Normal file
120
src/node/server.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { app } from 'electron'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import url from 'url'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import simpleCert from 'node-simple-cert'
|
||||
import Koa from 'koa'
|
||||
import auth from 'koa-basic-auth'
|
||||
import route from 'koa-route'
|
||||
import serveStatic from 'koa-static'
|
||||
import views from 'koa-views'
|
||||
import websocket from 'koa-easy-ws'
|
||||
|
||||
const webDistPath = path.join(app.getAppPath(), 'web')
|
||||
|
||||
function initApp({ username, password, baseURL, getInitialState, onMessage }) {
|
||||
const sockets = new Set()
|
||||
|
||||
const app = new Koa()
|
||||
|
||||
// silence koa printing errors when websockets close early
|
||||
app.silent = true
|
||||
|
||||
app.use(auth({ name: username, pass: password }))
|
||||
app.use(views(webDistPath, { extension: 'ejs' }))
|
||||
app.use(serveStatic(webDistPath))
|
||||
app.use(websocket())
|
||||
|
||||
app.use(
|
||||
route.get('/', async (ctx) => {
|
||||
await ctx.render('control', {
|
||||
wsEndpoint: url.resolve(baseURL, 'ws').replace(/^http/, 'ws'),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
app.use(
|
||||
route.get('/ws', async (ctx) => {
|
||||
if (ctx.ws) {
|
||||
const ws = await ctx.ws()
|
||||
sockets.add(ws)
|
||||
|
||||
ws.on('close', () => {
|
||||
sockets.delete(ws)
|
||||
})
|
||||
|
||||
ws.on('message', (dataText) => {
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(dataText)
|
||||
} catch (err) {
|
||||
console.warn('received unexpected ws data:', dataText)
|
||||
}
|
||||
|
||||
try {
|
||||
onMessage(data)
|
||||
} catch (err) {
|
||||
console.error('failed to handle ws message:', data, err)
|
||||
}
|
||||
})
|
||||
|
||||
const state = getInitialState()
|
||||
ws.send(JSON.stringify({ type: 'state', state }))
|
||||
return
|
||||
}
|
||||
ctx.status = 404
|
||||
}),
|
||||
)
|
||||
|
||||
const broadcastState = (state) => {
|
||||
for (const ws of sockets) {
|
||||
ws.send(JSON.stringify({ type: 'state', state }))
|
||||
}
|
||||
}
|
||||
|
||||
return { app, broadcastState }
|
||||
}
|
||||
|
||||
export default async function initWebServer({
|
||||
certDir,
|
||||
email,
|
||||
url: baseURL,
|
||||
username,
|
||||
password,
|
||||
getInitialState,
|
||||
onMessage,
|
||||
}) {
|
||||
let { protocol, hostname, port } = new URL(baseURL)
|
||||
if (!port) {
|
||||
port = protocol === 'https' ? 443 : 80
|
||||
}
|
||||
|
||||
const { app, broadcastState } = initApp({
|
||||
username,
|
||||
password,
|
||||
baseURL,
|
||||
getInitialState,
|
||||
onMessage,
|
||||
})
|
||||
|
||||
let server
|
||||
if (protocol === 'https:') {
|
||||
const { key, cert } = await simpleCert({
|
||||
dataDir: certDir,
|
||||
commonName: hostname,
|
||||
email,
|
||||
production: process.env.NODE_DEV === 'production',
|
||||
serverHost: hostname,
|
||||
})
|
||||
server = https.createServer({ key, cert }, app.callback())
|
||||
} else {
|
||||
server = http.createServer(app.callback())
|
||||
}
|
||||
|
||||
const listen = promisify(server.listen).bind(server)
|
||||
await listen(port, hostname)
|
||||
|
||||
return { broadcastState }
|
||||
}
|
||||
Reference in New Issue
Block a user