diff --git a/docs/casting.md b/docs/casting.md index 213c641..620d501 100644 --- a/docs/casting.md +++ b/docs/casting.md @@ -16,6 +16,8 @@ Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面” 网页优先协商用户选择的 H.264 或 H.265。当前官方 CEF 运行包不能解码所选编码时,头显自带 FFmpeg 以 libvpx 提供 VP8 + Opus 兼容输出;所有需要 VP8 的观看者共享一个额外的软件编码进程。采集来源不变。支持所选编码的观看者直接接收原串流,DLNA 仍使用 H.264。VP8 增加 CPU 消耗;兼容观看结束后收回该进程。Framely 不安装、调用或依赖外部 Chrome。 +在“在其他设备上观看”中开启 **免验证**,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。 + 声音使用小片段和独立的有界输入队列,避免音频阻塞拖慢视频;编码使用无 B 帧和低延迟参数。SteamVR 的 H.265 是软件编码,会增加 CPU 负载;屏幕采集使用 Iris 硬件编码。 ## 接收与发送 diff --git a/src/cast/mod.rs b/src/cast/mod.rs index 9005cc8..c123d09 100644 --- a/src/cast/mod.rs +++ b/src/cast/mod.rs @@ -4,3 +4,4 @@ pub mod settings; pub mod runtime; pub mod receiver; +pub mod watch_access; diff --git a/src/cast/runtime.rs b/src/cast/runtime.rs index f8c265e..d6850c2 100644 --- a/src/cast/runtime.rs +++ b/src/cast/runtime.rs @@ -100,6 +100,12 @@ impl Casting { /// All VP8 viewers share one on-demand compatibility encoder. #[cfg(test)] pub(crate) fn test_rtsp_port(&self) -> u16 {self.rtsp_port} + #[cfg(test)] + pub(crate) fn test_whep_server(&mut self, port: u16) { + self.started = Some(Instant::now()); + self.active_settings = Some(Settings::default()); + self.whep_port = port; + } pub fn codec(&self) -> Codec { self.active_settings.as_ref().map(|s|s.codec).unwrap_or_default() } pub fn ensure_vp8(&mut self) -> Result<()> { ensure!(self.status()["running"] == true, "请先开始头显串流"); @@ -409,11 +415,11 @@ impl Casting { if let Some(fov) = s.horizontal_fov { command.args(["--fov", &fov.to_string()]); } + // Bind signed values to their options: older capture workers' + // clap parser treats a separate negative number as a new flag. command.args([ - "--center-x", - &s.center_x.to_string(), - "--center-y", - &s.center_y.to_string(), + format!("--center-x={}", s.center_x), + format!("--center-y={}", s.center_y), ]); } bind_to_session(&mut command); diff --git a/src/cast/watch_access.rs b/src/cast/watch_access.rs new file mode 100644 index 0000000..b38d9f2 --- /dev/null +++ b/src/cast/watch_access.rs @@ -0,0 +1,100 @@ +//! Persistent, watch-only capability; deliberately separate from cast settings. +use anyhow::{ensure, Result}; +use serde::{Deserialize, Serialize}; +use std::{fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path}; + +#[derive(Default, Serialize, Deserialize)] +pub struct Grant { + pub token: Option, +} +impl Grant { + pub fn load(root: &Path) -> Result { + let bytes = match fs::read(root.join("cast-watch-access.json")) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()), + Err(e) => return Err(e.into()), + }; + let grant: Self = serde_json::from_slice(&bytes)?; + ensure!(grant.token.as_ref().is_none_or(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())), "Invalid watch access record"); + Ok(grant) + } + pub fn save(root: &Path, enabled: bool) -> Result { + let mut grant = Self::load(root)?; + if enabled && grant.token.is_none() { + grant.token = Some(crate::session::cast_random_key()); + } else if !enabled { + grant.token = None; + } + let temporary = root.join("cast-watch-access.json.tmp"); + let mut file = fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&temporary)?; + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + let result = (|| -> Result<()> { + file.write_all(&serde_json::to_vec(&grant)?)?; + file.sync_all()?; + fs::rename(&temporary, root.join("cast-watch-access.json"))?; + fs::File::open(root)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { let _ = fs::remove_file(&temporary); } + result?; + Ok(grant) + } +} + +#[derive(Default)] +pub struct Access { + pub token: Option, + pub sessions: std::collections::BTreeMap, + pub revoked: Vec, +} +impl Access { + pub fn accepts(&self, candidate: &str) -> bool { + self.token.as_deref().is_some_and(|token| { + token.len() == candidate.len() && token.bytes().zip(candidate.bytes()).fold(0u8, |diff, (a,b)| diff | (a ^ b)) == 0 + }) + } + pub fn replace(&mut self, token: Option) { + if self.token != token { + self.revoked.extend(std::mem::take(&mut self.sessions).into_values()); + self.token = token; + } + } + pub fn path(&self) -> String { + match &self.token { + Some(token) => format!("/cast/watch?token={token}"), + None => "/cast/watch".into(), + } + } + pub fn disconnect_revoked(&mut self) { + let client = ureq::AgentBuilder::new().redirects(0).timeout(std::time::Duration::from_secs(2)).build(); + self.revoked.retain(|target| { + match client.delete(target).set("If-Match", "*").call() { + Ok(_) | Err(ureq::Error::Status(404 | 410, _)) => false, + Err(_) => true, + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + #[test] + fn watch_access_persists_rotates_and_preserves_on_failure() { + let root = tempfile::tempdir().unwrap(); + assert!(Grant::load(root.path()).unwrap().token.is_none()); + let first = Grant::save(root.path(), true).unwrap().token.unwrap(); + assert_eq!(first.len(), 64); + assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first)); + assert_eq!(Grant::save(root.path(), true).unwrap().token.as_ref(), Some(&first)); + assert_eq!(fs::metadata(root.path().join("cast-watch-access.json")).unwrap().permissions().mode() & 0o777, 0o600); + fs::create_dir(root.path().join("cast-watch-access.json.tmp")).unwrap(); + assert!(Grant::save(root.path(), false).is_err()); + assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first)); + fs::remove_dir(root.path().join("cast-watch-access.json.tmp")).unwrap(); + assert!(Grant::save(root.path(), false).unwrap().token.is_none()); + assert_ne!(Grant::save(root.path(), true).unwrap().token.unwrap(), first); + } +} diff --git a/src/service.rs b/src/service.rs index a44f20e..f01685c 100644 --- a/src/service.rs +++ b/src/service.rs @@ -779,6 +779,7 @@ impl Service { | "agreement.revoke" | "network.password.verify" | "network.password.configured" + | "cast.watchAccess.get" | "network.password.setup" | "system.uninstall.prepare" ), @@ -1120,6 +1121,11 @@ impl Service { self.save()?; Ok(json!(true)) } + "cast.watchAccess.get" => Ok(serde_json::to_value(crate::cast::watch_access::Grant::load(&self.root)?)?), + "cast.watchAccess.save" => { + let enabled = p["enabled"].as_bool().context("Missing enabled")?; + Ok(serde_json::to_value(crate::cast::watch_access::Grant::save(&self.root, enabled)?)?) + } "cast.settings.save" => { let settings: crate::cast::settings::Settings = serde_json::from_value(p)?; settings.validate()?; diff --git a/src/session.rs b/src/session.rs index 03cf369..32a2fde 100644 --- a/src/session.rs +++ b/src/session.rs @@ -37,6 +37,7 @@ struct Agent { files: Arc, catalog_cache: Arc>>, casting: Mutex, + watch_access: Mutex, receivers: Mutex, } #[derive(Default)] @@ -75,6 +76,25 @@ fn push(q: &Mutex>, v: Value) { q.push_back(v); } impl Agent { + fn watch_cookie(r: &Request) -> Option { + r.headers().iter().filter(|h| h.field.equiv("Cookie")) + .flat_map(|h| h.value.as_str().split(';')) + .find_map(|cookie| cookie.trim().strip_prefix("framely-watch=").map(str::to_owned)) + } + fn watch_resource(path: &str, method: &Method) -> bool { + (method == &Method::Get && matches!(path, "/cast/watch" | "/cast/access" | "/assets/app.js" | "/assets/app.css")) + || ((path == "/cast/whep" || path.starts_with("/cast/whep/")) + && matches!(method, Method::Post | Method::Patch | Method::Delete | Method::Options)) + } + fn watch_expired(r: Request) -> Result<()> { + r.respond(Response::from_string("Framely

