Add revocable watch-only casting links and fix signed framing arguments

This commit is contained in:
toorux committed 2026-10-13 21:48:39 +08:00
1 parent 69831e474e
commit d229ad2b09
10 files changed
+361 -16

No files matched your search

+2
View File
@@ -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 硬件编码。
## 接收与发送
+1
View File
@@ -4,3 +4,4 @@ pub mod settings;
pub mod runtime;
pub mod receiver;
pub mod watch_access;
+10 -4
View File
@@ -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);
+100
View File
@@ -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<String>,
}
impl Grant {
pub fn load(root: &Path) -> Result<Self> {
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<Self> {
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<String>,
pub sessions: std::collections::BTreeMap<String, String>,
pub revoked: Vec<String>,
}
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<String>) {
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);
}
}
+6
View File
@@ -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()?;
+216 -6
View File
@@ -37,6 +37,7 @@ struct Agent {
files: Arc<crate::manager_files::Files>,
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
casting: Mutex<crate::cast::runtime::Casting>,
watch_access: Mutex<crate::cast::watch_access::Access>,
receivers: Mutex<crate::cast::receiver::Receivers>,
}
#[derive(Default)]
@@ -75,6 +76,25 @@ fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
q.push_back(v);
}
impl Agent {
fn watch_cookie(r: &Request) -> Option<String> {
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("<!doctype html><meta charset=\"utf-8\"><title>Framely</title><p>观看链接已失效</p>")
.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<Value> {
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("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 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::<Vec<_>>());
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::<Vec<_>>())}else{json!([])};
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}{watch_path}",network["port"])).collect::<Vec<_>>())}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<Agent> {
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);
+3
View File
@@ -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",
+3
View File
@@ -3,6 +3,9 @@
"locale": "zh-CN",
"name": "简体中文",
"messages": {
"免验证": "免验证",
"持有此链接的人无需密码即可观看。": "持有此链接的人无需密码即可观看。",
"观看链接已失效": "观看链接已失效",
"正在读取串流信息…": "正在读取串流信息…",
"当前浏览器实际接收并解码的串流信息": "当前浏览器实际接收并解码的串流信息",
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
+16 -3
View File
@@ -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 <div className="cast-video-stage" style={{aspectRatio:aspectRatio??ratio}}><video ref={video} autoPlay playsInline muted controls={controls} onLoadedMetadata={()=>{if(video.current?.videoHeight)setRatio(video.current.videoWidth/video.current.videoHeight);}} onPlaying={()=>setState('playing')}/>{showStatus&&active&&state==='connecting'&&<span role="status">{t('正在连接画面…')}</span>}{showStatus&&active&&state==='failed'&&<div className="cast-video-failure"><p>{t('观看连接失败,请重新连接。')}</p><button onClick={()=>setRetry(v=>v+1)}>{t('重新连接')}</button></div>}{!active&&<span>{t('开始串流后显示头显画面。')}</span>}</div>;
}
@@ -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 <main ref={root} tabIndex={-1} className={`cast-watch ${visible||state!=='playing'||paused?'controls-visible':''}`} onPointerMove={e=>{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();}}>
<CastVideo key={key} active controls={false} videoRef={video} onState={setState} onError={setError} showStatus={false}/>
<header className="cast-watch-top cast-watch-overlay"><a href="/manager#casting" className="cast-watch-icon" aria-label={t('返回投屏设置')} title={t('返回投屏设置')}><IconArrowLeft size={22}/></a><strong className="cast-watch-title">{t('头显画面')}</strong><span className="cast-watch-connection" role="status">{state==='playing'?t('实时画面'):state==='connecting'?t('正在连接'):t('连接已断开')}</span></header>
{state!=='playing'&&<div className="cast-watch-message" role={state==='failed'?'alert':'status'}>{state==='connecting'?<><span className="cast-spinner"/><h1>{t('正在连接头显')}</h1><p>{t('画面即将开始,请稍候。')}</p></>:<><h1>{t('暂时无法观看')}</h1><p>{error||t('确认头显已开始串流,然后重新连接。')}</p><button className="primary" onClick={reconnect}><IconRefresh size={20}/>{t('重新连接')}</button></>}</div>}
{state!=='playing'&&<div className="cast-watch-message" role={state==='failed'?'alert':'status'}>{state==='connecting'?<><span className="cast-spinner"/><h1>{t('正在连接头显')}</h1><p>{t('画面即将开始,请稍候。')}</p></>:<><h1>{t(expired?'观看链接已失效':'暂时无法观看')}</h1><p>{error||t('确认头显已开始串流,然后重新连接。')}</p>{!expired&&<button className="primary" onClick={reconnect}><IconRefresh size={20}/>{t('重新连接')}</button>}</>}</div>}
{error&&state==='playing'&&<p className="cast-watch-toast" role="alert">{error}</p>}
<footer className="cast-watch-bottom cast-watch-overlay"><div className="cast-watch-controls"><button className="cast-watch-icon" aria-label={t(paused?'播放':'暂停')} title={t(paused?'播放':'暂停')} disabled={state!=='playing'} onClick={togglePlay}>{paused?<IconPlayerPlay size={24}/>:<IconPlayerPause size={24}/>}</button><div className="cast-watch-sound"><button className="cast-watch-icon" aria-label={t(muted?'开启声音':'静音')} title={t(muted?'开启声音':'静音')} aria-pressed={!muted} onClick={()=>{setMuted(v=>!v);reveal();}}>{muted||volume===0?<IconVolumeOff size={23}/>:<IconVolume size={23}/>}</button><CastRange aria-label={t('音量')} min={0} max={1} step={.05} value={muted?0:volume} onChange={e=>{setVolume(+e.target.value);setMuted(false);reveal();}}/></div><span className="cast-watch-live">{t('实时')}</span><div className="cast-watch-tools"><button className="cast-watch-icon" aria-label={t('重新连接')} title={t('重新连接')} onClick={reconnect}><IconRefresh size={22}/></button><button className="cast-watch-icon" aria-label={t(fullscreen?'退出全屏':'全屏')} title={t(fullscreen?'退出全屏':'全屏')} onClick={()=>void toggleFullscreen()}>{fullscreen?<IconMinimize size={22}/>:<IconMaximize size={22}/>}</button></div></div></footer>
</main>;
+4 -3
View File
@@ -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<CastPage,[string,string]>={casting:['网页观看','把头显画面分享给同一网络中的手机或电脑。'],'casting-picture':['画面与声音','选择画面来源,直接调整取景和输出画质。'],'casting-receive':['接收投屏','接收其他设备的音频和视频,在头显中打开。'],'casting-devices':['投到设备','选择同一网络中的设备,播放当前头显画面。']};
function Choices<T extends string|number>({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<Device[]>('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'&&<>
<div className="cast-monitor"><CastVideo key={epoch} active={!!runtime?.running&&!runtime.preview} onError={setError} onStreamInfo={setStreamInfo}/>{!runtime?.running&&<div className="cast-monitor-empty"><IconDeviceVisionPro size={44} stroke={1.5}/><h2>{t('分享你看到的画面')}</h2><p>{t('启动后,手机和电脑可以通过网页实时观看。')}</p></div>}</div>
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button></div>
{runtime?.running&&<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><div className="cast-share-link"><code>{(!['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}</code><button onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href="/cast/watch" aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div></section>}
{runtime?.running&&<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><SwitchRow label={t('免验证')} checked={runtime.watchAccessEnabled??false} disabled={busy} onChange={enabled=>void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/><p>{t('持有此链接的人无需密码即可观看。')}</p><div className="cast-share-link"><code>{watchAddress}</code><button disabled={busy} onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href={watchAddress} aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div></section>}
</>}
{page==='casting-picture'&&<>