1#![cfg_attr(not(feature = "app"), allow(dead_code, unused_imports))]
31
32use crate::open::{Access, folder_of, page_missing};
33use geop_core_math::geop_error::{GeopError, GeopResult};
34use sha2::Digest;
35use std::{
36 ffi::OsString,
37 io::{Read, Write},
38 net::{SocketAddr, TcpStream},
39 path::{Path, PathBuf},
40 time::Duration,
41};
42
43#[derive(clap::Args)]
44pub struct AppArgs {
45 pub path: Option<PathBuf>,
48}
49
50fn claim_file(root: &Path) -> PathBuf {
55 let digest = sha2::Sha256::digest(root.as_os_str().as_encoded_bytes());
56 let name: String = digest[..8].iter().map(|b| format!("{b:02x}")).collect();
57 let dir = std::env::var_os("XDG_RUNTIME_DIR")
58 .map(PathBuf::from)
59 .filter(|d| d.is_dir())
60 .unwrap_or_else(std::env::temp_dir);
61 dir.join(format!("geop-app-{name}.json"))
62}
63
64pub struct Claim {
69 file: PathBuf,
70 text: String,
71}
72
73impl Claim {
74 pub fn new(root: &Path, access: &Access) -> GeopResult<Self> {
76 let file = claim_file(root);
77 let text = serde_json::json!({ "port": access.port, "token": access.token }).to_string();
78 let failed = |e: std::io::Error| {
79 GeopError::new(format!(
80 "claiming {}: writing {}: {e}",
81 root.display(),
82 file.display()
83 ))
84 };
85 let _ = std::fs::remove_file(&file);
86 let mut options = std::fs::OpenOptions::new();
87 options.write(true).create_new(true);
88 #[cfg(unix)]
89 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
90 options
91 .open(&file)
92 .and_then(|mut f| f.write_all(text.as_bytes()))
93 .map_err(failed)?;
94 Ok(Self { file, text })
95 }
96
97 pub fn release(self) {
99 if std::fs::read_to_string(&self.file).is_ok_and(|t| t == self.text) {
100 let _ = std::fs::remove_file(&self.file);
101 }
102 }
103}
104
105pub fn raise_running(root: &Path) -> bool {
108 let Ok(text) = std::fs::read_to_string(claim_file(root)) else {
109 return false;
110 };
111 let Ok(claim) = serde_json::from_str::<serde_json::Value>(&text) else {
112 return false;
113 };
114 let (Some(port), Some(token)) = (claim["port"].as_u64(), claim["token"].as_str()) else {
115 return false;
116 };
117 let Ok(port) = u16::try_from(port) else {
118 return false;
119 };
120 let raised = || -> std::io::Result<bool> {
122 let address = SocketAddr::from(([127, 0, 0, 1], port));
123 let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2))?;
124 stream.set_read_timeout(Some(Duration::from_secs(5)))?;
125 write!(
126 stream,
127 "POST /api/raise HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
128 Authorization: Bearer {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
129 )?;
130 let mut status = [0u8; 12];
131 stream.read_exact(&mut status)?;
132 Ok(status.starts_with(b"HTTP/1.1 200") || status.starts_with(b"HTTP/1.0 200"))
133 };
134 raised().unwrap_or(false)
135}
136
137fn config_dir() -> Option<PathBuf> {
141 let var = |name| std::env::var_os(name).map(PathBuf::from);
142 let base = if cfg!(windows) {
143 var("APPDATA")
144 } else if cfg!(target_os = "macos") {
145 var("HOME").map(|h| h.join("Library/Application Support"))
146 } else {
147 var("XDG_CONFIG_HOME").or_else(|| var("HOME").map(|h| h.join(".config")))
148 };
149 base.map(|b| b.join("geop"))
150}
151
152#[derive(Clone, Copy, Debug, PartialEq)]
154pub struct WindowState {
155 pub width: f64,
156 pub height: f64,
157 pub maximized: bool,
158}
159
160impl Default for WindowState {
161 fn default() -> Self {
162 Self {
163 width: 1280.0,
164 height: 800.0,
165 maximized: false,
166 }
167 }
168}
169
170impl WindowState {
171 const SMALLEST: f64 = 480.0;
173
174 fn file() -> Option<PathBuf> {
175 config_dir().map(|d| d.join("app-window.json"))
176 }
177
178 fn read(text: &str) -> Option<Self> {
180 let value: serde_json::Value = serde_json::from_str(text).ok()?;
181 let state = Self {
182 width: value["width"].as_f64()?,
183 height: value["height"].as_f64()?,
184 maximized: value["maximized"].as_bool().unwrap_or(false),
185 };
186 let usable = |x: f64| x.is_finite() && (Self::SMALLEST..=100_000.0).contains(&x);
187 (usable(state.width) && usable(state.height)).then_some(state)
188 }
189
190 fn text(&self) -> String {
191 serde_json::json!({
192 "width": self.width,
193 "height": self.height,
194 "maximized": self.maximized,
195 })
196 .to_string()
197 }
198
199 pub fn load() -> Self {
201 (Self::file().and_then(|f| std::fs::read_to_string(f).ok()))
202 .and_then(|t| Self::read(&t))
203 .unwrap_or_default()
204 }
205
206 pub fn store(&self) {
208 if let Some(file) = Self::file() {
209 let _ = file.parent().map(std::fs::create_dir_all);
210 let _ = std::fs::write(file, self.text());
211 }
212 }
213}
214
215#[cfg_attr(not(feature = "desktop"), allow(dead_code))]
222pub fn desktop_args(args: impl IntoIterator<Item = OsString>) -> Vec<OsString> {
223 let mut args = (args.into_iter())
224 .filter(|a| !a.as_encoded_bytes().starts_with(b"-psn_"))
225 .collect::<Vec<_>>();
226 if args.get(1).is_none_or(|first| first != "app") {
227 args.insert(1.min(args.len()), "app".into());
228 }
229 args
230}
231
232fn last_folder_file() -> Option<PathBuf> {
234 config_dir().map(|d| d.join("app-folder.txt"))
235}
236
237fn keep_last_folder(folder: &Path) {
239 if let Some(file) = last_folder_file() {
240 let _ = file.parent().map(std::fs::create_dir_all);
241 let _ = std::fs::write(file, folder.as_os_str().as_encoded_bytes());
242 }
243}
244
245#[cfg_attr(not(feature = "desktop"), allow(dead_code))]
250fn default_folder(last: Option<PathBuf>, home: Option<PathBuf>) -> Option<PathBuf> {
251 if let Some(last) = last.filter(|l| l.is_dir()) {
252 return Some(last);
253 }
254 let folder = home?.join("Documents").join("Geop");
255 std::fs::create_dir_all(&folder).ok()?;
256 Some(folder)
257}
258
259fn is_page(url: &str, access: &Access) -> bool {
264 let origin = |host: &str| format!("http://{host}:{}/", access.port);
265 url.starts_with(&origin("127.0.0.1"))
266 || url.starts_with(&origin("localhost"))
267 || ["blob:", "data:", "about:"]
268 .iter()
269 .any(|s| url.starts_with(s))
270}
271
272fn is_for_browser(url: &str) -> bool {
274 ["https://", "http://", "mailto:"]
275 .iter()
276 .any(|s| url.starts_with(s))
277}
278
279#[cfg(not(feature = "app"))]
280pub fn run(_: &AppArgs) -> GeopResult<()> {
281 Err(GeopError::new(
282 "this geop was built without its app. Build it with the app in: \
283 `npm --prefix web run build:open`, then \
284 `cargo build --release -p geop-cad-cli --features frontend,app` \
285 (on Linux, with WebKitGTK: `sudo apt install libwebkit2gtk-4.1-dev`). \
286 Or edit the folder in your browser: `geop open`",
287 ))
288}
289
290#[cfg(feature = "app")]
291pub fn run(args: &AppArgs) -> GeopResult<()> {
292 if let Some(missing) = page_missing() {
293 return Err(missing);
294 }
295 let path = match &args.path {
296 Some(path) => path.clone(),
297 None => {
298 let kept = (last_folder_file())
299 .and_then(|f| std::fs::read_to_string(f).ok())
300 .map(PathBuf::from);
301 let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" });
302 let chosen = cfg!(feature = "desktop")
303 .then(|| default_folder(kept, home.map(PathBuf::from)))
304 .flatten();
305 match chosen
306 .or_else(|| window::pick_folder(&std::env::current_dir().unwrap_or_default()))
307 {
308 Some(folder) => folder,
309 None => return Ok(()),
310 }
311 }
312 };
313 let (folder, active) = folder_of(&path)?;
314 keep_last_folder(folder.root());
315 if raise_running(folder.root()) {
316 eprintln!(
317 "geop is editing {} already: brought its window to the front",
318 folder.root().display()
319 );
320 return Ok(());
321 }
322 window::run(folder, active)
323}
324
325#[cfg(feature = "app")]
327mod window {
328 use super::{Claim, WindowState, is_for_browser, is_page};
329 use crate::open::{Serving, open_browser};
330 use geop_cad_base::disk::Folder;
331 use geop_core_math::geop_error::{GeopError, GeopResult};
332 use std::{
333 path::{Path, PathBuf},
334 rc::Rc,
335 sync::Mutex,
336 };
337 use tao::{
338 dpi::LogicalSize,
339 event::{Event, WindowEvent},
340 event_loop::{ControlFlow, EventLoopBuilder},
341 window::{Icon, Window, WindowBuilder},
342 };
343 use wry::{NewWindowResponse, WebContext, WebViewBuilder};
344
345 #[derive(Debug)]
347 enum AppEvent {
348 Raise,
350 OpenFolder,
352 }
353
354 const IN_APP: &str = r#"
359window.geopApp = { openFolder: () => window.ipc.postMessage("open-folder") };
360{
361 const say = (text) => window.ipc.postMessage("error:" + text);
362 const original = console.error.bind(console);
363 console.error = (...args) => { original(...args); say(args.map(String).join(" ")); };
364 window.addEventListener("error", (e) => say(`${e.message} (${e.filename}:${e.lineno})`));
365 window.addEventListener("unhandledrejection", (e) => say(`unhandled: ${e.reason?.stack ?? e.reason}`));}
366"#;
367
368 pub fn pick_folder(dir: &Path) -> Option<PathBuf> {
370 rfd::FileDialog::new()
371 .set_title("Open a folder in geop")
372 .set_directory(dir)
373 .pick_folder()
374 }
375
376 #[cfg(target_os = "macos")]
380 fn mac_menu() -> GeopResult<muda::Menu> {
381 use muda::{Menu, PredefinedMenuItem as Item, Submenu};
382 let failed = |e: muda::Error| GeopError::new(format!("making the menu: {e}"));
383 let app = Submenu::with_items(
384 "Geop",
385 true,
386 &[
387 &Item::about(None, None),
388 &Item::separator(),
389 &Item::hide(None),
390 &Item::hide_others(None),
391 &Item::show_all(None),
392 &Item::separator(),
393 &Item::quit(None),
394 ],
395 )
396 .map_err(failed)?;
397 let edit = Submenu::with_items(
398 "Edit",
399 true,
400 &[
401 &Item::cut(None),
402 &Item::copy(None),
403 &Item::paste(None),
404 &Item::select_all(None),
405 ],
406 )
407 .map_err(failed)?;
408 let window = Submenu::with_items(
409 "Window",
410 true,
411 &[
412 &Item::minimize(None),
413 &Item::maximize(None),
414 &Item::close_window(None),
415 ],
416 )
417 .map_err(failed)?;
418 let menu = Menu::new();
419 menu.append_items(&[&app, &edit, &window]).map_err(failed)?;
420 menu.init_for_nsapp();
421 Ok(menu)
422 }
423
424 fn webview_data() -> Option<PathBuf> {
429 cfg!(windows)
430 .then(|| std::env::var_os("LOCALAPPDATA"))
431 .flatten()
432 .map(|local| PathBuf::from(local).join("Geop").join("WebView2"))
433 }
434
435 fn icon() -> Option<Icon> {
437 const LOGO: &str = include_str!("../../../web/src/assets/logo.svg");
438 const SIZE: u32 = 256;
439 let tree = resvg::usvg::Tree::from_str(LOGO, &Default::default()).ok()?;
440 let mut pixmap = resvg::tiny_skia::Pixmap::new(SIZE, SIZE)?;
441 let scale = SIZE as f32 / tree.size().width().max(tree.size().height());
442 let transform = resvg::tiny_skia::Transform::from_scale(scale, scale);
443 resvg::render(&tree, transform, &mut pixmap.as_mut());
444 let rgba = (pixmap.pixels().iter())
445 .flat_map(|p| {
446 let c = p.demultiply();
447 [c.red(), c.green(), c.blue(), c.alpha()]
448 })
449 .collect();
450 Icon::from_rgba(rgba, SIZE, SIZE).ok()
451 }
452
453 fn save_as(window: &Window, root: &Path, suggested: &Path) -> Option<PathBuf> {
456 let mut dialog = rfd::FileDialog::new()
457 .set_title("Save")
458 .set_directory(root)
459 .set_parent(window);
460 if let Some(name) = suggested.file_name().and_then(|n| n.to_str()) {
461 dialog = dialog.set_file_name(name);
462 }
463 dialog.save_file()
464 }
465
466 fn start_app(folder: &Path) -> GeopResult<()> {
469 let exe = std::env::current_exe()
470 .map_err(|e| GeopError::new(format!("finding geop itself: {e}")))?;
471 std::process::Command::new(exe)
472 .arg("app")
473 .arg(folder)
474 .stdin(std::process::Stdio::null())
475 .spawn()
476 .map(drop)
477 .map_err(|e| GeopError::new(format!("starting geop app {}: {e}", folder.display())))
478 }
479
480 pub fn run(folder: Folder, active: Option<String>) -> GeopResult<()> {
481 let event_loop = EventLoopBuilder::<AppEvent>::with_user_event().build();
482 #[cfg(target_os = "macos")]
483 let menu = mac_menu()?;
484 let proxy = Mutex::new(event_loop.create_proxy());
485 let raise = Box::new(move || {
486 let _ = proxy.lock().map(|p| p.send_event(AppEvent::Raise));
487 });
488 let serving = Serving::start(folder, active, None, Some(raise))?;
489 let access = serving.access().clone();
490 let root = serving.root().to_path_buf();
491 let mut claim = Some(Claim::new(&root, &access)?);
492 std::thread::spawn(move || serving.run());
493
494 let state = WindowState::load();
495 let window = WindowBuilder::new()
496 .with_title("geop")
497 .with_inner_size(LogicalSize::new(state.width, state.height))
498 .with_maximized(state.maximized)
499 .with_window_icon(icon())
500 .build(&event_loop)
501 .map_err(|e| GeopError::new(format!("opening a window: {e}")))?;
502 let window = Rc::new(window);
503
504 let mut context = WebContext::new(webview_data());
505 let builder = WebViewBuilder::new_with_web_context(&mut context)
506 .with_url(access.url())
507 .with_initialization_script(IN_APP)
508 .with_clipboard(true)
509 .with_devtools(cfg!(debug_assertions))
510 .with_document_title_changed_handler({
511 let window = window.clone();
512 move |title| window.set_title(&title)
513 })
514 .with_navigation_handler(move |url| {
515 if is_page(&url, &access) {
516 return true;
517 }
518 if is_for_browser(&url) {
519 let _ = open_browser(&url);
520 }
521 false
522 })
523 .with_new_window_req_handler(|url, _| {
524 if is_for_browser(&url) {
525 let _ = open_browser(&url);
526 }
527 NewWindowResponse::Deny
528 })
529 .with_download_started_handler({
530 let (window, root) = (window.clone(), root.clone());
531 move |_url, path| match save_as(&window, &root, path) {
532 Some(chosen) => {
533 *path = chosen;
534 true
535 }
536 None => false,
537 }
538 })
539 .with_ipc_handler({
540 let proxy = event_loop.create_proxy();
541 move |request| {
542 let message = request.body();
543 if message == "open-folder" {
544 let _ = proxy.send_event(AppEvent::OpenFolder);
545 } else if let Some(error) = message.strip_prefix("error:") {
546 eprintln!("the editor's page: {error}");
547 }
548 }
549 });
550 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
551 let webview = {
552 use tao::platform::unix::WindowExtUnix;
553 use wry::WebViewBuilderExtUnix;
554 let vbox = (window.default_vbox())
555 .ok_or_else(|| GeopError::new("opening a window: it has no GTK box"))?;
556 builder.build_gtk(vbox)
557 };
558 #[cfg(any(target_os = "windows", target_os = "macos"))]
559 let webview = builder.build(&*window);
560 let webview = webview
561 .map_err(|e| GeopError::new(format!("showing the editor in the window: {e}")))?;
562 eprintln!("geop is editing {}", root.display());
563
564 event_loop.run(move |event, _, control_flow| {
565 *control_flow = ControlFlow::Wait;
566 let _ = (&webview, &context);
568 #[cfg(target_os = "macos")]
569 let _ = &menu;
570 match event {
571 Event::UserEvent(AppEvent::Raise) => {
572 window.set_minimized(false);
573 window.set_visible(true);
574 window.set_focus();
575 }
576 Event::UserEvent(AppEvent::OpenFolder) => {
577 if let Some(folder) = pick_folder(&root)
578 && let Err(e) = start_app(&folder)
579 {
580 eprintln!("{}", e.root_message());
581 }
582 }
583 Event::WindowEvent {
584 event: WindowEvent::CloseRequested,
585 ..
586 } => {
587 let size = window.inner_size().to_logical::<f64>(window.scale_factor());
588 let maximized = window.is_maximized();
589 let kept = WindowState::load();
591 let (width, height) = if maximized {
592 (kept.width, kept.height)
593 } else {
594 (size.width, size.height)
595 };
596 WindowState {
597 width,
598 height,
599 maximized,
600 }
601 .store();
602 *control_flow = ControlFlow::Exit;
603 }
604 Event::LoopDestroyed => {
605 if let Some(claim) = claim.take() {
606 claim.release();
607 }
608 }
609 _ => {}
610 }
611 })
612 }
613}
614
615#[cfg(test)]
618fn serve_headless(
619 folder: geop_cad_base::disk::Folder,
620 raise: crate::open::Raise,
621) -> GeopResult<(Access, Claim)> {
622 let serving = crate::open::Serving::start(folder, None, None, Some(raise))?;
623 let access = serving.access().clone();
624 let claim = Claim::new(serving.root(), &access)?;
625 std::thread::spawn(move || serving.run());
626 Ok((access, claim))
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use std::sync::{
633 Arc,
634 atomic::{AtomicUsize, Ordering},
635 };
636
637 fn scratch(test: &str) -> PathBuf {
638 let dir = std::env::temp_dir().join(format!("geop-app-{test}-{}", std::process::id()));
639 let _ = std::fs::remove_dir_all(&dir);
640 std::fs::create_dir_all(&dir).unwrap();
641 dir
642 }
643
644 #[test]
647 fn what_a_double_click_starts_is_the_app() {
648 let given = |args: &[&str]| -> Vec<String> {
649 desktop_args(args.iter().map(OsString::from))
650 .iter()
651 .map(|a| a.to_string_lossy().into_owned())
652 .collect()
653 };
654 assert_eq!(given(&["geop"]), ["geop", "app"]);
655 assert_eq!(given(&["geop", "part.geop"]), ["geop", "app", "part.geop"]);
656 assert_eq!(given(&["geop", "app", "dir"]), ["geop", "app", "dir"]);
657 assert_eq!(given(&["Geop", "-psn_0_12345"]), ["Geop", "app"]);
658 }
659
660 #[test]
663 fn the_folder_opened_with_none_asked_for() {
664 let home = scratch("home");
665 let made = home.join("Documents").join("Geop");
666 assert_eq!(default_folder(None, Some(home.clone())), Some(made.clone()));
667 assert!(made.is_dir());
668 let last = scratch("last");
669 assert_eq!(
670 default_folder(Some(last.clone()), Some(home.clone())),
671 Some(last)
672 );
673 let gone = home.join("gone");
674 assert_eq!(default_folder(Some(gone), Some(home)), Some(made));
675 assert_eq!(default_folder(None, None), None);
676 }
677
678 #[test]
683 fn the_app_serves_the_editor_and_is_raised_by_a_second_app() {
684 let dir = scratch("serve");
685 std::fs::write(dir.join("part.geop"), "{\"steps\": []}\n").unwrap();
686 let (folder, _) = folder_of(&dir).unwrap();
687 let root = folder.root().to_path_buf();
688 let raised = Arc::new(AtomicUsize::new(0));
689 let counter = raised.clone();
690 let raise = Box::new(move || {
691 counter.fetch_add(1, Ordering::SeqCst);
692 });
693 let (access, claim) = serve_headless(folder, raise).unwrap();
694 let here = format!("127.0.0.1:{}", access.port);
695
696 use tungstenite::client::IntoClientRequest;
698 let mut request = format!("ws://{here}/ws?token={}", access.token)
699 .into_client_request()
700 .unwrap();
701 let origin = format!("http://{here}").parse().unwrap();
702 request.headers_mut().insert("Origin", origin);
703 let (mut socket, _) = tungstenite::connect(request).unwrap();
704 socket
705 .send(tungstenite::Message::text(r#"{"command": "show"}"#))
706 .unwrap();
707 let update = socket.read().unwrap().into_text().unwrap();
708 let update: serde_json::Value = serde_json::from_str(&update).unwrap();
709 assert!(update["error"].is_null(), "{update}");
710
711 let mut stream = TcpStream::connect(&here).unwrap();
713 write!(
714 stream,
715 "GET /api/files HTTP/1.1\r\nHost: {here}\r\nAuthorization: Bearer {}\r\n\
716 Connection: close\r\n\r\n",
717 access.token
718 )
719 .unwrap();
720 let mut answer = String::new();
721 stream.read_to_string(&mut answer).unwrap();
722 assert!(answer.starts_with("HTTP/1.1 200"), "{answer}");
723 assert!(answer.contains("part.geop"), "{answer}");
724
725 assert!(raise_running(&root));
726 assert_eq!(raised.load(Ordering::SeqCst), 1);
727 claim.release();
728 assert!(!raise_running(&root));
729 assert_eq!(raised.load(Ordering::SeqCst), 1);
730 }
731
732 #[test]
735 fn a_claim_of_an_app_that_is_gone_raises_nothing() {
736 let dir = scratch("gone");
737 let root = std::fs::canonicalize(&dir).unwrap();
738 let port = std::net::TcpListener::bind("127.0.0.1:0")
740 .unwrap()
741 .local_addr()
742 .unwrap()
743 .port();
744 let claim = Claim::new(&root, &Access::new(port).unwrap()).unwrap();
745 assert!(!raise_running(&root));
746 claim.release();
747 assert!(!claim_file(&root).exists());
748 }
749
750 #[test]
751 fn each_folder_is_claimed_apart() {
752 assert_ne!(claim_file(Path::new("/a")), claim_file(Path::new("/b")));
753 assert_eq!(claim_file(Path::new("/a")), claim_file(Path::new("/a")));
754 }
755
756 #[test]
757 fn the_window_size_kept_is_read_back_and_a_useless_one_is_not() {
758 let state = WindowState {
759 width: 1440.0,
760 height: 900.5,
761 maximized: true,
762 };
763 assert_eq!(WindowState::read(&state.text()), Some(state));
764 assert_eq!(WindowState::read(r#"{"width": 10, "height": 900}"#), None);
765 assert_eq!(WindowState::read(r#"{"width": 1e9, "height": 900}"#), None);
766 assert_eq!(WindowState::read("not json"), None);
767 }
768
769 #[test]
770 fn links_out_of_the_page_go_to_the_browser() {
771 let access = Access {
772 port: 4321,
773 token: "t".into(),
774 };
775 assert!(is_page("http://127.0.0.1:4321/#token=t", &access));
776 assert!(is_page("http://localhost:4321/assets/a.js", &access));
777 assert!(is_page("blob:http://127.0.0.1:4321/x", &access));
778 assert!(!is_page("http://127.0.0.1:4322/", &access));
779 assert!(!is_page("https://discord.gg/btRDgrcwJ5", &access));
780 assert!(is_for_browser("https://github.com/geop-cad/geop"));
781 assert!(!is_for_browser("file:///etc/passwd"));
782 assert!(!is_for_browser("javascript:alert(1)"));
783 }
784}