diff --git a/app/ui.js b/app/ui.js index 2542e0591..0a5d8b69c 100644 --- a/app/ui.js +++ b/app/ui.js @@ -189,6 +189,7 @@ const UI = { UI.initSetting('repeaterID', ''); UI.initSetting('reconnect', false); UI.initSetting('reconnect_delay', 5000); + UI.initSetting('request_wakelock', false); }, // Adds a link to the label elements on the corresponding input elements setupSettingLabels() { @@ -371,6 +372,8 @@ const UI = { UI.addSettingChangeHandler('view_only', UI.updateViewOnly); UI.addSettingChangeHandler('show_dot'); UI.addSettingChangeHandler('show_dot', UI.updateShowDotCursor); + UI.addSettingChangeHandler('request_wakelock'); + UI.addSettingChangeHandler('request_wakelock', UI.updateRequestWakelock); UI.addSettingChangeHandler('host'); UI.addSettingChangeHandler('port'); UI.addSettingChangeHandler('path'); @@ -1101,6 +1104,7 @@ const UI = { UI.rfb.qualityLevel = parseInt(UI.getSetting('quality')); UI.rfb.compressionLevel = parseInt(UI.getSetting('compression')); UI.rfb.showDotCursor = UI.getSetting('show_dot'); + UI.rfb.requestLocalWakelock = UI.getSetting('request_wakelock'); UI.updateViewOnly(); // requires UI.rfb }, @@ -1769,6 +1773,12 @@ const UI = { document.title = e.detail.name + " - " + PAGE_TITLE; }, + updateRequestWakelock() { + if (!UI.rfb) return; + UI.rfb.requestLocalWakelock = UI.getSetting('request_wakelock'); + }, + + bell(e) { if (UI.getSetting('bell') === 'on') { const promise = document.getElementById('noVNC_bell').play(); diff --git a/core/rfb.js b/core/rfb.js index 80011e4a1..ed0048010 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -26,6 +26,7 @@ import XtScancode from "./input/xtscancodes.js"; import { encodings } from "./encodings.js"; import RSAAESAuthenticationState from "./ra2.js"; import legacyCrypto from "./crypto/crypto.js"; +import WakeLockManager from './util/wakelock.js'; import RawDecoder from "./decoders/raw.js"; import CopyRectDecoder from "./decoders/copyrect.js"; @@ -167,6 +168,7 @@ export default class RFB extends EventTargetMixin { this._keyboard = null; // Keyboard input handler object this._gestures = null; // Gesture input handler object this._resizeObserver = null; // Resize observer object + this._wakelock = new WakeLockManager(); // Timers this._disconnTimer = null; // disconnection timer @@ -303,6 +305,8 @@ export default class RFB extends EventTargetMixin { this._qualityLevel = 6; this._compressionLevel = 2; + + this._requestLocalWakelock = false; } // ===== PROPERTIES ===== @@ -413,6 +417,24 @@ export default class RFB extends EventTargetMixin { } } + get requestLocalWakelock() { + return this._requestLocalWakelock; + } + set requestLocalWakelock(requestLocalWakelock) { + let newState = !!requestLocalWakelock; + if (newState === this._requestLocalWakelock) { + return; + } + this._requestLocalWakelock = newState; + if (newState) { + if (this._rfbConnectionState === 'connected') { + this._wakelock.acquire(); + } + } else { + this._wakelock.release(); + } + } + // ===== PUBLIC METHODS ===== disconnect() { @@ -927,6 +949,9 @@ export default class RFB extends EventTargetMixin { break; case 'connected': + if (this._requestLocalWakelock) { + this._wakelock.acquire(); + } this.dispatchEvent(new CustomEvent("connect", { detail: {} })); break; @@ -943,6 +968,7 @@ export default class RFB extends EventTargetMixin { this.dispatchEvent(new CustomEvent( "disconnect", { detail: { clean: this._rfbCleanDisconnect } })); + this._wakelock.release(); break; } } diff --git a/core/util/wakelock.js b/core/util/wakelock.js new file mode 100644 index 000000000..906d82e16 --- /dev/null +++ b/core/util/wakelock.js @@ -0,0 +1,199 @@ +/* + * noVNC: HTML5 VNC client + * Copyright (C) 2025 The noVNC authors + * Licensed under MPL 2.0 or any later version (see LICENSE.txt) + * + * Wrapper around the `navigator.wakeLock` api that handles reacquiring the + * lock on visiblility changes. + * + * The `acquire` and `release` methods may be called any number of times. The + * most recent call dictates the desired end-state (if `acquire` was most + * recently called, then we will try to acquire and hold the wake lock). + */ + +import * as Log from './logging.js'; + +const _STATES = { + /* No wake lock. + * + * Can transition to: + * - AWAITING_VISIBLE: `acquire` called when document is hidden. + * - ACQUIRING: `acquire` called. + * - ERROR: `acquired` called when the api is not available. + */ + RELEASED: 'released', + /* Wake lock requested, waiting for browser. + * + * Can transition to: + * - ACQUIRED: success + * - ACQUIRING_WANT_RELEASE: `release` called while waiting + * - ERROR + */ + ACQUIRING: 'acquiring', + /* Wake lock requested, release called, still waiting for browser. + * + * Can transition to: + * - ACQUIRING: `acquire` called (but promise has not resolved yet) + * - RELEASED: success + */ + ACQUIRING_WANT_RELEASE: 'releasing', + /* Wake lock held. + * + * Can transition to: + * - AWAITING_VISIBLE: wakelock lost due to visibility change + * - RELEASED: success + */ + ACQUIRED: 'acquired', + /* Caller wants wakelock, but we can not get it due to visibility. + * + * Can transition to: + * - ACQUIRING: document is now visible, attempting to get wakelock. + * - RELEASED: when release is called. + */ + AWAITING_VISIBLE: 'awaiting_visible', + /* An error has occurred. + * + * Can transition to: + * - RELEASED: will happen immediately. + */ + ERROR: 'error', +}; + +class TestOnlyWakeLockManagerStateChangeEvent extends Event { + constructor(oldState, newState) { + super("testOnlyStateChange"); + this.oldState = oldState; + this.newState = newState; + } +} + +export default class WakeLockManager extends EventTarget { + constructor() { + super(); + + this._state = _STATES.RELEASED; + this._wakelock = null; + + this._eventHandlers = { + wakelockAcquired: this._wakelockAcquired.bind(this), + wakelockReleased: this._wakelockReleased.bind(this), + documentVisibilityChange: this._documentVisibilityChange.bind(this), + }; + } + + acquire() { + switch (this._state) { + case _STATES.ACQUIRING_WANT_RELEASE: + // We are currently waiting to acquire the wakelock. While + // waiting, `release()` was called. By transitioning back to + // ACQUIRING, we will keep the lock after we receive it. + this._transitionTo(_STATES.ACQUIRING); + break; + case _STATES.AWAITING_VISIBLE: + case _STATES.ACQUIRING: + case _STATES.ACQUIRED: + break; + case _STATES.ERROR: + case _STATES.RELEASED: + if (document.hidden) { + // We can not acquire the wakelock while the document is + // hidden (eg, not the active tab). Wait until it is + // visible, then acquire the wakelock. + this._awaitVisible(); + break; + } + this._acquireWakelockNow(); + break; + } + } + + release() { + switch (this._state) { + case _STATES.ERROR: + case _STATES.RELEASED: + case _STATES.ACQUIRING_WANT_RELEASE: + break; + case _STATES.ACQUIRING: + // We are have requested (but not yet received) the wakelock. + // Give it up as soon as we acquire it. + this._transitionTo(_STATES.ACQUIRING_WANT_RELEASE); + break; + case _STATES.ACQUIRED: + // We remove the event listener first, as we don't want to be + // notified about this release (it is expected). + this._wakelock.removeEventListener("release", this._eventHandlers.wakelockReleased); + this._wakelock.release(); + this._wakelock = null; + this._transitionTo(_STATES.RELEASED); + break; + case _STATES.AWAITING_VISIBLE: + // We don't currently have the lock, but are waiting for the + // document to become visible. By removing the event listener, + // we will not attempt to get the wakelock in the future. + document.removeEventListener("visibilitychange", this._eventHandlers.documentVisibilityChange); + this._transitionTo(_STATES.RELEASED); + break; + } + } + + _transitionTo(newState) { + let oldState = this._state; + Log.Debug(`WakelockManager transitioning ${oldState} -> ${newState}`); + this._state = newState; + this.dispatchEvent(new TestOnlyWakeLockManagerStateChangeEvent(oldState, newState)); + } + + _awaitVisible() { + document.addEventListener("visibilitychange", this._eventHandlers.documentVisibilityChange); + this._transitionTo(_STATES.AWAITING_VISIBLE); + } + + _acquireWakelockNow() { + if (!("wakeLock" in navigator)) { + Log.Warn("Unable to request wakeLock, Browser does not have wakeLock api"); + this._transitionTo(_STATES.ERROR); + this._transitionTo(_STATES.RELEASED); + return; + } + navigator.wakeLock.request("screen") + .then(this._eventHandlers.wakelockAcquired) + .catch((err) => { + Log.Warn("Error occurred while acquiring wakelock: " + err); + this._transitionTo(_STATES.ERROR); + this._transitionTo(_STATES.RELEASED); + }); + this._transitionTo(_STATES.ACQUIRING); + } + + + _wakelockAcquired(wakelock) { + if (this._state === _STATES.ACQUIRING_WANT_RELEASE) { + // We were requested to release the wakelock while we were trying to + // acquire it. Now that we have acquired it, immediately release it. + wakelock.release(); + this._transitionTo(_STATES.RELEASED); + return; + } + this._wakelock = wakelock; + this._wakelock.addEventListener("release", this._eventHandlers.wakelockReleased); + this._transitionTo(_STATES.ACQUIRED); + } + + _wakelockReleased(event) { + this._wakelock = null; + if (document.visibilityState === "visible") { + Log.Warn("Lost wakelock, but document is still visible. Not reacquiring"); + this._transitionTo(_STATES.RELEASED); + return; + } + this._awaitVisible(); + } + + _documentVisibilityChange(event) { + if (document.visibilityState !== "visible") { + return; + } + document.removeEventListener("visibilitychange", this._eventHandlers.documentVisibilityChange); + this._acquireWakelockNow(); + } +} diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index 9e927d0d3..10bd7eba3 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -95,6 +95,10 @@ Currently, the following options are available: * `logging` - The console log level. Can be one of `error`, `warn`, `info` or `debug`. +* `request_wakelock` - Should we prevent the (local) display from going into + sleep mode while a connection is active? Useful for view-only sessions where + there unlikely to be any keyboard/mouse activity to keep the device active. + ## HTTP serving considerations ### Browser cache issue diff --git a/tests/test.rfb.js b/tests/test.rfb.js index 2a7bbeaab..6399c2d3a 100644 --- a/tests/test.rfb.js +++ b/tests/test.rfb.js @@ -9,6 +9,7 @@ import KeyTable from '../core/input/keysym.js'; import legacyCrypto from '../core/crypto/crypto.js'; import FakeWebSocket from './fake.websocket.js'; +import WakeLockManager from '../core/util/wakelock.js'; function push8(arr, num) { "use strict"; @@ -5148,6 +5149,102 @@ describe('Remote Frame Buffer protocol client', function () { expect(RFB.messages.clientEncodings.getCall(0).args[1]).to.include(encodings.pseudoEncodingCompressLevel0 + newCompression); }); }); + + describe('wakelock setting', function () { + let client; + + beforeEach(function () { + sinon.spy(WakeLockManager.prototype, "acquire"); + sinon.spy(WakeLockManager.prototype, "release"); + + client = makeRFB(); + }); + + afterEach(function () { + WakeLockManager.prototype.acquire.restore(); + WakeLockManager.prototype.release.restore(); + }); + + it('should acquire wakelock when connected', function() { + expect(WakeLockManager.prototype.acquire).to.not.have.been.called; + + client.requestLocalWakelock = true; + expect(WakeLockManager.prototype.acquire).to.have.been.calledOnce; + }); + + it('should acquire wakelock after connection', function() { + expect(WakeLockManager.prototype.acquire).to.not.have.been.called; + client._rfbConnectionState = 'connecting'; + + client.requestLocalWakelock = true; + expect(WakeLockManager.prototype.acquire).to.not.have.been.called; + + client._updateConnectionState('connected'); + expect(WakeLockManager.prototype.acquire).to.have.been.calledOnce; + }); + + it('should release wakelock when disabled', function() { + client.requestLocalWakelock = true; + expect(WakeLockManager.prototype.acquire).to.have.been.calledOnce; + expect(WakeLockManager.prototype.release).to.not.have.been.called; + + client.requestLocalWakelock = false; + expect(WakeLockManager.prototype.release).to.have.been.calledOnce; + }); + + it('should release wakelock when disconnected', function() { + client.requestLocalWakelock = true; + expect(WakeLockManager.prototype.acquire).to.have.been.calledOnce; + expect(WakeLockManager.prototype.release).to.not.have.been.called; + + client.disconnect(); + expect(WakeLockManager.prototype.release).to.have.been.calledOnce; + }); + + it('should behave sensibly with non-boolean values', function() { + // Client starts with requestLocakWakelock = false, setting it to + // the same value should have no effect. + client.requestLocalWakelock = false; + expect(client.requestLocalWakelock).to.be.false; + expect(WakeLockManager.prototype.acquire).to.not.have.been.called; + expect(WakeLockManager.prototype.release).to.not.have.been.called; + + // Setting it to something else falsely should have no effect. + client.requestLocalWakelock = null; + expect(client.requestLocalWakelock).to.be.false; + expect(WakeLockManager.prototype.acquire).to.not.have.been.called; + expect(WakeLockManager.prototype.release).to.not.have.been.called; + + // Setting it to something else falsely should have no effect. + client.requestLocalWakelock = undefined; + expect(client.requestLocalWakelock).to.be.false; + expect(WakeLockManager.prototype.acquire).to.not.have.been.called; + expect(WakeLockManager.prototype.release).to.not.have.been.called; + + // Switching to something true should trigger a single call to + // acquire. + client.requestLocalWakelock = true; + expect(client.requestLocalWakelock).to.be.true; + expect(WakeLockManager.prototype.acquire).to.have.been.calledOnce; + expect(WakeLockManager.prototype.release).to.not.have.been.called; + + WakeLockManager.prototype.acquire.resetHistory(); + + // Switching to something else trueish should have no effect. + client.requestLocalWakelock = "some-value"; + expect(client.requestLocalWakelock).to.be.true; + expect(WakeLockManager.prototype.acquire).to.not.have.been.called; + expect(WakeLockManager.prototype.release).to.not.have.been.called; + + // Validate that switching from a trueish value to a falseish value + // works. + client.requestLocalWakelock = null; + expect(client.requestLocalWakelock).to.be.false; + expect(WakeLockManager.prototype.acquire).to.not.have.been.called; + expect(WakeLockManager.prototype.release).to.have.been.calledOnce; + }); + + }); }); describe('RFB messages', function () { diff --git a/tests/test.wakelock.js b/tests/test.wakelock.js new file mode 100644 index 000000000..a00245ea6 --- /dev/null +++ b/tests/test.wakelock.js @@ -0,0 +1,198 @@ +/* jshint expr: true */ + +import WakeLockManager from '../core/util/wakelock.js'; + +class FakeWakeLockSentinal extends EventTarget { + constructor() { + super(); + this.released = false; + } + + async release() { + if (this.released) { + return; + } + this.released = true; + this.dispatchEvent(new Event("release")); + } +} + +function waitForStateTransition(wakelockManager, newState) { + const {promise, resolve} = Promise.withResolvers(); + + const eventListener = (event) => { + console.warn(`Got state change: ${event.oldState} -> ${event.newState}`); + if (event.newState !== newState) { + return; + } + wakelockManager.removeEventListener("testOnlyStateChange", eventListener); + resolve(); + }; + wakelockManager.addEventListener("testOnlyStateChange", eventListener); + + return promise; +} + +describe('WakeLockManager', function () { + "use strict"; + + let wakelockRequest; + beforeEach(function() { + wakelockRequest = sinon.stub(navigator.wakeLock, 'request'); + }) + afterEach(function() { + wakelockRequest.restore(); + }); + + it('can acquire and release lock', async function() { + let wakeLockSentinal = new FakeWakeLockSentinal(); + wakelockRequest.onFirstCall().resolves(wakeLockSentinal); + + let wlm = new WakeLockManager(); + expect(wakelockRequest).to.not.have.been.called; + + let done = waitForStateTransition(wlm, 'acquired'); + wlm.acquire(); + await done; + expect(wakelockRequest).to.have.been.calledOnce; + expect(wakeLockSentinal.released).to.be.false; + + done = waitForStateTransition(wlm, 'released'); + wlm.release(); + await done; + expect(wakelockRequest).to.have.been.calledOnce; + expect(wakeLockSentinal.released).to.be.true; + }); + + it('can release without holding wakelock', async function() { + let wlm = new WakeLockManager(); + wlm.release(); + expect(wakelockRequest).to.not.have.been.called; + }); + + it('can release while waiting for wakelock', async function() { + let wakeLockSentinal = new FakeWakeLockSentinal(); + let {promise, resolve} = Promise.withResolvers(); + + wakelockRequest.onFirstCall().returns(promise); + + let wlm = new WakeLockManager(); + expect(wakelockRequest).to.not.have.been.called; + + let seenAcquiring = waitForStateTransition(wlm, 'acquiring'); + let seenReleasing = waitForStateTransition(wlm, 'releasing'); + let seenReleased = waitForStateTransition(wlm, 'released'); + + wlm.acquire(); + await seenAcquiring; + expect(wakelockRequest).to.have.been.calledOnce; + + // We can call acquire multiple times, while waiting for the promise + // to resolve. + wlm.acquire(); + // It should not request a second wakelock. + expect(wakelockRequest).to.have.been.calledOnce; + + wlm.release(); + await seenReleasing; + + expect(wakeLockSentinal.released).to.be.false; + + // Now return the wake lock, we should immediately release it. + resolve(wakeLockSentinal); + await seenReleased; + expect(wakeLockSentinal.released).to.be.true; + }); + + it('handles visibility loss', async function() { + let documentHidden = sinon.stub(document, 'hidden'); + let documentVisibility = sinon.stub(document, 'visibilityState'); + afterEach(function() { + documentHidden.restore(); + documentVisibility.restore(); + }); + documentHidden.value(false); + documentVisibility.value('visible'); + + let wakeLockSentinal1 = new FakeWakeLockSentinal(); + let wakeLockSentinal2 = new FakeWakeLockSentinal(); + wakelockRequest.onFirstCall().resolves(wakeLockSentinal1); + wakelockRequest.onSecondCall().resolves(wakeLockSentinal2); + + let wlm = new WakeLockManager(); + let seenAcquired = waitForStateTransition(wlm, 'acquired'); + let seenAwaitingVisible = waitForStateTransition(wlm, 'awaiting_visible'); + + wlm.acquire(); + await seenAcquired; + expect(wakelockRequest).to.have.been.calledOnce; + + // Fake a visibility change. + documentHidden.value(true); + documentVisibility.value('hidden'); + wakeLockSentinal1.release() + + await seenAwaitingVisible; + seenAcquired = waitForStateTransition(wlm, 'acquired'); + + // Fake a visibility change back + documentHidden.value(false); + documentVisibility.value('visible'); + document.dispatchEvent(new Event('visibilitychange')); + await seenAcquired; + + expect(wakelockRequest).to.have.been.calledTwice; + expect(wakeLockSentinal2.released).to.be.false; + }); + + it('can start hidden', async function() { + let documentHidden = sinon.stub(document, 'hidden'); + let documentVisibility = sinon.stub(document, 'visibilityState'); + afterEach(function() { + documentHidden.restore(); + documentVisibility.restore(); + }); + documentHidden.value(true); + documentVisibility.value('hidden'); + + let wakeLockSentinal = new FakeWakeLockSentinal(); + wakelockRequest.onFirstCall().resolves(wakeLockSentinal); + + let wlm = new WakeLockManager(); + let seenAwaitingVisible = waitForStateTransition(wlm, 'awaiting_visible'); + let seenAcquired = waitForStateTransition(wlm, 'acquired'); + + wlm.acquire(); + await seenAwaitingVisible; + expect(wakelockRequest).to.not.have.been.called; + + // Fake a visibility change. + documentHidden.value(false); + documentVisibility.value('visible'); + document.dispatchEvent(new Event('visibilitychange')); + await seenAcquired; + + expect(wakelockRequest).to.have.been.calledOnce; + expect(wakeLockSentinal.released).to.be.false; + }); + + it('handles acquire errors', async function() { + wakelockRequest.onFirstCall().rejects('WakeLockError'); + let wakeLockSentinal = new FakeWakeLockSentinal(); + wakelockRequest.onSecondCall().resolves(wakeLockSentinal); + + let wlm = new WakeLockManager(); + + let seenError = waitForStateTransition(wlm, 'error'); + wlm.acquire(); + await seenError; + expect(wakelockRequest).to.have.been.calledOnce; + + // Even though we saw an error previously, it will retry when + // requested. + let seenAcquired = waitForStateTransition(wlm, 'acquired'); + wlm.acquire(); + await seenAcquired; + expect(wakelockRequest).to.have.been.calledTwice; + }); +}); diff --git a/vnc.html b/vnc.html index 82cacd580..43e1315be 100644 --- a/vnc.html +++ b/vnc.html @@ -296,6 +296,13 @@

no
VNC

Show dot when no cursor +
  • + +