mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-21 19:01:50 +02:00
3364 lines
143 KiB
Rust
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(¬ice) {
|
|
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, ¶ms);
|
|
}
|
|
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);
|
|
}
|
|
}
|