观看链接已失效

") + .with_status_code(403) + .with_header(header("Content-Type", "text/html; charset=utf-8")) + .with_header(header("X-Framely-Watch-Expired", "1")) + .with_header(header("Cache-Control", "no-store")) + .with_header(header("Referrer-Policy", "no-referrer")))?; + Ok(()) + } fn core(&self, m: &str, p: Value) -> Result { ipc::call(&self.socket, m, p) } @@ -229,8 +249,30 @@ impl Agent { return Ok(()); } } - let configured = remote && self.core("network.password.configured", json!({}))? == true; - if remote && (!configured || !self.network_authorized(&r)) { + let watch_cookie = Self::watch_cookie(&r); + if remote && path == "/cast/watch" && r.method() == &Method::Get { + let token = r.url().split_once('?').and_then(|(_, query)| url::form_urlencoded::parse(query.as_bytes()).find(|(key,_)| key == "token").map(|(_,value)| value.into_owned())); + if let Some(token) = token { + if !self.watch_access.lock().unwrap().accepts(&token) { return Self::watch_expired(r); } + r.respond(Response::empty(StatusCode(303)) + .with_header(header("Location", "/cast/watch")) + .with_header(header("Set-Cookie", &format!("framely-watch={token}; Path=/; HttpOnly; SameSite=Strict"))) + .with_header(header("Cache-Control", "no-store")) + .with_header(header("Referrer-Policy", "no-referrer")))?; + return Ok(()); + } + } + let manager_authorized = remote && self.network_authorized(&r); + let guest = remote && !manager_authorized && Self::watch_resource(&path, r.method()) + && watch_cookie.as_ref().is_some_and(|token| self.watch_access.lock().unwrap().accepts(token)); + if remote && Self::watch_resource(&path, r.method()) && !manager_authorized && !guest + && (watch_cookie.is_some() || path == "/cast/access") { return Self::watch_expired(r); } + if remote && path == "/cast/access" && (guest || manager_authorized) { + r.respond(Response::empty(StatusCode(204)).with_header(header("Cache-Control", "no-store")))?; + return Ok(()); + } + let configured = remote && !guest && self.core("network.password.configured", json!({}))? == true; + if remote && !guest && (!configured || !manager_authorized) { let status = self.core("status", json!({}))?; let protected = status["database"]["networkPanel"]["passwordEnabled"] .as_bool() @@ -511,6 +553,15 @@ impl Agent { } if let Some(rel) = path.strip_prefix("/assets/") { safe_path(rel)?; + if guest { + let mime = if path.ends_with(".js") { "text/javascript" } else { "text/css" }; + r.respond(Response::from_data(fs::read(self.assets.join(&path[1..]))?) + .with_header(header("Content-Type", mime)) + .with_header(header("X-Content-Type-Options", "nosniff")) + .with_header(header("Cache-Control", "no-store")) + .with_header(header("Referrer-Policy", "no-referrer")))?; + return Ok(()); + } return send_file(r, &self.assets.join(&path[1..]), true); } if let Some(key) = path.strip_prefix("/boot/") { @@ -538,7 +589,7 @@ impl Agent { } ensure!( if remote { - self.network_authorized(&r) + guest || self.network_authorized(&r) } else { self.authorized(&r) }, @@ -791,11 +842,20 @@ impl Agent { ); } if path == "/cast/whep" || path.starts_with("/cast/whep/") { + // Serialize guest creation/registration with revocation: disabling + // cannot miss a WHEP session that is still being created upstream. + let mut grant = if guest { Some(self.watch_access.lock().unwrap()) } else { None }; + if let Some(access) = grant.as_ref() { + if !watch_cookie.as_ref().is_some_and(|token| access.accepts(token)) { return Self::watch_expired(r); } + } ensure!(matches!(r.method(), Method::Post | Method::Patch | Method::Delete | Method::Options), "Invalid WHEP method"); let origin = r.headers().iter().find(|h| h.field.equiv("Origin")).map(|h|h.value.as_str()); ensure!(origin == Some(expected_origin.as_str()), "Cross-origin playback request denied"); if r.method()==&Method::Options {r.respond(Response::empty(StatusCode(204)).with_header(header("Accept-Post","application/sdp")))?;return Ok(());} let suffix = path.strip_prefix("/cast/whep").unwrap(); + if let Some(access) = grant.as_ref() { + if !suffix.is_empty() && !access.sessions.contains_key(&path) { return Self::watch_expired(r); } + } let mut body = Vec::new(); r.as_reader().take(65537).read_to_end(&mut body)?; ensure!(body.len() <= 65536, "SDP too large"); @@ -819,7 +879,7 @@ impl Agent { } let response = match request.send_bytes(&body) { Ok(v) => v, Err(ureq::Error::Status(_,v)) => v, Err(e) => return Err(e.into()) }; let code = response.status(); - let mut headers = vec![header("Cache-Control", "no-store")]; + let mut headers = vec![header("Cache-Control", "no-store"), header("Referrer-Policy", "no-referrer")]; for name in ["Content-Type", "ETag", "Accept-Patch"] { if let Some(value) = response.header(name) { headers.push(header(name,value)); } } @@ -828,6 +888,14 @@ impl Agent { let prefix=format!("/{media_path}/whep/"); ensure!(parsed.path().starts_with(&prefix), "Invalid media session location"); headers.push(header("Location", &format!("/cast/whep/{codec}/{}",parsed.path().strip_prefix(&prefix).unwrap()))); + if (200..300).contains(&code) { + if let Some(access) = grant.as_mut() { + access.sessions.insert(format!("/cast/whep/{codec}/{}", parsed.path().strip_prefix(&prefix).unwrap()), parsed.to_string()); + } + } + } + if r.method() == &Method::Delete && matches!(code, 200..=299 | 404 | 410) { + if let Some(access) = grant.as_mut() { access.sessions.remove(&path); } } let mut bytes = Vec::new(); response.into_reader().take(65537).read_to_end(&mut bytes)?; ensure!(bytes.len() <= 65536, "Media response too large"); @@ -889,7 +957,8 @@ impl Agent { } else { data }; - let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'")); + let data = if guest { String::from_utf8(data)?.replacen("", "", 1).into_bytes() } else { data }; + let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Referrer-Policy","no-referrer")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'")); r.respond(response)?; return Ok(()); } @@ -898,6 +967,10 @@ impl Agent { } fn maintain_cast(&self) { self.casting.lock().unwrap().maintain(); + // Retry failed disconnects, without interrupting authenticated viewers. + if let Ok(mut access) = self.watch_access.try_lock() { + if !access.revoked.is_empty() { access.disconnect_revoked(); } + } let events=self.receivers.lock().unwrap().poll(); for event in events { let session=&event["session"]; @@ -945,13 +1018,25 @@ impl Agent { match method { "cast.status" => { let mut value = self.casting.lock().unwrap().status(); + let access = self.watch_access.lock().unwrap(); + let watch_path = access.path(); + value["watchAccessEnabled"] = json!(access.token.is_some()); + value["watchPath"] = json!(watch_path); let receivers = self.receivers.lock().unwrap(); value["sessions"] = json!(receivers.sessions.values().collect::>()); value["receiverError"] = json!(receivers.error); let network=self.network_status.lock().unwrap(); - value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}/cast/watch",network["port"])).collect::>())}else{json!([])}; + value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}{watch_path}",network["port"])).collect::>())}else{json!([])}; Ok(value) }, + "cast.watchAccess.save" => { + let mut access = self.watch_access.lock().unwrap(); + let result = self.core(method, p)?; + access.replace(result["token"].as_str().map(str::to_owned)); + access.disconnect_revoked(); + Ok(json!({"enabled":access.token.is_some()})) + }, + "cast.watchAccess.get" => anyhow::bail!("Internal watch access operation"), "cast.settings.save" => { let settings: crate::cast::settings::Settings = serde_json::from_value(p.clone())?; settings.validate()?; @@ -1898,6 +1983,10 @@ pub fn serve( use std::os::unix::fs::PermissionsExt; fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?; let agent = Arc::new(Agent { + watch_access: Mutex::new(crate::cast::watch_access::Access { + token: ipc::call(&socket, "cast.watchAccess.get", json!({}))?["token"].as_str().map(str::to_owned), + ..Default::default() + }), casting: Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets))), receivers: Mutex::new(crate::cast::receiver::Receivers::new(runtime.join("receive"), crate::cast::runtime::binary_directory(&assets))), socket, @@ -2431,6 +2520,7 @@ mod tests { fn agent(server: &Server, root: &Path) -> Arc { Arc::new(Agent { + watch_access: Mutex::default(), casting: Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media"))), receivers: Mutex::new(crate::cast::receiver::Receivers::new(root.join("receive"), root.join("media"))), socket: root.join("control.sock"), @@ -2880,6 +2970,126 @@ mod tests { thread.join().unwrap(); } + #[test] + fn watch_access_limits_http_routes_and_revokes_only_guest_whep() { + let root = tempfile::tempdir().unwrap(); + let server = Server::http("127.0.0.1:0").unwrap(); + let agent = agent(&server, root.path()); + let mut core = crate::tests::accepted_service(&agent.state, unsafe { libc::geteuid() }).unwrap(); + // Authentication is exercised through existing manager cookies, not a + // password hash, so no password derivation is needed in this fixture. + fs::write(agent.state.join("network-password.json"), b"{}").unwrap(); + core.db.network_panel.password_enabled = true; + let socket = UnixListener::bind(&agent.socket).unwrap(); + socket.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let core_stop = stop.clone(); + let core_worker = std::thread::spawn(move || { + while !core_stop.load(Ordering::Relaxed) { + if let Ok((mut stream,_)) = socket.accept() { + let request = ipc::read(&mut stream).unwrap(); + let response = ipc::response(core.handle(request["method"].as_str().unwrap(), request["params"].clone())); + ipc::write(&mut stream, &response).unwrap(); + } else { std::thread::sleep(Duration::from_millis(5)); } + } + }); + let worker_agent = agent.clone(); + let worker_stop = stop.clone(); + let worker = std::thread::spawn(move || { + while !worker_stop.load(Ordering::Relaxed) { + if let Ok(Some(r)) = server.recv_timeout(Duration::from_millis(50)) { + let _ = worker_agent.handle_http(r, true); + } + } + }); + let media = Server::http("127.0.0.1:0").unwrap(); + agent.casting.lock().unwrap().test_whep_server(media.server_addr().to_ip().unwrap().port()); + let media_stop = stop.clone(); + let deleted = Arc::new(Mutex::new(Vec::new())); + let media_deleted = deleted.clone(); + let (creating_tx, creating_rx) = std::sync::mpsc::channel(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel(); + let media_worker = std::thread::spawn(move || { + let mut number = 0; + while !media_stop.load(Ordering::Relaxed) { + if let Ok(Some(r)) = media.recv_timeout(Duration::from_millis(50)) { + if r.method() == &Method::Post { + number += 1; + if number == 3 { + creating_tx.send(()).unwrap(); + finish_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + } + r.respond(Response::from_string("answer").with_status_code(201) + .with_header(header("Location", &format!("/headset/whep/00000000-0000-0000-0000-{number:012}")))).unwrap(); + } else if r.method() == &Method::Delete { + media_deleted.lock().unwrap().push(r.url().to_owned()); + r.respond(Response::empty(StatusCode(204))).unwrap(); + } else { r.respond(Response::empty(StatusCode(400))).unwrap(); } + } + } + }); + agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap(); + let token = agent.watch_access.lock().unwrap().token.clone().unwrap(); + let client = ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build(); + let origin = &agent.origin; + let grant = client.get(&format!("{origin}/cast/watch?token={token}")).call().unwrap(); + assert_eq!(grant.status(), 303); + assert_eq!(grant.header("Location"), Some("/cast/watch")); + assert_eq!(grant.header("Referrer-Policy"), Some("no-referrer")); + let cookie_header = grant.header("Set-Cookie").unwrap(); + assert!(cookie_header.contains("HttpOnly") && cookie_header.contains("SameSite=Strict")); + let cookie = cookie_header.split(';').next().unwrap().to_owned(); + fs::remove_file(agent.state.join("network-password.json")).unwrap(); + let watch = client.get(&format!("{origin}/cast/watch")).set("Cookie", &cookie).call().unwrap(); + assert_eq!(watch.header("Cache-Control"), Some("no-store")); + assert!(watch.into_string().unwrap().contains("framely-cast-guest")); + for asset in ["app.js", "app.css"] { + assert!(client.get(&format!("{origin}/assets/{asset}")).set("Cookie", &cookie).call().is_ok()); + } + fs::write(agent.state.join("network-password.json"), b"{}").unwrap(); + let manager = client.get(&format!("{origin}/manager")).set("Cookie", &cookie).call().unwrap().into_string().unwrap(); + assert!(manager.contains("action=\"/login\"")); + for path in ["/api", "/manager-api/files", "/api/upload/a/0", "/host/poll"] { + assert!(client.post(&format!("{origin}{path}")).set("Cookie", &cookie).set("Origin", origin).send_json(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).is_err()); + } + assert!(client.get(&format!("{origin}/cast/watch?token=invalid")).call().is_err()); + assert!(client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", "http://evil.test").send_string("a=rtpmap:96 H264/90000").is_err()); + let guest = client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap(); + assert_eq!(guest.status(), 201); + let guest_resource = guest.header("Location").unwrap().to_owned(); + let manager_cookie = format!("framely-network={}", agent.network_key.lock().unwrap()); + let logged_in = client.post(&format!("{origin}/cast/whep")).set("Cookie", &manager_cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap(); + assert_eq!(logged_in.status(), 201); + assert_eq!(agent.watch_access.lock().unwrap().sessions.len(), 1); + agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap(); + assert_eq!(deleted.lock().unwrap().as_slice(), &["/headset/whep/00000000-0000-0000-0000-000000000001"]); + assert!(client.get(&format!("{origin}/cast/access")).set("Cookie", &cookie).call().is_err()); + assert!(client.get(&format!("{origin}/cast/watch?token={token}")).call().is_err()); + assert!(client.delete(&format!("{origin}{guest_resource}")).set("Cookie", &cookie).set("Origin", origin).call().is_err()); + assert_eq!(client.get(&format!("{origin}/cast/access")).set("Cookie", &manager_cookie).call().unwrap().status(), 204); + // Revoke while the upstream WHEP POST is in flight. Disabling must + // wait for registration and then delete that new session as well. + agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap(); + let new_token = agent.watch_access.lock().unwrap().token.clone().unwrap(); + assert_ne!(new_token, token); + let race_origin = origin.clone(); + let creating = std::thread::spawn(move || { + ureq::post(&format!("{race_origin}/cast/whep")).set("Cookie", &format!("framely-watch={new_token}")) + .set("Origin", &race_origin).send_string("a=rtpmap:96 H264/90000").unwrap().status() + }); + creating_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + assert!(agent.watch_access.try_lock().is_err()); + let disabling_agent = agent.clone(); + let disabling = std::thread::spawn(move || disabling_agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap()); + finish_tx.send(()).unwrap(); + assert_eq!(creating.join().unwrap(), 201); + disabling.join().unwrap(); + assert_eq!(deleted.lock().unwrap().len(), 2); + assert!(deleted.lock().unwrap()[1].ends_with("000000000003")); + stop.store(true, Ordering::Relaxed); + worker.join().unwrap(); core_worker.join().unwrap(); media_worker.join().unwrap(); + } + #[test] fn network_panel_requires_login_and_same_origin_and_blocks_native_routes() { let password = "密码".repeat(70); diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index 9e45bb7..f7af1b7 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -3,6 +3,9 @@ "locale": "en-US", "name": "English", "messages": { + "免验证": "Password-free viewing", + "持有此链接的人无需密码即可观看。": "Anyone with this link can watch without a password.", + "观看链接已失效": "This viewing link has expired", "正在读取串流信息…": "Reading stream information…", "当前浏览器实际接收并解码的串流信息": "Actual stream received and decoded by this browser", "L2 / R2 按键触发行程": "L2 / R2 button activation threshold", diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index 6fea0d1..3c9734c 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -3,6 +3,9 @@ "locale": "zh-CN", "name": "简体中文", "messages": { + "免验证": "免验证", + "持有此链接的人无需密码即可观看。": "持有此链接的人无需密码即可观看。", + "观看链接已失效": "观看链接已失效", "正在读取串流信息…": "正在读取串流信息…", "当前浏览器实际接收并解码的串流信息": "当前浏览器实际接收并解码的串流信息", "L2 / R2 按键触发行程": "L2 / R2 按键触发行程", diff --git a/ui/src/cast-viewer.tsx b/ui/src/cast-viewer.tsx index bacc821..e0ab00d 100644 --- a/ui/src/cast-viewer.tsx +++ b/ui/src/cast-viewer.tsx @@ -39,6 +39,17 @@ export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onS finally{statsBusy=false;} } const statsTimer=setInterval(()=>void reportStreamInfo(),1000); + const guest=!!document.querySelector('meta[name="framely-cast-guest"]'); + let accessBusy=false; + const accessTimer=guest?setInterval(()=>{ + if(closed||accessBusy)return;accessBusy=true; + void fetch('/cast/access',{cache:'no-store',signal:abort.signal}).then(response=>{ + if(!closed&&response.headers.get('X-Framely-Watch-Expired')==='1'){ + closed=true;abort.abort();peer.close();if(video.current)video.current.srcObject=null; + setState('failed');failure.current?.(t('观看链接已失效')); + } + }).catch(()=>{}).finally(()=>{accessBusy=false;}); + },2000):undefined; setState('connecting'); for(const kind of ['video','audio']){ const receiver=peer.addTransceiver(kind,{direction:'recvonly'}).receiver; @@ -56,13 +67,14 @@ export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onS peer.addEventListener('icegatheringstatechange',check);abort.signal.addEventListener('abort',cancel,{once:true});check(); }); const response=await fetch('/cast/whep',{method:'POST',headers:{'Content-Type':'application/sdp'},body:peer.localDescription?.sdp,signal:abort.signal}); + if(response.headers.get('X-Framely-Watch-Expired')==='1')throw Error(t('观看链接已失效')); if(!response.ok)throw Error(await response.text()||t('串流尚未就绪,请稍后重试。')); resource=response.headers.get('Location')??undefined; if(closed){if(resource)void fetch(resource,{method:'DELETE'});return;} await peer.setRemoteDescription({type:'answer',sdp:await response.text()}); }catch(error){if(!abort.signal.aborted){setState('failed');failure.current?.(String(error));}} })(); - return()=>{closed=true;clearInterval(statsTimer);streamInfo.current?.(null);abort.abort();peer.close();if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});if(video.current)video.current.srcObject=null;}; + return()=>{closed=true;clearInterval(statsTimer);clearInterval(accessTimer);streamInfo.current?.(null);abort.abort();peer.close();if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});if(video.current)video.current.srcObject=null;}; },[active,retry]); return
; } @@ -74,13 +86,14 @@ export function CastViewer(){ useEffect(()=>{const changed=()=>setFullscreen(!!document.fullscreenElement);document.addEventListener('fullscreenchange',changed);return()=>document.removeEventListener('fullscreenchange',changed);},[]); useEffect(()=>{const el=video.current;if(!el)return;const play=()=>setPaused(false),pause=()=>setPaused(true);el.addEventListener('play',play);el.addEventListener('pause',pause);return()=>{el.removeEventListener('play',play);el.removeEventListener('pause',pause);};},[key]); useEffect(()=>{if(video.current){video.current.muted=muted;video.current.volume=volume;}},[muted,volume,key,state]); - function reconnect(){setError('');setPaused(false);setKey(v=>v+1);reveal();} + const expired=error.includes(t('观看链接已失效')); + function reconnect(){if(expired)return;setError('');setPaused(false);setKey(v=>v+1);reveal();} function togglePlay(){const el=video.current;if(!el)return;if(el.paused)void el.play().catch(()=>setError(t('无法播放,请重新连接。')));else el.pause();reveal();} async function toggleFullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else if(root.current?.requestFullscreen)await root.current.requestFullscreen();else (video.current as HTMLVideoElement&{webkitEnterFullscreen?:()=>void})?.webkitEnterFullscreen?.();}catch{setError(t('此浏览器暂不支持全屏。'));}reveal();} return
{if(e.pointerType==='mouse')reveal();}} onClick={e=>{if(!(e.target as HTMLElement).closest('button,a,input')){reveal();}}} onFocusCapture={reveal} onBlurCapture={reveal} onKeyDown={e=>{if((e.target as HTMLElement).closest('button,a,input'))return;if(e.code==='Space'){e.preventDefault();togglePlay();}else if(e.key==='Escape')reveal();}}>
{t('头显画面')}{state==='playing'?t('实时画面'):state==='connecting'?t('正在连接'):t('连接已断开')}
- {state!=='playing'&&
{state==='connecting'?<>

{t('正在连接头显')}

{t('画面即将开始,请稍候。')}

:<>

{t('暂时无法观看')}

{error||t('确认头显已开始串流,然后重新连接。')}

}
} + {state!=='playing'&&
{state==='connecting'?<>

{t('正在连接头显')}

{t('画面即将开始,请稍候。')}

:<>

{t(expired?'观看链接已失效':'暂时无法观看')}

{error||t('确认头显已开始串流,然后重新连接。')}

{!expired&&}}
} {error&&state==='playing'&&

{error}

}
{setVolume(+e.target.value);setMuted(false);reveal();}}/>
{t('实时')}
; diff --git a/ui/src/casting.tsx b/ui/src/casting.tsx index 5f37bc0..ffdfe8c 100644 --- a/ui/src/casting.tsx +++ b/ui/src/casting.tsx @@ -13,7 +13,7 @@ import './casting.css'; export type CastSettings={source:'screen'|'steamVR';codec:'h264'|'h265';output:'eye'|'raw';eye:'steamVR'|'left'|'right';width:number;height:number;fps:number;bitrateMbps:number;horizontalFov:number|null;centerX:number;centerY:number;systemAudio:boolean;microphone:boolean;airplay:boolean;dlna:boolean;receiverName:string}; type CastSession={id:string;protocol:string;accepted:boolean;width?:number;height?:number}; type Device={id:string;name:string}; -type Runtime={running:boolean;error:string|null;dominantEye:number|null;watchPath:string|null;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]}; +type Runtime={running:boolean;error:string|null;dominantEye:number|null;watchPath:string|null;watchAccessEnabled?:boolean;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]}; export type CastPage='casting'|'casting-picture'|'casting-receive'|'casting-devices'; const titles:Record={casting:['网页观看','把头显画面分享给同一网络中的手机或电脑。'],'casting-picture':['画面与声音','选择画面来源,直接调整取景和输出画质。'],'casting-receive':['接收投屏','接收其他设备的音频和视频,在头显中打开。'],'casting-devices':['投到设备','选择同一网络中的设备,播放当前头显画面。']}; function Choices({label,value,options,onChange,disabled=false}:{label:string;value:T;options:{value:T;label:string;description?:string}[];onChange:(value:T)=>void;disabled?:boolean}){ @@ -38,7 +38,8 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett useEffect(()=>{if(page!=='casting-picture'&&editorActive.current)void action('preview',cancelFraming);},[page]); async function scan(){const token=++deviceScan.current;setSearched(true);for(let i=0;i<3;i++){const list=await api('cast.devices');if(token!==deviceScan.current||!alive.current)return;setDevices(list);if(i<2)await new Promise(resolve=>setTimeout(resolve,1000));}} useEffect(()=>{if(page==='casting-devices')void action('scan',scan);},[page]); - async function copy(){const address=['localhost','127.0.0.1','[::1]'].includes(location.hostname)?runtime?.watchAddresses?.[0]??new URL('/cast/watch',location.origin).href:new URL('/cast/watch',location.origin).href;if(navigator.clipboard)await navigator.clipboard.writeText(address);else{const input=document.createElement('textarea');input.value=address;document.body.append(input);input.select();document.execCommand('copy');input.remove();}setCopied(true);setTimeout(()=>setCopied(false),2500);} + const watchAddress=['localhost','127.0.0.1','[::1]'].includes(location.hostname)?runtime?.watchAddresses?.[0]??new URL(runtime?.watchPath??'/cast/watch',location.origin).href:new URL(runtime?.watchPath??'/cast/watch',location.origin).href; + async function copy(){if(navigator.clipboard)await navigator.clipboard.writeText(watchAddress);else{const input=document.createElement('textarea');input.value=watchAddress;document.body.append(input);input.select();document.execCommand('copy');input.remove();}setCopied(true);setTimeout(()=>setCopied(false),2500);} const errorMessage=error||(page==='casting-receive'||page==='casting-devices'?runtime?.receiverError:runtime?.error); const presets=framePresets(aspect); function chooseAspect(value:number){const edge=Math.max(draft.width,draft.height);const size=value>=1?{width:edge,height:Math.round(edge/value/2)*2}:{width:Math.round(edge*value/2)*2,height:edge};change(size);} @@ -48,7 +49,7 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett {page==='casting'&&<>
{!runtime?.running&&

{t('分享你看到的画面')}

{t('启动后,手机和电脑可以通过网页实时观看。')}

}
{t(config.source==='screen'?'屏幕采集':'SteamVR')}{config.width} × {config.height} {config.fps} fps
- {runtime?.running&&

{t('在其他设备上观看')}

{t('连接同一网络,打开观看地址。')}

{(!['localhost','127.0.0.1','[::1]'].includes(location.hostname)?new URL('/cast/watch',location.origin).href:runtime.watchAddresses?.[0])??new URL('/cast/watch',location.origin).href}
} + {runtime?.running&&

{t('在其他设备上观看')}

{t('连接同一网络,打开观看地址。')}

void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/>

{t('持有此链接的人无需密码即可观看。')}

{watchAddress}
} } {page==='casting-picture'&&<>