use crate::{ipc, model::*}; use anyhow::{ensure, Context, Result}; use serde_json::{json, Value}; use std::{ collections::VecDeque, fs, io::Read, path::{Path, PathBuf}, process::{Command, Stdio}, sync::{Arc, Mutex}, time::Duration, }; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; struct FrameAccess { plugin: String, version: String, network_key: String, expires: std::time::Instant, } struct Agent { socket: PathBuf, state: PathBuf, assets: PathBuf, origin: String, web_key: String, network_key: Mutex, network_status: Mutex, frame_access: Mutex>, native_key: String, commands: Arc>>, events: Mutex, jobs: crate::jobs::Jobs, launcher_search: Mutex, terminals: crate::terminal::Terminals, files: Arc, catalog_cache: Arc>>, } #[derive(Default)] struct EventLog { sequence: u64, entries: VecDeque, } impl EventLog { fn append(&mut self, mut event: Value) { self.sequence += 1; event["sequence"] = json!(self.sequence); if self.entries.len() >= 256 { self.entries.pop_front(); } self.entries.push_back(event); } fn after(&self, cursor: u64) -> Value { json!({"events": self.entries.iter().filter(|e| e["sequence"].as_u64().unwrap_or(0) > cursor).collect::>(), "cursor":self.sequence}) } } fn header(name: &str, value: &str) -> Header { Header::from_bytes(name, value).unwrap() } fn random_key() -> String { use rand::RngCore; let mut b = [0; 32]; rand::rngs::OsRng.fill_bytes(&mut b); hex::encode(b) } fn push(q: &Mutex>, v: Value) { let mut q = q.lock().unwrap(); if q.len() >= 256 { q.pop_front(); } q.push_back(v); } impl Agent { fn core(&self, m: &str, p: Value) -> Result { ipc::call(&self.socket, m, p) } fn authorized(&self, r: &Request) -> bool { let cookie = r .headers() .iter() .find(|h| h.field.equiv("Cookie")) .map(|h| h.value.as_str()) .unwrap_or(""); cookie .split(';') .any(|c| c.trim() == format!("framely={}", self.web_key)) } fn network_authorized(&self, r: &Request) -> bool { let key = self.network_key.lock().unwrap(); r.headers() .iter() .find(|h| h.field.equiv("Cookie")) .is_some_and(|h| { h.value .as_str() .split(';') .any(|c| c.trim() == format!("framely-network={key}")) }) } fn native(&self, r: &Request) -> bool { r.headers() .iter() .any(|h| h.field.equiv("X-Framely-Native") && h.value.as_str() == self.native_key) } fn handle(&self, r: Request) -> Result<()> { self.handle_http(r, false) } fn handle_http(&self, mut r: Request, remote: bool) -> Result<()> { let path = r.url().split('?').next().unwrap_or("/").to_owned(); let expected_origin = if remote { let host = r .headers() .iter() .find(|h| h.field.equiv("Host")) .context("Missing Host")? .value .as_str(); let url = url::Url::parse(&format!("http://{host}"))?; ensure!( url.host_str() .is_some_and(|h| h.parse::().is_ok() || h == "localhost") && url.username().is_empty() && url.password().is_none() && url.path() == "/" && url.query().is_none() && url.fragment().is_none(), "Use the device IP address" ); url.origin().ascii_serialization() } else { self.origin.clone() }; if remote && (path.starts_with("/boot/") || path.starts_with("/host/")) { r.respond(Response::empty(StatusCode(403)))?; return Ok(()); } if remote { if let Some(resource) = path.strip_prefix("/plugin-resources/") { let (token, file) = resource.split_once('/').context("Invalid frame resource")?; let (id, version) = { let access = self.frame_access.lock().unwrap(); let grant = access.get(token).context("Frame resource expired")?; ensure!( grant.expires > std::time::Instant::now() && grant.network_key == *self.network_key.lock().unwrap(), "Frame resource expired" ); (grant.plugin.clone(), grant.version.clone()) }; let status = self.core("status", json!({}))?; let plugin = &status["database"]["plugins"][&id]; ensure!( status["agreement"]["accepted"] == true && plugin["enabled"] == true && status["database"]["safeMode"] == false && plugin["manifest"]["version"] == version, "Plugin disabled or changed" ); if file == "sdk.js" { return send_file(r, &self.assets.join("assets/plugin-bootstrap.js"), true); } let file = file .strip_prefix("plugin/") .context("Invalid frame resource")?; safe_path(file)?; ensure!( plugin["manifest"]["files"].get(file).is_some(), "Unlisted asset" ); return send_file( r, &self .state .join("plugins") .join(id) .join("versions") .join(version) .join(file), true, ); } } // Public, compile-time branding only. No other assets bypass network login. let branding: Option<(&[u8], &str)> = match path.as_str() { "/assets/branding/framely-mark-light.svg" => Some(( include_bytes!("../assets/branding/framely-mark-light.svg"), "image/svg+xml", )), "/assets/branding/framely-logo-light.svg" => Some(( include_bytes!("../assets/branding/framely-logo-light.svg"), "image/svg+xml", )), "/assets/branding/framely-app-icon.svg" => Some(( include_bytes!("../assets/branding/framely-app-icon.svg"), "image/svg+xml", )), "/assets/branding/framely-app-icon.png" => Some(( include_bytes!("../assets/branding/framely-app-icon.png"), "image/png", )), "/assets/branding/framely.ico" | "/favicon.ico" => Some(( include_bytes!("../assets/branding/framely.ico"), "image/x-icon", )), _ => None, }; if r.method() == &Method::Get { if let Some((data, mime)) = branding { r.respond( Response::from_data(data) .with_header(header("Content-Type", mime)) .with_header(header("X-Content-Type-Options", "nosniff")) .with_header(header("Cache-Control", "no-cache")), )?; return Ok(()); } } let configured = remote && self.core("network.password.configured", json!({}))? == true; if remote && (!configured || !self.network_authorized(&r)) { let status = self.core("status", json!({}))?; let protected = status["database"]["networkPanel"]["passwordEnabled"] .as_bool() .unwrap_or(false); if configured && !protected && r.method() == &Method::Get && (path == "/" || path == "/manager") { r.respond( Response::empty(StatusCode(302)) .with_header(header("Location", "/manager")) .with_header(header( "Set-Cookie", &format!( "framely-network={}; HttpOnly; SameSite=Strict; Path=/", self.network_key.lock().unwrap() ), )), )?; return Ok(()); } if path == "/setup" && r.method() == &Method::Post { ensure!( r.headers() .iter() .any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin setup denied" ); let mut body = String::new(); r.as_reader().take(8193).read_to_string(&mut body)?; ensure!(body.len() <= 8192, "Setup request too large"); let fields: std::collections::BTreeMap<_, _> = url::form_urlencoded::parse(body.as_bytes()) .into_owned() .collect(); match self.core("network.password.setup", json!({"password":fields.get("password"),"confirmPassword":fields.get("confirmPassword")})) { Ok(_) => { *self.network_key.lock().unwrap() = random_key(); r.respond(Response::empty(StatusCode(303)).with_header(header("Location", "/manager")))?; } Err(error) => { r.respond(Response::from_string(format!("{error}。请返回重试。")) .with_status_code(409) .with_header(header("Content-Type", "text/plain; charset=utf-8")))?; } } return Ok(()); } if path == "/login" && r.method() == &Method::Post { if !configured { r.respond( Response::from_string("请先设置访问密码。") .with_status_code(409) .with_header(header("Content-Type", "text/plain; charset=utf-8")), )?; return Ok(()); } ensure!( r.headers() .iter() .any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin login denied" ); let mut body = String::new(); r.as_reader().take(8193).read_to_string(&mut body)?; ensure!(body.len() <= 8192, "Login request too large"); let code = url::form_urlencoded::parse(body.as_bytes()) .find(|(k, _)| k == "password") .map(|(_, v)| v.into_owned()); let peer = r .remote_addr() .map(|a| a.ip().to_string()) .unwrap_or_else(|| "unknown".into()); let key_before = self.network_key.lock().unwrap().clone(); let verified = match self.core( "network.password.verify", json!({"password":code.unwrap_or_default(),"peer":peer}), ) { Ok(value) => value.as_bool() == Some(true), Err(error) if error.to_string().contains("Login rate limit exceeded") => { r.respond( Response::from_string("登录尝试过多,请稍后重试。") .with_status_code(429) .with_header(header("Retry-After", "60")), )?; return Ok(()); } Err(error) => return Err(error), }; if verified && key_before == *self.network_key.lock().unwrap() { r.respond( Response::empty(StatusCode(302)) .with_header(header("Location", "/manager")) .with_header(header( "Set-Cookie", &format!( "framely-network={}; HttpOnly; SameSite=Strict; Path=/", self.network_key.lock().unwrap() ), )), )?; } else { r.respond( Response::from_string("访问密码不正确,请返回重试。") .with_header(header("Content-Type", "text/plain; charset=utf-8")) .with_status_code(401), )?; } return Ok(()); } if r.method() == &Method::Get && (path == "/" || path == "/manager") { r.respond(Response::from_string(if configured { NETWORK_LOGIN } else { NETWORK_SETUP }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?; } else { r.respond( Response::empty(StatusCode(401)) .with_header(header("X-Framely-Reauthenticate", "1")), )?; } return Ok(()); } if remote && r.method() == &Method::Get && path == "/" { r.respond( Response::empty(StatusCode(302)).with_header(header("Location", "/manager")), )?; return Ok(()); } if path == "/host/poll" { ensure!(!remote && self.native(&r), "Native authentication failed"); if r.method() == &Method::Post { let mut bytes = Vec::new(); r.as_reader().take(32769).read_to_end(&mut bytes)?; ensure!(bytes.len() <= 32768, "Visibility report too large"); let views: Value = serde_json::from_slice(&bytes)?; if views.is_object() { self.core("host.ui.visibility", views)?; } } let mut queue = self.commands.lock().unwrap(); let mut commands = Vec::new(); let mut size = 0; while let Some(command) = queue.front() { let n = serde_json::to_vec(command)?.len(); if !commands.is_empty() && size + n > 3 * 1024 * 1024 { break; } size += n; commands.push(queue.pop_front().unwrap()); } drop(queue); let badge = self .core("host.notification.badge", json!({})) .unwrap_or(json!(0)); return send_json( r, json!({"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":self.core("status",json!({})).ok().map(|v|v["database"]["launcher"].clone()).unwrap_or(json!({}))}), ); } if let Some(rel) = path.strip_prefix("/plugin-assets/") { let (id, file) = rel.split_once('/').context("Invalid asset path")?; valid_id(id)?; safe_path(file)?; let status = self.core("status", json!({}))?; ensure!( status["agreement"]["accepted"] == true, "请先同意用户协议和隐私声明" ); let p = status["database"]["plugins"][id].clone(); ensure!(!p.is_null(), "Unknown plugin"); let m: Manifest = serde_json::from_value(p["manifest"].clone())?; ensure!(m.files.contains_key(file), "Unlisted asset"); let root = self .state .join("plugins") .join(id) .join("versions") .join(&m.version); return send_file(r, &root.join(file), true); } if let Some(rel) = path .strip_prefix("/plugin-frame/") .or_else(|| path.strip_prefix("/plugin-action-frame/")) { let parts: Vec<_> = rel.split('/').collect(); ensure!(parts.len() == 2, "Invalid plugin frame"); let id = parts[0]; valid_id(id)?; let status = self.core("status", json!({}))?; ensure!( status["agreement"]["accepted"] == true, "请先同意用户协议和隐私声明" ); let p = &status["database"]["plugins"][id]; ensure!( p["enabled"] == true && status["database"]["safeMode"] == false, "Plugin disabled" ); let m: Manifest = serde_json::from_value(p["manifest"].clone())?; if let Some(e) = &m.engines { e.check()?; } let entry = if path.starts_with("/plugin-action-frame/") { let action = parts[1]; match &m .ui .launcher_actions .iter() .find(|a| a.id == action) .context("Unknown action")? .target { LauncherActionTarget::Frontend { entry } => entry.clone(), _ => anyhow::bail!("Not a frontend action"), } } else if parts[1] == "quick" { m.ui.quick_page.clone().context("No quick page")? } else { m.ui.windows .get(parts[1]) .context("Unknown window")? .entry .clone() }; let resource_origin = &expected_origin; let (sdk, script, base) = if remote { let token = random_key(); let mut grants = self.frame_access.lock().unwrap(); grants.retain(|_, grant| grant.expires > std::time::Instant::now()); if grants.len() >= 256 { let oldest = grants .iter() .min_by_key(|(_, g)| g.expires) .map(|(k, _)| k.clone()) .unwrap(); grants.remove(&oldest); } grants.insert( token.clone(), FrameAccess { plugin: id.into(), version: m.version.clone(), network_key: self.network_key.lock().unwrap().clone(), expires: std::time::Instant::now() + Duration::from_secs(24 * 60 * 60), }, ); let prefix = format!("{resource_origin}/plugin-resources/{token}"); ( format!("{prefix}/sdk.js"), format!("{prefix}/plugin/{entry}"), format!("{prefix}/plugin/"), ) } else { ( format!("{resource_origin}/assets/plugin-bootstrap.js"), format!("{resource_origin}/plugin-assets/{id}/{entry}"), format!("{resource_origin}/plugin-assets/{id}/"), ) }; let html=format!("
"); let csp=format!("default-src 'none'; script-src {}; style-src 'unsafe-inline' {}; img-src {} data: blob: https: http:; connect-src https: http: ws: wss:; font-src {} data:; base-uri {}; form-action 'none'; frame-src 'none'",resource_origin,resource_origin,resource_origin,resource_origin,resource_origin); let response = Response::from_string(html) .with_header(header("Content-Type", "text/html; charset=utf-8")) .with_header(header("Content-Security-Policy", &csp)) .with_header(header("Access-Control-Allow-Origin", "*")) .with_header(header("Cache-Control", "no-store")) .with_header(header("Referrer-Policy", "no-referrer")); r.respond(response)?; return Ok(()); } if let Some(rel) = path.strip_prefix("/assets/") { safe_path(rel)?; return send_file(r, &self.assets.join(&path[1..]), true); } if let Some(key) = path.strip_prefix("/boot/") { ensure!(key == self.web_key, "Invalid boot token"); let response = Response::empty(StatusCode(302)) .with_header(header( "Set-Cookie", &format!( "framely={}; HttpOnly; SameSite=Strict; Path=/", self.web_key ), )) .with_header(header( "Location", if r.url().ends_with("?view=manager") { "/manager" } else if r.url().ends_with("?view=notifications") { "/notifications" } else { "/" }, )); r.respond(response)?; return Ok(()); } ensure!( if remote { self.network_authorized(&r) } else { self.authorized(&r) }, "Session authentication failed" ); if path.starts_with("/host/files/") { ensure!( !remote && r.method() == &Method::Post, "File browser is only available inside Frame" ); ensure!( r.headers() .iter() .any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin file browser request denied" ); ensure!( self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明" ); let mut bytes = Vec::new(); r.as_reader().take(65537).read_to_end(&mut bytes)?; ensure!(bytes.len() <= 65536, "File browser request too large"); let result = (|| -> Result { let params: Value = serde_json::from_slice(&bytes)?; let home = crate::steam::home()?; match path.as_str() { "/host/files/list" => crate::file_browser::list(&home, ¶ms), "/host/files/apk-inspect" => { let paths = crate::file_browser::selection(&home, ¶ms)?; ensure!( paths.len() == 1 && params["directory"] != true, "Select one APK file" ); let path = paths.into_iter().next().unwrap(); self.jobs.task("apk:inspect", move |cancel| { crate::apk::inspect_local(&home, &path, &cancel) }) } "/host/files/complete" => { let id = params["id"] .as_str() .filter(|s| !s.is_empty() && s.len() <= 128) .context("Invalid file dialog")?; let paths = crate::file_browser::selection(&home, ¶ms)?; push( &self.commands, json!({"kind":"file.dialog.complete","id":id,"paths":paths}), ); Ok(json!(true)) } _ => anyhow::bail!("Unknown file browser operation"), } })(); return send_json( r, match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}), }, ); } if let Some(path) = path.strip_prefix("/api/upload/") { ensure!(r.method() == &Method::Post, "Invalid upload 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 API request denied" ); ensure!( self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明" ); let (id, offset) = path.split_once('/').context("Invalid upload path")?; let offset: u64 = offset.parse()?; let mut bytes = Vec::new(); r.as_reader() .take((crate::uploads::CHUNK + 1) as u64) .read_to_end(&mut bytes)?; let result = self.jobs.uploads.append(id, offset, &bytes)?; return send_json(r, json!({"result":result})); } if let Some(id) = path.strip_prefix("/manager-api/file-content/") { ensure!( matches!(r.method(), Method::Get | Method::Head), "Invalid download method" ); ensure!( !r.headers() .iter() .any(|h| h.field.equiv("Sec-Fetch-Site") && h.value.as_str() == "cross-site"), "Cross-origin download denied" ); ensure!( !r.headers() .iter() .any(|h| h.field.equiv("Origin") && h.value.as_str() != expected_origin), "Cross-origin download denied" ); ensure!( self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明" ); let (file, inline) = self.files.content(id)?; return serve_user_file(r, &file, inline); } if path == "/manager-api/files" || path.starts_with("/manager-api/file-upload/") { ensure!(r.method() == &Method::Post, "Invalid file manager method"); ensure!( r.headers() .iter() .any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin manager request denied" ); ensure!( self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明" ); if let Some(upload) = path.strip_prefix("/manager-api/file-upload/") { let (id, offset) = upload.split_once('/').context("Invalid upload path")?; let offset: u64 = offset.parse()?; let mut bytes = Vec::new(); r.as_reader() .take((crate::uploads::CHUNK + 1) as u64) .read_to_end(&mut bytes)?; let result = self.files.append(id, offset, &bytes); return send_json( r, match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}), }, ); } let mut bytes = Vec::new(); r.as_reader() .take(32 * 1024 * 1024 + 1) .read_to_end(&mut bytes)?; ensure!( bytes.len() <= 32 * 1024 * 1024, "File manager request too large" ); let result = (|| -> Result { let p: Value = serde_json::from_slice(&bytes)?; let operation = p["operation"].as_str().unwrap_or("list"); if matches!( operation, "copy" | "move" | "delete" | "search" | "compress" | "extract" | "download.batch" ) { let files = self.files.clone(); let home = crate::steam::home()?; return self.jobs.task_progress( &format!("files:{operation}:{}", crate::manager_files::token()), move |cancel, progress| files.task(&home, &p, cancel, progress), ); } self.files.api(&crate::steam::home()?, &p) })(); return send_json( r, match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}), }, ); } if path == "/manager-api/terminal" || path == "/manager-api/terminal/ws" { ensure!( r.headers() .iter() .any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin manager request denied" ); ensure!( self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明" ); if path.ends_with("/ws") { ensure!(r.method() == &Method::Get, "Invalid terminal method"); let query = r.url().split_once('?').map(|(_, q)| q).unwrap_or(""); let id = url::form_urlencoded::parse(query.as_bytes()) .find(|(k, _)| k == "id") .map(|(_, v)| v.into_owned()) .context("Missing terminal")?; let key = r .headers() .iter() .find(|h| h.field.equiv("Sec-WebSocket-Key")) .context("Missing WebSocket key")? .value .as_str(); ensure!(r.headers().iter().any(|h|h.field.equiv("Sec-WebSocket-Version")&&h.value.as_str()=="13"),"Unsupported WebSocket version"); ensure!( r.headers().iter().any(|h| h.field.equiv("Upgrade") && h.value.as_str().eq_ignore_ascii_case("websocket")), "Missing WebSocket upgrade" ); ensure!( r.headers().iter().any(|h| h.field.equiv("Connection") && h.value .as_str() .split(',') .any(|v| v.trim().eq_ignore_ascii_case("upgrade"))), "Missing WebSocket connection upgrade" ); let accept = tungstenite::handshake::derive_accept_key(key.as_bytes()); let key = self.network_key.lock().unwrap().clone(); let response = Response::empty(101).with_header(header("Sec-WebSocket-Accept", &accept)); let stream = r.upgrade("websocket", response); let config = tungstenite::protocol::WebSocketConfig::default() .max_message_size(Some(262144)) .max_frame_size(Some(262144)); let socket = tungstenite::WebSocket::from_raw_socket( stream, tungstenite::protocol::Role::Server, Some(config), ); let _ = self.terminals.websocket(&id, socket, || { !remote || *self.network_key.lock().unwrap() == key }); return Ok(()); } ensure!(r.method() == &Method::Post, "Invalid manager method"); let mut bytes = Vec::new(); r.as_reader().take(8193).read_to_end(&mut bytes)?; ensure!(bytes.len() <= 8192, "Manager request too large"); let result = (|| -> Result { self.terminals.api( &crate::steam::home()?, &serde_json::from_slice::(&bytes)?, ) })(); return send_json( r, match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}), }, ); } if path == "/api" && r.method() == &Method::Post { 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 API request denied" ); let mut bytes = Vec::new(); r.as_reader().read_to_end(&mut bytes)?; let mut request: Value = serde_json::from_slice(&bytes)?; if request["method"] == "diagnostics.export" { request["params"] = json!({"remote":remote}); } if remote && request["method"] == "ui.visibility.get" { request["params"] = json!({"view":""}); } let result = if remote && request["method"] == "agreement.decline" { Ok(json!({"closed":false})) } else { self.api(request) }; let v = match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}), }; return send_json(r, v); } if path == "/" || path == "/manager" || path == "/notifications" || path == "/launcher" || path.starts_with("/window/") { let data = fs::read(self.assets.join("index.html"))?; let data = if remote { 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("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(()); } r.respond(Response::from_string("Not found").with_status_code(404))?; Ok(()) } fn api(&self, v: Value) -> Result { let method = v["method"].as_str().context("Missing method")?; let p = v["params"].clone(); if !matches!( method, "status" | "language.list" | "language.save" | "agreement.accept" | "agreement.status" | "agreement.decline" | "agreement.revoke" | "ui.events" | "ui.visibility.get" | "host.manager.open" | "host.manager.close" | "host.menu.close" | "host.haptic" | "host.keyboard" ) { ensure!( self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明" ); } match method { "launcher.search.index" => { let names: Vec = serde_json::from_value(p["names"].clone())?; ensure!( names.len() <= 4096 && names.iter().map(String::len).sum::() <= 262144 && names .iter() .all(|n| n.len() <= 1024 && !n.chars().any(char::is_control)), "Invalid launcher search names" ); let parent = self .assets .parent() .context("Missing search asset directory")?; let packaged = parent.join("search/ipadic.dic.zst"); let path = if packaged.is_file() { packaged } else { // Source checkout: --assets ui/dist, with data in assets/search. parent .parent() .unwrap_or(Path::new(".")) .join("assets/search/ipadic.dic.zst") }; // HTTP requests run on workers, so dictionary loading never blocks native input. self.launcher_search.lock().unwrap().batch(&path, &names) } "apk.upload.start" => { let size = p["size"].as_u64().context("Missing APK upload size")?; let root = crate::apk::upload_dir(&crate::steam::home()?, size)?; self.jobs.uploads.start_in(size, &root) } "apk.selection" => { let app = p["app"].as_str().context("Missing APK application")?; ensure!(app.len() <= 512, "Invalid APK application"); self.events.lock().unwrap().append( json!({"kind":"apk.selection","app":app,"uninstall":p["uninstall"]==true}), ); Ok(json!(true)) } "apk.operation.active" => Ok(self.jobs.active_apk()), "apk.list.start" => { let home = crate::steam::home()?; self.jobs.task("apk:list", move |cancel| { cancel.check()?; crate::apk::list(&home) }) } "apk.inspect.start" => { let home = crate::steam::home()?; let staged = self .jobs .uploads .take(p["upload"].as_str().context("Missing APK upload")?)?; self.jobs.task("apk:inspect", move |cancel| { crate::apk::inspect(&home, &staged.path, &cancel) }) } "apk.review.drop" => crate::apk::reviews_drop( &crate::steam::home()?, p["ticket"].as_str().context("Missing APK review")?, ), "apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?), "apk.logs" => crate::apk::logs( &crate::steam::home()?, p["app"].as_str().context("Missing APK application")?, ), "apk.operation.start" => { let home = crate::steam::home()?; let kind = p["operation"] .as_str() .context("Missing APK operation")? .to_owned(); self.jobs .task_progress("apk:operation", move |cancel, progress| { crate::apk::operate(&home, &kind, &p, cancel, progress) }) } "desktop.list" => Ok(json!(crate::desktop::discover(&crate::steam::home()?))), "desktop.launch" => { let id = p["app"].as_str().context("Missing desktop app")?; let launcher_session = p["launcherSession"].as_u64(); if let Some(app) = id.strip_prefix("lepton:") { let commands = self.commands.clone(); crate::apk::launch_app_with_progress( &crate::steam::home()?, app, Arc::new(move |event| { if event["phase"] == "started" { push( &commands, json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}), ); } }), )?; } else { crate::desktop::launch(&crate::steam::home()?, id)?; push( &self.commands, json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}), ); } Ok(json!(true)) } "steam.list" => { if p["remoteOnly"] == true { Ok(crate::steam::remote_snapshot()) } else { Ok(json!(crate::steam::discover_launcher( &crate::steam::home()? ))) } } "steam.launch" => { let launcher_session = p["launcherSession"].as_u64(); let id = p["app"] .as_u64() .filter(|v| *v > 0 && *v <= u32::MAX as u64) .context("Invalid Steam app")? as u32; if let Some(client) = p["remoteClient"].as_str() { crate::steam::launch_remote(id, client)?; } else { crate::steam::launch(id)?; } push( &self.commands, json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}), ); Ok(json!(true)) } "host.launcher.calibration.target" => { let id = p["id"] .as_str() .filter(|id| !id.is_empty() && id.len() <= 128) .context("Invalid calibration target")?; for coordinate in ["x", "y"] { let value = p[coordinate] .as_f64() .context("Invalid calibration target")?; ensure!( value.is_finite() && (0.0..=1.0).contains(&value), "Invalid calibration target" ); } let preview: Option = serde_json::from_value(p["preview"].clone())?; if let Some(calibration) = &preview { calibration.validate_preview()?; } push( &self.commands, json!({"kind":"gaze.target","id":id,"x":p["x"],"y":p["y"],"preview":preview}), ); Ok(json!(true)) } "host.launcher.calibrate" => { push( &self.commands, json!({"kind":"launcher.calibrate","active":p["active"].as_bool().unwrap_or(true)}), ); Ok(json!(true)) } "host.launcher.close" => { push(&self.commands, json!({"kind":"launcher.close"})); Ok(json!(true)) } "host.plugin.manage" => { let id = p["plugin"].as_str().context("Missing plugin")?; valid_id(id)?; push( &self.commands, json!({"kind":"manager.open","page":format!("plugin:{id}")}), ); Ok(json!(true)) } "agreement.decline" => { let view = p["view"].as_str().context("Missing view")?; ensure!( view == "menu" || view == "launcher" || view == "framely.manager" || view.starts_with("framely.window."), "Invalid view" ); push( &self.commands, json!({"kind":"agreement.decline","view":view}), ); Ok(json!({"closed":true})) } "catalog" => self.catalog(), "catalog.start" => { let status = self.core("status", json!({}))?; let sources: Vec = serde_json::from_value(status["database"]["sources"].clone())?; let cache = self.catalog_cache.clone(); let proxy = serde_json::from_value(status["database"]["proxy"].clone())?; let kind = format!( "catalog:{}", crate::package::digest(&serde_json::to_vec( &json!({"sources":sources,"proxy":proxy}) )?) ); self.jobs.task_progress(&kind, move |cancel, progress| { fetch_catalogs_progress(sources, proxy, cache, &cancel, progress.as_ref()) }) } "catalog.versions.start" => { let plugin_id = p["pluginId"] .as_str() .context("Missing plugin ID")? .to_owned(); valid_id(&plugin_id)?; let source_id = p["source"].as_str().context("Missing source ID")?; let status = self.core("status", json!({}))?; let sources: Vec = serde_json::from_value(status["database"]["sources"].clone())?; let source = sources .into_iter() .find(|s| s.id == source_id && s.enabled) .context("插件源不存在或已停用")?; let proxy = self.proxy()?; self.jobs.task( &format!("versions:{}:{}", source.id, plugin_id), move |cancel| { Ok(json!(fetch_plugin_versions_cancel( &source, &plugin_id, &proxy, &cancel )?)) }, ) } "subscriptions.preview.start" => { let url = p["url"].as_str().context("缺少订阅 URL")?.to_owned(); let proxy = self.proxy()?; self.jobs.task("subscription", move |cancel| { let (document, _, _) = crate::subscriptions::fetch_cancel(&url, None, None, &proxy, &cancel)?; Ok(json!({"url":url,"document":document.context("订阅未返回内容")?})) }) } "subscriptions.refresh.start" => { let id = p["id"].as_str().context("缺少订阅 ID")?.to_owned(); let socket = self.socket.clone(); let cache = self.catalog_cache.clone(); self.jobs .task(&format!("subscription:{id}"), move |cancel| { refresh_subscription(&socket, &id, cache, &cancel) }) } "source.test" => { let source: Source = serde_json::from_value(p["source"].clone())?; source.validate()?; let proxy = self.proxy()?; self.jobs.task("source", move |cancel| { let catalog = fetch_catalog_proxy_cancel(&source, &proxy, &cancel)?; Ok(json!({"name":catalog.name,"count":catalog.plugins.iter().map(|p|&p.id).collect::>().len()})) }) } "upload.start" => self .jobs .uploads .start(p["size"].as_u64().context("Missing upload size")?), "upload.abort" => self .jobs .uploads .abort(p["upload"].as_str().context("Missing upload")?), "inspect.start" => self.jobs.inspect(self.socket.clone(), p), "inspect.resolve.start" => self.jobs.resolve(self.socket.clone(), p), "install.start" => self.jobs.install(self.socket.clone(), p), "job.status" => self.jobs.status(p["job"].as_str().context("Missing job")?), "job.cancel" => self.jobs.cancel(p["job"].as_str().context("Missing job")?), "ui.visibility.get" => { let mut state = self.core("ui.visibility.get", json!({}))?; state["pageVisible"] = json!( state["known"] == true && state["views"][p["view"].as_str().unwrap_or("")] == true ); state.as_object_mut().unwrap().remove("views"); Ok(state) } "ui.events" => { let result = self .events .lock() .unwrap() .after(p["cursor"].as_u64().unwrap_or(0)); Ok(result) } "host.manager.open" | "host.manager.close" | "host.menu.close" => { let kind = match method { "host.manager.open" => "manager.open", "host.manager.close" => "manager.close", _ => "menu.close", }; let mut command = json!({"kind":kind}); if method == "host.manager.open" && matches!( p["page"].as_str(), Some( "catalog" | "settings" | "updates" | "notification-settings" | "launcher-settings" | "apk" | "apk-settings" ) ) { command["page"] = p["page"].clone(); } push(&self.commands, command); Ok(json!(true)) } "network.status" => Ok( json!({"passwordConfigured":self.core("network.password.configured",json!({}))?,"localOrigin":self.origin,"ips":network_ipv4_addresses(),"listener":self.network_status.lock().unwrap().clone()}), ), "network.save" => { let mut config_params = p.clone(); config_params .as_object_mut() .context("Invalid settings")? .remove("password"); let config: NetworkPanel = serde_json::from_value(config_params)?; config.validate()?; let current = self.network_status.lock().unwrap().clone(); if config.enabled && current["port"].as_u64() != Some(config.port as u64) { let probe = Server::http(("0.0.0.0", config.port)) .map_err(|e| anyhow::anyhow!("端口不可用:{e}"))?; drop(probe); } let previous: NetworkPanel = serde_json::from_value( self.core("status", json!({}))?["database"]["networkPanel"].clone(), )?; let password_changed = p["password"].as_str().is_some_and(|v| !v.is_empty()); self.core("network.save", p)?; let changed = config != previous || password_changed; if changed { *self.network_key.lock().unwrap() = random_key(); } Ok(json!({"saved":true,"reauthenticate":changed})) } "host.external.open.start" => { let url = p["url"].as_str().context("Missing web link")?.to_owned(); crate::model::validate_web_link(&url)?; self.jobs.task("external-link", move |cancel| { cancel.commit(|| crate::process::open_web_link(&url))?; Ok(json!(true)) }) } "diagnostics.export" => { use base64::Engine; let mut result = self.core("diagnostics.export", json!({}))?; if p["remote"] != true { let name = result["name"].as_str().context("Missing archive name")?; ensure!( name.starts_with("framely-logs-") && name.ends_with(".zip") && !name.contains('/') && !name.contains('\\'), "Invalid archive name" ); let bytes = base64::engine::general_purpose::STANDARD .decode(result["data"].as_str().context("Missing archive data")?)?; let dir = PathBuf::from(std::env::var_os("HOME").context("Missing home directory")?) .join("Downloads"); fs::create_dir_all(&dir)?; let file = dir.join(format!( "{}-{}.zip", name.trim_end_matches(".zip"), random_key() )); use std::io::Write; use std::os::unix::fs::OpenOptionsExt; fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&file)? .write_all(&bytes)?; result["path"] = json!(file); result.as_object_mut().unwrap().remove("data"); } Ok(result) } "host.haptic" => { let view = p["view"].as_str().context("Missing haptic view")?; ensure!( view.len() <= 160 && (matches!( view, "menu" | "launcher" | "framely.manager" | "notifications" ) || view.starts_with("framely.window.")), "Invalid haptic view" ); let mut commands = self.commands.lock().unwrap(); if !commands .iter() .any(|c| c["kind"] == "haptic" && c["view"] == view) { ensure!(commands.len() < 256, "Native command queue full"); commands.push_back(json!({"kind":"haptic","view":view})); } Ok(json!(true)) } "host.keyboard" => { push( &self.commands, json!({"kind":"keyboard","view":p["view"],"existing":p["existing"],"password":p["password"],"multiline":p["multiline"]}), ); Ok(json!(true)) } "host.notifications.empty" => { let s = self.core("status", json!({}))?; if s["notifications"].as_array().is_some_and(Vec::is_empty) { push(&self.commands, json!({"kind":"notifications.empty"})); } Ok(json!(true)) } "status" | "inspect" | "install" | "plugin.restart" | "plugin.enable" | "plugin.enable.preview" | "plugin.disable.preview" | "plugin.dependents" | "plugin.dependencies" | "plugin.favorite" | "plugin.order" | "plugin.uninstall" | "plugin.open" | "plugin.call" | "plugin.launch.context" | "plugin.launch" | "plugin.launcher.action" | "launcher.settings.save" | "launcher.gaze.calibration.get" | "launcher.gaze.calibration.save" | "launcher.order.save" | "launcher.favorite" | "steam.favorite" | "window.open" | "window.close" | "notification.send" | "notification.dismiss" | "notification.remove" | "notification.action" | "notification.settings.save" | "notification.read" | "sources.save" | "subscriptions.add" | "subscriptions.change" | "agreement.revoke" | "agreement.accept" | "agreement.status" | "language.list" | "language.save" | "language.install" | "proxy.save" | "safeMode" | "system.source.save" | "system.channel.save" | "system.check.settings.save" | "system.check.start" | "system.download.start" | "system.job.status" | "system.apply" | "system.rollback" | "logs" => self.core(method, p), _ => anyhow::bail!("Unknown UI method"), } } fn proxy(&self) -> Result { Ok(serde_json::from_value( self.core("status", json!({}))?["database"]["proxy"].clone(), )?) } fn catalog(&self) -> Result { let status = self.core("status", json!({}))?; let sources: Vec = serde_json::from_value(status["database"]["sources"].clone())?; Ok(fetch_catalogs_proxy( sources, serde_json::from_value(status["database"]["proxy"].clone())?, self.catalog_cache.clone(), )) } } pub(crate) fn fetch_catalog_proxy_cancel( source: &Source, proxy: &crate::model::ProxySettings, cancel: &crate::jobs::Cancellation, ) -> Result { cancel.check()?; source.validate()?; let response = crate::http::get_with_proxy_cancel( &source.url, source.allow_http, Duration::from_secs(12), &[], proxy, cancel, ) .context("插件源连接失败")?; let bytes = crate::http::read_cancel(response, 2 * 1024 * 1024, cancel)?; ensure!(bytes.len() <= 2 * 1024 * 1024, "插件目录超过 2 MiB"); let mut catalog: Catalog = serde_json::from_slice(&bytes).context("目录格式无效")?; ensure!( catalog.schema_version == 1 && catalog.plugins.len() <= 1000, "不支持的插件目录" ); let mut ids = std::collections::BTreeSet::new(); for p in &mut catalog.plugins { p.validate(source.allow_http)?; p.compatible = Some( p.api_version == API_VERSION && p.engines .as_ref() .is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION"))), ); ensure!(ids.insert(&p.id), "主目录中有重复插件 ID"); } Ok(catalog) } pub(crate) fn fetch_plugin_versions_cancel( source: &Source, plugin_id: &str, proxy: &crate::model::ProxySettings, cancel: &crate::jobs::Cancellation, ) -> Result> { source.validate()?; valid_id(plugin_id)?; let url = url::Url::parse(&source.url)?.join(&format!("plugins/{plugin_id}/versions.json"))?; let response = crate::http::get_with_proxy_cancel( url.as_str(), source.allow_http, Duration::from_secs(12), &[], proxy, cancel, )?; let bytes = crate::http::read_cancel(response, 2 * 1024 * 1024, cancel)?; let mut document: PluginVersions = serde_json::from_slice(&bytes).context("历史版本格式无效")?; ensure!( document.schema_version == 1 && document.id == plugin_id && document.versions.len() <= 1000, "不支持的插件历史版本目录" ); let mut versions = std::collections::BTreeSet::new(); for entry in &mut document.versions { entry.validate(source.allow_http)?; entry.compatible = Some( entry.api_version == API_VERSION && entry .engines .as_ref() .is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION"))), ); ensure!( entry.id == plugin_id && versions.insert(&entry.version), "历史版本 ID 不匹配或版本重复" ); } Ok(document.versions) } fn refresh_subscription( socket: &Path, id: &str, cache: Arc>>, cancel: &crate::jobs::Cancellation, ) -> Result { cancel.check()?; let status = ipc::call(socket, "status", json!({}))?; let database: crate::model::Database = serde_json::from_value(status["database"].clone())?; let sub = database .subscriptions .iter() .find(|s| s.id == id) .context("订阅不存在")?; ensure!(sub.enabled, "订阅已停用"); let snapshot = crate::package::digest(&serde_json::to_vec(sub)?); let result = crate::subscriptions::fetch_cancel( &sub.url, sub.etag.as_deref(), sub.last_modified.as_deref(), &database.proxy, cancel, ); let request = match &result { Ok((doc, etag, modified)) => { json!({"id":id,"snapshot":snapshot,"document":doc,"etag":etag,"lastModified":modified,"error":null}) } Err(e) => { json!({"id":id,"snapshot":snapshot,"document":null,"etag":null,"lastModified":null,"error":e.to_string()}) } }; cancel.commit(|| ipc::call(socket, "subscriptions.apply", request))?; result?; let status = ipc::call(socket, "status", json!({}))?; let sources = serde_json::from_value(status["database"]["sources"].clone())?; Ok( json!({"catalogs":fetch_catalogs_cancel(sources,serde_json::from_value(status["database"]["proxy"].clone())?,cache,cancel)?}), ) } #[cfg(test)] fn fetch_catalogs( sources: Vec, cache: Arc>>, ) -> Value { fetch_catalogs_proxy(sources, Default::default(), cache) } fn fetch_catalogs_proxy( sources: Vec, proxy: crate::model::ProxySettings, cache: Arc>>, ) -> Value { fetch_catalogs_cancel(sources, proxy, cache, &Default::default()) .unwrap_or_else(|e| json!({"error":e.to_string()})) } fn fetch_catalogs_cancel( sources: Vec, proxy: crate::model::ProxySettings, cache: Arc>>, cancel: &crate::jobs::Cancellation, ) -> Result { fetch_catalogs_progress(sources, proxy, cache, cancel, &|_| {}) } fn fetch_catalogs_progress( sources: Vec, proxy: crate::model::ProxySettings, cache: Arc>>, cancel: &crate::jobs::Cancellation, progress: &(dyn Fn(Value) + Send + Sync), ) -> Result { cancel.check()?; let sources: Vec<_> = sources.into_iter().filter(|s| s.enabled).collect(); let mut results = vec![]; let completed = Mutex::new(Vec::::new()); let total_sources = sources.len(); progress(json!({"completedSources":0,"totalSources":total_sources,"partial":[]})); for chunk in sources.chunks(8) { cancel.check()?; let batch = std::thread::scope(|scope| { let handles: Vec<_> = chunk.iter().cloned().map(|source| { let cache=cache.clone();let proxy=proxy.clone();let completed=&completed;scope.spawn(move || { let key=format!("{}:{}",source.id,source.url); let value=match fetch_catalog_proxy_cancel(&source,&proxy,cancel) { Ok(catalog) => { let value=json!({"source":source,"catalog":catalog,"fetchedAt":crate::service::now_ms(),"cached":false}); let _=cancel.commit(|| { let mut map=cache.lock().unwrap(); if map.len()>=200 && !map.contains_key(&key) { if let Some(old)=map.keys().next().cloned(){map.remove(&old);} } map.insert(key,value.clone());Ok(()) }); value } Err(error) => { let mut value=cache.lock().unwrap().get(&key).cloned().unwrap_or_else(||json!({"source":source})); value["cached"]=json!(value.get("catalog").is_some());value["error"]=json!(format!("{error:#}"));value } }; if cancel.check().is_ok(){let mut partial=completed.lock().unwrap();partial.push(value.clone());progress(json!({"completedSources":partial.len(),"totalSources":total_sources,"partial":*partial}));} value }) }).collect(); handles .into_iter() .map(|h| { h.join() .unwrap_or_else(|_| json!({"error":"插件源任务异常"})) }) .collect::>() }); results.extend(batch); } cancel.check()?; Ok(json!(results)) } fn send_json(r: Request, v: Value) -> Result<()> { r.respond( Response::from_string(serde_json::to_string(&v)?) .with_header(header("Content-Type", "application/json")) .with_header(header("Cache-Control", "no-store")), )?; Ok(()) } fn send_file(r: Request, p: &Path, cors: bool) -> Result<()> { let data = fs::read(p)?; let mime = match p.extension().and_then(|e| e.to_str()) { Some("js") => "text/javascript", Some("css") => "text/css", Some("png") => "image/png", Some("jpg" | "jpeg") => "image/jpeg", Some("svg") => "image/svg+xml", Some("woff2") => "font/woff2", _ => "application/octet-stream", }; let mut response = Response::from_data(data) .with_header(header("Content-Type", mime)) .with_header(header("X-Content-Type-Options", "nosniff")); if cors { response = response.with_header(header("Access-Control-Allow-Origin", "*")); } r.respond(response)?; Ok(()) } fn serve_user_file(r: Request, path: &Path, inline: bool) -> Result<()> { use std::io::{Seek, SeekFrom}; use std::os::unix::fs::OpenOptionsExt; let mut file = fs::OpenOptions::new() .read(true) .custom_flags(libc::O_NONBLOCK) .open(path)?; ensure!(file.metadata()?.is_file(), "Select a regular file"); let length = file.metadata()?.len(); let range = r .headers() .iter() .find(|h| h.field.equiv("Range")) .map(|h| h.value.as_str()); let mut start = 0; let mut end = length.saturating_sub(1); let mut partial = false; if let Some(range) = range { let parsed = (|| -> Result<(u64, u64)> { let value = range.strip_prefix("bytes=").context("Invalid range")?; ensure!(!value.contains(','), "Multiple ranges are not supported"); let (first, last) = value.split_once('-').context("Invalid range")?; let (first, last) = if first.is_empty() { let size: u64 = last.parse()?; ensure!(size > 0, "Invalid range"); (length.saturating_sub(size), length.saturating_sub(1)) } else { let first: u64 = first.parse()?; let last = if last.is_empty() { length.saturating_sub(1) } else { last.parse::()?.min(length.saturating_sub(1)) }; (first, last) }; ensure!( length > 0 && first < length && first <= last, "Range is outside the file" ); Ok((first, last)) })(); match parsed { Ok((a, b)) => { start = a; end = b; partial = true; } Err(_) => { r.respond( Response::empty(416) .with_header(header("Content-Range", &format!("bytes */{length}"))), )?; return Ok(()); } } } let size = if length == 0 { 0 } else { end - start + 1 }; file.seek(SeekFrom::Start(start))?; let mime = if inline { crate::manager_files::media_type(path).unwrap_or("application/octet-stream") } else { "application/octet-stream" }; let filename = path.file_name().unwrap_or_default().to_string_lossy(); let encoded = url::form_urlencoded::byte_serialize(filename.as_bytes()) .collect::() .replace('+', "%20"); let mut headers = vec![ header("Content-Type", mime), header("Accept-Ranges", "bytes"), header("X-Content-Type-Options", "nosniff"), header("Cache-Control", "no-store"), header("Content-Security-Policy", "default-src 'none'; sandbox"), header( "Content-Disposition", &format!( "{}; filename*=UTF-8''{encoded}", if inline { "inline" } else { "attachment" } ), ), ]; if partial { headers.push(header( "Content-Range", &format!("bytes {start}-{end}/{length}"), )); } let response = Response::new( StatusCode(if partial { 206 } else { 200 }), headers, file.take(size), Some(size as usize), None, ); r.respond(response)?; Ok(()) } pub fn serve( socket: PathBuf, state: PathBuf, assets: PathBuf, native: PathBuf, http_only: bool, ) -> Result<()> { ensure!( unsafe { libc::geteuid() } != 0, "UI session must not run as root" ); crate::gamepad::session_started(); crate::apk::steam_session_started()?; crate::apk::lifecycle_started(); let server = Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!(e.to_string()))?; let port = server .server_addr() .to_ip() .context("Missing HTTP address")? .port(); let origin = format!("http://127.0.0.1:{port}"); let web_key = random_key(); let native_key = random_key(); let runtime = std::env::var_os("XDG_RUNTIME_DIR") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("/tmp")); let runtime = runtime.join(format!("framely-session-{}", std::process::id())); fs::create_dir(&runtime)?; use std::os::unix::fs::PermissionsExt; fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?; let agent = Arc::new(Agent { socket, state, assets, origin: origin.clone(), web_key: web_key.clone(), network_key: Mutex::new(random_key()), network_status: Mutex::new(json!({"enabled":false})), frame_access: Mutex::default(), native_key: native_key.clone(), commands: Arc::default(), events: Mutex::default(), jobs: crate::jobs::Jobs::default(), launcher_search: Mutex::default(), terminals: crate::terminal::Terminals::default(), files: Arc::default(), catalog_cache: Arc::default(), }); let file_agent = agent.clone(); std::thread::spawn(move || loop { if let Ok(home) = crate::steam::home() { let _ = file_agent.files.maintain(&home); } std::thread::sleep(Duration::from_secs(3600)); }); let network_agent = agent.clone(); std::thread::spawn(move || network_listener(network_agent, || true)); let subscriptions_agent = agent.clone(); std::thread::spawn(move || loop { if let Ok(status) = subscriptions_agent.core("status", json!({})) { if status["agreement"]["accepted"] != true { std::thread::sleep(Duration::from_secs(5)); continue; } if let Ok(db) = serde_json::from_value::(status["database"].clone()) { for sub in db.subscriptions.into_iter().filter(|s| { s.enabled && s.auto_refresh && s.next_refresh <= crate::service::now_ms() }) { let socket = subscriptions_agent.socket.clone(); let cache = subscriptions_agent.catalog_cache.clone(); let id = sub.id; let _ = subscriptions_agent .jobs .task(&format!("subscription:{id}"), move |cancel| { refresh_subscription(&socket, &id, cache, &cancel) }); } } } std::thread::sleep(Duration::from_secs(30)); }); let event_agent = agent.clone(); std::thread::spawn(move || loop { if let Ok(events) = event_agent.core("events", json!({})) { if let Some(events) = events.as_array() { for e in events { event_agent.events.lock().unwrap().append(e.clone()); if [ "window.open", "window.close", "plugin.disabled", "notification.changed", "manager.open", "menu.open", ] .contains(&e["kind"].as_str().unwrap_or("")) { let mut command = e.clone(); if let Some(path) = command["spec"]["iconPath"].as_str() { if let Ok(bytes) = fs::read(path) { if bytes.len() <= 1024 * 1024 { use base64::Engine; command["spec"]["iconData"] = json!( base64::engine::general_purpose::STANDARD.encode(bytes) ); } } command["spec"].as_object_mut().unwrap().remove("iconPath"); } push(&event_agent.commands, command); } } } } std::thread::sleep(Duration::from_millis(200)); }); if !http_only { let run = runtime.clone(); let url = format!("{origin}/boot/{web_key}"); std::thread::spawn(move || loop { wait_for_steam_session(); let result = Command::new(&native) .arg("--url") .arg(&url) .arg("--port") .arg(port.to_string()) .arg("--runtime") .arg(&run) .args([ "--disable-background-networking", "--disable-component-update", "--no-default-browser-check", ]) .env("FRAMELY_NATIVE_TOKEN", &native_key) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status(); eprintln!("Native host stopped: {:?}; retrying in 3 seconds", result); std::thread::sleep(Duration::from_secs(3)); }); } eprintln!("Framely UI session listening on 127.0.0.1:{port}"); for r in server.incoming_requests() { let a = agent.clone(); std::thread::spawn(move || { let _ = a.handle(r); }); } Ok(()) } fn network_ipv4_addresses() -> Vec { let mut addresses = std::collections::BTreeSet::new(); unsafe { let mut first: *mut libc::ifaddrs = std::ptr::null_mut(); if libc::getifaddrs(&mut first) != 0 { return Vec::new(); } let mut current = first; while !current.is_null() { let interface = &*current; if !interface.ifa_addr.is_null() && interface.ifa_flags & libc::IFF_UP as u32 != 0 && interface.ifa_flags & libc::IFF_LOOPBACK as u32 == 0 && (*interface.ifa_addr).sa_family as i32 == libc::AF_INET { let socket = &*(interface.ifa_addr as *const libc::sockaddr_in); let ip = std::net::Ipv4Addr::from(socket.sin_addr.s_addr.to_ne_bytes()); if !ip.is_loopback() && !ip.is_unspecified() { addresses.insert(ip.to_string()); } } current = interface.ifa_next; } libc::freeifaddrs(first); } addresses.into_iter().collect() } const NETWORK_SETUP: &str = r#"Framely · 设置访问密码
Framely

设置访问密码

首次访问网络管理面板,请设置至少 8 个字符的密码。保存后使用该密码登录。

"#; const NETWORK_LOGIN: &str = r#"Framely

Framely

输入你在设备设置中配置的访问密码。

"#; fn network_listener(agent: Arc, keep_running: impl Fn() -> bool) { let mut server: Option = None; let mut active: Option = None; while keep_running() { if let Ok(status) = agent.core("status", json!({})) { let config = serde_json::from_value::(status["database"]["networkPanel"].clone()) .unwrap_or_default(); if active.as_ref() != Some(&config) { server = None; active = None; if config.enabled { match Server::http(("0.0.0.0", config.port)) { Ok(listener) => { server = Some(listener); active = Some(config.clone()); *agent.network_status.lock().unwrap() = json!({"enabled":true,"port":config.port}); } Err(e) => { *agent.network_status.lock().unwrap() = json!({"enabled":false,"error":format!("端口 {} 无法监听:{e}",config.port)}); } } } else { active = Some(config); *agent.network_status.lock().unwrap() = json!({"enabled":false}); } } } if let Some(listener) = &server { if let Ok(Some(request)) = listener.recv_timeout(Duration::from_millis(500)) { let a = agent.clone(); std::thread::spawn(move || { let _ = a.handle_http(request, true); }); } } else { std::thread::sleep(Duration::from_millis(500)); } } } fn steam_session_ready(states: &str) -> bool { let states: Vec<_> = states.lines().collect(); states.len() == 3 && states.iter().all(|state| *state == "active") } fn wait_for_steam_session() { loop { let ready = Command::new("systemctl") .args([ "--user", "is-active", "steamvr.service", "gamescope-session.service", "steam.service", ]) .stderr(Stdio::null()) .output() .is_ok_and(|output| { output.status.success() && steam_session_ready(&String::from_utf8_lossy(&output.stdout)) }); if ready { return; } std::thread::sleep(Duration::from_secs(1)); } } #[cfg(test)] mod tests { #[test] fn catalog_progress_reports_fast_source_and_cancellation_does_not_wait_for_slow_source() { use std::sync::mpsc; let fast = Server::http("127.0.0.1:0").unwrap(); let slow = Server::http("127.0.0.1:0").unwrap(); let source = |id: &str, port: String| Source { id: id.into(), name: id.into(), url: format!("http://{port}/catalog.json"), enabled: true, allow_http: true, }; let sources = vec![ source("fast", fast.server_addr().to_string()), source("slow", slow.server_addr().to_string()), ]; let fast_worker = std::thread::spawn(move || { let req = fast.recv().unwrap(); req.respond(Response::from_string( r#"{"schemaVersion":1,"name":"Fast source","plugins":[]}"#, )) .unwrap(); }); let (release_tx, release_rx) = mpsc::channel(); let (started_tx, started_rx) = mpsc::channel(); let slow_worker = std::thread::spawn(move || { let req = slow.recv().unwrap(); started_tx.send(()).unwrap(); release_rx.recv().unwrap(); let _ = req.respond(Response::empty(500)); }); let (tx, rx) = mpsc::channel(); let token = crate::jobs::Cancellation::default(); let worker_token = token.clone(); let worker = std::thread::spawn(move || { fetch_catalogs_progress( sources, Default::default(), Default::default(), &worker_token, &move |v| { let _ = tx.send(v); }, ) }); loop { let event = rx.recv_timeout(Duration::from_secs(2)).unwrap(); if event["completedSources"] == 1 { assert_eq!(event["partial"][0]["source"]["id"], "fast"); assert_eq!(event["totalSources"], 2); break; } } // The cancellation check needs an in-flight request, otherwise the mock // server can wait forever for a request cancelled before connecting. started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); let started = std::time::Instant::now(); token.stop(); assert!(worker.join().unwrap().is_err()); assert!(started.elapsed() < Duration::from_millis(500)); release_tx.send(()).unwrap(); slow_worker.join().unwrap(); fast_worker.join().unwrap(); } #[test] fn native_workers_require_all_steam_services() { assert!(super::steam_session_ready("active\nactive\nactive\n")); for states in [ "active\ninactive\nactive\n", "activating\nactive\nactive\n", "active\ndeactivating\nactive\n", "active\n", "", "active\nactive\nactive\nactive\n", ] { assert!(!super::steam_session_ready(states), "{states:?}"); } } use super::*; use crate::package; use base64::{engine::general_purpose::STANDARD, Engine}; use std::os::unix::net::UnixListener; use std::sync::atomic::{AtomicBool, Ordering}; #[test] fn binary_upload_endpoint_requires_auth_origin_and_accepts_out_of_order_chunks() { let root = tempfile::tempdir().unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&server, root.path()); 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 = std::thread::spawn(move || { while !core_stop.load(Ordering::Relaxed) { if let Ok((mut stream, _)) = socket.accept() { let request = ipc::read(&mut stream).unwrap(); assert_eq!(request["method"], "agreement.status"); ipc::write(&mut stream, &json!({"result":{"accepted":true}})).unwrap(); } else { std::thread::sleep(Duration::from_millis(5)); } } }); let worker = http_server(server, agent.clone(), stop.clone()); let id = agent.jobs.uploads.start(3).unwrap()["upload"] .as_str() .unwrap() .to_owned(); let url = format!("{}/api/upload/{id}/0", agent.origin); let client = ureq::AgentBuilder::new() .timeout(Duration::from_secs(3)) .build(); assert!(client .post(&url) .set("Origin", &agent.origin) .send_bytes(b"a") .is_err()); let cookie = format!("framely={}", agent.web_key); assert!(client .post(&url) .set("Cookie", &cookie) .set("Origin", "http://evil.test") .send_bytes(b"a") .is_err()); let tail: Value = client .post(&format!("{}/api/upload/{id}/2", agent.origin)) .set("Cookie", &cookie) .set("Origin", &agent.origin) .send_bytes(b"c") .unwrap() .into_json() .unwrap(); assert_eq!(tail["result"]["received"], 3); assert_eq!(tail["result"]["uploadedBytes"], 1); assert!(agent.jobs.uploads.take(&id).is_err()); let response: Value = client .post(&url) .set("Cookie", &cookie) .set("Origin", &agent.origin) .send_bytes(b"ab") .unwrap() .into_json() .unwrap(); assert_eq!(response["result"]["received"], 2); assert!(client .post(&url) .set("Cookie", &cookie) .set("Origin", &agent.origin) .send_bytes(b"c") .is_err()); client .post(&format!("{}/api/upload/{id}/2", agent.origin)) .set("Cookie", &cookie) .set("Origin", &agent.origin) .send_bytes(b"c") .unwrap(); let staged = agent.jobs.uploads.take(&id).unwrap(); assert_eq!(std::fs::read(&staged.path).unwrap(), b"abc"); stop.store(true, Ordering::Relaxed); worker.join().unwrap(); core.join().unwrap(); } #[test] fn event_cursors_do_not_steal_events_from_other_views() { let mut log = EventLog::default(); log.append(json!({"kind":"plugin.event","plugin":"a"})); assert_eq!(log.after(0), log.after(0)); assert_eq!(log.after(0)["events"].as_array().unwrap().len(), 1); assert!(log.after(1)["events"].as_array().unwrap().is_empty()); for _ in 0..300 { log.append(json!({"kind":"plugin.event"})); } assert_eq!(log.after(0)["events"].as_array().unwrap().len(), 256); assert_eq!(log.after(0)["cursor"], 301); } fn agent(server: &Server, root: &Path) -> Arc { Arc::new(Agent { socket: root.join("control.sock"), state: root.join("state"), assets: std::env::var_os("FRAMELY_TEST_ASSETS") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ui/dist")), origin: format!("http://{}", server.server_addr()), web_key: random_key(), network_key: Mutex::new(random_key()), network_status: Mutex::new(json!({"enabled":false})), frame_access: Mutex::default(), native_key: random_key(), commands: Arc::default(), events: Mutex::default(), jobs: crate::jobs::Jobs::default(), launcher_search: Mutex::default(), terminals: crate::terminal::Terminals::default(), files: Arc::default(), catalog_cache: Arc::default(), }) } fn http_server( server: Server, agent: Arc, stop: Arc, ) -> std::thread::JoinHandle<()> { std::thread::spawn(move || { while !stop.load(Ordering::Relaxed) { if let Ok(Some(r)) = server.recv_timeout(Duration::from_millis(50)) { let _ = agent.handle(r); } } }) } #[test] fn calibration_preview_allows_unvalidated_error_but_rejects_invalid_geometry() { 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(); 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 http = http_server(server, agent.clone(), stop.clone()); let request = |preview: Value| -> Value { ureq::post(&format!("{}/api", agent.origin)) .set("Cookie", &format!("framely={}", agent.web_key)) .set("Origin", &agent.origin) .send_json(json!({"method":"host.launcher.calibration.target", "params":{"id":"verify:1","x":0.3,"y":0.5,"preview":preview}})) .unwrap().into_json().unwrap() }; let preview = json!({"space":"headAngles","matrix":[1.0,0.0,0.1,0.0,1.0,-0.1],"width":1440,"height":800,"error":0.08,"maxError":0.15}); let allowed = request(preview.clone()); let mut invalid = preview.clone(); invalid["matrix"][0] = json!(-1); let rejected = request(invalid); stop.store(true, Ordering::Relaxed); http.join().unwrap(); core_worker.join().unwrap(); assert_eq!(allowed["result"], json!(true), "{allowed}"); assert!(rejected.get("error").is_some(), "{rejected}"); let commands = agent.commands.lock().unwrap(); assert_eq!(commands.len(), 1); assert_eq!(commands[0]["preview"], preview); let model: crate::model::GazeCalibration = serde_json::from_value(preview).unwrap(); assert!(model.validate().is_err()); // Still cannot persist a poor result. } #[test] fn launcher_favorites_pass_through_real_http_session_and_persist() { let root = tempfile::tempdir().unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&server, root.path()); let manager = unsafe { libc::geteuid() }; let mut core = crate::tests::accepted_service(&agent.state, manager).unwrap(); let package = crate::tests::fixture(root.path(), "favorite.demo", "1.0.0", 1, None); core.handle( "install", json!({"package":STANDARD.encode(package),"approve":true}), ) .unwrap(); 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 http = http_server(server, agent.clone(), stop.clone()); let request = |method: &str, params: Value| -> Value { ureq::post(&format!("{}/api", agent.origin)) .set("Cookie", &format!("framely={}", agent.web_key)) .set("Origin", &agent.origin) .send_json(json!({"method":method,"params":params})) .unwrap() .into_json() .unwrap() }; let responses: Vec<_> = [ "plugin:favorite.demo", "steam:42", "framely", "desktop:terminal.desktop", "lepton:default/app", ] .into_iter() .map(|key| request("launcher.favorite", json!({"key":key,"favorite":true}))) .collect(); let status = request("status", json!({})); let removed = request( "launcher.favorite", json!({"key":"framely","favorite":false}), ); let denied = request("launcher.favorite.invalid", json!({})); stop.store(true, Ordering::Relaxed); http.join().unwrap(); core_worker.join().unwrap(); for response in responses { assert_eq!(response["result"], true, "{response}"); } assert_eq!( status["result"]["database"]["plugins"]["favorite.demo"]["favorite"], true ); assert_eq!(status["result"]["database"]["steamFavorites"], json!([42])); assert_eq!(removed["result"], true); assert!(denied.get("error").is_some()); let loaded = crate::service::Service::load(&agent.state, manager).unwrap(); assert!(loaded.db.plugins["favorite.demo"].favorite); assert!(loaded.db.steam_favorites.contains(&42)); assert_eq!( loaded.db.launcher_favorites, [ "desktop:terminal.desktop".to_owned(), "lepton:default/app".to_owned() ] .into_iter() .collect() ); } #[test] fn manager_entry_preserves_catalog_and_update_targets_and_rejects_arbitrary_pages() { let root = tempfile::tempdir().unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&server, root.path()); for params in [ json!({"page":"catalog"}), json!({"page":"updates"}), json!({"page":"notification-settings"}), json!({"page":"launcher-settings"}), json!({"page":"settings"}), json!({}), json!({"page":"https://example.org"}), ] { agent .api(json!({"method":"host.manager.open","params":params})) .unwrap(); } let commands = agent.commands.lock().unwrap(); assert_eq!(commands[0], json!({"kind":"manager.open","page":"catalog"})); assert_eq!(commands[1], json!({"kind":"manager.open","page":"updates"})); assert_eq!( commands[2], json!({"kind":"manager.open","page":"notification-settings"}) ); assert_eq!( commands[3], json!({"kind":"manager.open","page":"launcher-settings"}) ); assert_eq!( commands[4], json!({"kind":"manager.open","page":"settings"}) ); assert_eq!(commands[5], json!({"kind":"manager.open"})); assert_eq!(commands[6], json!({"kind":"manager.open"})); } #[test] fn declining_agreement_only_closes_current_view() { let root = tempfile::tempdir().unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&server, root.path()); assert_eq!( agent .api(json!({"method":"agreement.decline","params":{"view":"menu"}})) .unwrap()["closed"], true ); assert_eq!( agent.commands.lock().unwrap().pop_front().unwrap(), json!({"kind":"agreement.decline","view":"menu"}) ); assert!(agent.commands.lock().unwrap().is_empty()); assert!(agent .api(json!({"method":"agreement.decline","params":{"view":"invalid"}})) .is_err()); assert!(!agent.state.join("state.json").exists()); } #[test] fn agreement_blocks_session_jobs_before_consent() { let root = tempfile::tempdir().unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&server, root.path()); let listener = std::os::unix::net::UnixListener::bind(&agent.socket).unwrap(); let state = agent.state.clone(); let worker = std::thread::spawn(move || { let mut core = crate::service::Service::load(&state, 1000).unwrap(); for i in 0..4 { let (mut stream, _) = listener.accept().unwrap(); let request = ipc::read(&mut stream).unwrap(); assert_eq!( request["method"], if i == 3 { "language.save" } else { "agreement.status" } ); let result = core .handle( request["method"].as_str().unwrap(), request["params"].clone(), ) .unwrap(); ipc::write(&mut stream, &json!({"result":result})).unwrap(); } }); for method in ["catalog.start", "subscriptions.preview.start", "proxy.save"] { assert!(agent .api(json!({"method":method,"params":{}})) .unwrap_err() .to_string() .contains("请先同意")); } assert_eq!( agent .api(json!({"method":"language.save","params":{"language":"en-US"}})) .unwrap(), json!(true) ); worker.join().unwrap(); let core = crate::service::Service::load(&agent.state, 1000).unwrap(); assert_eq!(core.db.language, "en-US"); assert!(core.db.agreement_acceptance.is_none()); } #[test] fn network_panel_rebinds_disables_and_rejects_occupied_ports() { let root = tempfile::tempdir().unwrap(); let local = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&local, root.path()); let free_port = || { let socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); socket.local_addr().unwrap().port() }; let port = free_port(); let mut core = crate::tests::accepted_service(&agent.state, 1000).unwrap(); core.handle( "network.save", json!({"enabled":true,"port":port,"password":"fixture password"}), ) .unwrap(); 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 result = core .handle( request["method"].as_str().unwrap(), request["params"].clone(), ) .unwrap(); ipc::write(&mut stream, &json!({"result":result})).unwrap(); } else { std::thread::sleep(Duration::from_millis(5)); } } }); let listener_agent = agent.clone(); let listener_stop = stop.clone(); let listener = std::thread::spawn(move || { network_listener(listener_agent, || !listener_stop.load(Ordering::Relaxed)) }); let wait = |port: Option| { for _ in 0..100 { let status = agent.network_status.lock().unwrap().clone(); if port.map_or(status["enabled"] == false, |p| { status["enabled"] == true && status["port"] == p }) { return; } std::thread::sleep(Duration::from_millis(25)); } panic!("listener did not update"); }; wait(Some(port)); assert_eq!( ureq::AgentBuilder::new() .redirects(0) .build() .get(&format!("http://127.0.0.1:{port}")) .call() .unwrap() .status(), 302 ); let occupied = std::net::TcpListener::bind("0.0.0.0:0").unwrap(); let blocked = occupied.local_addr().unwrap().port(); assert!(agent .api(json!({"method":"network.save","params":{"enabled":true,"port":blocked}})) .is_err()); assert_eq!( agent.core("status", json!({})).unwrap()["database"]["networkPanel"]["port"], port ); let next = free_port(); agent .api(json!({"method":"network.save","params":{"enabled":true,"port":next}})) .unwrap(); wait(Some(next)); assert!(ureq::get(&format!("http://127.0.0.1:{port}")) .call() .is_err()); agent .api(json!({"method":"network.save","params":{"enabled":false,"port":next}})) .unwrap(); wait(None); assert!(ureq::get(&format!("http://127.0.0.1:{next}")) .call() .is_err()); stop.store(true, Ordering::Relaxed); listener.join().unwrap(); core_worker.join().unwrap(); } #[test] fn editor_http_accepts_five_mib_after_json_escaping() { if unsafe { libc::geteuid() } == 0 { return; } let root = tempfile::tempdir().unwrap(); let file = root.path().join("generated text.txt"); fs::write(&file, b"old").unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&server, root.path()); let revision = agent .files .api(root.path(), &json!({"operation":"read","path":file})) .unwrap()["revision"] .clone(); let listener = UnixListener::bind(&agent.socket).unwrap(); let core = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let request = ipc::read(&mut stream).unwrap(); assert_eq!(request["method"], "agreement.status"); ipc::write(&mut stream, &json!({"result":{"accepted":true}})).unwrap(); }); let stop = Arc::new(AtomicBool::new(false)); let worker = http_server(server, agent.clone(), stop.clone()); let text = "\"".repeat(5 * 1024 * 1024); let value: Value = ureq::post(&format!("{}/manager-api/files", agent.origin)) .set("Cookie", &format!("framely={}", agent.web_key)) .set("Origin", &agent.origin) .send_json(json!({"operation":"write","path":file,"text":text,"revision":revision})) .unwrap() .into_json() .unwrap(); assert!(value["error"].is_null(), "{value}"); assert_eq!( value["result"]["text"].as_str().unwrap().len(), 5 * 1024 * 1024 ); assert_eq!(fs::metadata(file).unwrap().len(), 5 * 1024 * 1024); stop.store(true, Ordering::Relaxed); worker.join().unwrap(); core.join().unwrap(); } #[test] fn file_streams_support_range_head_and_safe_download_headers() { let root = tempfile::tempdir().unwrap(); let path = root.path().join("generated sample.mp4"); fs::write(&path, b"0123456789").unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let origin = format!("http://{}", server.server_addr()); let thread = std::thread::spawn(move || { for _ in 0..7 { serve_user_file(server.recv().unwrap(), &path, true).unwrap(); } }); for (range, expected) in [ ("bytes=2-5", "2345"), ("bytes=-3", "789"), ("bytes=8-", "89"), ] { let reply = ureq::get(&origin).set("Range", range).call().unwrap(); assert_eq!(reply.status(), 206); assert_eq!(reply.header("Accept-Ranges"), Some("bytes")); assert_eq!(reply.header("X-Content-Type-Options"), Some("nosniff")); assert_eq!(reply.into_string().unwrap(), expected); } for range in ["bytes=10-", "bytes=1-2,4-5"] { assert!(matches!( ureq::get(&origin).set("Range", range).call(), Err(ureq::Error::Status(416, _)) )); } let reply = ureq::get(&origin).call().unwrap(); assert_eq!(reply.header("Content-Type"), Some("video/mp4")); assert!(reply .header("Content-Disposition") .unwrap() .contains("generated%20sample.mp4")); assert_eq!(reply.into_string().unwrap(), "0123456789"); let reply = ureq::head(&origin).call().unwrap(); assert_eq!(reply.header("Content-Length"), Some("10")); assert!(reply.into_string().unwrap().is_empty()); thread.join().unwrap(); } #[test] fn network_panel_requires_login_and_same_origin_and_blocks_native_routes() { let password = "密码".repeat(70); 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::service::Service::load(&agent.state, 1000).unwrap(); let socket = UnixListener::bind(&agent.socket).unwrap(); socket.set_nonblocking(true).unwrap(); let stop = Arc::new(AtomicBool::new(false)); let worker_agent = agent.clone(); let worker_stop = stop.clone(); 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 = match core.handle( request["method"].as_str().unwrap(), request["params"].clone(), ) { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}), }; ipc::write(&mut stream, &response).unwrap(); } else { std::thread::sleep(Duration::from_millis(5)); } } }); 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 origin = agent.origin.clone(); let page = ureq::get(&origin).call().unwrap().into_string().unwrap(); assert!(page.contains("设置访问密码")); assert!(page.contains("action=\"/setup\"")); assert!(page.contains("/assets/branding/framely-logo-light.svg")); let logo = ureq::get(&format!("{origin}/assets/branding/framely-logo-light.svg")) .call() .unwrap(); assert_eq!(logo.header("Content-Type"), Some("image/svg+xml")); assert_eq!(logo.header("X-Content-Type-Options"), Some("nosniff")); assert_eq!( logo.into_string().unwrap(), include_str!("../assets/branding/framely-logo-light.svg") ); let icon = ureq::get(&format!("{origin}/favicon.ico")).call().unwrap(); assert_eq!(icon.header("Content-Type"), Some("image/x-icon")); for path in [ "/assets/app.js", "/assets/branding/../app.js", "/assets/branding/unknown.svg", ] { assert!(matches!( ureq::get(&format!("{origin}{path}")).call(), Err(ureq::Error::Status(401, _)) )); } assert!(!page.contains(password.as_str())); assert!(ureq::post(&format!("{origin}/api")) .set("Origin", &origin) .send_json(json!({"method":"ui.events"})) .is_err()); assert!(ureq::get(&format!("{origin}/boot/{}", agent.web_key)) .call() .is_err()); for path in [ "/host/files/list", "/host/files/complete", "/host/files/apk-inspect", ] { assert!(matches!( ureq::post(&format!("{origin}{path}")) .set("Origin", &origin) .send_json(json!({})), Err(ureq::Error::Status(403, _)) )); } assert!(ureq::get(&format!("{origin}/host/poll")) .set("X-Framely-Native", &agent.native_key) .call() .is_err()); let client = ureq::AgentBuilder::new().redirects(0).build(); assert!(client .post(&format!("{origin}/setup")) .set("Origin", "http://other.example") .send_form(&[ ("password", password.as_str()), ("confirmPassword", password.as_str()) ]) .is_err()); for (password, confirmation) in [("short", "short"), (password.as_str(), "different")] { assert!(client .post(&format!("{origin}/setup")) .set("Origin", &origin) .send_form(&[("password", password), ("confirmPassword", confirmation)]) .is_err()); } assert!(!agent.state.join("network-password.json").exists()); fs::create_dir(agent.state.join("state.json.tmp")).unwrap(); assert!(client .post(&format!("{origin}/setup")) .set("Origin", &origin) .send_form(&[ ("password", password.as_str()), ("confirmPassword", password.as_str()) ]) .is_err()); assert!(!agent.state.join("network-password.json").exists()); fs::remove_dir(agent.state.join("state.json.tmp")).unwrap(); let setup = client .post(&format!("{origin}/setup")) .set("Origin", &origin) .send_form(&[ ("password", password.as_str()), ("confirmPassword", password.as_str()), ]) .unwrap(); assert_eq!(setup.status(), 303); assert!(setup.header("Set-Cookie").is_none()); assert!(client .post(&format!("{origin}/setup")) .set("Origin", &origin) .send_form(&[ ("password", "replacement password"), ("confirmPassword", "replacement password") ]) .is_err()); let login = client.get(&origin).call().unwrap().into_string().unwrap(); assert!(login.contains("action=\"/login\"")); let saved = fs::read(agent.state.join("network-password.json")).unwrap(); assert!(!String::from_utf8_lossy(&saved).contains(password.as_str())); use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(agent.state.join("network-password.json")) .unwrap() .permissions() .mode() & 0o777, 0o600 ); let restored = crate::service::Service::load(&agent.state, 1000).unwrap(); assert!(restored.db.network_panel.password_enabled); assert!(restored.db.agreement_acceptance.is_none()); assert!(client .post(&format!("{origin}/login")) .set("Origin", &origin) .send_form(&[("password", "wrong")]) .is_err()); let response = client .post(&format!("{origin}/login")) .set("Origin", &origin) .send_form(&[("password", password.as_str())]) .unwrap(); assert_eq!(response.status(), 302); let cookie = response .header("Set-Cookie") .unwrap() .split(';') .next() .unwrap(); let landing = client.get(&origin).set("Cookie", cookie).call().unwrap(); assert_eq!(landing.status(), 302); assert_eq!(landing.header("Location"), Some("/manager")); let result: Value = client .post(&format!("{origin}/api")) .set("Cookie", cookie) .set("Origin", &origin) .send_json(json!({"method":"ui.events","params":{}})) .unwrap() .into_json() .unwrap(); assert_eq!(result["result"]["cursor"], 0); assert!(client .post(&format!("{origin}/api")) .set("Cookie", cookie) .set("Origin", "http://other.example") .send_json(json!({"method":"ui.events"})) .is_err()); stop.store(true, Ordering::Relaxed); worker.join().unwrap(); core_worker.join().unwrap(); } #[test] fn http_session_rejects_unauthenticated_and_opaque_plugin_requests() { let root = tempfile::tempdir().unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&server, root.path()); let stop = Arc::new(AtomicBool::new(false)); let thread = http_server(server, agent.clone(), stop.clone()); let url = format!("{}/api", agent.origin); assert!(ureq::post(&url) .send_json(json!({"method":"ui.events"})) .is_err()); assert!(ureq::post(&url) .set("Cookie", &format!("framely={}", agent.web_key)) .set("Origin", "null") .send_json(json!({"method":"ui.events"})) .is_err()); for route in [ "/manager-api/files", "/manager-api/terminal", "/manager-api/file-upload/id/0", ] { for origin in ["null", "http://other.example", ""] { assert!(ureq::post(&format!("{}{route}", agent.origin)) .set("Cookie", &format!("framely={}", agent.web_key)) .set("Origin", origin) .send_json(json!({"operation":"list"})) .is_err()); } assert!(ureq::post(&format!("{}{route}", agent.origin)) .set("Origin", &agent.origin) .send_json(json!({"operation":"list"})) .is_err()); } assert!( ureq::get(&format!("{}/manager-api/terminal/ws?id=x", agent.origin)) .set("Cookie", &format!("framely={}", agent.web_key)) .set("Origin", "null") .call() .is_err() ); let v: Value = ureq::post(&url) .set("Cookie", &format!("framely={}", agent.web_key)) .set("Origin", &agent.origin) .send_json(json!({"method":"ui.events","params":{}})) .unwrap() .into_json() .unwrap(); assert_eq!(v["result"]["cursor"], 0); assert!(ureq::get(&format!("{}/host/poll", agent.origin)) .call() .is_err()); let v: Value = ureq::get(&format!("{}/host/poll", agent.origin)) .set("X-Framely-Native", &agent.native_key) .call() .unwrap() .into_json() .unwrap(); assert_eq!(v["commands"], json!([])); stop.store(true, Ordering::Relaxed); thread.join().unwrap(); } #[test] fn native_visibility_reports_require_native_auth_and_are_not_public_api() { let root = tempfile::tempdir().unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); let agent = agent(&server, root.path()); let listener = std::os::unix::net::UnixListener::bind(&agent.socket).unwrap(); let socket_worker = std::thread::spawn(move || { let mut core = crate::service::Service::load(&root.path().join("state"), 1000).unwrap(); for expected in [ "host.ui.visibility", "host.notification.badge", "launcher.gaze.calibration.get", "status", "agreement.status", ] { let (mut stream, _) = listener.accept().unwrap(); let request = ipc::read(&mut stream).unwrap(); assert_eq!(request["method"], expected); let response = match core.handle( request["method"].as_str().unwrap(), request["params"].clone(), ) { Ok(value) => json!({"result":value}), Err(error) => json!({"error":error.to_string()}), }; ipc::write(&mut stream, &response).unwrap(); } assert_eq!( core.handle("ui.visibility.get", json!({})).unwrap()["captureObscured"], true ); }); let stop = Arc::new(AtomicBool::new(false)); let worker = http_server(server, agent.clone(), stop.clone()); let url = format!("{}/host/poll", agent.origin); assert!(ureq::post(&url).send_json(json!({"menu":true})).is_err()); assert!(ureq::post(&url) .set("Cookie", &format!("framely={}", agent.web_key)) .send_json(json!({"menu":true})) .is_err()); ureq::post(&url) .set("X-Framely-Native", &agent.native_key) .send_json(json!({"menu":true})) .unwrap(); assert!(agent .api(json!({"method":"host.ui.visibility","params":{"menu":false}})) .is_err()); socket_worker.join().unwrap(); stop.store(true, Ordering::Relaxed); worker.join().unwrap(); } #[test] fn version_history_checks_identity_duplicates_and_schema() { let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); let source = Source { id: "history-test".into(), name: "History".into(), url: format!("http://{}/catalog.json", server.server_addr()), enabled: true, allow_http: true, }; let entry = json!({"id":"test.history","name":"History","version":"1.0.0","description":"","author":"Test","apiVersion":1,"runAs":"steamos","url":"http://example.org/plugin.framely","sha256":"ab".repeat(32),"future":true}); let documents = vec![ json!({"schemaVersion":1,"id":"test.history","versions":[entry.clone()],"future":true}), json!({"schemaVersion":1,"id":"other.plugin","versions":[]}), json!({"schemaVersion":1,"id":"test.history","versions":[entry.clone(),entry]}), json!({"schemaVersion":2,"id":"test.history","versions":[]}), ]; let thread = std::thread::spawn(move || { for doc in documents { let request = server.recv().unwrap(); assert_eq!(request.url(), "/plugins/test.history/versions.json"); request .respond(tiny_http::Response::from_data( serde_json::to_vec(&doc).unwrap(), )) .unwrap(); } }); assert_eq!( fetch_plugin_versions_cancel( &source, "test.history", &Default::default(), &Default::default() ) .unwrap() .len(), 1 ); for _ in 0..3 { assert!(fetch_plugin_versions_cancel( &source, "test.history", &Default::default(), &Default::default() ) .is_err()); } thread.join().unwrap(); } #[test] fn catalog_sources_fail_independently_and_keep_cached_data() { let server = Server::http("127.0.0.1:0").unwrap(); let url = format!("http://{}/catalog.json", server.server_addr()); let source = Source { id: "good".into(), name: "Good".into(), url, enabled: true, allow_http: true, }; let offline = Source { id: "offline".into(), name: "Offline".into(), url: "http://127.0.0.1:1/catalog.json".into(), enabled: true, allow_http: true, }; let handle = std::thread::spawn(move || { let r = server.recv().unwrap(); r.respond(Response::from_string( r#"{"schemaVersion":1,"name":"Test store","plugins":[]}"#, )) .unwrap(); }); let cache = Arc::default(); let result = fetch_catalogs(vec![source.clone(), offline], Arc::clone(&cache)); handle.join().unwrap(); let results = result.as_array().unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0]["catalog"]["name"], "Test store"); assert!(results[1].get("error").is_some()); let stale = fetch_catalogs(vec![source], cache); assert_eq!(stale[0]["cached"], true); assert_eq!(stale[0]["catalog"]["name"], "Test store"); } #[test] fn remote_frames_load_scoped_assets_and_unchanged_settings_keep_session() { let root = tempfile::tempdir().unwrap(); let local = Server::http("127.0.0.1:0").unwrap(); let assets = root.path().join("ui"); std::fs::create_dir_all(assets.join("assets")).unwrap(); let sdk_fixture = "window.__framely_test_sdk = true;"; std::fs::write(assets.join("assets/plugin-bootstrap.js"), sdk_fixture).unwrap(); let mut agent = agent(&local, root.path()); Arc::get_mut(&mut agent).unwrap().assets = assets; let remote = Server::http("127.0.0.1:0").unwrap(); let origin = format!("http://{}", remote.server_addr()); let mut core = crate::tests::accepted_service(&agent.state, 1000).unwrap(); core.handle("network.save", json!({"enabled":true,"port":15915,"passwordEnabled":false,"password":"fixture password"})).unwrap(); let bytes = crate::tests::fixture(root.path(), "test.remote", "1", 1, None); core.handle( "install", json!({"package":STANDARD.encode(bytes),"approve":true}), ) .unwrap(); *agent.network_status.lock().unwrap() = json!({"enabled":true,"port":15915}); 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 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 = match core.handle( request["method"].as_str().unwrap(), request["params"].clone(), ) { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}), }; ipc::write(&mut stream, &response).unwrap(); } else { std::thread::sleep(Duration::from_millis(5)); } } }); let http_stop = stop.clone(); let a = agent.clone(); let http = std::thread::spawn(move || { while !http_stop.load(Ordering::Relaxed) { if let Ok(Some(r)) = remote.recv_timeout(Duration::from_millis(50)) { let _ = a.handle_http(r, true); } } }); let old_key = agent.network_key.lock().unwrap().clone(); let cookie = format!("framely-network={old_key}"); let frame = ureq::get(&format!("{origin}/plugin-frame/test.remote/quick")) .set("Cookie", &cookie) .call() .unwrap(); let csp = frame.header("Content-Security-Policy").unwrap().to_owned(); assert!(csp.contains("connect-src https: http: ws: wss:")); assert!(csp.contains("img-src ") && csp.contains("data: blob: https: http:")); let html = frame.into_string().unwrap(); assert!(!html.contains(&agent.origin)); assert!(!csp.contains(&agent.origin)); assert!(csp.contains(&origin)); let scripts: Vec<_> = html .split("