+ {{ $t("wake.section") }}
+
+ {{ $t(`wake.${wakeStatus}`) }}
+
+ {{ $t("wake.retry") }}
+
+
{{ $t("host.connectDevice") }}
diff --git a/frontend/pages/settings/general.vue b/frontend/pages/settings/general.vue
index 9a42310..72814f9 100644
--- a/frontend/pages/settings/general.vue
+++ b/frontend/pages/settings/general.vue
@@ -6,6 +6,7 @@ import {
Globe2,
Palette,
ShieldCheck,
+ Monitor,
} from "lucide-vue-next";
const { locale, setLocale, t } = useI18n();
useHead({ title: computed(() => `${t("general.title")} · Webka`) });
@@ -113,6 +114,12 @@ const colors = [
+
+
+
{{ $t("wake.section") }}
+
+
+
diff --git a/frontend/scripts/test-wake-lock.mjs b/frontend/scripts/test-wake-lock.mjs
new file mode 100644
index 0000000..caf576c
--- /dev/null
+++ b/frontend/scripts/test-wake-lock.mjs
@@ -0,0 +1,122 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createScreenWakeLock } from '../utils/screenWakeLock.ts';
+
+const settle = () => new Promise(resolve => setImmediate(resolve));
+class Lock extends EventTarget {
+ released = false;
+ async release() {
+ this.released = true;
+ this.dispatchEvent(new Event('release'));
+ }
+}
+function fixture(request) {
+ const locks = [];
+ const state = { visible: true, status: undefined, calls: 0 };
+ const controller = createScreenWakeLock({
+ visible: () => state.visible,
+ status: value => { state.status = value; },
+ request: request === null ? undefined : async () => {
+ state.calls++;
+ if (request) return request();
+ const lock = new Lock();
+ locks.push(lock);
+ return lock;
+ },
+ });
+ return { controller, locks, state };
+}
+test('only holds a lock when enabled and hosting; stop releases it', async () => {
+ const { controller, locks, state } = fixture();
+ controller.update(false);
+ assert.equal(state.calls, 0);
+ controller.update(true);
+ controller.refresh();
+ await settle();
+ assert.equal(state.calls, 1);
+ assert.equal(state.status, 'active');
+ controller.update(false);
+ assert.equal(locks[0].released, true);
+ assert.equal(state.status, 'off');
+});
+test('releases when hidden and reacquires on return while still hosting', async () => {
+ const { controller, locks, state } = fixture();
+ controller.update(true);
+ await settle();
+ state.visible = false;
+ controller.refresh();
+ assert.equal(locks[0].released, true);
+ assert.equal(state.status, 'waiting');
+ state.visible = true;
+ controller.refresh();
+ await settle();
+ assert.equal(state.calls, 2);
+ assert.equal(state.status, 'active');
+ controller.dispose();
+ assert.equal(locks[1].released, true);
+});
+for (const action of ['stop', 'dispose']) {
+ test(`late acquisition is released after ${action}`, async () => {
+ let resolve;
+ const lock = new Lock();
+ const { controller, state } = fixture(() => new Promise(done => { resolve = done; }));
+ controller.update(true);
+ if (action === 'stop') controller.update(false); else controller.dispose();
+ resolve(lock);
+ await settle();
+ assert.equal(lock.released, true);
+ assert.equal(state.calls, 1);
+ assert.notEqual(state.status, 'active');
+ });
+}
+test('off/on during acquisition discards stale lock and acquires only one new lock', async () => {
+ const resolvers = [];
+ const { controller, state } = fixture(() => new Promise(done => resolvers.push(done)));
+ controller.update(true);
+ controller.update(false);
+ controller.update(true);
+ const old = new Lock();
+ resolvers.shift()(old);
+ await settle();
+ assert.equal(old.released, true);
+ assert.equal(state.calls, 2);
+ const current = new Lock();
+ resolvers.shift()(current);
+ await settle();
+ assert.equal(state.status, 'active');
+ controller.dispose();
+ assert.equal(current.released, true);
+});
+test('denial leaves streaming alone and can be retried without an automatic loop', async () => {
+ let denied = true;
+ const { controller, state } = fixture(async () => {
+ if (denied) throw new Error('NotAllowedError');
+ return new Lock();
+ });
+ controller.update(true);
+ await settle();
+ assert.equal(state.status, 'unavailable');
+ assert.equal(state.calls, 1);
+ denied = false;
+ controller.refresh();
+ await settle();
+ assert.equal(state.status, 'active');
+ controller.dispose();
+});
+test('system revocation reports loss without repeatedly requesting another lock', async () => {
+ const { controller, state, locks } = fixture();
+ controller.update(true);
+ await settle();
+ await locks[0].release();
+ await settle();
+ assert.equal(state.status, 'unavailable');
+ assert.equal(state.calls, 1);
+ controller.dispose();
+});
+test('unsupported browser is reported without throwing', () => {
+ const { controller, state } = fixture(null);
+ controller.update(true);
+ assert.equal(state.status, 'unsupported');
+ controller.update(false);
+ assert.equal(state.status, 'off');
+});
diff --git a/frontend/utils/screenWakeLock.ts b/frontend/utils/screenWakeLock.ts
new file mode 100644
index 0000000..cf9dcec
--- /dev/null
+++ b/frontend/utils/screenWakeLock.ts
@@ -0,0 +1,62 @@
+export type WakeStatus = "off" | "waiting" | "requesting" | "active" | "unavailable" | "unsupported";
+type Sentinel = Pick;
+
+// Browser access is injected so asynchronous acquisition and release can be tested.
+export function createScreenWakeLock(options: {
+ request?: () => Promise;
+ visible: () => boolean;
+ status: (value: WakeStatus) => void;
+}) {
+ let desired = false;
+ let disposed = false;
+ let pending = false;
+ let generation = 0;
+ let lock: Sentinel | null = null;
+ const report = (value: WakeStatus) => { if (!disposed) options.status(value); };
+ const wanted = () => desired && !disposed && options.visible();
+ function release() {
+ generation++;
+ const old = lock;
+ lock = null;
+ if (old) void old.release().catch(() => {});
+ }
+ async function sync() {
+ if (!wanted()) {
+ release();
+ report(desired ? "waiting" : "off");
+ return;
+ }
+ if (!options.request) { report("unsupported"); return; }
+ if (lock && !lock.released) { report("active"); return; }
+ if (pending) return;
+ pending = true;
+ const version = generation;
+ report("requesting");
+ try {
+ const acquired = await options.request();
+ if (version !== generation || !wanted()) {
+ await acquired.release();
+ return;
+ }
+ lock = acquired;
+ acquired.addEventListener("release", () => {
+ if (lock !== acquired) return;
+ lock = null;
+ // Do not repeatedly fight a system/battery decision to revoke the lock.
+ report(wanted() ? "unavailable" : desired ? "waiting" : "off");
+ }, { once: true });
+ report(acquired.released ? "unavailable" : "active");
+ } catch {
+ if (version === generation && wanted()) report("unavailable");
+ } finally {
+ pending = false;
+ // An off/on or hidden/visible transition may have invalidated a pending request.
+ if (version !== generation && wanted()) void sync();
+ }
+ }
+ return {
+ update(enabled: boolean) { desired = enabled; void sync(); },
+ refresh() { void sync(); },
+ dispose() { disposed = true; desired = false; release(); },
+ };
+}
diff --git a/package.json b/package.json
index 4cf73c1..7c6c01a 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"dev:all": "node scripts/dev-all.mjs",
"start": "node scripts/dev-all.mjs",
"test:launcher": "node --test scripts/dev-all.test.mjs",
+ "test:wake-lock": "node --experimental-strip-types --test frontend/scripts/test-wake-lock.mjs",
"test:install": "node scripts/test-install.mjs"
},
"engines": {