mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 17:01:35 +02:00
3232 lines
139 KiB
Rust
3232 lines
139 KiB
Rust
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<String>,
|
|
network_status: Mutex<Value>,
|
|
frame_access: Mutex<std::collections::BTreeMap<String, FrameAccess>>,
|
|
native_key: String,
|
|
commands: Arc<Mutex<VecDeque<Value>>>,
|
|
events: Mutex<EventLog>,
|
|
jobs: crate::jobs::Jobs,
|
|
launcher_search: Mutex<crate::launcher_search::SearchCache>,
|
|
terminals: crate::terminal::Terminals,
|
|
files: Arc<crate::manager_files::Files>,
|
|
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
|
}
|
|
#[derive(Default)]
|
|
struct EventLog {
|
|
sequence: u64,
|
|
entries: VecDeque<Value>,
|
|
}
|
|
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::<Vec<_>>(), "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<VecDeque<Value>>, 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<Value> {
|
|
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::<std::net::IpAddr>().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!("<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><base href='{base}'><div id=root></div><script src='{sdk}'></script><script src='{script}'></script>");
|
|
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<Value> {
|
|
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(1048577).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<Value> {
|
|
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<Value> {
|
|
self.terminals.api(
|
|
&crate::steam::home()?,
|
|
&serde_json::from_slice::<Value>(&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(
|
|
"<head>",
|
|
"<head><meta name=\"framely-remote\" 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("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<Value> {
|
|
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<String> = serde_json::from_value(p["names"].clone())?;
|
|
ensure!(
|
|
names.len() <= 4096
|
|
&& names.iter().map(String::len).sum::<usize>() <= 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" => Ok(json!(crate::steam::discover(&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;
|
|
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<crate::model::GazeCalibration> =
|
|
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<Source> =
|
|
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<Source> =
|
|
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::<std::collections::BTreeSet<_>>().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"
|
|
)
|
|
)
|
|
{
|
|
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<crate::model::ProxySettings> {
|
|
Ok(serde_json::from_value(
|
|
self.core("status", json!({}))?["database"]["proxy"].clone(),
|
|
)?)
|
|
}
|
|
fn catalog(&self) -> Result<Value> {
|
|
let status = self.core("status", json!({}))?;
|
|
let sources: Vec<Source> = 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<Catalog> {
|
|
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<Vec<CatalogEntry>> {
|
|
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<Mutex<std::collections::BTreeMap<String, Value>>>,
|
|
cancel: &crate::jobs::Cancellation,
|
|
) -> Result<Value> {
|
|
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<Source>,
|
|
cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
|
) -> Value {
|
|
fetch_catalogs_proxy(sources, Default::default(), cache)
|
|
}
|
|
fn fetch_catalogs_proxy(
|
|
sources: Vec<Source>,
|
|
proxy: crate::model::ProxySettings,
|
|
cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
|
) -> Value {
|
|
fetch_catalogs_cancel(sources, proxy, cache, &Default::default())
|
|
.unwrap_or_else(|e| json!({"error":e.to_string()}))
|
|
}
|
|
fn fetch_catalogs_cancel(
|
|
sources: Vec<Source>,
|
|
proxy: crate::model::ProxySettings,
|
|
cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
|
cancel: &crate::jobs::Cancellation,
|
|
) -> Result<Value> {
|
|
fetch_catalogs_progress(sources, proxy, cache, cancel, &|_| {})
|
|
}
|
|
fn fetch_catalogs_progress(
|
|
sources: Vec<Source>,
|
|
proxy: crate::model::ProxySettings,
|
|
cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
|
cancel: &crate::jobs::Cancellation,
|
|
progress: &(dyn Fn(Value) + Send + Sync),
|
|
) -> Result<Value> {
|
|
cancel.check()?;
|
|
let sources: Vec<_> = sources.into_iter().filter(|s| s.enabled).collect();
|
|
let mut results = vec![];
|
|
let completed = Mutex::new(Vec::<Value>::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::<Vec<_>>()
|
|
});
|
|
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::<u64>()?.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::<String>()
|
|
.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();
|
|
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::<crate::model::Database>(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<String> {
|
|
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#"<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely · 设置访问密码</title><link rel="icon" href="/assets/branding/framely-app-icon.svg" type="image/svg+xml"><link rel="alternate icon" href="/assets/branding/framely.ico"><link rel="apple-touch-icon" href="/assets/branding/framely-app-icon.png"><style>body{background:#17191c;color:#f0f1f3;font:18px system-ui;margin:0;display:grid;min-height:100vh;place-items:center}main{padding:32px;max-width:360px}input,button{box-sizing:border-box;width:100%;padding:14px;margin:12px 0;border:1px solid #59616d;border-radius:5px;background:#292c31;color:inherit;font:inherit}p{color:#a4a8b0}.brand{display:block;width:200px;height:48px;margin-bottom:24px;image-rendering:pixelated}</style><main><img class="brand" src="/assets/branding/framely-logo-light.svg" alt="Framely"><h1>设置访问密码</h1><p>首次访问网络管理面板,请设置至少 8 个字符的密码。保存后使用该密码登录。</p><form method="post" action="/setup"><label>访问密码<input name="password" type="password" required autocomplete="new-password" minlength="8" maxlength="512"></label><label>确认密码<input name="confirmPassword" type="password" required autocomplete="new-password" minlength="8" maxlength="512"></label><button>保存访问密码</button></form></main></html>"#;
|
|
const NETWORK_LOGIN: &str = r#"<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Framely</title><link rel="icon" href="/assets/branding/framely-app-icon.svg" type="image/svg+xml"><link rel="alternate icon" href="/assets/branding/framely.ico"><link rel="apple-touch-icon" href="/assets/branding/framely-app-icon.png"><style>body{background:#17191c;color:#f0f1f3;font:18px system-ui;margin:0;display:grid;min-height:100vh;place-items:center}main{padding:32px;max-width:360px}input,button{box-sizing:border-box;width:100%;padding:14px;margin:12px 0;border:1px solid #59616d;border-radius:5px;background:#292c31;color:inherit;font:inherit}p{color:#a4a8b0}.brand{display:block;width:200px;height:48px;margin-bottom:24px;image-rendering:pixelated}</style><main><h1><img class="brand" src="/assets/branding/framely-logo-light.svg" alt="Framely"></h1><p>输入你在设备设置中配置的访问密码。</p><form method="post" action="/login"><label>访问密码<input name="password" type="password" required autocomplete="current-password" maxlength="512"></label><button>打开管理面板</button></form></main></html>"#;
|
|
|
|
fn network_listener(agent: Arc<Agent>, keep_running: impl Fn() -> bool) {
|
|
let mut server: Option<Server> = None;
|
|
let mut active: Option<NetworkPanel> = None;
|
|
while keep_running() {
|
|
if let Ok(status) = agent.core("status", json!({})) {
|
|
let config =
|
|
serde_json::from_value::<NetworkPanel>(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_order() {
|
|
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 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<Agent> {
|
|
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<Agent>,
|
|
stop: Arc<AtomicBool>,
|
|
) -> 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<u16>| {
|
|
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("<script src='")
|
|
.skip(1)
|
|
.map(|s| s.split('\'').next().unwrap())
|
|
.collect();
|
|
assert_eq!(scripts.len(), 2);
|
|
for (index, script) in scripts.iter().enumerate() {
|
|
let response = ureq::get(script).set("Origin", "null").call().unwrap();
|
|
assert_eq!(response.header("Access-Control-Allow-Origin"), Some("*"));
|
|
if index == 0 {
|
|
assert_eq!(response.into_string().unwrap(), sdk_fixture);
|
|
}
|
|
}
|
|
assert!(
|
|
ureq::get(&format!("{origin}/plugin-assets/test.remote/page.js"))
|
|
.call()
|
|
.is_err()
|
|
);
|
|
let forged = scripts[0].replace("sdk.js", "api");
|
|
assert!(ureq::post(&forged)
|
|
.send_json(json!({"method":"status"}))
|
|
.is_err());
|
|
let response=ureq::post(&format!("{origin}/api")).set("Origin",&origin).set("Cookie",&cookie).send_json(json!({"method":"network.save","params":{"enabled":true,"port":15915,"passwordEnabled":false}})).unwrap().into_json::<Value>().unwrap();
|
|
assert_eq!(response["result"]["reauthenticate"], false);
|
|
assert_eq!(*agent.network_key.lock().unwrap(), old_key);
|
|
assert!(ureq::post(&format!("{origin}/api"))
|
|
.set("Origin", &origin)
|
|
.set("Cookie", &cookie)
|
|
.send_json(json!({"method":"status"}))
|
|
.is_ok());
|
|
agent.api(json!({"method":"network.save","params":{"enabled":false,"port":15915,"passwordEnabled":false}})).unwrap();
|
|
assert!(ureq::get(scripts[0]).call().is_err());
|
|
let error = ureq::post(&format!("{origin}/api"))
|
|
.set("Origin", &origin)
|
|
.set("Cookie", &cookie)
|
|
.send_json(json!({"method":"status"}));
|
|
match error {
|
|
Err(ureq::Error::Status(401, r)) => {
|
|
assert_eq!(r.header("X-Framely-Reauthenticate"), Some("1"))
|
|
}
|
|
other => panic!("unexpected result: {other:?}"),
|
|
}
|
|
stop.store(true, Ordering::Relaxed);
|
|
http.join().unwrap();
|
|
worker.join().unwrap();
|
|
}
|
|
#[test]
|
|
#[ignore = "requires compiled tests/browser_probe.cpp and built React UI"]
|
|
fn cef_manager_plugin_bridge_integration() {
|
|
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::getuid() }).unwrap();
|
|
core.handle("language.save", json!({"language":"zh-CN"}))
|
|
.unwrap();
|
|
let payload = root.path().join("payload");
|
|
fs::create_dir(&payload).unwrap();
|
|
let keyboard_fixture = fs::read_to_string("target/keyboard-fixture.js").unwrap();
|
|
fs::write(payload.join("page.js"),keyboard_fixture+r#"
|
|
(async()=>{try{
|
|
for(let i=0;i<300&&!window.__framelyKeyboardFixtureDone;i++)await new Promise(r=>setTimeout(r,20));if(!window.__framelyKeyboardFixtureDone)throw Error('React keyboard fixture failed');
|
|
const bridge=window.__framelyBridge;let isolated=false;try{parent.document.body;}catch{isolated=true;}if(!isolated)throw new Error('iframe can read manager DOM');
|
|
let blocked=false;try{const response=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status'})});blocked=!response.ok;}catch{blocked=true;}if(!blocked)throw new Error('opaque plugin called admin API');
|
|
await bridge.request('haptic',{view:'framely.window.forged'});
|
|
await bridge.request('window.open',{plugin:'another.plugin',window:'main'});
|
|
await bridge.request('notification.send',{plugin:'another.plugin',notification:{id:'test',title:'Bridge test',body:'one',actions:[]}});
|
|
await bridge.request('notification.send',{notification:{id:'test',title:'Updated',body:'two',actions:[]}});
|
|
await bridge.request('notification.remove',{id:'test'});
|
|
await bridge.request('window.close',{window:'main'});
|
|
const dependencies=await bridge.request('dependencies',{plugin:'forged'});if(!Array.isArray(dependencies)||dependencies.length)throw new Error('dependency bridge scope');let denied=false;try{await bridge.request('install',{});}catch{denied=true;}if(!denied)throw new Error('plugin installation allowed');
|
|
console.log('FRAMELY_BRIDGE_PASS');
|
|
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e.stack);}})();"#).unwrap();
|
|
let manifest = root.path().join("manifest.json");
|
|
fs::write(&manifest,serde_json::to_vec(&json!({"schemaVersion":1,"apiVersion":1,"id":"test.bridge","name":"Bridge","author":"Test","version":"1","backend":null,"ui":{"quickPage":"page.js","windows":{"main":{"entry":"page.js","title":"Test","dockIcon":true}}},"files":{}})).unwrap()).unwrap();
|
|
let out = root.path().join("test.framely");
|
|
package::pack(&manifest, &payload, &out).unwrap();
|
|
core.handle(
|
|
"install",
|
|
json!({"package":STANDARD.encode(fs::read(&out).unwrap()),"approve":true}),
|
|
)
|
|
.unwrap();
|
|
let shop = Server::http("127.0.0.1:0").unwrap();
|
|
let shop_url = format!("http://{}", shop.server_addr());
|
|
let mut next: Value = serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap();
|
|
next["version"] = json!("2.0.0");
|
|
next["details"] = json!("Store integration details");
|
|
next["tags"] = json!(["工具"]);
|
|
next["changelog"] = json!("Updated version");
|
|
fs::write(&manifest, serde_json::to_vec(&next).unwrap()).unwrap();
|
|
let next_package = root.path().join("next.framely");
|
|
package::pack(&manifest, &payload, &next_package).unwrap();
|
|
let next_bytes = fs::read(next_package).unwrap();
|
|
let prior_bytes = fs::read(&out).unwrap();
|
|
let catalog = json!({"futureMetadata":{"new":true},"schemaVersion":1,"name":"Integration store","plugins":[{"id":"test.bridge","name":"Bridge","author":"Test","version":"2.0.0","apiVersion":1,"futureEntryField":true,"description":"Store test","details":"Store integration details","tags":["工具"],"changelog":"Updated version","runAs":"root","url":format!("{shop_url}/next.framely"),"sha256":package::digest(&next_bytes)}]});
|
|
let history = json!({"futureHistoryField":true,"schemaVersion":1,"id":"test.bridge","versions":[{"id":"test.bridge","name":"Bridge","author":"Test","version":"1","apiVersion":1,"description":"Original version","runAs":"steamos","url":format!("{shop_url}/prior.framely"),"sha256":package::digest(&prior_bytes)}]});
|
|
core.handle("sources.save",json!({"sources":[{"id":"test-shop","name":"Test store","url":format!("{shop_url}/catalog.json"),"enabled":true,"allowHttp":true}]})).unwrap();
|
|
core.handle("subscriptions.add",json!({"url":"https://example.org/subscription-fixture.json","document":{"schemaVersion":1,"name":"Subscription fixture","sources":[]},"approve":true})).unwrap();
|
|
let shop_stop = Arc::new(AtomicBool::new(false));
|
|
let done = shop_stop.clone();
|
|
let shop_thread = std::thread::spawn(move || {
|
|
while !done.load(Ordering::Relaxed) {
|
|
if let Ok(Some(r)) = shop.recv_timeout(Duration::from_millis(50)) {
|
|
let data = if r.url() == "/next.framely" {
|
|
next_bytes.clone()
|
|
} else if r.url() == "/prior.framely" {
|
|
prior_bytes.clone()
|
|
} else if r.url() == "/plugins/test.bridge/versions.json" {
|
|
serde_json::to_vec(&history).unwrap()
|
|
} else {
|
|
serde_json::to_vec(&catalog).unwrap()
|
|
};
|
|
let _ = r.respond(Response::from_data(data));
|
|
}
|
|
}
|
|
});
|
|
let listener = UnixListener::bind(&agent.socket).unwrap();
|
|
listener.set_nonblocking(true).unwrap();
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let end = stop.clone();
|
|
let core_thread = std::thread::spawn(move || {
|
|
while !end.load(Ordering::Relaxed) {
|
|
if let Ok((mut stream, _)) = listener.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));
|
|
}
|
|
}
|
|
core
|
|
});
|
|
let http = http_server(server, agent.clone(), stop.clone());
|
|
let probe = std::env::var_os("FRAMELY_BROWSER_PROBE").expect("set FRAMELY_BROWSER_PROBE");
|
|
let result = Command::new(probe)
|
|
.arg(format!("{}/boot/{}", agent.origin, agent.web_key))
|
|
.arg(root.path())
|
|
.env("HOME", root.path())
|
|
.env("XDG_RUNTIME_DIR", root.path())
|
|
.env("GSETTINGS_BACKEND", "memory")
|
|
.args(["--ozone-platform=headless", "--disable-gpu"])
|
|
.output()
|
|
.unwrap();
|
|
agent.api(json!({"method":"network.save","params":{"enabled":true,"port":15915,"passwordEnabled":false,"password":"fixture password"}})).unwrap();
|
|
let remote_server = Server::http("127.0.0.1:0").unwrap();
|
|
let remote_origin = format!("http://{}", remote_server.server_addr());
|
|
let remote_stop = stop.clone();
|
|
let remote_agent = agent.clone();
|
|
let remote_http = std::thread::spawn(move || {
|
|
while !remote_stop.load(Ordering::Relaxed) {
|
|
if let Ok(Some(r)) = remote_server.recv_timeout(Duration::from_millis(50)) {
|
|
let _ = remote_agent.handle_http(r, true);
|
|
}
|
|
}
|
|
});
|
|
let remote_result = Command::new(std::env::var_os("FRAMELY_BROWSER_PROBE").unwrap())
|
|
.arg(format!("{remote_origin}/manager"))
|
|
.arg(root.path())
|
|
.env("FRAMELY_REMOTE_TEST", "1")
|
|
.env("HOME", root.path())
|
|
.env("XDG_RUNTIME_DIR", root.path())
|
|
.env("GSETTINGS_BACKEND", "memory")
|
|
.args(["--ozone-platform=headless", "--disable-gpu"])
|
|
.output()
|
|
.unwrap();
|
|
print!("{}", String::from_utf8_lossy(&remote_result.stdout));
|
|
eprint!("{}", String::from_utf8_lossy(&remote_result.stderr));
|
|
let store_result = Command::new(std::env::var_os("FRAMELY_BROWSER_PROBE").unwrap())
|
|
.arg(format!(
|
|
"{}/boot/{}?view=manager",
|
|
agent.origin, agent.web_key
|
|
))
|
|
.arg(root.path())
|
|
.env("FRAMELY_STORE_TEST", "1")
|
|
.env("HOME", root.path())
|
|
.env("XDG_RUNTIME_DIR", root.path())
|
|
.env("GSETTINGS_BACKEND", "memory")
|
|
.args(["--ozone-platform=headless", "--disable-gpu"])
|
|
.output()
|
|
.unwrap();
|
|
print!("{}", String::from_utf8_lossy(&store_result.stdout));
|
|
eprint!("{}", String::from_utf8_lossy(&store_result.stderr));
|
|
agent.core("notification.send",json!({"plugin":"test.bridge","notification":{"id":"toast","title":"Toast integration","body":"Compact notification body","image":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=","durationMs":15000,"actions":[{"id":"ok","label":"确认","icon":"✓","closeOnClick":false,"removeFromInboxOnClick":false},{"id":"save","label":"保存","icon":"↓"},{"id":"more","label":"详情","icon":"⋯"}]}})).unwrap();
|
|
let notification_result = Command::new(std::env::var_os("FRAMELY_BROWSER_PROBE").unwrap())
|
|
.arg(format!(
|
|
"{}/boot/{}?view=notifications",
|
|
agent.origin, agent.web_key
|
|
))
|
|
.arg(root.path())
|
|
.env("FRAMELY_NOTIFICATION_TEST", "1")
|
|
.env("HOME", root.path())
|
|
.env("XDG_RUNTIME_DIR", root.path())
|
|
.env("GSETTINGS_BACKEND", "memory")
|
|
.args(["--ozone-platform=headless", "--disable-gpu"])
|
|
.output()
|
|
.unwrap();
|
|
print!("{}", String::from_utf8_lossy(¬ification_result.stdout));
|
|
eprint!("{}", String::from_utf8_lossy(¬ification_result.stderr));
|
|
assert!(
|
|
notification_result.status.success(),
|
|
"CEF notification integration failed"
|
|
);
|
|
shop_stop.store(true, Ordering::Relaxed);
|
|
shop_thread.join().unwrap();
|
|
stop.store(true, Ordering::Relaxed);
|
|
http.join().unwrap();
|
|
remote_http.join().unwrap();
|
|
assert!(
|
|
remote_result.status.success(),
|
|
"CEF remote iframe integration failed"
|
|
);
|
|
let mut core = core_thread.join().unwrap();
|
|
print!("{}", String::from_utf8_lossy(&result.stdout));
|
|
eprint!("{}", String::from_utf8_lossy(&result.stderr));
|
|
assert!(result.status.success(), "CEF browser integration failed");
|
|
assert!(
|
|
store_result.status.success(),
|
|
"CEF store integration failed"
|
|
);
|
|
assert_eq!(core.db.plugins["test.bridge"].manifest.version, "2.0.0");
|
|
assert!(
|
|
core.db.subscriptions.is_empty(),
|
|
"CEF subscription removal did not persist"
|
|
);
|
|
assert!(
|
|
agent
|
|
.commands
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|c| c["kind"] == "manager.open"),
|
|
"Settings must open the native manager window"
|
|
);
|
|
let commands = agent.commands.lock().unwrap();
|
|
assert!(commands
|
|
.iter()
|
|
.any(|c| c["kind"] == "haptic" && c["view"] == "menu"));
|
|
assert!(!commands
|
|
.iter()
|
|
.any(|c| c["view"] == "framely.window.forged"));
|
|
drop(commands);
|
|
let events = core.handle("events", json!({})).unwrap();
|
|
assert!(events
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|e| e["kind"] == "window.open" && e["plugin"] == "test.bridge"));
|
|
assert!(events
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|e| e["event"] == "notification.action"
|
|
&& e["data"]["id"] == "toast"
|
|
&& e["data"]["action"] == "ok"));
|
|
assert!(core.handle("status", json!({})).unwrap()["notifications"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
}
|