1pub 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
51pub 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
65pub 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#[derive(Clone)]
113pub struct MainDispatcher {
114 proxy: tao::event_loop::EventLoopProxy<UserEvent>,
115}
116
117impl MainDispatcher {
118 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
137pub type CommandHandler = Arc<dyn Fn(&Ctx, &Value) -> Result<Value, String> + Send + Sync>;
139
140pub struct Ctx {
144 pub identifier: String,
145 pub emitter: EventEmitter,
146 pub fs: Option<Arc<FsState>>,
147 pub main: MainDispatcher,
148}
149
150pub 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 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 pub fn assets(mut self, root: impl Into<PathBuf>) -> Self {
184 self.asset_root = root.into();
185 self
186 }
187
188 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 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 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 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 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 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 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 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(&window)
466 .with_url("asset://localhost/")
467 .with_initialization_script(IPC_INIT)
468 .with_custom_protocol("asset".into(), move |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 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()?;
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}