mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-25 18:01:57 +01:00
Add revocable watch-only casting links and fix signed framing arguments
This commit is contained in:
1 parent
69831e474e
commit
d229ad2b09
10 files changed
+361
-16
No files matched your search
@@ -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 硬件编码。
|
||||
|
||||
## 接收与发送
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod settings;
|
||||
pub mod runtime;
|
||||
|
||||
pub mod receiver;
|
||||
pub mod watch_access;
|
||||
+10
-4
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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,6 +3,9 @@
|
||||
"locale": "zh-CN",
|
||||
"name": "简体中文",
|
||||
"messages": {
|
||||
"免验证": "免验证",
|
||||
"持有此链接的人无需密码即可观看。": "持有此链接的人无需密码即可观看。",
|
||||
"观看链接已失效": "观看链接已失效",
|
||||
"正在读取串流信息…": "正在读取串流信息…",
|
||||
"当前浏览器实际接收并解码的串流信息": "当前浏览器实际接收并解码的串流信息",
|
||||
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
|
||||
|
||||
+16
-3
@@ -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
@@ -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'&&<>
|
||||
|
||||
Reference in new issue
Block a user