Skip to main content

geop/
app.rs

1//! `geop app [DIR | program.geop]`: the web editor in a window of its own,
2//! editing the files of the folder `DIR` on the kernel of this process.
3//!
4//! It is `geop open` (see [`crate::open`]) without the browser: the same
5//! page, served by the same server on 127.0.0.1, shown in the system's
6//! webview (WebKitGTK, WKWebView, WebView2, through `wry` and `tao`) with
7//! no browser chrome around it. Everything the editor does is the page's,
8//! so it behaves as the web app does; what the window adds is only what a
9//! browser tab cannot do:
10//!
11//! - its title is the page's, which names the program edited and the
12//!   folder (`backend.local.ts`);
13//! - a folder to edit is asked for in the system's dialog, when none is
14//!   given and from the page's File menu ("Open folder…", which starts
15//!   another `geop app`); a file chosen "from disk" is chosen in the
16//!   system's dialog, which the webview shows for the page's file inputs;
17//! - a file the page downloads (Save, an export) is saved where the
18//!   system's save dialog says;
19//! - a link out of the page opens in the default browser;
20//! - its size is kept from one run to the next ([`WindowState`]);
21//! - one app edits a folder: a second `geop app` of the same folder
22//!   brings the first to the front and ends ([`Claim`]).
23//!
24//! What needs no window — how one app finds the one already editing its
25//! folder, what it keeps between runs — is built in every `geop`, and the
26//! tests drive the app's server ([`crate::open::Serving`]) without one;
27//! the window needs the feature `app`.
28
29// Built without the window, what it would run is only the tests'.
30#![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    /// The folder to edit the files of — or a program in it, to edit
46    /// first. Asked for in a dialog, if not given.
47    pub path: Option<PathBuf>,
48}
49
50// ── one app per folder ─────────────────────────────────────────────────
51
52/// Where the app editing `root` says how to reach it: a file per folder,
53/// named by a digest of its canonical path, in the user's runtime folder.
54fn 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
64/// This app's claim on its folder: the port and token another `geop app`
65/// of the folder raises it with (`POST /api/raise`). Readable by the user
66/// alone, since the token opens the folder's files; given up when the app
67/// ends ([`Claim::release`]).
68pub struct Claim {
69    file: PathBuf,
70    text: String,
71}
72
73impl Claim {
74    /// Claim `root` for the app `access` reaches.
75    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    /// Give the folder up, unless another app has claimed it since.
98    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
105/// Bring the app editing `root` to the front, if one is: whether one was.
106/// A claim whose app is gone (it crashed, or was killed) is no app.
107pub 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    // By hand, not by an HTTP client that might send it through a proxy.
121    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
137// ── what is kept between runs ──────────────────────────────────────────
138
139/// The folder geop keeps its settings in: the platform's for each user.
140fn 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/// The window's size as the last app left it, in logical pixels.
153#[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    /// The smallest window the editor's panels fit in.
172    const SMALLEST: f64 = 480.0;
173
174    fn file() -> Option<PathBuf> {
175        config_dir().map(|d| d.join("app-window.json"))
176    }
177
178    /// The state `text` holds, if it holds one of a usable size.
179    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    /// The state kept, or the default.
200    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    /// Keep this state for the next run; a failure only loses it.
207    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// ── what a double click starts ─────────────────────────────────────────
216
217/// The arguments of the program a person double-clicks (the feature
218/// `desktop`): it is the app, whatever it is given. A file dropped on it is
219/// the path to open; `app ...` is what the app's "Open folder…" starts it
220/// with. An old macOS adds a `-psn_...` of its own.
221#[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
232/// The file the last folder edited is kept in.
233fn last_folder_file() -> Option<PathBuf> {
234    config_dir().map(|d| d.join("app-folder.txt"))
235}
236
237/// Keep `folder` as the one to open next time; a failure only loses it.
238fn 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/// The folder to open with none asked for: the one edited last, while it
246/// is there; else `Geop` in the user's Documents, made if it has to be. A
247/// person who has only clicked on an icon has nothing to answer yet, and
248/// their parts are somewhere they will find them.
249#[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
259// ── the app ────────────────────────────────────────────────────────────
260
261/// The page's own address: what it may navigate to in the window. Any
262/// other is a link out of it, for the browser.
263fn 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
272/// Whether `url` is one for the browser: a page on the web or a mail.
273fn 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/// The window, in the system's webview.
326#[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    /// What reaches the window from elsewhere.
346    #[derive(Debug)]
347    enum AppEvent {
348        /// Another `geop app` of this folder was started.
349        Raise,
350        /// The page's "Open folder…".
351        OpenFolder,
352    }
353
354    /// What the page is told it is in: the File menu's "Open folder…"
355    /// asks the window (`backend.local.ts`). And what goes wrong in the
356    /// page, which a window shows no console of, is said in the terminal
357    /// the app was started from, as `geop open`'s is in the browser's.
358    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    /// A folder, asked for in the system's dialog, starting in `dir`.
369    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    /// The menu bar a Mac expects of an app: without one, ⌘Q does nothing,
377    /// and ⌘C and ⌘V reach no text field of the page, a webview having them
378    /// from the menu. Undo is left to the page, whose steps it undoes.
379    #[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    /// Where the webview keeps what it keeps. WebView2 would otherwise make
425    /// a folder beside the program, wherever it was downloaded to, and fail
426    /// where it may not write. The other webviews keep theirs in the user's
427    /// own folders already.
428    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    /// The window's icon: the logo the page shows, drawn at 256 pixels.
436    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    /// Where the page's download `suggested` goes: asked in the system's
454    /// save dialog, starting in the folder edited.
455    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    /// Start another `geop app` on `folder`: it raises this one if
467    /// `folder` is this one's.
468    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            // Kept alive as long as the window is.
567            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                    // A maximized window keeps the size it is restored to.
590                    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/// Serve `folder` as `geop app` does, without the window: what the tests
616/// drive, as the window's page would.
617#[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    /// A double click is the app, whatever comes with it; the app's own
645    /// "Open folder…" is not made `app app`.
646    #[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    /// With nothing asked for, it opens the folder edited last while it is
661    /// there, else one in Documents, which it makes.
662    #[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    /// The server of the app answers the editor's protocol over its
679    /// WebSocket and lists the folder's files, as `geop open`'s does; a
680    /// second app of the folder raises it, and none raises it once it
681    /// has given the folder up.
682    #[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        // The editor, as the page reaches it.
697        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        // The files, as the page lists them.
712        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    /// A claim left by an app that is gone raises nothing: the next app
733    /// takes the folder.
734    #[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        // A port nothing listens on: one just let go of.
739        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}