From 01d3379b74535bd966526881745eb2e3d278b9cd Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 03:20:01 +0100 Subject: [PATCH 1/4] fix(desktop): recover a crashed page, name every dialog's frame, and four smaller review fixes The findings of the 2026-11-05 review of this branch, each with a unit test where the code can be tested without a window: - A window whose web content process ended (a WebKit crash, or macOS taking the process back under memory pressure) stayed blank: wry answers WebKit's notice itself, which turns WebKit's own reload off. The window now loads its page again. A second end within 30 seconds sends the main window back to the connect page and closes a pop-out, so it cannot loop (policy::recovery). - A JavaScript dialog from a frame nothing names (about:srcdoc, a data: or blob: URL under an opaque origin) showed the page's text as its bold first line. The first line is now always the app's own, the "embedded" flag is set when the origin names no host (it was the wrong way round), and every sub-frame counts as embedded, a trusted web tab on Codeman's origin included. - The connect page lost the text of an error that needs the login fields: the tab switch cleared the status it had just set. - Start for http://localhost:PORT missed the LaunchAgent (the compare was exact against 127.0.0.1) and ran a second, unsupervised server on its port. - The path picker's preview Open link (api/filesystem/preview) did nothing: it was the one page Codeman opens in a window that the allowlist lacked. - The port of a running install was lost whenever the Terminal script that wraps the installer came first in ps. Co-Authored-By: Claude Fable 5.1 --- packages/desktop/README.md | 7 +-- packages/desktop/assets/connect.html | 3 +- packages/desktop/src/macos.rs | 54 +++++++++++---------- packages/desktop/src/main.rs | 67 +++++++++++++++++++++++++- packages/desktop/src/policy.rs | 70 ++++++++++++++++++++++++++-- packages/desktop/src/setup.rs | 49 ++++++++++++++++++- 6 files changed, 214 insertions(+), 36 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 75eaf4fc..de628b92 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -33,12 +33,13 @@ cannot do. | **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. 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. | +| **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). | | **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. | ## Build (on a Mac) @@ -95,8 +96,8 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency. - An app window opens only for Codeman's own pop-out pages under the server's base path, checked after decoding the path once, and which ones depends on who asks. `window.CodemanHost` (the app shell only) may open sessions, - rendered HTML views, the raw, preview, attachment and folder file routes, and - `api/download`. A `window.open` or `target=_blank` link, which WebKit passes + rendered HTML views, the raw, preview, attachment and folder file routes, the + path picker's preview and `api/download`. A `window.open` or `target=_blank` link, which WebKit passes on from any frame without saying which, gets only the file routes and `api/download`, the pages Codeman itself opens that way; otherwise a rendered HTML file could open copies of itself as top-level windows that pass for the diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index 972c7e2f..a950f178 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -798,11 +798,12 @@ break; case 'error': setBusy(false); - setStatus('bad', msg.message || 'Something went wrong.'); + // The tab first: switching it clears the status line. if (msg.needsPassword) { selectTab('remote'); showLogin(); } + setStatus('bad', msg.message || 'Something went wrong.'); break; case 'serverStarting': setBusy(true, false); diff --git a/packages/desktop/src/macos.rs b/packages/desktop/src/macos.rs index 5f339ab9..a26e4940 100644 --- a/packages/desktop/src/macos.rs +++ b/packages/desktop/src/macos.rs @@ -161,14 +161,17 @@ fn origin_label(host: &str, port: i64) -> Option { } /// The bold first line of a dialog, like Safari's, so a frame cannot pass its -/// text off as the app's own. `embedded` is a sandboxed frame (an HTML file -/// preview, an untrusted web tab): its origin is opaque, so `origin` is only -/// the server it was loaded from, and it must not read as Codeman speaking. -fn dialog_title(origin: &str, embedded: bool) -> String { - if embedded { - format!("An embedded page on {origin} says:") - } else { - format!("The page at {origin} says:") +/// text off as the app's own: the page's text always goes below it. `frame` is +/// the host that names the frame and whether it is embedded, which a sandboxed +/// frame is (an HTML file preview, an untrusted web tab): its origin is opaque, +/// so the host is only the server it was loaded from, and it must not read as +/// Codeman speaking. `None` is a frame nothing names: an opaque origin whose +/// document has no host either (`about:srcdoc`, a `data:` or `blob:` URL). +fn dialog_title(frame: Option<(&str, bool)>) -> String { + match frame { + Some((origin, false)) => format!("The page at {origin} says:"), + Some((origin, true)) => format!("An embedded page on {origin} says:"), + None => "An embedded page says:".to_string(), } } @@ -574,13 +577,11 @@ mod imp { Some(message) => clipped(message), None => NSString::from_str(""), }; - match frame.and_then(frame_origin) { - Some((origin, embedded)) => { - alert.setMessageText(&NSString::from_str(&dialog_title(&origin, embedded))); - alert.setInformativeText(&body); - } - None => alert.setMessageText(&body), - } + // The bold line is always the app's own; the page's text goes below. + let named = frame.and_then(frame_origin); + let title = dialog_title(named.as_ref().map(|(origin, embedded)| (origin.as_str(), *embedded))); + alert.setMessageText(&NSString::from_str(&title)); + alert.setInformativeText(&body); let _ = alert.addButtonWithTitle(&NSString::from_str("OK")); if panel != Panel::Alert { let _ = alert.addButtonWithTitle(&NSString::from_str("Cancel")); @@ -623,16 +624,18 @@ mod imp { } /// The host (and port) of the frame that opened a dialog: its security - /// origin, else its request URL's host. The flag is set when the origin is - /// opaque (a sandboxed frame), where only the URL names a host. + /// origin, else its request URL's host. The flag says "embedded". It is + /// set for every sub-frame, whatever its origin (a trusted web tab is + /// proxied under Codeman's own origin and would read as Codeman + /// otherwise), and whenever the origin names no host, which is an opaque + /// origin (a sandboxed frame) or no origin at all: a frame the app is + /// unsure about never speaks as the page. fn frame_origin(frame: &WKFrameInfo) -> Option<(String, bool)> { autoreleasepool(|_| { // SAFETY (every read here): property reads on objects WebKit handed // us, with nil handled by the `Option` return types. + let sub_frame = !unsafe { frame.isMainFrame() }; let origin: Option> = unsafe { msg_send![frame, securityOrigin] }; - // An origin without a host is opaque; when unsure, say "embedded" - // rather than let a frame speak as the page. - let opaque = origin.is_some(); if let Some(origin) = origin { let host: Option> = unsafe { msg_send![&*origin, host] }; let port: NSInteger = unsafe { msg_send![&*origin, port] }; @@ -641,7 +644,7 @@ mod imp { .and_then(rust_string) .and_then(|h| origin_label(&h, port as i64)) { - return Some((label, false)); + return Some((label, sub_frame)); } } let request: Option> = unsafe { msg_send![frame, request] }; @@ -650,7 +653,7 @@ mod imp { let url = url?; let host: Option> = unsafe { msg_send![&*url, host] }; let host = host?; - Some((origin_label(&rust_string(&host)?, 0)?, opaque)) + Some((origin_label(&rust_string(&host)?, 0)?, true)) }) } @@ -1042,12 +1045,15 @@ mod tests { assert_eq!(origin_label("::1", 3000).as_deref(), Some("[::1]:3000")); assert_eq!(origin_label(" ", 3000), None); assert_eq!( - dialog_title("localhost:3000", false), + dialog_title(Some(("localhost:3000", false))), "The page at localhost:3000 says:" ); assert_eq!( - dialog_title("localhost:3000", true), + dialog_title(Some(("localhost:3000", true))), "An embedded page on localhost:3000 says:" ); + // A frame nothing names (about:srcdoc under an opaque origin) still + // gets a first line of the app's own, never its own text in bold. + assert_eq!(dialog_title(None), "An embedded page says:"); } } diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 0025f47a..12e4ae6a 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -51,7 +51,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView use config::{Config, Rect, WindowFrame}; use credentials::Keychain; -use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Via}; +use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via}; use probe::ProbeOutcome; const APP_NAME: &str = "Codeman"; @@ -68,6 +68,10 @@ const LOAD_TIMEOUT: Duration = Duration::from_secs(15); /// page reported that its session is gone, so a login the server keeps /// refusing can never turn into a reload loop. const SESSION_RELOAD_COOLDOWN: Duration = Duration::from_secs(60); +/// A window loads its page again at most once in this long after its web +/// content process ended, so a page that cannot stay up never becomes a reload +/// loop. +const CRASH_RELOAD_COOLDOWN: Duration = Duration::from_secs(30); /// When the app first looks whether the main window made it into the full /// screen it asked for at launch, how often it asks again, and how many times. /// tao retries a failed entry only for a window BUILT in full screen, which @@ -88,6 +92,7 @@ const AUTH_REJECTED: &str = "The server did not accept the saved login. Enter th const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The saved password may be wrong: \ enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes."; const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again."; +const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again."; const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address."; mod menu_id { @@ -157,6 +162,12 @@ enum UserEvent { /// The challenge handler sent its login and got challenged again (macOS). /// Not trusted on its own: recheck_login asks the server first. AuthRejected, + /// The web content process of a window ended (macOS): a crash, or the + /// system taking it back under memory pressure. The window is blank until + /// something loads in it again (on_content_process_gone). + ContentProcessGone { + window: WindowId, + }, /// A background step for the connect page finished (This Mac status, an /// install opened in Terminal, the install command copied): the message /// goes to the page if it is still the one choosing a server. @@ -211,6 +222,8 @@ struct AppWindow { committed: Option, /// When the window last reloaded itself because its session expired. session_reload_at: Option, + /// When the window's web content process last ended. + content_process_gone_at: Option, } struct AppMenu { @@ -324,6 +337,7 @@ fn main() { kind: Kind::Main, committed: None, session_reload_at: None, + content_process_gone_at: None, }, ); @@ -714,6 +728,17 @@ fn build_webview( }); }); + // wry answers WebKit's "web content process ended" notice itself, which + // turns WebKit's own reload off: without a handler the window stays blank. + #[cfg(target_os = "macos")] + { + use wry::WebViewBuilderExtDarwin; + let gone_proxy = proxy.clone(); + builder = builder.with_on_web_content_process_terminate_handler(move || { + let _ = gone_proxy.send_event(UserEvent::ContentProcessGone { window: id }); + }); + } + builder = match load { Load::ConnectPage { manual: false } => builder.with_url(policy::CONNECT_PAGE), Load::ConnectPage { manual: true } => builder.with_url(format!("{}?manual=1", policy::CONNECT_PAGE)), @@ -973,6 +998,45 @@ impl App { } } UserEvent::AuthRejected => self.recheck_login(), + UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window), + } + } + + /// The web content process of `window` ended, so the window is blank + /// (policy::recovery decides what it does about it). A second end within + /// CRASH_RELOAD_COOLDOWN counts as the same page failing again. + fn on_content_process_gone(&mut self, window: WindowId) { + let now = Instant::now(); + let Some(w) = self.windows.get_mut(&window) else { + return; + }; + // A pop-out hidden for its download shows no page, and the download + // does not run in that process: loading again would fetch it twice. + if matches!( + w.kind, + Kind::Popout { + hidden_for_download: true, + .. + } + ) { + return; + } + let again = w + .content_process_gone_at + .is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN); + w.content_process_gone_at = Some(now); + let on_server = self.phase.borrow().loaded().is_some(); + match policy::recovery(window == self.main_id, on_server, again) { + Recovery::Reload => { + if let Some((url, with_auth)) = self.reload_target(window) { + self.load_in(window, &url, with_auth); + } + } + Recovery::ConnectPage { gave_up } => { + self.show_connect_page(gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false); + } + Recovery::Close => self.remove_window(window), + Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"), } } @@ -1610,6 +1674,7 @@ impl App { }, committed: None, session_reload_at: None, + content_process_gone_at: None, }, ); true diff --git a/packages/desktop/src/policy.rs b/packages/desktop/src/policy.rs index 7680cc33..a1fbb194 100644 --- a/packages/desktop/src/policy.rs +++ b/packages/desktop/src/policy.rs @@ -186,8 +186,9 @@ pub fn trusted_frame(frame: &Url, server: &Url) -> bool { /// (`session/`), a rendered HTML file (`html-view/...`, served with a /// sandbox CSP), a detached file preview or attachment (`api/sessions//` /// `file-raw`, `file-preview`, `attachments//raw|preview`, -/// `folders//files/...`) and a download (`api/download`). A script's -/// `window.open` gets only the last two kinds (new_window, Via::Script). +/// `folders//files/...`, and the path picker's `api/filesystem/preview`) +/// and a download (`api/download`). A script's `window.open` gets only the +/// last two kinds (new_window, Via::Script). /// /// Everything else on the origin is refused, a proxied dashboard under /// `webview/` above all. A browser keeps the opener frame's sandbox on a popup; @@ -222,8 +223,8 @@ enum Popout { Session(String), /// `html-view/...`: a rendered HTML file. HtmlView, - /// A detached file preview, an attachment, a file in a granted folder, or - /// a download (`api/download`). + /// A detached file preview, an attachment, a file in a granted folder, + /// the path picker's preview, or a download (`api/download`). File, } @@ -245,7 +246,7 @@ fn popout_kind(url: &Url, server: &Url) -> Option { } match segments.as_slice() { ["session", id] if !id.is_empty() => Some(Popout::Session((*id).to_string())), - ["api", "download"] => Some(Popout::File), + ["api", "download"] | ["api", "filesystem", "preview"] => Some(Popout::File), ["api", "sessions", id, "file-raw" | "file-preview"] if !id.is_empty() => Some(Popout::File), ["api", "sessions", id, "attachments", attachment, "raw" | "preview"] if !id.is_empty() && !attachment.is_empty() => @@ -414,6 +415,38 @@ impl Default for OpenBudget { } } +/// What a window does once its web content process has ended: a crash, or the +/// system taking the process back under memory pressure. The window is blank +/// from then on, since nothing reloads it by itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Recovery { + /// Load the window's page again. + Reload, + /// Show the connect page: with the reason, when a server page was given up. + ConnectPage { gave_up: bool }, + /// Close the pop-out. + Close, + /// Leave the window as it is. + Nothing, +} + +/// How a window recovers from the end of its web content process. `on_server`: +/// a server page is the main window's document (pop-outs only exist then). +/// `again`: the same window lost its process a moment ago already, so its page +/// cannot stay up and loading it once more would only repeat that. +pub fn recovery(is_main: bool, on_server: bool, again: bool) -> Recovery { + match (is_main, on_server, again) { + (_, true, false) => Recovery::Reload, + (true, true, true) => Recovery::ConnectPage { gave_up: true }, + // The connect page itself, or a server load that had not committed. + (true, false, false) => Recovery::ConnectPage { gave_up: false }, + (true, false, true) => Recovery::Nothing, + (false, _, true) => Recovery::Close, + // A pop-out with no server page behind it is on its way out anyway. + (false, false, false) => Recovery::Nothing, + } +} + #[derive(Debug, PartialEq, Eq)] pub enum Navigation { Allow, @@ -930,6 +963,7 @@ mod tests { "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw", "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview", "http://127.0.0.1:3000/api/download?path=x", + "http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md", ] { assert_eq!(script(s), NewWindow::AppWindow(u(s)), "{s}"); assert_eq!(host(s), NewWindow::AppWindow(u(s)), "{s}"); @@ -1105,6 +1139,10 @@ mod tests { "http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview" )); assert!(ok("http://127.0.0.1:3000/api/download?path=x")); + // The path picker's preview, which its Open link shows in a window. + assert!(ok( + "http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md&sessionId=s1" + )); // A single decode is how the server's router sees the path. assert!(ok("http://127.0.0.1:3000/%73ession/abc")); @@ -1138,6 +1176,10 @@ mod tests { 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/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")); + assert!(!ok("http://127.0.0.1:3000/api/filesystem/mkdir")); + assert!(!ok("http://127.0.0.1:3000/api/filesystem/preview/x")); assert!(!ok("http://127.0.0.1:3000/api/status")); // Other origins and userinfo. assert!(!ok("http://127.0.0.1:3001/session/abc")); @@ -1274,6 +1316,24 @@ mod tests { assert!(!b.allow(later)); } + #[test] + fn a_window_recovers_from_a_lost_content_process_once() { + // The first time, every window that shows a server page loads it again. + assert_eq!(recovery(true, true, false), Recovery::Reload); + assert_eq!(recovery(false, true, false), Recovery::Reload); + // The connect page is shown anew, which also ends a load under way. + assert_eq!(recovery(true, false, false), Recovery::ConnectPage { gave_up: false }); + // A second time in a row never reloads: that would loop. + assert_eq!(recovery(true, true, true), Recovery::ConnectPage { gave_up: true }); + assert_eq!(recovery(false, true, true), Recovery::Close); + assert_eq!(recovery(true, false, true), Recovery::Nothing); + for is_main in [true, false] { + for on_server in [true, false] { + assert_ne!(recovery(is_main, on_server, true), Recovery::Reload); + } + } + } + #[test] fn basic_auth() { assert_eq!(basic_auth_header("admin", "secret"), "Basic YWRtaW46c2VjcmV0"); diff --git a/packages/desktop/src/setup.rs b/packages/desktop/src/setup.rs index 2efa931f..c3719b93 100644 --- a/packages/desktop/src/setup.rs +++ b/packages/desktop/src/setup.rs @@ -359,6 +359,16 @@ fn codeman_servers(procs: &[(u32, String)]) -> Vec { .collect() } +/// The install the app started on this Mac, if one runs, with the port it +/// asked for as soon as one of its processes names it. The Terminal script +/// that wraps the installer names none and is the older process, so the first +/// match alone would report no port for the whole install. +fn running_install(procs: &[(u32, String)]) -> Option> { + let mut installs = procs.iter().filter_map(|(_, c)| install_in_progress(c)).peekable(); + installs.peek()?; + Some(installs.flatten().next()) +} + /// A listening TCP socket: its pid, address (`*`, `127.0.0.1`, `[::1]`, a /// specific IP) and port. #[derive(Debug, Clone, PartialEq, Eq)] @@ -494,7 +504,7 @@ pub fn inspect(depth: Depth) -> LocalStatus { let procs = list_processes(); let servers = codeman_servers(&procs); let found = listeners(&servers); - let install = procs.iter().find_map(|(_, c)| install_in_progress(c)); + let install = running_install(&procs); // The login goes only to this user's own Codeman on that port. let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) { @@ -612,6 +622,15 @@ fn already_running() -> Option { )) } +/// Whether `url` is where the launchd job `service` answers: its scheme and +/// port on this Mac, under `localhost` as much as 127.0.0.1. The path is left +/// out: a job with a base path answers below `/` too, and a second server on +/// its port would run without the job's settings. +fn served_by(service: &ServiceConfig, url: &Url) -> bool { + let at = local_url(Some(service)); + policy::is_loopback(url) && url.scheme() == at.scheme() && url.port_or_known_default() == at.port_or_known_default() +} + /// Start the Codeman on this Mac for `url`. A LaunchAgent that serves that /// address is started through launchd, so it runs with its own settings /// (host, password) and stays supervised; anything else gets `codeman web -d` @@ -621,7 +640,7 @@ pub fn start_local(url: &Url) -> Result { return Err(why); } match read_service() { - Some(s) if local_url(Some(&s)) == *url => { + Some(s) if served_by(&s, url) => { if s.daemon { return Err(format!( "A system LaunchDaemon runs Codeman on this Mac; an administrator starts it: sudo launchctl kickstart -k system/{SERVICE_LABEL}" @@ -1218,6 +1237,27 @@ mod tests { ); } + #[test] + fn a_start_goes_to_the_launchd_job_that_serves_the_address() { + let job = parse_service(&json!({ + "ProgramArguments": ["node", "i.js", "web"], + "EnvironmentVariables": { "CODEMAN_PORT": "3001", "CODEMAN_BASE_URL": "/codeman" } + })); + // The app's own address for it, and what a user types for the same server. + assert!(served_by(&job, &u("http://127.0.0.1:3001/codeman/"))); + assert!(served_by(&job, &u("http://localhost:3001/codeman/"))); + assert!(served_by(&job, &u("http://LOCALHOST:3001/"))); + // Another port, scheme or machine is not this job's server. + assert!(!served_by(&job, &u("http://127.0.0.1:3000/codeman/"))); + assert!(!served_by(&job, &u("https://127.0.0.1:3001/codeman/"))); + assert!(!served_by(&job, &u("http://192.168.1.5:3001/codeman/"))); + assert!(!served_by(&job, &u("http://localhost.example.com:3001/"))); + + let https = parse_service(&json!({ "ProgramArguments": ["node", "i.js", "web", "--https"] })); + assert!(served_by(&https, &u("https://localhost:3000/"))); + assert!(!served_by(&https, &u("http://localhost:3000/"))); + } + #[test] fn processes_and_listeners() { let ps = " 101 /opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web\n\ @@ -1242,6 +1282,11 @@ mod tests { assert_eq!(codeman_servers(&procs), [101, 202, 303]); let installs: Vec> = procs.iter().filter_map(|(_, c)| install_in_progress(c)).collect(); assert_eq!(installs, [None, Some(3001)]); + // The Terminal script comes first and names no port: the installer's wins. + assert_eq!(running_install(&procs), Some(Some(3001))); + // Before the installer is downloaded, only the script runs. + assert_eq!(running_install(&procs[..8]), Some(None)); + assert_eq!(running_install(&procs[..7]), None); let lsof = "p101\nf21\nn127.0.0.1:3000\nf22\nn172.17.0.1:3999\np202\nf9\nn*:4000\np303\nn[::1]:5000\nnbogus\n"; let found = parse_lsof(lsof); From 116db3ba66a665ce3dd9a24c9d1daf13a066b22b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 03:26:57 +0100 Subject: [PATCH 2/4] fix(desktop): a connect page that holds still, answers a press and never strands the user Layout: - The header no longer moves. The page was centred with align-items, so every change of the card's height moved everything (up to 150px between states), and a page taller than its window lost its top above the scroll origin, where no scrolling reaches it. The top margin is now fixed, the card grows downward and has a minimum height, and the page scrolls from its top. A short window gets a compact header. - The status line sits right under the action it reports on, above the "not installed there yet?" section, takes no room while empty, and is scrolled into view in a small window. It was below the fold there. - Looking at this Mac again keeps what was found on screen (the pane used to collapse to "Looking..." and grow back). Feedback and keys: - Buttons answer hover and press, every control shows keyboard focus, the primary button and the footer meet WCAG AA contrast, "Opening Codeman" keeps its spinner while the server page loads. - Return opens or starts the Codeman on this Mac, Esc cancels a connect. Install always takes a click. - The This Mac result is a live region, Forget buttons are named after their server, a long unbroken token wraps inside the card, the status text can be selected. Correctness: - The second click of a double click acts only on the control that took the first. It could connect to the server that slid under the pointer after a Forget, or flip "Connect automatically". - An address, username or password too long for the app is refused with a reason. The app drops such a message without an answer, which left the page on "Connecting..." (the limits are pinned to policy.rs by a test). - A connect answer goes to the tab that asked, a failed SSH install to its tab, and the login block no longer claims a saved password for another server. - An automatic connect to the Codeman on this Mac no longer looks at this Mac first; a failed connect, Cancel or an error asks for that look. - A login over plain http:// to another machine says that it is unencrypted. Co-Authored-By: Claude Fable 5.1 --- packages/desktop/README.md | 2 +- packages/desktop/assets/connect.html | 197 ++++++++++++++++++++------- packages/desktop/src/assets.rs | 13 ++ packages/desktop/src/policy.rs | 4 +- 4 files changed, 163 insertions(+), 53 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index de628b92..ece14eda 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -30,7 +30,7 @@ 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. | | **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. 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. 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. | diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index a950f178..d84f3fda 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -20,20 +20,35 @@ --ok: #4ade80; } * { box-sizing: border-box; } - html, body { height: 100%; margin: 0; } + html { height: 100%; } body { + min-height: 100%; margin: 0; background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; - display: flex; align-items: center; justify-content: center; + display: flex; flex-direction: column; align-items: center; -webkit-user-select: none; user-select: none; } - main { width: min(440px, calc(100vw - 48px)); } + /* The top margin centres the tallest everyday page (a saved server with its + login, 708px), so the header stays where it is while the card changes: a + taller card grows downward, and a window too small for the page scrolls it + from the top (centring with align-items would cut the top off, out of + reach). */ + main { width: min(440px, calc(100vw - 48px)); margin: max(20px, calc((100vh - 708px) / 2)) 0 24px; } header { text-align: center; margin-bottom: 26px; } header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; } h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; } header p { margin: 4px 0 0; color: var(--muted); } + @media (max-height: 640px) { + header { margin-bottom: 16px; } + header img { width: 48px; height: 48px; margin-bottom: 8px; } + header p { display: none; } + h1 { font-size: 20px; } + } + /* As tall as its usual content at least, with the two settings at its foot, + so switching tabs or a status arriving does not resize it. */ .card { + display: flex; flex-direction: column; min-height: 304px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45); } @@ -67,16 +82,23 @@ button.primary, button.secondary { height: 38px; border-radius: 9px; font: inherit; font-weight: 600; padding: 0 16px; cursor: default; } - button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-2) 100%); } - button.primary:disabled { opacity: 0.55; } + button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, #2f6fed 0%, #2563eb 100%); } button.secondary { border: 1px solid var(--line-strong); background: #16181d; color: var(--text); } + /* A press answers at once, before the app does. Disabled comes last, so a + button that cannot be pressed does not look as if it could. */ + button.primary:hover { filter: brightness(1.07); } + button.primary:active { filter: brightness(0.9); } button.secondary:hover { border-color: var(--accent-2); } - button.secondary:disabled { opacity: 0.55; } + button.secondary:active { background: #1f232b; } + button.primary:disabled, button.secondary:disabled { opacity: 0.55; filter: none; } + button.secondary:disabled { border-color: var(--line-strong); background: #16181d; } + button:focus-visible, .check input:focus-visible, summary:focus-visible, footer a:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; } button[hidden] { display: none; } - #status { min-height: 20px; margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; } + #status { margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; } #status.bad { color: var(--bad); } #status.warn { color: var(--warn); } #status.ok { color: var(--ok); } + #status-text { min-width: 0; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; } #status .detail { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; white-space: pre-wrap; -webkit-user-select: text; user-select: text; } #suggest { display: block; max-width: 100%; margin-top: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #suggest[hidden] { display: none; } @@ -86,7 +108,8 @@ .spinner[hidden] { display: none; } @keyframes spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } } - .prefs { margin-top: 16px; } + .prefs { margin-top: auto; } + .prefs::before { content: ''; display: block; margin-top: 16px; padding-top: 4px; border-top: 1px solid var(--line); } .tabs { display: flex; gap: 4px; padding: 4px; margin: 0 0 18px; background: #0c0d10; border: 1px solid var(--line); border-radius: 11px; } .tabs button { all: unset; flex: 1; text-align: center; height: 30px; line-height: 30px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--muted); cursor: default; } .tabs button:hover { color: var(--text); } @@ -103,7 +126,8 @@ #local-title { font-weight: 600; } .sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; } #local-network-row[hidden] { display: none; } - #elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); } + #elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); } + #elsewhere[hidden] { display: none; } #elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; } #elsewhere summary::-webkit-details-marker { display: none; } #elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; } @@ -116,10 +140,11 @@ .oneliner { display: flex; gap: 8px; align-items: center; margin-top: 8px; } .oneliner code { flex: 1; min-width: 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; background: #0c0d10; border: 1px solid var(--line-strong); border-radius: 8px; padding: 9px 10px; color: #c9d1d9; -webkit-user-select: all; user-select: all; overflow-x: auto; white-space: nowrap; } .statusrow { display: flex; gap: 10px; align-items: flex-start; } + .statusrow[hidden] { display: none; } .statusrow #status { flex: 1; } .statusrow #cancel { margin-top: 10px; } - footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; } - footer a { color: #7c8491; text-decoration: none; cursor: default; } + footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; } + footer a { color: #aab1bb; text-decoration: none; cursor: default; } footer a:hover { color: var(--accent); } kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 4px; padding: 2px 4px; color: #9aa1ab; } @@ -143,7 +168,7 @@
-
+
Looking for Codeman on this Mac…
@@ -163,7 +188,7 @@ -
+