Add web-based remote control with HTTPS support

This commit is contained in:
Max Goodhart
2020-06-18 15:58:30 -07:00
parent 9a9c500137
commit 32a0a189b4
19 changed files with 1203 additions and 248 deletions

View File

@@ -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)
})
}