Skip to main content

fude/
lib.rs

1//! # fude (筆)
2//!
3//! A minimal wry + tao shell for AI-assisted document editors. The brush:
4//! the tool you reach for to write with an AI as co-author. A lightweight
5//! alternative to Tauri for this narrow use case.
6//!
7//! - Boots a single-window webview loading `asset://localhost/` from a local
8//!   `dist/` directory
9//! - Exposes `window.__shell_ipc(cmd, args) => Promise` to the frontend
10//!   with JSON request/reply semantics, plus `window.__shell_listen(name, fn)`
11//!   for server-push events
12//! - Ships opt-in building blocks for allow-list file I/O and native dialogs
13//! - Leaves app-specific commands (PTY for AI CLI panes, ACP clients, custom
14//!   business logic) to the consumer via [`App::command`]
15//!
16//! ## Example
17//!
18//! ```no_run
19//! use fude::App;
20//!
21//! fn main() -> Result<(), Box<dyn std::error::Error>> {
22//!     App::new("com.example.my-editor")
23//!         .title("My Editor")
24//!         .assets("./dist")
25//!         .with_fs_sandbox()
26//!         .with_dialogs()
27//!         .command("ping", |_ctx, _args| Ok(serde_json::json!("pong")))
28//!         .run()
29//! }
30//! ```
31
32pub mod acp;
33pub mod acp_commands;
34mod assets;
35pub mod dialogs;
36pub mod events;
37pub mod fs;
38pub mod pty;
39pub mod sandbox;
40pub mod settings;
41pub mod shell;
42
43pub use acp::AcpAdapterConfig;
44pub use events::EventEmitter;
45pub use fs::FsState;
46pub use sandbox::{
47    app_config_dir, app_data_dir, atomic_write, ensure_scratch, is_dir_allowed, is_path_allowed,
48    new_list, safe_lock, validate_path, SharedList,
49};
50
51/// Create and allow-list `<app_data_dir>/<name>`. Convenience wrapper
52/// around [`ensure_scratch`] for app-owned scratch directories
53/// (e.g. `"temp-images"`, `"cache"`).
54///
55/// Requires [`App::with_fs_sandbox`].
56pub fn ensure_scratch_dir(ctx: &Ctx, name: &str) -> Result<std::path::PathBuf, String> {
57    let fs = ctx
58        .fs
59        .as_ref()
60        .ok_or("ensure_scratch_dir requires with_fs_sandbox")?;
61    let data = sandbox::app_data_dir(&ctx.identifier)?;
62    sandbox::ensure_scratch(&data, &fs.allowed_dirs, name)
63}
64
65/// Returns an `asset://` URL that streams an allow-listed local file to
66/// the webview. Equivalent to `window.__shell_asset_url(path)` on the
67/// frontend: path is percent-encoded and suffixed onto
68/// `asset://localhost/__file/`. The file is served only when its
69/// canonical path is in the `FsState` allow-list at request time; calling
70/// this function does not grant access on its own.
71///
72/// The file is only served if its canonical path is in the
73/// [`FsState`] allow-list at request time. Calling this function does
74/// **not** grant access on its own.
75pub fn asset_url_from_file(path: &str) -> String {
76    let mut out = String::from("asset://localhost/__file/");
77    for b in path.as_bytes() {
78        match *b {
79            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
80                out.push(*b as char);
81            }
82            _ => out.push_str(&format!("%{:02X}", b)),
83        }
84    }
85    out
86}
87
88use std::collections::HashMap;
89use std::path::{Path, PathBuf};
90use std::sync::Arc;
91
92use serde::{Deserialize, Serialize};
93use serde_json::Value;
94use tao::{
95    event::{Event, WindowEvent},
96    event_loop::{ControlFlow, EventLoopBuilder},
97    window::WindowBuilder,
98};
99use wry::WebViewBuilder;
100
101#[doc(hidden)]
102pub enum UserEvent {
103    IpcReply(String),
104    Eval(String),
105    RunOnMain(Box<dyn FnOnce() + Send>),
106}
107
108/// Dispatches a closure to run on the main (UI) thread and blocks the
109/// caller until it completes. Required on macOS for native dialogs —
110/// AppKit `NSOpenPanel` / `NSSavePanel` / `NSAlert` refuse to run from
111/// background threads.
112#[derive(Clone)]
113pub struct MainDispatcher {
114    proxy: tao::event_loop::EventLoopProxy<UserEvent>,
115}
116
117impl MainDispatcher {
118    /// Run `f` on the main thread and return its result. Blocks the
119    /// caller. Returns `Err` if the event loop has already exited.
120    pub fn run<F, R>(&self, f: F) -> Result<R, String>
121    where
122        F: FnOnce() -> R + Send + 'static,
123        R: Send + 'static,
124    {
125        let (tx, rx) = std::sync::mpsc::channel();
126        let boxed: Box<dyn FnOnce() + Send> = Box::new(move || {
127            let _ = tx.send(f());
128        });
129        self.proxy
130            .send_event(UserEvent::RunOnMain(boxed))
131            .map_err(|_| "event loop closed".to_string())?;
132        rx.recv()
133            .map_err(|_| "main thread dropped result".to_string())
134    }
135}
136
137/// Handler signature for commands registered via [`App::command`].
138pub type CommandHandler = Arc<dyn Fn(&Ctx, &Value) -> Result<Value, String> + Send + Sync>;
139
140/// Runtime context passed to every command. Provides the app identifier,
141/// the shared [`EventEmitter`] for server-push events, and the allow-list
142/// state if `with_fs_sandbox` was called.
143pub struct Ctx {
144    pub identifier: String,
145    pub emitter: EventEmitter,
146    pub fs: Option<Arc<FsState>>,
147    pub main: MainDispatcher,
148}
149
150/// Builder for a fude application.
151pub struct App {
152    identifier: String,
153    title: String,
154    asset_root: PathBuf,
155    commands: HashMap<String, CommandHandler>,
156    fs_state: Option<Arc<FsState>>,
157    pty_sessions: Option<Arc<pty::PtySessions>>,
158    acp_ctx: Option<Arc<acp_commands::AcpCtx>>,
159}
160
161impl App {
162    /// Create a new app. `identifier` is a reverse-DNS string used to locate
163    /// the config / data directory (e.g. `"com.example.editor"`).
164    pub fn new(identifier: impl Into<String>) -> Self {
165        Self {
166            identifier: identifier.into(),
167            title: String::from("fude"),
168            asset_root: assets::default_root(),
169            commands: HashMap::new(),
170            fs_state: None,
171            pty_sessions: None,
172            acp_ctx: None,
173        }
174    }
175
176    pub fn title(mut self, title: impl Into<String>) -> Self {
177        self.title = title.into();
178        self
179    }
180
181    /// Directory served as the root of `asset://localhost/`. Typically
182    /// your Vite/webpack `dist/` output.
183    pub fn assets(mut self, root: impl Into<PathBuf>) -> Self {
184        self.asset_root = root.into();
185        self
186    }
187
188    /// Register a custom IPC command.
189    pub fn command<F>(mut self, name: impl Into<String>, handler: F) -> Self
190    where
191        F: Fn(&Ctx, &Value) -> Result<Value, String> + Send + Sync + 'static,
192    {
193        self.commands.insert(name.into(), Arc::new(handler));
194        self
195    }
196
197    /// Register the built-in allow-list FS commands:
198    /// `allow_path`, `allow_dir`, `list_directory`, `read_file`,
199    /// `read_file_binary`, `write_file`, `write_file_binary`, `ensure_dir`.
200    ///
201    /// The frontend must call `allow_path` / `allow_dir` after a native
202    /// dialog selection — no file I/O is permitted until then.
203    pub fn with_fs_sandbox(mut self) -> Self {
204        let state = Arc::new(FsState {
205            allowed_paths: new_list(),
206            allowed_dirs: new_list(),
207        });
208        self.fs_state = Some(state.clone());
209
210        let s = state.clone();
211        self = self.command("allow_path", move |_ctx, args| fs::allow_path(&s, args));
212        let s = state.clone();
213        self = self.command("allow_dir", move |_ctx, args| fs::allow_dir(&s, args));
214        let s = state.clone();
215        self = self.command("list_directory", move |_ctx, args| {
216            fs::list_directory(&s, args)
217        });
218        let s = state.clone();
219        self = self.command("read_file", move |_ctx, args| fs::read_file(&s, args));
220        let s = state.clone();
221        self = self.command("read_file_binary", move |_ctx, args| {
222            fs::read_file_binary(&s, args)
223        });
224        let s = state.clone();
225        self = self.command("write_file", move |_ctx, args| fs::write_file(&s, args));
226        let s = state.clone();
227        self = self.command("write_file_binary", move |_ctx, args| {
228            fs::write_file_binary(&s, args)
229        });
230        let s = state.clone();
231        self = self.command("ensure_dir", move |_ctx, args| fs::ensure_dir(&s, args));
232        self
233    }
234
235    /// Register `load_settings` / `save_settings` — persist an arbitrary
236    /// JSON object at `<app_config_dir>/settings.json` via
237    /// [`crate::atomic_write`].
238    ///
239    /// `load_settings()` returns the parsed object, or `{}` if none saved.
240    /// `save_settings({ settings: {...} })` writes atomically. The
241    /// payload must be a JSON object; arrays / scalars are rejected.
242    pub fn with_settings(mut self) -> Self {
243        self = self.command("load_settings", |ctx, _args| settings::load(ctx));
244        self = self.command("save_settings", settings::save);
245        self
246    }
247
248    /// Register `shell_open` — opens URLs (`http`/`https`/`mailto`) or
249    /// allow-listed local file paths in the OS default application.
250    ///
251    /// File-path targets require [`App::with_fs_sandbox`] and must
252    /// already be in the allow-list (via a dialog or manual `allow_path`).
253    pub fn with_shell_open(mut self) -> Self {
254        self = self.command("shell_open", |ctx, args| {
255            shell::open(args, ctx.fs.as_deref())
256        });
257        self
258    }
259
260    /// Register native dialog commands: `dialog_open`, `dialog_save`,
261    /// `dialog_ask`, `dialog_message`.
262    pub fn with_dialogs(mut self) -> Self {
263        self = self.command("dialog_open", |ctx, args| dialogs::open(&ctx.main, args));
264        self = self.command("dialog_save", |ctx, args| dialogs::save(&ctx.main, args));
265        self = self.command("dialog_ask", |ctx, args| dialogs::ask(&ctx.main, args));
266        self = self.command("dialog_message", |ctx, args| {
267            dialogs::message(&ctx.main, args)
268        });
269        self
270    }
271
272    /// Register PTY commands for spawning CLI agents (e.g. `claude`, `codex`).
273    ///
274    /// Registers: `pty_spawn`, `pty_write`, `pty_resize`, `pty_kill` and
275    /// emits `pty:data` / `pty:exit` events.
276    ///
277    /// `allowed_tools` is the only set of binaries that may ever be spawned;
278    /// anything else is rejected. Requires [`App::with_fs_sandbox`] to have
279    /// been called — the PTY cwd must live inside an allow-listed directory.
280    pub fn with_pty(mut self, allowed_tools: &[&str]) -> Self {
281        let cfg = pty::PtyConfig {
282            allowed_tools: allowed_tools.iter().map(|s| s.to_string()).collect(),
283        };
284        let sessions = Arc::new(pty::PtySessions::new(cfg));
285        self.pty_sessions = Some(sessions.clone());
286
287        let s = sessions.clone();
288        self = self.command("pty_spawn", move |ctx, args| {
289            let fs = ctx
290                .fs
291                .as_ref()
292                .ok_or("pty_spawn requires with_fs_sandbox")?;
293            pty::spawn(&s, &fs.allowed_dirs, &ctx.emitter, args)
294        });
295        let s = sessions.clone();
296        self = self.command("pty_write", move |_ctx, args| pty::write(&s, args));
297        let s = sessions.clone();
298        self = self.command("pty_resize", move |_ctx, args| pty::resize(&s, args));
299        let s = sessions.clone();
300        self = self.command("pty_kill", move |_ctx, args| pty::kill(&s, args));
301        self
302    }
303
304    /// Register ACP (Agent Client Protocol) commands.
305    ///
306    /// Registers 11 `acp_*` commands. Requires [`App::with_fs_sandbox`] — the
307    /// agent's `fs/read_text_file` / `fs/write_text_file` calls are
308    /// intercepted and reject anything outside the user's allow-list.
309    pub fn with_acp(
310        mut self,
311        adapters: Vec<AcpAdapterConfig>,
312        client_name: impl Into<String>,
313        client_version: impl Into<String>,
314    ) -> Self {
315        let state = Arc::new(acp::AcpState::new(
316            adapters,
317            client_name.into(),
318            client_version.into(),
319        ));
320        let acp_ctx = Arc::new(acp_commands::AcpCtx::new(state));
321        self.acp_ctx = Some(acp_ctx.clone());
322
323        let c = acp_ctx.clone();
324        self = self.command("acp_get_adapter", move |_ctx, _args| {
325            acp_commands::get_adapter(&c)
326        });
327        let c = acp_ctx.clone();
328        self = self.command("acp_set_adapter", move |_ctx, args| {
329            acp_commands::set_adapter(&c, args)
330        });
331        let c = acp_ctx.clone();
332        self = self.command("acp_initialize", move |ctx, _args| {
333            acp_commands::initialize(&c, ctx.fs.as_ref(), &ctx.emitter)
334        });
335        let c = acp_ctx.clone();
336        self = self.command("acp_new_session", move |ctx, args| {
337            acp_commands::new_session(&c, ctx.fs.as_ref(), &ctx.emitter, args)
338        });
339        let c = acp_ctx.clone();
340        self = self.command("acp_prompt", move |ctx, args| {
341            acp_commands::prompt(&c, ctx.fs.as_ref(), &ctx.emitter, args)
342        });
343        let c = acp_ctx.clone();
344        self = self.command("acp_cancel", move |ctx, args| {
345            acp_commands::cancel(&c, ctx.fs.as_ref(), &ctx.emitter, args)
346        });
347        let c = acp_ctx.clone();
348        self = self.command("acp_set_model", move |ctx, args| {
349            acp_commands::set_model(&c, ctx.fs.as_ref(), &ctx.emitter, args)
350        });
351        let c = acp_ctx.clone();
352        self = self.command("acp_set_config", move |ctx, args| {
353            acp_commands::set_config(&c, ctx.fs.as_ref(), &ctx.emitter, args)
354        });
355        let c = acp_ctx.clone();
356        self = self.command("acp_list_sessions", move |ctx, args| {
357            acp_commands::list_sessions(&c, ctx.fs.as_ref(), &ctx.emitter, args)
358        });
359        let c = acp_ctx.clone();
360        self = self.command("acp_resume_session", move |ctx, args| {
361            acp_commands::resume_session(&c, ctx.fs.as_ref(), &ctx.emitter, args)
362        });
363        let c = acp_ctx.clone();
364        self = self.command("acp_shutdown", move |_ctx, _args| {
365            acp_commands::shutdown(&c)
366        });
367        self
368    }
369
370    /// Block on the event loop. Returns only when the window is closed.
371    pub fn run(self) -> Result<(), Box<dyn std::error::Error>> {
372        run(self)
373    }
374}
375
376#[derive(Deserialize)]
377struct IpcRequest {
378    id: u64,
379    cmd: String,
380    #[serde(default)]
381    args: Value,
382}
383
384#[derive(Serialize)]
385struct IpcResponse {
386    id: u64,
387    ok: bool,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    result: Option<Value>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    error: Option<String>,
392}
393
394const IPC_INIT: &str = r#"
395(() => {
396  let nextId = 1;
397  const pending = new Map();
398  const listeners = new Map();
399  window.__shell_on_reply = (payload) => {
400    try {
401      const msg = typeof payload === "string" ? JSON.parse(payload) : payload;
402      const p = pending.get(msg.id);
403      if (!p) return;
404      pending.delete(msg.id);
405      if (msg.ok) p.resolve(msg.result); else p.reject(new Error(msg.error || "ipc error"));
406    } catch (e) { console.error(e); }
407  };
408  window.__shell_on_event = (name, payload) => {
409    const set = listeners.get(name);
410    if (!set) return;
411    for (const fn of set) { try { fn(payload); } catch (e) { console.error(e); } }
412  };
413  window.__shell_ipc = (cmd, args = {}) => new Promise((resolve, reject) => {
414    const id = nextId++;
415    pending.set(id, { resolve, reject });
416    window.ipc.postMessage(JSON.stringify({ id, cmd, args }));
417  });
418  window.__shell_listen = (name, fn) => {
419    if (!listeners.has(name)) listeners.set(name, new Set());
420    listeners.get(name).add(fn);
421    return () => listeners.get(name)?.delete(fn);
422  };
423  window.__shell_asset_url = (path) => {
424    // Maps an allow-listed absolute path to an asset:// URL the webview
425    // can render directly (<img>, <video>, <iframe> src). The file is
426    // served only if its canonical path is in the FS allow-list.
427    return "asset://localhost/__file/" + encodeURIComponent(path);
428  };
429})();
430"#;
431
432fn run(app: App) -> Result<(), Box<dyn std::error::Error>> {
433    let App {
434        identifier,
435        title,
436        asset_root,
437        commands,
438        fs_state,
439        pty_sessions: _pty_sessions,
440        acp_ctx: _acp_ctx,
441    } = app;
442    let asset_root: Arc<Path> = Arc::from(asset_root.as_path());
443
444    let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
445    let proxy = event_loop.create_proxy();
446    let emitter = EventEmitter::new(proxy.clone());
447
448    let ctx = Arc::new(Ctx {
449        identifier: identifier.clone(),
450        emitter: emitter.clone(),
451        fs: fs_state,
452        main: MainDispatcher {
453            proxy: proxy.clone(),
454        },
455    });
456
457    let window = WindowBuilder::new().with_title(&title).build(&event_loop)?;
458
459    let commands = Arc::new(commands);
460    let commands_for_ipc = commands.clone();
461    let ctx_for_ipc = ctx.clone();
462    let asset_root_for_protocol = asset_root.clone();
463    let fs_for_protocol = ctx.fs.clone();
464
465    let webview = WebViewBuilder::new()
466        .with_url("asset://localhost/")
467        .with_initialization_script(IPC_INIT)
468        .with_custom_protocol("asset".into(), move |_id, req| {
469            assets::serve(
470                &asset_root_for_protocol,
471                fs_for_protocol.as_ref(),
472                req.uri().path(),
473            )
474            .map(|b| b.into())
475        })
476        .with_ipc_handler(move |req| {
477            // wry calls this handler on the main (UI) thread on macOS.
478            // Offload to a worker so command handlers can dispatch back to
479            // main (e.g. for native dialogs) without deadlocking.
480            let body: String = req.body().to_string();
481            let commands = commands_for_ipc.clone();
482            let ctx = ctx_for_ipc.clone();
483            let proxy = proxy.clone();
484            std::thread::spawn(move || {
485                let parsed: Result<IpcRequest, _> = serde_json::from_str(&body);
486                let response = match parsed {
487                    Ok(r) => {
488                        let (ok, result, error) = match commands.get(&r.cmd) {
489                            Some(handler) => match handler(&ctx, &r.args) {
490                                Ok(v) => (true, Some(v), None),
491                                Err(e) => (false, None, Some(e)),
492                            },
493                            None => (false, None, Some(format!("unknown cmd: {}", r.cmd))),
494                        };
495                        IpcResponse {
496                            id: r.id,
497                            ok,
498                            result,
499                            error,
500                        }
501                    }
502                    Err(e) => IpcResponse {
503                        id: 0,
504                        ok: false,
505                        result: None,
506                        error: Some(format!("bad ipc: {e}")),
507                    },
508                };
509                let json = serde_json::to_string(&response).unwrap_or_else(|_| "{}".into());
510                let js = format!("window.__shell_on_reply({json})");
511                let _ = proxy.send_event(UserEvent::IpcReply(js));
512            });
513        })
514        .build(&window)?;
515
516    event_loop.run(move |event, _, control_flow| {
517        *control_flow = ControlFlow::Wait;
518        match event {
519            Event::WindowEvent {
520                event: WindowEvent::CloseRequested,
521                ..
522            } => *control_flow = ControlFlow::Exit,
523            Event::UserEvent(UserEvent::IpcReply(js)) | Event::UserEvent(UserEvent::Eval(js)) => {
524                let _ = webview.evaluate_script(&js);
525            }
526            Event::UserEvent(UserEvent::RunOnMain(f)) => f(),
527            _ => {}
528        }
529    });
530}