Skip to main content

agentmux_srv/backend/
widget_packages.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Widget packages: user widgets as folders with a `widget.json` manifest,
5//! which run only once the user approves them
6//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §5, §8).
7//!
8//! srv scans `~/.agentmux/widgets/` (shared by every channel), validates each
9//! package's manifest, hashes every file, and keeps this instance's approval
10//! record. A package's files are served (`/agentmux/widget-files/…`) only while
11//! they still hash to what the user approved, checked per file on every read,
12//! so an edit after approval can't run until it is approved again — whether
13//! or not the folder watcher saw it.
14
15use std::collections::{BTreeMap, HashMap};
16use std::path::{Component, Path, PathBuf};
17use std::sync::{Arc, Mutex, OnceLock};
18
19use hmac::{Hmac, Mac};
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22
23use super::wconfig::WidgetConfigType;
24use super::widget_signature::{self as sig, Pins, WidgetPublisherPin, WidgetSignatureInfo};
25
26pub const MANIFEST_FILE: &str = "widget.json";
27pub const MANIFEST_VERSION: u32 = 1;
28pub const MAX_PACKAGE_BYTES: u64 = 50 * 1024 * 1024;
29pub const MAX_PACKAGE_FILES: usize = 2000;
30/// Palette commands and status bar items a package may add (spec §6.8).
31pub const MAX_COMMANDS: usize = 20;
32pub const MAX_STATUS_ITEMS: usize = 4;
33const APPROVALS_FILE: &str = "widget-approvals.json";
34/// Publisher → the key its first approved signed package was signed with.
35const PUBLISHERS_FILE: &str = "widget-publishers.json";
36
37/// Every permission a manifest may ask for (spec §6.4). `net:<origin>` is
38/// checked separately.
39pub const PERMISSIONS: &[&str] = &["storage", "files", "clipboard:write", "panes", "agents:read", "agents:send"];
40
41// ── The manifest (spec §5.3) ────────────────────────────────────────────────
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
44#[serde(rename_all = "lowercase")]
45#[ts(export, export_to = "../../../frontend/types/rpc/")]
46pub enum WidgetKind {
47    Sandboxed,
48    Trusted,
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct ManifestPane {
54    pub name: String,
55    #[serde(default)]
56    pub label: Option<String>,
57    #[serde(default)]
58    pub icon: Option<String>,
59    #[serde(default)]
60    pub entry: Option<String>,
61    #[serde(default)]
62    pub default_meta: Option<serde_json::Map<String, serde_json::Value>>,
63    #[serde(default)]
64    pub singleton: bool,
65}
66
67/// A command palette entry (spec §6.8).
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69pub struct ManifestCommand {
70    pub id: String,
71    pub title: String,
72    #[serde(default)]
73    pub icon: Option<String>,
74    /// The pane it runs in; the first pane when left out.
75    #[serde(default)]
76    pub pane: Option<String>,
77    #[serde(default)]
78    pub keywords: Option<String>,
79}
80
81/// A status bar item (spec §6.8).
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct ManifestStatusItem {
84    pub id: String,
85    pub text: String,
86    #[serde(default)]
87    pub icon: Option<String>,
88    #[serde(default)]
89    pub tooltip: Option<String>,
90    /// One of the package's commands, run on a click; the first pane opens
91    /// when left out.
92    #[serde(default)]
93    pub command: Option<String>,
94    #[serde(default)]
95    pub alignment: Option<StatusAlignment>,
96}
97
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
99#[serde(rename_all = "lowercase")]
100#[ts(export, export_to = "../../../frontend/types/rpc/")]
101pub enum StatusAlignment {
102    Left,
103    #[default]
104    Right,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct ManifestContributes {
109    #[serde(default)]
110    pub panes: Vec<ManifestPane>,
111    #[serde(default)]
112    pub commands: Vec<ManifestCommand>,
113    #[serde(default, rename = "statusItems")]
114    pub status_items: Vec<ManifestStatusItem>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct Manifest {
120    pub manifest_version: u32,
121    pub id: String,
122    pub name: String,
123    pub version: String,
124    #[serde(default)]
125    pub description: Option<String>,
126    #[serde(default)]
127    pub author: Option<String>,
128    #[serde(default)]
129    pub homepage: Option<String>,
130    #[serde(default)]
131    pub icon: Option<String>,
132    #[serde(default)]
133    pub default_hue: Option<i64>,
134    #[serde(default)]
135    pub kind: Option<WidgetKind>,
136    #[serde(default)]
137    pub entry: Option<String>,
138    #[serde(default)]
139    pub permissions: Vec<String>,
140    #[serde(default)]
141    pub min_agent_mux: Option<String>,
142    pub contributes: ManifestContributes,
143}
144
145impl Manifest {
146    pub fn kind(&self) -> WidgetKind {
147        self.kind.clone().unwrap_or(WidgetKind::Sandboxed)
148    }
149
150    fn default_entry(&self) -> String {
151        self.entry.clone().unwrap_or_else(|| match self.kind() {
152            WidgetKind::Sandboxed => "index.html".to_string(),
153            WidgetKind::Trusted => "index.js".to_string(),
154        })
155    }
156}
157
158fn is_slug(s: &str) -> bool {
159    !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
160}
161
162pub fn valid_id(id: &str) -> bool {
163    id.len() <= 64 && matches!(id.split_once('.'), Some((a, b)) if is_slug(a) && is_slug(b))
164}
165
166/// A Font Awesome name: lowercase letters, digits and `-`.
167fn valid_icon(icon: &Option<String>) -> bool {
168    icon.as_deref().is_none_or(|i| i.len() <= 60 && is_slug(i))
169}
170
171fn valid_semver(v: &str) -> bool {
172    let core = v.split(['-', '+']).next().unwrap_or("");
173    let parts: Vec<&str> = core.split('.').collect();
174    parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
175}
176
177/// A permission is a known name, or `net:<origin>`: `http(s)://host[:port]`,
178/// where host may start with `*.` (subdomains), never a bare `*`.
179pub fn valid_permission(p: &str) -> bool {
180    if PERMISSIONS.contains(&p) {
181        return true;
182    }
183    let Some(origin) = p.strip_prefix("net:") else {
184        return false;
185    };
186    let Some(rest) = origin.strip_prefix("https://").or_else(|| origin.strip_prefix("http://")) else {
187        return false;
188    };
189    if rest.is_empty() || rest.contains('/') || rest.contains('?') || rest.contains('#') || rest.contains('@') {
190        return false;
191    }
192    let host = rest.rsplit_once(':').map(|(h, port)| (h, port)).map_or(rest, |(h, port)| {
193        if port.bytes().all(|b| b.is_ascii_digit()) && !port.is_empty() {
194            h
195        } else {
196            "\u{0}"
197        }
198    });
199    let bare = host.strip_prefix("*.").unwrap_or(host);
200    !bare.is_empty() && !bare.contains('*') && bare.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'[' || b == b']' || b == b':')
201}
202
203/// A path inside a package: relative, `/`-separated, no `..`.
204fn valid_rel_path(p: &str) -> bool {
205    !p.is_empty()
206        && !p.starts_with('/')
207        && !p.contains('\\')
208        && Path::new(p).components().all(|c| matches!(c, Component::Normal(_)))
209}
210
211/// Check a manifest; the first problem, in words the user can act on.
212pub fn validate(m: &Manifest, folder: &str) -> Result<(), String> {
213    if m.manifest_version != MANIFEST_VERSION {
214        return Err(format!(
215            "manifestVersion {} isn't one this AgentMux reads (it reads {MANIFEST_VERSION})",
216            m.manifest_version
217        ));
218    }
219    if !valid_id(&m.id) {
220        return Err(format!("id {:?} must look like publisher.name (lowercase letters, digits, -)", m.id));
221    }
222    if m.id != folder {
223        return Err(format!("id {:?} must match its folder name {:?}", m.id, folder));
224    }
225    if m.name.trim().is_empty() || m.name.chars().count() > 60 {
226        return Err("name must be 1–60 characters".to_string());
227    }
228    if !valid_semver(&m.version) {
229        return Err(format!("version {:?} isn't semver (like 1.2.0)", m.version));
230    }
231    if m.description.as_deref().is_some_and(|d| d.chars().count() > 300) {
232        return Err("description is longer than 300 characters".to_string());
233    }
234    if let Some(h) = &m.homepage {
235        if !h.starts_with("https://") {
236            return Err("homepage must be an https:// link".to_string());
237        }
238    }
239    if let Some(h) = m.default_hue {
240        if !(0..=359).contains(&h) {
241            return Err("defaultHue must be 0–359".to_string());
242        }
243    }
244    for p in &m.permissions {
245        if !valid_permission(p) {
246            return Err(format!("unknown permission {p:?}"));
247        }
248    }
249    if m.contributes.panes.is_empty() {
250        return Err("contributes.panes must list at least one pane".to_string());
251    }
252    let mut seen = std::collections::HashSet::new();
253    for pane in &m.contributes.panes {
254        if !is_slug(&pane.name) {
255            return Err(format!("pane name {:?} must be lowercase letters, digits and -", pane.name));
256        }
257        if !seen.insert(pane.name.as_str()) {
258            return Err(format!("two panes are named {:?}", pane.name));
259        }
260        let entry = pane.entry.clone().unwrap_or_else(|| m.default_entry());
261        if !valid_rel_path(&entry) {
262            return Err(format!("entry {entry:?} must be a path inside the package"));
263        }
264        if let Some(meta) = &pane.default_meta {
265            if let Some(k) = meta.keys().find(|k| !k.starts_with("widget:")) {
266                return Err(format!("defaultMeta key {k:?} must start with \"widget:\""));
267            }
268        }
269    }
270    validate_contributions(m)
271}
272
273/// `contributes.commands` and `contributes.statusItems` (spec §6.8).
274fn validate_contributions(m: &Manifest) -> Result<(), String> {
275    let c = &m.contributes;
276    if c.commands.len() > MAX_COMMANDS {
277        return Err(format!("contributes.commands lists more than {MAX_COMMANDS} commands"));
278    }
279    let mut seen = std::collections::HashSet::new();
280    for cmd in &c.commands {
281        if !is_slug(&cmd.id) || cmd.id.len() > 60 {
282            return Err(format!("command id {:?} must be lowercase letters, digits and -", cmd.id));
283        }
284        if !seen.insert(cmd.id.as_str()) {
285            return Err(format!("two commands have the id {:?}", cmd.id));
286        }
287        if cmd.title.trim().is_empty() || cmd.title.chars().count() > 60 {
288            return Err(format!("command {:?} needs a title of 1–60 characters", cmd.id));
289        }
290        if !valid_icon(&cmd.icon) {
291            return Err(format!("command {:?}: icon must be a Font Awesome name", cmd.id));
292        }
293        if cmd.keywords.as_deref().is_some_and(|k| k.chars().count() > 200) {
294            return Err(format!("command {:?}: keywords are longer than 200 characters", cmd.id));
295        }
296        if let Some(p) = &cmd.pane {
297            if !c.panes.iter().any(|pane| &pane.name == p) {
298                return Err(format!("command {:?} runs in pane {p:?}, which the package doesn't contribute", cmd.id));
299            }
300        }
301    }
302    if c.status_items.len() > MAX_STATUS_ITEMS {
303        return Err(format!("contributes.statusItems lists more than {MAX_STATUS_ITEMS} items"));
304    }
305    let mut seen = std::collections::HashSet::new();
306    for item in &c.status_items {
307        if !is_slug(&item.id) || item.id.len() > 60 {
308            return Err(format!("status item id {:?} must be lowercase letters, digits and -", item.id));
309        }
310        if !seen.insert(item.id.as_str()) {
311            return Err(format!("two status items have the id {:?}", item.id));
312        }
313        if item.text.trim().is_empty() || item.text.chars().count() > 40 {
314            return Err(format!("status item {:?} needs a text of 1–40 characters", item.id));
315        }
316        if item.tooltip.as_deref().is_some_and(|t| t.chars().count() > 120) {
317            return Err(format!("status item {:?}: tooltip is longer than 120 characters", item.id));
318        }
319        if !valid_icon(&item.icon) {
320            return Err(format!("status item {:?}: icon must be a Font Awesome name", item.id));
321        }
322        if let Some(cmd) = &item.command {
323            if !c.commands.iter().any(|x| &x.id == cmd) {
324                return Err(format!("status item {:?} runs command {cmd:?}, which the package doesn't contribute", item.id));
325            }
326        }
327    }
328    Ok(())
329}
330
331// ── Files and the content hash (spec §5.1, §8.2) ────────────────────────────
332
333/// Every file in `dir` (relative path → SHA-256 hex), within the limits, with
334/// no symlinks. `widget.sig` at the root is left out: it signs the hash of
335/// the rest (SPEC_WIDGET_SHARING_2026_10_10.md §2.1).
336pub fn hash_files(dir: &Path) -> Result<BTreeMap<String, String>, String> {
337    let mut out = BTreeMap::new();
338    let mut total: u64 = 0;
339    let mut stack = vec![dir.to_path_buf()];
340    while let Some(d) = stack.pop() {
341        let entries = std::fs::read_dir(&d).map_err(|e| format!("can't read {}: {e}", d.display()))?;
342        for entry in entries {
343            let entry = entry.map_err(|e| e.to_string())?;
344            let ft = entry.file_type().map_err(|e| e.to_string())?;
345            let path = entry.path();
346            if ft.is_symlink() {
347                return Err(format!("{} is a link; a package can't contain links", path.display()));
348            }
349            if ft.is_dir() {
350                stack.push(path);
351                continue;
352            }
353            let rel = path
354                .strip_prefix(dir)
355                .map_err(|e| e.to_string())?
356                .components()
357                .map(|c| c.as_os_str().to_string_lossy().into_owned())
358                .collect::<Vec<_>>()
359                .join("/");
360            if rel == sig::SIG_FILE {
361                continue;
362            }
363            let bytes = std::fs::read(&path).map_err(|e| format!("can't read {rel}: {e}"))?;
364            total += bytes.len() as u64;
365            if total > MAX_PACKAGE_BYTES {
366                return Err(format!("the package is over {} MB", MAX_PACKAGE_BYTES / (1024 * 1024)));
367            }
368            out.insert(rel, hex::encode(Sha256::digest(&bytes)));
369            if out.len() > MAX_PACKAGE_FILES {
370                return Err(format!("the package has over {MAX_PACKAGE_FILES} files"));
371            }
372        }
373    }
374    Ok(out)
375}
376
377/// The package's content hash: SHA-256 over each file's path and hash, in
378/// path order. Any change to any file changes it.
379pub fn package_hash(files: &BTreeMap<String, String>) -> String {
380    let mut h = Sha256::new();
381    for (path, file_hash) in files {
382        h.update(path.as_bytes());
383        h.update([0]);
384        h.update(file_hash.as_bytes());
385        h.update([b'\n']);
386    }
387    hex::encode(h.finalize())
388}
389
390// ── Approvals (spec §8.3) ───────────────────────────────────────────────────
391
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
393pub struct Approval {
394    pub hash: String,
395    pub kind: WidgetKind,
396    pub permissions: Vec<String>,
397    /// Per-file hashes at approval: what the files route checks every read
398    /// against.
399    pub files: BTreeMap<String, String>,
400    pub approved_at: i64,
401    #[serde(default = "yes")]
402    pub enabled: bool,
403    /// The publisher key that signed it (base64), if it was signed.
404    #[serde(default)]
405    pub signer: Option<String>,
406}
407
408fn yes() -> bool {
409    true
410}
411
412pub type Approvals = BTreeMap<String, Approval>;
413
414pub fn read_approvals(path: &Path) -> Approvals {
415    std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
416}
417
418pub fn write_approvals(path: &Path, approvals: &Approvals) -> Result<(), String> {
419    if let Some(dir) = path.parent() {
420        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
421    }
422    let tmp = path.with_extension("json.tmp");
423    std::fs::write(&tmp, serde_json::to_vec_pretty(approvals).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
424    std::fs::rename(&tmp, path).map_err(|e| e.to_string())
425}
426
427// ── What the frontend sees (spec §8.1) ──────────────────────────────────────
428
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
430#[serde(rename_all = "snake_case")]
431#[ts(export, export_to = "../../../frontend/types/rpc/")]
432pub enum WidgetState {
433    Approved,
434    NeedsApproval,
435    Changed,
436    Invalid,
437    Disabled,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
441#[ts(export, export_to = "../../../frontend/types/rpc/")]
442pub struct WidgetPaneInfo {
443    /// `ext:<id>/<name>` (or a v1 widget's own `ext:<name>`).
444    pub view: String,
445    pub name: String,
446    pub label: String,
447    pub icon: String,
448    pub entry: String,
449    pub singleton: bool,
450    #[ts(type = "Record<string, unknown>")]
451    pub default_meta: serde_json::Map<String, serde_json::Value>,
452}
453
454/// A palette command, as the UI registers it (spec §6.8).
455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
456#[ts(export, export_to = "../../../frontend/types/rpc/")]
457pub struct WidgetCommandInfo {
458    pub id: String,
459    pub title: String,
460    pub icon: String,
461    /// The view of the pane it runs in.
462    pub view: String,
463    pub keywords: String,
464}
465
466/// A status bar item, before any pane of the widget updates it.
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
468#[ts(export, export_to = "../../../frontend/types/rpc/")]
469pub struct WidgetStatusItemInfo {
470    pub id: String,
471    pub text: String,
472    pub icon: String,
473    pub tooltip: Option<String>,
474    pub command: Option<String>,
475    pub alignment: StatusAlignment,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
479#[ts(export, export_to = "../../../frontend/types/rpc/")]
480pub struct WidgetPackageInfo {
481    pub id: String,
482    pub name: String,
483    pub version: String,
484    pub description: Option<String>,
485    pub author: Option<String>,
486    pub homepage: Option<String>,
487    pub icon: String,
488    #[ts(type = "number | null")]
489    pub default_hue: Option<i64>,
490    pub kind: WidgetKind,
491    pub permissions: Vec<String>,
492    /// Permissions the user granted, when approved (an update that asks for
493    /// more is `changed` until approved again).
494    pub granted: Vec<String>,
495    pub state: WidgetState,
496    pub error: Option<String>,
497    /// The current content hash (empty when the files couldn't be read).
498    pub hash: String,
499    pub panes: Vec<WidgetPaneInfo>,
500    pub commands: Vec<WidgetCommandInfo>,
501    pub status_items: Vec<WidgetStatusItemInfo>,
502    /// Who signed it, against this instance's pinned publishers.
503    pub signature: WidgetSignatureInfo,
504    /// Where its files are served, ending in `/`, while it is approved and
505    /// enabled; relative to srv's web endpoint.
506    pub files_url: Option<String>,
507    /// A v1 `widgets.json` module entry, shown as an implied package.
508    pub implied: bool,
509    pub folder: String,
510}
511
512// ── The scan ────────────────────────────────────────────────────────────────
513
514/// A package found on disk, before approval is applied.
515#[derive(Debug, Clone)]
516pub struct Found {
517    pub id: String,
518    pub dir: PathBuf,
519    pub manifest: Result<Manifest, String>,
520    pub files: Result<BTreeMap<String, String>, String>,
521    pub implied: bool,
522}
523
524/// Every folder in `widgets_dir` (a missing folder is none).
525pub fn scan(widgets_dir: &Path) -> Vec<Found> {
526    let mut out = Vec::new();
527    let Ok(entries) = std::fs::read_dir(widgets_dir) else {
528        return out;
529    };
530    for entry in entries.flatten() {
531        let Ok(ft) = entry.file_type() else { continue };
532        if !ft.is_dir() {
533            continue;
534        }
535        let dir = entry.path();
536        let folder = entry.file_name().to_string_lossy().into_owned();
537        let manifest_path = dir.join(MANIFEST_FILE);
538        if !manifest_path.exists() {
539            // A v1 widget's module folder (e.g. hello/ with only index.js) is
540            // not a package; the v1 entry in widgets.json names it instead.
541            continue;
542        }
543        let manifest = std::fs::read_to_string(&manifest_path)
544            .map_err(|e| format!("can't read widget.json: {e}"))
545            .and_then(|s| serde_json::from_str::<Manifest>(&s).map_err(|e| format!("widget.json: {e}")))
546            .and_then(|m| validate(&m, &folder).map(|()| m));
547        out.push(Found { id: folder, files: hash_files(&dir), dir, manifest, implied: false });
548    }
549    out.sort_by(|a, b| a.id.cmp(&b.id));
550    out
551}
552
553/// A v1 `widgets.json` entry with a `module` as an implied trusted package
554/// (spec §5.5): its one module file is the package.
555pub fn implied_from_v1(key: &str, entry: &WidgetConfigType, widgets_dir: &Path) -> Option<Found> {
556    let module = entry.module.as_str();
557    if module.is_empty() {
558        return None;
559    }
560    let view = entry.block_def.meta.get("view").and_then(|v| v.as_str())?.to_string();
561    let name = view.strip_prefix("ext:")?;
562    if name.contains('/') {
563        return None;
564    }
565    let raw = Path::new(module);
566    let path = if raw.is_absolute() { raw.to_path_buf() } else { widgets_dir.join(raw) };
567    let dir = path.parent()?.to_path_buf();
568    let file = path.file_name()?.to_string_lossy().into_owned();
569    let slug: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
570    let id = format!("local.{slug}");
571    let files = std::fs::read(&path)
572        .map_err(|e| format!("can't read {}: {e}", path.display()))
573        .map(|bytes| BTreeMap::from([(file.clone(), hex::encode(Sha256::digest(&bytes)))]));
574    let manifest = Manifest {
575        manifest_version: MANIFEST_VERSION,
576        id: id.clone(),
577        name: if entry.label.is_empty() { name.to_string() } else { entry.label.clone() },
578        version: "0.0.0".to_string(),
579        description: Some(format!("From widgets.json ({key})")),
580        author: None,
581        homepage: None,
582        icon: Some(entry.icon.clone()).filter(|i| !i.is_empty()),
583        default_hue: None,
584        kind: Some(WidgetKind::Trusted),
585        entry: Some(file),
586        permissions: vec![],
587        min_agent_mux: None,
588        contributes: ManifestContributes {
589            panes: vec![ManifestPane { name: slug, label: None, icon: None, entry: None, default_meta: None, singleton: false }],
590            commands: vec![],
591            status_items: vec![],
592        },
593    };
594    Some(Found { id, dir, manifest: Ok(manifest), files, implied: true })
595}
596
597/// The view a pane opens as: `ext:<id>/<name>`, or a v1 widget's own name.
598fn pane_view(found: &Found, m: &Manifest, pane: &ManifestPane, v1_view: Option<&str>) -> String {
599    match (found.implied, v1_view) {
600        (true, Some(v)) => v.to_string(),
601        _ => format!("ext:{}/{}", m.id, pane.name),
602    }
603}
604
605/// The files URL's unguessable part: HMAC(instance secret, id, hash), so a web
606/// page outside AgentMux can't name a package's files (they aren't secret,
607/// but nothing else needs them).
608pub fn files_key(secret: &str, id: &str, hash: &str) -> String {
609    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key length");
610    mac.update(b"agentmux-widget-files\0");
611    mac.update(id.as_bytes());
612    mac.update(b"\0");
613    mac.update(hash.as_bytes());
614    hex::encode(&mac.finalize().into_bytes()[..16])
615}
616
617/// Combine what's on disk with this instance's approvals and pins.
618pub fn describe(found: &Found, approvals: &Approvals, pins: &Pins, secret: &str, v1_view: Option<&str>) -> WidgetPackageInfo {
619    let folder = found.dir.display().to_string();
620    let invalid = |error: String| WidgetPackageInfo {
621        id: found.id.clone(),
622        name: found.id.clone(),
623        version: String::new(),
624        description: None,
625        author: None,
626        homepage: None,
627        icon: "puzzle-piece".to_string(),
628        default_hue: None,
629        kind: WidgetKind::Sandboxed,
630        permissions: vec![],
631        granted: vec![],
632        state: WidgetState::Invalid,
633        error: Some(error),
634        hash: String::new(),
635        panes: vec![],
636        commands: vec![],
637        status_items: vec![],
638        signature: sig::describe(&found.id, None, pins),
639        files_url: None,
640        implied: found.implied,
641        folder: folder.clone(),
642    };
643    let m = match &found.manifest {
644        Ok(m) => m,
645        Err(e) => return invalid(e.clone()),
646    };
647    let files = match &found.files {
648        Ok(f) => f,
649        Err(e) => return invalid(e.clone()),
650    };
651    let entry_missing = m.contributes.panes.iter().find_map(|p| {
652        let entry = p.entry.clone().unwrap_or_else(|| m.default_entry());
653        (!files.contains_key(&entry)).then_some(entry)
654    });
655    if let Some(entry) = entry_missing {
656        return invalid(format!("its entry {entry:?} isn't in the package"));
657    }
658    let hash = package_hash(files);
659    let signer = match sig::check(&found.dir, &m.id, &m.version, &hash) {
660        None => None,
661        Some(Ok(key)) => Some(key),
662        Some(Err(e)) => return invalid(e),
663    };
664    let kind = m.kind();
665    let approval = approvals.get(&m.id);
666    let state = match approval {
667        None => WidgetState::NeedsApproval,
668        Some(a) if a.hash != hash || a.kind != kind || a.signer != signer => WidgetState::Changed,
669        Some(a) if !a.enabled => WidgetState::Disabled,
670        Some(_) => WidgetState::Approved,
671    };
672    let icon = m.icon.clone().unwrap_or_else(|| "puzzle-piece".to_string());
673    let panes = m
674        .contributes
675        .panes
676        .iter()
677        .map(|p| WidgetPaneInfo {
678            view: pane_view(found, m, p, v1_view),
679            name: p.name.clone(),
680            label: p.label.clone().unwrap_or_else(|| m.name.clone()),
681            icon: p.icon.clone().unwrap_or_else(|| icon.clone()),
682            entry: p.entry.clone().unwrap_or_else(|| m.default_entry()),
683            singleton: p.singleton,
684            default_meta: p.default_meta.clone().unwrap_or_default(),
685        })
686        .collect::<Vec<WidgetPaneInfo>>();
687    let commands = m
688        .contributes
689        .commands
690        .iter()
691        .map(|c| {
692            let pane = c.pane.as_deref().unwrap_or(&m.contributes.panes[0].name);
693            WidgetCommandInfo {
694                id: c.id.clone(),
695                title: c.title.clone(),
696                icon: c.icon.clone().unwrap_or_else(|| icon.clone()),
697                view: panes.iter().find(|p| p.name == pane).map(|p| p.view.clone()).unwrap_or_default(),
698                keywords: c.keywords.clone().unwrap_or_default(),
699            }
700        })
701        .collect();
702    let status_items = m
703        .contributes
704        .status_items
705        .iter()
706        .map(|s| WidgetStatusItemInfo {
707            id: s.id.clone(),
708            text: s.text.clone(),
709            icon: s.icon.clone().unwrap_or_else(|| icon.clone()),
710            tooltip: s.tooltip.clone(),
711            command: s.command.clone(),
712            alignment: s.alignment.unwrap_or_default(),
713        })
714        .collect();
715    let files_url = (state == WidgetState::Approved)
716        .then(|| format!("/agentmux/widget-files/{}/{}/{}/", m.id, hash, files_key(secret, &m.id, &hash)));
717    WidgetPackageInfo {
718        id: m.id.clone(),
719        name: m.name.clone(),
720        version: m.version.clone(),
721        description: m.description.clone(),
722        author: m.author.clone(),
723        homepage: m.homepage.clone(),
724        icon,
725        default_hue: m.default_hue,
726        kind: kind.clone(),
727        permissions: if kind == WidgetKind::Trusted { vec![] } else { m.permissions.clone() },
728        granted: approval.filter(|a| a.hash == hash).map(|a| a.permissions.clone()).unwrap_or_default(),
729        state,
730        error: None,
731        hash,
732        panes,
733        commands,
734        status_items,
735        signature: sig::describe(&m.id, signer.as_deref(), pins),
736        files_url,
737        implied: found.implied,
738        folder,
739    }
740}
741
742/// The widget-bar entries an approved, enabled package adds, keyed
743/// `ext@<id>/<name>` (spec §5.4). v1 widgets already have theirs.
744pub fn widget_entries(packages: &[WidgetPackageInfo]) -> HashMap<String, WidgetConfigType> {
745    let mut out = HashMap::new();
746    for p in packages.iter().filter(|p| p.state == WidgetState::Approved && !p.implied) {
747        for pane in &p.panes {
748            let mut entry = WidgetConfigType {
749                label: pane.label.clone(),
750                icon: pane.icon.clone(),
751                description: p.description.clone().unwrap_or_default(),
752                ..Default::default()
753            };
754            entry.block_def.meta.insert("view".to_string(), serde_json::Value::String(pane.view.clone()));
755            for (k, v) in &pane.default_meta {
756                entry.block_def.meta.insert(k.clone(), v.clone());
757            }
758            out.insert(format!("ext@{}/{}", p.id, pane.name), entry);
759        }
760    }
761    out
762}
763
764// ── The service ─────────────────────────────────────────────────────────────
765
766/// srv's view of the widgets folder and this instance's approvals.
767pub struct WidgetPackages {
768    pub widgets_dir: PathBuf,
769    approvals_path: PathBuf,
770    pins_path: PathBuf,
771    secret: String,
772    inner: Mutex<Inner>,
773}
774
775#[derive(Default)]
776struct Inner {
777    found: Vec<Found>,
778    v1_views: HashMap<String, String>,
779    packages: Vec<WidgetPackageInfo>,
780    approvals: Approvals,
781    pins: Pins,
782}
783
784static SERVICE: OnceLock<Arc<WidgetPackages>> = OnceLock::new();
785
786pub fn service() -> Option<&'static Arc<WidgetPackages>> {
787    SERVICE.get()
788}
789
790/// `~/.agentmux/widgets`, shared by every channel (spec §3, decision 5).
791pub fn widgets_dir() -> PathBuf {
792    crate::srv_info::agentmux_home()
793        .unwrap_or_else(crate::backend::base::get_mux_data_dir)
794        .join("widgets")
795}
796
797impl WidgetPackages {
798    pub fn new(widgets_dir: PathBuf, data_dir: &Path, secret: String) -> Self {
799        let approvals_path = data_dir.join(APPROVALS_FILE);
800        let approvals = read_approvals(&approvals_path);
801        let pins_path = data_dir.join(PUBLISHERS_FILE);
802        let pins = sig::read_pins(&pins_path);
803        Self { widgets_dir, approvals_path, pins_path, secret, inner: Mutex::new(Inner { approvals, pins, ..Default::default() }) }
804    }
805
806    pub fn install_global(self) -> &'static Arc<WidgetPackages> {
807        SERVICE.get_or_init(|| Arc::new(self))
808    }
809
810    /// The process-wide service for tests, in one temp folder that lives as
811    /// long as the process: every test that needs the global service shares
812    /// it, so each finds its own package by id.
813    #[cfg(test)]
814    pub fn for_tests() -> &'static Arc<WidgetPackages> {
815        static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
816        let dir = DIR.get_or_init(|| tempfile::tempdir().unwrap());
817        WidgetPackages::new(dir.path().join("widgets"), &dir.path().join("data"), "test-secret-key".into()).install_global()
818    }
819
820    /// Scan again, with the v1 entries currently in the user's widgets.json.
821    pub fn rescan(&self, v1_entries: &HashMap<String, WidgetConfigType>) -> Vec<WidgetPackageInfo> {
822        let mut found = scan(&self.widgets_dir);
823        let mut v1_views = HashMap::new();
824        let mut keys: Vec<&String> = v1_entries.keys().collect();
825        keys.sort();
826        for key in keys {
827            if let Some(f) = implied_from_v1(key, &v1_entries[key], &self.widgets_dir) {
828                if found.iter().any(|x| x.id == f.id) {
829                    continue;
830                }
831                if let Some(v) = v1_entries[key].block_def.meta.get("view").and_then(|v| v.as_str()) {
832                    v1_views.insert(f.id.clone(), v.to_string());
833                }
834                found.push(f);
835            }
836        }
837        let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
838        let packages = found
839            .iter()
840            .map(|f| describe(f, &inner.approvals, &inner.pins, &self.secret, v1_views.get(&f.id).map(String::as_str)))
841            .collect::<Vec<_>>();
842        inner.found = found;
843        inner.v1_views = v1_views;
844        inner.packages = packages.clone();
845        packages
846    }
847
848    pub fn list(&self) -> Vec<WidgetPackageInfo> {
849        self.inner.lock().unwrap_or_else(|p| p.into_inner()).packages.clone()
850    }
851
852    /// Record the user's approval of exactly `hash`, signed by the key whose
853    /// fingerprint the prompt showed (`shown_signer`, "" for unsigned); the
854    /// host-only route calls this. Fails if the package or its signature
855    /// changed since the prompt was shown: `widget.sig` isn't in the hash, so
856    /// it is checked here (SPEC_WIDGET_SHARING_2026_10_10.md §2.3).
857    pub fn approve(&self, id: &str, hash: &str, shown_signer: &str) -> Result<(), String> {
858        let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
859        let found = inner.found.iter().find(|f| f.id == id).ok_or("no such widget")?;
860        let m = found.manifest.as_ref().map_err(|e| e.clone())?;
861        let files = found.files.as_ref().map_err(|e| e.clone())?;
862        if package_hash(files) != hash {
863            return Err("the widget changed since you were asked; review it again".to_string());
864        }
865        let signer = match sig::check(&found.dir, &m.id, &m.version, hash) {
866            None => None,
867            Some(Ok(key)) => Some(key),
868            Some(Err(e)) => return Err(e),
869        };
870        if signer.as_deref().map(sig::fingerprint).unwrap_or_default() != shown_signer {
871            return Err("the widget's signature changed since you were asked; review it again".to_string());
872        }
873        // The first signed package of a publisher pins it to its key; a
874        // later one signed otherwise doesn't move the pin
875        // (SPEC_WIDGET_SHARING_2026_10_10.md §2.2).
876        let publisher = sig::publisher_of(id).to_string();
877        let pin = signer.clone().filter(|_| !inner.pins.contains_key(&publisher));
878        let approval = Approval {
879            hash: hash.to_string(),
880            kind: m.kind(),
881            permissions: if m.kind() == WidgetKind::Trusted { vec![] } else { m.permissions.clone() },
882            files: files.clone(),
883            approved_at: chrono::Utc::now().timestamp(),
884            enabled: true,
885            signer,
886        };
887        if let Some(key) = pin {
888            inner.pins.insert(publisher, key);
889            sig::write_pins(&self.pins_path, &inner.pins)?;
890        }
891        inner.approvals.insert(id.to_string(), approval);
892        write_approvals(&self.approvals_path, &inner.approvals)
893    }
894
895    /// Who signed a package, against this instance's pins.
896    pub fn describe_signature(&self, id: &str, signer: Option<&str>) -> WidgetSignatureInfo {
897        sig::describe(id, signer, &self.inner.lock().unwrap_or_else(|p| p.into_inner()).pins)
898    }
899
900    /// An approved sandboxed package's approved bytes, for a bundle export
901    /// (SPEC_WIDGET_SHARING_2026_10_10.md §3.4): every file hashed again
902    /// against the approval, never a newer copy on disk, and its
903    /// `widget.sig` when it still names the approved signer.
904    pub fn approved_package(&self, id: &str) -> Result<(String, BTreeMap<String, Vec<u8>>), String> {
905        let (dir, approval) = {
906            let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
907            let a = inner.approvals.get(id).filter(|a| a.enabled).cloned().ok_or(format!("{id} isn't installed and turned on"))?;
908            let dir = inner.found.iter().find(|f| f.id == id && !f.implied).map(|f| f.dir.clone()).ok_or(format!("{id} isn't a widget package"))?;
909            (dir, a)
910        };
911        if approval.kind != WidgetKind::Sandboxed {
912            return Err(format!("{id} is a trusted widget; bundles carry sandboxed widgets only"));
913        }
914        let mut files = BTreeMap::new();
915        for (path, file_hash) in &approval.files {
916            let bytes = std::fs::read(dir.join(path)).map_err(|e| format!("{id}: can't read {path}: {e}"))?;
917            if hex::encode(Sha256::digest(&bytes)) != *file_hash {
918                return Err(format!("{id} changed since you approved it; approve it again first"));
919            }
920            files.insert(path.clone(), bytes);
921        }
922        if let Some(signer) = &approval.signer {
923            let text = std::fs::read(dir.join(sig::SIG_FILE)).map_err(|_| format!("{id} lost its signature since you approved it"))?;
924            let manifest: Manifest = serde_json::from_slice(files.get(MANIFEST_FILE).ok_or("no widget.json")?).map_err(|e| e.to_string())?;
925            if sig::check(&dir, id, &manifest.version, &approval.hash) != Some(Ok(signer.clone())) {
926                return Err(format!("{id}'s signature changed since you approved it"));
927            }
928            files.insert(sig::SIG_FILE.to_string(), text);
929        }
930        Ok((approval.hash, files))
931    }
932
933    /// The publishers this instance has pinned to a key, for Settings.
934    pub fn publishers(&self) -> Vec<WidgetPublisherPin> {
935        let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
936        inner.pins.iter().map(|(publisher, key)| WidgetPublisherPin { publisher: publisher.clone(), fingerprint: sig::fingerprint(key) }).collect()
937    }
938
939    /// Forget a publisher's key (the user's **Forget key**, through the host
940    /// route): its next signed package pins it again.
941    pub fn forget_publisher(&self, publisher: &str) -> Result<(), String> {
942        let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
943        if inner.pins.remove(publisher).is_none() {
944            return Err(format!("no key is kept for {publisher:?}"));
945        }
946        sig::write_pins(&self.pins_path, &inner.pins)
947    }
948
949    pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), String> {
950        let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
951        let a = inner.approvals.get_mut(id).ok_or("that widget isn't approved")?;
952        a.enabled = enabled;
953        write_approvals(&self.approvals_path, &inner.approvals)
954    }
955
956    /// Forget a package's approval (uninstall does this; so does a v1 entry
957    /// leaving widgets.json, at the next rescan's caller's choice).
958    pub fn forget(&self, id: &str) -> Result<(), String> {
959        let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
960        inner.approvals.remove(id);
961        write_approvals(&self.approvals_path, &inner.approvals)
962    }
963
964    pub fn package_dir(&self, id: &str) -> Option<(PathBuf, bool)> {
965        let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
966        inner.found.iter().find(|f| f.id == id).map(|f| (f.dir.clone(), f.implied))
967    }
968
969    /// The bytes of `path` in package `id`, if `hash` and `key` name its
970    /// approved version and the file on disk still matches that approval
971    /// (hashed now, not trusted from the last scan).
972    pub fn read_file(&self, id: &str, hash: &str, key: &str, path: &str) -> Result<Vec<u8>, FileError> {
973        if !valid_rel_path(path) {
974            return Err(FileError::NotFound);
975        }
976        if !agentmux_common::secret_eq::secret_eq(key.as_bytes(), files_key(&self.secret, id, hash).as_bytes()) {
977            return Err(FileError::NotFound);
978        }
979        let (dir, file_hash) = {
980            let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
981            let approval = inner.approvals.get(id).filter(|a| a.hash == hash && a.enabled).ok_or(FileError::NotFound)?;
982            let file_hash = approval.files.get(path).cloned().ok_or(FileError::NotFound)?;
983            let dir = inner.found.iter().find(|f| f.id == id).map(|f| f.dir.clone()).ok_or(FileError::NotFound)?;
984            (dir, file_hash)
985        };
986        let full = dir.join(path);
987        // No links anywhere on the way: the approved bytes are the package's
988        // own, not wherever a link points now.
989        let mut p = dir.clone();
990        for c in Path::new(path).components() {
991            p.push(c);
992            if std::fs::symlink_metadata(&p).map(|m| m.file_type().is_symlink()).unwrap_or(true) {
993                return Err(FileError::NotFound);
994            }
995        }
996        let bytes = std::fs::read(&full).map_err(|_| FileError::NotFound)?;
997        if hex::encode(Sha256::digest(&bytes)) != file_hash {
998            return Err(FileError::Changed);
999        }
1000        Ok(bytes)
1001    }
1002}
1003
1004// ── Keeping everyone current ────────────────────────────────────────────────
1005
1006/// Scan again, merge the widget bar again, and tell every UI.
1007pub fn refresh(
1008    config_watcher: &Arc<super::wconfig::ConfigState>,
1009    event_bus: &Arc<super::eventbus::EventBus>,
1010    broker: &super::mps::Broker,
1011) -> Vec<WidgetPackageInfo> {
1012    let Some(svc) = service() else {
1013        return vec![];
1014    };
1015    let packages = svc.rescan(&super::user_widgets::last_v1_entries());
1016    super::user_widgets::recompute(config_watcher);
1017    super::config_watcher_fs::broadcast_full_config(config_watcher, event_bus);
1018    publish(broker, &packages);
1019    packages
1020}
1021
1022/// `refresh` on a blocking thread: a rescan reads and hashes every file of
1023/// every package, which mustn't stall an async worker.
1024pub async fn refresh_off_thread(
1025    config_watcher: &Arc<super::wconfig::ConfigState>,
1026    event_bus: &Arc<super::eventbus::EventBus>,
1027    broker: &Arc<super::mps::Broker>,
1028) -> Vec<WidgetPackageInfo> {
1029    let (c, e, b) = (config_watcher.clone(), event_bus.clone(), broker.clone());
1030    tokio::task::spawn_blocking(move || refresh(&c, &e, &b)).await.unwrap_or_default()
1031}
1032
1033/// Tell every UI the package list (Settings → Widgets and the loader follow it).
1034pub fn publish(broker: &super::mps::Broker, packages: &[WidgetPackageInfo]) {
1035    broker.publish_event::<crate::backend::mps_events::WidgetPackages>(&crate::backend::mps_events::WidgetPackagesEvent { packages: packages.to_vec() }, vec![], 0);
1036}
1037
1038/// Start the service: scan, merge, and watch the widgets folder.
1039pub fn start(
1040    data_dir: &Path,
1041    secret: String,
1042    pool: Arc<super::fs_watch::FsWatchPool>,
1043    config_watcher: Arc<super::wconfig::ConfigState>,
1044    event_bus: Arc<super::eventbus::EventBus>,
1045    broker: Arc<super::mps::Broker>,
1046) {
1047    let data_dir = if data_dir.as_os_str().is_empty() { super::base::get_mux_data_dir() } else { data_dir.to_path_buf() };
1048    let data_dir = data_dir.as_path();
1049    let dir = widgets_dir();
1050    if let Err(e) = std::fs::create_dir_all(&dir) {
1051        tracing::warn!(dir = %dir.display(), error = %e, "can't create the widgets folder; widget packages off");
1052        return;
1053    }
1054    WidgetPackages::new(dir.clone(), data_dir, secret).install_global();
1055    let packages = refresh(&config_watcher, &event_bus, &broker);
1056    tracing::info!(dir = %dir.display(), count = packages.len(), "widget packages scanned");
1057
1058    let mut events = pool.events();
1059    let _sub = pool.subscribe_dir_recursive(&dir);
1060    let watched = dir.canonicalize().unwrap_or(dir);
1061    tokio::spawn(async move {
1062        let _sub = _sub;
1063        loop {
1064            match events.recv().await {
1065                Ok(ev) if ev.path.starts_with(&watched) => {}
1066                Ok(_) => continue,
1067                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1068                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
1069            }
1070            // Coalesce a burst (an editor's save, a copy of a whole folder).
1071            tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1072            while events.try_recv().is_ok() {}
1073            refresh_off_thread(&config_watcher, &event_bus, &broker).await;
1074        }
1075    });
1076}
1077
1078#[derive(Debug, PartialEq)]
1079pub enum FileError {
1080    NotFound,
1081    /// The file no longer matches what the user approved.
1082    Changed,
1083}
1084
1085/// The content type a widget file is served with.
1086pub fn content_type(path: &str) -> &'static str {
1087    let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
1088    match ext.as_str() {
1089        "html" | "htm" => "text/html; charset=utf-8",
1090        "js" | "mjs" => "text/javascript; charset=utf-8",
1091        "css" => "text/css; charset=utf-8",
1092        "json" | "map" => "application/json",
1093        "svg" => "image/svg+xml",
1094        "png" => "image/png",
1095        "jpg" | "jpeg" => "image/jpeg",
1096        "gif" => "image/gif",
1097        "webp" => "image/webp",
1098        "ico" => "image/x-icon",
1099        "woff" => "font/woff",
1100        "woff2" => "font/woff2",
1101        "ttf" => "font/ttf",
1102        "wasm" => "application/wasm",
1103        "txt" | "md" => "text/plain; charset=utf-8",
1104        "mp3" => "audio/mpeg",
1105        "wav" => "audio/wav",
1106        "mp4" => "video/mp4",
1107        "webm" => "video/webm",
1108        _ => "application/octet-stream",
1109    }
1110}
1111
1112/// The policy every widget file is served with (spec §6.1).
1113pub const WIDGET_CSP: &str = "default-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; \
1114style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; \
1115connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'";
1116
1117// ── Install and uninstall (spec §8.4) ───────────────────────────────────────
1118
1119/// Copy the package at `source` (a folder, a `widget.json` in one, or a
1120/// `.zip`) into the widgets folder; returns its id. Validates first; refuses
1121/// to replace an existing package unless `replace`.
1122pub fn install(widgets_dir: &Path, source: &Path, replace: bool) -> Result<String, String> {
1123    let is_zip = source.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"));
1124    let staging = tempfile::tempdir_in(widgets_dir.parent().unwrap_or(widgets_dir))
1125        .or_else(|_| tempfile::tempdir())
1126        .map_err(|e| format!("can't make a staging folder: {e}"))?;
1127    let stage = staging.path().join("pkg");
1128    if is_zip {
1129        unzip(source, &stage)?;
1130    } else {
1131        let folder = if source.file_name().is_some_and(|n| n == MANIFEST_FILE) { source.parent().unwrap_or(source) } else { source };
1132        copy_tree(folder, &stage, &mut Budget::default())?;
1133    }
1134    // A zip may hold the package at its root or in one top-level folder.
1135    let root = if stage.join(MANIFEST_FILE).exists() {
1136        stage.clone()
1137    } else {
1138        let mut dirs = std::fs::read_dir(&stage).map_err(|e| e.to_string())?.flatten().filter(|e| e.path().is_dir());
1139        match (dirs.next(), dirs.next()) {
1140            (Some(d), None) if d.path().join(MANIFEST_FILE).exists() => d.path(),
1141            _ => return Err("no widget.json at the package's top level".to_string()),
1142        }
1143    };
1144    let manifest: Manifest = serde_json::from_str(
1145        &std::fs::read_to_string(root.join(MANIFEST_FILE)).map_err(|e| format!("can't read widget.json: {e}"))?,
1146    )
1147    .map_err(|e| format!("widget.json: {e}"))?;
1148    validate(&manifest, &manifest.id)?;
1149    hash_files(&root)?;
1150    std::fs::create_dir_all(widgets_dir).map_err(|e| e.to_string())?;
1151    let dest = widgets_dir.join(&manifest.id);
1152    // A replaced version moves aside first and comes back if the new one
1153    // can't be put in place, so a failed install never leaves nothing.
1154    let old = staging.path().join("old");
1155    let replacing = dest.exists();
1156    if replacing {
1157        if !replace {
1158            return Err(format!("{} is already installed", manifest.id));
1159        }
1160        std::fs::rename(&dest, &old).map_err(|e| format!("can't replace the installed version: {e}"))?;
1161    }
1162    if let Err(e) = put_in_place(&root, &dest) {
1163        let _ = std::fs::remove_dir_all(&dest);
1164        if replacing {
1165            let _ = std::fs::rename(&old, &dest);
1166        }
1167        return Err(e);
1168    }
1169    Ok(manifest.id)
1170}
1171
1172/// Moves the staged package to `dest`, or copies it when they're on
1173/// different drives.
1174fn put_in_place(root: &Path, dest: &Path) -> Result<(), String> {
1175    if std::fs::rename(root, dest).is_ok() {
1176        return Ok(());
1177    }
1178    copy_tree(root, dest, &mut Budget::default())
1179}
1180
1181/// What a package may still take while it is copied or unpacked: the limits
1182/// apply to the bytes actually written, so neither a large folder nor a zip
1183/// whose header understates its sizes can fill the disk before hash_files.
1184#[derive(Default)]
1185struct Budget {
1186    bytes: u64,
1187    files: usize,
1188}
1189
1190impl Budget {
1191    /// Copies `from` into `to`, counting what was written against the limits.
1192    fn copy(&mut self, from: &mut impl std::io::Read, to: &Path) -> Result<(), String> {
1193        self.files += 1;
1194        if self.files > MAX_PACKAGE_FILES {
1195            return Err(format!("the package has over {MAX_PACKAGE_FILES} files"));
1196        }
1197        let left = MAX_PACKAGE_BYTES - self.bytes;
1198        let mut w = std::fs::File::create(to).map_err(|e| e.to_string())?;
1199        let written = std::io::copy(&mut from.take(left + 1), &mut w).map_err(|e| e.to_string())?;
1200        if written > left {
1201            return Err(format!("the package is over {} MB", MAX_PACKAGE_BYTES / (1024 * 1024)));
1202        }
1203        self.bytes += written;
1204        Ok(())
1205    }
1206}
1207
1208fn copy_tree(from: &Path, to: &Path, budget: &mut Budget) -> Result<(), String> {
1209    std::fs::create_dir_all(to).map_err(|e| e.to_string())?;
1210    for entry in std::fs::read_dir(from).map_err(|e| format!("can't read {}: {e}", from.display()))? {
1211        let entry = entry.map_err(|e| e.to_string())?;
1212        let ft = entry.file_type().map_err(|e| e.to_string())?;
1213        let target = to.join(entry.file_name());
1214        if ft.is_symlink() {
1215            return Err(format!("{} is a link; a package can't contain links", entry.path().display()));
1216        } else if ft.is_dir() {
1217            copy_tree(&entry.path(), &target, budget)?;
1218        } else {
1219            let mut f = std::fs::File::open(entry.path()).map_err(|e| e.to_string())?;
1220            budget.copy(&mut f, &target)?;
1221        }
1222    }
1223    Ok(())
1224}
1225
1226pub(crate) fn unzip(zip_path: &Path, to: &Path) -> Result<(), String> {
1227    let file = std::fs::File::open(zip_path).map_err(|e| format!("can't open {}: {e}", zip_path.display()))?;
1228    let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("not a zip file: {e}"))?;
1229    if archive.len() > MAX_PACKAGE_FILES {
1230        return Err(format!("the package has over {MAX_PACKAGE_FILES} files"));
1231    }
1232    let mut budget = Budget::default();
1233    for i in 0..archive.len() {
1234        let mut f = archive.by_index(i).map_err(|e| e.to_string())?;
1235        // enclosed_name refuses `..` and absolute names.
1236        let rel = f.enclosed_name().ok_or_else(|| format!("unsafe path in the zip: {}", f.name()))?;
1237        if f.is_symlink() {
1238            return Err(format!("{} is a link; a package can't contain links", f.name()));
1239        }
1240        let out = to.join(rel);
1241        if f.is_dir() {
1242            std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
1243            continue;
1244        }
1245        if let Some(parent) = out.parent() {
1246            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1247        }
1248        budget.copy(&mut f, &out)?;
1249    }
1250    Ok(())
1251}
1252
1253use std::io::Read as _;
1254
1255#[cfg(test)]
1256#[path = "widget_packages_tests.rs"]
1257mod tests;