Files
SteamFramelyHomebrew--framely/src/service.rs
T

3364 lines
143 KiB
Rust

use crate::{
ipc,
model::*,
package,
process::{self, Events, Running},
recovery::Recovery,
};
use anyhow::{bail, ensure, Context, Result};
use serde_json::{json, Value};
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
fs,
io::{Read, Seek, SeekFrom},
os::{
fd::AsRawFd,
unix::{
fs::{symlink, PermissionsExt},
net::{UnixListener, UnixStream},
},
},
path::{Path, PathBuf},
process::Command,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
pub struct Service {
verifier: crate::auth::Verifier,
pub root: PathBuf,
pub db: Database,
pub manager: u32,
running: BTreeMap<String, Running>,
pub events: Events,
notifications: BTreeMap<String, NotificationEntry>,
automatic_updater: crate::update::Updater,
auto_update_job: Option<(String, UpdateSource, UpdateChannel)>,
next_update_check: Instant,
last_update_check: Option<u64>,
auto_update_error: Option<String>,
rates: BTreeMap<String, VecDeque<Instant>>,
recovery: BTreeMap<String, Recovery>,
updater: crate::update::Updater,
runtime: BTreeMap<String, Value>,
waiting: BTreeSet<String>,
batching: bool,
storage_jobs: crate::jobs::Jobs,
plugin_migration: bool,
visibility: crate::visibility::Tracker,
visibility_pending: BTreeSet<String>,
launch_contexts: BTreeMap<String, Value>,
}
impl Service {
fn plugin_store(&self) -> &Path {
self.db.plugin_storage.as_deref().unwrap_or(&self.root)
}
fn ensure_plugin_store(&self) -> Result<()> {
let path = self.plugin_store();
let meta = if self.db.plugin_storage.is_some() { fs::symlink_metadata(path)? } else { fs::metadata(path)? };
ensure!(meta.is_dir() && (self.db.plugin_storage.is_none() || !meta.file_type().is_symlink()), "插件存储目录不可用:{}", path.display());
Ok(())
}
pub fn prepare_uninstall(&mut self) -> Result<Value> {
self.cancel_automatic_check();
self.updater.ensure_idle()?;
self.updater.jobs.ensure_idle()?;
let roots: Vec<_> = self.db.plugins.keys().cloned().collect();
let mut ids = crate::relations::order(&self.db, &roots, false).unwrap_or(roots);
ids.reverse();
// Persist disabling before running hooks, so a failed cleanup or reboot
// cannot restart a partially uninstalled plugin collection.
self.db.safe_mode = true;
for plugin in self.db.plugins.values_mut() {
plugin.enabled = false;
}
self.save()?;
for id in &ids {
self.stop_one(id, "uninstall");
}
self.waiting.clear();
for id in &ids {
let installed = self.plugin(id)?.clone();
let payload = self.payload(id)?;
self.command_hook(
&installed.manifest,
&payload,
"onUninstall",
"uninstall",
None,
Value::Null,
)
.with_context(|| format!("插件 {id} 卸载失败,本体保留,请重试"))?;
fs::remove_dir_all(self.plugin_store().join("plugins").join(id))?;
self.db.plugins.remove(id);
self.db.notification_settings.plugins.remove(id);
self.runtime.remove(id);
self.notifications.retain(|_, n| n.plugin != *id);
self.save_notifications()?;
}
// Safe mode is an uninstall guard, not the default for a later install.
// A failed hook exits above and leaves the guard persisted.
self.db.safe_mode = false;
self.save()?;
Ok(json!({"uninstalled": ids}))
}
pub fn load(root: &Path, manager: u32) -> Result<Self> {
fs::create_dir_all(root)?;
let p = root.join("state.json");
let mut db: Database = if p.exists() {
serde_json::from_slice(&fs::read(p)?)?
} else {
Database::default()
};
let migration = root.join("plugin-storage-migration.json");
if let Ok(bytes) = fs::read(&migration) {
let record: Value = serde_json::from_slice(&bytes)?;
if record["target"].as_str() == db.plugin_storage.as_deref().and_then(Path::to_str) {
fs::remove_file(migration)?;
}
}
db.update_check.validate()?;
db.launcher.validate()?;
db.casting.validate()?;
for p in db.plugins.values_mut() {
if let Some(e) = &p.manifest.engines {
if let Err(error) = e.check() {
p.error = Some(error.to_string());
} else if p
.error
.as_deref()
.is_some_and(|v| v.starts_with("Plugin requires Framely "))
{
p.error = None;
}
}
}
if db.update_source.is_none() {
db.update_source = fs::read(root.join("update-source.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
}
let mut resume = BTreeSet::new();
if let Ok(bytes) = fs::read(root.join("install-transaction.json")) {
let journal: Value = serde_json::from_slice(&bytes)?;
let original: Database = serde_json::from_value(journal["database"].clone())?;
restore_replaced(root, &journal)?;
restore_links(
original.plugin_storage.as_deref().unwrap_or(root),
&original,
&serde_json::from_value::<Vec<String>>(journal["targets"].clone())?,
)?;
resume = serde_json::from_value(journal["running"].clone())?;
db = original;
fs::write(root.join("state.json.tmp"), serde_json::to_vec_pretty(&db)?)?;
fs::set_permissions(
root.join("state.json.tmp"),
fs::Permissions::from_mode(0o600),
)?;
fs::File::open(root.join("state.json.tmp"))?.sync_all()?;
fs::rename(root.join("state.json.tmp"), root.join("state.json"))?;
fs::remove_file(root.join("install-transaction.json"))?;
}
let radial_default_migrated = !db.system_radial_default_off_applied;
if radial_default_migrated {
db.launcher.system_radial_menu = false;
db.system_radial_default_off_applied = true;
}
let initialized_defaults = crate::subscriptions::initialize_defaults(&mut db)?;
crate::subscriptions::initialize(&mut db)?;
// Rehydrate presentation after a state-schema downgrade from immutable payloads.
for installed in db.plugins.values_mut() {
let path = db.plugin_storage.as_deref().unwrap_or(root)
.join("plugins")
.join(&installed.manifest.id)
.join("versions")
.join(&installed.manifest.version)
.join("manifest.json");
if let Ok(bytes) = fs::read(path) {
if let Ok(manifest) = serde_json::from_slice::<Manifest>(&bytes) {
if manifest.id == installed.manifest.id
&& manifest.version == installed.manifest.version
&& manifest.validate().is_ok()
{
installed.manifest.ui = manifest.ui;
installed.manifest.icon = manifest.icon;
installed.manifest.author_url = manifest.author_url;
installed.manifest.documentation_url = manifest.documentation_url;
installed.manifest.homepage = manifest.homepage;
installed.manifest.details = manifest.details;
installed.manifest.tags = manifest.tags;
installed.manifest.screenshots = manifest.screenshots;
installed.manifest.changelog = manifest.changelog;
installed.manifest.lifecycle = manifest.lifecycle;
installed.manifest.backend = manifest.backend;
installed.manifest.dependencies = manifest.dependencies;
installed.manifest.optional_dependencies = manifest.optional_dependencies;
installed.manifest.conflicts = manifest.conflicts;
installed.manifest.exclusive_resources = manifest.exclusive_resources;
}
}
}
}
let notifications = db
.notification_inbox
.iter()
.map(|(key, entry)| {
let mut entry = entry.clone();
entry.toast = false;
(key.clone(), entry)
})
.collect();
let service = Self {
verifier: Default::default(),
root: root.into(),
db,
manager,
running: BTreeMap::new(),
events: Arc::default(),
notifications,
automatic_updater: Default::default(),
auto_update_job: None,
next_update_check: Instant::now() + Duration::from_secs(30),
last_update_check: None,
auto_update_error: None,
rates: BTreeMap::new(),
recovery: BTreeMap::new(),
updater: crate::update::Updater::default(),
runtime: BTreeMap::new(),
waiting: resume,
batching: false,
storage_jobs: Default::default(),
plugin_migration: false,
visibility: Default::default(),
visibility_pending: BTreeSet::new(),
launch_contexts: BTreeMap::new(),
};
if initialized_defaults || radial_default_migrated {
service.save()?;
}
Ok(service)
}
fn save(&self) -> Result<()> {
let tmp = self.root.join("state.json.tmp");
fs::write(&tmp, serde_json::to_vec_pretty(&self.db)?)?;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
fs::File::open(&tmp)?.sync_all()?;
fs::rename(tmp, self.root.join("state.json"))?;
fs::File::open(&self.root)?.sync_all()?;
Ok(())
}
fn plugin(&self, id: &str) -> Result<&Installed> {
valid_id(id)?;
self.db.plugins.get(id).context("Plugin not installed")
}
fn active(&self, id: &str) -> Result<&Installed> {
ensure!(self.agreement_accepted(), "请先同意用户协议和隐私声明");
let p = self.plugin(id)?;
if let Some(e) = &p.manifest.engines {
e.check()?;
}
ensure!(
p.enabled && !self.db.safe_mode,
"Plugin disabled or safe mode enabled"
);
Ok(p)
}
fn payload(&self, id: &str) -> Result<PathBuf> {
let p = self.plugin(id)?;
Ok(self
.plugin_store()
.join("plugins")
.join(id)
.join("versions")
.join(&p.manifest.version))
}
fn state(&mut self, id: &str, phase: &str, detail: Value) {
let value = json!({"phase":phase,"detail":detail,"updatedAt":now_ms()});
self.runtime.insert(id.into(), value.clone());
process::event(
&self.events,
json!({"kind":"plugin.dependency.changed","plugin":id}),
);
use std::io::Write;
let dir = self.root.join("logs");
let _ = fs::create_dir_all(&dir);
let log = dir.join(format!("{id}.lifecycle.log"));
if fs::metadata(&log).is_ok_and(|m| m.len() > 2 * 1024 * 1024) {
let _ = fs::rename(&log, log.with_extension("log.1"));
}
if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&log) {
let _ = writeln!(file, "{value}");
}
process::event(
&self.events,
json!({"kind":"plugin.lifecycle","plugin":id,"state":value}),
);
}
fn cleanup_resources(&mut self, id: &str) {
for entry in self.notifications.values_mut().filter(|v| v.plugin == id) {
entry.toast = false;
}
self.notifications.retain(|_, v| v.toast || v.in_inbox);
self.rates.remove(id);
process::event(&self.events, json!({"kind":"plugin.disabled","plugin":id}));
process::event(
&self.events,
json!({"kind":"notification.changed","plugin":id}),
);
}
fn context(
&self,
m: &Manifest,
phase: &str,
reason: &str,
previous: Option<&str>,
extra: Value,
) -> Value {
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.plugin_store().join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra,"launchContext":self.launch_contexts.get(&format!("{}/quick",m.id))})
}
fn command_hook(
&mut self,
m: &Manifest,
payload: &Path,
phase: &str,
reason: &str,
previous: Option<&str>,
extra: Value,
) -> Result<()> {
let Some(l) = &m.lifecycle else { return Ok(()) };
let hook = match phase {
"onInstall" => &l.on_install,
"onUpdate" => &l.on_update,
"onUninstall" => &l.on_uninstall,
"onCrashCleanup" => &l.on_crash_cleanup,
_ => return Ok(()),
};
let Some(hook) = hook else { return Ok(()) };
self.state(&m.id, phase, json!({"reason":reason}));
let result = process::hook(
m,
hook,
payload,
self.manager,
&self.root.join("logs"),
self.plugin_store(),
self.context(m, phase, reason, previous, extra),
Duration::from_secs(l.timeout_seconds),
);
process::event(
&self.events,
json!({"kind":"plugin.hook","plugin":m.id,"phase":phase,"success":result.is_ok(),"error":result.as_ref().err().map(ToString::to_string)}),
);
result
}
fn stop(&mut self, id: &str) {
self.stop_reason(id, "stop");
}
fn stop_one(&mut self, id: &str, reason: &str) {
self.state(id, "stopping", json!({"reason":reason}));
if let Some(mut running) = self.running.remove(id) {
if let Some(m) = self.db.plugins.get(id).map(|p| p.manifest.clone()) {
if let Some(l) = m.lifecycle.as_ref().filter(|l| l.on_stop) {
if !running.exited() {
let result = running.call_timeout(
"framely.lifecycle.stop",
self.context(&m, "onStop", reason, None, Value::Null),
Duration::from_secs(l.timeout_seconds),
);
process::event(
&self.events,
json!({"kind":"plugin.hook","plugin":id,"phase":"onStop","success":result.is_ok(),"error":result.err().map(|e|e.to_string())}),
);
}
}
}
running.stop();
}
self.recovery.remove(id);
self.cleanup_resources(id);
self.state(id, "stopped", json!({"reason":reason}));
}
fn start(&mut self, id: &str) -> Result<()> {
self.start_reason(id, "open")
}
fn start_one(&mut self, id: &str, reason: &str) -> Result<()> {
self.active(id)?;
if self.running.get_mut(id).is_some_and(|p| !p.exited()) {
return Ok(());
}
if self.running.contains_key(id) {
self.crashed(id);
}
self.active(id)?;
let m = self.plugin(id)?.manifest.clone();
if m.backend.is_none() {
self.state(id, "ready", Value::Null);
return Ok(());
}
self.state(id, "starting", json!({"reason":reason}));
let result = (|| -> Result<Running> {
let mut running = Running::start(
&m,
&self.payload(id)?,
self.manager,
self.events.clone(),
&self.root.join("logs"),
self.plugin_store(),
)?;
if let Some(l) = m.lifecycle.as_ref().filter(|l| l.on_start) {
running.call_timeout(
"framely.lifecycle.start",
self.context(&m, "onStart", reason, None, Value::Null),
Duration::from_secs(l.timeout_seconds),
)?;
}
if self.visibility.expire(Instant::now()) {
self.publish_visibility();
}
if m.backend.as_ref().is_some_and(|b| b.ui_visibility_events) {
running.call_timeout(
"framely.ui.visibility",
serde_json::to_value(&self.visibility.state)?,
Duration::from_secs(1),
)?;
}
ensure!(!running.exited(), "Backend exited during startup");
Ok(running)
})();
match result {
Ok(running) => {
self.running.insert(id.into(), running);
self.recovery
.entry(id.into())
.or_default()
.started(Instant::now());
self.db.plugins.get_mut(id).unwrap().error = None;
self.state(id, "running", Value::Null);
Ok(())
}
Err(error) => {
self.cleanup_resources(id);
if let Ok(payload) = self.payload(id) {
let _ = self.command_hook(
&m,
&payload,
"onCrashCleanup",
"start-failure",
None,
json!({"reason":"start-failure","message":error.to_string()}),
);
}
self.failed_backend(id, Instant::now(), &error.to_string());
Err(error)
}
}
}
fn crashed(&mut self, id: &str) {
self.suspend_dependents(id, "dependency-failed", true);
let Some(mut running) = self.running.remove(id) else {
return;
};
let exit = running.exit_info();
running.stop();
drop(running);
self.cleanup_resources(id);
if exit.reason == "exited"
&& exit.exit_code == Some(0)
&& exit.signal.is_none()
&& !exit.oom
{
self.recovery.remove(id);
self.state(id, "stopped", json!({"reason":"backend-completed"}));
if let Some(p) = self.db.plugins.get_mut(id) {
p.error = None;
}
let _ = self.save();
return;
}
self.state(id, "crashed", json!(exit));
if let Ok(installed) = self.plugin(id).cloned() {
if let Ok(payload) = self.payload(id) {
if let Err(error) = self.command_hook(
&installed.manifest,
&payload,
"onCrashCleanup",
&exit.reason,
None,
json!(exit),
) {
process::event(
&self.events,
json!({"kind":"plugin.cleanup.failed","plugin":id,"message":error.to_string()}),
);
}
}
}
self.failed_backend(
id,
Instant::now(),
&format!(
"{} (exit={:?}, signal={:?}, OOM={})",
exit.reason, exit.exit_code, exit.signal, exit.oom
),
);
}
fn suspend_dependents(&mut self, id: &str, reason: &str, resume: bool) {
let ids = crate::relations::dependents(&self.db, id);
let ordered =
crate::relations::order(&self.db, &ids, false).unwrap_or_else(|_| ids.clone());
for target in ordered
.into_iter()
.rev()
.filter(|target| ids.contains(target))
{
if self.running.contains_key(&target) {
let budget = if resume {
self.recovery.remove(&target)
} else {
None
};
self.stop_one(&target, reason);
if resume {
if let Some(mut budget) = budget {
budget.started = None;
budget.next = None;
self.recovery.insert(target.clone(), budget);
}
self.waiting.insert(target.clone());
self.state(&target, "waiting-dependency", json!({"dependency":id}));
}
}
}
process::event(
&self.events,
json!({"kind":"plugin.dependency.changed","plugin":id}),
);
}
fn stop_reason(&mut self, id: &str, reason: &str) {
self.suspend_dependents(
id,
reason,
["restart", "update", "dependency-failed"].contains(&reason),
);
self.stop_one(id, reason);
}
fn start_reason(&mut self, id: &str, reason: &str) -> Result<()> {
self.active(id)?;
for (other, p) in &self.db.plugins {
if other != id && p.enabled {
if let Some(reason) =
crate::relations::conflict(&self.db.plugins[id].manifest, &p.manifest)?
{
anyhow::bail!("{reason}")
}
}
}
let ordered = match crate::relations::order(&self.db, &[id.to_owned()], true) {
Ok(v) => v,
Err(e) => {
self.state(id, "waiting-dependency", json!({"error":e.to_string()}));
return Err(e);
}
};
for target in ordered {
if target != id
&& self
.recovery
.get(&target)
.is_some_and(|r| r.started.is_none())
{
self.state(id, "waiting-dependency", json!({"dependency":target}));
anyhow::bail!("依赖正在恢复或已失败:{target}");
}
if let Err(e) = self.start_one(&target, reason) {
if target != id {
self.state(
id,
"waiting-dependency",
json!({"dependency":target,"error":e.to_string()}),
);
}
return Err(e);
}
}
self.waiting.remove(id);
Ok(())
}
pub fn shutdown(&mut self) {
self.cancel_automatic_check();
let roots: Vec<_> = self.running.keys().cloned().collect();
let ids = crate::relations::order(&self.db, &roots, false).unwrap_or(roots);
for id in ids.into_iter().rev() {
self.stop_one(&id, "manager-shutdown");
}
let _ = self.save();
}
pub fn autostart(&mut self) {
if self.db.safe_mode || !self.agreement_accepted() {
return;
}
let ids: Vec<_> = self
.db
.plugins
.values()
.filter(|p| p.enabled && p.manifest.backend.as_ref().is_some_and(|b| b.autostart))
.map(|p| p.manifest.id.clone())
.collect();
for id in ids {
let _ = self.start_reason(&id, "autostart");
}
let _ = self.save();
}
fn publish_visibility(&mut self) {
let state = serde_json::to_value(&self.visibility.state).unwrap();
process::event(
&self.events,
json!({"kind":"ui.visibility.changed","data":state}),
);
self.visibility_pending.extend(
self.running
.keys()
.filter(|id| {
self.db.plugins.get(*id).is_some_and(|p| {
p.manifest
.backend
.as_ref()
.is_some_and(|b| b.ui_visibility_events)
})
})
.cloned(),
);
self.deliver_visibility();
}
fn deliver_visibility(&mut self) {
let state = serde_json::to_value(&self.visibility.state).unwrap();
for id in self.visibility_pending.clone() {
let Some(running) = self.running.get_mut(&id) else {
self.visibility_pending.remove(&id);
continue;
};
match running.call_timeout(
"framely.ui.visibility",
state.clone(),
Duration::from_secs(1),
) {
Ok(_) => {
self.visibility_pending.remove(&id);
if let Some(p) = self.db.plugins.get_mut(&id) {
if p.error
.as_ref()
.is_some_and(|e| e.starts_with("Visibility delivery failed:"))
{
p.error = None;
}
}
}
Err(error) => {
if let Some(p) = self.db.plugins.get_mut(&id) {
p.error = Some(format!("Visibility delivery failed: {error}"));
}
}
}
}
}
pub fn maintenance(&mut self) {
if self.plugin_migration { return; }
self.expire_notifications();
if self.visibility.expire(Instant::now()) {
self.publish_visibility();
}
self.deliver_visibility();
if !self.agreement_accepted() {
return;
}
self.automatic_update_check();
let now = Instant::now();
let exited: Vec<_> = self
.running
.iter_mut()
.filter_map(|(id, r)| r.exited().then_some(id.clone()))
.collect();
for id in exited {
self.crashed(&id);
}
let retry: Vec<_> = self
.recovery
.iter()
.filter(|(id, r)| {
!self.running.contains_key(*id)
&& r.next.is_some_and(|t| now >= t)
&& plugin_restarts(&self.db, id)
&& !self.db.safe_mode
})
.map(|(id, _)| id.clone())
.collect();
for id in retry {
let _ = self.start_reason(&id, "crash-recovery");
}
let waiting: Vec<_> = self.waiting.iter().cloned().collect();
for id in waiting {
if !self.db.plugins.get(&id).is_some_and(|p| p.enabled) {
self.waiting.remove(&id);
continue;
}
if self.db.safe_mode {
continue;
}
if let Ok(order) = crate::relations::order(&self.db, std::slice::from_ref(&id), true) {
if order.iter().filter(|target| *target != &id).all(|target| {
self.db.plugins[target].manifest.backend.is_none()
|| self.running.contains_key(target)
}) {
let _ = self.start_reason(&id, "dependency-restored");
}
}
}
}
fn failed_backend(&mut self, id: &str, now: Instant, reason: &str) {
let limit = self
.db
.plugins
.get(id)
.and_then(|p| p.manifest.backend.as_ref())
.map(|b| b.restart_limit)
.unwrap_or(3);
let retry = plugin_restarts(&self.db, id) && !self.db.safe_mode;
let quarantine = retry
&& self
.recovery
.entry(id.into())
.or_default()
.failed_with_limit(now, limit);
if !retry {
self.recovery.remove(id);
}
if let Some(p) = self.db.plugins.get_mut(id) {
p.error = Some(if quarantine {
format!("连续启动失败,已自动停用:{reason}")
} else if retry {
format!("正在等待自动重启:{reason}")
} else {
reason.into()
});
if quarantine {
p.enabled = false;
}
}
self.state(
id,
if retry && !quarantine {
"recovering"
} else {
"failed"
},
json!({"reason":reason}),
);
if quarantine {
process::event(&self.events, json!({"kind":"plugin.disabled","plugin":id}));
}
let _ = self.save();
}
fn agreement_accepted(&self) -> bool {
self.db
.agreement_acceptance
.as_ref()
.is_some_and(|a| a.version == AGREEMENT_VERSION && a.accepted_at > 0)
}
pub fn handle(&mut self, method: &str, p: Value) -> Result<Value> {
if self.plugin_migration && (method.starts_with("plugin.") || matches!(method, "install" | "install.batch" | "safeMode" | "system.uninstall.prepare")) {
bail!("插件存储正在迁移,请等待完成");
}
ensure!(
self.agreement_accepted()
|| matches!(
method,
"status"
| "events"
| "host.ui.visibility"
| "host.notification.badge"
| "ui.visibility.get"
| "language.list"
| "language.save"
| "agreement.status"
| "agreement.accept"
| "agreement.revoke"
| "network.password.verify"
| "network.password.configured"
| "cast.watchAccess.get"
| "network.password.setup"
| "system.uninstall.prepare"
),
"请先同意用户协议和隐私声明"
);
if method.starts_with("system.") && method != "system.job.status" {
let installing = fs::read(self.root.join("update-status.json"))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.is_some_and(|v| v["phase"] == "installing");
ensure!(!installing, "正在切换 Framely 版本,请等待服务恢复");
}
let id = p["plugin"].as_str().unwrap_or("").to_owned();
match method {
"storage.plugin.status" => {
let store = self.plugin_store();
let (program_bytes, program_incomplete) = crate::storage_location::disk_bytes(&store.join("plugins"))?;
let (data_bytes, data_incomplete) = crate::storage_location::disk_bytes(&store.join("data"))?;
let details = self.db.plugins.iter().map(|(id, installed)| {
let (program, a) = crate::storage_location::disk_bytes(&store.join("plugins").join(id))?;
let (data, b) = crate::storage_location::disk_bytes(&store.join("data").join(id))?;
Ok(json!({"id":id,"name":installed.manifest.name,"bytes":program.saturating_add(data),"programBytes":program,"dataBytes":data,"sizeIncomplete":a || b}))
}).collect::<Result<Vec<Value>>>()?;
let device = fs::metadata(store).ok().map(|m| std::os::unix::fs::MetadataExt::dev(&m));
Ok(json!({"path":store,"bytes":program_bytes.saturating_add(data_bytes),"sizeIncomplete":program_incomplete || data_incomplete,"device":device,"details":details,"available":self.ensure_plugin_store().is_ok(),"migrating":self.plugin_migration,"interrupted":fs::read(self.root.join("plugin-storage-migration.json")).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok()).map(|v|v["target"].clone())}))
},
"host.ui.visibility" => {
let views = serde_json::from_value(p)?;
if self.visibility.report(views, Instant::now())? {
self.publish_visibility();
}
Ok(json!(true))
}
"ui.visibility.get" => {
if self.visibility.expire(Instant::now()) {
self.publish_visibility();
}
serde_json::to_value(&self.visibility.state).map_err(Into::into)
}
"system.uninstall.prepare" => {
ensure!(
p["approve"].as_bool() == Some(true),
"请确认卸载全部插件及 Framely"
);
self.prepare_uninstall()
}
"language.list" => crate::localization::list(&self.root),
"language.install" => {
let pack: crate::localization::LanguagePack =
serde_json::from_value(p["pack"].clone())?;
crate::localization::install(&self.root, &pack)?;
Ok(json!(true))
}
"language.save" => {
let language = p["language"]
.as_str()
.context("Missing language")?
.to_owned();
crate::localization::validate_selection(&self.root, &language)?;
let old = self.db.language.clone();
self.db.language = language;
if let Err(e) = self.save() {
self.db.language = old;
return Err(e);
}
Ok(json!(true))
}
"agreement.status" => {
Ok(json!({"version":AGREEMENT_VERSION,"accepted":self.agreement_accepted()}))
}
"agreement.revoke" => {
ensure!(
p["approve"] == true && p["version"].as_str() == Some(AGREEMENT_VERSION),
"请确认拒绝协议将停用全部插件"
);
let old = self.db.clone();
let targets: Vec<_> = self.db.plugins.keys().cloned().collect();
let ordered = crate::relations::order(&self.db, &targets, false).unwrap_or(targets);
self.db.agreement_acceptance = None;
for plugin in self.db.plugins.values_mut() {
plugin.enabled = false;
}
if let Err(e) = self.save() {
self.db = old;
return Err(e);
}
self.waiting.clear();
self.recovery.clear();
for id in ordered.into_iter().rev() {
self.stop_one(&id, "agreement-revoked");
}
self.notifications.clear();
self.db.notification_inbox.clear();
self.cancel_automatic_check();
self.save()?;
process::event(&self.events, json!({"kind":"agreement.revoked"}));
Ok(json!(true))
}
"agreement.accept" => {
ensure!(
p["version"].as_str() == Some(AGREEMENT_VERSION)
&& p["userAgreement"] == true
&& p["privacyStatement"] == true,
"请明确同意当前用户协议和隐私声明"
);
if !self.agreement_accepted() {
let old = self.db.agreement_acceptance.clone();
self.db.agreement_acceptance = Some(AgreementAcceptance {
version: AGREEMENT_VERSION.into(),
accepted_at: now_ms(),
});
if let Err(e) = self.save() {
self.db.agreement_acceptance = old;
return Err(e);
}
self.autostart();
}
Ok(json!(true))
}
"status" => {
self.maintenance();
Ok(
json!({"agreement":{"version":AGREEMENT_VERSION,"accepted":self.agreement_accepted()},"version":env!("CARGO_PKG_VERSION"),"build":fs::read_to_string(self.root.join("current/VERSION")).unwrap_or_else(|_|env!("CARGO_PKG_VERSION").into()).trim(),"systemUpdate":fs::read(self.root.join("update-status.json")).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok()),"previousRelease":fs::read_to_string(self.root.join("previous-release")).ok().filter(|s|!s.trim().is_empty()),"apiVersion":API_VERSION,"runtime":self.runtime,"database":self.db,"running":self.running.iter_mut().filter_map(|(id,r)|if !r.exited(){Some(id.clone())}else{None}).collect::<Vec<_>>(),"notifications":self.notifications.values().filter(|n| n.toast).collect::<Vec<_>>(),"inbox":self.notifications.values().filter(|n| n.in_inbox).collect::<Vec<_>>(),"notificationBadge":self.notification_badge(),"automaticUpdate":{"checking":self.auto_update_job.is_some(),"lastCheckedAt":self.last_update_check,"error":self.auto_update_error} }),
)
}
"inspect" => {
let (manifest, hash) = if p.get("packagePath").is_some() {
(
package::verify_manifest(package::open_staged(&p, self.manager)?)?,
p["sha256"]
.as_str()
.context("Missing package hash")?
.to_owned(),
)
} else {
let staged = package::stage_request_proxy(&p, &self.db.proxy, |_, _| Ok(()))?;
{
let m = staged.manifest()?;
if let Some(e) = &m.engines {
e.check()?;
}
(m, staged.hash.clone())
}
};
if let Some(e) = &manifest.engines {
e.check()?;
}
let old = self.db.plugins.get(&manifest.id);
let changed = old.is_some_and(|o| o.manifest.run_as() != manifest.run_as());
Ok(
json!({"database":self.db,"fingerprint":crate::relations::fingerprint(&self.db)?,"manifest":manifest,"runAsChanged":changed,"sha256":hash}),
)
}
"install" => self.install(p),
"install.batch" => self.install_batch(p),
"plugin.enable.preview" => Ok(json!(crate::relations::activation(&self.db, &id)?)),
"plugin.disable.preview" => Ok(
json!({"dependents":crate::relations::dependents(&self.db,&id).into_iter().filter(|id|self.db.plugins[id].enabled).collect::<Vec<_>>(),"fingerprint":crate::relations::fingerprint(&self.db)?}),
),
"plugin.dependents" => Ok(json!(crate::relations::dependents(&self.db, &id))),
"plugin.dependencies" => self.dependency_status(&id),
"plugin.enable" => {
let enabled = p["enabled"].as_bool().context("Missing enabled")?;
self.plugin(&id)?;
if enabled {
let plan = crate::relations::activation(&self.db, &id)?;
let extra = plan
.enable
.iter()
.any(|target| target != &id && !self.db.plugins[target].enabled)
|| !plan.disable.is_empty();
ensure!(
!extra
|| (p["approve"].as_bool() == Some(true)
&& p["fingerprint"].as_str() == Some(plan.fingerprint.as_str())),
"请先确认依赖启用与冲突停用计划"
);
for target in &plan.enable {
if let Some(e) = &self.plugin(target)?.manifest.engines {
e.check()?;
}
}
for target in &plan.disable {
self.stop_reason(target, "conflict");
self.db.plugins.get_mut(target).unwrap().enabled = false;
self.waiting.remove(target);
}
for target in &plan.enable {
let plugin = self.db.plugins.get_mut(target).unwrap();
plugin.enabled = true;
plugin.error = None;
}
self.save()?;
if plugin_autostart(&self.db, &id) {
self.start_reason(&id, "enable")?;
}
} else {
self.disable_dependents(&id, &p)?;
self.stop_reason(&id, "disable");
self.db.plugins.get_mut(&id).unwrap().enabled = false;
self.waiting.remove(&id);
self.save()?;
}
process::event(
&self.events,
json!({"kind":"plugin.dependency.changed","plugin":id}),
);
Ok(json!(true))
}
"plugin.restart" => {
self.active(&id)?;
self.stop_reason(&id, "restart");
self.start_reason(&id, "restart")?;
self.save()?;
Ok(json!(true))
}
"plugin.favorite" => {
self.plugin(&id)?;
let f = p["favorite"].as_bool().context("Missing favorite")?;
self.db.plugins.get_mut(&id).unwrap().favorite = f;
self.save()?;
Ok(json!(true))
}
"plugin.order" => {
let ids: Vec<String> = serde_json::from_value(p["plugins"].clone())?;
let expected: std::collections::BTreeSet<_> =
self.db.plugins.keys().cloned().collect();
ensure!(
ids.len() == expected.len()
&& ids
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>()
== expected,
"Order must contain each installed plugin once"
);
for (i, id) in ids.iter().enumerate() {
self.db.plugins.get_mut(id).unwrap().order = i as u32;
}
self.save()?;
Ok(json!(true))
}
"plugin.uninstall" => {
let installed = self.plugin(&id)?.clone();
let payload = self.payload(&id)?;
self.disable_dependents(&id, &p)?;
self.stop_reason(&id, "uninstall");
if let Err(error) = self.command_hook(
&installed.manifest,
&payload,
"onUninstall",
"uninstall",
None,
Value::Null,
) {
if p["force"].as_bool() != Some(true) {
self.db.plugins.get_mut(&id).unwrap().error =
Some(format!("卸载清理失败,可重试或强制卸载:{error}"));
self.state(&id, "failed", json!({"reason":error.to_string()}));
self.save()?;
return Err(error);
}
}
self.db.plugins.remove(&id);
self.db.notification_settings.plugins.remove(&id);
self.notifications.retain(|_, n| n.plugin != id);
self.save_notifications()?;
fs::remove_dir_all(self.plugin_store().join("plugins").join(&id))?;
if p["purge"].as_bool() == Some(true) {
let data = self.plugin_store().join("data").join(&id);
if data.exists() {
fs::remove_dir_all(data)?;
}
}
self.runtime.remove(&id);
Ok(json!(true))
}
"launcher.favorite" => {
let key = p["key"].as_str().context("Missing launcher entry")?;
let favorite = p["favorite"].as_bool().context("Missing favorite")?;
if let Some(id) = key.strip_prefix("plugin:") {
self.db
.plugins
.get_mut(id)
.context("Unknown plugin")?
.favorite = favorite;
} else if let Some(id) = key.strip_prefix("steam:") {
let app: u32 = id.parse().context("Invalid Steam ID")?;
ensure!(app > 0, "Invalid Steam ID");
if favorite {
self.db.steam_favorites.insert(app);
} else {
self.db.steam_favorites.remove(&app);
}
} else {
ensure!(
key.len() <= 512
&& !key.chars().any(char::is_control)
&& (key == "framely"
|| ["desktop:", "lepton:"]
.iter()
.any(|prefix| key.starts_with(prefix)
&& key.len() > prefix.len())),
"Invalid launcher entry"
);
if favorite {
ensure!(
self.db.launcher_favorites.len() < 4096
|| self.db.launcher_favorites.contains(key),
"Too many favorites"
);
self.db.launcher_favorites.insert(key.into());
} else {
self.db.launcher_favorites.remove(key);
}
}
self.save()?;
Ok(json!(true))
}
"launcher.gaze.calibration.get" => Ok(json!(self.db.gaze_calibration)),
"launcher.gaze.calibration.save" => {
let calibration: Option<GazeCalibration> = serde_json::from_value(p)?;
if let Some(value) = &calibration {
value.validate()?;
}
self.db.gaze_calibration = calibration;
self.save()?;
Ok(json!(true))
}
"feedback.settings.save" => {
self.db.feedback = serde_json::from_value(p)?;
self.save()?;
Ok(json!(true))
}
"launcher.settings.save" => {
let settings: LauncherSettings = serde_json::from_value(p)?;
settings.validate()?;
self.db.launcher = settings;
self.save()?;
Ok(json!(true))
}
"cast.watchAccess.get" => Ok(serde_json::to_value(crate::cast::watch_access::Grant::load(&self.root)?)?),
"cast.watchAccess.save" => {
let enabled = p["enabled"].as_bool().context("Missing enabled")?;
Ok(serde_json::to_value(crate::cast::watch_access::Grant::save(&self.root, enabled)?)?)
}
"cast.settings.save" => {
let settings: crate::cast::settings::Settings = serde_json::from_value(p)?;
settings.validate()?;
self.db.casting = settings;
self.save()?;
Ok(json!(true))
}
"cast.region.visible.save" => {
let enabled=p["enabled"].as_bool().context("Missing region visibility")?;
let previous=self.db.casting.recording_region_visible;
self.db.casting.recording_region_visible=enabled;
if let Err(error)=self.save() {self.db.casting.recording_region_visible=previous;return Err(error);}
Ok(json!(true))
}
"cast.recordingIndicator.visible.save" => {
let enabled = p["enabled"].as_bool().context("Missing indicator visibility")?;
let previous = self.db.casting.recording_indicator_visible;
self.db.casting.recording_indicator_visible = enabled;
if let Err(error) = self.save() {
self.db.casting.recording_indicator_visible = previous;
return Err(error);
}
Ok(json!({"enabled":enabled}))
}
"cast.panel.start" => {
let executable = self.root.join("current/bin/framely-panel-grab");
ensure!(executable.is_file(), "缺少内置屏幕采集助手");
let directory = self.root.join("cast-runtime");
fs::create_dir_all(&directory)?;
fs::set_permissions(&directory, fs::Permissions::from_mode(0o755))?;
let path = directory.join(format!("panel-{}.sock", &crate::session::cast_random_key()[..16]));
let listener = UnixListener::bind(&path)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
let name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
ensure!(unsafe { libc::chown(name.as_ptr(), self.manager, u32::MAX) } == 0, "无法设置采集助手连接身份");
listener.set_nonblocking(true)?;
let manager = self.manager;
let cleanup = path.clone();
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
match listener.accept() {
Ok((stream, _)) => {
let mut credentials: libc::ucred = unsafe { std::mem::zeroed() };
let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let valid = unsafe { libc::getsockopt(stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, (&mut credentials as *mut libc::ucred).cast(), &mut length) } == 0 && credentials.uid == manager;
if !valid { continue; }
let _ = fs::remove_file(&cleanup);
let _ = Command::new(&executable).stdin(std::process::Stdio::from(std::os::fd::OwnedFd::from(stream))).stdout(std::process::Stdio::null()).status();
break;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => std::thread::sleep(Duration::from_millis(20)),
Err(_) => break,
}
}
let _ = fs::remove_file(cleanup);
});
Ok(json!({"socket":path}))
}
"launcher.order.save" => {
let order: Vec<String> = serde_json::from_value(p["order"].clone())?;
ensure!(order.len() <= 4096, "Too many launcher entries");
let mut seen = std::collections::BTreeSet::new();
for key in &order {
ensure!(
key.len() <= 512
&& !key.chars().any(char::is_control)
&& (key == "framely"
|| ["plugin:", "steam:", "desktop:", "lepton:"]
.iter()
.any(|prefix| key.starts_with(prefix)
&& key.len() > prefix.len()))
&& seen.insert(key),
"Invalid or duplicate launcher entry"
);
}
self.db.launcher_order = order;
self.save()?;
Ok(json!(true))
}
"steam.favorite" => {
let app = p["app"]
.as_u64()
.filter(|n| *n > 0 && *n <= u32::MAX as u64)
.context("Invalid Steam ID")? as u32;
if p["favorite"].as_bool().context("Missing favorite")? {
self.db.steam_favorites.insert(app);
} else {
self.db.steam_favorites.remove(&app);
}
self.save()?;
Ok(json!(true))
}
"plugin.launch.context" => {
self.active(&id)?;
let entry = p["entry"].as_str().unwrap_or("quick");
Ok(self
.launch_contexts
.get(&format!("{id}/{entry}"))
.cloned()
.unwrap_or(json!({"source":"quickPanel","trigger":"shortPress"})))
}
"plugin.launch" => {
let source = p["source"].as_str().context("Missing launch source")?;
ensure!(
["launcher", "quickPanel", "manager"].contains(&source),
"Invalid launch source"
);
let m = self.active(&id)?.manifest.clone();
let target = if p["quickPage"] == true {
LaunchTarget::QuickPage
} else {
m.ui.launch
.get(source)
.cloned()
.unwrap_or(LaunchTarget::QuickPage)
};
match &target {
LaunchTarget::QuickPage => {
ensure!(m.ui.quick_page.is_some(), "Plugin has no quick page")
}
LaunchTarget::Window { window } => {
ensure!(m.ui.windows.contains_key(window), "Window not declared")
}
}
let context = json!({"source":source,"trigger":if p["quickPage"]==true {"menuAction"} else {"shortPress"}});
self.launch_contexts
.insert(format!("{id}/quick"), context.clone());
self.start(&id)?;
let entry = match &target {
LaunchTarget::QuickPage => "quick",
LaunchTarget::Window { window } => window.as_str(),
};
self.launch_contexts
.insert(format!("{id}/{entry}"), context.clone());
process::event(
&self.events,
json!({"kind":"plugin.launch.context","plugin":id,"entry":entry,"context":context}),
);
let response = json!({"target":target,"context":context});
if p["navigate"] != false {
match target {
LaunchTarget::QuickPage => process::event(
&self.events,
json!({"kind":"menu.open","plugin":id,"context":context}),
),
LaunchTarget::Window { window } => process::event(
&self.events,
json!({"kind":"window.open","plugin":id,"window":window,"spec":m.ui.windows[&window],"context":context}),
),
}
}
Ok(response)
}
"plugin.launcher.action" => {
let m = self.active(&id)?.manifest.clone();
let action =
m.ui.launcher_actions
.iter()
.find(|a| Some(a.id.as_str()) == p["action"].as_str())
.context("Unknown launcher action")?
.clone();
let context =
json!({"source":"launcher","trigger":"menuAction","actionId":action.id});
self.launch_contexts
.insert(format!("{id}/quick"), context.clone());
self.start(&id)?;
match action.target {
LauncherActionTarget::Window { window } => {
self.launch_contexts
.insert(format!("{id}/{window}"), context.clone());
process::event(
&self.events,
json!({"kind":"window.open","plugin":id,"window":window,"spec":m.ui.windows[&window],"context":context}),
);
Ok(json!({"opened":true}))
}
LauncherActionTarget::Backend { method, params } => {
let value = self
.running
.get_mut(&id)
.context("No backend")?
.call(&method, json!({"params":params,"context":context}))?;
Ok(json!({"result":value}))
}
LauncherActionTarget::Frontend { entry } => {
Ok(json!({"frontend":entry,"context":context}))
}
}
}
"plugin.open" => {
self.start(&id)?;
Ok(json!(true))
}
"plugin.call" => {
self.start(&id)?;
let name = p["method"].as_str().context("Missing backend method")?;
ensure!(
!name.starts_with("framely.lifecycle.") && !name.starts_with("framely.ui."),
"Lifecycle methods are reserved for the manager"
);
let r = self
.running
.get_mut(&id)
.context("Plugin has no backend")?
.call(name, p["params"].clone());
if let Err(e) = &r {
self.db.plugins.get_mut(&id).unwrap().error = Some(e.to_string());
if self.running.get_mut(&id).is_some_and(|p| p.exited()) {
self.crashed(&id);
}
}
r
}
"window.open" => {
let plugin = self.active(&id)?.clone();
let key = p["window"].as_str().context("Missing window")?;
let window = plugin
.manifest
.ui
.windows
.get(key)
.context("Window not declared")?;
let mut v = json!({"kind":"window.open","plugin":id,"window":key,"spec":window});
if let Some(context) = p.get("launchContext") {
if ["launcher", "quickPanel", "manager"]
.contains(&context["source"].as_str().unwrap_or(""))
{
self.launch_contexts
.insert(format!("{id}/{key}"), context.clone());
v["context"] = context.clone();
}
}
if let Some(icon) = &plugin.manifest.icon {
v["spec"]["iconPath"] = json!(self.payload(&id)?.join(icon));
}
process::event(&self.events, v);
Ok(json!(true))
}
"window.close" => {
self.active(&id)?;
let key = p["window"].as_str().context("Missing window")?;
ensure!(
self.plugin(&id)?.manifest.ui.windows.contains_key(key),
"Window not declared"
);
process::event(
&self.events,
json!({"kind":"window.close","plugin":id,"window":key}),
);
Ok(json!(true))
}
"host.notification.badge" => Ok(json!(self.notification_badge())),
"notification.settings.save" => {
let patch: NotificationSettingsPatch = serde_json::from_value(p)?;
ensure!(
patch.popups.is_some() || patch.badge.is_some(),
"Missing notification setting"
);
let mut settings = self.db.notification_settings.clone();
if let Some(id) = patch.plugin {
// Core notifications already use the empty sender identity.
if !id.is_empty() {
self.plugin(&id)?;
}
let preference = settings.plugins.entry(id).or_default();
if let Some(value) = patch.popups {
preference.popups = value;
}
if let Some(value) = patch.badge {
preference.badge = value;
}
} else {
if let Some(value) = patch.popups {
settings.popups = value;
}
if let Some(value) = patch.badge {
settings.badge = value;
}
}
self.expire_notifications();
let new_inbox = self
.notifications
.values()
.filter(|n| n.in_inbox || n.toast && !settings.popups_for(&n.plugin))
.count();
ensure!(new_inbox <= 256, "Notification inbox full");
self.db.notification_settings = settings;
for entry in self.notifications.values_mut() {
if entry.toast && !self.db.notification_settings.popups_for(&entry.plugin) {
entry.toast = false;
entry.in_inbox = true;
}
}
self.save_notifications()?;
Ok(json!(true))
}
"notification.read" => {
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ReadEntry {
plugin: String,
id: String,
created_at: u64,
}
let entries: Vec<ReadEntry> = serde_json::from_value(p["entries"].clone())?;
ensure!(entries.len() <= 256, "Too many notifications");
let mut changed = false;
for item in entries {
if let Some(n) = self
.notifications
.get_mut(&format!("{}:{}", item.plugin, item.id))
{
if n.in_inbox && !n.read && n.created_at == item.created_at {
n.read = true;
changed = true;
}
}
}
if changed {
self.save_notifications()?;
}
Ok(json!(true))
}
"cast.notification" => {
ensure!(id.is_empty(), "Core notification only");
let notification: Notification=serde_json::from_value(p["notification"].clone())?;
notification.validate()?;
ensure!(notification.id.starts_with("cast."), "Invalid cast notification");
self.insert_notification("", "Framely", notification, None)?;
Ok(json!(true))
},
"notification.send" => self.notify(&id, p["notification"].clone()),
"notification.remove" | "notification.dismiss" => {
if !id.is_empty() {
self.plugin(&id)?;
}
let key = p["id"].as_str().context("Missing notification ID")?;
valid_id(key)?;
let compound = format!("{id}:{key}");
if let Some(entry) = self.notifications.get_mut(&compound) {
ensure!(
p["createdAt"]
.as_u64()
.is_none_or(|time| time == entry.created_at),
"Notification has changed"
);
entry.toast = false;
if method == "notification.remove" {
entry.in_inbox = false;
}
}
self.notifications.retain(|_, n| n.toast || n.in_inbox);
self.save_notifications()?;
Ok(json!(true))
}
"notification.action" => self.notification_action(&id, &p),
"events" => {
let events: Vec<_> = self.events.lock().unwrap().drain(..).collect();
let mut out = Vec::new();
for e in events {
if e["kind"] == "plugin.event" {
let plugin = e["plugin"].as_str().unwrap_or("");
if e["event"] == "notification" {
let _ = self.notify(plugin, e["data"].clone());
} else {
out.push(e);
}
} else {
if e["kind"] == "plugin.error" {
if let Some(p) =
self.db.plugins.get_mut(e["plugin"].as_str().unwrap_or(""))
{
p.error = e["message"].as_str().map(str::to_owned);
}
}
out.push(e);
}
}
self.expire_notifications();
Ok(json!(out))
}
"system.source.save" => {
let source: Option<UpdateSource> = serde_json::from_value(p["source"].clone())?;
if let Some(source) = &source {
source.validate()?;
}
self.updater.ensure_idle()?;
if let Some(source) = &source {
let path = self.root.join("update-source.json");
fs::write(&path, serde_json::to_vec(source)?)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
} else {
let _ = fs::remove_file(self.root.join("update-source.json"));
}
self.reset_automatic_updates();
self.db.update_source = source;
self.updater.clear();
self.save()?;
Ok(json!(true))
}
"system.channel.save" => {
let channel: UpdateChannel = serde_json::from_value(p["channel"].clone())?;
self.updater.ensure_idle()?;
self.reset_automatic_updates();
self.db.update_channel = channel;
self.updater.clear();
self.save()?;
Ok(json!(true))
}
"system.check.settings.save" => {
let settings: UpdateCheckSettings = serde_json::from_value(p["settings"].clone())?;
settings.validate()?;
self.cancel_automatic_check();
self.db.update_check = settings;
self.next_update_check = Instant::now() + Duration::from_secs(30);
self.save()?;
Ok(json!(true))
}
"system.check.start" => self.updater.check(
self.db.update_source.clone().context("请先设置更新源")?,
self.db.update_channel,
self.db.proxy.clone(),
),
"system.download.start" => self
.updater
.prepare(self.root.clone(), self.db.proxy.clone()),
"system.job.status" => self
.updater
.jobs
.status(p["job"].as_str().context("Missing job")?),
"system.apply" => self.updater.apply(
&self.root,
self.manager,
self.db.update_source.as_ref().context("请先配置更新源")?,
self.db.update_channel,
p,
),
"system.rollback" => crate::update::Updater::rollback(&self.root, self.manager, p),
"sources.save" => {
let mut proposed = self.db.clone();
crate::subscriptions::save_sources(
&mut proposed,
serde_json::from_value(p["sources"].clone())?,
)?;
self.db = proposed;
self.save()?;
Ok(json!(true))
}
"subscriptions.add" => {
ensure!(p["approve"].as_bool() == Some(true), "请确认添加订阅");
let mut proposed = self.db.clone();
let id = crate::subscriptions::add(
&mut proposed,
p["url"].as_str().context("缺少订阅 URL")?,
serde_json::from_value(p["document"].clone())?,
)?;
self.db = proposed;
self.save()?;
Ok(json!({"id":id}))
}
"subscriptions.apply" => {
let mut proposed = self.db.clone();
let id = p["id"].as_str().context("缺少订阅 ID")?;
let expected = p["snapshot"].as_str().context("缺少订阅快照")?;
ensure!(
crate::package::digest(&serde_json::to_vec(
self.db
.subscriptions
.iter()
.find(|s| s.id == id)
.context("订阅已移除")?
)?) == expected,
"订阅已改变,请重新刷新"
);
crate::subscriptions::apply(
&mut proposed,
id,
serde_json::from_value(p["document"].clone())?,
serde_json::from_value(p["etag"].clone())?,
serde_json::from_value(p["lastModified"].clone())?,
serde_json::from_value(p["error"].clone())?,
)?;
self.db = proposed;
self.save()?;
Ok(json!(true))
}
"subscriptions.change" => {
let mut proposed = self.db.clone();
let accept = p["acceptUrls"].as_bool().unwrap_or(false);
let remove = p["remove"].as_bool().unwrap_or(false);
ensure!(
!(accept || remove) || p["approve"].as_bool() == Some(true),
"请确认订阅变更"
);
crate::subscriptions::change(
&mut proposed,
p["id"].as_str().context("缺少订阅 ID")?,
p["enabled"].as_bool(),
p["autoRefresh"].as_bool(),
accept,
remove,
)?;
self.db = proposed;
self.save()?;
Ok(json!(true))
}
"network.password.configured" => {
Ok(json!(self.root.join("network-password.json").is_file()))
}
"network.password.verify" => self.verifier.verify(&self.root, &p),
"proxy.save" => {
let mut config: ProxySettings = serde_json::from_value(p)?;
config.http = config.http.trim().to_owned();
config.github = config.github.trim().trim_end_matches('/').to_owned();
config.validate()?;
let previous = std::mem::replace(&mut self.db.proxy, config);
if let Err(error) = self.save() { self.db.proxy = previous; return Err(error); }
Ok(json!(true))
}
"network.save" | "network.password.setup" => {
let setup = method == "network.password.setup";
let mut params = if setup {
ensure!(
!self.root.join("network-password.json").exists(),
"访问密码已设置,请登录"
);
let password = p["password"].as_str().context("请设置访问密码")?;
ensure!(
password.chars().count() >= 8 && password.len() <= 512,
"访问密码至少 8 个字符,最多 512 字节"
);
ensure!(
p["confirmPassword"].as_str() == Some(password),
"两次输入的密码不一致"
);
let mut config = self.db.network_panel.clone();
config.password_enabled = true;
// Persist authentication before creating credentials so an interrupted
// setup cannot leave a configured password with authentication disabled.
self.db.network_panel = config.clone();
self.save()?;
let mut params = serde_json::to_value(config)?;
params["password"] = json!(password);
params
} else {
p.clone()
};
let password = params
.as_object_mut()
.context("Invalid settings")?
.remove("password");
let config: NetworkPanel = serde_json::from_value(params)?;
config.validate()?;
if let Some(password) = password
.and_then(|p| p.as_str().map(str::to_owned))
.filter(|p| !p.is_empty())
{
ensure!(password.len() <= 512, "密码过长");
let salt: [u8; 16] = rand::random();
let mut hash = [0u8; 32];
pbkdf2::pbkdf2_hmac::<sha2::Sha256>(
password.as_bytes(),
&salt,
600_000,
&mut hash,
);
let file = self.root.join("network-password.json.new");
let mut options = fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut out = options.mode(0o600).open(&file)?;
out.write_all(&serde_json::to_vec(
&json!({"salt":hex::encode(salt),"hash":hex::encode(hash)}),
)?)?;
out.sync_all()?;
fs::rename(file, self.root.join("network-password.json"))?;
}
ensure!(
!config.password_enabled || self.root.join("network-password.json").is_file(),
"请先设置访问密码"
);
self.db.network_panel = config;
self.save()?;
Ok(json!(true))
}
"safeMode" => {
let enabled = p["enabled"].as_bool().context("Missing enabled")?;
self.db.safe_mode = enabled;
let ids: Vec<_> = self.running.keys().cloned().collect();
for id in ids {
self.stop(&id);
}
self.save()?;
if !enabled {
self.autostart();
}
Ok(json!(true))
}
"diagnostics.export" => crate::diagnostics::collect(&self.root),
"logs" => {
if !id.is_empty() {
self.plugin(&id)?;
}
let mut result = String::new();
let name = if id.is_empty() { "framely" } else { &id };
for suffix in ["log", "lifecycle.log"] {
let path = self.root.join("logs").join(format!("{name}.{suffix}"));
if path.exists() {
let mut file = fs::File::open(path)?;
let start = file.metadata()?.len().saturating_sub(32768);
file.seek(SeekFrom::Start(start))?;
let mut data = Vec::new();
file.take(32768).read_to_end(&mut data)?;
result.push_str(&format!("--- {suffix} ---\n"));
result.push_str(&String::from_utf8_lossy(&data));
result.push('\n');
}
}
Ok(json!(result))
}
_ => bail!("Unknown service method"),
}
}
fn notify(&mut self, id: &str, value: Value) -> Result<Value> {
let plugin = self.active(id)?;
let n: Notification = serde_json::from_value(value)?;
n.validate()?;
let name = plugin.manifest.name.clone();
let rate = self.rates.entry(id.into()).or_default();
let now = Instant::now();
while rate
.front()
.is_some_and(|t| now.duration_since(*t) > Duration::from_secs(10))
{
rate.pop_front();
}
ensure!(rate.len() < 10, "Notification rate exceeded");
rate.push_back(now);
self.insert_notification(id, &name, n, None)?;
Ok(json!(true))
}
fn insert_notification(
&mut self,
id: &str,
name: &str,
notification: Notification,
update: Option<UpdateNotice>,
) -> Result<()> {
self.expire_notifications();
let key = format!("{id}:{}", notification.id);
let toast = self.db.notification_settings.popups_for(id);
let in_inbox = notification.inbox || !toast;
ensure!(
!toast
|| self.notifications.values().filter(|n| n.toast).count() < 64
|| self.notifications.get(&key).is_some_and(|n| n.toast),
"Notification queue full"
);
ensure!(
!in_inbox
|| self.notifications.values().filter(|n| n.in_inbox).count() < 256
|| self.notifications.get(&key).is_some_and(|n| n.in_inbox),
"Notification inbox full"
);
let created_at = now_ms().max(self.notifications.get(&key).map_or(0, |n| n.created_at + 1));
let expires_at =
(notification.duration_ms != 0).then(|| created_at + notification.duration_ms);
let entry = NotificationEntry {
plugin: id.into(),
plugin_name: name.into(),
created_at,
expires_at,
toast,
in_inbox,
read: false,
notification,
update,
};
self.notifications.insert(key, entry);
self.save_notifications()
}
fn notification_badge(&self) -> usize {
if !self.agreement_accepted() {
return 0;
}
self.notifications
.values()
.filter(|n| n.in_inbox && !n.read && self.db.notification_settings.badge_for(&n.plugin))
.count()
}
fn save_notifications(&mut self) -> Result<()> {
self.db.notification_inbox = self
.notifications
.iter()
.filter(|(_, n)| n.in_inbox)
.map(|(k, n)| (k.clone(), n.clone()))
.collect();
self.save()?;
process::event(&self.events, json!({"kind":"notification.changed"}));
Ok(())
}
fn expire_notifications(&mut self) {
let now = now_ms();
let mut changed = false;
for n in self.notifications.values_mut() {
if n.toast && n.expires_at.is_some_and(|time| time <= now) {
n.toast = false;
changed = true;
}
}
self.notifications.retain(|_, n| n.toast || n.in_inbox);
if changed {
process::event(&self.events, json!({"kind":"notification.changed"}));
}
}
fn notification_action(&mut self, id: &str, p: &Value) -> Result<Value> {
self.expire_notifications();
let key = p["id"].as_str().context("Missing ID")?;
let action = p["action"].as_str().context("Missing action")?;
let compound = format!("{id}:{key}");
let entry = self
.notifications
.get(&compound)
.context("Notification expired")?
.clone();
ensure!(
p["createdAt"]
.as_u64()
.is_none_or(|time| time == entry.created_at),
"Notification has changed"
);
let policy = entry
.notification
.actions
.iter()
.find(|a| a.id == action)
.context("Action not declared")?;
let result = if id.is_empty() && key.starts_with("cast.") {
json!(true)
} else if let Some(update) = &entry.update {
ensure!(
self.db.update_source.as_ref() == Some(&update.source)
&& self.db.update_channel == update.channel,
"Update source or channel has changed"
);
match action {
"ignore" => json!(true),
"open" => {
process::event(
&self.events,
json!({"kind":"manager.open","page":"updates"}),
);
json!({"openUpdates":true})
}
_ => bail!("Action not declared"),
}
} else {
self.active(id)?;
let result = if self.plugin(id)?.manifest.backend.is_some() {
match self.handle("plugin.call", json!({"plugin":id,"method":"notification.action","params":{"id":key,"action":action}})) {
Ok(value) => value,
Err(error) => {
// Starting a failed backend may clear transient resources. Keep the
// message for retry, and never remove it as a successful action.
if self.notifications.get(&compound).is_none_or(|n| n.created_at == entry.created_at) {
self.notifications.insert(compound.clone(), entry.clone());
self.save_notifications()?;
}
return Err(error);
}
}
} else {
json!(true)
};
process::event(
&self.events,
json!({"kind":"plugin.event","plugin":id,"event":"notification.action","data":{"id":key,"action":action}}),
);
result
};
if let Some(n) = self
.notifications
.get_mut(&compound)
.filter(|n| n.created_at == entry.created_at)
{
if policy.close_on_click {
n.toast = false;
}
if policy.remove_from_inbox_on_click {
n.in_inbox = false;
}
}
self.notifications.retain(|_, n| n.toast || n.in_inbox);
self.save_notifications()?;
Ok(result)
}
fn cancel_automatic_check(&mut self) {
if let Some((job, _, _)) = self.auto_update_job.take() {
let _ = self.automatic_updater.jobs.cancel(&job);
}
self.auto_update_error = None;
}
fn reset_automatic_updates(&mut self) {
self.cancel_automatic_check();
self.db.update_notice = None;
self.notifications.retain(|_, n| n.update.is_none());
self.db.notification_inbox.retain(|_, n| n.update.is_none());
self.next_update_check = Instant::now() + Duration::from_secs(30);
process::event(&self.events, json!({"kind":"notification.changed"}));
}
fn automatic_update_check(&mut self) {
if let Some((job, source, channel)) = self.auto_update_job.clone() {
if let Ok(result) = self.automatic_updater.jobs.status(&job) {
if matches!(
result["phase"].as_str(),
Some("done" | "failed" | "cancelled")
) {
self.auto_update_job = None;
self.last_update_check = Some(now_ms());
self.auto_update_error = result["error"].as_str().map(str::to_owned);
if result["phase"] == "done"
&& self.db.update_check.enabled
&& self.db.update_source.as_ref() == Some(&source)
&& self.db.update_channel == channel
{
if let Err(e) = self.announce_update(&result["result"], source, channel) {
self.auto_update_error = Some(e.to_string());
}
}
}
} else {
self.auto_update_job = None;
}
}
if !self.db.update_check.enabled
|| self.auto_update_job.is_some()
|| Instant::now() < self.next_update_check
|| self.updater.ensure_idle().is_err()
{
return;
}
let Some(source) = self.db.update_source.clone() else {
return;
};
let installing = fs::read(self.root.join("update-status.json"))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.is_some_and(|v| v["phase"] == "installing");
if installing {
return;
}
self.next_update_check =
Instant::now() + Duration::from_secs(self.db.update_check.interval_hours * 3600);
match self.automatic_updater.check(
source.clone(),
self.db.update_channel,
self.db.proxy.clone(),
) {
Ok(job) => {
self.auto_update_job = Some((
job["job"].as_str().unwrap().into(),
source,
self.db.update_channel,
));
self.auto_update_error = None;
}
Err(e) => {
self.auto_update_error = Some(e.to_string());
self.last_update_check = Some(now_ms());
}
}
}
fn announce_update(
&mut self,
result: &Value,
source: UpdateSource,
channel: UpdateChannel,
) -> Result<()> {
let Some(version) = result["release"]["version"].as_str() else {
return Ok(());
};
let current = fs::read_to_string(self.root.join("current/VERSION"))
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into());
if crate::update::descriptor_version(version)?
.cmp_precedence(&crate::update::descriptor_version(current.trim())?)
!= std::cmp::Ordering::Greater
{
return Ok(());
}
let notice = UpdateNotice {
source,
channel,
version: version.into(),
};
if self.db.update_notice.as_ref() == Some(&notice) {
return Ok(());
}
let notification: Notification = serde_json::from_value(
json!({"id":"framely.update","title":format!("Framely {version} is available"),"body":"Open Updates to review and install this version, or ignore this reminder.","inbox":true,"durationMs":15000,"actions":[{"id":"ignore","label":"Ignore"},{"id":"open","label":"Open updates"}]}),
)?;
self.insert_notification("", "Framely", notification, Some(notice.clone()))?;
self.db.update_notice = Some(notice);
self.save()
}
fn install(&mut self, p: Value) -> Result<Value> {
ensure!(
p["approve"].as_bool() == Some(true),
"Installation requires explicit approval"
);
let bytes = package::request_bytes(&p, &self.db.proxy, self.manager)?;
let v = package::verify(&bytes)?;
if let Some(e) = &v.manifest.engines {
e.check()?;
}
if let Some(hash) = p["inspectionHash"].as_str() {
ensure!(
package::digest(&bytes) == hash,
"Package changed since review"
);
}
let id = v.manifest.id.clone();
if !self.batching {
if let Some(expected) = p["fingerprint"].as_str() {
ensure!(
crate::relations::fingerprint(&self.db)? == expected,
"插件状态已改变,请重新检查安装计划"
);
}
let mut proposed = self.db.clone();
let mut installed = proposed.plugins.get(&id).cloned().unwrap_or(Installed {
manifest: v.manifest.clone(),
enabled: true,
favorite: false,
order: proposed.plugins.len() as u32,
source: None,
error: None,
});
installed.manifest = v.manifest.clone();
installed.source = p["source"].as_str().map(str::to_owned);
proposed.plugins.insert(id.clone(), installed);
crate::relations::order(&proposed, std::slice::from_ref(&id), false)?;
crate::relations::validate_updates(&proposed, std::slice::from_ref(&id))?;
crate::relations::check_enabled(&proposed)?;
}
if let Some(old) = self.db.plugins.get(&id) {
let expanded = old.manifest.run_as() != v.manifest.run_as();
ensure!(
!expanded || p["approveRunAs"].as_bool() == Some(true),
"Run user changed; confirmation required"
);
}
let source = p["source"].as_str().map(str::to_owned);
if let Some(s) = &source {
ensure!(
self.db.sources.iter().any(|q| q.id == *s),
"Unknown installation source"
);
}
let base = self.plugin_store().join("plugins").join(&id);
self.ensure_plugin_store()?;
fs::create_dir_all(base.join("versions"))?;
let dest = base.join("versions").join(&v.manifest.version);
let newly_created = !dest.exists();
let stage = base.join(format!(".stage-{}", rand::random::<u64>()));
if let Err(e) = package::unpack(&v, &stage) {
let _ = fs::remove_dir_all(&stage);
return Err(e);
}
let was_running = self.running.contains_key(&id);
let old = self.db.plugins.get(&id).cloned();
if !newly_created {
if let Err(e) = record_replacement(&self.root, &id, &v.manifest.version, &self.db, was_running, self.batching) {
let _ = fs::remove_dir_all(&stage);
return Err(e);
}
}
self.stop_reason(&id, if old.is_some() { "update" } else { "install" });
let switch = if !newly_created {
let journal: Value = serde_json::from_slice(&fs::read(self.root.join("install-transaction.json"))?)?;
let backup = replacement_path(&self.root, &journal, &id)?;
fs::rename(&dest, &backup).and_then(|_| fs::rename(&stage, &dest))
} else {
fs::rename(&stage, &dest)
};
if let Err(error) = switch {
let _ = fs::remove_dir_all(&stage);
if !newly_created {
restore_one_replacement(&self.root, &id)?;
if !self.batching { clear_replacement_journal(&self.root)?; }
}
if !self.batching && was_running { let _ = self.start_reason(&id, "install-rollback"); }
return Err(error.into());
}
let phase = if old.is_some() {
"onUpdate"
} else {
"onInstall"
};
if let Err(error) = self.command_hook(
&v.manifest,
&dest,
phase,
if old.is_some() { "update" } else { "install" },
old.as_ref().map(|p| p.manifest.version.as_str()),
Value::Null,
) {
if !newly_created {
restore_one_replacement(&self.root, &id)?;
if !self.batching { clear_replacement_journal(&self.root)?; }
}
if newly_created {
let _ = fs::remove_dir_all(&dest);
}
if let Some(previous) = &old {
self.db.plugins.get_mut(&id).unwrap().error =
Some(format!("更新失败,保留旧版:{error}"));
if was_running || (previous.enabled && plugin_autostart(&self.db, &id)) {
let _ = self.start_reason(&id, "update-rollback");
}
}
self.state(&id, "failed", json!({"reason":error.to_string()}));
self.save()?;
return Err(error);
}
let installed = Installed {
manifest: v.manifest,
enabled: old.as_ref().map(|o| o.enabled).unwrap_or(true),
favorite: old.as_ref().is_some_and(|o| o.favorite),
order: old
.as_ref()
.map(|o| o.order)
.unwrap_or(self.db.plugins.len() as u32),
source: source.or_else(|| old.as_ref().and_then(|o| o.source.clone())),
error: None,
};
let link = base.join("current.tmp");
let _ = fs::remove_file(&link);
symlink(
Path::new("versions").join(&installed.manifest.version),
&link,
)?;
fs::rename(link, base.join("current"))?;
self.db.plugins.insert(id.clone(), installed);
if let Err(e) = self.save() {
if !newly_created {
restore_one_replacement(&self.root, &id)?;
if !self.batching { clear_replacement_journal(&self.root)?; }
}
if let Some(old) = old.clone() {
let _ = fs::remove_file(base.join("current"));
let _ = symlink(
Path::new("versions").join(&old.manifest.version),
base.join("current"),
);
self.db.plugins.insert(id, old);
} else {
self.db.plugins.remove(&id);
let _ = fs::remove_file(base.join("current"));
}
if newly_created {
let _ = fs::remove_dir_all(&dest);
}
return Err(e);
}
self.state(&id, "installed", Value::Null);
if !self.batching
&& (was_running || plugin_autostart(&self.db, &id))
&& self.db.plugins[&id].enabled
&& !self.db.safe_mode
{
if let Err(error) = self.start_reason(&id, "install") {
// A startup hook failure is an installation failure, not a successful activation.
self.stop_reason(&id, "install-rollback");
if !newly_created {
restore_one_replacement(&self.root, &id)?;
clear_replacement_journal(&self.root)?;
}
if let Some(previous) = old {
let _ = fs::remove_file(base.join("current"));
let _ = symlink(
Path::new("versions").join(&previous.manifest.version),
base.join("current"),
);
self.db.plugins.insert(id.clone(), previous);
if was_running || plugin_autostart(&self.db, &id) {
let _ = self.start_reason(&id, "update-rollback");
}
} else {
self.db.plugins.remove(&id);
let _ = fs::remove_file(base.join("current"));
}
if newly_created {
let _ = fs::remove_dir_all(&dest);
}
self.save()?;
return Err(error);
}
}
if !self.batching && !newly_created {
clear_replacement_journal(&self.root)?;
}
Ok(json!(self.db.plugins[&id]))
}
fn disable_dependents(&mut self, id: &str, p: &Value) -> Result<()> {
let ids: Vec<_> = crate::relations::dependents(&self.db, id)
.into_iter()
.filter(|id| self.db.plugins[id].enabled)
.collect();
ensure!(
ids.is_empty()
|| (p["approveDependents"].as_bool() == Some(true)
&& p["fingerprint"].as_str()
== Some(crate::relations::fingerprint(&self.db)?.as_str())),
"此操作会停用依赖该插件的插件,请先确认"
);
for target in ids {
self.stop_reason(&target, "dependency-disabled");
self.db.plugins.get_mut(&target).unwrap().enabled = false;
self.waiting.remove(&target);
}
Ok(())
}
fn dependency_status(&self, id: &str) -> Result<Value> {
let m = &self.plugin(id)?.manifest;
let mut items = vec![];
for (target, dep) in m.dependencies.iter().chain(&m.optional_dependencies) {
let p = self.db.plugins.get(target);
let matches = p.is_some_and(|p| {
dep.matches(&p.manifest.version).unwrap_or(false)
&& crate::relations::source_matches(&self.db, dep, target).unwrap_or(false)
});
let available = matches
&& p.is_some_and(|p| p.enabled)
&& !self.db.safe_mode
&& p.is_some_and(|p| {
p.manifest.backend.is_none() || self.running.contains_key(target)
});
items.push(json!({"id":target,"required":m.dependencies.contains_key(target),"constraint":dep,"version":p.map(|p|&p.manifest.version),"enabled":p.map(|p|p.enabled).unwrap_or(false),"matches":matches,"available":available,"state":self.runtime.get(target)}));
}
Ok(json!(items))
}
fn install_batch(&mut self, p: Value) -> Result<Value> {
ensure!(p["approve"].as_bool() == Some(true), "请确认批量安装");
let plan: crate::planner::Plan = serde_json::from_value(p["plan"].clone())?;
ensure!(
plan.fingerprint == crate::relations::fingerprint(&self.db)?,
"插件状态已改变,请重新检查安装计划"
);
let requests = p["packages"].as_array().context("缺少安装包快照")?;
ensure!(
!requests.is_empty() && requests.len() <= 32 && plan.items.len() <= 32,
"无效批量安装数量"
);
let original = self.db.clone();
let mut proposed = original.clone();
for source in &plan.add_sources {
source.validate()?;
ensure!(
!proposed.sources.iter().any(|s| s.id == source.id),
"新增源 ID 已存在"
);
proposed.sources.push(source.clone());
}
crate::subscriptions::initialize(&mut proposed)?;
let mut verified = BTreeMap::new();
for request in requests {
let bytes = package::request_bytes(request, &self.db.proxy, self.manager)?;
let v = package::verify(&bytes)?;
if let Some(e) = &v.manifest.engines {
e.check()?;
}
let source = request["source"].as_str().map(str::to_owned);
let item = plan
.items
.iter()
.find(|item| item.manifest.id == v.manifest.id)
.context("安装包不在已审核计划中")?;
ensure!(
serde_json::to_value(&item.manifest)? == serde_json::to_value(&v.manifest)?
&& item.source == source,
"安装包与已审核计划不一致"
);
ensure!(
source
.as_ref()
.is_none_or(|id| proposed.sources.iter().any(|s| &s.id == id)),
"未知安装来源"
);
let mut installed =
original
.plugins
.get(&v.manifest.id)
.cloned()
.unwrap_or(Installed {
manifest: v.manifest.clone(),
enabled: true,
favorite: false,
order: proposed.plugins.len() as u32,
source: None,
error: None,
});
installed.manifest = v.manifest.clone();
installed.source = source;
proposed.plugins.insert(v.manifest.id.clone(), installed);
ensure!(
verified
.insert(v.manifest.id.clone(), request.clone())
.is_none(),
"重复安装包"
);
}
crate::relations::order(&proposed, std::slice::from_ref(&plan.root), false)?;
crate::relations::validate_updates(
&proposed,
&verified.keys().cloned().collect::<Vec<_>>(),
)?;
let activation = if proposed
.plugins
.get(&plan.root)
.context("缺少根插件")?
.enabled
{
Some(crate::relations::activation(&proposed, &plan.root)?)
} else {
None
};
let (enable, disable) = activation
.map(|p| (p.enable, p.disable))
.unwrap_or_default();
ensure!(
enable == plan.enable && disable == plan.disable,
"启用和冲突计划已改变"
);
for id in &disable {
proposed
.plugins
.get_mut(id)
.context("无效冲突插件")?
.enabled = false;
}
for id in &enable {
proposed
.plugins
.get_mut(id)
.context("无效依赖插件")?
.enabled = true;
}
crate::relations::check_enabled(&proposed)?;
let targets: Vec<_> = verified.keys().cloned().collect();
let mut impacted: BTreeSet<_> = targets.iter().cloned().collect();
for id in &targets {
impacted.extend(crate::relations::dependents(&original, id));
}
impacted.extend(disable.iter().cloned());
let running: BTreeSet<_> = self
.running
.keys()
.filter(|id| impacted.contains(*id))
.cloned()
.collect();
let journal = json!({"database":original,"targets":targets,"running":running});
let path = self.root.join("install-transaction.json");
let temporary = self.root.join("install-transaction.tmp");
fs::write(&temporary, serde_json::to_vec(&journal)?)?;
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
fs::File::open(&temporary)?.sync_all()?;
fs::rename(temporary, &path)?;
fs::File::open(&self.root)?.sync_all()?;
for id in &impacted {
self.stop_reason(id, "batch-install");
self.waiting.remove(id);
}
self.db.sources = proposed.sources.clone();
self.db.source_origins = proposed.source_origins.clone();
self.db.source_urls = proposed.source_urls.clone();
self.db.source_enabled = proposed.source_enabled.clone();
self.batching = true;
let result = (|| -> Result<Value> {
for item in &plan.items {
if let Some(request) = verified.get(&item.manifest.id) {
let mut request = request.clone();
request["approve"] = json!(true);
request["approveRunAs"] = p["approveRunAs"].clone();
self.install(request)
.with_context(|| format!("安装插件 {} 失败", item.manifest.id))?;
}
}
for (id, plugin) in &proposed.plugins {
if let Some(current) = self.db.plugins.get_mut(id) {
current.enabled = plugin.enabled;
}
}
self.batching = false;
self.save()?;
let roots: Vec<_> = impacted
.iter()
.filter(|id| {
!self.db.safe_mode
&& self.db.plugins.get(*id).is_some_and(|p| p.enabled)
&& (running.contains(*id) || plugin_autostart(&self.db, id))
})
.cloned()
.collect();
for id in roots {
self.start_reason(&id, "batch-install")
.with_context(|| format!("启动插件 {id} 失败"))?;
}
self.save()?;
Ok(json!({"installed":targets,"disabled":disable}))
})();
self.batching = false;
if let Err(error) = result {
for id in &impacted {
self.stop_reason(id, "transaction-rollback");
self.waiting.remove(id);
}
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
restore_replaced(&self.root, &journal)?;
restore_links(original.plugin_storage.as_deref().unwrap_or(&self.root), &original, &targets)?;
self.db = original;
self.save()?;
for id in &running {
let _ = self.start_reason(id, "transaction-rollback");
}
self.save()?;
fs::remove_file(path)?;
return Err(error).context("批量安装失败,已恢复原版本和启用状态");
}
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
fs::remove_file(path)?;
discard_replacements(&self.root, &journal)?;
fs::File::open(&self.root)?.sync_all()?;
for id in impacted {
process::event(
&self.events,
json!({"kind":"plugin.dependency.changed","plugin":id}),
);
}
result
}
}
fn restore_links(root: &Path, original: &Database, targets: &[String]) -> Result<()> {
for id in targets {
valid_id(id)?;
let base = root.join("plugins").join(id);
if let Some(p) = original.plugins.get(id) {
let path = Path::new("versions").join(&p.manifest.version);
ensure!(
base.join(&path).join("manifest.json").is_file(),
"无法恢复旧版本:{id}"
);
let temporary = base.join("current.tmp");
let _ = fs::remove_file(&temporary);
symlink(path, &temporary)?;
fs::rename(temporary, base.join("current"))?;
} else if base.exists() {
fs::remove_dir_all(base)?;
}
}
Ok(())
}
fn replacement_path(root: &Path, journal: &Value, id: &str) -> Result<PathBuf> {
valid_id(id)?;
let name = journal["backups"][id]
.as_str()
.context("缺少同版本安装备份记录")?;
ensure!(name.starts_with(".previous-") && !name.contains('/') && !name.contains('\\'), "无效插件备份路径");
let original: Database = serde_json::from_value(journal["database"].clone())?;
Ok(original.plugin_storage.as_deref().unwrap_or(root).join("plugins").join(id).join(name))
}
fn record_replacement(root: &Path, id: &str, version: &str, original: &Database, running: bool, batching: bool) -> Result<()> {
let path = root.join("install-transaction.json");
let mut journal: Value = if batching {
serde_json::from_slice(&fs::read(&path)?)?
} else {
json!({"database": original, "targets": [id], "running": if running {vec![id]} else {vec![]}})
};
let name = format!(".previous-{}-{:016x}", version, rand::random::<u64>());
ensure!(!name.contains('/') && !name.contains('\\'), "无效插件版本");
journal["backups"][id] = json!(name);
journal["replacementVersions"][id] = json!(version);
let temporary = root.join("install-transaction.tmp");
fs::write(&temporary, serde_json::to_vec(&journal)?)?;
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
fs::File::open(&temporary)?.sync_all()?;
fs::rename(temporary, &path)?;
fs::File::open(root)?.sync_all()?;
Ok(())
}
fn restore_one_replacement(root: &Path, id: &str) -> Result<()> {
let journal: Value = serde_json::from_slice(&fs::read(root.join("install-transaction.json"))?)?;
let backup = replacement_path(root, &journal, id)?;
if backup.is_dir() {
let version = journal["replacementVersions"][id].as_str().context("缺少替换版本")?;
let original: Database = serde_json::from_value(journal["database"].clone())?;
let dest = original.plugin_storage.as_deref().unwrap_or(root).join("plugins").join(id).join("versions").join(version);
if dest.exists() { fs::remove_dir_all(&dest)?; }
fs::rename(backup, dest)?;
}
Ok(())
}
fn restore_replaced(root: &Path, journal: &Value) -> Result<()> {
if let Some(backups) = journal["backups"].as_object() {
for id in backups.keys() {
restore_one_replacement(root, id)?;
}
}
Ok(())
}
fn discard_replacements(root: &Path, journal: &Value) -> Result<()> {
if let Some(backups) = journal["backups"].as_object() {
for id in backups.keys() {
let path = replacement_path(root, journal, id)?;
if path.exists() { fs::remove_dir_all(path)?; }
}
}
Ok(())
}
fn clear_replacement_journal(root: &Path) -> Result<()> {
let path = root.join("install-transaction.json");
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
fs::remove_file(path)?;
fs::File::open(root)?.sync_all()?;
discard_replacements(root, &journal)
}
fn plugin_restarts(db: &Database, id: &str) -> bool {
db.plugins.get(id).is_some_and(|p| {
p.enabled
&& p.manifest
.backend
.as_ref()
.is_some_and(|b| b.restart == RestartPolicy::OnFailure)
})
}
fn plugin_autostart(db: &Database, id: &str) -> bool {
db.plugins
.get(id)
.is_some_and(|p| p.enabled && p.manifest.backend.as_ref().is_some_and(|b| b.autostart))
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn dispatch(core: &Arc<Mutex<Service>>, method: &str, params: Value) -> Result<Value> {
if method == "storage.plugin.job" {
let service = core.lock().unwrap();
return service.storage_jobs.status(params["job"].as_str().context("缺少任务 ID")?);
}
if method == "storage.plugin.start" {
let path = PathBuf::from(params["path"].as_str().context("缺少目标路径")?);
let (jobs, source, target, root, resume, follow_links) = {
let mut service = core.lock().unwrap();
ensure!(service.agreement_accepted(), "请先同意用户协议和隐私声明");
ensure!(!service.plugin_migration, "插件存储正在迁移");
ensure!(!service.root.join("install-transaction.json").exists(), "插件安装正在进行");
let source = service.plugin_store().to_path_buf();
service.ensure_plugin_store()?;
let follow_links = service.db.plugin_storage.is_none();
let root = service.root.clone();
let journal = root.join("plugin-storage-migration.json");
let (target, resume) = if journal.exists() {
let record: Value = serde_json::from_slice(&fs::read(&journal)?)?;
ensure!(record["source"] == json!(source), "存储迁移来源已改变,请检查原目录");
if record["target"] == json!(path) {
let resume = path.exists() && crate::storage_location::verified_selected(&source, &path, &["plugins".into(), "data".into()], follow_links)?;
ensure!(!path.exists() || resume || (path.is_dir() && fs::read_dir(&path)?.next().is_none()), "上次迁移副本已与原目录不同,请选择新的空目录");
(path.clone(), resume)
} else { (crate::storage_location::validate_target(&source, &path, false)?, false) }
} else {
(crate::storage_location::validate_target(&source, &path, false)?, false)
};
service.plugin_migration = true;
(service.storage_jobs.clone(), source, target, root, resume, follow_links)
};
let shared = core.clone();
let task = jobs.task_progress("storage:plugin", move |cancel, progress| {
let mut running = Vec::new();
let result = (|| -> Result<Value> {
crate::storage_location::write_journal(&root.join("plugin-storage-migration.json"), &json!({"source":source,"target":target}))?;
progress(json!({"step":"stopping"}));
{
let mut service = shared.lock().unwrap();
running = service.running.keys().cloned().collect();
for id in running.iter().rev() { service.stop_reason(id, "storage-migration"); }
service.recovery.clear();
}
cancel.check()?;
if !resume { crate::storage_location::copy_selected(&source, &target, &["plugins".into(), "data".into()], follow_links, |step| progress(json!({"step":step})))?; }
cancel.check()?;
{
let mut service = shared.lock().unwrap();
let previous = service.db.plugin_storage.clone();
service.db.plugin_storage = Some(target.clone());
if let Err(error) = service.save() {
service.db.plugin_storage = previous;
return Err(error);
}
progress(json!({"step":"restarting"}));
let restart = (|| -> Result<()> {
for id in &running { service.start_reason(id, "storage-migration")?; }
Ok(())
})();
if let Err(error) = restart {
for id in &running { service.stop_reason(id, "storage-rollback"); }
service.db.plugin_storage = previous;
service.save()?;
return Err(error).context("迁移后插件启动失败,已恢复原位置");
}
}
fs::remove_file(root.join("plugin-storage-migration.json"))?;
progress(json!({"step":"cleaning"}));
let mut cleanup_errors = Vec::new();
for name in ["plugins", "data"] {
let old = source.join(name);
if old.exists() {
let remove = if follow_links && fs::symlink_metadata(&old).is_ok_and(|m|m.file_type().is_symlink()) {
let physical = old.canonicalize();
physical.and_then(|directory| {
for entry in fs::read_dir(directory)? {
let path = entry?.path();
if path.is_dir() && !fs::symlink_metadata(&path)?.file_type().is_symlink() { fs::remove_dir_all(path)?; }
else { fs::remove_file(path)?; }
}
Ok(())
})
} else { fs::remove_dir_all(old) };
if let Err(error) = remove { cleanup_errors.push(error.to_string()); }
}
}
Ok(json!({"path":target,"cleanupWarnings":cleanup_errors}))
})();
{
let mut service = shared.lock().unwrap();
if result.is_err() {
for id in &running { let _ = service.start_reason(id, "storage-rollback"); }
}
service.plugin_migration = false;
}
result
});
if task.is_err() { core.lock().unwrap().plugin_migration = false; }
return task;
}
if method == "network.password.verify" {
let (root, verifier) = {
let service = core.lock().unwrap();
(service.root.clone(), service.verifier.clone())
};
return verifier.verify(&root, &params);
}
core.lock().unwrap().handle(method, params)
}
static SHUTDOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
extern "C" fn shutdown_signal(_: libc::c_int) {
SHUTDOWN.store(true, std::sync::atomic::Ordering::Release);
}
pub fn serve(root: &Path, socket: &Path, manager: u32) -> Result<()> {
ensure!(
unsafe { libc::geteuid() } == 0,
"Core service must run as root"
);
unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = shutdown_signal as *const () as usize;
libc::sigemptyset(&mut action.sa_mask);
ensure!(
libc::sigaction(libc::SIGTERM, &action, std::ptr::null_mut()) == 0
&& libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut()) == 0,
"Cannot register shutdown signals"
);
}
let mut core = Service::load(root, manager)?;
fs::create_dir_all(socket.parent().context("Socket path needs parent")?)?;
if socket.exists() {
ensure!(
UnixStream::connect(socket).is_err(),
"Core service already running"
);
fs::remove_file(socket)?;
}
let listener = UnixListener::bind(socket)?;
fs::set_permissions(socket, fs::Permissions::from_mode(0o660))?;
let group = Command::new("id")
.args(["-g", &manager.to_string()])
.output()?;
ensure!(group.status.success(), "Cannot resolve Steam user group");
let gid: libc::gid_t = std::str::from_utf8(&group.stdout)?.trim().parse()?;
let name = std::ffi::CString::new(socket.as_os_str().as_encoded_bytes())?;
ensure!(
unsafe { libc::chown(name.as_ptr(), 0, gid) } == 0,
"Cannot set socket owner"
);
listener.set_nonblocking(true)?;
core.autostart();
let core = Arc::new(Mutex::new(core));
let monitor = core.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_millis(500));
if SHUTDOWN.load(std::sync::atomic::Ordering::Acquire) {
break;
}
if let Ok(mut core) = monitor.lock() {
core.maintenance();
}
});
while !SHUTDOWN.load(std::sync::atomic::Ordering::Acquire) {
let mut stream = match listener.accept() {
Ok((stream, _)) => stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(30));
continue;
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.into()),
};
let mut cred: libc::ucred = unsafe { std::mem::zeroed() };
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let r = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut cred as *mut _ as *mut _,
&mut len,
)
};
if r != 0 || !(cred.uid == 0 || cred.uid == manager) {
let _ = ipc::write(&mut stream, &json!({"error":"Unauthorized peer UID"}));
continue;
}
stream.set_read_timeout(Some(Duration::from_secs(90)))?;
let core = core.clone();
std::thread::spawn(move || {
let result = (|| -> Result<Value> {
let request = ipc::read(&mut stream)?;
let method = request["method"].as_str().context("Missing method")?;
ensure!(
method != "system.uninstall.prepare" || cred.uid == 0,
"Uninstall requires root"
);
ensure!(
!SHUTDOWN.load(std::sync::atomic::Ordering::Acquire),
"Manager is shutting down"
);
dispatch(&core, method, request["params"].clone())
})();
if let Err(error) = &result {
eprintln!("Framely request failed: {error:#}");
}
let response = ipc::response(result);
let _ = ipc::write(&mut stream, &response);
});
}
core.lock().unwrap().shutdown();
let _ = fs::remove_file(socket);
Ok(())
}
#[cfg(test)]
mod request_tests {
use super::*;
#[test]
fn password_verification_does_not_hold_service_lock() {
let root = tempfile::tempdir().unwrap();
let service = Service::load(root.path(), 1000).unwrap();
let verifier = service.verifier.clone();
fs::write(
root.path().join("network-password.json"),
serde_json::to_vec(&json!({"salt":"00".repeat(16),"hash":"00".repeat(32)})).unwrap(),
)
.unwrap();
let core = Arc::new(Mutex::new(service));
let worker_core = core.clone();
let worker = std::thread::spawn(move || {
dispatch(
&worker_core,
"network.password.verify",
json!({"password":"wrong"}),
)
.unwrap()
});
for _ in 0..200 {
if verifier.active() > 0 {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(verifier.active(), 1);
let result = core
.try_lock()
.expect("Password verification blocked core")
.handle("status", json!({}))
.unwrap();
assert!(result.get("database").is_some());
assert_eq!(worker.join().unwrap(), json!(false));
}
}
#[cfg(test)]
mod storage_tests {
use super::*;
#[test]
fn plugin_storage_migration_moves_files_and_survives_reload() {
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let state = temp.path().join("state");
let mut service = crate::tests::accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
let package = crate::tests::fixture(temp.path(), "test.storage", "1.0.0", 1, None);
service.handle("install", json!({"package":base64::Engine::encode(&base64::engine::general_purpose::STANDARD, package),"approve":true})).unwrap();
let data = state.join("data/test.storage/steamos/save.txt");
fs::create_dir_all(data.parent().unwrap()).unwrap();
fs::write(&data, "saved").unwrap();
let persistent = temp.path().join("persistent");
fs::create_dir(&persistent).unwrap();
for name in ["plugins", "data"] {
fs::rename(state.join(name), persistent.join(name)).unwrap();
symlink(persistent.join(name), state.join(name)).unwrap();
}
let target = temp.path().join("relocated");
let core = Arc::new(Mutex::new(service));
let job = dispatch(&core, "storage.plugin.start", json!({"path":target})).unwrap()["job"].as_str().unwrap().to_owned();
let result = loop {
let status = dispatch(&core, "storage.plugin.job", json!({"job":job})).unwrap();
if status["phase"] == "done" || status["phase"] == "failed" { break status; }
std::thread::sleep(Duration::from_millis(20));
};
assert_eq!(result["phase"], "done", "{result}");
assert_eq!(fs::read_to_string(target.join("data/test.storage/steamos/save.txt")).unwrap(), "saved");
assert!(target.join("plugins/test.storage/versions/1.0.0/manifest.json").is_file());
assert!(fs::symlink_metadata(state.join("plugins")).unwrap().file_type().is_symlink());
assert!(fs::read_dir(persistent.join("plugins")).unwrap().next().is_none());
assert!(fs::read_dir(persistent.join("data")).unwrap().next().is_none());
drop(core);
let loaded = Service::load(&state, unsafe { libc::geteuid() }).unwrap();
assert_eq!(loaded.db.plugin_storage.as_deref(), Some(target.as_path()));
assert!(loaded.payload("test.storage").unwrap().is_dir());
}
}
#[cfg(test)]
mod notification_tests {
use super::*;
fn plugin(core: &mut Service) {
core.db.plugins.insert("test.notice".into(), serde_json::from_value(json!({"manifest":{"schemaVersion":1,"apiVersion":1,"id":"test.notice","name":"Notice tests","author":"Framely","version":"1.0.0","ui":{"quickPage":"page.js"},"files":{}},"enabled":true,"favorite":false,"order":0,"source":null})).unwrap());
}
fn send(core: &mut Service, id: &str, extra: Value) {
let mut n = json!({"id":id,"title":"Download finished","body":"Ready to open","actions":[{"id":"open","label":"Open"}]});
for (k, v) in extra.as_object().unwrap() {
n[k] = v.clone();
}
core.handle(
"notification.send",
json!({"plugin":"test.notice","notification":n}),
)
.unwrap();
}
fn action(core: &mut Service, id: &str) -> Result<Value> {
core.handle(
"notification.action",
json!({"plugin":"test.notice","id":id,"action":"open"}),
)
}
#[test]
fn popup_settings_preserve_suppressed_messages_and_survive_reload() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
assert!(core.db.notification_settings.popups);
assert!(core.db.notification_settings.badge);
send(&mut core, "visible", json!({}));
core.handle("notification.settings.save", json!({"popups":false}))
.unwrap();
assert!(!core.notifications["test.notice:visible"].toast);
assert!(core.notifications["test.notice:visible"].in_inbox);
send(&mut core, "silent", json!({}));
let status = core.handle("status", json!({})).unwrap();
assert_eq!(status["notifications"].as_array().unwrap().len(), 0);
assert_eq!(status["inbox"].as_array().unwrap().len(), 2);
assert_eq!(status["notificationBadge"], 2);
let mut reloaded = Service::load(root.path(), 1000).unwrap();
assert!(!reloaded.db.notification_settings.popups);
assert_eq!(reloaded.notification_badge(), 2);
reloaded
.handle("notification.settings.save", json!({"popups":true}))
.unwrap();
assert!(reloaded.notifications.values().all(|n| !n.toast));
send(&mut reloaded, "next", json!({}));
assert!(reloaded.notifications["test.notice:next"].toast);
assert!(!reloaded.notifications["test.notice:next"].in_inbox);
}
#[test]
fn plugin_notification_preferences_are_independent_and_global_switches_take_priority() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
core.handle(
"notification.settings.save",
json!({"plugin":"test.notice","popups":false}),
)
.unwrap();
core.handle(
"notification.settings.save",
json!({"plugin":"test.notice","badge":false}),
)
.unwrap();
send(&mut core, "silent", json!({}));
assert!(!core.notifications["test.notice:silent"].toast);
assert!(core.notifications["test.notice:silent"].in_inbox);
assert_eq!(core.notification_badge(), 0);
core.handle(
"notification.settings.save",
json!({"plugin":"test.notice","badge":true}),
)
.unwrap();
assert_eq!(core.notification_badge(), 1);
core.handle(
"notification.settings.save",
json!({"badge":false,"popups":false}),
)
.unwrap();
core.handle(
"notification.settings.save",
json!({"plugin":"test.notice","popups":true}),
)
.unwrap();
send(&mut core, "global", json!({}));
assert!(!core.notifications["test.notice:global"].toast);
assert_eq!(core.notification_badge(), 0);
assert!(core.db.notification_settings.plugins["test.notice"].badge);
core.handle("notification.settings.save", json!({"badge":true}))
.unwrap();
assert_eq!(core.notification_badge(), 2);
assert!(core
.handle(
"notification.settings.save",
json!({"plugin":"missing","badge":false})
)
.is_err());
assert!(core
.handle("notification.settings.save", json!({"badge":"false"}))
.is_err());
assert!(core
.handle("notification.settings.save", json!({"unknown":true}))
.is_err());
assert_eq!(core.notification_badge(), 2);
}
#[test]
fn framely_notification_preferences_control_update_reminders_and_survive_reload() {
let root = tempfile::tempdir().unwrap();
// Model an installed release independently of the build's package version.
fs::create_dir(root.path().join("current")).unwrap();
fs::write(
root.path().join("current/VERSION"),
"0.4.2-preview.12-012345abcdef\n",
)
.unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
send(&mut core, "saved", json!({"inbox":true}));
let source = UpdateSource {
url: "https://example.org/framely-release.json".into(),
};
core.db.update_source = Some(source.clone());
core.announce_update(
&json!({"release":{"version":"0.5.0-abcdef012345"}}),
source.clone(),
UpdateChannel::Stable,
)
.unwrap();
assert!(core.notifications[":framely.update"].toast);
assert_eq!(core.notification_badge(), 2);
core.handle(
"notification.settings.save",
json!({"plugin":"","popups":false,"badge":false}),
)
.unwrap();
assert!(core.db.notification_settings.popups && core.db.notification_settings.badge);
assert!(!core.notifications[":framely.update"].toast);
assert!(core.notifications[":framely.update"].in_inbox);
assert!(core.notifications["test.notice:saved"].toast);
assert_eq!(core.notification_badge(), 1);
core.announce_update(
&json!({"release":{"version":"0.5.1-abcdef012345"}}),
source,
UpdateChannel::Stable,
)
.unwrap();
assert!(!core.notifications[":framely.update"].toast);
assert!(core.notifications[":framely.update"].in_inbox);
let mut core = Service::load(root.path(), 1000).unwrap();
assert!(!core.db.notification_settings.plugins[""].popups);
assert!(!core.db.notification_settings.plugins[""].badge);
assert_eq!(core.notification_badge(), 1);
core.handle(
"notification.settings.save",
json!({"plugin":"","badge":true}),
)
.unwrap();
assert_eq!(core.notification_badge(), 2);
assert!(!core.notifications[":framely.update"].toast);
core.handle("notification.settings.save", json!({"badge":false}))
.unwrap();
assert_eq!(core.notification_badge(), 0);
assert!(core.db.notification_settings.plugins[""].badge);
}
#[test]
fn reading_inbox_clears_badge_without_removing_messages_or_reading_replacements() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
send(&mut core, "saved", json!({"inbox":true}));
let old = core.notifications["test.notice:saved"].created_at;
send(&mut core, "saved", json!({"inbox":true}));
core.handle(
"notification.read",
json!({"entries":[{"plugin":"test.notice","id":"saved","createdAt":old}]}),
)
.unwrap();
assert_eq!(core.notification_badge(), 1);
let current = core.notifications["test.notice:saved"].created_at;
core.handle(
"notification.read",
json!({"entries":[{"plugin":"test.notice","id":"saved","createdAt":current}]}),
)
.unwrap();
assert_eq!(core.notification_badge(), 0);
assert!(core.notifications["test.notice:saved"].toast);
assert_eq!(core.db.notification_inbox.len(), 1);
let reloaded = Service::load(root.path(), 1000).unwrap();
assert_eq!(reloaded.notification_badge(), 0);
assert!(reloaded.db.notification_inbox["test.notice:saved"].read);
}
#[test]
fn notifications_opt_in_to_persistent_inbox_and_default_actions_remove() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
send(&mut core, "transient", json!({}));
send(&mut core, "saved", json!({"inbox":true}));
assert_eq!(core.db.notification_inbox.len(), 1);
core.notifications
.get_mut("test.notice:saved")
.unwrap()
.expires_at = Some(0);
core.notifications
.get_mut("test.notice:transient")
.unwrap()
.expires_at = Some(0);
core.expire_notifications();
assert!(!core.notifications.contains_key("test.notice:transient"));
assert!(!core.notifications["test.notice:saved"].toast);
let mut reloaded = Service::load(root.path(), 1000).unwrap();
assert_eq!(reloaded.notifications.len(), 1);
assert!(!reloaded.notifications["test.notice:saved"].toast);
action(&mut reloaded, "saved").unwrap();
assert!(reloaded.notifications.is_empty());
assert!(Service::load(root.path(), 1000)
.unwrap()
.db
.notification_inbox
.is_empty());
}
#[test]
fn notification_action_policies_are_independent_and_stale_actions_are_rejected() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
for close in [false, true] {
for remove in [false, true] {
let id = format!("policy.{close}.{remove}");
send(
&mut core,
&id,
json!({"inbox":true,"durationMs":0,"actions":[{"id":"open","label":"Open","closeOnClick":close,"removeFromInboxOnClick":remove}]}),
);
action(&mut core, &id).unwrap();
let entry = core.notifications.get(&format!("test.notice:{id}"));
assert_eq!(entry.is_some(), !close || !remove);
if let Some(entry) = entry {
assert_eq!(entry.toast, !close);
assert_eq!(entry.in_inbox, !remove);
assert!(entry.expires_at.is_none());
}
}
}
send(&mut core, "replace", json!({"inbox":true}));
let old = core.notifications["test.notice:replace"].created_at;
send(&mut core, "replace", json!({"inbox":true}));
assert!(core
.handle(
"notification.action",
json!({"plugin":"test.notice","id":"replace","action":"open","createdAt":old})
)
.is_err());
assert!(core.notifications["test.notice:replace"].toast);
}
#[test]
fn dismiss_and_disable_keep_inbox_but_removal_drops_it() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
send(&mut core, "saved", json!({"inbox":true}));
core.handle(
"notification.dismiss",
json!({"plugin":"test.notice","id":"saved"}),
)
.unwrap();
assert!(!core.notifications["test.notice:saved"].toast);
assert!(core.notifications["test.notice:saved"].in_inbox);
core.cleanup_resources("test.notice");
core.db.plugins.get_mut("test.notice").unwrap().enabled = false;
assert!(action(&mut core, "saved").is_err());
assert!(core.notifications.contains_key("test.notice:saved"));
core.handle(
"notification.remove",
json!({"plugin":"test.notice","id":"saved"}),
)
.unwrap();
assert!(core.db.notification_inbox.is_empty());
}
#[test]
fn failed_notification_backend_keeps_message_for_retry() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
crate::process::TEST_TOOLS
.with(|p| *p.borrow_mut() = Some(root.path().join("missing-tools")));
send(&mut core, "saved", json!({"inbox":true}));
core.db
.plugins
.get_mut("test.notice")
.unwrap()
.manifest
.backend =
Some(serde_json::from_value(json!({"entry":"missing","runAs":"steamos"})).unwrap());
assert!(action(&mut core, "saved").is_err());
assert!(core.notifications["test.notice:saved"].toast);
assert!(core.db.notification_inbox.contains_key("test.notice:saved"));
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
}
#[test]
fn automatic_update_notifications_ignore_old_versions_and_deduplicate_after_reload() {
let root = tempfile::tempdir().unwrap();
// Model an installed release independently of the build's package version.
fs::create_dir(root.path().join("current")).unwrap();
fs::write(
root.path().join("current/VERSION"),
"0.4.2-preview.12-012345abcdef\n",
)
.unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
let source = UpdateSource {
url: "https://example.org/framely-release.json".into(),
};
core.db.update_source = Some(source.clone());
for version in ["0.1.0", "0.4.2-preview.12-012345abcdef"] {
core.announce_update(
&json!({"release":{"version":version}}),
source.clone(),
UpdateChannel::Stable,
)
.unwrap();
assert!(core.notifications.is_empty());
}
let result = json!({"release":{"version":"0.5.0-abcdef012345"}});
core.announce_update(&result, source.clone(), UpdateChannel::Stable)
.unwrap();
assert_eq!(core.notifications.len(), 1);
core.handle(
"notification.action",
json!({"id":"framely.update","action":"ignore"}),
)
.unwrap();
assert!(core.notifications.is_empty());
let mut core = Service::load(root.path(), 1000).unwrap();
core.announce_update(&result, source.clone(), UpdateChannel::Stable)
.unwrap();
assert!(core.notifications.is_empty());
core.announce_update(
&json!({"release":{"version":"0.5.1-abcdef012345"}}),
source,
UpdateChannel::Stable,
)
.unwrap();
assert_eq!(
core.handle(
"notification.action",
json!({"id":"framely.update","action":"open"})
)
.unwrap(),
json!({"openUpdates":true})
);
assert!(core
.events
.lock()
.unwrap()
.iter()
.any(|e| e["kind"] == "manager.open" && e["page"] == "updates"));
assert!(core.db.notification_inbox.is_empty());
}
#[test]
fn automatic_update_schedule_settings_and_stale_completions() {
let root = tempfile::tempdir().unwrap();
// Model an installed release independently of the build's package version.
fs::create_dir(root.path().join("current")).unwrap();
fs::write(
root.path().join("current/VERSION"),
"0.4.2-preview.12-012345abcdef\n",
)
.unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
let source = UpdateSource {
url: "https://example.org/framely-release.json".into(),
};
core.db.update_source = Some(source.clone());
assert!(core.db.update_check.enabled);
assert_eq!(core.db.update_check.interval_hours, 6);
core.handle(
"system.check.settings.save",
json!({"settings":{"enabled":false,"intervalHours":12}}),
)
.unwrap();
core.next_update_check = Instant::now();
core.automatic_update_check();
assert!(core.auto_update_job.is_none());
assert!(
!Service::load(root.path(), 1000)
.unwrap()
.db
.update_check
.enabled
);
for hours in [0, 169] {
assert!(core
.handle(
"system.check.settings.save",
json!({"settings":{"enabled":true,"intervalHours":hours}})
)
.is_err());
}
core.db.update_check.enabled = true;
core.next_update_check = Instant::now() + Duration::from_secs(3600);
core.automatic_update_check();
assert!(core.auto_update_job.is_none());
// Inject a completed background job to exercise the timer's completion path
// without any external network request or UI polling.
let job = core
.automatic_updater
.jobs
.task("system.check", |_| {
Ok(json!({"release":{"version":"0.5.0-abcdef012345"}}))
})
.unwrap();
let id = job["job"].as_str().unwrap().to_owned();
for _ in 0..100 {
if core.automatic_updater.jobs.status(&id).unwrap()["phase"] == "done" {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
core.auto_update_job = Some((id, source.clone(), UpdateChannel::Stable));
core.automatic_update_check();
assert_eq!(core.db.notification_inbox.len(), 1);
assert!(core.last_update_check.is_some());
core.handle("system.channel.save", json!({"channel":"testing"}))
.unwrap();
assert!(core.db.notification_inbox.is_empty());
assert!(core.db.update_notice.is_none());
// A result from the previous channel arriving late cannot recreate its reminder.
core.auto_update_job = Some((
job["job"].as_str().unwrap().into(),
source,
UpdateChannel::Stable,
));
core.automatic_update_check();
assert!(core.db.notification_inbox.is_empty());
// A due check advances the configured timer even if source validation fails;
// this exercises failure/retry scheduling without contacting a server.
core.db.update_source = Some(UpdateSource {
url: "file:///invalid-release.json".into(),
});
core.next_update_check = Instant::now();
core.automatic_update_check();
assert!(core.auto_update_error.is_some());
assert!(core.auto_update_job.is_none());
assert!(
core.next_update_check.duration_since(Instant::now())
> Duration::from_secs(12 * 3600 - 2)
);
}
}
#[cfg(test)]
mod feedback_tests {
use super::*;
#[test]
fn feedback_defaults_persist_and_reject_invalid_settings() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
assert!(core.db.feedback.haptics && !core.db.feedback.sounds);
core.handle(
"feedback.settings.save",
json!({"haptics":false,"sounds":true}),
)
.unwrap();
let mut reloaded = Service::load(root.path(), 1000).unwrap();
assert!(!reloaded.db.feedback.haptics && reloaded.db.feedback.sounds);
assert!(reloaded
.handle("feedback.settings.save", json!({"haptics":"off"}))
.is_err());
assert!(reloaded
.handle("feedback.settings.save", json!({"other":true}))
.is_err());
assert!(!reloaded.db.feedback.haptics && reloaded.db.feedback.sounds);
reloaded
.handle(
"feedback.settings.save",
json!({"haptics":false,"sounds":false}),
)
.unwrap();
let reloaded = Service::load(root.path(), 1000).unwrap();
assert!(!reloaded.db.feedback.haptics && !reloaded.db.feedback.sounds);
let old: Database =
serde_json::from_value(json!({"plugins":{},"sources":[],"safeMode":false})).unwrap();
assert!(old.feedback.haptics && !old.feedback.sounds);
}
}