Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
872 souborů
core-20261030T220507Z-61e4558d889f
Přidáno: · Sestaveno:
1
//! Module service and window runtime (port of `plugins/pluginService.js` and the window2
//! part of `plugins/pluginRuntime.js`): catalog state, install/uninstall, isolated module3
//! windows (one per module, child of the main window), collapse to the Core taskbar.5
use crate::core::app::{Core, MODULE_API_VERSION};6
use crate::core::error::{CoreError, CoreResult};7
use crate::core::modules::catalog::CatalogProvider;8
use crate::core::modules::installer::ModuleInstaller;9
use crate::core::modules::registry::ModuleRegistry;10
use crate::core::version::compare_versions;11
use crate::security::caller::{ModuleContext, MAIN_LABEL};12
use parking_lot::Mutex;13
use serde_json::{json, Value};14
use std::cmp::Ordering;15
use std::collections::HashMap;16
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};17
use std::sync::Arc;18
use tauri::Manager;20
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'";22
#[derive(Clone)]23
struct WindowState {24
label: String,25
name: String,26
version: String,27
minimized: bool,28
}30
pub struct ModuleService {31
pub registry: Arc<ModuleRegistry>,32
installer: ModuleInstaller,33
catalog: Mutex<Vec<Value>>,34
source_base_url: Mutex<Option<String>>,35
operation: Mutex<Option<&'static str>>,36
windows: Mutex<HashMap<String, WindowState>>,37
counter: AtomicU64,38
close_gate: Mutex<bool>,39
close_guard_installed: Mutex<bool>,40
close_queries: Mutex<HashMap<String, (u64, tokio::sync::oneshot::Sender<bool>)>>,41
exit_approved: std::sync::atomic::AtomicBool,42
}44
struct OperationGuard<'a>(&'a Mutex<Option<&'static str>>);45
impl Drop for OperationGuard<'_> {46
fn drop(&mut self) {47
*self.0.lock() = None;48
}49
}51
async fn confirm_windows<F, Fut>(labels: &[String], mut confirm: F) -> bool52
where F: FnMut(String) -> Fut, Fut: std::future::Future<Output = bool> {53
for label in labels {54
if !confirm(label.clone()).await { return false; }55
}56
true57
}59
impl ModuleService {60
pub fn new(registry: Arc<ModuleRegistry>, installer: ModuleInstaller) -> Self {61
Self {62
registry,63
installer,64
catalog: Mutex::new(Vec::new()),65
source_base_url: Mutex::new(None),66
operation: Mutex::new(None),67
windows: Mutex::new(HashMap::new()),68
counter: AtomicU64::new(0),69
close_gate: Mutex::new(false),70
close_guard_installed: Mutex::new(false),71
close_queries: Mutex::new(HashMap::new()),72
exit_approved: Default::default(),73
}74
}76
fn begin(&self, name: &'static str) -> CoreResult<OperationGuard<'_>> {77
let mut op = self.operation.lock();78
if op.is_some() {79
return Err(CoreError::plain("Another plugin operation is already running."));80
}81
*op = Some(name);82
Ok(OperationGuard(&self.operation))83
}85
/// Install once the main bridge is ready, before any module can hold a draft.86
pub fn install_close_guard(&self, core: &Core) -> CoreResult<()> {87
let mut installed = self.close_guard_installed.lock();88
if *installed { return Ok(()); }89
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("draft-close-guard")90
.on_event(|app, event| {91
let Some(core) = app.state::<crate::core::app::CoreSlot>().try_get() else { return };92
match event {93
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { api, .. }, .. }94
if label == MAIN_LABEL || core.windows.modules().iter().any(|context| context.label == *label) => {95
api.prevent_close();96
ModuleService::request_close(core, Some(label.clone()), None);97
}98
tauri::RunEvent::ExitRequested { api, code, .. } if !core.services.modules.exit_approved() => {99
api.prevent_exit();100
ModuleService::request_close(core, None, *code);101
}102
_ => {}103
}104
}).build();105
core.app.plugin(plugin).map_err(|error| CoreError::plain(format!("Close guard could not be installed: {error}")))?;106
*installed = true;107
Ok(())108
}110
pub fn exit_approved(&self) -> bool {111
self.exit_approved.load(AtomicOrdering::SeqCst)112
}114
/// Replies are scoped to the calling WebView and a fresh query ID.115
pub fn close_query_reply(&self, label: &str, id: u64, dirty: bool) {116
let mut queries = self.close_queries.lock();117
if queries.get(label).is_some_and(|(expected, _)| *expected == id) {118
if let Some((_, reply)) = queries.remove(label) { let _ = reply.send(dirty); }119
}120
}122
async fn confirm_close(&self, core: &Core, label: &str) -> bool {123
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};124
let Some(window) = core.app.get_webview_window(label) else { return true };125
let id = self.counter.fetch_add(1, AtomicOrdering::SeqCst) + 1;126
let (tx, rx) = tokio::sync::oneshot::channel();127
self.close_queries.lock().insert(label.to_string(), (id, tx));128
let script = format!("window.dispatchEvent(new CustomEvent('chj:query-dirty', {{ detail: {id} }}))");129
if window.eval(&script).is_err() {130
self.close_queries.lock().remove(label);131
return false;132
}133
let dirty = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await;134
self.close_queries.lock().remove(label);135
match dirty {136
Ok(Ok(false)) => true,137
Ok(Ok(true)) => {138
let (tx, rx) = tokio::sync::oneshot::channel();139
core.app.dialog().message(core.t("chrome.discardChanges")).title(window.title().unwrap_or_default())140
.parent(&window).kind(MessageDialogKind::Warning)141
.buttons(MessageDialogButtons::OkCancelCustom(core.t("chrome.discard"), core.t("chrome.cancel")))142
.show(move |discard| { let _ = tx.send(discard); });143
rx.await.unwrap_or(false)144
}145
_ => false,146
}147
}149
/// Called by native CloseRequested and ExitRequested before any window is destroyed.150
pub fn request_close(core: Arc<Core>, label: Option<String>, exit_code: Option<i32>) {151
{152
let mut active = core.services.modules.close_gate.lock();153
if *active { return; }154
*active = true;155
}156
tauri::async_runtime::spawn(async move {157
let modules = &core.services.modules;158
let all = label.as_deref().is_none_or(|l| l == MAIN_LABEL);159
let mut labels: Vec<String> = if all {160
core.windows.modules().iter().map(|ctx| ctx.label.clone()).collect()161
} else { vec![label.clone().unwrap()] };162
// Confirm every draft before destroying anything, so Cancel preserves the whole app.163
if all && core.app.get_webview_window(MAIN_LABEL).is_some() { labels.push(MAIN_LABEL.to_string()); }164
if !confirm_windows(&labels, |target| { let core = &core; async move { modules.confirm_close(core, &target).await } }).await {165
*modules.close_gate.lock() = false;166
return;167
}168
if label.is_none() || (all && !cfg!(target_os = "macos")) {169
modules.exit_approved.store(true, AtomicOrdering::SeqCst);170
}171
for target in labels {172
if let Some(window) = core.app.get_webview_window(&target) { let _ = window.destroy(); }173
}174
*modules.close_gate.lock() = false;175
if label.is_none() { core.app.exit(exit_code.unwrap_or(0)); }176
});177
}179
pub fn provider(&self, core: &Core) -> CoreResult<CatalogProvider> {180
let config = core.config.get();181
let base_urls: Vec<String> = config["updates"]["baseUrls"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();182
CatalogProvider::new(&base_urls, core.services.https.clone(), core.paths.in_data("plugins/.downloads"), core.logger.clone())183
}185
pub fn list_windows(&self) -> Vec<Value> {186
let mut list: Vec<Value> = self187
.windows188
.lock()189
.iter()190
.map(|(id, w)| json!({ "pluginId": id, "moduleId": id, "name": w.name, "version": w.version, "minimized": w.minimized }))191
.collect();192
list.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));193
list194
}196
pub fn get_state(&self) -> CoreResult<Value> {197
let installed = self.registry.list_installed()?;198
let catalog: Vec<Value> = self199
.catalog200
.lock()201
.iter()202
.map(|module| {203
let current = installed.iter().find(|m| m["id"] == module["id"]);204
let comparison = current205
.map(|c| compare_versions(module["version"].as_str().unwrap_or("0.0"), c["version"].as_str().unwrap_or("0.0")).unwrap_or(Ordering::Equal))206
.unwrap_or(Ordering::Greater);207
let same_artifact = current.and_then(|c| c["installedSha512"].as_str()).map(|s| Some(s) == module["sha512"].as_str()).unwrap_or(false);208
let mut entry = module.clone();209
entry["installedVersion"] = current.map(|c| c["version"].clone()).unwrap_or(Value::Null);210
entry["installedReleaseId"] = current.map(|c| c["installedReleaseId"].clone()).unwrap_or(Value::Null);211
entry["updateAvailable"] = Value::Bool(current.is_some() && (comparison == Ordering::Greater || (comparison == Ordering::Equal && !same_artifact)));212
entry["installed"] = Value::Bool(current.is_some() && (comparison == Ordering::Less || (comparison == Ordering::Equal && same_artifact)));213
entry214
})215
.collect();216
Ok(json!({217
"status": self.operation.lock().unwrap_or("idle"),218
"installed": installed,219
"windows": self.list_windows(),220
"catalog": catalog,221
"sourceBaseUrl": *self.source_base_url.lock()222
}))223
}225
pub async fn check_catalog(&self, core: &Core) -> CoreResult<Value> {226
let _guard = self.begin("checking")?;227
let config = core.config.get();228
let channel = config["plugins"]["channel"].as_str().unwrap_or("alpha").to_string();229
let (list, base) = self.provider(core)?.list(&core.build.version, MODULE_API_VERSION, &channel).await?;230
core.logger.info("Plugin catalog checked.", json!({ "count": list.len(), "channel": channel, "sourceBaseUrl": base }));231
*self.catalog.lock() = list;232
*self.source_base_url.lock() = Some(base);233
drop(_guard);234
self.get_state()235
}237
pub async fn install(&self, core: &Core, release_id: &str) -> CoreResult<Value> {238
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."))?;239
let guard = self.begin("installing")?;240
let provider = self.provider(core)?;241
let package = provider.download_and_verify(&module).await?;242
let result = self.installer.install(&package, &module)?;243
match provider.remove_downloaded(&package) {244
Ok(removed) => core.logger.info("Installed plugin package removed from download cache.", json!({ "pluginId": module["id"], "releaseId": module["releaseId"], "removed": removed })),245
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 })),246
}247
core.logger.info("Plugin installed.", json!({ "pluginId": result.manifest["id"], "version": result.manifest["version"], "permissions": result.manifest["permissions"] }));248
drop(guard);249
let mut state = self.get_state()?;250
state["installedPlugin"] = result.manifest;251
state["reused"] = Value::Bool(result.reused);252
Ok(state)253
}255
pub async fn uninstall(&self, core: &Core, module_id: &str) -> CoreResult<Value> {256
let guard = self.begin("uninstalling")?;257
let label = self.windows.lock().get(module_id).map(|window| window.label.clone());258
if let Some(label) = label {259
if !self.confirm_close(core, &label).await {260
drop(guard);261
let mut state = self.get_state()?;262
state["canceled"] = json!(true);263
return Ok(state);264
}265
if let Some(window) = core.app.get_webview_window(&label) {266
window.destroy().map_err(|error| CoreError::plain(error.to_string()))?;267
}268
}269
let removed = self.registry.remove_installed(module_id)?;270
drop(guard);271
let mut state = self.get_state()?;272
state["removedPluginId"] = if removed { Value::String(module_id.to_string()) } else { Value::Null };273
Ok(state)274
}276
pub fn cleanup_downloads(&self, core: &Core) -> CoreResult<bool> {277
self.provider(core)?.cleanup_downloads()278
}280
pub fn open(&self, core: &Core, module_id: &str) -> CoreResult<Value> {281
self.install_close_guard(core)?;282
if !core.vault.is_unlocked() {283
return Err(CoreError::plain("Unlock the vault before opening a plugin."));284
}285
let resolved = self.registry.resolve(module_id)?;286
let id = resolved.manifest["id"].as_str().unwrap_or("").to_string();287
let existing = self.windows.lock().get(&id).cloned();288
if let Some(state) = existing {289
if let Some(window) = core.app.get_webview_window(&state.label) {290
let _ = window.unminimize();291
let _ = window.show();292
let _ = window.set_focus();293
if let Some(entry) = self.windows.lock().get_mut(&id) {294
entry.minimized = false;295
}296
self.emit_state(core);297
return Ok(resolved.manifest);298
}299
self.windows.lock().remove(&id);300
}301
let label = format!("module-{}", self.counter.fetch_add(1, AtomicOrdering::SeqCst) + 1);302
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();303
core.windows.register_module(ModuleContext { id: id.clone(), label: label.clone(), manifest: resolved.manifest.clone(), permissions, root: resolved.root.clone() });304
let name = resolved.manifest["name"].as_str().unwrap_or(&id).to_string();305
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 });306
let created = crate::windows::create_module_window(307
&core.app,308
&core.i18n,309
crate::windows::ModuleWindowSpec {310
label: &label,311
module_id: &id,312
name: &name,313
entry: resolved.manifest["entry"].as_str().unwrap_or("ui/index.html"),314
root: &resolved.root,315
data_directory: core.paths.module_webview_data(&id),316
},317
);318
if let Err(error) = created {319
core.windows.unregister_module(&label);320
self.windows.lock().remove(&id);321
core.logger.error("Plugin window failed to load.", json!({ "pluginId": id, "message": error.to_string() }));322
return Err(CoreError::plain(format!("The module window could not be opened: {error}")));323
}324
core.logger.info("Plugin window opened.", json!({ "pluginId": id, "version": resolved.manifest["version"] }));325
self.emit_state(core);326
Ok(resolved.manifest)327
}329
/// The module page finished loading: show it unless it was collapsed meanwhile.330
pub fn on_loaded(&self, core: &Core, label: &str) {331
let minimized = self.windows.lock().values().find(|w| w.label == label).map(|w| w.minimized).unwrap_or(true);332
if let Some(window) = core.app.get_webview_window(label) {333
if !minimized {334
let _ = window.show();335
let _ = window.set_focus();336
}337
}338
self.emit_state(core);339
}341
pub fn minimize_label(&self, core: &Core, label: &str, focus_main: bool) {342
let id = self.windows.lock().iter().find(|(_, w)| w.label == label).map(|(id, _)| id.clone());343
let Some(id) = id else { return };344
if let Some(window) = core.app.get_webview_window(label) {345
let _ = window.hide();346
}347
if let Some(entry) = self.windows.lock().get_mut(&id) {348
entry.minimized = true;349
}350
if focus_main {351
if let Some(main) = core.app.get_webview_window(MAIN_LABEL) {352
let _ = main.unminimize();353
let _ = main.show();354
let _ = main.set_focus();355
}356
}357
self.emit_state(core);358
core.logger.info("Plugin window minimized to the Core taskbar.", json!({ "pluginId": id }));359
}361
pub fn minimize_all(&self, core: &Core) {362
let labels: Vec<String> = self.windows.lock().values().map(|w| w.label.clone()).collect();363
for label in labels {364
self.minimize_label(core, &label, false);365
}366
}368
pub fn close(&self, core: &Core, module_id: &str) {369
let label = self.windows.lock().get(module_id).map(|w| w.label.clone());370
if let Some(window) = label.and_then(|l| core.app.get_webview_window(&l)) {371
let _ = window.close();372
}373
}375
pub fn close_label(&self, core: &Core, label: &str) {376
if let Some(window) = core.app.get_webview_window(label) {377
let _ = window.close();378
}379
}381
pub fn close_all(&self, core: &Core) {382
let labels: Vec<String> = self.windows.lock().values().map(|w| w.label.clone()).collect();383
// Vault lock/reset wipes keys before this call; those security closes cannot wait384
// for a renderer or leave sensitive windows visible. Main close/quit already confirmed.385
for label in labels {386
if let Some(window) = core.app.get_webview_window(&label) { let _ = window.destroy(); }387
}388
}390
/// Window destroyed: drop its context, release per-module resources, update the taskbar.391
pub fn on_destroyed(&self, core: &Core, label: &str) {392
let context = core.windows.unregister_module(label);393
self.windows.lock().retain(|_, w| w.label != label);394
if let Some(context) = context {395
core.services.on_module_closed(&context.id);396
}397
self.emit_state(core);398
}400
pub fn notify_language(&self, core: &Core, language: &str) {401
for context in core.windows.modules() {402
core.emit_window(&context.label, "module:ui:languageChanged", language.to_string());403
}404
}406
pub fn emit_state(&self, core: &Core) {407
core.emit_main("core:pluginWindowState", self.list_windows());408
}409
}411
#[cfg(test)]412
mod close_tests {413
use super::*;415
#[tokio::test]416
async fn cancel_stops_confirmation_and_discard_confirms_every_window() {417
let labels = vec!["files".into(), "nginx".into(), MAIN_LABEL.into()];418
let visited = Mutex::new(Vec::new());419
assert!(!confirm_windows(&labels, |label| {420
let accept = label != "nginx";421
visited.lock().push(label);422
std::future::ready(accept)423
}).await);424
assert_eq!(*visited.lock(), vec!["files", "nginx"]);425
visited.lock().clear();426
assert!(confirm_windows(&labels, |label| {427
visited.lock().push(label);428
std::future::ready(true)429
}).await);430
assert_eq!(*visited.lock(), labels);431
}433
#[tokio::test]434
async fn dirty_reply_cannot_answer_for_another_window_or_old_query() {435
let dir = tempfile::tempdir().unwrap();436
let registry = Arc::new(ModuleRegistry::new(dir.path(), None, Arc::new(crate::core::logging::Logger::new(dir.path()))));437
let service = ModuleService::new(registry, ModuleInstaller::new(dir.path(), "0.1.1", "1.7.0"));438
let (tx, mut rx) = tokio::sync::oneshot::channel();439
service.close_queries.lock().insert("module-1".into(), (42, tx));440
service.close_query_reply("module-2", 42, false);441
service.close_query_reply("module-1", 41, false);442
assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty)));443
service.close_query_reply("module-1", 42, true);444
assert!(rx.await.unwrap());445
assert!(service.close_queries.lock().is_empty());446
}447
}449
#[cfg(test)]450
#[path = "../../../tests/support/module_runtime_safety.rs"]451
mod safety_tests;SHA-256
55c89324e364087d69a964781ef2b884963d361e2b5060a78d32d6223d5c3490CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df