Compare commits
2 Commits
codex/dung
...
codex/dung
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b55a86ba5 | |||
| ae3a3c5def |
@@ -119,6 +119,10 @@ Back up `WEBKA_DATA` securely. On Unix the data directory is owner-only and secr
|
||||
|
||||
Quality presets control the host’s per-peer send bitrate; the viewer shows actual received resolution. Capture is video-only. Closing/navigating away from the host tab stops capture; a browser may also suspend hosting when the device locks or the tab is backgrounded. Screen share support varies, particularly on mobile. Back-camera discovery becomes reliable after granting camera permission. Physical-device tests are documented in `docs/MANUAL_TEST_PLAN.md`.
|
||||
|
||||
Enable **Settings → Host screen → Do not turn off the host** to keep the capturing device's screen awake while hosting. The preference defaults to off and is saved in this browser. The same toggle is on the host screen so you can change it without leaving a live stream. An indicator shows whether the request is active, unavailable, or unsupported; a failed request never stops the stream.
|
||||
|
||||
This uses the browser's [Screen Wake Lock API](https://www.w3.org/TR/screen-wake-lock/). Use HTTPS (or localhost) and leave Webka visible. The lock is released on stop, navigation, logout, or when the page becomes hidden, and is requested again on return if hosting is still active. Power-saving settings can deny or release it; a retry button is provided. It cannot override manual locking, closing the lid, or background suspension, and does not keep the separate server computer awake. Keeping the screen on uses more battery. No scrolling or simulated input is needed.
|
||||
|
||||
Nuxt 3 is retained to match the requested stack. Its upstream documentation now marks it end-of-life; plan a Nuxt 4 upgrade before broader distribution. Reference: https://nuxt.com/docs/3.x/getting-started/installation.
|
||||
|
||||
## Validation
|
||||
@@ -126,6 +130,7 @@ Nuxt 3 is retained to match the requested stack. Its upstream documentation now
|
||||
```sh
|
||||
npm run test:install
|
||||
npm run test:launcher
|
||||
npm run test:wake-lock
|
||||
cargo fmt --manifest-path backend/Cargo.toml --check
|
||||
cargo test --manifest-path backend/Cargo.toml
|
||||
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
|
||||
|
||||
@@ -20,6 +20,14 @@ These are required manual checks, not a claim that physical devices have been te
|
||||
7. Switch bitrate between low/adaptive/high. Inspect browser WebRTC stats: max bitrate changes (actual resolution depends on capture and congestion).
|
||||
8. Stop hosting, navigate to settings, close the tab, and log out separately. Each must stop the host camera and close outgoing peer streams.
|
||||
|
||||
## Keep the host screen awake
|
||||
|
||||
1. In Settings, enable **Do not turn off the host**. Refresh and confirm it remains enabled. Check the label and hint in English, Russian, and Spanish, including keyboard/Space operation of the switch.
|
||||
2. Open the host screen: preview alone must not acquire a lock. Go live over HTTPS with the setting enabled: the status should confirm the screen stays on. Leave the device untouched beyond its normal auto-lock timeout and confirm the screen and stream remain active. Repeat on a phone and computer.
|
||||
3. Toggle the setting off/on directly on the host screen without interrupting video. Stop hosting, navigate away, and sign out separately: the screen lock must be released. The viewer page must never request a lock.
|
||||
4. Background Webka, then return while still hosting: the wake lock should be acquired again if the platform allows it. Manually locking the device may suspend video; the setting does not bypass that.
|
||||
5. Test an unsupported browser, insecure HTTP, denied screen-wake-lock policy, and a battery-saving mode where available. Confirm a translated unavailable/unsupported message and no false active status. If denied, remove the restriction and use the retry button. No automatic retry loop or scrolling should occur.
|
||||
|
||||
## Viewer lifecycle and session revocation
|
||||
|
||||
1. Join before the host is live; automatic playback should start once the host starts. Check fullscreen, language switching while fullscreen, quality/resolution, and disconnect.
|
||||
|
||||
18
frontend/components/KeepHostAwakeSetting.vue
Normal file
18
frontend/components/KeepHostAwakeSetting.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
const enabled = useKeepHostAwakePreference();
|
||||
</script>
|
||||
<template>
|
||||
<label class="setting-row keep-awake-setting">
|
||||
<span>
|
||||
<strong>{{ $t("wake.title") }}</strong>
|
||||
<span class="keep-awake-hint">{{ $t("wake.hint") }}</span>
|
||||
</span>
|
||||
<input v-model="enabled" type="checkbox" role="switch" />
|
||||
</label>
|
||||
</template>
|
||||
<style scoped>
|
||||
.keep-awake-setting { cursor: pointer; flex-direction: row; align-items: center; }
|
||||
.keep-awake-setting strong { font-size: 0.9rem; }
|
||||
.keep-awake-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 0.83rem; }
|
||||
.keep-awake-setting input { width: 24px; height: 24px; min-height: 24px; padding: 0; flex-shrink: 0; accent-color: var(--accent); }
|
||||
</style>
|
||||
38
frontend/composables/useKeepHostAwake.ts
Normal file
38
frontend/composables/useKeepHostAwake.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createScreenWakeLock, type WakeStatus } from "../utils/screenWakeLock";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
export function useKeepHostAwakePreference() {
|
||||
return useCookie<boolean>("webka_keep_host_awake", {
|
||||
default: () => false,
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
export function useHostWakeLock(hosting: Ref<boolean>) {
|
||||
const enabled = useKeepHostAwakePreference();
|
||||
const status = ref<WakeStatus>("off");
|
||||
let controller: ReturnType<typeof createScreenWakeLock> | undefined;
|
||||
let unwatch: (() => void) | undefined;
|
||||
const refresh = () => controller?.refresh();
|
||||
onMounted(() => {
|
||||
controller = createScreenWakeLock({
|
||||
request: window.isSecureContext && navigator.wakeLock?.request
|
||||
? () => navigator.wakeLock.request("screen")
|
||||
: undefined,
|
||||
visible: () => document.visibilityState === "visible",
|
||||
status: (value) => { status.value = value; },
|
||||
});
|
||||
unwatch = watch([enabled, hosting], () => {
|
||||
controller?.update(enabled.value === true && hosting.value);
|
||||
}, { immediate: true, flush: "sync" });
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
unwatch?.();
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
controller?.dispose();
|
||||
});
|
||||
return { status, retry: refresh };
|
||||
}
|
||||
@@ -173,5 +173,17 @@
|
||||
"OverconstrainedError": "This camera source is unavailable. Try the front camera.",
|
||||
"clipboard": "Couldn’t copy automatically. Select and copy the address above.",
|
||||
"fullscreen": "Fullscreen isn’t available in this browser."
|
||||
},
|
||||
"wake": {
|
||||
"section": "Host screen",
|
||||
"title": "Do not turn off the host",
|
||||
"hint": "Keep this device’s screen on while hosting. Leave Webka visible. Saved in this browser; uses more battery.",
|
||||
"off": "Screen keep-awake is off or hosting has not started.",
|
||||
"waiting": "Return to Webka to keep the screen awake.",
|
||||
"requesting": "Requesting screen keep-awake…",
|
||||
"active": "Screen stays on while hosting.",
|
||||
"unavailable": "The browser or power-saving settings released or denied keep-awake. Keep this page visible and try again.",
|
||||
"unsupported": "Screen keep-awake is unavailable here. Use HTTPS and a supported browser, or adjust your device’s auto-lock setting.",
|
||||
"retry": "Try keep-awake again"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,5 +173,17 @@
|
||||
"OverconstrainedError": "Esta fuente no está disponible. Prueba la cámara frontal.",
|
||||
"clipboard": "No se pudo copiar automáticamente. Selecciona y copia la dirección.",
|
||||
"fullscreen": "La pantalla completa no está disponible en este navegador."
|
||||
},
|
||||
"wake": {
|
||||
"section": "Pantalla del anfitrión",
|
||||
"title": "No apagar la pantalla del anfitrión",
|
||||
"hint": "Mantén encendida la pantalla de este dispositivo durante la transmisión. Deja Webka visible. Se guarda en este navegador; consume más batería.",
|
||||
"off": "Mantener la pantalla encendida está desactivado o la transmisión no ha comenzado.",
|
||||
"waiting": "Vuelve a Webka para mantener la pantalla encendida.",
|
||||
"requesting": "Solicitando mantener la pantalla encendida…",
|
||||
"active": "La pantalla permanece encendida durante la transmisión.",
|
||||
"unavailable": "El navegador o el ahorro de energía ha desactivado o rechazado esta función. Mantén la página visible e inténtalo de nuevo.",
|
||||
"unsupported": "Esta función no está disponible aquí. Usa HTTPS y un navegador compatible, o ajusta el bloqueo automático del dispositivo.",
|
||||
"retry": "Volver a intentarlo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,5 +173,17 @@
|
||||
"OverconstrainedError": "Этот источник недоступен. Попробуйте переднюю камеру.",
|
||||
"clipboard": "Не удалось скопировать автоматически. Выделите и скопируйте адрес выше.",
|
||||
"fullscreen": "Полноэкранный режим недоступен в этом браузере."
|
||||
},
|
||||
"wake": {
|
||||
"section": "Экран хоста",
|
||||
"title": "Не выключать экран хоста",
|
||||
"hint": "Не давать экрану этого устройства гаснуть во время трансляции. Оставьте Webka на экране. Сохраняется в этом браузере; расходует больше заряда.",
|
||||
"off": "Удержание экрана выключено или трансляция ещё не началась.",
|
||||
"waiting": "Вернитесь в Webka, чтобы экран не гас.",
|
||||
"requesting": "Запрашиваем удержание экрана…",
|
||||
"active": "Экран не гаснет во время трансляции.",
|
||||
"unavailable": "Браузер или режим энергосбережения отключил или запретил удержание экрана. Оставьте страницу видимой и повторите попытку.",
|
||||
"unsupported": "Удержание экрана недоступно. Используйте HTTPS и поддерживаемый браузер или измените автоблокировку устройства.",
|
||||
"retry": "Повторить удержание экрана"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ const qr = ref(""),
|
||||
const apiError = useApiError();
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
const live = computed(() => state.value === "live");
|
||||
const hosting = computed(() => state.value !== "offline");
|
||||
const { status: wakeStatus, retry: retryWakeLock } = useHostWakeLock(hosting);
|
||||
const elapsed = computed(() => {
|
||||
const seconds = status.value?.since
|
||||
? Math.max(0, Math.floor(tick.value / 1000) - status.value.since)
|
||||
@@ -239,6 +241,14 @@ async function fullscreen() {
|
||||
</div>
|
||||
</section>
|
||||
<aside class="host-aside">
|
||||
<section class="content-panel">
|
||||
<div class="section-heading"><h2>{{ $t("wake.section") }}</h2></div>
|
||||
<KeepHostAwakeSetting />
|
||||
<p class="muted" role="status">{{ $t(`wake.${wakeStatus}`) }}</p>
|
||||
<GlassButton v-if="wakeStatus === 'unavailable'" class="small-button" @click="retryWakeLock">
|
||||
{{ $t("wake.retry") }}
|
||||
</GlassButton>
|
||||
</section>
|
||||
<GlassCard class="share-panel"
|
||||
><div class="section-heading">
|
||||
<h2>{{ $t("host.connectDevice") }}</h2>
|
||||
|
||||
@@ -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 = [
|
||||
<span class="status-dot live" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-panel">
|
||||
<div class="section-heading">
|
||||
<h2><Monitor :size="21" />{{ $t("wake.section") }}</h2>
|
||||
</div>
|
||||
<KeepHostAwakeSetting />
|
||||
</section>
|
||||
<p class="settings-footnote">{{ $t("general.savedLocally") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
122
frontend/scripts/test-wake-lock.mjs
Normal file
122
frontend/scripts/test-wake-lock.mjs
Normal file
@@ -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');
|
||||
});
|
||||
62
frontend/utils/screenWakeLock.ts
Normal file
62
frontend/utils/screenWakeLock.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type WakeStatus = "off" | "waiting" | "requesting" | "active" | "unavailable" | "unsupported";
|
||||
type Sentinel = Pick<WakeLockSentinel, "released" | "release" | "addEventListener">;
|
||||
|
||||
// Browser access is injected so asynchronous acquisition and release can be tested.
|
||||
export function createScreenWakeLock(options: {
|
||||
request?: () => Promise<Sentinel>;
|
||||
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(); },
|
||||
};
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user