Skip to main content

entracte_lib/scheduler/commands/
plugins.rs

1//! Content-plugin install / uninstall / list commands.
2//!
3//! Install is gated by a native confirmation dialog (mirroring `set_hooks`):
4//! the user must explicitly approve installing a plugin, with its provenance
5//! shown. A content plugin's pack is merged into the active profile and the
6//! exact additions are recorded in the registry (merge-and-track), so
7//! uninstall removes precisely what was added. The registry persists to
8//! `plugins.json`; the merged content persists in the profile as usual.
9
10use 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
28/// Plugin IPC is restricted to the main settings window, like content packs.
29const MAIN_WINDOW_LABEL: &str = "main";
30/// Hard cap on a plugin manifest we'll read+parse. A content plugin embeds
31/// its pack, so this matches the content-pack cap.
32const 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
44/// Read a plugin manifest file with the size cap, mapping I/O errors to
45/// user-facing strings. Pure (filesystem only), so the read + error-mapping
46/// is testable without a Tauri window.
47fn 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/// Result of an install, surfaced to the renderer. `hints_added` /
66/// `routines_added` are the content merge effect (zero for a detector).
67#[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    /// Image assets written to disk (zero for non-content plugins).
75    #[serde(default)]
76    pub images_added: usize,
77    /// Total decoded bytes of those images.
78    #[serde(default)]
79    pub images_bytes: u64,
80    /// Sound-cue assets written to disk (zero for non-content plugins).
81    #[serde(default)]
82    pub sounds_added: usize,
83}
84
85/// A validated plugin awaiting consent + apply. Holds enough to show the
86/// confirmation dialog and then apply the right install path.
87enum 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/// Install a plugin from `path`: read (size-capped), validate (parse, schema,
103/// signature, and — for detectors — decode + sandbox link check), confirm via
104/// a native dialog, then apply. Content plugins merge their pack into the
105/// active profile; detector plugins persist their module and register their
106/// granted capabilities. Returns a summary of the effect.
107#[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    // Validate fully *before* prompting (so a bad plugin errors without
118    // touching the dialog), dispatching on the declared kind.
119    let prepared = {
120        let registry = scheduler.plugins.lock().await;
121        match parse_manifest(&text)?.kind {
122            PluginKind::Content => Prepared::Content(prepare_content_install(&text, &registry)?),
123            PluginKind::Detector => Prepared::Detector(prepare_detector_install(&text, &registry)?),
124            PluginKind::Export => Prepared::Export(prepare_export_install(&text, &registry)?),
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
152/// Merge a validated content plugin into the active profile, record the
153/// additions in the registry, and persist both. Split out so it's
154/// unit-testable without a `WebviewWindow`/dialog. Mirrors the content-pack
155/// `apply_pack` write sequence (merge into a clone, rebuild the derived
156/// caches, store, upsert the active profile, persist).
157async 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    // Decode + re-validate every asset BEFORE writing any, so a validation
167    // error can't leave half the sidecars on disk; map each pack-local id to
168    // its stored absolute path.
169    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    // Now write the sidecars. On any I/O failure, roll back the ones already
188    // written so a partial install never leaves untracked files behind (the
189    // registry record — and thus uninstall — only exists once we finish).
190    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    // Rewrite each step's pack-local asset id to the stored absolute path, so
202    // the merged routine resolves to a real file at break time with no further
203    // lookup. Steps referencing nothing (the common case) are untouched.
204    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
263/// Persist a validated detector's module to disk and register it (granted
264/// capabilities + detect config travel in the record). Split out so it's
265/// unit-testable without a `WebviewWindow`/dialog. The module bytes are
266/// written first; only on success is the registry updated and persisted, so a
267/// failed write leaves no dangling record.
268async 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
292/// Register a validated export adapter (declarative — no module, no content
293/// merge). The export config travels in the record for the delivery path.
294/// Split out so it's unit-testable without a `WebviewWindow`/dialog.
295async 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/// Uninstall the plugin `id`: remove exactly the content it added from the
314/// active profile and drop its registry record. No-op-safe if the user
315/// already deleted some of that content by hand. Returns what was removed.
316#[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
324/// Drop the registry record for `id` and remove its tracked content from the
325/// active profile, persisting both. Errors if `id` isn't installed. Split
326/// from the command wrapper so it's testable against a real `Scheduler`.
327async 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    // A detector's module lives on disk; drop it too.
336    if record.kind == PluginKind::Detector {
337        crate::plugin_store::delete_module(&scheduler.plugins_path, id);
338    }
339    // A content plugin's image sidecars likewise; remove exactly the files it
340    // wrote (idempotent — missing files are ignored).
341    for file_name in &record.added.asset_files {
342        crate::plugin_store::delete_asset(&scheduler.plugins_path, file_name);
343    }
344    // Content removal is a no-op for a detector (its `added` is empty).
345    let outcome = apply_uninstall(scheduler, &record.added).await;
346    persist_plugins(scheduler).await;
347    Ok(outcome)
348}
349
350/// Remove `added` content from the active profile and persist. The registry
351/// entry is dropped by the caller; this only touches settings. Split out for
352/// unit testing. Returns `MergeSummary` repurposed as removal counts.
353async 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/// List installed plugins for the Settings UI.
376#[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
409/// Number of leading characters of the base64 signing key shown as a short
410/// visual fingerprint in the dialog, so a returning user can recognise a
411/// familiar author and spot a substituted one.
412const KEY_FINGERPRINT_CHARS: usize = 16;
413
414/// Most capabilities shown in full in the dialog before truncating, so a long
415/// (or padded) import list can't push the safety text off-screen.
416const 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                // Approximate decoded size from the base64 length (¾) — good
467                // enough for a dialog, and avoids decoding every blob here.
468                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
514/// Same control-character / bidi sanitisation as the hooks dialog, so a
515/// hostile manifest can't spoof or scramble the consent prompt.
516fn 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: no module on disk, just the registry record.
663        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 drops both the registry record and the module file.
709        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        // A content plugin with no media says nothing about it.
757        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        // Registry recorded it, and the derived cache the fire path reads was
772        // rebuilt so the idea is actually live.
773        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        // Uninstall removes exactly what was added.
783        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    /// A valid 12×12 PNG `ManifestAsset` with the given id.
821    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    /// A content manifest carrying one PNG asset that its single routine step
838    /// references by id.
839    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        // The breath inhale cue is rewritten to the same stored sidecar path.
893        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        // The merged routine's step now points at a real on-disk sidecar.
915        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        // A second asset whose write will fail.
944        m.assets.push(png_manifest_asset("block"));
945
946        // Force the second write to fail by planting a directory where its
947        // sidecar file would go (a file write over a directory errors).
948        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); // 8 + the ellipsis
1063        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// Integration tests that need a Tauri `AppHandle` / `WebviewWindow` /
1077// `State`, driven through `tauri::test`'s MockRuntime. Gated off Windows
1078// like the rest of the mock-app rig (see Cargo.toml). These cover the
1079// command wrappers around the unit-tested cores: the main-window gate,
1080// `list_plugins`, `uninstall_plugin`, and `install_plugin`'s
1081// pre-dialog error paths. The native confirmation dialog itself can't be
1082// driven headless, so the post-consent install path is covered via
1083// `apply_install` in the unit tests above.
1084#[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        // The not-installed path through the wrapper.
1149        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    /// Write a validly-signed content-plugin manifest to a temp file and
1206    /// return its path. Lets the install command get past `prepare_content_
1207    /// install` to the dialog-guard logic.
1208    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        // A valid signed plugin gets past prepare; with the dialog flag
1255        // already set, the single-flight guard rejects before any dialog —
1256        // exercising the guard branch without a blocking native prompt.
1257        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        // A parseable export manifest with no export config: routing reaches
1282        // the export installer, which rejects it before any dialog.
1283        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        // A valid detector manifest with no embedded module: routing reaches
1306        // the detector installer, which rejects it before any dialog.
1307        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}