المصدر المنشور
تنزيل الشيفرة المصدرية بصيغة ZIP
CH-J Tools
تصفّح الأدلة والملفات الخاصة بإصدار محدد من التطبيق.
CH-J / Server Manager
1704 ملفات
0.1.2-beta
تاريخ الإضافة: · تاريخ البناء:
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
/// Windows that hold a detached module. The WebView inside keeps its `module-N` label.23
pub const DETACHED_LABEL_PREFIX: &str = "detached-";25
#[derive(Clone)]26
struct WindowState {27
label: String,28
name: String,29
version: String,30
minimized: bool,31
/// Label of the separate window; `None` while the module is embedded in the main window.32
detached: Option<String>,33
}35
/// Module area of the main page in logical pixels.36
#[derive(Clone, Copy)]37
pub struct Area {38
pub x: f64,39
pub y: f64,40
pub width: f64,41
pub height: f64,42
}44
pub struct ModuleService {45
pub registry: Arc<ModuleRegistry>,46
installer: ModuleInstaller,47
catalog: Mutex<Vec<Value>>,48
source_base_url: Mutex<Option<String>>,49
operation: Mutex<Option<&'static str>>,50
windows: Mutex<HashMap<String, WindowState>>,51
counter: AtomicU64,52
close_gate: Mutex<bool>,53
close_guard_installed: Mutex<bool>,54
close_queries: Mutex<HashMap<String, (u64, tokio::sync::oneshot::Sender<bool>)>>,55
exit_approved: std::sync::atomic::AtomicBool,56
/// The embedded module shown in the module area and that area; no area means another view is open.57
active: Mutex<Option<String>>,58
area: Mutex<Option<Area>>,59
}61
struct OperationGuard<'a>(&'a Mutex<Option<&'static str>>);62
impl Drop for OperationGuard<'_> {63
fn drop(&mut self) {64
*self.0.lock() = None;65
}66
}68
async fn confirm_windows<F, Fut>(labels: &[String], mut confirm: F) -> bool69
where F: FnMut(String) -> Fut, Fut: std::future::Future<Output = bool> {70
for label in labels {71
if !confirm(label.clone()).await { return false; }72
}73
true74
}76
impl ModuleService {77
pub fn new(registry: Arc<ModuleRegistry>, installer: ModuleInstaller) -> Self {78
Self {79
registry,80
installer,81
catalog: Mutex::new(Vec::new()),82
source_base_url: Mutex::new(None),83
operation: Mutex::new(None),84
windows: Mutex::new(HashMap::new()),85
counter: AtomicU64::new(0),86
close_gate: Mutex::new(false),87
close_guard_installed: Mutex::new(false),88
close_queries: Mutex::new(HashMap::new()),89
exit_approved: Default::default(),90
active: Mutex::new(None),91
area: Mutex::new(None),92
}93
}95
fn begin(&self, name: &'static str) -> CoreResult<OperationGuard<'_>> {96
let mut op = self.operation.lock();97
if op.is_some() {98
return Err(CoreError::plain("Another plugin operation is already running."));99
}100
*op = Some(name);101
Ok(OperationGuard(&self.operation))102
}104
/// Install once the main bridge is ready, before any module can hold a draft.105
pub fn install_close_guard(&self, core: &Core) -> CoreResult<()> {106
let mut installed = self.close_guard_installed.lock();107
if *installed { return Ok(()); }108
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("draft-close-guard")109
.on_event(|app, event| {110
let Some(core) = app.state::<crate::core::app::CoreSlot>().try_get() else { return };111
match event {112
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { api, .. }, .. }113
if label == MAIN_LABEL || core.services.modules.module_of_window(label).is_some() => {114
api.prevent_close();115
let target = if label == MAIN_LABEL { label.clone() } else { core.services.modules.module_of_window(label).unwrap_or_else(|| label.clone()) };116
ModuleService::request_close(core, Some(target), None);117
}118
tauri::RunEvent::ExitRequested { api, code, .. } if !core.services.modules.exit_approved() => {119
api.prevent_exit();120
ModuleService::request_close(core, None, *code);121
}122
_ => {}123
}124
}).build();125
core.app.plugin(plugin).map_err(|error| CoreError::plain(format!("Close guard could not be installed: {error}")))?;126
*installed = true;127
Ok(())128
}130
pub fn exit_approved(&self) -> bool {131
self.exit_approved.load(AtomicOrdering::SeqCst)132
}134
/// Replies are scoped to the calling WebView and a fresh query ID.135
pub fn close_query_reply(&self, label: &str, id: u64, dirty: bool) {136
let mut queries = self.close_queries.lock();137
if queries.get(label).is_some_and(|(expected, _)| *expected == id) {138
if let Some((_, reply)) = queries.remove(label) { let _ = reply.send(dirty); }139
}140
}142
async fn confirm_close(&self, core: &Core, label: &str) -> bool {143
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};144
let Some(webview) = core.app.get_webview(label) else { return true };145
let window = webview.window();146
let id = self.counter.fetch_add(1, AtomicOrdering::SeqCst) + 1;147
let (tx, rx) = tokio::sync::oneshot::channel();148
self.close_queries.lock().insert(label.to_string(), (id, tx));149
let script = format!("window.dispatchEvent(new CustomEvent('chj:query-dirty', {{ detail: {id} }}))");150
if webview.eval(&script).is_err() {151
self.close_queries.lock().remove(label);152
return false;153
}154
let dirty = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await;155
self.close_queries.lock().remove(label);156
match dirty {157
Ok(Ok(false)) => true,158
Ok(Ok(true)) => {159
let (tx, rx) = tokio::sync::oneshot::channel();160
core.app.dialog().message(core.t("chrome.discardChanges")).title(window.title().unwrap_or_default())161
.parent(&window).kind(MessageDialogKind::Warning)162
.buttons(MessageDialogButtons::OkCancelCustom(core.t("chrome.discard"), core.t("chrome.cancel")))163
.show(move |discard| { let _ = tx.send(discard); });164
rx.await.unwrap_or(false)165
}166
_ => false,167
}168
}170
/// Called by native CloseRequested and ExitRequested before any window is destroyed.171
pub fn request_close(core: Arc<Core>, label: Option<String>, exit_code: Option<i32>) {172
{173
let mut active = core.services.modules.close_gate.lock();174
if *active { return; }175
*active = true;176
}177
tauri::async_runtime::spawn(async move {178
let modules = &core.services.modules;179
let all = label.as_deref().is_none_or(|l| l == MAIN_LABEL);180
let mut labels: Vec<String> = if all {181
core.windows.modules().iter().map(|ctx| ctx.label.clone()).collect()182
} else { vec![label.clone().unwrap()] };183
// Confirm every draft before destroying anything, so Cancel preserves the whole app.184
if all && core.app.get_window(MAIN_LABEL).is_some() { labels.push(MAIN_LABEL.to_string()); }185
if !confirm_windows(&labels, |target| { let core = &core; async move { modules.confirm_close(core, &target).await } }).await {186
*modules.close_gate.lock() = false;187
return;188
}189
if label.is_none() || (all && !cfg!(target_os = "macos")) {190
modules.exit_approved.store(true, AtomicOrdering::SeqCst);191
}192
for target in labels {193
if target == MAIN_LABEL {194
if let Some(window) = core.app.get_window(&target) { let _ = window.destroy(); }195
} else {196
modules.destroy_module(&core, &target);197
}198
}199
*modules.close_gate.lock() = false;200
if label.is_none() { core.app.exit(exit_code.unwrap_or(0)); }201
});202
}204
pub fn provider(&self, core: &Core) -> CoreResult<CatalogProvider> {205
let config = core.config.get();206
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();207
CatalogProvider::new(&base_urls, core.services.https.clone(), core.paths.in_data("plugins/.downloads"), core.logger.clone())208
}210
pub fn list_windows(&self) -> Vec<Value> {211
let active = self.active.lock().clone();212
let mut list: Vec<Value> = self213
.windows214
.lock()215
.iter()216
.map(|(id, w)| json!({ "pluginId": id, "moduleId": id, "name": w.name, "version": w.version, "minimized": w.minimized, "detached": w.detached.is_some(), "active": active.as_deref() == Some(id.as_str()) }))217
.collect();218
list.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));219
list220
}222
pub fn get_state(&self) -> CoreResult<Value> {223
let installed = self.registry.list_installed()?;224
let catalog: Vec<Value> = self225
.catalog226
.lock()227
.iter()228
.map(|module| {229
let current = installed.iter().find(|m| m["id"] == module["id"]);230
let comparison = current231
.map(|c| compare_versions(module["version"].as_str().unwrap_or("0.0"), c["version"].as_str().unwrap_or("0.0")).unwrap_or(Ordering::Equal))232
.unwrap_or(Ordering::Greater);233
let same_artifact = current.and_then(|c| c["installedSha512"].as_str()).map(|s| Some(s) == module["sha512"].as_str()).unwrap_or(false);234
let mut entry = module.clone();235
entry["installedVersion"] = current.map(|c| c["version"].clone()).unwrap_or(Value::Null);236
entry["installedReleaseId"] = current.map(|c| c["installedReleaseId"].clone()).unwrap_or(Value::Null);237
entry["updateAvailable"] = Value::Bool(current.is_some() && (comparison == Ordering::Greater || (comparison == Ordering::Equal && !same_artifact)));238
entry["installed"] = Value::Bool(current.is_some() && (comparison == Ordering::Less || (comparison == Ordering::Equal && same_artifact)));239
entry240
})241
.collect();242
Ok(json!({243
"status": self.operation.lock().unwrap_or("idle"),244
"installed": installed,245
"windows": self.list_windows(),246
"catalog": catalog,247
"sourceBaseUrl": *self.source_base_url.lock()248
}))249
}251
pub async fn check_catalog(&self, core: &Core) -> CoreResult<Value> {252
let _guard = self.begin("checking")?;253
let config = core.config.get();254
let channel = config["plugins"]["channel"].as_str().unwrap_or("alpha").to_string();255
let (list, base) = self.provider(core)?.list(&core.build.version, MODULE_API_VERSION, &channel).await?;256
core.logger.info("Plugin catalog checked.", json!({ "count": list.len(), "channel": channel, "sourceBaseUrl": base }));257
*self.catalog.lock() = list;258
*self.source_base_url.lock() = Some(base);259
drop(_guard);260
self.get_state()261
}263
pub async fn install(&self, core: &Core, release_id: &str) -> CoreResult<Value> {264
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."))?;265
let guard = self.begin("installing")?;266
let provider = self.provider(core)?;267
let package = provider.download_and_verify(&module).await?;268
let result = self.installer.install(&package, &module)?;269
match provider.remove_downloaded(&package) {270
Ok(removed) => core.logger.info("Installed plugin package removed from download cache.", json!({ "pluginId": module["id"], "releaseId": module["releaseId"], "removed": removed })),271
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 })),272
}273
core.logger.info("Plugin installed.", json!({ "pluginId": result.manifest["id"], "version": result.manifest["version"], "permissions": result.manifest["permissions"] }));274
drop(guard);275
let mut state = self.get_state()?;276
state["installedPlugin"] = result.manifest;277
state["reused"] = Value::Bool(result.reused);278
Ok(state)279
}281
pub async fn uninstall(&self, core: &Core, module_id: &str) -> CoreResult<Value> {282
let guard = self.begin("uninstalling")?;283
let label = self.windows.lock().get(module_id).map(|window| window.label.clone());284
if let Some(label) = label {285
if !self.confirm_close(core, &label).await {286
drop(guard);287
let mut state = self.get_state()?;288
state["canceled"] = json!(true);289
return Ok(state);290
}291
self.destroy_module(core, &label);292
}293
let removed = self.registry.remove_installed(module_id)?;294
drop(guard);295
let mut state = self.get_state()?;296
state["removedPluginId"] = if removed { Value::String(module_id.to_string()) } else { Value::Null };297
Ok(state)298
}300
pub fn cleanup_downloads(&self, core: &Core) -> CoreResult<bool> {301
self.provider(core)?.cleanup_downloads()302
}304
pub fn open(&self, core: &Core, module_id: &str) -> CoreResult<Value> {305
self.install_close_guard(core)?;306
let resolved = self.registry.resolve(module_id)?;307
let id = resolved.manifest["id"].as_str().unwrap_or("").to_string();308
let existing = self.windows.lock().get(&id).cloned();309
if let Some(state) = existing {310
if core.app.get_webview(&state.label).is_some() {311
if let Some(entry) = self.windows.lock().get_mut(&id) {312
entry.minimized = false;313
}314
match state.detached.as_ref().and_then(|window| core.app.get_window(window)) {315
Some(window) => {316
let _ = window.unminimize();317
let _ = window.show();318
let _ = window.set_focus();319
}320
None => self.activate(core, &id),321
}322
self.emit_state(core);323
return Ok(resolved.manifest);324
}325
self.windows.lock().remove(&id);326
}327
let label = format!("module-{}", self.counter.fetch_add(1, AtomicOrdering::SeqCst) + 1);328
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();329
core.windows.register_module(ModuleContext { id: id.clone(), label: label.clone(), manifest: resolved.manifest.clone(), permissions, root: resolved.root.clone() });330
let name = resolved.manifest["name"].as_str().unwrap_or(&id).to_string();331
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, detached: None });332
let created = crate::windows::create_module_webview(333
&core.app,334
&core.i18n,335
crate::windows::ModuleWindowSpec {336
label: &label,337
module_id: &id,338
entry: resolved.manifest["entry"].as_str().unwrap_or("ui/index.html"),339
root: &resolved.root,340
},341
);342
if let Err(error) = created {343
core.windows.unregister_module(&label);344
self.windows.lock().remove(&id);345
core.logger.error("Plugin window failed to load.", json!({ "pluginId": id, "message": error.to_string() }));346
return Err(CoreError::plain(format!("The module window could not be opened: {error}")));347
}348
core.logger.info("Plugin window opened.", json!({ "pluginId": id, "version": resolved.manifest["version"] }));349
self.activate(core, &id);350
self.emit_state(core);351
Ok(resolved.manifest)352
}354
/// The module page finished loading: show it unless it was collapsed meanwhile.355
pub fn on_loaded(&self, core: &Core, _label: &str) {356
self.apply_layout(core);357
self.emit_state(core);358
}360
/// Shows an embedded module in the module area of the main window.361
fn activate(&self, core: &Core, id: &str) {362
*self.active.lock() = Some(id.to_string());363
core.emit_main("core:moduleActivate", id.to_string());364
self.apply_layout(core);365
}367
/// The main page reports its module area; `None` means another view covers the window.368
pub fn set_area(&self, core: &Core, area: Option<Area>) {369
*self.area.lock() = area;370
self.apply_layout(core);371
}373
/// Only the active embedded module is visible, and only while the module area is on screen.374
fn apply_layout(&self, core: &Core) {375
let area = *self.area.lock();376
let active = self.active.lock().clone();377
let states: Vec<(String, WindowState)> = self.windows.lock().iter().map(|(id, w)| (id.clone(), w.clone())).collect();378
for (id, state) in states {379
if state.detached.is_some() {380
continue;381
}382
let Some(webview) = core.app.get_webview(&state.label) else { continue };383
match area.filter(|_| active.as_deref() == Some(id.as_str()) && !state.minimized) {384
Some(area) => {385
crate::windows::place_embedded(&webview, area.x, area.y, area.width, area.height);386
let _ = webview.show();387
let _ = webview.set_focus();388
}389
None => {390
let _ = webview.hide();391
}392
}393
}394
}396
/// The module whose separate window has this label, or the module WebView itself.397
pub fn module_of_window(&self, window_label: &str) -> Option<String> {398
self.windows.lock().values().find(|w| w.detached.as_deref() == Some(window_label) || w.label == window_label).map(|w| w.label.clone())399
}401
pub fn label_of(&self, module_id: &str) -> CoreResult<String> {402
self.windows.lock().get(module_id).map(|w| w.label.clone()).ok_or_else(|| CoreError::plain("The module is not open."))403
}405
/// Whether a module runs in its own window; the module page uses this for its window controls.406
pub fn host_state(&self, label: &str) -> Value {407
let detached = self.windows.lock().values().any(|w| w.label == label && w.detached.is_some());408
json!({ "embedded": !detached })409
}411
/// Moves a running module to a separate window. The WebView keeps its page, state and identity.412
pub fn detach(&self, core: &Core, label: &str) -> CoreResult<Value> {413
let failed = |error: tauri::Error| CoreError::plain(format!("The module window could not be opened: {error}"));414
let (id, state) = self.windows.lock().iter().find(|(_, w)| w.label == label).map(|(id, w)| (id.clone(), w.clone())).ok_or_else(|| CoreError::plain("The module is not open."))?;415
if state.detached.is_some() {416
return Ok(self.host_state(label));417
}418
let webview = core.app.get_webview(label).ok_or_else(|| CoreError::plain("The module is not open."))?;419
let window_label = format!("{DETACHED_LABEL_PREFIX}{}", self.counter.fetch_add(1, AtomicOrdering::SeqCst) + 1);420
let window = crate::windows::create_detached_window(&core.app, &window_label, &state.name).map_err(failed)?;421
crate::windows::release_from_main(&webview);422
if let Err(error) = webview.reparent(&window) {423
crate::windows::embed_in_main(&webview);424
let _ = window.destroy();425
self.apply_layout(core);426
return Err(failed(error));427
}428
if let Some(entry) = self.windows.lock().get_mut(&id) {429
entry.detached = Some(window_label);430
entry.minimized = false;431
}432
{433
let mut active = self.active.lock();434
if active.as_deref() == Some(id.as_str()) {435
*active = None;436
}437
}438
let _ = webview.show();439
let _ = window.show();440
let _ = window.set_focus();441
self.fit_detached(core, label);442
core.emit_window(label, "module:ui:hostChanged", self.host_state(label));443
self.emit_state(core);444
Ok(self.host_state(label))445
}447
/// A detached module fills its window. Tauri's automatic resize keeps the old position after a reparent, so the448
/// bounds are set from the window size; on Linux the window's GtkBox already does this.449
pub fn fit_detached(&self, core: &Core, label: &str) {450
#[cfg(not(target_os = "linux"))]451
{452
let Some(webview) = core.app.get_webview(label) else { return };453
// On macOS the inner size follows the first WebView, here the module's old size; the content view fills the frame.454
#[cfg(target_os = "macos")]455
let size = webview.window().outer_size();456
#[cfg(not(target_os = "macos"))]457
let size = webview.window().inner_size();458
let Ok(size) = size else { return };459
let _ = webview.set_bounds(tauri::Rect { position: tauri::PhysicalPosition::new(0, 0).into(), size: size.into() });460
}461
#[cfg(target_os = "linux")]462
let _ = (core, label);463
}465
/// Moves a detached module back into the main window and shows it there.466
pub fn attach(&self, core: &Core, label: &str) -> CoreResult<Value> {467
let failed = |error: tauri::Error| CoreError::plain(format!("The module could not be returned: {error}"));468
let (id, state) = self.windows.lock().iter().find(|(_, w)| w.label == label).map(|(id, w)| (id.clone(), w.clone())).ok_or_else(|| CoreError::plain("The module is not open."))?;469
let Some(window_label) = state.detached else { return Ok(self.host_state(label)) };470
let webview = core.app.get_webview(label).ok_or_else(|| CoreError::plain("The module is not open."))?;471
let main = core.app.get_window(MAIN_LABEL).ok_or_else(|| CoreError::plain("The main window is not available."))?;472
let _ = webview.hide();473
if let Err(error) = webview.reparent(&main) {474
let _ = webview.show();475
return Err(failed(error));476
}477
// Cleared before the separate window closes, which must not take the module down with it.478
if let Some(entry) = self.windows.lock().get_mut(&id) {479
entry.detached = None;480
entry.minimized = false;481
}482
crate::windows::embed_in_main(&webview);483
if let Some(window) = core.app.get_window(&window_label) {484
let _ = window.destroy();485
}486
let _ = main.unminimize();487
let _ = main.show();488
let _ = main.set_focus();489
core.emit_window(label, "module:ui:hostChanged", self.host_state(label));490
self.activate(core, &id);491
self.emit_state(core);492
Ok(self.host_state(label))493
}495
/// Ends a module without asking; callers confirmed drafts or are locking the vault.496
fn destroy_module(&self, core: &Core, label: &str) {497
let window = self.windows.lock().values().find(|w| w.label == label).and_then(|w| w.detached.clone());498
if let Some(webview) = core.app.get_webview(label) {499
let _ = webview.close();500
}501
if let Some(window) = window.and_then(|window| core.app.get_window(&window)) {502
let _ = window.destroy();503
}504
self.on_destroyed(core, label);505
}507
pub fn minimize_label(&self, core: &Core, label: &str, focus_main: bool) {508
let id = self.windows.lock().iter().find(|(_, w)| w.label == label).map(|(id, _)| id.clone());509
let Some(id) = id else { return };510
let detached = self.windows.lock().get(&id).and_then(|w| w.detached.clone());511
if let Some(window) = detached.and_then(|window| core.app.get_window(&window)) {512
let _ = window.hide();513
}514
if let Some(entry) = self.windows.lock().get_mut(&id) {515
entry.minimized = true;516
}517
{518
let mut active = self.active.lock();519
if active.as_deref() == Some(id.as_str()) {520
*active = None;521
}522
}523
self.apply_layout(core);524
if focus_main {525
if let Some(main) = core.app.get_window(MAIN_LABEL) {526
let _ = main.unminimize();527
let _ = main.show();528
let _ = main.set_focus();529
}530
}531
self.emit_state(core);532
core.logger.info("Plugin window minimized to the Core taskbar.", json!({ "pluginId": id }));533
}535
pub fn minimize_all(&self, core: &Core) {536
// Embedded modules minimize together with the main window.537
let labels: Vec<String> = self.windows.lock().values().filter(|w| w.detached.is_some()).map(|w| w.label.clone()).collect();538
for label in labels {539
self.minimize_label(core, &label, false);540
}541
}543
pub fn close(&self, core: &Arc<Core>, module_id: &str) {544
let label = self.windows.lock().get(module_id).map(|w| w.label.clone());545
if let Some(label) = label {546
ModuleService::request_close(core.clone(), Some(label), None);547
}548
}550
pub fn close_label(&self, core: &Arc<Core>, label: &str) {551
if core.app.get_webview(label).is_some() {552
ModuleService::request_close(core.clone(), Some(label.to_string()), None);553
}554
}556
pub fn close_all(&self, core: &Core) {557
let labels: Vec<String> = self.windows.lock().values().map(|w| w.label.clone()).collect();558
// Vault lock/reset wipes keys before this call; those security closes cannot wait559
// for a renderer or leave sensitive windows visible. Main close/quit already confirmed.560
for label in labels {561
self.destroy_module(core, &label);562
}563
}565
/// Window destroyed: drop its context, release per-module resources, update the taskbar.566
pub fn on_destroyed(&self, core: &Core, label: &str) {567
let context = core.windows.unregister_module(label);568
{569
let mut active = self.active.lock();570
let closing = self.windows.lock().iter().find(|(_, w)| w.label == label).map(|(id, _)| id.clone());571
if closing.is_some() && *active == closing {572
*active = None;573
}574
}575
self.windows.lock().retain(|_, w| w.label != label);576
if let Some(context) = context {577
core.services.on_module_closed(&context.id);578
}579
self.emit_state(core);580
}582
pub fn notify_language(&self, core: &Core, language: &str) {583
for context in core.windows.modules() {584
core.emit_window(&context.label, "module:ui:languageChanged", language.to_string());585
}586
}588
pub fn emit_state(&self, core: &Core) {589
core.emit_main("core:pluginWindowState", self.list_windows());590
}591
}593
#[cfg(test)]594
mod close_tests {595
use super::*;597
#[tokio::test]598
async fn cancel_stops_confirmation_and_discard_confirms_every_window() {599
let labels = vec!["files".into(), "nginx".into(), MAIN_LABEL.into()];600
let visited = Mutex::new(Vec::new());601
assert!(!confirm_windows(&labels, |label| {602
let accept = label != "nginx";603
visited.lock().push(label);604
std::future::ready(accept)605
}).await);606
assert_eq!(*visited.lock(), vec!["files", "nginx"]);607
visited.lock().clear();608
assert!(confirm_windows(&labels, |label| {609
visited.lock().push(label);610
std::future::ready(true)611
}).await);612
assert_eq!(*visited.lock(), labels);613
}615
#[tokio::test]616
async fn dirty_reply_cannot_answer_for_another_window_or_old_query() {617
let dir = tempfile::tempdir().unwrap();618
let registry = Arc::new(ModuleRegistry::new(dir.path(), None, Arc::new(crate::core::logging::Logger::new(dir.path()))));619
let service = ModuleService::new(registry, ModuleInstaller::new(dir.path(), "0.1.1", "1.7.0"));620
let (tx, mut rx) = tokio::sync::oneshot::channel();621
service.close_queries.lock().insert("module-1".into(), (42, tx));622
service.close_query_reply("module-2", 42, false);623
service.close_query_reply("module-1", 41, false);624
assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty)));625
service.close_query_reply("module-1", 42, true);626
assert!(rx.await.unwrap());627
assert!(service.close_queries.lock().is_empty());628
}629
}631
#[cfg(test)]632
#[path = "../../../tests/support/module_runtime_safety.rs"]633
mod safety_tests;SHA-256
f7323342e2d835561106b3a537d7106a387d64143d47805c9612f06d8f17b47fCH-J Proprietary Software License 1.14 · تُتاح الشيفرة المصدرية بموجب CH-J Proprietary Software License 1.14. وإتاحتها لا تغيّر شروط الترخيص ولا تمنح حقوقًا إضافية.
SHA-256 للأرشيف
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f