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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 03:20:01 +01:00
1 parent 8ba5134174
commit 01d3379b74
6 files changed
+214 -36

No files matched your search

+4 -3
View File
@@ -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:<port>` or `http://localhost:<port>` 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
+2 -1
View File
@@ -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);
+30 -24
View File
@@ -161,14 +161,17 @@ fn origin_label(host: &str, port: i64) -> Option<String> {
}
/// 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<Retained<WKSecurityOrigin>> = 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<Retained<NSString>> = 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<Retained<NSURLRequest>> = unsafe { msg_send![frame, request] };
@@ -650,7 +653,7 @@ mod imp {
let url = url?;
let host: Option<Retained<NSString>> = 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:");
}
}
+66 -1
View File
@@ -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<Url>,
/// When the window last reloaded itself because its session expired.
session_reload_at: Option<Instant>,
/// When the window's web content process last ended.
content_process_gone_at: Option<Instant>,
}
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
+65 -5
View File
@@ -186,8 +186,9 @@ pub fn trusted_frame(frame: &Url, server: &Url) -> bool {
/// (`session/<id>`), a rendered HTML file (`html-view/...`, served with a
/// sandbox CSP), a detached file preview or attachment (`api/sessions/<id>/`
/// `file-raw`, `file-preview`, `attachments/<id>/raw|preview`,
/// `folders/<id>/files/...`) and a download (`api/download`). A script's
/// `window.open` gets only the last two kinds (new_window, Via::Script).
/// `folders/<id>/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<Popout> {
}
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");
+47 -2
View File
@@ -359,6 +359,16 @@ fn codeman_servers(procs: &[(u32, String)]) -> Vec<u32> {
.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<Option<u16>> {
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<String> {
))
}
/// 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<String, String> {
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<Option<u16>> = 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);