//! Module service and window runtime (port of `plugins/pluginService.js` and the window //! part of `plugins/pluginRuntime.js`): catalog state, install/uninstall, isolated module //! windows (one per module, child of the main window), collapse to the Core taskbar. use crate::core::app::{Core, MODULE_API_VERSION}; use crate::core::error::{CoreError, CoreResult}; use crate::core::modules::catalog::CatalogProvider; use crate::core::modules::installer::ModuleInstaller; use crate::core::modules::registry::ModuleRegistry; use crate::core::version::compare_versions; use crate::security::caller::{ModuleContext, MAIN_LABEL}; use parking_lot::Mutex; use serde_json::{json, Value}; use std::cmp::Ordering; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; use std::sync::Arc; use tauri::Manager; pub const MODULE_CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; worker-src 'self'; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'"; #[derive(Clone)] struct WindowState { label: String, name: String, version: String, minimized: bool, } pub struct ModuleService { pub registry: Arc, installer: ModuleInstaller, catalog: Mutex>, source_base_url: Mutex>, operation: Mutex>, windows: Mutex>, counter: AtomicU64, close_gate: Mutex, close_guard_installed: Mutex, close_queries: Mutex)>>, exit_approved: std::sync::atomic::AtomicBool, } struct OperationGuard<'a>(&'a Mutex>); impl Drop for OperationGuard<'_> { fn drop(&mut self) { *self.0.lock() = None; } } async fn confirm_windows(labels: &[String], mut confirm: F) -> bool where F: FnMut(String) -> Fut, Fut: std::future::Future { for label in labels { if !confirm(label.clone()).await { return false; } } true } impl ModuleService { pub fn new(registry: Arc, installer: ModuleInstaller) -> Self { Self { registry, installer, catalog: Mutex::new(Vec::new()), source_base_url: Mutex::new(None), operation: Mutex::new(None), windows: Mutex::new(HashMap::new()), counter: AtomicU64::new(0), close_gate: Mutex::new(false), close_guard_installed: Mutex::new(false), close_queries: Mutex::new(HashMap::new()), exit_approved: Default::default(), } } fn begin(&self, name: &'static str) -> CoreResult> { let mut op = self.operation.lock(); if op.is_some() { return Err(CoreError::plain("Another plugin operation is already running.")); } *op = Some(name); Ok(OperationGuard(&self.operation)) } /// Install once the main bridge is ready, before any module can hold a draft. pub fn install_close_guard(&self, core: &Core) -> CoreResult<()> { let mut installed = self.close_guard_installed.lock(); if *installed { return Ok(()); } let plugin = tauri::plugin::Builder::::new("draft-close-guard") .on_event(|app, event| { let Some(core) = app.state::().try_get() else { return }; match event { tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { api, .. }, .. } if label == MAIN_LABEL || core.windows.modules().iter().any(|context| context.label == *label) => { api.prevent_close(); ModuleService::request_close(core, Some(label.clone()), None); } tauri::RunEvent::ExitRequested { api, code, .. } if !core.services.modules.exit_approved() => { api.prevent_exit(); ModuleService::request_close(core, None, *code); } _ => {} } }).build(); core.app.plugin(plugin).map_err(|error| CoreError::plain(format!("Close guard could not be installed: {error}")))?; *installed = true; Ok(()) } pub fn exit_approved(&self) -> bool { self.exit_approved.load(AtomicOrdering::SeqCst) } /// Replies are scoped to the calling WebView and a fresh query ID. pub fn close_query_reply(&self, label: &str, id: u64, dirty: bool) { let mut queries = self.close_queries.lock(); if queries.get(label).is_some_and(|(expected, _)| *expected == id) { if let Some((_, reply)) = queries.remove(label) { let _ = reply.send(dirty); } } } async fn confirm_close(&self, core: &Core, label: &str) -> bool { use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; let Some(window) = core.app.get_webview_window(label) else { return true }; let id = self.counter.fetch_add(1, AtomicOrdering::SeqCst) + 1; let (tx, rx) = tokio::sync::oneshot::channel(); self.close_queries.lock().insert(label.to_string(), (id, tx)); let script = format!("window.dispatchEvent(new CustomEvent('chj:query-dirty', {{ detail: {id} }}))"); if window.eval(&script).is_err() { self.close_queries.lock().remove(label); return false; } let dirty = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await; self.close_queries.lock().remove(label); match dirty { Ok(Ok(false)) => true, Ok(Ok(true)) => { let (tx, rx) = tokio::sync::oneshot::channel(); core.app.dialog().message(core.t("chrome.discardChanges")).title(window.title().unwrap_or_default()) .parent(&window).kind(MessageDialogKind::Warning) .buttons(MessageDialogButtons::OkCancelCustom(core.t("chrome.discard"), core.t("chrome.cancel"))) .show(move |discard| { let _ = tx.send(discard); }); rx.await.unwrap_or(false) } _ => false, } } /// Called by native CloseRequested and ExitRequested before any window is destroyed. pub fn request_close(core: Arc, label: Option, exit_code: Option) { { let mut active = core.services.modules.close_gate.lock(); if *active { return; } *active = true; } tauri::async_runtime::spawn(async move { let modules = &core.services.modules; let all = label.as_deref().is_none_or(|l| l == MAIN_LABEL); let mut labels: Vec = if all { core.windows.modules().iter().map(|ctx| ctx.label.clone()).collect() } else { vec![label.clone().unwrap()] }; // Confirm every draft before destroying anything, so Cancel preserves the whole app. if all && core.app.get_webview_window(MAIN_LABEL).is_some() { labels.push(MAIN_LABEL.to_string()); } if !confirm_windows(&labels, |target| { let core = &core; async move { modules.confirm_close(core, &target).await } }).await { *modules.close_gate.lock() = false; return; } if label.is_none() || (all && !cfg!(target_os = "macos")) { modules.exit_approved.store(true, AtomicOrdering::SeqCst); } for target in labels { if let Some(window) = core.app.get_webview_window(&target) { let _ = window.destroy(); } } *modules.close_gate.lock() = false; if label.is_none() { core.app.exit(exit_code.unwrap_or(0)); } }); } pub fn provider(&self, core: &Core) -> CoreResult { let config = core.config.get(); let base_urls: Vec = config["updates"]["baseUrls"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); CatalogProvider::new(&base_urls, core.services.https.clone(), core.paths.in_data("plugins/.downloads"), core.logger.clone()) } pub fn list_windows(&self) -> Vec { let mut list: Vec = self .windows .lock() .iter() .map(|(id, w)| json!({ "pluginId": id, "moduleId": id, "name": w.name, "version": w.version, "minimized": w.minimized })) .collect(); list.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); list } pub fn get_state(&self) -> CoreResult { let installed = self.registry.list_installed()?; let catalog: Vec = self .catalog .lock() .iter() .map(|module| { let current = installed.iter().find(|m| m["id"] == module["id"]); let comparison = current .map(|c| compare_versions(module["version"].as_str().unwrap_or("0.0"), c["version"].as_str().unwrap_or("0.0")).unwrap_or(Ordering::Equal)) .unwrap_or(Ordering::Greater); let same_artifact = current.and_then(|c| c["installedSha512"].as_str()).map(|s| Some(s) == module["sha512"].as_str()).unwrap_or(false); let mut entry = module.clone(); entry["installedVersion"] = current.map(|c| c["version"].clone()).unwrap_or(Value::Null); entry["installedReleaseId"] = current.map(|c| c["installedReleaseId"].clone()).unwrap_or(Value::Null); entry["updateAvailable"] = Value::Bool(current.is_some() && (comparison == Ordering::Greater || (comparison == Ordering::Equal && !same_artifact))); entry["installed"] = Value::Bool(current.is_some() && (comparison == Ordering::Less || (comparison == Ordering::Equal && same_artifact))); entry }) .collect(); Ok(json!({ "status": self.operation.lock().unwrap_or("idle"), "installed": installed, "windows": self.list_windows(), "catalog": catalog, "sourceBaseUrl": *self.source_base_url.lock() })) } pub async fn check_catalog(&self, core: &Core) -> CoreResult { let _guard = self.begin("checking")?; let config = core.config.get(); let channel = config["plugins"]["channel"].as_str().unwrap_or("alpha").to_string(); let (list, base) = self.provider(core)?.list(&core.build.version, MODULE_API_VERSION, &channel).await?; core.logger.info("Plugin catalog checked.", json!({ "count": list.len(), "channel": channel, "sourceBaseUrl": base })); *self.catalog.lock() = list; *self.source_base_url.lock() = Some(base); drop(_guard); self.get_state() } pub async fn install(&self, core: &Core, release_id: &str) -> CoreResult { let module = self.catalog.lock().iter().find(|m| m["releaseId"].as_str() == Some(release_id)).cloned().ok_or_else(|| CoreError::plain("Selected plugin release is not in the current catalog."))?; let guard = self.begin("installing")?; let provider = self.provider(core)?; let package = provider.download_and_verify(&module).await?; let result = self.installer.install(&package, &module)?; match provider.remove_downloaded(&package) { Ok(removed) => core.logger.info("Installed plugin package removed from download cache.", json!({ "pluginId": module["id"], "releaseId": module["releaseId"], "removed": removed })), Err(error) => core.logger.warn("Installed plugin package could not be removed from download cache.", json!({ "pluginId": module["id"], "releaseId": module["releaseId"], "message": error.message })), } core.logger.info("Plugin installed.", json!({ "pluginId": result.manifest["id"], "version": result.manifest["version"], "permissions": result.manifest["permissions"] })); drop(guard); let mut state = self.get_state()?; state["installedPlugin"] = result.manifest; state["reused"] = Value::Bool(result.reused); Ok(state) } pub async fn uninstall(&self, core: &Core, module_id: &str) -> CoreResult { let guard = self.begin("uninstalling")?; let label = self.windows.lock().get(module_id).map(|window| window.label.clone()); if let Some(label) = label { if !self.confirm_close(core, &label).await { drop(guard); let mut state = self.get_state()?; state["canceled"] = json!(true); return Ok(state); } if let Some(window) = core.app.get_webview_window(&label) { window.destroy().map_err(|error| CoreError::plain(error.to_string()))?; } } let removed = self.registry.remove_installed(module_id)?; drop(guard); let mut state = self.get_state()?; state["removedPluginId"] = if removed { Value::String(module_id.to_string()) } else { Value::Null }; Ok(state) } pub fn cleanup_downloads(&self, core: &Core) -> CoreResult { self.provider(core)?.cleanup_downloads() } pub fn open(&self, core: &Core, module_id: &str) -> CoreResult { self.install_close_guard(core)?; if !core.vault.is_unlocked() { return Err(CoreError::plain("Unlock the vault before opening a plugin.")); } let resolved = self.registry.resolve(module_id)?; let id = resolved.manifest["id"].as_str().unwrap_or("").to_string(); let existing = self.windows.lock().get(&id).cloned(); if let Some(state) = existing { if let Some(window) = core.app.get_webview_window(&state.label) { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); if let Some(entry) = self.windows.lock().get_mut(&id) { entry.minimized = false; } self.emit_state(core); return Ok(resolved.manifest); } self.windows.lock().remove(&id); } let label = format!("module-{}", self.counter.fetch_add(1, AtomicOrdering::SeqCst) + 1); let permissions = resolved.manifest["permissions"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); core.windows.register_module(ModuleContext { id: id.clone(), label: label.clone(), manifest: resolved.manifest.clone(), permissions, root: resolved.root.clone() }); let name = resolved.manifest["name"].as_str().unwrap_or(&id).to_string(); self.windows.lock().insert(id.clone(), WindowState { label: label.clone(), name: name.clone(), version: resolved.manifest["version"].as_str().unwrap_or("").to_string(), minimized: false }); let created = crate::windows::create_module_window( &core.app, &core.i18n, crate::windows::ModuleWindowSpec { label: &label, module_id: &id, name: &name, entry: resolved.manifest["entry"].as_str().unwrap_or("ui/index.html"), root: &resolved.root, data_directory: core.paths.module_webview_data(&id), }, ); if let Err(error) = created { core.windows.unregister_module(&label); self.windows.lock().remove(&id); core.logger.error("Plugin window failed to load.", json!({ "pluginId": id, "message": error.to_string() })); return Err(CoreError::plain(format!("The module window could not be opened: {error}"))); } core.logger.info("Plugin window opened.", json!({ "pluginId": id, "version": resolved.manifest["version"] })); self.emit_state(core); Ok(resolved.manifest) } /// The module page finished loading: show it unless it was collapsed meanwhile. pub fn on_loaded(&self, core: &Core, label: &str) { let minimized = self.windows.lock().values().find(|w| w.label == label).map(|w| w.minimized).unwrap_or(true); if let Some(window) = core.app.get_webview_window(label) { if !minimized { let _ = window.show(); let _ = window.set_focus(); } } self.emit_state(core); } pub fn minimize_label(&self, core: &Core, label: &str, focus_main: bool) { let id = self.windows.lock().iter().find(|(_, w)| w.label == label).map(|(id, _)| id.clone()); let Some(id) = id else { return }; if let Some(window) = core.app.get_webview_window(label) { let _ = window.hide(); } if let Some(entry) = self.windows.lock().get_mut(&id) { entry.minimized = true; } if focus_main { if let Some(main) = core.app.get_webview_window(MAIN_LABEL) { let _ = main.unminimize(); let _ = main.show(); let _ = main.set_focus(); } } self.emit_state(core); core.logger.info("Plugin window minimized to the Core taskbar.", json!({ "pluginId": id })); } pub fn minimize_all(&self, core: &Core) { let labels: Vec = self.windows.lock().values().map(|w| w.label.clone()).collect(); for label in labels { self.minimize_label(core, &label, false); } } pub fn close(&self, core: &Core, module_id: &str) { let label = self.windows.lock().get(module_id).map(|w| w.label.clone()); if let Some(window) = label.and_then(|l| core.app.get_webview_window(&l)) { let _ = window.close(); } } pub fn close_label(&self, core: &Core, label: &str) { if let Some(window) = core.app.get_webview_window(label) { let _ = window.close(); } } pub fn close_all(&self, core: &Core) { let labels: Vec = self.windows.lock().values().map(|w| w.label.clone()).collect(); // Vault lock/reset wipes keys before this call; those security closes cannot wait // for a renderer or leave sensitive windows visible. Main close/quit already confirmed. for label in labels { if let Some(window) = core.app.get_webview_window(&label) { let _ = window.destroy(); } } } /// Window destroyed: drop its context, release per-module resources, update the taskbar. pub fn on_destroyed(&self, core: &Core, label: &str) { let context = core.windows.unregister_module(label); self.windows.lock().retain(|_, w| w.label != label); if let Some(context) = context { core.services.on_module_closed(&context.id); } self.emit_state(core); } pub fn notify_language(&self, core: &Core, language: &str) { for context in core.windows.modules() { core.emit_window(&context.label, "module:ui:languageChanged", language.to_string()); } } pub fn emit_state(&self, core: &Core) { core.emit_main("core:pluginWindowState", self.list_windows()); } } #[cfg(test)] mod close_tests { use super::*; #[tokio::test] async fn cancel_stops_confirmation_and_discard_confirms_every_window() { let labels = vec!["files".into(), "nginx".into(), MAIN_LABEL.into()]; let visited = Mutex::new(Vec::new()); assert!(!confirm_windows(&labels, |label| { let accept = label != "nginx"; visited.lock().push(label); std::future::ready(accept) }).await); assert_eq!(*visited.lock(), vec!["files", "nginx"]); visited.lock().clear(); assert!(confirm_windows(&labels, |label| { visited.lock().push(label); std::future::ready(true) }).await); assert_eq!(*visited.lock(), labels); } #[tokio::test] async fn dirty_reply_cannot_answer_for_another_window_or_old_query() { let dir = tempfile::tempdir().unwrap(); let registry = Arc::new(ModuleRegistry::new(dir.path(), None, Arc::new(crate::core::logging::Logger::new(dir.path())))); let service = ModuleService::new(registry, ModuleInstaller::new(dir.path(), "0.1.1", "1.7.0")); let (tx, mut rx) = tokio::sync::oneshot::channel(); service.close_queries.lock().insert("module-1".into(), (42, tx)); service.close_query_reply("module-2", 42, false); service.close_query_reply("module-1", 41, false); assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); service.close_query_reply("module-1", 42, true); assert!(rx.await.unwrap()); assert!(service.close_queries.lock().is_empty()); } } #[cfg(test)] #[path = "../../../tests/support/module_runtime_safety.rs"] mod safety_tests;