From 116db3ba66a665ce3dd9a24c9d1daf13a066b22b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 03:26:57 +0100 Subject: [PATCH] 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 @@ -
+