diff --git a/packages/desktop/README.md b/packages/desktop/README.md index ece14eda..06dbc9c8 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -28,16 +28,16 @@ cannot do. | | | | --- | --- | -| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. | +| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. | | **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. | -| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | +| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | | **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:` or `http://localhost:` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. | | **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. | | **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. | | **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. | | **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. | -| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. | -| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Links to other sites inside rendered HTML previews and web tabs do not open (see Known limits). | +| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. Each pop-out opens a step down and to the right of the window in use, never exactly on top of another one. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. | +| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). | | **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. | | **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. | | **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. | @@ -235,11 +235,13 @@ and the Dock icon bounces. or later installed. On an older WebKit the connect screen says so and does not connect. Session pop-outs also need BroadcastChannel (Safari 15.4), which the same requirement covers. -- **Links in rendered HTML and web tabs do not open.** A link to another site - inside a rendered HTML preview or a web tab does nothing, in the preview - overlay, and for a rendered HTML file in its pop-out window too. The app - sends links out only from Codeman's own pages, and blocks navigation to - other sites in every frame (see Decisions). +- **Plain links in rendered HTML and web tabs do not open.** A link to another + site inside a rendered HTML preview or a web tab does nothing, in the + preview overlay, and for a rendered HTML file in its pop-out window too: the + app blocks navigation to other sites in every frame (see Decisions). A link + there that asks for a new window (`target="_blank"`, or a script's + `window.open`) does open, in your default browser, within the budget of 3 at + once and then 1 per second. - **No login dialog for a web tab's own HTTP Basic login.** The app answers only Codeman's own login, and WebKit shows no dialog for any other, so a dashboard in a web tab that asks for HTTP Basic shows its 401 page. diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index d84f3fda..30b1ad89 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -275,31 +275,46 @@ // tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab // the connect under way was asked from, where its answer belongs. local: the // last This Mac status from the app. wait: set while an install runs in - // Terminal and this page polls for the server it brings up. + // Terminal and this page polls for the server it brings up. reopen: a + // connect to this Mac was refused, so open it if it turns out to be starting. + // startingLooks: how often the page has looked again at a starting server. var state = { servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '', tab: 'local', askedFrom: 'local', local: null, localChecking: false, opening: false, - wait: null, waitTimer: 0, stoppedWaiting: false + wait: null, waitTimer: 0, stoppedWaiting: false, + reopen: false, startingLooks: 0, startingTimer: 0 }; // While an install runs: how often to look, how long a finished install may // take to answer before Start is offered, and when to stop looking. var WAIT_POLL_MS = 3000; var WAIT_START_GRACE_MS = 90 * 1000; var WAIT_MAX_MS = 30 * 60 * 1000; + // A server that is still starting: how often to look again, and how many times. + var STARTING_POLL_MS = 1000; + var STARTING_MAX_LOOKS = 20; // What the app takes in one message, in UTF-8 bytes (MAX_URL_LEN and // MAX_FIELD_LEN in policy.rs). It drops a longer one without an answer, so // the page has to be the one that says so. var MAX_URL = 4096; var MAX_FIELD = 1024; - var utf8 = new TextEncoder(); - function tooLong(text, max) { return utf8.encode(text).length > max; } + function tooLong(text, max) { + var bytes = 0; + for (var i = 0; i < text.length; i++) { + var c = text.charCodeAt(i); + if (c < 0x80) bytes += 1; + else if (c < 0x800) bytes += 2; + else if (c >= 0xd800 && c < 0xdc00) { bytes += 4; i++; } // a surrogate pair: one character + else bytes += 3; + } + return bytes > max; + } function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } } function setStatus(kind, text, detail) { els.statusRow.hidden = !text; // In a small window the line may be below the fold: bring it into view. - if (text) els.statusRow.scrollIntoView({ block: 'nearest' }); + if (text && els.statusRow.scrollIntoView) els.statusRow.scrollIntoView({ block: 'nearest' }); els.status.className = kind || ''; els.spinner.hidden = kind !== 'busy'; els.statusText.textContent = text || ''; @@ -353,14 +368,16 @@ } // The address as the app normalizes it (policy::normalize_server_url): - // http:// by default, https:// for a .ts.net name, no query or fragment, a - // trailing slash. Only used to tell whether two addresses are one server. + // http:// by default, https:// for a .ts.net name without a port, no query + // or fragment, a trailing slash. Only used to tell whether two addresses + // are one server. function normalize(raw) { var s = String(raw || '').trim(); if (!s) return ''; if (s.indexOf('://') < 0) { var name = s.split(/[\/:]/)[0].toLowerCase(); - s = (/\.ts\.net$/.test(name) ? 'https' : 'http') + '://' + s; + var tailscaleServe = /\.ts\.net$/.test(name) && s.charAt(name.length) !== ':'; + s = (tailscaleServe ? 'https' : 'http') + '://' + s; } try { var u = new URL(s); @@ -497,6 +514,7 @@ showLogin(); } else if (asked === 'local') { // Opening the Codeman on this Mac failed: look at it again. + state.reopen = o.kind === 'refused'; selectTab('local'); // selectTab starts the look itself when there has been none yet. if (!state.localChecking) checkLocal('login'); @@ -618,6 +636,11 @@ title = 'Codeman is being installed in Terminal.'; detail = 'Finish the installer there, then Check again.'; show.check = true; + } else if (s.state === 'starting') { + dot = 'busy'; + title = 'Codeman is starting on this Mac…'; + detail = 'It will answer at ' + s.url + ' in a moment.'; + show.check = true; } else if (s.state === 'runningElsewhere') { dot = 'warn'; title = 'A Codeman server is already running on this Mac.'; @@ -704,9 +727,36 @@ onWaitStatus(state.local || {}); return; } + if (followStarting(s)) return; renderLocal(); } + // A Codeman that is still starting (its process runs, but listens nowhere + // yet: the first seconds after login, a restart): look again every second + // for a while. Once it is up, open it if a connect to this Mac was refused + // on the way here, which is what the user had asked for. True when it opened. + function followStarting(s) { + clearTimeout(state.startingTimer); + if (s && s.state === 'starting') { + if (state.startingLooks < STARTING_MAX_LOOKS) { + state.startingLooks++; + state.startingTimer = setTimeout(function () { + if (state.local && state.local.state === 'starting' && !state.wait) checkLocal('quick'); + }, STARTING_POLL_MS); + } + return false; + } + var up = !!s && (s.state === 'running' || s.state === 'needsLogin'); + var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy; + state.startingLooks = 0; + state.reopen = false; + if (open) { + renderLocal(); + openLocal(); + } + return open; + } + // Point the Another machine form at `url` with only the login saved for it: // never the username or a typed password of the server the form showed // before, which connect() would otherwise send along. @@ -968,7 +1018,9 @@ if (ev.key === 'Escape' && !els.cancel.hidden) { ev.preventDefault(); els.cancel.click(); return; } if (ev.key !== 'Enter' || state.tab !== 'local') return; if (ev.target && ev.target.closest && ev.target.closest('button, a, input, summary')) return; - var main = [els.localOpen, els.localStart].filter(function (b) { return !b.hidden && !b.disabled; })[0]; + // offsetParent: really on screen, whatever hides it (its own `hidden`, or + // a container that is not shown). + var main = [els.localOpen, els.localStart].filter(function (b) { return b.offsetParent && !b.disabled; })[0]; if (main) { ev.preventDefault(); main.click(); } }); els.localOpen.addEventListener('click', openLocal); diff --git a/packages/desktop/src/config.rs b/packages/desktop/src/config.rs index d0942095..ec199259 100644 --- a/packages/desktop/src/config.rs +++ b/packages/desktop/src/config.rs @@ -25,10 +25,14 @@ const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0 /// How much of the title bar must be on a screen to grab it. const MIN_GRIP: f64 = 80.0; /// Room `fitted_to` leaves when it shrinks a window. Screens are full display -/// bounds, so the menu bar and the Dock are inside them; these are the margins -/// main.rs uses for the centred default frame. +/// bounds, so the menu bar and the Dock are inside them. const FIT_MARGIN_X: f64 = 40.0; const FIT_MARGIN_Y: f64 = 80.0; +/// Room a first-run window leaves above its content (the menu bar, taller on +/// a display with a notch, and its own title bar) and below it (a Dock of the +/// default size): `Rect` is the whole display, so neither is taken off yet. +const FIRST_RUN_ROOM_ABOVE: f64 = 66.0; +const FIRST_RUN_ROOM_BELOW: f64 = 90.0; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default, rename_all = "camelCase")] @@ -39,9 +43,17 @@ pub struct Config { pub auto_connect: bool, /// Always open in full screen (otherwise the last state is restored). pub start_fullscreen: bool, + /// Anything that is not a whole frame reads as None: a bad frame must not + /// make the whole file unreadable, which would cost the server list. + #[serde(deserialize_with = "lenient_window")] pub window: Option, } +fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).ok()) +} + impl Default for Config { fn default() -> Self { Config { @@ -92,6 +104,23 @@ pub struct Rect { } impl WindowFrame { + /// The frame of a first-run window on `screen`: `size` or what fits, never + /// less than `min`, centred across and in the height between the menu bar + /// and the Dock. Centring in the whole display put the bottom of a window + /// under the Dock on a 900 pt high one. + pub fn first_run(screen: &Rect, size: (f64, f64), min: (f64, f64)) -> WindowFrame { + let width = size.0.min(screen.width - FIT_MARGIN_X).max(min.0); + let room = screen.height - FIRST_RUN_ROOM_ABOVE - FIRST_RUN_ROOM_BELOW; + let height = size.1.min(room).max(min.1); + WindowFrame { + x: screen.x + (screen.width - width) / 2.0, + y: screen.y + FIRST_RUN_ROOM_ABOVE + ((room - height) / 2.0).max(0.0), + width, + height, + fullscreen: false, + } + } + /// The frame clamped to sane sizes, or None when it holds junk. pub fn sanitized(self) -> Option { let vals = [self.x, self.y, self.width, self.height]; @@ -626,6 +655,77 @@ mod tests { assert_eq!(c.servers[0].username, None); } + #[test] + fn a_bad_window_frame_keeps_the_servers() { + for (name, window) in [ + ("missing-height", r#"{"x":1,"y":2,"width":3}"#), + ("not-a-frame", r#""maximized""#), + ("null-number", r#"{"x":null,"y":2,"width":3,"height":4}"#), + ] { + let dir = tmp_dir(&format!("bad-window-{name}")); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("desktop.json"); + fs::write( + &path, + format!(r#"{{"servers":[{{"url":"http://a/"}}],"autoConnect":false,"window":{window}}}"#), + ) + .unwrap(); + let (c, can_save) = Config::load_from(&path); + assert!(can_save, "{name}"); + assert_eq!(c.servers.len(), 1, "{name}"); + assert!(!c.auto_connect, "{name}"); + assert_eq!(c.window, None, "{name}"); + // The file was read, so it stays where it is. + assert!(path.is_file(), "{name}"); + assert!(!path.with_extension("json.corrupt").exists(), "{name}"); + } + // A whole frame and an absent one read as before. + let c: Config = + serde_json::from_str(r#"{"window":{"x":1,"y":2,"width":900,"height":600,"fullscreen":true}}"#).unwrap(); + assert_eq!(c.window.map(|f| (f.width, f.fullscreen)), Some((900.0, true))); + assert_eq!(serde_json::from_str::("{}").unwrap().window, None); + assert_eq!( + serde_json::from_str::(r#"{"window":null}"#).unwrap().window, + None + ); + } + + #[test] + fn first_run_window_sits_between_the_menu_bar_and_the_dock() { + let screen = |width: f64, height: f64| Rect { + x: 0.0, + y: 0.0, + width, + height, + }; + let size = (1280.0, 820.0); + let min = (480.0, 360.0); + // A 13 inch MacBook: the window is shortened, and ends above the Dock. + let f = WindowFrame::first_run(&screen(1440.0, 900.0), size, min); + assert_eq!((f.width, f.height), (1280.0, 744.0)); + assert_eq!((f.x, f.y), (80.0, 66.0)); + assert!(f.y + f.height <= 900.0 - 90.0); + // A large display: the full size, centred across and in the free height. + let f = WindowFrame::first_run(&screen(2560.0, 1440.0), size, min); + assert_eq!((f.width, f.height), (1280.0, 820.0)); + assert_eq!(f.x, 640.0); + assert_eq!(f.y - 66.0, 1440.0 - 90.0 - (f.y + f.height)); + // A second display to the left of the first. + let left = Rect { + x: -1920.0, + y: 120.0, + width: 1920.0, + height: 1080.0, + }; + let f = WindowFrame::first_run(&left, size, min); + assert_eq!(f.x, -1920.0 + 320.0); + assert!(f.y >= 120.0 + 66.0 && f.y + f.height <= 120.0 + 1080.0 - 90.0); + // Never smaller than the minimum, on a display too small for it too. + let f = WindowFrame::first_run(&screen(500.0, 400.0), size, min); + assert_eq!((f.width, f.height), min); + assert!(!f.fullscreen); + } + #[test] fn frame_sanitizing_and_reachability() { assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none()); diff --git a/packages/desktop/src/local_server.rs b/packages/desktop/src/local_server.rs index 9047f6d4..17467a6b 100644 --- a/packages/desktop/src/local_server.rs +++ b/packages/desktop/src/local_server.rs @@ -107,6 +107,10 @@ impl From for Run { fn from(out: Output) -> Self { let mut output = out.stdout; if !out.stderr.trim().is_empty() { + // On a line of its own, also when stdout ended without a newline. + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } output.push_str(&out.stderr); } Run { @@ -151,10 +155,16 @@ fn exec(mut cmd: Command, deadline: Instant) -> Result { pump(child.stdout.take(), 0, tx.clone()); pump(child.stderr.take(), 1, tx); + // A login shell usually answers within tens of milliseconds: look often + // at first, then back off. + let mut nap = Duration::from_millis(5); let status = loop { match child.try_wait() { Ok(Some(status)) => break status, - Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(nap); + nap = (nap * 2).min(Duration::from_millis(100)); + } Ok(None) => { let _ = child.kill(); let _ = child.wait(); @@ -265,6 +275,16 @@ fn capture_env( })) } +/// Run `cmd` in the home folder, where a terminal and the LaunchAgent start +/// Codeman too. The server keeps the directory it was started in and uses it +/// as the default for a session that names none; an app opened from the Finder +/// or the Dock runs in `/`. +fn in_home(cmd: &mut Command) { + if let Some(home) = dirs::home_dir() { + cmd.current_dir(home); + } +} + /// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured /// environment, so the server gets the profile's PATH, API keys and CODEMAN_* /// settings and nothing of launchd's bare one. @@ -272,6 +292,7 @@ fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadlin let mut cmd = Command::new("/bin/sh"); cmd.arg("-c").arg(script).arg("codeman-desktop").args(args); cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v))); + in_home(&mut cmd); exec(cmd, deadline).map(Run::from) } @@ -289,6 +310,7 @@ fn run_in_shell( cmd.arg("-i"); } cmd.arg("-c").arg(script).arg("codeman-desktop").args(args); + in_home(&mut cmd); exec(cmd, deadline).map(Run::from) } @@ -400,6 +422,26 @@ mod tests { assert_eq!(last_lines("", 3), ""); } + #[test] + fn stderr_starts_on_a_line_of_its_own() { + let said = |stdout: &str, stderr: &str| { + Run::from(Output { + code: Some(1), + stdout: stdout.into(), + stderr: stderr.into(), + }) + .output + }; + // stdout that ended without a newline used to be glued to stderr. + assert_eq!( + said("Starting Codeman", "Error: port 3000 is in use\n"), + "Starting Codeman\nError: port 3000 is in use" + ); + assert_eq!(said("Starting\n", "Error\n"), "Starting\nError"); + assert_eq!(said("", "Error\n"), "Error"); + assert_eq!(said("Started\n", " \n"), "Started"); + } + #[test] fn refuses_non_local_or_https() { assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err()); diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 12e4ae6a..2e817da5 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -60,6 +60,9 @@ const DEFAULT_SIZE: (f64, f64) = (1280.0, 820.0); const POPOUT_SIZE: (f64, f64) = (960.0, 680.0); const MIN_SIZE: (f64, f64) = (480.0, 360.0); const MAX_POPOUTS: usize = 24; +/// How far a pop-out opens down and to the right of the window in use, per +/// pop-out already open (it starts over after eight). +const POPOUT_STEP: f64 = 28.0; const SAVE_DEBOUNCE: Duration = Duration::from_secs(1); /// How long a server load may take to commit before the app gives up on it /// and brings the connect page back. @@ -245,7 +248,10 @@ struct App { /// aside, in which case it must not be overwritten. config_path: Option, config_dirty: bool, - last_save: Instant, + /// The settings are written SAVE_DEBOUNCE after this: the last write, or + /// the last move or resize of the main window, so a drag writes the file + /// once when it ends, not once a second while it goes on. + save_held_since: Instant, /// Where the main window is in reaching a server. Shared with the web view /// handlers, which run on this same thread. phase: Rc>, @@ -322,7 +328,7 @@ fn main() { let (window, wants_fullscreen) = create_main_window(&event_loop, &config).unwrap_or_else(|e| fatal(&format!("Could not open a window: {e}"))); - let webview = build_webview(&window, true, Load::ConnectPage { manual: false }, &proxy, &phase) + let webview = build_webview(&window, true, true, Load::ConnectPage { manual: false }, &proxy, &phase) .unwrap_or_else(|e| fatal(&format!("Could not create the web view: {e}"))); #[cfg(target_os = "macos")] macos::install(&webview); @@ -346,7 +352,7 @@ fn main() { config, config_path, config_dirty: false, - last_save: Instant::now(), + save_held_since: Instant::now(), phase, loading_deadline: None, auth: None, @@ -482,21 +488,21 @@ fn build_menu(config: &Config) -> muda::Result { }) } +/// A display's whole area in points (the menu bar and the Dock are inside it). +fn display_rect(monitor: &tao::monitor::MonitorHandle) -> Rect { + let sf = monitor.scale_factor(); + let p = monitor.position().to_logical::(sf); + let s = monitor.size().to_logical::(sf); + Rect { + x: p.x, + y: p.y, + width: s.width, + height: s.height, + } +} + fn monitors(target: &EventLoopWindowTarget) -> Vec { - target - .available_monitors() - .map(|m| { - let sf = m.scale_factor(); - let p = m.position().to_logical::(sf); - let s = m.size().to_logical::(sf); - Rect { - x: p.x, - y: p.y, - width: s.width, - height: s.height, - } - }) - .collect() + target.available_monitors().map(|m| display_rect(&m)).collect() } /// The main window, and whether it was asked into full screen (App's @@ -519,17 +525,10 @@ fn create_main_window( None => { builder = builder.with_inner_size(LogicalSize::new(DEFAULT_SIZE.0, DEFAULT_SIZE.1)); if let Some(m) = target.primary_monitor() { - let sf = m.scale_factor(); - let p = m.position().to_logical::(sf); - let s = m.size().to_logical::(sf); - let w = DEFAULT_SIZE.0.min(s.width - 40.0); - let h = DEFAULT_SIZE.1.min(s.height - 80.0); + let f = WindowFrame::first_run(&display_rect(&m), DEFAULT_SIZE, MIN_SIZE); builder = builder - .with_inner_size(LogicalSize::new(w.max(MIN_SIZE.0), h.max(MIN_SIZE.1))) - .with_position(LogicalPosition::new( - p.x + (s.width - w) / 2.0, - p.y + (s.height - h) / 2.0, - )); + .with_inner_size(LogicalSize::new(f.width, f.height)) + .with_position(LogicalPosition::new(f.x, f.y)); } } } @@ -610,9 +609,13 @@ fn loaded_on_commit(phase: &Phase, committed: &str) -> Option { policy::same_origin(&url, server).then(|| Phase::Loaded(server.clone())) } +/// `focused`: the web view takes the keyboard and brings the app to the front +/// (wry's default). False for a window a script opened: it comes up without +/// pulling the user out of the app they are typing in. fn build_webview( window: &Window, is_main: bool, + focused: bool, load: Load<'_>, proxy: &EventLoopProxy, phase: &Rc>, @@ -621,6 +624,7 @@ fn build_webview( let mut builder = WebViewBuilder::new() .with_initialization_script(assets::HOST_SHIM) .with_devtools(true) + .with_focused(focused) .with_accept_first_mouse(true) .with_back_forward_navigation_gestures(false) // false = let WebKit take the drop, so Codeman's own HTML5 drop @@ -787,7 +791,7 @@ impl App { /// so it is only decided here, never reset per event. fn on_events_cleared(&mut self, control_flow: &mut ControlFlow) { let now = Instant::now(); - if self.config_dirty && now >= self.last_save + SAVE_DEBOUNCE { + if self.config_dirty && now >= self.save_held_since + SAVE_DEBOUNCE { self.save_config(); } let loading = matches!(*self.phase.borrow(), Phase::Loading(_)); @@ -802,7 +806,7 @@ impl App { if matches!(*control_flow, ControlFlow::ExitWithCode(_)) { return; } - let save_due = self.config_dirty.then(|| self.last_save + SAVE_DEBOUNCE); + let save_due = self.config_dirty.then(|| self.save_held_since + SAVE_DEBOUNCE); let fullscreen_due = self.fullscreen_retry.map(|(due, _)| due); *control_flow = match [save_due, self.loading_deadline, fullscreen_due] .into_iter() @@ -1636,14 +1640,33 @@ impl App { } let opened_url = policy::webkit_safe_url(url); let auth = self.auth.clone().filter(|_| policy::within_base(url, &server)); - let window = match WindowBuilder::new() + // tao centres a window that is given no position, so every pop-out + // would open exactly on top of the one before. Each one steps off the + // window in use instead. + let position = self + .focused_window_id() + .or(Some(self.main_id)) + .and_then(|id| self.windows.get(&id)) + .and_then(|w| { + // The corner tao's with_position places (see record_main_frame). + #[cfg(target_os = "macos")] + let corner = w.window.inner_position(); + #[cfg(not(target_os = "macos"))] + let corner = w.window.outer_position(); + let p = corner.ok()?.to_logical::(w.window.scale_factor()); + let step = POPOUT_STEP * (1 + popouts % 8) as f64; + Some(LogicalPosition::new(p.x + step, p.y + step)) + }); + let mut builder = WindowBuilder::new() .with_title(APP_NAME) .with_theme(Some(Theme::Dark)) .with_inner_size(LogicalSize::new(POPOUT_SIZE.0, POPOUT_SIZE.1)) .with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1)) - .with_focused(focused) - .build(target) - { + .with_focused(focused); + if let Some(position) = position { + builder = builder.with_position(position); + } + let window = match builder.build(target) { Ok(window) => window, Err(e) => { eprintln!("codeman-desktop: could not open a window: {e}"); @@ -1654,8 +1677,15 @@ impl App { url: &opened_url, auth: auth.as_deref(), }; - let webview = match build_webview(&window, false, load, &self.proxy, &self.phase) { - Ok(webview) => webview, + let webview = match build_webview(&window, false, focused, load, &self.proxy, &self.phase) { + Ok(webview) => { + // Still the one that takes the keys once the user comes to + // this window: first responder in its own window only. + if !focused { + let _ = webview.focus(); + } + webview + } Err(e) => { eprintln!("codeman-desktop: could not create a web view: {e}"); return false; @@ -1795,11 +1825,12 @@ impl App { if self.config.window != Some(frame) { self.config.window = Some(frame); self.config_dirty = true; + self.save_held_since = Instant::now(); } } fn save_config(&mut self) { - self.last_save = Instant::now(); + self.save_held_since = Instant::now(); self.config_dirty = false; if let Some(path) = &self.config_path { if let Err(e) = self.config.save_to(path) { diff --git a/packages/desktop/src/policy.rs b/packages/desktop/src/policy.rs index 402d25ee..8ba781cc 100644 --- a/packages/desktop/src/policy.rs +++ b/packages/desktop/src/policy.rs @@ -61,9 +61,14 @@ impl Phase { } } -/// Turn what the user typed into a server URL: scheme added when missing -/// (`https` for a tailnet `.ts.net` name, `http` otherwise), query and fragment -/// dropped, and the path ending in `/` so `api/status` joins below a base path. +/// Turn what the user typed into a server URL: scheme added when missing, +/// query and fragment dropped, and the path ending in `/` so `api/status` +/// joins below a base path. The scheme is `https` for a tailnet `.ts.net` +/// name on its own, which is `tailscale serve`, and `http` otherwise. A +/// `.ts.net` name with a port is Codeman's own listener, which speaks plain +/// http: guessing https there ended in a TLS error with no way forward, while +/// the other mistake (http to an https port) gets a button for the https +/// address (ProbeOutcome::MaybeHttps). pub fn normalize_server_url(input: &str) -> Result { let s = input.trim(); if s.is_empty() { @@ -75,8 +80,8 @@ pub fn normalize_server_url(input: &str) -> Result { let with_scheme = if s.contains("://") { s.to_string() } else { - let host = s.split(['/', ':']).next().unwrap_or(""); - let scheme = if host.to_ascii_lowercase().ends_with(".ts.net") { + let (host, rest) = s.split_at(s.find(['/', ':']).unwrap_or(s.len())); + let scheme = if host.to_ascii_lowercase().ends_with(".ts.net") && !rest.starts_with(':') { "https" } else { "http" @@ -112,9 +117,15 @@ pub fn same_origin(a: &Url, b: &Url) -> bool { /// Basic `Authorization` header goes only to such URLs, so another app sharing /// the origin (`/grafana/` next to `/codeman/`) never receives the password. /// `server.path()` always ends in `/` (normalize_server_url), so `/codemanx/` -/// is not below `/codeman/`. +/// is not below `/codeman/`. The part below the base is decoded once, as +/// is_popout_target does, and may hold no `.` or `..` segment: the url crate +/// resolves a literal `..`, but `..%2Fgrafana/` reaches a proxy that decodes +/// before it routes as a way out of the base. A path that does not decode is +/// not below it either (fail closed). pub fn within_base(url: &Url, server: &Url) -> bool { - same_origin(url, server) && url.path().starts_with(server.path()) + same_origin(url, server) + && below_base_decoded(url, server) + .is_some_and(|rest| !rest.split(['/', '\\']).any(|segment| segment == "." || segment == "..")) } /// A server on this machine: `localhost`, `127.0.0.0/8` or `::1`. @@ -253,7 +264,7 @@ fn popout_kind(url: &Url, server: &Url) -> Option { { Some(Popout::File) } - ["api", "sessions", id, "folders", folder, "files", name, ..] + ["api", "sessions", id, "folders", folder, "files", name, "raw" | "preview"] if !id.is_empty() && !folder.is_empty() && !name.is_empty() => { Some(Popout::File) @@ -778,6 +789,26 @@ mod tests { normalize_server_url("box.tailf80371.ts.net").unwrap().as_str(), "https://box.tailf80371.ts.net/" ); + assert_eq!( + normalize_server_url("Box.Tailf80371.TS.NET/codeman").unwrap().as_str(), + "https://box.tailf80371.ts.net/codeman/" + ); + // With a port it is Codeman's own listener, not tailscale serve. + assert_eq!( + normalize_server_url("box.tailf80371.ts.net:3000").unwrap().as_str(), + "http://box.tailf80371.ts.net:3000/" + ); + assert_eq!( + normalize_server_url("box.tailf80371.ts.net:8443/x").unwrap().as_str(), + "http://box.tailf80371.ts.net:8443/x/" + ); + // A scheme that was typed is never second-guessed. + assert_eq!( + normalize_server_url("https://box.tailf80371.ts.net:8443") + .unwrap() + .as_str(), + "https://box.tailf80371.ts.net:8443/" + ); assert_eq!( normalize_server_url("https://host/codeman").unwrap().as_str(), "https://host/codeman/" @@ -1175,6 +1206,13 @@ mod tests { assert!(!ok("http://127.0.0.1:3000/api/sessions//file-raw")); assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/delete")); assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/")); + assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png")); + assert!(!ok( + "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/thumbnail" + )); + assert!(!ok( + "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw/x" + )); assert!(!ok("http://127.0.0.1:3000/api/download/x")); // The picker's other routes list and create folders: never a window. assert!(!ok("http://127.0.0.1:3000/api/filesystem/browse?path=%2F")); @@ -1252,6 +1290,27 @@ mod tests { assert!(!within_base(&u("https://host/grafana/"), &srv)); assert!(!within_base(&u("https://host/"), &srv)); assert!(!within_base(&u("https://host/codeman/../grafana/"), &srv)); + // The same way out, spelled so that only a decoder sees it. + for escape in [ + "https://host/codeman/..%2Fgrafana/", + "https://host/codeman/..%2fgrafana/x", + "https://host/codeman/%2e%2e%2Fgrafana/", + "https://host/codeman/..%5Cgrafana/", + "https://host/codeman/a/%2E%2E%2F%2E%2E%2Fgrafana/", + "https://host/codeman/session/a%zz", + ] { + assert!(!within_base(&u(escape), &srv), "{escape}"); + assert!(!challenge_in_scope(&u(escape), &srv), "{escape}"); + } + // Encoded characters that are part of a name stay inside. + for inside in [ + "https://host/codeman/api/sessions/s1/folders/f1/files/50%25.txt/raw", + "https://host/codeman/api/sessions/s1/file-raw?path=..%2Fx", + "https://host/codeman/session/a%20b", + "https://host/codeman/a..b/c", + ] { + assert!(within_base(&u(inside), &srv), "{inside}"); + } assert!(!within_base(&u("http://host/codeman/"), &srv)); assert!(!within_base(&u("https://other/codeman/"), &srv)); let root = u("http://127.0.0.1:3000/"); diff --git a/packages/desktop/src/probe.rs b/packages/desktop/src/probe.rs index 0998f2c2..41276e35 100644 --- a/packages/desktop/src/probe.rs +++ b/packages/desktop/src/probe.rs @@ -228,6 +228,11 @@ fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError { /// Ask the server. Blocking: call it from a worker thread. pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome { + probe_within(server, authorization, TIMEOUT) +} + +/// `probe` with the time the whole exchange may take. +fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome { let status_url = match server.join(STATUS_PATH) { Ok(u) => u, Err(e) => return ProbeOutcome::Failed { detail: e.to_string() }, @@ -236,7 +241,7 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome { .root_certs(ureq::tls::RootCerts::PlatformVerifier) .build(); let agent: ureq::Agent = ureq::Agent::config_builder() - .timeout_global(Some(TIMEOUT)) + .timeout_global(Some(timeout)) .http_status_as_error(false) // Never follow: a 3xx comes back as an answer and is explained. .max_redirects(0) @@ -259,12 +264,16 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome { }; let location = header("location"); let retry_after = header("retry-after"); - let body = resp - .body_mut() - .with_config() - .limit(MAX_BODY) - .read_to_string() - .unwrap_or_default(); + let body = match resp.body_mut().with_config().limit(MAX_BODY).read_to_string() { + Ok(body) => body, + // The answer began but did not finish in time. Where the body + // decides what answered (200, 403), that is a timeout, not + // "something else answers here". + Err(e) if matches!(status, 200 | 403) && transport_error(&e, false) == TransportError::Timeout => { + return ProbeOutcome::Timeout; + } + Err(_) => String::new(), + }; classify_response( &status_url, status, @@ -724,4 +733,38 @@ mod tests { let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps); } + + /// A Codeman that sends its status headers and then stalls (a blocked + /// event loop, a lossy link) did not answer in time. It is not "something + /// else on this port", which is what an empty body read as before. + #[test] + fn a_body_that_stalls_is_a_timeout() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// Send `head` and the start of a body, then hold the connection open. + fn serve_stalling(status_line: &'static str) -> u16 { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + if let Ok((mut s, _)) = l.accept() { + let mut buf = [0u8; 4096]; + let _ = s.read(&mut buf); + let _ = s.write_all( + format!("HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: 4096\r\n\r\n{{\"success\":true,").as_bytes(), + ); + std::thread::sleep(Duration::from_secs(3)); + } + }); + port + } + let short = Duration::from_millis(400); + + let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("200 OK"))).unwrap(); + assert_eq!(probe_within(&url, None, short), ProbeOutcome::Timeout); + + // A 401 has said all it needs to with its status line. + let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("401 Unauthorized"))).unwrap(); + assert_eq!(probe_within(&url, None, short), ProbeOutcome::NeedsLogin); + } } diff --git a/packages/desktop/src/setup.rs b/packages/desktop/src/setup.rs index c3719b93..eaf385c2 100644 --- a/packages/desktop/src/setup.rs +++ b/packages/desktop/src/setup.rs @@ -316,16 +316,17 @@ fn parse_ps(text: &str) -> Vec<(u32, String)> { /// `src/index.ts web`), not one of its short-lived control commands. pub fn is_codeman_server(command: &str) -> bool { let words: Vec<&str> = command.split_whitespace().collect(); - let entry = words.iter().position(|w| { + let is_entry = |w: &str| { let name = w.rsplit('/').next().unwrap_or(w); w.ends_with("dist/index.js") || w.ends_with("src/index.ts") || name == "codeman" || name == "aicodeman" - }); - match entry { - Some(i) => { - words.get(i + 1) == Some(&"web") && !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status")) - } - None => false, - } + }; + // Any entry word that `web` follows: an earlier word may only look like + // one (a folder named codeman in `tsx watch --cwd ~/codeman src/index.ts web`). + (0..words.len()).any(|i| { + is_entry(words[i]) + && words.get(i + 1) == Some(&"web") + && !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status")) + }) } /// Whether a command line is an install the app started on this Mac (the @@ -431,9 +432,8 @@ fn serves_loopback_port(found: &[Listener], port: u16) -> bool { /// all-interfaces bind, else its own address. fn listener_url(l: &Listener) -> Option { let host = match l.host.as_str() { - "*" | "0.0.0.0" | "127.0.0.1" => "127.0.0.1".to_string(), - h if h.starts_with('[') => h.to_string(), - h => h.to_string(), + "*" | "0.0.0.0" => "127.0.0.1", + h => h, }; Url::parse(&format!("http://{host}:{}/", l.port)).ok() } @@ -446,6 +446,9 @@ pub enum LocalState { Running, /// Codeman answers but wants a login the app does not have. NeedsLogin, + /// A Codeman server of this user runs but listens nowhere yet: it is + /// still starting (the LaunchAgent at login, a restart). + Starting, /// A Codeman server of this user runs, but not at an address the app can /// open (bound to one network address, say): see `elsewhere`. RunningElsewhere, @@ -485,6 +488,33 @@ pub struct LocalStatus { pub outcome: ProbeOutcome, } +/// Whether the probe got an answer from a Codeman server, logged in or not. +fn answers(outcome: &ProbeOutcome) -> bool { + matches!( + outcome, + ProbeOutcome::Ok { .. } + | ProbeOutcome::PasswordChangeRequired + | ProbeOutcome::NeedsLogin + | ProbeOutcome::BadCredentials + | ProbeOutcome::RateLimited { .. } + ) +} + +/// What the This Mac tab shows for what `inspect` found. `server_runs`: one of +/// this user's Codeman server processes exists. `server_listens`: one of them +/// has a listening socket. `installed`: Codeman is on this Mac. +fn local_state(outcome: &ProbeOutcome, server_runs: bool, server_listens: bool, installed: bool) -> LocalState { + match outcome { + ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => LocalState::Running, + _ if answers(outcome) => LocalState::NeedsLogin, + ProbeOutcome::Refused if server_runs && !server_listens => LocalState::Starting, + ProbeOutcome::Refused if server_runs => LocalState::RunningElsewhere, + ProbeOutcome::Refused if installed => LocalState::Stopped, + ProbeOutcome::Refused => LocalState::NotInstalled, + _ => LocalState::Other, + } +} + /// How hard `inspect` looks for an installed Codeman when nothing answers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Depth { @@ -552,20 +582,13 @@ pub fn inspect(depth: Depth) -> LocalStatus { let app = home.join(".codeman/app"); let install_complete = app.join(".install-complete").is_file(); - let answering = match &outcome { - ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => Some(LocalState::Running), - ProbeOutcome::NeedsLogin | ProbeOutcome::BadCredentials | ProbeOutcome::RateLimited { .. } => { - Some(LocalState::NeedsLogin) - } - _ => None, - }; // Something else on Codeman's port (a dev server on 3000, say) says // nothing about whether Codeman is installed, so look either way. let on_disk = install_complete || app.join("dist/index.js").is_file() || agent_path(&home).is_file() || daemon_path().is_file(); - let installed = answering.is_some() + let installed = answers(&outcome) || !servers.is_empty() || on_disk || match depth { @@ -573,13 +596,7 @@ pub fn inspect(depth: Depth) -> LocalStatus { Depth::Login => local_server::installed(false), Depth::Interactive => local_server::installed(true), }; - let state = match (answering, &outcome) { - (Some(state), _) => state, - (None, ProbeOutcome::Refused) if !servers.is_empty() => LocalState::RunningElsewhere, - (None, ProbeOutcome::Refused) if installed => LocalState::Stopped, - (None, ProbeOutcome::Refused) => LocalState::NotInstalled, - (None, _) => LocalState::Other, - }; + let state = local_state(&outcome, !servers.is_empty(), !found.is_empty(), installed); if state != LocalState::RunningElsewhere { elsewhere.clear(); } @@ -696,7 +713,7 @@ fn start_launch_agent(port: u16) -> Result { if !port_free(port) { return Ok(format!("launchd started Codeman ({SERVICE_LABEL}).")); } - std::thread::sleep(Duration::from_millis(500)); + std::thread::sleep(Duration::from_millis(100)); } Err("launchd started Codeman, but it did not answer within 30 seconds. Its log is /tmp/codeman.log for an install.sh setup.".into()) } @@ -1258,6 +1275,50 @@ mod tests { assert!(!served_by(&https, &u("http://localhost:3000/"))); } + #[test] + fn this_mac_state_for_what_was_found() { + let ok = ProbeOutcome::Ok { + version: "1.48.0".into(), + }; + // Whatever else is true, a Codeman that answers is what the tab shows. + for (runs, listens, installed) in [(false, false, false), (true, true, true), (true, false, false)] { + assert_eq!(local_state(&ok, runs, listens, installed), LocalState::Running); + assert_eq!( + local_state(&ProbeOutcome::PasswordChangeRequired, runs, listens, installed), + LocalState::Running + ); + for login in [ + ProbeOutcome::NeedsLogin, + ProbeOutcome::BadCredentials, + ProbeOutcome::RateLimited { + sent_credentials: false, + retry_after_secs: None, + }, + ] { + assert_eq!(local_state(&login, runs, listens, installed), LocalState::NeedsLogin); + } + } + // Nothing answers. A server process with no socket yet is starting, + // which comes before "runs somewhere else": the app must not send the + // user to another address for a server that is two seconds from up. + let refused = ProbeOutcome::Refused; + assert_eq!(local_state(&refused, true, false, true), LocalState::Starting); + assert_eq!(local_state(&refused, true, false, false), LocalState::Starting); + assert_eq!(local_state(&refused, true, true, true), LocalState::RunningElsewhere); + assert_eq!(local_state(&refused, false, false, true), LocalState::Stopped); + assert_eq!(local_state(&refused, false, false, false), LocalState::NotInstalled); + // Something else holds the port, or the look itself failed. + for other in [ + ProbeOutcome::NotCodeman { status: 404 }, + ProbeOutcome::Timeout, + ProbeOutcome::MaybeHttps, + ] { + assert_eq!(local_state(&other, true, true, true), LocalState::Other); + assert_eq!(local_state(&other, false, false, false), LocalState::Other); + } + assert!(answers(&ProbeOutcome::BadCredentials) && !answers(&refused)); + } + #[test] fn processes_and_listeners() { let ps = " 101 /opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web\n\ @@ -1271,6 +1332,15 @@ mod tests { 909 bash /var/folders/x/T/codeman-install.Ab12Cd --local --service --port 3001\n\ 1001 /bin/bash /var/folders/x/T/install-codeman-remote-55-1.command\n\ garbage line\n"; + // A folder named like the command comes before the real entry point. + assert!(is_codeman_server( + "node /x/tsx/cli.mjs watch --cwd /Users/k/codeman src/index.ts web" + )); + assert!(is_codeman_server( + "node --watch-path /Users/k/codeman /Users/k/codeman/src/index.ts web --port 3001" + )); + assert!(!is_codeman_server("node /Users/k/codeman/scripts/build.mjs web")); + assert!(!is_codeman_server("/opt/homebrew/bin/codeman attach web")); let procs = parse_ps(ps); assert_eq!( procs[0],