diff --git a/src/helpers/APIHelper.ts b/src/helpers/APIHelper.ts index 9ad09d9..b1a4972 100644 --- a/src/helpers/APIHelper.ts +++ b/src/helpers/APIHelper.ts @@ -147,9 +147,9 @@ export class APIHelper { ctx.body = ` GET /api/status
GET /api/list
-POST /api/playURL/:windowId body: {"url": "", "jsCode": "" }
-POST /api/restart/:windowId
-POST /api/stop/:windowId
+PUT /api/playURL/:windowId body: {"url": "", "jsCode": "" }
+PUT /api/restart/:windowId
+PUT /api/stop/:windowId
POST /api/execute/:windowId body: {"jsCode": "" }
` }) diff --git a/src/helpers/WindowHelper.ts b/src/helpers/WindowHelper.ts index c3eca8e..df3ac39 100644 --- a/src/helpers/WindowHelper.ts +++ b/src/helpers/WindowHelper.ts @@ -6,6 +6,7 @@ import { Logger } from '../lib/logging' import { ReportStatusIpcPayload, StatusCode, StatusObject } from '../lib/api' import * as path from 'path' import urlJoin = require('url-join') +import { Queue } from '../lib/queue' export class WindowHelper extends EventEmitter { private window: BrowserWindow @@ -27,6 +28,8 @@ export class WindowHelper extends EventEmitter { */ private updateHash = 0 + private queue = new Queue() + constructor( private logger: Logger, public readonly id: string, @@ -36,6 +39,14 @@ export class WindowHelper extends EventEmitter { ) { super() + // Put the public methods in a queue, to ensure that they are run in order: + this.close = this.queue.bindMethod(this.close.bind(this), { reason: 'close' }) + this.updateConfig = this.queue.bindMethod(this.updateConfig.bind(this), { reason: 'updateConfig' }) + this.playURL = this.queue.bindMethod(this.playURL.bind(this), { reason: 'playURL' }) + this.restart = this.queue.bindMethod(this.restart.bind(this)) + this.stop = this.queue.bindMethod(this.stop.bind(this), { reason: 'stop' }) + this.executeJavascript = this.queue.bindMethod(this.executeJavascript.bind(this)) + this.window = new BrowserWindow({ height: this.config.height, width: this.config.width, @@ -53,19 +64,19 @@ export class WindowHelper extends EventEmitter { this.window.on('resized', () => { this.logger.info(`Window "${this.id}": resized`) - this.updateSizeAndPosition() + this._updateSizeAndPosition() }) this.window.on('moved', () => { this.logger.info(`Window "${this.id}": moved`) - this.updateSizeAndPosition() + this._updateSizeAndPosition() }) this.window.on('maximize', () => { this.logger.info(`Window "${this.id}": maximized`) - this.updateSizeAndPosition() + this._updateSizeAndPosition() }) this.window.on('unmaximize', () => { this.logger.info(`Window "${this.id}": unmaximized`) - this.updateSizeAndPosition() + this._updateSizeAndPosition() }) this.window.on('focus', () => { this.emit('focus') @@ -91,106 +102,105 @@ export class WindowHelper extends EventEmitter { public get url(): string | null { return this._url } - // public get status(): StatusObject { - // return this._status - // } public async init(): Promise { // Set the user-agent: this.userAgent = this.window.webContents.getUserAgent() + ' sofie-chef' - await this.updateWindow() + await this._updateWindow() // Trigger loading default page: // await this.restart() // await mainWindow.loadFile(path.join(__dirname, '../static/index.html')) // await mainWindow.loadURL(`file://${app.getAppPath()}/dist/index.html`) } + /** Closes the window. */ public async close(): Promise { + // Note: This Method runs in a queue! this.logger.info(`Closing window "${this.id}"`) this.window.close() } public async updateConfig(sharedConfig: ConfigWindowShared, config: ConfigWindow): Promise { + // Note: This Method runs in a queue! const oldConfig = this._config this._sharedConfig = sharedConfig this._config = config if (!_.isEqual(oldConfig, config)) { - await this.updateWindow(oldConfig) + await this._updateWindow(oldConfig) } } - public async updateWindow(oldConfig?: ConfigWindow): Promise { - if ( - this.config.x !== undefined && - this.config.y !== undefined && - this.config.height !== undefined && - this.config.width !== undefined - ) { - // Hack to make it work on Windows with multi-dpi screens - // Ref: https://github.com/electron/electron/pull/10972 - const bestDisplay = screen.getDisplayMatching({ - height: this.config.height, - width: this.config.width, - x: this.config.x, - y: this.config.y, - }) - - const windowBounds = { - x: Math.max(this.config.x, bestDisplay.workArea.x), - y: Math.max(this.config.y, bestDisplay.workArea.y), - width: Math.min(this.config.width, bestDisplay.workArea.width), - height: Math.min(this.config.height, bestDisplay.workArea.height), - } + public toggleFullScreen(): void { + this.window.setFullScreen(!this.window.isFullScreen()) + this._updateSizeAndPosition() + } + public toggleDevTools(): void { + this.window.webContents.toggleDevTools() + } + /** Restarts (reloads) the window. */ + public async restart(): Promise { + // Note: This Method runs in a queue! + return this._restart() + } + /** Play the specified URL in the window */ + public async playURL(url: string | null): Promise { + // Note: This Method runs in a queue! + return this._playURL(url) + } + /** + * Stops playing the content in window + */ + public async stop(): Promise { + // Note: This Method runs in a queue! + await this._playURL(null) + } + /** + * Executes a javascript inside the web player + * (This Method runs in a queue.) + */ + public async executeJavascript(script: string): Promise { + await this.window.webContents.executeJavaScript(script) + } + public getURL(): string { + const windowUrl = this._url ?? this._config.defaultURL - this.window.setBounds(windowBounds) + if (this._sharedConfig.baseURL && !windowUrl.match(/^(?:[a-z+]+:)?\/\//i)) { + // URL does not look absolute, add the baseURL + return urlJoin(this._sharedConfig.baseURL, windowUrl) } else { - this.window.setBounds({ - width: this.config.width, - height: this.config.height, - }) + return windowUrl } + } + public receiveExternalStatus(browserWindow: BrowserWindow, payload: ReportStatusIpcPayload): void { + if (browserWindow !== this.window) return - this.window.setFullScreen(this.config.fullScreen) - - if (this.config.onTop) { - // Note: Some popups are only displayed below if running in fullscreen as well. - this.window.setAlwaysOnTop(true, 'screen-saver') - } else { - this.window.setAlwaysOnTop(false) + this.status = { + statusCode: payload.status, + message: payload.message || '', } + } - if (this.config.zoomFactor !== oldConfig?.zoomFactor) { - this.window.webContents.setZoomFactor((this.config.zoomFactor ?? 100) / 100) - } - if ( - this.window.webContents.getURL() !== this.getURL() || - this.config.displayDebug !== oldConfig?.displayDebug || - this.config.hideCursor !== oldConfig?.hideCursor || - this.config.hideScrollbar !== oldConfig?.hideScrollbar - ) { - await this.restart() + public get status(): StatusObject { + let status = this._status + if (this._contentStatus && this._contentStatus.statusCode > status.statusCode) { + status = this._contentStatus } - - this.window.moveTop() - } - public hasFocus(): boolean { - return this.window.isFocused() - } - public toggleFullScreen(): void { - this.window.setFullScreen(!this.window.isFullScreen()) - this.updateSizeAndPosition() + return status } - public toggleDevTools(): void { - this.window.webContents.toggleDevTools() + private set status(status: StatusObject) { + if (this._status.statusCode !== status.statusCode || this._status.message !== status.message) { + this._status = status + this._emitStatus() + } } - /** Play the specified URL in the window */ - async playURL(url: string | null): Promise { + + private async _playURL(url: string | null): Promise { if (this._url !== url) { this._url = url - await this.restart() + await this._restart() } } /** Restarts (reloads) the window */ - async restart(): Promise { + private async _restart(): Promise { delete this._contentStatus const updateHash = ++this.updateHash @@ -210,18 +220,18 @@ export class WindowHelper extends EventEmitter { await this.window.webContents.insertCSS(`html, body { background-color: ${defaultColor}; }`) } - this.setupWebContentListeners() + this._setupWebContentListeners() this.window.setTitle(this.title) this.window.webContents.setZoomFactor((this.config.zoomFactor ?? 100) / 100) if (this.config.displayDebug) { - await this.displayDebugOverlay() + await this._displayDebugOverlay() } if (updateHash !== this.updateHash) return // Abort if the updateHash has changed - await this.injectUpdateCSS() + await this._injectUpdateCSS() } catch (err) { this.status = { statusCode: StatusCode.ERROR, @@ -235,42 +245,66 @@ export class WindowHelper extends EventEmitter { message: ``, } } - /** Stops playing the content in window */ - async stop(): Promise { - await this.playURL(null) - } - /** Executes a javascript inside the web player */ - async executeJavascript(script: string): Promise { - await this.window.webContents.executeJavaScript(script) + + private _emitStatus() { + this.emit('status', this.status) } + private async _updateWindow(oldConfig?: ConfigWindow): Promise { + if ( + this.config.x !== undefined && + this.config.y !== undefined && + this.config.height !== undefined && + this.config.width !== undefined + ) { + // Hack to make it work on Windows with multi-dpi screens + // Ref: https://github.com/electron/electron/pull/10972 + const bestDisplay = screen.getDisplayMatching({ + height: this.config.height, + width: this.config.width, + x: this.config.x, + y: this.config.y, + }) - receiveExternalStatus(browserWindow: BrowserWindow, payload: ReportStatusIpcPayload): void { - if (browserWindow !== this.window) return + const windowBounds = { + x: Math.max(this.config.x, bestDisplay.workArea.x), + y: Math.max(this.config.y, bestDisplay.workArea.y), + width: Math.min(this.config.width, bestDisplay.workArea.width), + height: Math.min(this.config.height, bestDisplay.workArea.height), + } - this.status = { - statusCode: payload.status, - message: payload.message || '', + this.window.setBounds(windowBounds) + } else { + this.window.setBounds({ + width: this.config.width, + height: this.config.height, + }) } - } - public get status(): StatusObject { - let status = this._status - if (this._contentStatus && this._contentStatus.statusCode > status.statusCode) { - status = this._contentStatus + this.window.setFullScreen(this.config.fullScreen) + + if (this.config.onTop) { + // Note: Some popups are only displayed below if running in fullscreen as well. + this.window.setAlwaysOnTop(true, 'screen-saver') + } else { + this.window.setAlwaysOnTop(false) } - return status - } - private set status(status: StatusObject) { - if (this._status.statusCode !== status.statusCode || this._status.message !== status.message) { - this._status = status - this.emitStatus() + + if (this.config.zoomFactor !== oldConfig?.zoomFactor) { + this.window.webContents.setZoomFactor((this.config.zoomFactor ?? 100) / 100) } - } - private emitStatus() { - this.emit('status', this.status) + if ( + this.window.webContents.getURL() !== this.getURL() || + this.config.displayDebug !== oldConfig?.displayDebug || + this.config.hideCursor !== oldConfig?.hideCursor || + this.config.hideScrollbar !== oldConfig?.hideScrollbar + ) { + await this._restart() + } + + this.window.moveTop() } - private updateSizeAndPosition() { + private _updateSizeAndPosition() { this.config.fullScreen = this.window.isFullScreen() // No need to update position and size if it's fullscreen anyway. @@ -285,17 +319,15 @@ export class WindowHelper extends EventEmitter { this.emit('window-has-been-modified') } - public getURL(): string { - const windowUrl = this._url ?? this._config.defaultURL - if (this._sharedConfig.baseURL && !windowUrl.match(/^(?:[a-z+]+:)?\/\//i)) { - // URL does not look absolute, add the baseURL - return urlJoin(this._sharedConfig.baseURL, windowUrl) - } else { - return windowUrl - } + private _setupWebContentListeners() { + this.window.webContents.off('render-process-gone', this._handleRenderProcessGone) + this.window.webContents.on('render-process-gone', this._handleRenderProcessGone) + + this.window.webContents.off('console-message', this._handleConsoleMessage) + this.window.webContents.on('console-message', this._handleConsoleMessage) } - private handleRenderProcessGone = (event: Electron.Event, details: Electron.RenderProcessGoneDetails): void => { + private _handleRenderProcessGone = (event: Electron.Event, details: Electron.RenderProcessGoneDetails): void => { if (details.reason !== 'clean-exit') { this.status = { statusCode: StatusCode.ERROR, @@ -303,7 +335,7 @@ export class WindowHelper extends EventEmitter { } } } - private handleConsoleMessage = ( + private _handleConsoleMessage = ( _event: Electron.Event, level: number, message: string, @@ -315,15 +347,7 @@ export class WindowHelper extends EventEmitter { this.logger.debug(`${this.id}: ${logMessage}`) } } - - private setupWebContentListeners() { - this.window.webContents.off('render-process-gone', this.handleRenderProcessGone) - this.window.webContents.on('render-process-gone', this.handleRenderProcessGone) - - this.window.webContents.off('console-message', this.handleConsoleMessage) - this.window.webContents.on('console-message', this.handleConsoleMessage) - } - private async displayDebugOverlay() { + private async _displayDebugOverlay() { await this.window.webContents.executeJavaScript(` function setupMonitor() { var overlay = document.createElement('div'); @@ -359,7 +383,7 @@ function setupMonitor() { } setupMonitor();`) } - private async injectUpdateCSS() { + private async _injectUpdateCSS() { if (this.config.hideCursor ?? true) { // Hide cursor: // This should hide the cursor for most elements that sets their own cursor diff --git a/src/lib/queue.ts b/src/lib/queue.ts new file mode 100644 index 0000000..4356a47 --- /dev/null +++ b/src/lib/queue.ts @@ -0,0 +1,72 @@ +/** + * Super simple queue, that allows you to bind a method to a queue and it will run them in order. + */ +export class Queue { + private queue: { + isRunning: boolean + fcn: () => Promise + resolve: () => void + reject: (err: Error) => void + }[] = [] + + public bindMethod Promise>( + fcn: T, + clearWaiting?: { + reason: string + } + ): T { + return (async (...args: any[]) => { + await new Promise((resolve, reject) => { + if (clearWaiting) { + this.clearWaiting(clearWaiting.reason) + } + + this.queue.push({ + isRunning: false, + fcn: async () => { + return fcn(...args) + }, + resolve, + reject, + }) + + this.checkQueue() + }) + }) as T + } + + /** + * Clear the queue from any not-yet-running functions and rejected them with the given reason. + * Any functions that are already running will not be affected. + */ + public clearWaiting(reason: string): void { + for (const q of this.queue) { + if (q.isRunning) continue + + q.reject(new Error(`Aborted, due to reason: "${reason}"`)) + } + // Clear the queue, but leave any isRunning functions in place: + this.queue = this.queue.filter((q) => q.isRunning) + } + + private checkQueue() { + const nextInQueue = this.queue[0] + if (!nextInQueue) return + if (nextInQueue.isRunning) return + + nextInQueue.isRunning = true + + nextInQueue + .fcn() + .then(() => { + nextInQueue.resolve() + }) + .catch((err) => { + nextInQueue.reject(err) + }) + .finally(() => { + this.queue.shift() + this.checkQueue() + }) + } +}