CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
service.rs Hinzugefügt: Datum nicht erfasst 20,8 KB · 451 Zeilen
Code Datei herunterladen
1//! Module service and window runtime (port of `plugins/pluginService.js` and the window
2//! part of `plugins/pluginRuntime.js`): catalog state, install/uninstall, isolated module
3//! windows (one per module, child of the main window), collapse to the Core taskbar.
5use crate::core::app::{Core, MODULE_API_VERSION};
6use crate::core::error::{CoreError, CoreResult};
7use crate::core::modules::catalog::CatalogProvider;
8use crate::core::modules::installer::ModuleInstaller;
9use crate::core::modules::registry::ModuleRegistry;
10use crate::core::version::compare_versions;
11use crate::security::caller::{ModuleContext, MAIN_LABEL};
12use parking_lot::Mutex;
13use serde_json::{json, Value};
14use std::cmp::Ordering;
15use std::collections::HashMap;
16use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
17use std::sync::Arc;
18use tauri::Manager;
20pub 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)]
23struct WindowState {
24 label: String,
25 name: String,
26 version: String,
27 minimized: bool,
30pub 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,
44struct OperationGuard<'a>(&'a Mutex<Option<&'static str>>);
45impl Drop for OperationGuard<'_> {
46 fn drop(&mut self) {
47 *self.0.lock() = None;
48 }
51async fn confirm_windows<F, Fut>(labels: &[String], mut confirm: F) -> bool
52where 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 true
59impl 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> = self
187 .windows
188 .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 list
194 }
196 pub fn get_state(&self) -> CoreResult<Value> {
197 let installed = self.registry.list_installed()?;
198 let catalog: Vec<Value> = self
199 .catalog
200 .lock()
201 .iter()
202 .map(|module| {
203 let current = installed.iter().find(|m| m["id"] == module["id"]);
204 let comparison = current
205 .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 entry
214 })
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 wait
384 // 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 }
411#[cfg(test)]
412mod 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 }
449#[cfg(test)]
450#[path = "../../../tests/support/module_runtime_safety.rs"]
451mod safety_tests;
SHA-25655c89324e364087d69a964781ef2b884963d361e2b5060a78d32d6223d5c3490

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien