Merge pull request #1984 from tsukasa-au/add-wakelock-support

Add wakelock support
This commit is contained in:
Samuel Mannehed (ThinLinc team)
2026-01-11 00:33:32 +01:00
committed by GitHub
6 changed files with 429 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import KeyTable from "../core/input/keysym.js";
import keysyms from "../core/input/keysymdef.js";
import Keyboard from "../core/input/keyboard.js";
import RFB from "../core/rfb.js";
import WakeLockManager from './wakelock.js';
import * as WebUtil from "./webutil.js";
const PAGE_TITLE = "noVNC";
@@ -46,6 +47,8 @@ const UI = {
reconnectCallback: null,
reconnectPassword: null,
wakeLockManager: new WakeLockManager(),
async start(options={}) {
UI.customSettings = options.settings || {};
if (UI.customSettings.defaults === undefined) {
@@ -189,6 +192,7 @@ const UI = {
UI.initSetting('repeaterID', '');
UI.initSetting('reconnect', false);
UI.initSetting('reconnect_delay', 5000);
UI.initSetting('keep_device_awake', false);
},
// Adds a link to the label elements on the corresponding input elements
setupSettingLabels() {
@@ -371,6 +375,8 @@ const UI = {
UI.addSettingChangeHandler('view_only', UI.updateViewOnly);
UI.addSettingChangeHandler('show_dot');
UI.addSettingChangeHandler('show_dot', UI.updateShowDotCursor);
UI.addSettingChangeHandler('keep_device_awake');
UI.addSettingChangeHandler('keep_device_awake', UI.updateRequestWakelock);
UI.addSettingChangeHandler('host');
UI.addSettingChangeHandler('port');
UI.addSettingChangeHandler('path');
@@ -1072,6 +1078,10 @@ const UI = {
url.protocol = (window.location.protocol === "https:") ? 'wss:' : 'ws:';
}
if (UI.getSetting('keep_device_awake')) {
UI.wakeLockManager.acquire();
}
try {
UI.rfb = new RFB(document.getElementById('noVNC_container'),
url.href,
@@ -1171,6 +1181,7 @@ const UI = {
UI.connected = false;
UI.rfb = undefined;
UI.wakeLockManager.release();
if (!e.detail.clean) {
UI.updateVisualState('disconnected');
@@ -1819,6 +1830,16 @@ const UI = {
document.title = e.detail.name + " - " + PAGE_TITLE;
},
updateRequestWakelock() {
if (!UI.rfb) return;
if (UI.getSetting('keep_device_awake')) {
UI.wakeLockManager.acquire();
} else {
UI.wakeLockManager.release();
}
},
bell(e) {
if (UI.getSetting('bell') === 'on') {
const promise = document.getElementById('noVNC_bell').play();

199
app/wakelock.js Normal file
View File

@@ -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 '../core/util/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();
}
}

View File

@@ -92,6 +92,10 @@ Currently, the following options are available:
* `logging` - The console log level. Can be one of `error`, `warn`, `info` or
`debug`.
* `keep_device_awake` - 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

View File

@@ -37,6 +37,7 @@ module.exports = (config) => {
{ pattern: 'node_modules/sinon-chai/**', included: false },
// modules to test
{ pattern: 'app/localization.js', included: false, type: 'module' },
{ pattern: 'app/wakelock.js', included: false, type: 'module' },
{ pattern: 'app/webutil.js', included: false, type: 'module' },
{ pattern: 'core/**/*.js', included: false, type: 'module' },
{ pattern: 'vendor/pako/**/*.js', included: false, type: 'module' },

197
tests/test.wakelock.js Normal file
View File

@@ -0,0 +1,197 @@
/* jshint expr: true */
import WakeLockManager from '../app/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) => {
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;
});
});

View File

@@ -296,6 +296,13 @@
Show dot when no cursor
</label>
</li>
<li>
<label>
<input id="noVNC_setting_keep_device_awake" type="checkbox"
class="toggle">
Keep client display awake while connected
</label>
</li>
<li><hr></li>
<!-- Logging selection dropdown -->
<li>