diff --git a/config/test-suites.ts b/config/test-suites.ts index 49812b3a..e6f95e04 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -60,6 +60,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/fullscreen-tab-switch-capture.browser.test.ts', 'test/terminal-touch-swipe-axis.browser.test.ts', 'test/phone-press-feedback.browser.test.ts', + 'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception ]; /** diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 8ba34096..b148b890 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -28,17 +28,18 @@ 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 sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done 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). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. 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 sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done 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). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. 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. 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. | +| **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. | ## Build (on a Mac) @@ -54,6 +55,12 @@ cargo test # unit tests open dist/Codeman.app ``` +The connect page has its own test, which needs no Mac: from the repository +root, `npm run test:browser -- test/desktop-connect-page.browser.test.ts` drives +`assets/connect.html` in Chromium through every state of both tabs, under the +Content-Security-Policy the app serves it with. It is part of the nightly +browser suite, not of `npm test`. + To try a change to `install.sh` before it is merged, build with `CODEMAN_DESKTOP_INSTALLER_URL` (where to get the installer) and `CODEMAN_DESKTOP_INSTALL_BRANCH` (the branch it installs from) set. Both are @@ -100,8 +107,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 @@ -260,11 +267,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 cd2457a2..f7e4b1a8 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -20,22 +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; justify-content: center; + display: flex; flex-direction: column; align-items: center; -webkit-user-select: none; user-select: none; } - /* margin:auto, not align-items:center: a flex child taller than the window - then starts at the top instead of being cut off above it. */ - main { width: min(440px, calc(100vw - 48px)); margin: auto; padding: 24px 0; } + /* 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); } @@ -69,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; } @@ -88,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); } @@ -104,7 +125,8 @@ @media (prefers-reduced-motion: reduce) { .dot.busy { animation: none; } } #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; } - #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; } @@ -117,14 +139,15 @@ .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; } - /* The graphical install takes over the card, and its steps differ in height: - anchored from the top, so the logo does not jump from one to the next. */ - main:has(.card.wizard-on) { margin-top: max(0px, calc(14vh - 60px)); margin-bottom: auto; } + /* The graphical install takes over the card. Its steps differ in height, and + the page is anchored from the top (see `main`), so the logo stays put. */ main:has(.card.wizard-on) header p { display: none; } .card.wizard-on > .tabs, .card.wizard-on > #pane-local, .card.wizard-on > #form, - .card.wizard-on > .statusrow, .card.wizard-on > #suggest, .card.wizard-on > .prefs { display: none; } + .card.wizard-on > .statusrow, .card.wizard-on > #suggest, .card.wizard-on > #elsewhere, + .card.wizard-on > .prefs { display: none; } #wizard[hidden], #wizard [hidden] { display: none; } .wiz-head { display: flex; align-items: center; gap: 9px; } .wiz-head .spinner { margin-top: 0; } @@ -194,8 +217,8 @@ white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; } #wizard .actions .wiz-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; } - 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; } @@ -258,7 +281,7 @@
-
+
Looking for Codeman on this Mac…
@@ -277,7 +300,7 @@ -
+