1use std::path::Path;
11use std::sync::atomic::AtomicBool;
12use std::sync::Arc;
13
14use serde::Serialize;
15use tauri::{AppHandle, Runtime, WebviewWindow};
16
17use crate::plugins::{
18 parse_manifest, prepare_content_install, prepare_detector_install, prepare_export_install,
19 InstalledPlugin, Manifest, PluginKind, PluginSummary, PreparedDetector,
20};
21use crate::scheduler::content_pack::{
22 merge_pack_tracked, remove_content, AddedContent, MergeSummary,
23};
24use crate::secure_io::read_capped;
25
26use super::super::{persist_plugins, persist_profiles, Scheduler};
27
28const MAIN_WINDOW_LABEL: &str = "main";
30const MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024;
33
34const PLUGIN_DIALOG_ALLOW: &str = "Install";
35const PLUGIN_DIALOG_CANCEL: &str = "Cancel";
36
37fn ensure_main_window<R: Runtime>(webview: &WebviewWindow<R>) -> Result<(), String> {
38 if webview.label() != MAIN_WINDOW_LABEL {
39 return Err("plugin commands are restricted to the main window".to_string());
40 }
41 Ok(())
42}
43
44fn read_manifest_text(path: &str) -> Result<String, String> {
48 read_capped(Path::new(path), MAX_MANIFEST_BYTES).map_err(|e| match e.kind() {
49 std::io::ErrorKind::InvalidData => format!(
50 "plugin file is too large (over {} MiB)",
51 MAX_MANIFEST_BYTES / (1024 * 1024)
52 ),
53 _ => format!("failed to read plugin file: {e}"),
54 })
55}
56
57struct DialogBusyGuard(Arc<AtomicBool>);
58
59impl Drop for DialogBusyGuard {
60 fn drop(&mut self) {
61 self.0.store(false, std::sync::atomic::Ordering::Release);
62 }
63}
64
65#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
68pub struct InstallOutcome {
69 pub id: String,
70 pub name: String,
71 pub kind: PluginKind,
72 pub hints_added: usize,
73 pub routines_added: usize,
74 #[serde(default)]
76 pub images_added: usize,
77 #[serde(default)]
79 pub images_bytes: u64,
80 #[serde(default)]
82 pub sounds_added: usize,
83}
84
85enum Prepared {
88 Content(Manifest),
89 Detector(PreparedDetector),
90 Export(Manifest),
91}
92
93impl Prepared {
94 fn manifest(&self) -> &Manifest {
95 match self {
96 Prepared::Content(m) | Prepared::Export(m) => m,
97 Prepared::Detector(p) => &p.manifest,
98 }
99 }
100}
101
102#[tauri::command]
108pub async fn install_plugin<R: Runtime>(
109 app: AppHandle<R>,
110 webview: WebviewWindow<R>,
111 scheduler: tauri::State<'_, Scheduler>,
112 path: String,
113) -> Result<InstallOutcome, String> {
114 ensure_main_window(&webview)?;
115 let text = read_manifest_text(&path)?;
116
117 let prepared = {
120 let registry = scheduler.plugins.lock().await;
121 match parse_manifest(&text)?.kind {
122 PluginKind::Content => Prepared::Content(prepare_content_install(&text, ®istry)?),
123 PluginKind::Detector => Prepared::Detector(prepare_detector_install(&text, ®istry)?),
124 PluginKind::Export => Prepared::Export(prepare_export_install(&text, ®istry)?),
125 }
126 };
127
128 if scheduler
129 .plugin_dialog_busy
130 .compare_exchange(
131 false,
132 true,
133 std::sync::atomic::Ordering::Acquire,
134 std::sync::atomic::Ordering::Relaxed,
135 )
136 .is_err()
137 {
138 return Err("another plugin install is already pending".to_string());
139 }
140 let _guard = DialogBusyGuard(scheduler.plugin_dialog_busy.clone());
141 if !confirm_install(&app, prepared.manifest()).await {
142 return Err("user declined plugin install".to_string());
143 }
144
145 match prepared {
146 Prepared::Content(manifest) => apply_install(scheduler.inner(), &manifest).await,
147 Prepared::Detector(prepared) => apply_detector_install(scheduler.inner(), &prepared).await,
148 Prepared::Export(manifest) => Ok(apply_export_install(scheduler.inner(), &manifest).await),
149 }
150}
151
152async fn apply_install(
158 scheduler: &Scheduler,
159 manifest: &Manifest,
160) -> Result<InstallOutcome, String> {
161 let pack = manifest
162 .content
163 .as_ref()
164 .expect("content plugin always carries a pack (validated)");
165
166 let mut asset_paths: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
170 let mut prepared: Vec<(String, Vec<u8>)> = Vec::new();
171 let mut images_bytes: u64 = 0;
172 let mut images_added = 0usize;
173 let mut sounds_added = 0usize;
174 for asset in &manifest.assets {
175 let (bytes, kind) = crate::plugins::validate_asset(asset)?;
176 let file_name = crate::plugin_store::asset_file_name(&manifest.id, &asset.id, kind.ext());
177 let path = crate::plugin_store::asset_path(&scheduler.plugins_path, &file_name);
178 asset_paths.insert(asset.id.as_str(), path.to_string_lossy().into_owned());
179 images_bytes += bytes.len() as u64;
180 if kind.is_audio() {
181 sounds_added += 1;
182 } else {
183 images_added += 1;
184 }
185 prepared.push((file_name, bytes));
186 }
187 let mut asset_files: Vec<String> = Vec::new();
191 for (file_name, bytes) in &prepared {
192 if let Err(e) = crate::plugin_store::save_asset(&scheduler.plugins_path, file_name, bytes) {
193 for written in &asset_files {
194 crate::plugin_store::delete_asset(&scheduler.plugins_path, written);
195 }
196 return Err(format!("failed to save plugin asset: {e}"));
197 }
198 asset_files.push(file_name.clone());
199 }
200
201 let rewrite = |slot: &mut Option<String>| {
205 if let Some(id) = slot.take() {
206 *slot = asset_paths.get(id.as_str()).cloned();
207 }
208 };
209 let mut pack = pack.clone();
210 for r in &mut pack.routines {
211 for st in &mut r.steps {
212 rewrite(&mut st.asset);
213 rewrite(&mut st.sound);
214 }
215 if let Some(sounds) = r.breath.as_mut().and_then(|b| b.sounds.as_mut()) {
216 rewrite(&mut sounds.inhale);
217 rewrite(&mut sounds.hold);
218 rewrite(&mut sounds.exhale);
219 rewrite(&mut sounds.hold_out);
220 }
221 }
222
223 let (merged, summary, mut added) = {
224 let mut next = scheduler.settings.lock().await.clone();
225 let (summary, added) = merge_pack_tracked(&pack, &mut next);
226 next.rebuild_derived();
227 (next, summary, added)
228 };
229 added.asset_files = asset_files;
230 *scheduler.settings.lock().await = merged.clone();
231 {
232 let active = scheduler.active_profile_name.lock().await.clone();
233 let mut profiles = scheduler.profiles.lock().await;
234 if let Some(p) = profiles.iter_mut().find(|p| p.name == active) {
235 p.settings = merged;
236 } else {
237 profiles.push(crate::config::Profile {
238 name: active,
239 settings: merged,
240 });
241 }
242 }
243 persist_profiles(scheduler).await;
244
245 {
246 let mut registry = scheduler.plugins.lock().await;
247 registry.insert(InstalledPlugin::from_manifest(manifest, added));
248 }
249 persist_plugins(scheduler).await;
250
251 Ok(InstallOutcome {
252 id: manifest.id.clone(),
253 name: manifest.name.clone(),
254 kind: PluginKind::Content,
255 hints_added: summary.hints_added,
256 routines_added: summary.routines_added,
257 images_added,
258 images_bytes,
259 sounds_added,
260 })
261}
262
263async fn apply_detector_install(
269 scheduler: &Scheduler,
270 prepared: &PreparedDetector,
271) -> Result<InstallOutcome, String> {
272 let manifest = &prepared.manifest;
273 crate::plugin_store::save_module(&scheduler.plugins_path, &manifest.id, &prepared.module)
274 .map_err(|e| format!("failed to save plugin module: {e}"))?;
275 {
276 let mut registry = scheduler.plugins.lock().await;
277 registry.insert(InstalledPlugin::from_detector(manifest));
278 }
279 persist_plugins(scheduler).await;
280 Ok(InstallOutcome {
281 id: manifest.id.clone(),
282 name: manifest.name.clone(),
283 kind: PluginKind::Detector,
284 hints_added: 0,
285 routines_added: 0,
286 images_added: 0,
287 images_bytes: 0,
288 sounds_added: 0,
289 })
290}
291
292async fn apply_export_install(scheduler: &Scheduler, manifest: &Manifest) -> InstallOutcome {
296 {
297 let mut registry = scheduler.plugins.lock().await;
298 registry.insert(InstalledPlugin::from_export(manifest));
299 }
300 persist_plugins(scheduler).await;
301 InstallOutcome {
302 id: manifest.id.clone(),
303 name: manifest.name.clone(),
304 kind: PluginKind::Export,
305 hints_added: 0,
306 routines_added: 0,
307 images_added: 0,
308 images_bytes: 0,
309 sounds_added: 0,
310 }
311}
312
313#[tauri::command]
317pub async fn uninstall_plugin(
318 scheduler: tauri::State<'_, Scheduler>,
319 id: String,
320) -> Result<MergeSummary, String> {
321 uninstall_by_id(scheduler.inner(), &id).await
322}
323
324async fn uninstall_by_id(scheduler: &Scheduler, id: &str) -> Result<MergeSummary, String> {
328 let removed = {
329 let mut registry = scheduler.plugins.lock().await;
330 registry.remove(id)
331 };
332 let Some(record) = removed else {
333 return Err(format!("plugin '{id}' is not installed"));
334 };
335 if record.kind == PluginKind::Detector {
337 crate::plugin_store::delete_module(&scheduler.plugins_path, id);
338 }
339 for file_name in &record.added.asset_files {
342 crate::plugin_store::delete_asset(&scheduler.plugins_path, file_name);
343 }
344 let outcome = apply_uninstall(scheduler, &record.added).await;
346 persist_plugins(scheduler).await;
347 Ok(outcome)
348}
349
350async fn apply_uninstall(scheduler: &Scheduler, added: &AddedContent) -> MergeSummary {
354 let (merged, hints_removed, routines_removed) = {
355 let mut next = scheduler.settings.lock().await.clone();
356 let (h, r) = remove_content(&mut next, added);
357 next.rebuild_derived();
358 (next, h, r)
359 };
360 *scheduler.settings.lock().await = merged.clone();
361 {
362 let active = scheduler.active_profile_name.lock().await.clone();
363 let mut profiles = scheduler.profiles.lock().await;
364 if let Some(p) = profiles.iter_mut().find(|p| p.name == active) {
365 p.settings = merged;
366 }
367 }
368 persist_profiles(scheduler).await;
369 MergeSummary {
370 hints_added: hints_removed,
371 routines_added: routines_removed,
372 }
373}
374
375#[tauri::command]
377pub async fn list_plugins(
378 scheduler: tauri::State<'_, Scheduler>,
379) -> Result<Vec<PluginSummary>, String> {
380 Ok(scheduler.plugins.lock().await.summaries())
381}
382
383async fn confirm_install<R: Runtime>(app: &AppHandle<R>, manifest: &Manifest) -> bool {
384 use tauri_plugin_dialog::{
385 DialogExt, MessageDialogButtons, MessageDialogKind, MessageDialogResult,
386 };
387 let summary = format_install_summary(manifest);
388 let app = app.clone();
389 let (tx, rx) = tokio::sync::oneshot::channel::<MessageDialogResult>();
390 std::thread::spawn(move || {
391 let result = app
392 .dialog()
393 .message(summary)
394 .title("Entracte: install plugin")
395 .kind(MessageDialogKind::Warning)
396 .buttons(MessageDialogButtons::OkCancelCustom(
397 PLUGIN_DIALOG_CANCEL.to_string(),
398 PLUGIN_DIALOG_ALLOW.to_string(),
399 ))
400 .blocking_show_with_result();
401 let _ = tx.send(result);
402 });
403 match rx.await {
404 Ok(MessageDialogResult::Custom(label)) => label == PLUGIN_DIALOG_ALLOW,
405 _ => false,
406 }
407}
408
409const KEY_FINGERPRINT_CHARS: usize = 16;
413
414const DIALOG_MAX_CAPABILITIES_SHOWN: usize = 12;
417
418fn format_install_summary(manifest: &Manifest) -> String {
419 let author = if manifest.author.trim().is_empty() {
420 "(unknown author)".to_string()
421 } else {
422 sanitize_for_dialog(&manifest.author, 80)
423 };
424 let key: String = manifest
425 .signature
426 .public_key
427 .chars()
428 .take(KEY_FINGERPRINT_CHARS)
429 .collect();
430
431 let mut s = String::new();
432 s.push_str("⚠ Only click Install if you chose this plugin file yourself.\n\n");
433 s.push_str(&format!(
434 "Plugin: {}\n",
435 sanitize_for_dialog(&manifest.name, 120)
436 ));
437 s.push_str(&format!("Author: {author}\n"));
438 s.push_str(&format!(
439 "Version: {}\n",
440 sanitize_for_dialog(&manifest.version, 40)
441 ));
442 s.push_str(&format!("Signing key: {key}…\n\n"));
443
444 match manifest.kind {
445 PluginKind::Content => {
446 let hints = manifest
447 .content
448 .as_ref()
449 .map(|p| {
450 p.hints.micro_physical.len()
451 + p.hints.micro_psychological.len()
452 + p.hints.long_solo.len()
453 + p.hints.long_social.len()
454 + p.hints.sleep.len()
455 })
456 .unwrap_or(0);
457 let routines = manifest
458 .content
459 .as_ref()
460 .map(|p| p.routines.len())
461 .unwrap_or(0);
462 s.push_str(&format!(
463 "Adds up to {hints} idea(s) and {routines} routine(s) (duplicates are skipped).\n"
464 ));
465 if !manifest.assets.is_empty() {
466 let bytes: usize = manifest
469 .assets
470 .iter()
471 .map(|a| a.data_base64.len() / 4 * 3)
472 .sum();
473 s.push_str(&format!(
474 "Ships {} media file(s) — images and/or sounds ({:.1} MB).\n",
475 manifest.assets.len(),
476 bytes as f64 / (1024.0 * 1024.0)
477 ));
478 }
479 }
480 PluginKind::Detector => {
481 s.push_str("This plugin runs sandboxed code and is granted ONLY these permissions:\n");
482 if manifest.imports.is_empty() {
483 s.push_str("• (none)\n");
484 }
485 for cap in manifest.imports.iter().take(DIALOG_MAX_CAPABILITIES_SHOWN) {
486 s.push_str(&format!("• {}\n", sanitize_for_dialog(cap, 120)));
487 }
488 if manifest.imports.len() > DIALOG_MAX_CAPABILITIES_SHOWN {
489 s.push_str(&format!(
490 "• … and {} more\n",
491 manifest.imports.len() - DIALOG_MAX_CAPABILITIES_SHOWN
492 ));
493 }
494 }
495 PluginKind::Export => {
496 if let Some(cfg) = &manifest.export {
497 let where_to = match cfg.sink {
498 crate::plugins::ExportSink::File => "write your break statistics to the file",
499 crate::plugins::ExportSink::Http => "SEND your break statistics to",
500 };
501 s.push_str(&format!(
502 "This plugin will {where_to}:\n• {}\n",
503 sanitize_for_dialog(&cfg.destination, 200)
504 ));
505 if cfg.sink == crate::plugins::ExportSink::Http {
506 s.push_str("⚠ This sends data OFF your machine to that address.\n");
507 }
508 }
509 }
510 }
511 s
512}
513
514fn sanitize_for_dialog(s: &str, max_chars: usize) -> String {
517 let mut out = String::with_capacity(s.len().min(max_chars * 4));
518 for (count, c) in s.chars().enumerate() {
519 if count >= max_chars {
520 out.push('…');
521 break;
522 }
523 let replacement = match c {
524 '\n' | '\r' | '\t' => Some('␣'),
525 c if (c as u32) < 0x20 || c as u32 == 0x7F => Some('·'),
526 '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}' => {
527 Some('·')
528 }
529 _ => None,
530 };
531 out.push(replacement.unwrap_or(c));
532 }
533 out
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::plugins::{PluginKind, Signature};
540 use crate::scheduler::content_pack::{ContentPack, PackHints, CONTENT_PACK_VERSION};
541 use crate::scheduler::routines::{Routine, RoutineCategory, RoutineDifficulty, RoutineKind};
542 use crate::scheduler::types::RoutineStep;
543 use crate::scheduler::BreakKind;
544 use crate::test_support::test_scheduler;
545
546 fn content_manifest(id: &str) -> Manifest {
547 Manifest {
548 manifest_version: crate::plugins::MANIFEST_VERSION,
549 id: id.to_string(),
550 name: "Stretch pack".to_string(),
551 version: "1.0.0".to_string(),
552 author: "Jane".to_string(),
553 description: String::new(),
554 kind: PluginKind::Content,
555 module: None,
556 module_base64: None,
557 abi_version: None,
558 imports: vec![],
559 detect: None,
560 export: None,
561 content: Some(ContentPack {
562 version: CONTENT_PACK_VERSION,
563 name: "Stretch pack".to_string(),
564 hints: PackHints {
565 micro_physical: vec!["Roll your shoulders".to_string()],
566 ..PackHints::default()
567 },
568 routines: vec![Routine {
569 id: "plugin-rt".to_string(),
570 label: "Plugin routine".to_string(),
571 kind: RoutineKind::Micro,
572 category: RoutineCategory::Eyes,
573 difficulty: RoutineDifficulty::Gentle,
574 steps: vec![RoutineStep {
575 text: "Look away".to_string(),
576 seconds: 5,
577 asset: None,
578 sound: None,
579 }],
580 pacing: None,
581 max_step_secs: None,
582 breath: None,
583 }],
584 }),
585 assets: Vec::new(),
586 signature: Signature {
587 alg: "ed25519".to_string(),
588 public_key: "QUJDREVGR0hJSktMTU5PUA==".to_string(),
589 sig: String::new(),
590 },
591 }
592 }
593
594 fn detector_manifest(id: &str) -> Manifest {
595 use crate::plugins::DetectConfig;
596 Manifest {
597 manifest_version: crate::plugins::MANIFEST_VERSION,
598 id: id.to_string(),
599 name: "Focus detector".to_string(),
600 version: "1.0.0".to_string(),
601 author: "Jane".to_string(),
602 description: String::new(),
603 kind: PluginKind::Detector,
604 module: Some("module.wasm".to_string()),
605 module_base64: None,
606 abi_version: Some(crate::plugins::SUPPORTED_ABI_VERSION),
607 imports: vec!["detect:processes".to_string()],
608 detect: Some(DetectConfig {
609 process_name: Some("zoom".to_string()),
610 }),
611 export: None,
612 content: None,
613 assets: Vec::new(),
614 signature: Signature {
615 alg: "ed25519".to_string(),
616 public_key: "QUJDREVGR0hJSktMTU5PUA==".to_string(),
617 sig: String::new(),
618 },
619 }
620 }
621
622 fn export_manifest(id: &str) -> Manifest {
623 use crate::plugins::{ExportConfig, ExportFormat, ExportSink};
624 Manifest {
625 manifest_version: crate::plugins::MANIFEST_VERSION,
626 id: id.to_string(),
627 name: "JSON export".to_string(),
628 version: "1.0.0".to_string(),
629 author: "Jane".to_string(),
630 description: String::new(),
631 kind: PluginKind::Export,
632 module: None,
633 module_base64: None,
634 abi_version: None,
635 imports: vec![],
636 detect: None,
637 export: Some(ExportConfig {
638 sink: ExportSink::Http,
639 format: ExportFormat::Json,
640 destination: "http://127.0.0.1:8080/entracte".to_string(),
641 on: vec![crate::hooks::HookEvent::BreakEnd],
642 }),
643 content: None,
644 assets: Vec::new(),
645 signature: Signature {
646 alg: "ed25519".to_string(),
647 public_key: "QUJDREVGR0hJSktMTU5PUA==".to_string(),
648 sig: String::new(),
649 },
650 }
651 }
652
653 #[tokio::test]
654 async fn apply_export_install_registers_and_uninstall_removes() {
655 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
656 let outcome = apply_export_install(&sched, &export_manifest("com.example.exp")).await;
657 assert_eq!(outcome.kind, PluginKind::Export);
658 {
659 let reg = sched.plugins.lock().await;
660 assert!(reg.contains("com.example.exp"));
661 }
662 uninstall_by_id(&sched, "com.example.exp").await.unwrap();
664 assert!(!sched.plugins.lock().await.contains("com.example.exp"));
665 }
666
667 #[test]
668 fn format_install_summary_shows_export_destination_and_egress_warning() {
669 let s = format_install_summary(&export_manifest("com.example.exp"));
670 assert!(s.contains("http://127.0.0.1:8080/entracte"));
671 assert!(s.contains("SEND your break statistics"));
672 assert!(s.contains("OFF your machine"));
673 let warn = s.find("Only click Install").unwrap();
674 let body = s.find("This plugin will").unwrap();
675 assert!(warn < body);
676 }
677
678 #[test]
679 fn format_install_summary_file_export_has_no_egress_warning() {
680 let mut m = export_manifest("com.example.exp");
681 let cfg = m.export.as_mut().unwrap();
682 cfg.sink = crate::plugins::ExportSink::File;
683 cfg.destination = "/home/me/breaks.csv".to_string();
684 let s = format_install_summary(&m);
685 assert!(s.contains("write your break statistics"));
686 assert!(s.contains("/home/me/breaks.csv"));
687 assert!(!s.contains("OFF your machine"));
688 }
689
690 #[tokio::test]
691 async fn apply_detector_install_persists_module_and_record_uninstall_removes_both() {
692 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
693 let prepared = PreparedDetector {
694 manifest: detector_manifest("com.example.focus"),
695 module: b"\0asm fake module".to_vec(),
696 };
697 let outcome = apply_detector_install(&sched, &prepared).await.unwrap();
698 assert_eq!(outcome.kind, PluginKind::Detector);
699
700 let module_path =
701 crate::plugin_store::module_path(&sched.plugins_path, "com.example.focus");
702 assert!(module_path.exists(), "module persisted to disk");
703 {
704 let reg = sched.plugins.lock().await;
705 assert!(reg.contains("com.example.focus"));
706 }
707
708 uninstall_by_id(&sched, "com.example.focus").await.unwrap();
710 assert!(!module_path.exists(), "module deleted on uninstall");
711 assert!(!sched.plugins.lock().await.contains("com.example.focus"));
712 }
713
714 #[test]
715 fn prepared_manifest_accessor_returns_the_inner_manifest() {
716 let content = Prepared::Content(content_manifest("com.example.c"));
717 assert_eq!(content.manifest().id, "com.example.c");
718 let detector = Prepared::Detector(PreparedDetector {
719 manifest: detector_manifest("com.example.d"),
720 module: vec![0, 1, 2],
721 });
722 assert_eq!(detector.manifest().id, "com.example.d");
723 }
724
725 #[test]
726 fn format_install_summary_lists_capabilities_for_a_detector() {
727 let s = format_install_summary(&detector_manifest("com.example.focus"));
728 assert!(s.contains("ONLY these permissions"));
729 assert!(s.contains("detect:processes"));
730 let warn = s.find("Only click Install").unwrap();
731 let perms = s.find("ONLY these permissions").unwrap();
732 assert!(warn < perms, "safety warning must come first");
733 }
734
735 #[test]
736 fn format_install_summary_handles_no_and_many_capabilities() {
737 let mut m = detector_manifest("com.example.focus");
738 m.imports = vec![];
739 assert!(format_install_summary(&m).contains("(none)"));
740
741 m.imports = (0..15).map(|i| format!("detect:file:/p/{i}")).collect();
742 let s = format_install_summary(&m);
743 assert!(
744 s.contains("and 3 more"),
745 "15 caps minus the {DIALOG_MAX_CAPABILITIES_SHOWN} shown = 3 more"
746 );
747 }
748
749 #[test]
750 fn format_install_summary_reports_media_for_a_content_plugin() {
751 let with = format_install_summary(&content_manifest_with_image("com.example.yoga"));
752 assert!(
753 with.contains("1 media file(s)"),
754 "summary mentions the media: {with}"
755 );
756 let without = format_install_summary(&content_manifest("com.example.plain"));
758 assert!(!without.contains("media file(s)"));
759 }
760
761 #[tokio::test]
762 async fn install_then_uninstall_round_trips_settings_and_registry() {
763 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
764 let before = sched.settings.lock().await.micro_physical_hints.clone();
765
766 let manifest = content_manifest("com.example.stretch");
767 let outcome = apply_install(&sched, &manifest).await.unwrap();
768 assert_eq!(outcome.hints_added, 1);
769 assert_eq!(outcome.routines_added, 1);
770
771 assert!(sched.plugins.lock().await.contains("com.example.stretch"));
774 {
775 let s = sched.settings.lock().await;
776 assert!(s
777 .effective_hints(BreakKind::Micro)
778 .contains(&"Roll your shoulders".to_string()));
779 assert!(s.custom_routines.iter().any(|r| r.id == "plugin-rt"));
780 }
781
782 let record = sched
784 .plugins
785 .lock()
786 .await
787 .remove("com.example.stretch")
788 .unwrap();
789 let removed = apply_uninstall(&sched, &record.added).await;
790 assert_eq!(removed.hints_added, 1);
791 assert_eq!(removed.routines_added, 1);
792 let s = sched.settings.lock().await;
793 assert_eq!(s.micro_physical_hints, before);
794 assert!(!s.custom_routines.iter().any(|r| r.id == "plugin-rt"));
795 }
796
797 #[tokio::test]
798 async fn uninstall_by_id_errors_when_not_installed() {
799 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
800 let err = uninstall_by_id(&sched, "com.nope.absent")
801 .await
802 .unwrap_err();
803 assert!(err.contains("not installed"));
804 }
805
806 #[tokio::test]
807 async fn uninstall_by_id_removes_tracked_content_and_record() {
808 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
809 apply_install(&sched, &content_manifest("com.example.stretch"))
810 .await
811 .unwrap();
812 let removed = uninstall_by_id(&sched, "com.example.stretch")
813 .await
814 .unwrap();
815 assert_eq!(removed.hints_added, 1);
816 assert_eq!(removed.routines_added, 1);
817 assert!(!sched.plugins.lock().await.contains("com.example.stretch"));
818 }
819
820 fn png_manifest_asset(asset_id: &str) -> crate::plugins::ManifestAsset {
822 use base64::prelude::{Engine, BASE64_STANDARD};
823 let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
824 bytes.extend_from_slice(&[0, 0, 0, 13]);
825 bytes.extend_from_slice(b"IHDR");
826 bytes.extend_from_slice(&12u32.to_be_bytes());
827 bytes.extend_from_slice(&12u32.to_be_bytes());
828 bytes.extend_from_slice(&[8, 6, 0, 0, 0]);
829 let hash = crate::plugins::sha256(&bytes);
830 crate::plugins::ManifestAsset {
831 id: asset_id.to_string(),
832 sha256: hash.iter().map(|b| format!("{b:02x}")).collect(),
833 data_base64: BASE64_STANDARD.encode(&bytes),
834 }
835 }
836
837 fn content_manifest_with_image(id: &str) -> Manifest {
840 let mut m = content_manifest(id);
841 m.assets = vec![png_manifest_asset("twist")];
842 m.content.as_mut().unwrap().routines[0].steps[0].asset = Some("twist".to_string());
843 m
844 }
845
846 fn ogg_manifest_asset(asset_id: &str) -> crate::plugins::ManifestAsset {
847 use base64::prelude::{Engine, BASE64_STANDARD};
848 let mut bytes = b"OggS".to_vec();
849 bytes.extend_from_slice(&[0u8; 32]);
850 let hash = crate::plugins::sha256(&bytes);
851 crate::plugins::ManifestAsset {
852 id: asset_id.to_string(),
853 sha256: hash.iter().map(|b| format!("{b:02x}")).collect(),
854 data_base64: BASE64_STANDARD.encode(&bytes),
855 }
856 }
857
858 #[tokio::test]
859 async fn install_rewrites_step_and_breath_sounds_and_counts_them() {
860 use crate::scheduler::{BreathPattern, BreathSounds};
861 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
862 let mut m = content_manifest("com.example.yoga");
863 m.assets = vec![ogg_manifest_asset("chime")];
864 let r = &mut m.content.as_mut().unwrap().routines[0];
865 r.steps[0].sound = Some("chime".to_string());
866 r.breath = Some(BreathPattern {
867 inhale: 4,
868 hold: 0,
869 exhale: 4,
870 hold_out: 0,
871 cycles: None,
872 then: None,
873 sounds: Some(BreathSounds {
874 inhale: Some("chime".to_string()),
875 ..Default::default()
876 }),
877 });
878
879 let outcome = apply_install(&sched, &m).await.unwrap();
880 assert_eq!(outcome.sounds_added, 1);
881 assert_eq!(outcome.images_added, 0);
882
883 let settings = sched.settings.lock().await;
884 let rt = settings
885 .custom_routines
886 .iter()
887 .find(|r| r.id == "plugin-rt")
888 .expect("routine merged");
889 let step_path = rt.steps[0].sound.clone().expect("step sound rewritten");
890 assert!(step_path.ends_with("com.example.yoga.chime.ogg"));
891 assert!(std::path::Path::new(&step_path).exists(), "sidecar written");
892 let breath_path = rt
894 .breath
895 .as_ref()
896 .unwrap()
897 .sounds
898 .as_ref()
899 .unwrap()
900 .inhale
901 .clone();
902 assert_eq!(breath_path, Some(step_path));
903 }
904
905 #[tokio::test]
906 async fn install_writes_image_sidecar_and_rewrites_step_to_its_path() {
907 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
908 let outcome = apply_install(&sched, &content_manifest_with_image("com.example.yoga"))
909 .await
910 .unwrap();
911 assert_eq!(outcome.images_added, 1);
912 assert!(outcome.images_bytes > 0);
913
914 let settings = sched.settings.lock().await;
916 let rt = settings
917 .custom_routines
918 .iter()
919 .find(|r| r.id == "plugin-rt")
920 .expect("routine merged");
921 let path = rt.steps[0].asset.clone().expect("step asset rewritten");
922 assert!(path.ends_with("com.example.yoga.twist.png"));
923 assert!(std::path::Path::new(&path).exists(), "sidecar written");
924 }
925
926 #[tokio::test]
927 async fn uninstall_removes_image_sidecars() {
928 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
929 apply_install(&sched, &content_manifest_with_image("com.example.yoga"))
930 .await
931 .unwrap();
932 let file =
933 crate::plugin_store::asset_path(&sched.plugins_path, "com.example.yoga.twist.png");
934 assert!(file.exists());
935 uninstall_by_id(&sched, "com.example.yoga").await.unwrap();
936 assert!(!file.exists(), "sidecar deleted on uninstall");
937 }
938
939 #[tokio::test]
940 async fn install_rolls_back_written_sidecars_when_a_later_write_fails() {
941 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
942 let mut m = content_manifest_with_image("com.example.yoga");
943 m.assets.push(png_manifest_asset("block"));
945
946 let first =
949 crate::plugin_store::asset_path(&sched.plugins_path, "com.example.yoga.twist.png");
950 let blocked =
951 crate::plugin_store::asset_path(&sched.plugins_path, "com.example.yoga.block.png");
952 std::fs::create_dir_all(blocked.parent().unwrap()).unwrap();
953 std::fs::create_dir(&blocked).unwrap();
954
955 let res = apply_install(&sched, &m).await;
956 assert!(
957 res.is_err(),
958 "install fails when a sidecar can't be written"
959 );
960 assert!(
961 !first.exists(),
962 "the already-written sidecar is rolled back, leaving no untracked file"
963 );
964 assert!(
965 !sched.plugins.lock().await.contains("com.example.yoga"),
966 "no registry record on a failed install"
967 );
968 }
969
970 #[test]
971 fn read_manifest_text_reads_a_file_and_reports_a_missing_one() {
972 let dir = crate::test_support::temp_dir();
973 let path = dir.path().join("plugin.json");
974 std::fs::write(&path, b"{\"hello\":true}").unwrap();
975 assert_eq!(
976 read_manifest_text(&path.display().to_string()).unwrap(),
977 "{\"hello\":true}"
978 );
979
980 let missing = dir.path().join("nope.json");
981 assert!(read_manifest_text(&missing.display().to_string())
982 .unwrap_err()
983 .contains("failed to read plugin file"));
984 }
985
986 #[test]
987 fn read_manifest_text_rejects_an_oversized_file() {
988 let dir = crate::test_support::temp_dir();
989 let path = dir.path().join("huge.json");
990 std::fs::write(&path, vec![b'x'; (MAX_MANIFEST_BYTES + 1) as usize]).unwrap();
991 assert!(read_manifest_text(&path.display().to_string())
992 .unwrap_err()
993 .contains("too large"));
994 }
995
996 #[tokio::test]
997 async fn apply_install_pushes_active_profile_when_absent() {
998 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
999 *sched.active_profile_name.lock().await = "Ghost".to_string();
1000 apply_install(&sched, &content_manifest("com.example.stretch"))
1001 .await
1002 .unwrap();
1003 let profiles = sched.profiles.lock().await;
1004 let ghost = profiles
1005 .iter()
1006 .find(|p| p.name == "Ghost")
1007 .expect("absent active profile was pushed");
1008 assert!(ghost
1009 .settings
1010 .custom_routines
1011 .iter()
1012 .any(|r| r.id == "plugin-rt"));
1013 }
1014
1015 #[tokio::test]
1016 async fn apply_install_writes_into_the_active_profile() {
1017 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1018 apply_install(&sched, &content_manifest("com.example.stretch"))
1019 .await
1020 .unwrap();
1021 let active = sched.active_profile_name.lock().await.clone();
1022 let profiles = sched.profiles.lock().await;
1023 let p = profiles.iter().find(|p| p.name == active).unwrap();
1024 assert!(p
1025 .settings
1026 .custom_routines
1027 .iter()
1028 .any(|r| r.id == "plugin-rt"));
1029 }
1030
1031 #[test]
1032 fn format_install_summary_shows_provenance_and_counts() {
1033 let m = content_manifest("com.example.stretch");
1034 let s = format_install_summary(&m);
1035 assert!(s.contains("Stretch pack"));
1036 assert!(s.contains("Jane"));
1037 assert!(s.contains("1.0.0"));
1038 assert!(s.contains("Signing key:"));
1039 assert!(s.contains("1 idea(s) and 1 routine(s)"));
1040 let warn = s.find("Only click Install").unwrap();
1041 let body = s.find("Adds up to").unwrap();
1042 assert!(warn < body, "safety warning must come first");
1043 }
1044
1045 #[test]
1046 fn format_install_summary_handles_missing_author() {
1047 let mut m = content_manifest("com.example.stretch");
1048 m.author = " ".to_string();
1049 assert!(format_install_summary(&m).contains("(unknown author)"));
1050 }
1051
1052 #[test]
1053 fn sanitize_for_dialog_strips_control_and_bidi() {
1054 let out = sanitize_for_dialog("a\nb\u{202E}c", 100);
1055 assert!(!out.contains('\n'));
1056 assert!(!out.contains('\u{202E}'));
1057 }
1058
1059 #[test]
1060 fn sanitize_for_dialog_clips_at_max_chars() {
1061 let out = sanitize_for_dialog(&"x".repeat(50), 8);
1062 assert_eq!(out.chars().count(), 9); assert!(out.ends_with('…'));
1064 }
1065
1066 #[test]
1067 fn dialog_busy_guard_resets_flag_on_drop() {
1068 let flag = Arc::new(AtomicBool::new(true));
1069 {
1070 let _g = DialogBusyGuard(flag.clone());
1071 }
1072 assert!(!flag.load(std::sync::atomic::Ordering::Acquire));
1073 }
1074}
1075
1076#[cfg(all(test, not(target_os = "windows")))]
1085mod mock_app_tests {
1086 use super::*;
1087 use crate::plugins::{InstalledPlugin, PluginKind};
1088 use crate::scheduler::content_pack::AddedContent;
1089 use crate::test_support::{temp_dir, test_scheduler, wrap_in_mock_app};
1090 use tauri::test::MockRuntime;
1091 use tauri::{App, Manager, WebviewWindowBuilder};
1092
1093 fn webview(app: &App<MockRuntime>, label: &str) -> tauri::WebviewWindow<MockRuntime> {
1094 WebviewWindowBuilder::new(app, label, Default::default())
1095 .build()
1096 .expect("mock webview builds")
1097 }
1098
1099 fn installed(id: &str) -> InstalledPlugin {
1100 InstalledPlugin {
1101 id: id.to_string(),
1102 name: "Pack".to_string(),
1103 author: "Me".to_string(),
1104 version: "1.0.0".to_string(),
1105 kind: PluginKind::Content,
1106 public_key: "AA==".to_string(),
1107 added: AddedContent {
1108 micro_physical: vec!["Stretch".to_string()],
1109 ..AddedContent::default()
1110 },
1111 capabilities: Vec::new(),
1112 detect: None,
1113 export: None,
1114 }
1115 }
1116
1117 #[tokio::test]
1118 async fn ensure_main_window_accepts_main_rejects_others() {
1119 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1120 let app = wrap_in_mock_app(sched);
1121 assert!(ensure_main_window(&webview(&app, MAIN_WINDOW_LABEL)).is_ok());
1122 assert!(ensure_main_window(&webview(&app, "overlay"))
1123 .unwrap_err()
1124 .contains("restricted to the main window"));
1125 }
1126
1127 #[tokio::test]
1128 async fn list_plugins_command_returns_summaries() {
1129 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1130 sched.plugins.lock().await.insert(installed("com.x.pack"));
1131 let app = wrap_in_mock_app(sched);
1132 let out = list_plugins(app.state::<Scheduler>()).await.unwrap();
1133 assert_eq!(out.len(), 1);
1134 assert_eq!(out[0].id, "com.x.pack");
1135 assert_eq!(out[0].hints_added, 1);
1136 }
1137
1138 #[tokio::test]
1139 async fn uninstall_plugin_command_removes_record() {
1140 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1141 sched.plugins.lock().await.insert(installed("com.x.pack"));
1142 let app = wrap_in_mock_app(sched.clone());
1143 uninstall_plugin(app.state::<Scheduler>(), "com.x.pack".to_string())
1144 .await
1145 .unwrap();
1146 assert!(!sched.plugins.lock().await.contains("com.x.pack"));
1147
1148 assert!(
1150 uninstall_plugin(app.state::<Scheduler>(), "com.nope".to_string())
1151 .await
1152 .unwrap_err()
1153 .contains("not installed")
1154 );
1155 }
1156
1157 #[tokio::test]
1158 async fn install_rejects_a_non_main_window_before_any_dialog() {
1159 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1160 let app = wrap_in_mock_app(sched);
1161 let err = install_plugin(
1162 app.handle().clone(),
1163 webview(&app, "overlay"),
1164 app.state::<Scheduler>(),
1165 "/whatever.json".to_string(),
1166 )
1167 .await
1168 .unwrap_err();
1169 assert!(err.contains("restricted to the main window"));
1170 }
1171
1172 #[tokio::test]
1173 async fn install_reports_an_unreadable_file_before_any_dialog() {
1174 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1175 let app = wrap_in_mock_app(sched);
1176 let err = install_plugin(
1177 app.handle().clone(),
1178 webview(&app, MAIN_WINDOW_LABEL),
1179 app.state::<Scheduler>(),
1180 "/no/such/plugin.json".to_string(),
1181 )
1182 .await
1183 .unwrap_err();
1184 assert!(err.contains("failed to read plugin file"));
1185 }
1186
1187 #[tokio::test]
1188 async fn install_rejects_a_malformed_manifest_before_any_dialog() {
1189 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1190 let app = wrap_in_mock_app(sched);
1191 let dir = temp_dir();
1192 let path = dir.path().join("bad.json");
1193 std::fs::write(&path, b"{ not a manifest").unwrap();
1194 let err = install_plugin(
1195 app.handle().clone(),
1196 webview(&app, MAIN_WINDOW_LABEL),
1197 app.state::<Scheduler>(),
1198 path.display().to_string(),
1199 )
1200 .await
1201 .unwrap_err();
1202 assert!(err.contains("not a valid plugin manifest"));
1203 }
1204
1205 fn write_signed_content_plugin(dir: &std::path::Path) -> String {
1209 use crate::plugins::{signing_payload, Signature};
1210 use crate::scheduler::content_pack::{ContentPack, PackHints, CONTENT_PACK_VERSION};
1211 use base64::prelude::{Engine, BASE64_STANDARD};
1212 use ed25519_dalek::{Signer, SigningKey};
1213
1214 let mut m = Manifest {
1215 manifest_version: crate::plugins::MANIFEST_VERSION,
1216 id: "com.example.signed".to_string(),
1217 name: "Signed pack".to_string(),
1218 version: "1.0.0".to_string(),
1219 author: "Jane".to_string(),
1220 description: String::new(),
1221 kind: crate::plugins::PluginKind::Content,
1222 module: None,
1223 module_base64: None,
1224 abi_version: None,
1225 imports: vec![],
1226 detect: None,
1227 export: None,
1228 content: Some(ContentPack {
1229 version: CONTENT_PACK_VERSION,
1230 name: "Signed pack".to_string(),
1231 hints: PackHints {
1232 micro_physical: vec!["Breathe".to_string()],
1233 ..PackHints::default()
1234 },
1235 routines: vec![],
1236 }),
1237 assets: Vec::new(),
1238 signature: Signature {
1239 alg: "ed25519".to_string(),
1240 public_key: String::new(),
1241 sig: String::new(),
1242 },
1243 };
1244 let key = SigningKey::from_bytes(&[11u8; 32]);
1245 m.signature.public_key = BASE64_STANDARD.encode(key.verifying_key().to_bytes());
1246 m.signature.sig = BASE64_STANDARD.encode(key.sign(&signing_payload(&m, None)).to_bytes());
1247 let path = dir.join("signed.json");
1248 std::fs::write(&path, serde_json::to_string(&m).unwrap()).unwrap();
1249 path.display().to_string()
1250 }
1251
1252 #[tokio::test]
1253 async fn install_rejects_when_a_dialog_is_already_pending() {
1254 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1258 sched
1259 .plugin_dialog_busy
1260 .store(true, std::sync::atomic::Ordering::Release);
1261 let app = wrap_in_mock_app(sched);
1262 let dir = temp_dir();
1263 let path = write_signed_content_plugin(dir.path());
1264 let err = install_plugin(
1265 app.handle().clone(),
1266 webview(&app, MAIN_WINDOW_LABEL),
1267 app.state::<Scheduler>(),
1268 path,
1269 )
1270 .await
1271 .unwrap_err();
1272 assert!(err.contains("another plugin install is already pending"));
1273 }
1274
1275 #[tokio::test]
1276 async fn install_routes_to_the_export_path_and_surfaces_its_errors() {
1277 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1278 let app = wrap_in_mock_app(sched);
1279 let dir = temp_dir();
1280 let path = dir.path().join("export.json");
1281 std::fs::write(
1284 &path,
1285 r#"{"manifest_version":1,"id":"com.x.exp","name":"E","version":"1.0.0","kind":"export","signature":{"alg":"ed25519","public_key":"","sig":""}}"#,
1286 )
1287 .unwrap();
1288 let err = install_plugin(
1289 app.handle().clone(),
1290 webview(&app, MAIN_WINDOW_LABEL),
1291 app.state::<Scheduler>(),
1292 path.display().to_string(),
1293 )
1294 .await
1295 .unwrap_err();
1296 assert!(err.contains("must carry an export config"));
1297 }
1298
1299 #[tokio::test]
1300 async fn install_routes_to_the_detector_path_and_surfaces_its_errors() {
1301 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
1302 let app = wrap_in_mock_app(sched);
1303 let dir = temp_dir();
1304 let path = dir.path().join("det.json");
1305 std::fs::write(
1308 &path,
1309 r#"{"manifest_version":1,"id":"com.x.det","name":"D","version":"1.0.0","kind":"detector","module":"m.wasm","abi_version":1,"imports":["detect:processes"],"detect":{"process_name":"zoom"},"signature":{"alg":"ed25519","public_key":"","sig":""}}"#,
1310 )
1311 .unwrap();
1312 let err = install_plugin(
1313 app.handle().clone(),
1314 webview(&app, MAIN_WINDOW_LABEL),
1315 app.state::<Scheduler>(),
1316 path.display().to_string(),
1317 )
1318 .await
1319 .unwrap_err();
1320 assert!(err.contains("missing its module"));
1321 }
1322}