Compare commits
9 Commits
codex/dung
...
8f8687047b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f8687047b | |||
| 1b55a86ba5 | |||
| ae3a3c5def | |||
| 75e8fec4fe | |||
| 33dbf37ae3 | |||
| 223b43de97 | |||
| d9560f60dc | |||
| 2f9f55d600 | |||
| 26e6b2247e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,3 +7,5 @@ data/
|
||||
.env
|
||||
*.log
|
||||
frontend/dist
|
||||
.webka-cache.json
|
||||
webka.config.json
|
||||
|
||||
87
README.md
87
README.md
@@ -2,31 +2,80 @@
|
||||
|
||||
A private camera and screen streaming app for your local network. Nuxt 3 / Vue 3 / TypeScript, Pinia, English/Russian/Spanish, and an Axum/Rust service with SQLite and browser-to-browser WebRTC.
|
||||
|
||||
## Run locally
|
||||
## One-command start
|
||||
|
||||
Requirements: Node **22.12+**, npm, stable Rust, and a browser with WebRTC. Run these commands from the repository root:
|
||||
Install Node **22.12+** (npm included) and stable Rust/Cargo once. Then, from the repository root:
|
||||
|
||||
```sh
|
||||
npm --prefix frontend ci
|
||||
npm --prefix frontend run build
|
||||
WEBKA_BIND=127.0.0.1:3000 cargo run --manifest-path backend/Cargo.toml
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
Open **http://localhost:3000**. The first login page creates the administrator account. Read the one-time setup token from `data/setup-token` on the server device and enter it in the form. The administrator account cannot be deleted. Password resets end all of the account’s sessions, including your own if you reset the administrator password.
|
||||
|
||||
For development, run the Rust backend on its default `127.0.0.1:3001` and the frontend in another terminal:
|
||||
This starts **http://127.0.0.1:3000**. To listen on all network interfaces at port 3002:
|
||||
|
||||
```sh
|
||||
npm --prefix frontend run dev
|
||||
npm run dev:all -- --host 0.0.0.0 --port 3002
|
||||
```
|
||||
|
||||
Use `http://localhost:3000` (not `127.0.0.1:3000`); the configured origin must match the browser URL exactly. The development proxy forwards REST and WebSocket traffic to Rust.
|
||||
The `--` after `dev:all` passes the options to the launcher. Use ordinary double hyphens. `npm start -- --host 0.0.0.0 --port 3002` is an alias. `--help` shows usage.
|
||||
|
||||
The launcher installs frontend dependencies automatically on the first run, builds Nuxt, builds Rust, and runs one server serving both the frontend and API on the chosen port. First startup needs internet and can take several minutes. Subsequent runs reuse unchanged dependencies/frontend output; Cargo builds incrementally. Restart the command after code changes to rebuild. This convenience command does not provide hot reload.
|
||||
|
||||
The terminal prints the local URL, LAN URLs when available, and the setup-token file location. Open the local URL on the server computer for camera capture; use a printed LAN URL on other devices to watch. The QR code uses the detected LAN address when listening on all interfaces. Local and detected private IPv4 origins are explicitly allowed; arbitrary origins remain blocked. If the selected port is busy, startup stops with an actionable message.
|
||||
|
||||
Press **Ctrl+C** to stop the app and any build subprocesses. Accounts remain in `data/`. The first login page creates the administrator account using the token from `data/setup-token`. The administrator cannot be deleted; resetting its password ends its sessions.
|
||||
|
||||
For an existing HTTPS proxy, use the homepage setting below. Browser capture on another LAN device still requires a trusted HTTPS address.
|
||||
|
||||
For frontend hot reload, the separate development workflow remains available: run `cargo run --manifest-path backend/Cargo.toml` and `npm --prefix frontend run dev` in separate terminals, then open `http://localhost:3000`.
|
||||
|
||||
## Existing Nginx Proxy Manager on another machine
|
||||
|
||||
Set just the address you want to open in a browser (the address served by NPM):
|
||||
|
||||
```sh
|
||||
npm run dev:all -- --homepage https://camera.example.com --port 3002
|
||||
```
|
||||
|
||||
Replace `https://camera.example.com` with your actual HTTPS domain or IP, including a public port if needed. A bare hostname/IP is treated as HTTPS. A trailing slash is accepted; hosting under a subpath is not supported.
|
||||
|
||||
This mode automatically:
|
||||
|
||||
- Listens on `0.0.0.0` so the other machine can reach Webka (unless you explicitly set `--host`).
|
||||
- Serves the frontend and API over **HTTP** on the chosen internal port; your existing NPM handles HTTPS.
|
||||
- Uses the homepage for QR/share links, secure cookies, and allowed request origins.
|
||||
- Disables mDNS, even if `WEBKA_MDNS=1` was previously set. No Caddy, `.local` hostname, or certificates on the Webka computer are needed.
|
||||
|
||||
In **Nginx Proxy Manager → Proxy Hosts**, configure:
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| Domain Names | Your homepage hostname, e.g. `camera.example.com` |
|
||||
| Scheme | `http` |
|
||||
| Forward Hostname / IP | The LAN IP of the **Webka computer**, e.g. `192.168.1.50` |
|
||||
| Forward Port | `3002` (or your `--port`) |
|
||||
| Websockets Support | Enabled |
|
||||
| SSL | Select your existing trusted certificate; enable Force SSL |
|
||||
|
||||
Open the homepage HTTPS address on both the host and viewer. Its hostname must resolve to the **NPM machine**, and NPM must be able to reach the Webka machine's LAN IP/port. The homepage is the browser-facing address, not the internal upstream address. For an HTTPS IP address, the certificate must cover that IP and NPM must route it to Webka. See the [official NPM guide](https://nginxproxymanager.com/guide/) and [WebSocket proxy support](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/develop/backend/templates/proxy_host.conf).
|
||||
|
||||
To save the address, create `webka.config.json` in the repository root (or copy `webka.config.example.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"homepage": "https://camera.example.com",
|
||||
"port": 3002
|
||||
}
|
||||
```
|
||||
|
||||
Then start with only `npm run dev:all`. `homepage` alone is sufficient if the internal port is the default `3000`. Optional `host` and `port` may also be saved; command-line options override the file. Restart to apply changes. This machine-specific file is ignored by Git. Homepage mode takes priority over legacy `WEBKA_ORIGIN`, `WEBKA_ALLOWED_ORIGINS`, and `WEBKA_MDNS` values.
|
||||
|
||||
Legacy manual proxy setup via `WEBKA_ORIGIN` and `WEBKA_ALLOWED_ORIGINS` remains supported when no homepage is configured. The launcher does not configure NPM or provision its certificates. The proxy carries page/API/signaling traffic; WebRTC media remains peer-to-peer on the LAN.
|
||||
|
||||
## Use on your LAN
|
||||
|
||||
A webpage **cannot launch a Rust server** on the device running its browser. Start the Rust service once on a computer on your LAN. A phone, tablet, or computer can then sign in as the administrator and act as the capturing host. This repository does not include a native iOS/Android service wrapper. Each service instance owns its account database.
|
||||
|
||||
Browser camera/screen APIs require a **secure context**: localhost on the service machine, or trusted HTTPS on a LAN device. LAN HTTP IP addresses cannot capture camera/screen. For LAN use, use the included `Caddyfile.example` with a trusted local certificate:
|
||||
Browser camera/screen APIs require a **secure context**: localhost on the service machine, or trusted HTTPS on a LAN device. LAN HTTP IP addresses cannot capture camera/screen. If you already have an HTTPS proxy, use the homepage mode above. If you do not, the included `Caddyfile.example` is an optional alternative with a trusted local certificate:
|
||||
|
||||
1. Point `webka.local` at the service computer, through mDNS (`WEBKA_MDNS=1`), your router, or a hosts entry. `.local` resolution and multicast support vary by network/device; a local DNS name or IP with a matching trusted certificate also works.
|
||||
2. Configure Caddy with the example below, and install/trust Caddy’s local CA on **every** client device. Keep this CA private.
|
||||
@@ -40,7 +89,7 @@ Browser camera/screen APIs require a **secure context**: localhost on the servic
|
||||
|
||||
4. Open `https://webka.local`, create the admin account, add viewer accounts in **Accounts**, enable preview, and choose **Go live**. Share the QR code/local address. Each viewer signs in with an account created on this service.
|
||||
|
||||
Caddy terminates HTTPS and proxies to the loopback Rust server. Cookies gain `Secure` automatically when `WEBKA_ORIGIN` starts with `https://`. WebSocket handshakes and all mutations validate the exact Origin. No CORS wildcard or trusted forwarded-IP headers are used. The login throttle sees the reverse proxy as one IP, so its ten-attempts-per-minute limit is shared behind that proxy.
|
||||
Caddy terminates HTTPS and proxies to the loopback Rust server. Cookies gain `Secure` automatically when `WEBKA_ORIGIN` starts with `https://`. WebSocket handshakes and all mutations validate an explicitly configured Origin. No CORS wildcard or trusted forwarded-IP headers are used. The login throttle sees the reverse proxy as one IP, so its ten-attempts-per-minute limit is shared behind that proxy.
|
||||
|
||||
Use a private LAN and firewall; do not expose the service through public port forwarding. The app uses **no cloud accounts, public STUN/TURN, telemetry, CDNs, or external fonts**. WebRTC connects peers directly with local ICE candidates. Client isolation, VLANs, and firewalls that block peer UDP can prevent streams; no cloud relay fallback is provided. The one active host sends a separate peer stream to each viewer (32 signaling connections total, including the host). Capacity depends on the host device and Wi-Fi.
|
||||
|
||||
@@ -50,6 +99,7 @@ Use a private LAN and firewall; do not expose the service through public port fo
|
||||
|---|---|---|
|
||||
| `WEBKA_BIND` | `127.0.0.1:3001` | Rust listener |
|
||||
| `WEBKA_ORIGIN` | `http://localhost:3000` | Exact public origin; used for CSRF checks, QR and secure cookies |
|
||||
| `WEBKA_ALLOWED_ORIGINS` | none | Additional trusted exact origins; launcher configures local/LAN aliases |
|
||||
| `WEBKA_DATA` | `data` | Local database and persistent secrets |
|
||||
| `WEBKA_ASSETS` | `frontend/.output/public` | Generated frontend; paths are relative to the current directory |
|
||||
| `WEBKA_MDNS` | off | Set `1` to advertise `_webka._tcp.local.` and `webka.local` |
|
||||
@@ -69,11 +119,18 @@ 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
|
||||
|
||||
```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
|
||||
@@ -82,6 +139,12 @@ npm --prefix frontend run build
|
||||
node frontend/scripts/check-locales.mjs
|
||||
cargo build --manifest-path backend/Cargo.toml
|
||||
python3 scripts/smoke.py
|
||||
# Optional POSIX integration test: reserves port 3002, tests startup/restart/stop.
|
||||
python3 scripts/test-launcher.py
|
||||
# Optional POSIX HTTPS proxy integration test (requires OpenSSL).
|
||||
python3 scripts/test-proxy.py
|
||||
```
|
||||
|
||||
`test:install` checks the frontend lockfile from a temporary folder without `node_modules`. When updating dependencies, run it with both npm 10 (bundled with Node 22) and npm 11; root `npm install` only installs the launcher package and does not repair the frontend lockfile.
|
||||
|
||||
See `docs/API.md` for REST/signaling contracts and `docs/MANUAL_TEST_PLAN.md` for real-device checks. Design references: [Apple’s introduction](https://www.apple.com/uk/newsroom/2025/06/apple-introduces-a-delightful-and-elegant-new-software-design/) and [Meet Liquid Glass](https://developer.apple.com/videos/play/wwdc2025/219/). No Apple assets are included. Nothing is published by the build commands.
|
||||
|
||||
@@ -71,6 +71,7 @@ struct App {
|
||||
key: Arc<Vec<u8>>,
|
||||
setup: Arc<String>,
|
||||
origin: Arc<String>,
|
||||
allowed_origins: Arc<Vec<String>>,
|
||||
secure: bool,
|
||||
peers: Arc<Mutex<Peers>>,
|
||||
limits: Arc<Mutex<HashMap<String, (i64, u32)>>>,
|
||||
@@ -232,11 +233,14 @@ async fn guard(State(app): State<App>, req: Request, next: Next) -> Response {
|
||||
axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS
|
||||
);
|
||||
if (mutation || req.uri().path() == "/api/signal")
|
||||
&& req
|
||||
&& !req
|
||||
.headers()
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
!= Some(app.origin.as_str())
|
||||
.is_some_and(|origin| {
|
||||
origin == app.origin.as_str()
|
||||
|| app.allowed_origins.iter().any(|allowed| allowed == origin)
|
||||
})
|
||||
{
|
||||
return ApiError(StatusCode::FORBIDDEN, "origin").into_response();
|
||||
}
|
||||
@@ -701,6 +705,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
key: Arc::new(key.into_bytes()),
|
||||
setup: Arc::new(setup),
|
||||
secure: origin.starts_with("https://"),
|
||||
allowed_origins: Arc::new(
|
||||
std::env::var("WEBKA_ALLOWED_ORIGINS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
),
|
||||
origin: Arc::new(origin),
|
||||
peers: Default::default(),
|
||||
limits: Default::default(),
|
||||
|
||||
@@ -14,6 +14,7 @@ async fn app() -> (App, tempfile::TempDir) {
|
||||
key: Arc::new(vec![42; 32]),
|
||||
setup: Arc::new("setup-test".into()),
|
||||
origin: Arc::new("http://localhost:3000".into()),
|
||||
allowed_origins: Arc::new(vec![]),
|
||||
secure: false,
|
||||
peers: Default::default(),
|
||||
limits: Default::default(),
|
||||
@@ -556,3 +557,36 @@ async fn saturated_signaling_queue_fails_closed() {
|
||||
send(&client, json!({"type":"viewer-left"}));
|
||||
assert!(*rx.borrow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launcher_origins_are_explicit_and_port_specific() {
|
||||
let (mut app, _dir) = app().await;
|
||||
app.allowed_origins = Arc::new(vec![
|
||||
"http://127.0.0.1:3002".into(),
|
||||
"http://192.168.1.23:3002".into(),
|
||||
]);
|
||||
for (origin, expected) in [
|
||||
("http://127.0.0.1:3002", StatusCode::UNAUTHORIZED),
|
||||
("http://192.168.1.23:3002", StatusCode::UNAUTHORIZED),
|
||||
("http://localhost:3000", StatusCode::UNAUTHORIZED),
|
||||
("http://192.168.1.23:3003", StatusCode::FORBIDDEN),
|
||||
("http://evil.example", StatusCode::FORBIDDEN),
|
||||
("null", StatusCode::FORBIDDEN),
|
||||
] {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/logout")
|
||||
.header("origin", origin)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router(app.clone(), "/none".into())
|
||||
.oneshot(req)
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
expected,
|
||||
"{origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# API and transport
|
||||
|
||||
All API paths are same-origin. Mutations and WebSocket upgrades require an `Origin` matching `WEBKA_ORIGIN` exactly. JSON requests use `Content-Type: application/json`. Authentication is the signed `webka_session` cookie. Error responses are `{ "error": "translationKey" }`; framework validation errors are shown as a translated generic error by the frontend.
|
||||
All API paths are same-origin. Mutations and WebSocket upgrades require an `Origin` matching `WEBKA_ORIGIN` or an explicit `WEBKA_ALLOWED_ORIGINS` entry exactly. JSON requests use `Content-Type: application/json`. Authentication is the signed `webka_session` cookie. Error responses are `{ "error": "translationKey" }`; framework validation errors are shown as a translated generic error by the frontend.
|
||||
|
||||
| Method | Path | Access | Result |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -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": "Повторить удержание экрана"
|
||||
}
|
||||
}
|
||||
|
||||
585
frontend/package-lock.json
generated
585
frontend/package-lock.json
generated
@@ -1737,9 +1737,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1928,6 +1925,293 @@
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-android-arm64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.148.0.tgz",
|
||||
"integrity": "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-darwin-arm64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.148.0.tgz",
|
||||
"integrity": "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-darwin-x64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.148.0.tgz",
|
||||
"integrity": "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-freebsd-x64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.148.0.tgz",
|
||||
"integrity": "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.148.0.tgz",
|
||||
"integrity": "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.148.0.tgz",
|
||||
"integrity": "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-linux-arm64-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-linux-arm64-musl": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.148.0.tgz",
|
||||
"integrity": "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-linux-s390x-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-linux-x64-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-linux-x64-musl": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.148.0.tgz",
|
||||
"integrity": "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-win32-arm64-msvc": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.148.0.tgz",
|
||||
"integrity": "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-parser/binding-win32-x64-msvc": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.148.0.tgz",
|
||||
"integrity": "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/@oxc-project/types": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
|
||||
"integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/oxc-parser": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.148.0.tgz",
|
||||
"integrity": "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.148.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxc-parser/binding-android-arm-eabi": "0.148.0",
|
||||
"@oxc-parser/binding-android-arm64": "0.148.0",
|
||||
"@oxc-parser/binding-darwin-arm64": "0.148.0",
|
||||
"@oxc-parser/binding-darwin-x64": "0.148.0",
|
||||
"@oxc-parser/binding-freebsd-x64": "0.148.0",
|
||||
"@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0",
|
||||
"@oxc-parser/binding-linux-arm-musleabihf": "0.148.0",
|
||||
"@oxc-parser/binding-linux-arm64-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-arm64-musl": "0.148.0",
|
||||
"@oxc-parser/binding-linux-ppc64-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-riscv64-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-riscv64-musl": "0.148.0",
|
||||
"@oxc-parser/binding-linux-s390x-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-x64-gnu": "0.148.0",
|
||||
"@oxc-parser/binding-linux-x64-musl": "0.148.0",
|
||||
"@oxc-parser/binding-openharmony-arm64": "0.148.0",
|
||||
"@oxc-parser/binding-win32-arm64-msvc": "0.148.0",
|
||||
"@oxc-parser/binding-win32-ia32-msvc": "0.148.0",
|
||||
"@oxc-parser/binding-win32-x64-msvc": "0.148.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/unctx": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/unctx/-/unctx-3.0.1.tgz",
|
||||
@@ -1954,6 +2238,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/unplugin": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz",
|
||||
"integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.4",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@farmfe/core": "*",
|
||||
"@rspack/core": "*",
|
||||
"bun-types-no-globals": "*",
|
||||
"esbuild": "*",
|
||||
"rolldown": "*",
|
||||
"rollup": "*",
|
||||
"unloader": "*",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@farmfe/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@rspack/core": {
|
||||
"optional": true
|
||||
},
|
||||
"bun-types-no-globals": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rolldown": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"unloader": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/nitro-server": {
|
||||
"version": "3.21.11",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/nitro-server/-/nitro-server-3.21.11.tgz",
|
||||
@@ -2323,9 +2663,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2342,9 +2679,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2361,9 +2695,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2380,9 +2711,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2399,9 +2727,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2418,9 +2743,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2437,9 +2759,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2456,9 +2775,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2532,6 +2848,23 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm-eabi": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.148.0.tgz",
|
||||
"integrity": "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm64": {
|
||||
"version": "0.93.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.93.0.tgz",
|
||||
@@ -2635,9 +2968,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2654,14 +2984,28 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.148.0.tgz",
|
||||
"integrity": "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -2673,14 +3017,28 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.148.0.tgz",
|
||||
"integrity": "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -2692,9 +3050,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2711,9 +3066,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2730,9 +3082,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2742,6 +3091,23 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-openharmony-arm64": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.148.0.tgz",
|
||||
"integrity": "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-wasm32-wasi": {
|
||||
"version": "0.93.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.93.0.tgz",
|
||||
@@ -2774,6 +3140,23 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.148.0.tgz",
|
||||
"integrity": "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
|
||||
"version": "0.93.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.93.0.tgz",
|
||||
@@ -2918,9 +3301,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2937,9 +3317,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2956,9 +3333,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2975,9 +3349,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2994,9 +3365,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3013,9 +3381,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3032,9 +3397,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3051,9 +3413,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3513,9 +3872,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3529,9 +3885,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3545,9 +3898,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3561,9 +3911,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3577,9 +3924,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3593,9 +3937,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3609,9 +3950,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3625,9 +3963,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3641,9 +3976,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3657,9 +3989,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3673,9 +4002,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3689,9 +4015,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3705,9 +4028,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8861,9 +9181,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8880,9 +9197,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8899,9 +9213,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8918,9 +9229,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8937,9 +9245,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8956,9 +9261,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9016,6 +9318,17 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nuxt/node_modules/@oxc-project/types": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
|
||||
"integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/nuxt/node_modules/@oxc-transform/binding-android-arm64": {
|
||||
"version": "0.143.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm64/-/binding-android-arm64-0.143.0.tgz",
|
||||
@@ -9119,9 +9432,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9138,9 +9448,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9157,9 +9464,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9176,9 +9480,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9195,9 +9496,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9214,9 +9512,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9408,9 +9703,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9427,9 +9719,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "webka",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
204
scripts/dev-all.mjs
Normal file
204
scripts/dev-all.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, isIP } from 'node:net';
|
||||
import { networkInterfaces } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function readSettings(path = join(root, 'webka.config.json')) {
|
||||
if (!existsSync(path)) return {};
|
||||
let settings;
|
||||
try { settings = JSON.parse(readFileSync(path, 'utf8')); }
|
||||
catch { throw new Error('webka.config.json must contain valid JSON.'); }
|
||||
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('webka.config.json must contain an object.');
|
||||
for (const key of Object.keys(settings)) if (!['homepage', 'host', 'port'].includes(key)) throw new Error(`Unknown webka.config.json setting: ${key}`);
|
||||
return settings;
|
||||
}
|
||||
export function normalizeHomepage(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new Error('Homepage must be an HTTPS address, for example https://camera.example.com.');
|
||||
let parsed;
|
||||
try { parsed = new URL(value.includes('://') ? value.trim() : `https://${value.trim()}`); }
|
||||
catch { throw new Error('Homepage must be a valid HTTPS hostname or IP address.'); }
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash || ['0.0.0.0', '[::]'].includes(parsed.hostname)) throw new Error('Homepage must be an HTTPS address without a path, credentials, or query (use the address served by your proxy).');
|
||||
return parsed.origin;
|
||||
}
|
||||
export function parseArgs(args, settings = {}) {
|
||||
let host = settings.host, port = String(settings.port ?? '3000'), homepage = settings.homepage;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--help' || arg === '-h') return { help: true };
|
||||
if (arg === '--host' || arg === '--port' || arg === '--homepage') {
|
||||
if (!args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`${arg} needs a value.`);
|
||||
if (arg === '--host') host = args[++i]; else if (arg === '--port') port = args[++i]; else homepage = args[++i];
|
||||
} else if (arg.startsWith('--host=')) host = arg.slice(7);
|
||||
else if (arg.startsWith('--port=')) port = arg.slice(7);
|
||||
else if (arg.startsWith('--homepage=')) homepage = arg.slice(11);
|
||||
else throw new Error(`Unknown argument: ${arg}. Use --host, --port, or --homepage (two ordinary hyphens).`);
|
||||
}
|
||||
if (homepage !== undefined) homepage = normalizeHomepage(homepage);
|
||||
host ??= homepage ? '0.0.0.0' : '127.0.0.1';
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (typeof host !== 'string' || !isIP(host)) throw new Error('--host must be an IPv4/IPv6 address or localhost.');
|
||||
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('--port must be between 1 and 65535.');
|
||||
return { host, port: Number(port), ...(homepage ? { homepage } : {}) };
|
||||
}
|
||||
const hostname = host => isIP(host) === 6 ? `[${host}]` : host;
|
||||
const url = (host, port) => `http://${hostname(host)}:${port}`;
|
||||
function privateIPv4(ip) {
|
||||
const parts = ip.split('.').map(Number);
|
||||
return isIP(ip) === 4 && (parts[0] === 10 || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31));
|
||||
}
|
||||
export function addresses({ host, port, homepage }, interfaces = networkInterfaces(), env = process.env) {
|
||||
const wildcard = host === '0.0.0.0' || host === '::';
|
||||
const local = url(wildcard ? (host === '::' ? '::1' : '127.0.0.1') : host, port);
|
||||
const loopback = host === '127.0.0.1' || host === '::1' || wildcard;
|
||||
const lan = wildcard ? [...new Set(Object.values(interfaces).flat().filter(item => item && !item.internal && item.family === 'IPv4' && privateIPv4(item.address)).map(item => url(item.address, port)))] : [];
|
||||
const origin = homepage || env.WEBKA_ORIGIN || lan[0] || (loopback ? `http://localhost:${port}` : local);
|
||||
const automatic = [origin, local, ...(loopback ? [`http://localhost:${port}`] : []), ...lan];
|
||||
// Explicit HTTPS/reverse-proxy setups retain their exact configured origin.
|
||||
const allowed = homepage ? [origin] : env.WEBKA_ALLOWED_ORIGINS !== undefined ? env.WEBKA_ALLOWED_ORIGINS.split(',').filter(Boolean) : env.WEBKA_ORIGIN ? [origin] : automatic;
|
||||
for (const value of [origin, ...allowed]) {
|
||||
const parsed = new URL(value);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== value || parsed.username || parsed.password) throw new Error(`Use an exact http(s) origin without a path: ${value}`);
|
||||
}
|
||||
return { local, lan, origin, allowed: [...new Set(allowed)], proxy: !!homepage };
|
||||
}
|
||||
|
||||
export function connectionEnv(config, urls, env = process.env) {
|
||||
return {
|
||||
WEBKA_BIND: `${hostname(config.host)}:${config.port}`,
|
||||
WEBKA_ORIGIN: urls.origin,
|
||||
WEBKA_ALLOWED_ORIGINS: urls.allowed.join(','),
|
||||
WEBKA_PUBLIC_PORT: String(new URL(urls.origin).port || (urls.origin.startsWith('https:') ? 443 : 80)),
|
||||
WEBKA_MDNS: urls.proxy ? '0' : (env.WEBKA_MDNS || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
function fingerprint(paths) {
|
||||
const hash = createHash('sha256');
|
||||
const ignored = new Set(['node_modules', '.nuxt', '.output', 'dist', '.git']);
|
||||
function visit(path) {
|
||||
for (const entry of readdirSync(path, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) {
|
||||
if (ignored.has(entry.name)) continue;
|
||||
const file = join(path, entry.name);
|
||||
if (entry.isDirectory()) visit(file);
|
||||
else if (entry.isFile()) hash.update(file.slice(root.length)).update(readFileSync(file));
|
||||
}
|
||||
}
|
||||
for (const path of paths) visit(path);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
function assertPort(host, port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', e => reject(new Error(e.code === 'EADDRINUSE' ? `Port ${port} is already in use. Choose another --port or stop the existing app.` : e.message)));
|
||||
server.listen({ host, port }, () => server.close(resolve));
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(args = process.argv.slice(2)) {
|
||||
const config = parseArgs(args, args.includes('--help') || args.includes('-h') ? {} : readSettings());
|
||||
if (config.help) {
|
||||
console.log('Usage: npm run dev:all -- [--host 127.0.0.1] [--port 3000] [--homepage https://camera.example.com]\nA homepage enables external HTTPS proxy mode, defaults --host to 0.0.0.0, and disables mDNS.\nOptional webka.config.json saves homepage, host, and port. CLI options override saved settings.\nBuilds and runs the frontend + backend together. Ctrl+C stops the app.');
|
||||
return;
|
||||
}
|
||||
const [major, minor] = process.versions.node.split('.').map(Number);
|
||||
if (major < 22 || (major === 22 && minor < 12)) throw new Error('Webka requires Node.js 22.12 or newer.');
|
||||
if (spawnSync('cargo', ['--version'], { stdio: 'ignore' }).status !== 0) throw new Error('Rust/Cargo is missing. Install stable Rust, then run this command again.');
|
||||
if (!process.env.npm_execpath) throw new Error('Start with npm run dev:all so the launcher can install frontend dependencies.');
|
||||
const urls = addresses(config);
|
||||
await assertPort(config.host, config.port);
|
||||
let child = null, stopping = false, killTimer;
|
||||
function stop() {
|
||||
stopping = true;
|
||||
if (child && child.exitCode === null) {
|
||||
// On Unix, kill only this launcher's process group, including build subprocesses.
|
||||
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
else {
|
||||
try { process.kill(-child.pid, 'SIGINT'); } catch {}
|
||||
const pid = child.pid;
|
||||
killTimer = setTimeout(() => { try { process.kill(-pid, 'SIGKILL'); } catch {} }, 5000);
|
||||
killTimer.unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
||||
function run(command, args, env = process.env) {
|
||||
if (stopping) throw new Error('Startup cancelled.');
|
||||
const proc = spawn(command, args, { cwd: root, env, stdio: 'inherit', detached: process.platform !== 'win32' });
|
||||
child = proc;
|
||||
const completion = new Promise((resolve, reject) => {
|
||||
proc.once('error', reject);
|
||||
proc.once('exit', (code, signal) => {
|
||||
clearTimeout(killTimer); if (child === proc) child = null;
|
||||
if (code === 0 || stopping) resolve(); else reject(new Error(`${command} failed (${signal || code}).`));
|
||||
});
|
||||
});
|
||||
return { proc, completion };
|
||||
}
|
||||
try {
|
||||
const cacheFile = join(root, '.webka-cache.json');
|
||||
let cache = {};
|
||||
try { cache = JSON.parse(readFileSync(cacheFile, 'utf8')); } catch {}
|
||||
const deps = createHash('sha256').update(readFileSync(join(root, 'frontend/package-lock.json'))).update(readFileSync(join(root, 'frontend/package.json'))).update(`${process.platform}-${process.arch}-${major}`).digest('hex');
|
||||
if (cache.dependencies !== deps || !existsSync(join(root, 'frontend/node_modules/nuxt/package.json'))) {
|
||||
console.log('\nInstalling frontend dependencies (first run or changed lockfile)…');
|
||||
await run(process.execPath, [process.env.npm_execpath, '--prefix', 'frontend', 'ci']).completion;
|
||||
if (stopping) return;
|
||||
cache = { dependencies: deps }; writeFileSync(cacheFile, JSON.stringify(cache));
|
||||
}
|
||||
const source = fingerprint([join(root, 'frontend')]);
|
||||
if (cache.frontend !== source || !existsSync(join(root, 'frontend/.output/public/index.html'))) {
|
||||
console.log('\nBuilding the frontend…');
|
||||
await run(process.execPath, [process.env.npm_execpath, '--prefix', 'frontend', 'run', 'build']).completion;
|
||||
if (stopping) return;
|
||||
cache.frontend = source; writeFileSync(cacheFile, JSON.stringify(cache));
|
||||
} else console.log('\nFrontend is up to date.');
|
||||
console.log('\nPreparing the Rust server…');
|
||||
await run('cargo', ['build', '--locked', '--manifest-path', 'backend/Cargo.toml']).completion;
|
||||
if (stopping) return;
|
||||
// Locate Cargo's actual target directory, including CARGO_TARGET_DIR overrides.
|
||||
const metadata = spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1', '--manifest-path', 'backend/Cargo.toml'], { cwd: root, encoding: 'utf8' });
|
||||
if (metadata.status !== 0) throw new Error('Could not locate the Rust executable.');
|
||||
const executable = join(JSON.parse(metadata.stdout).target_directory, 'debug', process.platform === 'win32' ? 'webka.exe' : 'webka');
|
||||
const data = resolve(root, process.env.WEBKA_DATA || 'data');
|
||||
const { proc, completion } = run(executable, [], { ...process.env, ...connectionEnv(config, urls), WEBKA_ASSETS: join(root, 'frontend/.output/public'), WEBKA_DATA: data });
|
||||
// Observe exit failures immediately while checking readiness.
|
||||
let failure;
|
||||
completion.catch(e => { failure = e; });
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 150 && !stopping; attempt++) {
|
||||
if (failure) throw failure;
|
||||
if (proc.exitCode !== null) throw new Error('Server exited before becoming ready.');
|
||||
try {
|
||||
const response = await fetch(`${urls.local}/api/bootstrap`, { signal: AbortSignal.timeout(1000) });
|
||||
if (response.ok) { ready = true; break; }
|
||||
} catch {}
|
||||
await delay(200);
|
||||
}
|
||||
if (!ready && !stopping) { stop(); await completion; throw new Error('The server did not become ready. Check the error above.'); }
|
||||
if (stopping) { await completion; return; }
|
||||
if (urls.proxy) {
|
||||
console.log(`\nWebka is ready!\n Open in browser: ${urls.origin}\n Proxy upstream: HTTP to this Webka computer's LAN IP, port ${config.port}\n HTTPS: handled by your existing proxy\n mDNS: disabled`);
|
||||
} else {
|
||||
console.log(`\nWebka is ready!\n This computer: ${urls.local}`);
|
||||
for (const address of urls.lan) console.log(` LAN: ${address}`);
|
||||
if (process.env.WEBKA_ORIGIN) console.log(` Public URL: ${urls.origin}`);
|
||||
if (urls.lan.length) console.log('Use the localhost/127.0.0.1 URL to capture on this computer. Capturing on a phone requires trusted HTTPS (see README).');
|
||||
}
|
||||
console.log(` Setup token: ${join(data, 'setup-token')}\n Stop: Ctrl+C\n`);
|
||||
await completion;
|
||||
} finally {
|
||||
if (child && child.exitCode === null) stop();
|
||||
process.off('SIGINT', stop); process.off('SIGTERM', stop);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch(error => { console.error(`\nWebka: ${error.message}`); process.exitCode = 1; });
|
||||
}
|
||||
66
scripts/dev-all.test.mjs
Normal file
66
scripts/dev-all.test.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseArgs, addresses, connectionEnv, readSettings } from './dev-all.mjs';
|
||||
|
||||
test('default and named host/port options', () => {
|
||||
assert.deepEqual(parseArgs([]), { host: '127.0.0.1', port: 3000 });
|
||||
assert.deepEqual(parseArgs(['--host', '0.0.0.0', '--port', '3002']), { host: '0.0.0.0', port: 3002 });
|
||||
assert.deepEqual(parseArgs(['--port=3002', '--host=localhost']), { host: '127.0.0.1', port: 3002 });
|
||||
for (const args of [['--port', '0'], ['--port', '65536'], ['--port', '3abc'], ['--host', 'evil.example'], ['--host'], ['—port', '3002'], ['--unknown']]) assert.throws(() => parseArgs(args));
|
||||
});
|
||||
const networks = { en0: [{ address: '192.168.1.23', family: 'IPv4', internal: false }], other: [{ address: '8.8.8.8', family: 'IPv4', internal: false }], lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }] };
|
||||
test('LAN listener keeps camera-capable loopback and explicit LAN origins', () => {
|
||||
const result = addresses({ host: '0.0.0.0', port: 3002 }, networks, {});
|
||||
assert.equal(result.origin, 'http://192.168.1.23:3002');
|
||||
assert.equal(result.local, 'http://127.0.0.1:3002');
|
||||
assert.deepEqual(result.allowed.sort(), ['http://192.168.1.23:3002', 'http://127.0.0.1:3002', 'http://localhost:3002'].sort());
|
||||
assert.ok(!result.allowed.some(origin => origin.includes('0.0.0.0') || origin.includes('8.8.8.8')));
|
||||
});
|
||||
test('loopback default and IPv6 formatting', () => {
|
||||
assert.equal(addresses(parseArgs([]), networks, {}).origin, 'http://localhost:3000');
|
||||
assert.deepEqual(addresses({ host: '::1', port: 3002 }, {}, {}).allowed, ['http://localhost:3002', 'http://[::1]:3002']);
|
||||
});
|
||||
test('explicit HTTPS origin does not silently authorize HTTP aliases', () => {
|
||||
const result = addresses({ host: '0.0.0.0', port: 3002 }, networks, { WEBKA_ORIGIN: 'https://webka.local' });
|
||||
assert.deepEqual(result.allowed, ['https://webka.local']);
|
||||
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ORIGIN: 'https://webka.local/login' }));
|
||||
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ALLOWED_ORIGINS: '*' }));
|
||||
});
|
||||
|
||||
test('homepage mode accepts proxy domain/IP, normalizes HTTPS and exposes the upstream', () => {
|
||||
assert.deepEqual(parseArgs(['--homepage', 'camera.example.com', '--port', '3002']), { host: '0.0.0.0', port: 3002, homepage: 'https://camera.example.com' });
|
||||
assert.deepEqual(parseArgs(['--homepage=https://192.168.1.10:8443/', '--host=127.0.0.1']), { host: '127.0.0.1', port: 3000, homepage: 'https://192.168.1.10:8443' });
|
||||
for (const address of ['', 'http://camera.example.com', 'https://camera.example.com/watch', 'https://a:b@camera.example.com', 'https://camera.example.com?x=1', 'https://camera.example.com#x', 'https://0.0.0.0', 'https://[::]']) assert.throws(() => parseArgs(['--homepage', address]));
|
||||
});
|
||||
test('saved settings apply, with command-line overrides', () => {
|
||||
const settings = { homepage: 'https://camera.example.com', port: 3002 };
|
||||
assert.deepEqual(parseArgs([], settings), { host: '0.0.0.0', port: 3002, homepage: settings.homepage });
|
||||
assert.deepEqual(parseArgs(['--homepage', 'https://other.example.com', '--port', '3003', '--host', '127.0.0.1'], settings), { host: '127.0.0.1', port: 3003, homepage: 'https://other.example.com' });
|
||||
assert.throws(() => parseArgs([], { port: 'invalid' }));
|
||||
});
|
||||
test('configuration file loads a homepage and rejects malformed or misspelled settings', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'webka-settings-'));
|
||||
const path = join(directory, 'webka.config.json');
|
||||
try {
|
||||
assert.deepEqual(readSettings(path), {});
|
||||
writeFileSync(path, JSON.stringify({ homepage: 'camera.example.com', port: 3002 }));
|
||||
assert.deepEqual(parseArgs([], readSettings(path)), { host: '0.0.0.0', port: 3002, homepage: 'https://camera.example.com' });
|
||||
for (const invalid of ['{', 'null', '[]', '{"homePage":"camera.example.com"}']) {
|
||||
writeFileSync(path, invalid);
|
||||
assert.throws(() => readSettings(path), /webka.config.json/);
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
test('homepage wins over old HTTPS/mDNS environment settings without allowing HTTP aliases', () => {
|
||||
const env = { WEBKA_MDNS: '1', WEBKA_ORIGIN: 'https://old.local', WEBKA_ALLOWED_ORIGINS: 'http://unsafe.example.com' };
|
||||
const config = parseArgs(['--homepage', 'https://camera.example.com:8443', '--port', '3002']);
|
||||
const urls = addresses(config, networks, env);
|
||||
assert.equal(urls.origin, config.homepage);
|
||||
assert.deepEqual(urls.allowed, [config.homepage]);
|
||||
assert.deepEqual(connectionEnv(config, urls, env), { WEBKA_BIND: '0.0.0.0:3002', WEBKA_ORIGIN: config.homepage, WEBKA_ALLOWED_ORIGINS: config.homepage, WEBKA_MDNS: '0', WEBKA_PUBLIC_PORT: '8443' });
|
||||
});
|
||||
21
scripts/test-install.mjs
Normal file
21
scripts/test-install.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
// Validate npm's resolution from only the committed manifests, with no node_modules.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
if (!process.env.npm_execpath) throw new Error('Run npm run test:install.');
|
||||
const fixture = mkdtempSync(join(tmpdir(), 'webka-install-'));
|
||||
try {
|
||||
for (const file of ['package.json', 'package-lock.json']) {
|
||||
copyFileSync(fileURLToPath(new URL(`../frontend/${file}`, import.meta.url)), join(fixture, file));
|
||||
}
|
||||
const result = spawnSync(process.execPath, [process.env.npm_execpath, 'ci', '--dry-run', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: fixture, encoding: 'utf8', timeout: 180000 });
|
||||
if (result.error || result.status !== 0) {
|
||||
console.error(result.stderr || result.error?.message || 'Clean lockfile validation failed.');
|
||||
process.exitCode = 1;
|
||||
} else console.log('PASS: npm ci accepts the frontend lockfile without an existing node_modules directory.');
|
||||
} finally {
|
||||
rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
92
scripts/test-launcher.py
Normal file
92
scripts/test-launcher.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""POSIX integration test for the actual npm launcher, including teardown."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
origin = 'http://127.0.0.1:3002'
|
||||
|
||||
def stop(proc):
|
||||
if proc.poll() is None:
|
||||
os.killpg(proc.pid, signal.SIGINT)
|
||||
try:
|
||||
proc.wait(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.wait()
|
||||
raise AssertionError('Launcher did not stop on Ctrl+C')
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='webka-launcher-') as temporary:
|
||||
data = Path(temporary, 'data')
|
||||
env = dict(os.environ, WEBKA_DATA=str(data), WEBKA_MDNS='0')
|
||||
for key in ['WEBKA_ORIGIN', 'WEBKA_ALLOWED_ORIGINS']:
|
||||
env.pop(key, None)
|
||||
for iteration in range(2):
|
||||
logfile = Path(temporary, f'launch-{iteration}.log')
|
||||
with logfile.open('w') as output:
|
||||
proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--host', '0.0.0.0', '--port', '3002'], cwd=root, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
for _ in range(1200):
|
||||
if proc.poll() is not None:
|
||||
raise AssertionError(logfile.read_text())
|
||||
if 'Webka is ready!' in logfile.read_text():
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise AssertionError('Startup timed out: ' + logfile.read_text()[-2000:])
|
||||
if iteration == 1:
|
||||
assert 'Frontend is up to date.' in logfile.read_text()
|
||||
assert 'Installing frontend dependencies' not in logfile.read_text()
|
||||
for route in ['/login', '/host', '/watch', '/settings/accounts', '/settings/general']:
|
||||
with urllib.request.urlopen(origin + route, timeout=3) as response:
|
||||
assert response.status == 200
|
||||
with urllib.request.urlopen(origin + '/api/bootstrap') as response:
|
||||
assert json.load(response)['needs_setup'] is (iteration == 0)
|
||||
body = json.dumps({'username': 'owner', 'password': 'launch-test-password-123', 'setup_token': (data / 'setup-token').read_text()}).encode()
|
||||
if iteration == 0:
|
||||
req = urllib.request.Request(origin + '/api/bootstrap', data=body, headers={'Origin': origin, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 201
|
||||
req = urllib.request.Request(origin + '/api/login', data=body, headers={'Origin': origin, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
cookie = response.headers['Set-Cookie'].split(';')[0]
|
||||
req = urllib.request.Request(origin + '/api/status', headers={'Cookie': cookie})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
status = json.load(response)
|
||||
assert ':3002/watch' in status['local_url']
|
||||
lan_origin = status['local_url'].removesuffix('/watch')
|
||||
req = urllib.request.Request(origin + '/api/login', data=body, headers={'Origin': lan_origin, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 200
|
||||
req = urllib.request.Request(origin + '/api/logout', data=b'{}', headers={'Origin': 'http://evil.example', 'Content-Type': 'application/json', 'Cookie': cookie})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
raise AssertionError('Untrusted origin accepted')
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 403
|
||||
finally:
|
||||
stop(proc)
|
||||
# Assert that the actual server, not just npm, released the listener.
|
||||
with socket.socket() as server:
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(('0.0.0.0', 3002))
|
||||
print(f'PASS: launch {iteration + 1}, routes, local/LAN auth, QR port, rejected foreign origin, Ctrl+C cleanup.', flush=True)
|
||||
with socket.socket() as occupied:
|
||||
occupied.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
occupied.bind(('127.0.0.1', 3002))
|
||||
occupied.listen()
|
||||
proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--port', '3002'], cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=20)
|
||||
assert proc.returncode != 0 and 'already in use' in stderr, stdout + stderr
|
||||
finally:
|
||||
stop(proc)
|
||||
print('PASS: occupied port fails before building or starting a server.', flush=True)
|
||||
164
scripts/test-proxy.py
Normal file
164
scripts/test-proxy.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""POSIX integration: HTTPS reverse proxy -> Webka HTTP upstream, no mDNS."""
|
||||
import base64
|
||||
import hashlib
|
||||
import http.client
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
upstream_port = free_port()
|
||||
proxy_port = free_port()
|
||||
homepage = f'https://127.0.0.1:{proxy_port}'
|
||||
|
||||
class Proxy(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def forward(self):
|
||||
body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
|
||||
headers = dict(self.headers)
|
||||
headers['X-Forwarded-Proto'] = 'https'
|
||||
headers['X-Forwarded-For'] = '192.168.1.99'
|
||||
connection = http.client.HTTPConnection('127.0.0.1', upstream_port, timeout=5)
|
||||
try:
|
||||
connection.request(self.command, self.path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
self.send_response(response.status)
|
||||
for name, value in response.getheaders():
|
||||
if name.lower() not in ['transfer-encoding', 'content-length', 'connection']:
|
||||
self.send_header(name, value)
|
||||
if response.status == 101:
|
||||
self.send_header('Connection', 'Upgrade')
|
||||
self.end_headers()
|
||||
self.wfile.flush()
|
||||
# Establish the WebSocket upgrade across TLS and forward its first server frame.
|
||||
# Explicit HTTPConnection upgrade retains the underlying socket.
|
||||
head = response.fp.read(2)
|
||||
self.wfile.write(head)
|
||||
size = head[1] & 127
|
||||
if size == 126:
|
||||
extended = response.fp.read(2)
|
||||
self.wfile.write(extended)
|
||||
size = int.from_bytes(extended, 'big')
|
||||
self.wfile.write(response.fp.read(size))
|
||||
self.wfile.flush()
|
||||
self.close_connection = True
|
||||
else:
|
||||
data = response.read()
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.send_header('Connection', 'close')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
self.close_connection = True
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
do_GET = forward
|
||||
do_POST = forward
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='webka-proxy-test-') as temporary:
|
||||
directory = Path(temporary)
|
||||
cert, key = directory / 'cert.pem', directory / 'key.pem'
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key), '-out', str(cert), '-days', '1', '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1'], check=True, capture_output=True)
|
||||
context = ssl.create_default_context(cafile=str(cert))
|
||||
data = directory / 'data'
|
||||
# Deliberately stale values must be overridden by homepage mode.
|
||||
env = dict(os.environ, WEBKA_DATA=str(data), WEBKA_MDNS='1', WEBKA_ORIGIN='https://old.local', WEBKA_ALLOWED_ORIGINS='http://old.example.com')
|
||||
proxy = ThreadingHTTPServer(('127.0.0.1', proxy_port), Proxy)
|
||||
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
server_context.load_cert_chain(cert, key)
|
||||
proxy.socket = server_context.wrap_socket(proxy.socket, server_side=True)
|
||||
thread = threading.Thread(target=proxy.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
logfile = directory / 'launch.log'
|
||||
try:
|
||||
with logfile.open('w') as output:
|
||||
proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--homepage', homepage, '--port', str(upstream_port)], cwd=root, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
for _ in range(1200):
|
||||
if proc.poll() is not None:
|
||||
raise AssertionError(logfile.read_text())
|
||||
if 'Webka is ready!' in logfile.read_text():
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise AssertionError('Startup timeout')
|
||||
assert 'mDNS: disabled' in logfile.read_text()
|
||||
assert f'Open in browser: {homepage}' in logfile.read_text()
|
||||
def request(path, body=None, origin=homepage, cookie=None):
|
||||
headers = {'Origin': origin, 'Content-Type': 'application/json'}
|
||||
if cookie:
|
||||
headers['Cookie'] = cookie
|
||||
req = urllib.request.Request(homepage + path, data=json.dumps(body).encode() if body is not None else None, headers=headers)
|
||||
return urllib.request.urlopen(req, context=context, timeout=5)
|
||||
with request('/login') as response:
|
||||
assert response.status == 200 and 'text/html' in response.headers['Content-Type']
|
||||
credentials = {'username': 'owner', 'password': 'proxy-test-password123', 'setup_token': (data / 'setup-token').read_text()}
|
||||
with request('/api/bootstrap', credentials) as response:
|
||||
assert response.status == 201
|
||||
with request('/api/login', credentials) as response:
|
||||
assert 'Secure' in response.headers['Set-Cookie']
|
||||
assert 'HttpOnly' in response.headers['Set-Cookie']
|
||||
cookie = response.headers['Set-Cookie'].split(';')[0]
|
||||
with request('/api/status', cookie=cookie) as response:
|
||||
assert json.load(response)['local_url'] == homepage + '/watch'
|
||||
with request('/api/session', cookie=cookie) as response:
|
||||
assert json.load(response)['role'] == 'admin'
|
||||
for wrong_origin in ['https://old.local', f'http://127.0.0.1:{upstream_port}', 'http://old.example.com']:
|
||||
try:
|
||||
request('/api/logout', {}, origin=wrong_origin, cookie=cookie)
|
||||
raise AssertionError('Unexpected origin allowed')
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 403
|
||||
# A real WSS handshake is forwarded to the HTTP WebSocket upstream.
|
||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||
conn = http.client.HTTPSConnection('127.0.0.1', proxy_port, context=context, timeout=5)
|
||||
conn.request('GET', '/api/signal?mode=host', headers={'Origin': homepage, 'Cookie': cookie, 'Connection': 'Upgrade', 'Upgrade': 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': ws_key})
|
||||
response = conn.getresponse()
|
||||
assert response.status == 101
|
||||
expected = base64.b64encode(hashlib.sha1((ws_key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
|
||||
assert response.headers['Sec-WebSocket-Accept'] == expected
|
||||
head = response.fp.read(2)
|
||||
size = head[1] & 127
|
||||
if size == 126:
|
||||
size = int.from_bytes(response.fp.read(2), 'big')
|
||||
assert json.loads(response.fp.read(size))['type'] == 'welcome'
|
||||
conn.close()
|
||||
print('PASS: HTTPS proxy login, Secure cookie, public share URL, origin restrictions, WSS upgrade, mDNS disabled.', flush=True)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
os.killpg(proc.pid, signal.SIGINT)
|
||||
try:
|
||||
proc.wait(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.wait()
|
||||
raise AssertionError('Launcher did not stop')
|
||||
finally:
|
||||
proxy.shutdown()
|
||||
proxy.server_close()
|
||||
thread.join(timeout=5)
|
||||
with socket.socket() as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(('0.0.0.0', upstream_port))
|
||||
print('PASS: proxy and launcher stopped; HTTP upstream port released.', flush=True)
|
||||
3
webka.config.example.json
Normal file
3
webka.config.example.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"homepage": "https://camera.example.com"
|
||||
}
|
||||
Reference in New Issue
Block a user