feat(desktop): server tabs, several Codeman servers open in one window

The Mac app showed one server at a time, and moving to another machine
meant Switch Server and a fresh connect. Now every server gets a tab in
the title bar, and every tab stays live.

- Tab strip: a small bundled page (assets/tabs.html) in its own web view
  across the top of the window, with the traffic lights inset into it.
  Each tab has a colour and a status badge: a red count when sessions
  need you, an amber dot when they are waiting, a blue ring while they
  work, a dashed circle while the server does not answer.
- The status comes from the app, not from the page: one thread per tab
  reads GET /api/sessions every 2 seconds (macOS stalls hidden web
  views), backs off for 60 s on a 429 and stops on 401/403, so a wrong
  password is never retried in a loop.
- + or ⌘T opens the connect screen in a new tab. A server that is
  already open just brings its tab forward instead of opening twice,
  also when two connects race (checked again when the probe ends).
- Double-click or the right-click menu renames a tab; the same menu
  sets the colour, reloads, reconnects, closes, or closes the others.
  Tabs can be dragged. A new Server menu carries ⌘T, ⌘W, ⇧⌘T (reopen
  closed tab, last 10), ⌘0, ⇧⌘[ / ⇧⌘] and ⌘1 to ⌘8 (⌘9 is the last
  tab once there are nine). Close Window moves to ⇧⌘W. Ctrl+Tab and
  Alt+1..9 stay with Codeman's own session switching.
- ⌘0 shows every server at once (assets/overview.html): the ones that
  need you first, up to four sessions each with what they ask, and a
  click on a session opens its tab and selects it.
- Tabs are saved in desktop.json (tabs, activeTab) and come back at
  launch, connecting by themselves when auto-connect is on.
- Logins: each tab keeps its own Basic credentials, and a challenge is
  answered only with the login of the tab whose web view asked
  (macos.rs binds each WKWebView to its tab).
- Cookies: on macOS 14 and later every server gets its own WebKit data
  store (WKWebsiteDataStore dataStoreForIdentifier, with a stable id
  hashed from the server URL), so a server on port 3000 and a beta on
  5000 of the same host cannot log each other out. Before macOS 14 a
  second tab on a host that is already open is refused with a message.
  The server used last before this change keeps the default store, so
  its per-device settings (font size, skin) survive; every other
  server starts those fresh once.
- The lid logo blink watches all open servers, and Keep This Mac Awake
  follows the tab that runs on this Mac.

New pure model in src/tabs.rs (names, colours, order, shortcuts, strip
and overview state) with unit tests; agents.rs reads the per-session
detail for the badges and the overview; policy.rs parses the strip and
overview IPC and refuses it from any other frame. Browser tests drive
both bundled pages (test/desktop-tabs-pages.browser.test.ts) and the
connect page's restored-tab path.

Not yet run in a real window: the Mac mini has no screen session. The
owner checks the traffic-light placement, clicks under the transparent
title bar, window drag and zoom from the strip, keyboard focus after
strip clicks, and two logged-in servers side by side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-14 18:36:08 +01:00
1 parent e99b42c387
commit 4cedeec529
18 files changed
+6105 -826

No files matched your search

+6 -2
View File
@@ -35,8 +35,8 @@ security-framework = "3"
# Ignoring signals in the logo blinker (src/logo.rs). Already in the tree
# through wry and tao, so no new crate is added.
libc = "0.2"
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
# content size (src/macos.rs). The versions wry already pulls in, so no new
# JavaScript dialogs, HTTP Basic challenges, clearing one server's cookies and
# the window's content size (src/macos.rs). The versions wry already pulls in, so no new
# crate is added.
objc2 = { version = "0.6.5", default-features = false, features = ["std"] }
block2 = { version = "0.6.2", default-features = false, features = ["std"] }
@@ -44,10 +44,12 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"std",
"objc2-core-foundation",
"block2",
"NSArray",
"NSBundle",
"NSDate",
"NSDictionary",
"NSGeometry",
"NSHTTPCookie",
"NSObject",
"NSOperation",
"NSProcessInfo",
@@ -61,6 +63,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"NSURLRequest",
"NSURLResponse",
"NSURLSession",
"NSUUID",
"NSValue",
] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
@@ -80,6 +83,7 @@ objc2-web-kit = { version = "0.3.2", default-features = false, features = [
"block2",
"objc2-app-kit",
"WKFrameInfo",
"WKHTTPCookieStore",
"WKNavigationDelegate",
"WKSecurityOrigin",
"WKUIDelegate",
+38 -9
View File
@@ -3,7 +3,8 @@
A small native app that opens Codeman in its own window. Double-click it, and
it runs Codeman on this Mac (installing it first if it is not there) or
connects to Codeman on another machine (installing it there over SSH if
needed), and Codeman fills the window. `⌃⌘F` puts it in full screen.
needed), and Codeman fills the window. Several servers open side by side as
tabs in the title bar, one click apart. `⌃⌘F` puts it in full screen.
macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
@@ -28,20 +29,21 @@ cannot do.
| | |
| --- | --- |
| **Server tabs** | One tab per Codeman server, in a strip that takes the place of the title bar (the traffic lights sit at its left end, so the strip costs about 10 points over a plain title bar and stays in full screen). A click switches tabs, and every tab stays loaded, so a switch is instant: no reload, no new login. Each tab shows what its server's agents do: a red count for sessions that need a permission or an answer, an amber dot when one finished and waits for you, a blue ring while agents work, a dashed circle when the server does not answer. The app reads that itself from each server every two seconds (`api/sessions` and `api/approvals`, the reader the Apple logo uses), because WebKit stalls a hidden page. Each tab has a colour of its own, as a stripe on the tab in front, so you do not type into the wrong machine. `+` (or `⌘T`) opens the connect screen in a new tab; a server that is already open in a tab is not opened twice, its tab comes to the front instead. A double-click on a name, or **Rename Tab…** in the right-click menu, renames it; the right-click menu also picks the colour, reloads, connects the tab to another server and closes tabs. Drag a tab to reorder. Closing a tab only closes the window onto that server: its sessions keep running there, and `⇧⌘T` opens it again. The tabs of a server come back at the next launch, and connect by themselves when *Connect automatically when the app opens* is on. **All Servers** (`⌘0`, or the grid button left of the tabs) shows every server at once, the ones that need you first, with their sessions most urgent first; a click on a session opens its tab and selects it. |
| **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). The tab also finds every Codeman server of yours that already runs on this Mac (with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, a beta instance beside the installed one) and recommends connecting to it. Each one's address is worked out from its listening socket and its command line (`127.0.0.1` for a bind to loopback or to every interface, its own address for a bind to one, `https` with `--https`, the path of `--base-url`), and each is asked once whether Codeman answers there. When nothing answers at the usual address (the LaunchAgent's, or `http://127.0.0.1:3000` without one), *Open Codeman* is for the first found server that does; when none of them can be opened, the tab says *Codeman is already running on this Mac* and lists them. Next to a Codeman that answers, the others are listed under *Also running on this Mac*. A row shows the address, the version, and a *Connect* button, or the reason the app cannot open that one (HTTPS with Codeman's own certificate, no answer). *Connect* is the connect of the Another machine tab with that address filled in: the same check, the login saved for it, the same entry under recent servers. Finding a server connects to nothing by itself. The tab 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. *Add a coding agent*, on this tab and in the app menu whenever Codeman is on this Mac, is the same chooser after the install: pick one agent, watch it install, open Codeman. It needs no password, leaves a running Codeman alone, and can be cancelled or left running like an install. After *Skip for now* the app says Codeman has no agent yet and offers this before it opens Codeman. |
| **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://<host>: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. 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 (or connects to the one found here, when that is the tab's main button) 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:<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. |
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected that tab with (each tab answers for its own server only), 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 that server'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). On macOS 14 and later each server's pages keep their cookies and local storage in a data store of their own (see Decisions); before macOS 14 two servers on one machine (a Codeman on port 3000 and a beta on 5000) cannot be open in two tabs at once, since they would share one login cookie, and the app says so. A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. |
| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. |
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. |
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Developer Tools `⌥⌘I`, and a Server menu: New Server Tab `⌘T` (Switch Server `⇧⌘O` does the same), Close Tab `⌘W` (Close Window is `⇧⌘W`), Reopen Closed Tab `⇧⌘T`, Show All Servers `⌘0`, Show Previous and Next Tab `⇧⌘[` and `⇧⌘]`, and one item per tab, `⌘1` to `⌘8` (from nine tabs on, `⌘9` is the last one). `⌃Tab` and `⌥1` to `⌥9` stay Codeman's own (they switch sessions inside the server you are on). 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. 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. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. LogoLight (1.3 or later) steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards, with whatever it showed before; while an older LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses and writes nothing. |
| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always** (while the app is open). Off until you choose. Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says so. The display still sleeps. No password is needed anywhere: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only When Plugged In** (on by default) pauses it on battery, and **With the Lid Closed** keeps a closed MacBook awake as well: while it holds, the app has macOS's sleep-on-lid-close turned off (see Decisions), and turns it back on when it lets go, quits or crashes. The lid is never kept awake on battery, whatever the settings: unplugged with the lid closed, the Mac goes to sleep. A line at the bottom of the menu says what it does right now: whether the lid is covered, that it is left to macOS while an external display is connected (a docked MacBook stays awake with its lid closed by macOS's own doing), and if the Mac slept under the lid all the same, until when. Codeman 1.50.1 kept the lid awake through a small root helper, behind the administrator password. Nothing uses that helper any more, and where it is still installed the menu has **Remove the Old Lid Helper…** (the one item that asks for the password). It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. That is why, while it watches a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, as Cmd+H does, also while it is paused (on battery, say), since it carries on by itself: a click on its Dock icon brings it back, and only Cmd+Q (or Quit from the Dock) ends it. With no server open, or one on another machine, closing the window quits. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server: with several tabs open it shows the most urgent state of all of them together, so a red tab on any server flashes it. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. LogoLight (1.3 or later) steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards, with whatever it showed before; while an older LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses and writes nothing. |
| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always** (while the app is open). Off until you choose. It watches the Codeman on this Mac among the open tabs (the tab in front when that is one). Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says so. The display still sleeps. No password is needed anywhere: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only When Plugged In** (on by default) pauses it on battery, and **With the Lid Closed** keeps a closed MacBook awake as well: while it holds, the app has macOS's sleep-on-lid-close turned off (see Decisions), and turns it back on when it lets go, quits or crashes. The lid is never kept awake on battery, whatever the settings: unplugged with the lid closed, the Mac goes to sleep. A line at the bottom of the menu says what it does right now: whether the lid is covered, that it is left to macOS while an external display is connected (a docked MacBook stays awake with its lid closed by macOS's own doing), and if the Mac slept under the lid all the same, until when. Codeman 1.50.1 kept the lid awake through a small root helper, behind the administrator password. Nothing uses that helper any more, and where it is still installed the menu has **Remove the Old Lid Helper…** (the one item that asks for the password). It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. That is why, while it watches a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, as Cmd+H does, also while it is paused (on battery, say), since it carries on by itself: a click on its Dock icon brings it back, and only Cmd+Q (or Quit from the Dock) ends it. With no server open, or one on another machine, closing the window quits. |
| **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)
@@ -73,7 +75,8 @@ the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and
takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a
cargo config's `build.target-dir` work.
Settings live in `~/Library/Application Support/Codeman/desktop.json`; passwords
Settings live in `~/Library/Application Support/Codeman/desktop.json` (with the
tabs to open again: their servers, names, colours and which was in front); passwords
in the Keychain under *Codeman Desktop*, one item per username and server. A
`desktop.json` the app cannot read or parse is moved aside, never overwritten:
to `desktop.json.corrupt`, or to the first free name of `desktop.json.corrupt.1`
@@ -99,6 +102,29 @@ the page logged in. Using the crates directly is also smaller.
the JavaScript dialog methods and the authentication-challenge method to the
classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
**One window, a web view per tab.** The tab strip (`assets/tabs.html`) is a
web view of its own across the top of the window, and each tab's pages sit
below it, the one in front visible and the others hidden but loaded
(`src/tabs.rs` holds the pure half: names, colours, order, the state the strip
and the overview are sent). A tab shows the connect page until its server's
page commits: that page loads in a second, hidden web view behind it, so the
connect page keeps its status line and Cancel while the server loads, and the
load can be abandoned without a blank tab. The strip and the overview listen to
their own page only (`policy::parse_strip_ipc`, `parse_overview_ipc`) and
navigate nowhere else, and a session id from the overview goes into Codeman's
page only when it is made of the characters Codeman's ids are.
**A data store per server.** Codeman's login cookie has the same name on every
port, and cookies are not told apart by port, so two servers on one machine in
one store would keep logging each other out. On macOS 14 and later each
server's pages (its tab and its pop-outs) use a WebKit data store of their own,
named by a hash of the server's address (`tabs::store_id`), so it is the same
store at every launch. The one exception is the server used last before tabs
existed: it keeps WebKit's default store, and with it the local storage where
Codeman keeps its per-device settings (every other server starts those afresh
once). Before a connect only that server's cookies are removed from its store,
never another tab's.
**What a page may ask of the app** (all in `src/policy.rs`, unit-tested):
- Codeman shows content it did not write on its own origin: rendered HTML files
@@ -136,8 +162,11 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
relay 401s from content Codeman did not write. Only such a challenge can
send the user back to the connect screen. WebKit is handed the login without
persistence, so it keeps no copy: every challenge gets the current login,
and one from before a Switch Server is never replayed.
- Each connect starts by clearing the app's cookies (never local storage).
and one from before another connect is never replayed. Each tab hands over
a login of its own, and a challenge goes to the tab whose server holds the
URL that got the 401 (the deepest base path, when two servers share an
origin behind one proxy), so one tab never answers for another.
- Each connect starts by clearing that server's cookies (never local storage).
Codeman checks its session cookie before the `Authorization` header, so an
earlier user's cookie would otherwise win over the new login.
- Microphone and camera requests prompt per page. wry's default without a
@@ -242,7 +271,7 @@ can write to the runtime folder. It asks `preflight` for the list like an instal
does, and both pass `--no-admin`: that flag decides which agents count as
installable, so without it on both the page could offer one the run refuses. The
menu item is on the connect page because that is where the app may run things:
from a Codeman page it leaves the page the way *Switch Server* does.
from a Codeman page it opens in a new tab, as New Server Tab does.
**Terminal stays for what the app cannot do.** The route with Homebrew asks for
the administrator password on a terminal, the Xcode tools arrive as a system
+13 -6
View File
@@ -1636,13 +1636,20 @@
els.fullscreen.checked = !!msg.startFullscreen;
els.version.textContent = 'Codeman Desktop ' + (msg.version || '');
renderRecent();
if (state.servers[0]) choose(state.servers[0]);
// A server tab opened again at launch names its server (target), and
// connects to it by itself when connectNow says so; any other tab
// shows the server used last.
var target = typeof msg.target === 'string' && msg.target ? msg.target : '';
var targeted = target ? (state.servers.filter(function (s) { return s.url === target; })[0] || { url: target }) : null;
var shown = targeted || state.servers[0];
if (shown) choose(shown);
var auto = !!(msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]);
// The tab of the server used last; This Mac on a first run. An
// automatic connect opens Codeman without looking at this Mac first:
// that look runs a few processes and asks the server once more, and
// it is only needed when the connect fails, which then asks for it.
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local', auto);
if (targeted) auto = !!(msg.connectNow && !tooOld && !msg.starting && !msg.notice);
// The tab of that server; This Mac on a first run. An automatic
// connect opens Codeman without looking at this Mac first: that look
// runs a few processes and asks the server once more, and it is only
// needed when the connect fails, which then asks for it.
selectTab(shown && !isLocalUrl(shown.url) ? 'remote' : 'local', auto);
if (msg.starting) {
setBusy(true, false);
setStatus('busy', 'Starting Codeman on this Mac…', msg.notice || '');
+373
View File
@@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>All servers</title>
<style>
/* All servers (Cmd+0): one card per server tab, what needs you first. The
native side sends the whole list, already ordered, on every change. */
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html { min-height: 100%; background: #0b0c0f; }
body {
margin: 0; min-height: 100%; overflow-x: hidden; background: #0b0c0f; color: #e8eaed;
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
-webkit-user-select: none; user-select: none; cursor: default;
}
main { padding: 22px 28px 28px; }
header { display: flex; align-items: baseline; flex-wrap: wrap; column-gap: 14px; row-gap: 4px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: -0.01em; }
#summary { font-size: 13px; color: #8b9099; }
#summary .needs { color: #fca5a5; }
#summary .waiting { color: #fcd34d; }
#grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); gap: 18px; align-items: stretch; }
button { font: inherit; color: inherit; margin: 0; cursor: pointer; -webkit-user-select: none; user-select: none; }
button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
.card {
--c: #94a3b8;
display: flex; flex-direction: column; min-width: 0; min-height: 236px; padding-top: 3px;
border: 1px solid #23252b; border-radius: 12px; background: #121316;
box-shadow: inset 0 3px 0 var(--c);
}
.card.urgent { border-color: rgba(248,113,113,0.45); }
.card.offline { border-style: dashed; border-color: #30333b; background: #0f1013; box-shadow: inset 0 3px 0 #30333b; }
.card.slim { min-height: 0; align-self: start; }
.head {
display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 11px 16px 10px;
border: 0; border-radius: 9px 9px 0 0; background: transparent; text-align: left;
}
.head:hover .name { text-decoration: underline; text-decoration-color: #3a3d45; text-underline-offset: 3px; }
.head:focus-visible { outline-offset: -2px; }
.title { flex: 1; min-width: 0; }
.name { display: block; font-size: 15px; font-weight: 650; line-height: 1.35; color: #e8eaed; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card.offline .name { color: #8b9099; }
.url { display: block; margin-top: 2px; font-size: 12px; color: #8b9099; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.key { flex: none; padding-top: 2px; font-size: 12px; color: #6b7079; }
.body { flex: 1; min-height: 0; padding: 0 10px; }
.row {
display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 8px; margin: 0 0 3px;
border: 0; border-radius: 8px; background: transparent; font-size: 12.5px; line-height: 18px; text-align: left;
}
.row:hover { background: #1a1c21; }
.row.needs { background: rgba(248,113,113,0.10); }
.row.needs:hover { background: rgba(248,113,113,0.16); }
.row.waiting { background: rgba(251,191,36,0.07); }
.row.waiting:hover { background: rgba(251,191,36,0.12); }
.mark { flex: none; display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; }
.mark::before { content: ""; display: block; }
.row.needs .mark { border-radius: 9px; background: #f87171; color: #2a0707; font-weight: 700; font-size: 11px; line-height: 18px; }
.row.needs .mark::before { display: none; }
.row.waiting .mark::before { width: 9px; height: 9px; border-radius: 50%; background: #fbbf24; }
.row.working .mark::before {
width: 11px; height: 11px; border-radius: 50%; border: 2px solid #60a5fa; border-right-color: transparent;
animation: spin 1s linear infinite;
}
.row.idle .mark::before { width: 8px; height: 8px; border-radius: 50%; background: #3a3d45; }
.sname { flex: 0 1 auto; min-width: 0; max-width: 55%; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stext { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8b9099; }
.row.needs .stext { color: #fca5a5; }
.row.waiting .stext { color: #fcd34d; }
.row.idle .stext { color: #6b7079; }
.more { padding: 3px 8px 0; font-size: 12px; color: #6b7079; }
.note { display: flex; align-items: center; gap: 9px; padding: 4px 6px; font-size: 12.5px; color: #8b9099; }
.note.warn { color: #fcd34d; }
.foot { display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 10px 16px 14px; border-top: 1px solid #1d1f24; }
.meta { flex: 1; min-width: 0; font-size: 12px; color: #6b7079; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.action {
flex: none; height: 28px; padding: 0 12px; border: 1px solid #30333b; border-radius: 7px;
background: #16181d; color: #e8eaed; font-weight: 600; font-size: 12px;
}
.action:hover { background: #1d2026; border-color: #3a3d45; }
.action.urgent { border-color: #ef4444; background: #ef4444; color: #ffffff; }
.action.urgent:hover { border-color: #dc2626; background: #dc2626; }
.add {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
min-height: 236px; padding: 0; border: 1.5px dashed #30333b; border-radius: 12px;
background: transparent; color: #8b9099;
}
.add:hover { border-color: #3a3d45; color: #c9ccd2; }
.add-ico { width: 40px; height: 40px; border-radius: 12px; border: 1.5px dashed #3a3d45; display: flex; align-items: center; justify-content: center; }
.add-label { font-size: 13px; font-weight: 600; }
.add-key { font-size: 12px; color: #6b7079; }
.spin {
flex: none; width: 14px; height: 14px; border-radius: 50%;
border: 2px solid #30333b; border-top-color: #60a5fa; animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.row.working .mark::before, .spin { animation: none; }
}
</style>
</head>
<body>
<main>
<header>
<h1>All servers</h1>
<span id="summary"></span>
</header>
<div id="grid"></div>
</main>
<script>
(function () {
'use strict';
var FALLBACK_COLOR = '#94a3b8';
var COLOR_RE = /^#[0-9a-fA-F]{6}$/;
var MAX_ROWS = 4;
var STATES = ['needs', 'waiting', 'working', 'idle'];
var SVG_NS = 'http://www.w3.org/2000/svg';
var grid = document.getElementById('grid');
var summary = document.getElementById('summary');
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
// Names, URLs, versions and session texts come from servers: they are only
// ever set as text, and a colour only after it passed the #rrggbb check.
function str(v) { return typeof v === 'string' ? v : ''; }
function color(v) { return typeof v === 'string' && COLOR_RE.test(v) ? v : FALLBACK_COLOR; }
function count(v) {
var n = Math.floor(Number(v));
return isFinite(n) && n > 0 ? n : 0;
}
function shortcut(v) {
var n = Number(v);
return n >= 1 && n <= 9 && Math.floor(n) === n ? n : null;
}
function plural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }
function normServer(s) {
if (!s || typeof s !== 'object') return null;
var tab = Number(s.tab);
if (!isFinite(tab)) return null;
var c = s.counts && typeof s.counts === 'object' ? s.counts : {};
var sessions = [];
(Array.isArray(s.sessions) ? s.sessions : []).forEach(function (x) {
if (!x || typeof x !== 'object') return;
sessions.push({
id: str(x.id),
name: str(x.name),
state: STATES.indexOf(x.state) >= 0 ? x.state : 'idle',
text: str(x.text)
});
});
return {
tab: tab,
name: str(s.name),
color: color(s.color),
url: str(s.url),
local: s.local === true,
key: shortcut(s.key),
version: str(s.version),
kind: s.kind === 'connect' || s.kind === 'loading' ? s.kind : 'server',
offline: s.offline === true,
refused: s.refused === true,
sessions: sessions,
counts: {
needsYou: count(c.needsYou),
waiting: count(c.waiting),
working: count(c.working),
sessions: count(c.sessions)
}
};
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function button(cls, focusKey, text) {
var b = el('button', cls, text);
b.type = 'button';
b.dataset.focus = focusKey;
return b;
}
function plusIcon(size) {
var s = document.createElementNS(SVG_NS, 'svg');
s.setAttribute('width', String(size));
s.setAttribute('height', String(size));
s.setAttribute('viewBox', '0 0 16 16');
s.setAttribute('fill', 'none');
s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', '1.7');
s.setAttribute('aria-hidden', 'true');
var p = document.createElementNS(SVG_NS, 'path');
p.setAttribute('d', 'M8 2.5v11M2.5 8h11');
s.appendChild(p);
return s;
}
function renderSummary(servers) {
var needs = 0, waiting = 0, working = 0, offline = 0;
servers.forEach(function (s) {
if (s.offline) { offline++; return; }
needs += s.counts.needsYou;
waiting += s.counts.waiting;
working += s.counts.working;
});
var parts = [];
if (needs) parts.push(el('span', 'needs', needs + (needs === 1 ? ' needs you' : ' need you')));
if (waiting) parts.push(el('span', 'waiting', waiting + ' waiting'));
if (working) parts.push(el('span', 'working', working + ' working'));
if (offline) parts.push(el('span', 'offline', offline + ' offline'));
if (!parts.length) parts.push(el('span', 'calm', 'Nothing needs you'));
var out = [];
parts.forEach(function (p, i) {
if (i) out.push(document.createTextNode(' · '));
out.push(p);
});
summary.replaceChildren.apply(summary, out);
}
function sessionRow(s, x) {
var row = button('row ' + x.state, 'row:' + s.tab + ':' + x.id);
var mark = el('span', 'mark', x.state === 'needs' ? '!' : undefined);
mark.setAttribute('aria-hidden', 'true');
row.appendChild(mark);
row.appendChild(el('span', 'sname', x.name || 'Untitled'));
row.appendChild(el('span', 'stext', x.text));
var said = { needs: 'needs you', waiting: 'waiting', working: 'working', idle: 'idle' }[x.state];
row.setAttribute('aria-label', (x.name || 'Untitled') + ', ' + said + (x.text ? ': ' + x.text : ''));
if (x.id) row.addEventListener('click', function () { post({ cmd: 'openSession', tab: s.tab, session: x.id }); });
else row.disabled = true;
return row;
}
function card(s) {
var c = el('section', 'card');
c.style.setProperty('--c', s.color);
var name = s.name || (s.kind === 'connect' ? 'New server tab' : 'Untitled');
c.setAttribute('aria-label', name);
var head = button('head', 'head:' + s.tab);
var title = el('span', 'title');
title.appendChild(el('span', 'name', name));
if (s.url) title.appendChild(el('span', 'url', s.url));
head.appendChild(title);
if (s.key) head.appendChild(el('span', 'key', '⌘' + s.key));
head.addEventListener('click', function () { post({ cmd: 'open', tab: s.tab }); });
c.appendChild(head);
var body = el('div', 'body');
var meta = '';
var action = button('action', 'act:' + s.tab, 'Open');
var onAction = function () { post({ cmd: 'open', tab: s.tab }); };
if (s.kind === 'connect') {
c.classList.add('slim');
body.appendChild(el('div', 'note', 'Not connected yet'));
} else if (s.kind === 'loading') {
var note = el('div', 'note');
note.appendChild(el('span', 'spin'));
note.appendChild(document.createTextNode('Connecting…'));
body.appendChild(note);
} else if (s.offline) {
c.classList.add('offline');
body.appendChild(el('div', 'note', 'Not answering, the app keeps trying'));
meta = s.version;
action.textContent = 'Retry';
onAction = function () { post({ cmd: 'retry', tab: s.tab }); };
} else if (s.refused) {
body.appendChild(el('div', 'note warn', 'The login was refused'));
meta = s.version;
} else {
var shown = s.sessions.slice(0, MAX_ROWS);
shown.forEach(function (x) { body.appendChild(sessionRow(s, x)); });
var total = Math.max(s.counts.sessions, s.sessions.length);
if (total > shown.length) body.appendChild(el('div', 'more', '+' + (total - shown.length) + ' more'));
if (!total) body.appendChild(el('div', 'note', 'No sessions'));
meta = plural(total, 'session', 'sessions') + (s.version ? ' · ' + s.version : '');
if (s.counts.needsYou > 0) {
c.classList.add('urgent');
action.textContent = 'Answer';
action.classList.add('urgent');
// Answer goes straight to the first session that needs you, when the
// list names it; else to the server.
var first = s.sessions.filter(function (x) { return x.state === 'needs' && x.id; })[0];
if (first) onAction = function () { post({ cmd: 'openSession', tab: s.tab, session: first.id }); };
}
}
c.appendChild(body);
var foot = el('div', 'foot');
foot.appendChild(el('span', 'meta', meta));
action.setAttribute('aria-label', action.textContent + ' ' + name);
action.addEventListener('click', onAction);
foot.appendChild(action);
c.appendChild(foot);
return c;
}
function addCard() {
var b = button('add', 'new');
b.setAttribute('aria-label', 'Add a server');
var ico = el('span', 'add-ico');
ico.appendChild(plusIcon(16));
b.appendChild(ico);
b.appendChild(el('span', 'add-label', 'Add a server'));
b.appendChild(el('span', 'add-key', '⌘T'));
b.addEventListener('click', function () { post({ cmd: 'new' }); });
return b;
}
var lastJson = null;
function render(servers) {
// A state arrives every few seconds: the button that had the keyboard
// focus gets it back in the new cards.
var active = document.activeElement;
var focusKey = active && active.dataset ? active.dataset.focus : '';
renderSummary(servers);
var cards = servers.map(card);
cards.push(addCard());
grid.replaceChildren.apply(grid, cards);
if (focusKey) {
var all = grid.querySelectorAll('[data-focus]');
for (var i = 0; i < all.length; i++) {
if (all[i].dataset.focus === focusKey) { all[i].focus({ preventScroll: true }); break; }
}
}
}
function onNative(msg) {
if (typeof msg === 'string') {
try { msg = JSON.parse(msg); } catch (e) { return; }
}
if (!msg || typeof msg !== 'object' || msg.type !== 'state') return;
var servers = [];
var seen = new Set();
(Array.isArray(msg.servers) ? msg.servers : []).forEach(function (raw) {
var s = normServer(raw);
if (!s || seen.has(s.tab)) return;
seen.add(s.tab);
servers.push(s);
});
var json = JSON.stringify(servers);
if (json === lastJson) return;
lastJson = json;
render(servers);
}
Object.defineProperty(window, '__codemanOverview', {
value: Object.freeze({ onNative: onNative }),
writable: false,
configurable: false,
enumerable: false
});
render([]);
post({ cmd: 'ready' });
})();
</script>
</body>
</html>
+604
View File
@@ -0,0 +1,604 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codeman tabs</title>
<style>
/* The tab strip: a 38px web view across the top of the window, under the
macOS traffic lights. One tab per Codeman server; the native side owns
the tabs and sends the whole list on every change. */
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: #141519; }
body {
font: 500 13px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: #a3a8b1; cursor: default;
-webkit-user-select: none; user-select: none;
}
#strip {
position: relative; height: 38px; display: flex; align-items: flex-end; gap: 4px;
padding: 0 8px 0 78px; overflow: hidden; background: #141519;
}
#strip.fullscreen { padding-left: 10px; }
/* The bottom line is drawn under the tabs, so the active tab covers it and
joins the page below. */
#strip::after {
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
background: #23252b; pointer-events: none; z-index: 1;
}
.icon-btn {
align-self: center; flex: none; width: 28px; height: 28px; padding: 0; border: 0;
border-radius: 7px; background: transparent; color: #8b9099;
display: flex; align-items: center; justify-content: center; cursor: default; outline: none;
}
.icon-btn:hover { background: #1b1d22; color: #c9ccd2; }
#overview { margin-right: 6px; }
#overview.on { background: #2563eb; color: #ffffff; }
#new { margin-left: 2px; }
/* Empty strip moves the window, so some of it stays empty however many
tabs there are. */
#fill { flex: 1 0 48px; align-self: stretch; }
/* The tabs share the room evenly: 200px each, down to 72px; past that the
row is clipped, never wrapped or scrolled. */
#tabs {
display: flex; align-items: flex-end; gap: 2px; flex: 0 1 auto; min-width: 0;
height: 100%; overflow: hidden;
}
.tab {
--c: #94a3b8;
/* width, not only flex-basis: the row's own width is worked out from
its tabs' widths, and flex-basis does not count there. */
position: relative; flex: 0 1 auto; width: 200px; min-width: 72px; height: 31px;
display: flex; align-items: center; gap: 8px; padding: 0 7px 0 10px;
border-radius: 9px 9px 0 0; color: #a3a8b1; overflow: hidden;
}
.tab:hover { background: #1b1d22; }
/* A thin line between two tabs neither in front nor under the pointer, so a
badge at a tab's right end reads as that tab's and not the next one's. */
.tab + .tab::before {
content: ''; position: absolute; left: 0; top: 9px; bottom: 7px; width: 1px;
background: #2c2f37; pointer-events: none;
}
.tab.active::before, .tab.active + .tab::before,
.tab:hover::before, .tab:hover + .tab::before { display: none; }
.tab.active {
z-index: 2; background: #0b0c0f; color: #e8eaed;
box-shadow: inset 0 2px 0 var(--c), inset 1px 0 0 #23252b, inset -1px 0 0 #23252b;
}
/* The tab button itself is a transparent layer over the whole tab, so a
click anywhere on it is one target and the name, icon and badge stay
plain text for the screen reader's label. */
.hit {
position: absolute; inset: 0; z-index: 0; margin: 0; padding: 0; border: 0;
background: transparent; border-radius: inherit; cursor: default; outline: none;
}
.ico { flex: none; display: flex; color: var(--c); }
.tab.connect .ico { color: #60a5fa; }
.name { flex: 1 1 auto; min-width: 0; line-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tab.offline .name { color: #6b7079; }
.tab.renaming .name { display: none; }
.rename {
position: relative; z-index: 1; flex: 1 1 auto; min-width: 0; height: 21px; margin: 0;
padding: 0 5px; border: 1px solid #3b82f6; border-radius: 5px; background: #0b0c0f;
color: #e8eaed; font: inherit; outline: none; -webkit-user-select: text; user-select: text;
}
.badge { flex: none; display: none; }
.badge.needs {
display: block; min-width: 18px; height: 18px; padding: 0 6px; border-radius: 9px;
background: #f87171; color: #2a0707; font: 700 11px/18px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
text-align: center; font-variant-numeric: tabular-nums;
}
.badge.waiting { display: block; width: 9px; height: 9px; border-radius: 50%; background: #fbbf24; }
.badge.working {
display: block; width: 11px; height: 11px; border-radius: 50%;
border: 2px solid #60a5fa; border-right-color: transparent; animation: spin 1s linear infinite;
}
.badge.offline { display: block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px dashed #6b7079; }
.spin {
display: block; width: 12px; height: 12px; margin: 1.5px; border-radius: 50%;
border: 2px solid #30333b; border-top-color: var(--c); animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.close {
position: relative; z-index: 1; flex: none; width: 16px; height: 16px; padding: 0; border: 0;
border-radius: 4px; background: transparent; color: #8b9099; cursor: default; outline: none;
display: none; align-items: center; justify-content: center;
}
.tab:hover .close, .tab.active .close { display: flex; }
.close:hover { background: #2a2d34; color: #e8eaed; }
/* Only while a tab is dragged do the others slide aside. */
#tabs.dragging .tab { transition: transform 150ms ease; }
#tabs.dragging .tab.lifted { transition: none; z-index: 3; background: #1b1d22; }
#tabs.dragging .tab.lifted.active { background: #0b0c0f; }
@media (prefers-reduced-motion: reduce) {
.badge.working, .spin { animation: none; }
#tabs.dragging .tab { transition: none; }
}
</style>
</head>
<body>
<div id="strip">
<button id="overview" class="icon-btn" type="button" tabindex="-1" aria-label="All servers" aria-pressed="false" title="All servers (⌘0)">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="1.5" y="1.5" width="5" height="5" rx="1.2"></rect><rect x="9.5" y="1.5" width="5" height="5" rx="1.2"></rect><rect x="1.5" y="9.5" width="5" height="5" rx="1.2"></rect><rect x="9.5" y="9.5" width="5" height="5" rx="1.2"></rect></svg>
</button>
<div id="tabs" role="tablist" aria-label="Servers"></div>
<button id="new" class="icon-btn" type="button" tabindex="-1" aria-label="New server tab" title="New server tab (⌘T)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M8 2.5v11M2.5 8h11"></path></svg>
</button>
<div id="fill"></div>
</div>
<script>
(function () {
'use strict';
var FALLBACK_COLOR = '#94a3b8';
var COLOR_RE = /^#[0-9a-fA-F]{6}$/;
var MAX_NAME = 40;
var DRAG_START_PX = 4;
var SVG_NS = 'http://www.w3.org/2000/svg';
var strip = document.getElementById('strip');
var tabsEl = document.getElementById('tabs');
var overviewBtn = document.getElementById('overview');
var newBtn = document.getElementById('new');
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
// Everything below arrives from the native side, and most of it was typed
// on a server (names, URLs): it is only ever set as text or as a checked
// attribute value, never parsed as markup or CSS.
function str(v) { return typeof v === 'string' ? v : ''; }
function color(v) { return typeof v === 'string' && COLOR_RE.test(v) ? v : FALLBACK_COLOR; }
function count(v) {
var n = Math.floor(Number(v));
return isFinite(n) && n > 0 ? n : 0;
}
function shortcut(v) {
var n = Number(v);
return n >= 1 && n <= 9 && Math.floor(n) === n ? n : null;
}
function normTab(t) {
if (!t || typeof t !== 'object') return null;
var id = Number(t.id);
if (!isFinite(id)) return null;
var st = t.status && typeof t.status === 'object' ? t.status : {};
return {
id: id,
name: str(t.name),
color: color(t.color),
active: t.active === true,
kind: t.kind === 'connect' || t.kind === 'loading' ? t.kind : 'server',
local: t.local === true,
url: str(t.url),
key: shortcut(t.key),
needs: count(st.needsYou),
waiting: count(st.waiting),
working: count(st.working),
offline: st.offline === true
};
}
// One badge, by precedence: needs you, waiting, working, not answering.
function badgeOf(t) {
if (t.kind === 'connect') return '';
if (t.needs) return 'needs';
if (t.waiting) return 'waiting';
if (t.working) return 'working';
if (t.offline) return 'offline';
return '';
}
function statusWords(t) {
if (t.kind === 'loading') return ['Connecting'];
if (t.kind === 'connect') return [];
var out = [];
if (t.needs) out.push(t.needs + (t.needs === 1 ? ' needs you' : ' need you'));
if (t.waiting) out.push(t.waiting + ' waiting');
if (t.working) out.push(t.working + ' working');
if (t.offline) out.push('Not answering');
return out;
}
function displayName(t) { return t.name || 'Untitled'; }
// ---- icons (fixed shapes from this file; no outside text goes in) ----
function svg(size, width, parts) {
var s = document.createElementNS(SVG_NS, 'svg');
s.setAttribute('width', String(size));
s.setAttribute('height', String(size));
s.setAttribute('viewBox', '0 0 16 16');
s.setAttribute('fill', 'none');
s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', String(width));
s.setAttribute('aria-hidden', 'true');
parts.forEach(function (p) {
var el = document.createElementNS(SVG_NS, p[0]);
Object.keys(p[1]).forEach(function (k) { el.setAttribute(k, p[1][k]); });
s.appendChild(el);
});
return s;
}
var ICONS = {
local: function () {
return svg(15, 1.5, [['rect', { x: '2.5', y: '3', width: '11', height: '7.5', rx: '1.2' }], ['path', { d: 'M1 13h14' }]]);
},
server: function () {
return svg(15, 1.5, [
['rect', { x: '2', y: '2', width: '12', height: '5', rx: '1.2' }],
['rect', { x: '2', y: '9', width: '12', height: '5', rx: '1.2' }],
['path', { d: 'M4.5 4.5h1M4.5 11.5h1' }]
]);
},
connect: function () { return svg(15, 1.7, [['path', { d: 'M8 2.5v11M2.5 8h11' }]]); },
loading: function () {
var s = document.createElement('span');
s.className = 'spin';
return s;
},
close: function () { return svg(10, 1.6, [['path', { d: 'M3 3l10 10M13 3L3 13' }]]); }
};
function iconKind(t) {
if (t.kind === 'connect' || t.kind === 'loading') return t.kind;
return t.local ? 'local' : 'server';
}
// ---- state ----
var state = { tabs: [], overviewActive: false, fullscreen: false };
var lastJson = '';
var views = new Map(); // tab id -> the tab's elements and what they show
var renaming = null; // { id, input, initial }
var drag = null; // a press on a tab that may turn into a drag
var pendingRender = false;
var rendering = false;
var suppressClick = false;
function createView(id) {
var root = document.createElement('div');
root.className = 'tab';
root.setAttribute('role', 'presentation');
root.dataset.id = String(id);
var hit = document.createElement('button');
hit.type = 'button';
hit.className = 'hit';
hit.tabIndex = -1;
hit.setAttribute('role', 'tab');
var ico = document.createElement('span');
ico.className = 'ico';
ico.setAttribute('aria-hidden', 'true');
var name = document.createElement('span');
name.className = 'name';
name.setAttribute('aria-hidden', 'true');
var badge = document.createElement('span');
badge.className = 'badge';
badge.setAttribute('aria-hidden', 'true');
var close = document.createElement('button');
close.type = 'button';
close.className = 'close';
close.tabIndex = -1;
close.setAttribute('aria-label', 'Close tab');
close.title = 'Close tab';
close.appendChild(ICONS.close());
root.appendChild(hit);
root.appendChild(ico);
root.appendChild(name);
root.appendChild(badge);
root.appendChild(close);
var v = { id: id, root: root, hit: hit, ico: ico, name: name, badge: badge, close: close, icon: '', badgeKind: null, tab: null };
views.set(id, v);
return v;
}
function updateView(v, t) {
v.tab = t;
var bk = badgeOf(t);
var cls = 'tab' + (t.active ? ' active' : '') + (bk === 'offline' ? ' offline' : '') +
(t.kind !== 'server' ? ' ' + t.kind : '') + (renaming && renaming.id === t.id ? ' renaming' : '');
if (v.root.className !== cls) v.root.className = cls;
v.root.style.setProperty('--c', t.color);
var ik = iconKind(t);
if (v.icon !== ik) {
v.icon = ik;
v.ico.replaceChildren(ICONS[ik]());
}
var label = displayName(t);
if (v.name.textContent !== label) v.name.textContent = label;
var btext = bk === 'needs' ? (t.needs > 99 ? '99+' : String(t.needs)) : '';
if (v.badgeKind !== bk + btext) {
v.badgeKind = bk + btext;
v.badge.className = 'badge' + (bk ? ' ' + bk : '');
v.badge.textContent = btext;
}
var words = statusWords(t);
v.hit.setAttribute('aria-selected', t.active ? 'true' : 'false');
v.hit.setAttribute('aria-label', [label].concat(words).join(', '));
// The strip is too short for a hover card under it, so the native
// tooltip carries the same lines.
var lines = [label + (t.key ? ' ⌘' + t.key : '')];
if (t.url) lines.push(t.url);
v.hit.title = lines.concat(words).join('\n');
}
function render() {
if (drag && drag.active) { pendingRender = true; return; }
pendingRender = false;
rendering = true;
strip.classList.toggle('fullscreen', state.fullscreen);
overviewBtn.classList.toggle('on', state.overviewActive);
overviewBtn.setAttribute('aria-pressed', state.overviewActive ? 'true' : 'false');
var seen = new Set();
state.tabs.forEach(function (t, i) {
var v = views.get(t.id) || createView(t.id);
updateView(v, t);
seen.add(t.id);
var at = tabsEl.children[i];
if (at !== v.root) tabsEl.insertBefore(v.root, at || null);
});
views.forEach(function (v, id) {
if (seen.has(id)) return;
if (renaming && renaming.id === id) endRename('gone');
v.root.remove();
views.delete(id);
});
rendering = false;
// Moving a tab in the row takes the focus from a rename under way: give it back.
if (renaming && document.activeElement !== renaming.input) renaming.input.focus();
}
function applyState(msg) {
var seen = new Set();
var tabs = [];
(Array.isArray(msg.tabs) ? msg.tabs : []).forEach(function (raw) {
var t = normTab(raw);
if (!t || seen.has(t.id)) return;
seen.add(t.id);
tabs.push(t);
});
var next = { tabs: tabs, overviewActive: msg.overviewActive === true, fullscreen: msg.fullscreen === true };
var json = JSON.stringify(next);
if (json === lastJson && !pendingRender) return;
lastJson = json;
state = next;
render();
}
// ---- rename ----
function startRename(id) {
var v = views.get(id);
if (!v || (drag && drag.active)) return;
if (renaming) {
if (renaming.id === id) { renaming.input.focus(); return; }
endRename('commit');
}
var input = document.createElement('input');
input.type = 'text';
input.className = 'rename';
input.maxLength = MAX_NAME;
input.spellcheck = false;
input.autocomplete = 'off';
input.setAttribute('aria-label', 'Tab name');
input.value = v.tab ? v.tab.name.slice(0, MAX_NAME) : '';
renaming = { id: id, input: input, before: v.tab ? v.tab.name : '', initial: input.value.trim() };
v.root.classList.add('renaming');
v.root.insertBefore(input, v.badge);
input.addEventListener('keydown', function (ev) {
ev.stopPropagation();
if (ev.key === 'Enter') { ev.preventDefault(); endRename('commit'); }
else if (ev.key === 'Escape') { ev.preventDefault(); endRename('cancel'); }
});
input.addEventListener('blur', function () {
if (rendering) return;
if (renaming && renaming.input === input) endRename('commit');
});
// The keyboard reaches this field only while this web view is the first
// responder, and the native side hands it back to the page below after
// every other click on the strip: ask for it.
post({ cmd: 'editing' });
input.focus();
input.select();
}
// how: 'commit' (Enter, a click elsewhere), 'cancel' (Escape) or 'gone'
// (the tab was closed). Every rename that ends on a tab still there posts
// one rename, a cancelled or unchanged one with the name the tab had: the
// native side takes that as "rename finished" and hands the keyboard back
// to the page below.
function endRename(how) {
var r = renaming;
if (!r) return;
renaming = null;
var name = r.input.value.trim().slice(0, MAX_NAME);
r.input.remove();
var v = views.get(r.id);
if (v) v.root.classList.remove('renaming');
if (how === 'gone') return;
if (how === 'cancel' || name === r.initial) {
post({ cmd: 'rename', id: r.id, name: r.before });
return;
}
// Shown at once; an empty name is the default the native side picks.
if (v && name) v.name.textContent = name;
post({ cmd: 'rename', id: r.id, name: name });
}
// ---- drag to reorder ----
function beginDrag() {
var roots = Array.prototype.slice.call(tabsEl.children);
drag.roots = roots;
drag.from = roots.indexOf(drag.root);
if (drag.from < 0) { drag = null; return; }
drag.rects = roots.map(function (r) { return r.getBoundingClientRect(); });
var r0 = drag.rects[drag.from];
var gap = roots.length > 1 ? drag.rects[1].left - drag.rects[0].right : 0;
drag.step = r0.width + gap;
drag.to = drag.from;
drag.active = true;
if (renaming) endRename('commit');
try { drag.root.setPointerCapture(drag.pointerId); } catch (e) { /* the pointer is gone */ }
tabsEl.classList.add('dragging');
drag.root.classList.add('lifted');
}
function moveDrag(dx) {
var rects = drag.rects;
var r0 = rects[drag.from];
var minDx = rects[0].left - r0.left;
var maxDx = rects[rects.length - 1].right - r0.right;
dx = Math.max(minDx, Math.min(maxDx, dx));
drag.root.style.transform = 'translateX(' + dx + 'px)';
var centre = r0.left + r0.width / 2 + dx;
var to = 0;
for (var i = 0; i < rects.length; i++) {
if (i !== drag.from && centre > rects[i].left + rects[i].width / 2) to++;
}
drag.to = to;
drag.roots.forEach(function (r, i) {
if (i === drag.from) return;
var shift = 0;
if (drag.from < to && i > drag.from && i <= to) shift = -drag.step;
else if (to < drag.from && i >= to && i < drag.from) shift = drag.step;
r.style.transform = shift ? 'translateX(' + shift + 'px)' : '';
});
}
function endDrag(commit) {
var d = drag;
drag = null;
if (!d || !d.active) return;
try { d.root.releasePointerCapture(d.pointerId); } catch (e) { /* already released */ }
// The slide stops before the tabs move, so nothing animates back.
tabsEl.classList.remove('dragging');
d.root.classList.remove('lifted');
d.roots.forEach(function (r) { r.style.transform = ''; });
// The click that ends a drag is not a select.
suppressClick = true;
setTimeout(function () { suppressClick = false; }, 0);
if (commit && d.to !== d.from) {
var at = state.tabs.findIndex(function (t) { return t.id === d.id; });
if (at >= 0) {
var moved = state.tabs.splice(at, 1)[0];
state.tabs.splice(d.to, 0, moved);
lastJson = JSON.stringify(state);
}
post({ cmd: 'move', id: d.id, to: d.to });
pendingRender = true;
}
if (pendingRender) render();
}
function tabOf(target) {
var root = target && target.closest ? target.closest('.tab') : null;
return root && views.has(Number(root.dataset.id)) ? Number(root.dataset.id) : null;
}
function inside(target, sel) { return !!(target && target.closest && target.closest(sel)); }
// ---- events ----
var middleDownOn = null;
document.addEventListener('mousedown', function (ev) {
var t = ev.target;
if (renaming && t !== renaming.input) endRename('commit');
if (inside(t, 'input')) return;
// The strip never takes the focus or starts a selection.
ev.preventDefault();
if (ev.button === 1) { middleDownOn = tabOf(t); return; }
if (ev.button !== 0 || ev.ctrlKey) return;
if (inside(t, '.tab, button')) return;
// The second press of a double click zooms, as a title bar does. Decided
// here, not on dblclick: the first press's window drag may keep WebKit
// from ever seeing a dblclick.
if (ev.detail === 2) post({ cmd: 'zoom' });
if (ev.detail > 1) return;
post({ cmd: 'drag' });
});
// Middle click closes, as in a browser (auxclick is not in every WebKit).
document.addEventListener('mouseup', function (ev) {
if (ev.button !== 1) return;
var id = tabOf(ev.target);
if (id !== null && id === middleDownOn) post({ cmd: 'close', id: id });
middleDownOn = null;
});
document.addEventListener('dblclick', function (ev) {
var t = ev.target;
var id = tabOf(t);
if (id !== null) {
if (!inside(t, '.close, input')) startRename(id);
return;
}
});
// WebKit's own menu (Reload, Inspect Element) never shows on the strip,
// the rename field included; only a tab has a menu, the native one, and
// not while its name is being typed.
document.addEventListener('contextmenu', function (ev) {
ev.preventDefault();
var id = inside(ev.target, 'input') ? null : tabOf(ev.target);
if (id !== null) post({ cmd: 'menu', id: id });
});
tabsEl.addEventListener('click', function (ev) {
if (suppressClick) { suppressClick = false; return; }
if (ev.ctrlKey) return; // a Control-click is the right click: the menu answers it
var id = tabOf(ev.target);
if (id === null || inside(ev.target, 'input')) return;
if (inside(ev.target, '.close')) post({ cmd: 'close', id: id });
else post({ cmd: 'select', id: id });
});
tabsEl.addEventListener('pointerdown', function (ev) {
if (ev.button !== 0 || ev.ctrlKey || !ev.isPrimary) return;
var id = tabOf(ev.target);
if (id === null || inside(ev.target, '.close, input')) return;
drag = { id: id, root: views.get(id).root, pointerId: ev.pointerId, startX: ev.clientX, active: false };
});
document.addEventListener('pointermove', function (ev) {
if (!drag || ev.pointerId !== drag.pointerId) return;
var dx = ev.clientX - drag.startX;
if (!drag.active) {
if (Math.abs(dx) < DRAG_START_PX) return;
beginDrag();
if (!drag) return;
}
moveDrag(dx);
});
document.addEventListener('pointerup', function (ev) {
if (drag && ev.pointerId === drag.pointerId) endDrag(true);
});
document.addEventListener('pointercancel', function (ev) {
if (drag && ev.pointerId === drag.pointerId) endDrag(false);
});
window.addEventListener('blur', function () { if (drag) endDrag(false); });
overviewBtn.addEventListener('click', function () { post({ cmd: 'overview' }); });
newBtn.addEventListener('click', function () { post({ cmd: 'new' }); });
function onNative(msg) {
if (typeof msg === 'string') {
try { msg = JSON.parse(msg); } catch (e) { return; }
}
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'state') applyState(msg);
else if (msg.type === 'rename') {
var id = Number(msg.id);
if (isFinite(id)) startRename(id);
}
}
Object.defineProperty(window, '__codemanTabs', {
value: Object.freeze({ onNative: onNative }),
writable: false,
configurable: false,
enumerable: false
});
post({ cmd: 'ready' });
})();
</script>
</body>
</html>
+238 -1
View File
@@ -57,6 +57,10 @@ pub enum Poll {
#[derive(Deserialize)]
struct Session {
#[serde(default)]
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
@@ -80,11 +84,20 @@ fn count_working(body: &str) -> Option<u32> {
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Approval {
#[serde(default)]
kind: String,
#[serde(default, rename = "acknowledgedAt")]
#[serde(default)]
acknowledged_at: Option<f64>,
#[serde(default)]
session_id: String,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
tool_summary: Option<String>,
#[serde(default)]
message: Option<String>,
}
#[derive(Deserialize)]
@@ -108,6 +121,145 @@ fn read_alerts(body: &str) -> Option<(bool, bool)> {
Some((waiting, needs_you))
}
/// What one agent session is doing, most urgent first (`SessionState::rank`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum SessionState {
/// Stopped on a permission or a question: a red tab.
Needs,
/// Finished, and nobody has looked yet: a yellow tab.
Waiting,
/// At work.
Working,
Idle,
}
impl SessionState {
pub fn rank(self) -> u8 {
match self {
SessionState::Needs => 0,
SessionState::Waiting => 1,
SessionState::Working => 2,
SessionState::Idle => 3,
}
}
}
/// One agent session of a server, for the tab badges and the overview. `name`
/// and `text` come from the server: the pages that show them treat them as
/// text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionRow {
pub id: String,
pub name: String,
pub state: SessionState,
pub text: String,
}
/// Every agent session of a server and what it is doing. Shell tabs are left
/// out, as for the logo.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Detail {
pub sessions: Vec<SessionRow>,
}
impl Detail {
/// The same answer `Poller::poll` gives for this server.
#[cfg_attr(not(test), allow(dead_code))]
pub fn activity(&self) -> Activity {
let any = |state: SessionState| self.sessions.iter().any(|s| s.state == state);
Activity {
working: any(SessionState::Working),
waiting: any(SessionState::Waiting),
needs_you: any(SessionState::Needs),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum DetailPoll {
Seen(Detail),
Failed,
/// 401 or 403: the login is refused (or must be changed first). Asking
/// again would count against the login rate limit.
Refused,
/// 429: too many failed logins from this address, maybe other clients'.
/// Worth asking again, much later.
RateLimited,
}
/// How long a server's text may be in a row of the overview, in characters.
const MAX_ROW_TEXT: usize = 160;
const MAX_ROW_NAME: usize = 80;
/// How many sessions of one server are kept, so a server cannot make the app
/// hold a list of any length.
const MAX_SESSIONS: usize = 200;
/// Server text for a row: control characters out, whitespace runs folded,
/// cut at `max` characters.
fn row_text(raw: &str, max: usize) -> String {
let folded = raw
.split(|c: char| c.is_whitespace() || c.is_control())
.filter(|w| !w.is_empty())
.collect::<Vec<_>>()
.join(" ");
match folded.char_indices().nth(max) {
Some((cut, _)) => format!("{}\u{2026}", &folded[..cut]),
None => folded,
}
}
/// The detail of a server from its two answers: `GET api/sessions` and, when
/// the server has it, `GET api/approvals` (None: an older server, no alerts).
/// None when the sessions answer is not a list of sessions.
pub fn read_detail(sessions_body: &str, approvals_body: Option<&str>) -> Option<Detail> {
let (Sessions::Envelope { data: sessions } | Sessions::Bare(sessions)) =
serde_json::from_str(sessions_body).ok()?;
let approvals: Vec<Approval> = approvals_body
.and_then(|body| serde_json::from_str::<ApprovalsAnswer>(body).ok())
.map(|a| a.data.approvals)
.unwrap_or_default();
let rows = sessions
.into_iter()
.filter(|s| s.mode != "shell" && !s.id.is_empty())
.take(MAX_SESSIONS)
.map(|s| {
let mine = || approvals.iter().filter(|a| a.session_id == s.id);
let urgent = mine().find(|a| a.kind == "permission" || a.kind == "question");
let waiting = mine().any(|a| a.kind == "idle" && a.acknowledged_at.is_none());
let (state, text) = if let Some(a) = urgent {
let text = match (a.kind.as_str(), &a.tool_name, &a.tool_summary, &a.message) {
("permission", Some(tool), Some(summary), _) => format!("{tool}: {summary}"),
("permission", Some(tool), None, _) => format!("Allow {tool}?"),
(_, _, _, Some(message)) => message.clone(),
("question", _, _, None) => "Has a question for you".to_string(),
_ => "Needs your permission".to_string(),
};
(SessionState::Needs, text)
} else if waiting {
(SessionState::Waiting, "Finished, waiting for you".to_string())
} else if s.status == "busy" {
(SessionState::Working, "Working".to_string())
} else {
(SessionState::Idle, "Idle".to_string())
};
let name = s.name.as_deref().map(|n| row_text(n, MAX_ROW_NAME)).unwrap_or_default();
let name = if name.is_empty() {
s.id.chars().take(8).collect()
} else {
name
};
SessionRow {
id: s.id,
name,
state,
text: row_text(&text, MAX_ROW_TEXT),
}
})
.collect();
Some(Detail { sessions: rows })
}
/// `name=value` out of a `Set-Cookie` header, for Codeman's session cookie.
fn session_cookie(header: &str) -> Option<&str> {
let pair = header.split(';').next()?.trim();
@@ -215,6 +367,29 @@ impl Poller {
(activity.waiting, activity.needs_you) = self.alerts;
Poll::Seen(activity)
}
/// Every agent session and what it is doing (`read_detail`), for a server
/// tab's badges and the overview. Needs a poller made with `alerts`.
pub fn detail(&mut self) -> DetailPoll {
let url = self.sessions_url.clone();
let sessions = match self.ask(&url) {
Some((200, Some(body))) => body,
Some((429, _)) => return DetailPoll::RateLimited,
Some((s, _)) if refused(s) => return DetailPoll::Refused,
_ => return DetailPoll::Failed,
};
let approvals = match self.approvals_url.clone() {
Some(url) => match self.ask(&url) {
Some((200, body)) => body,
Some((429, _)) => return DetailPoll::RateLimited,
Some((s, _)) if refused(s) => return DetailPoll::Refused,
// An older server has no list; a failed read counts as none.
_ => None,
},
None => None,
};
read_detail(&sessions, approvals.as_deref()).map_or(DetailPoll::Failed, DetailPoll::Seen)
}
}
#[cfg(test)]
@@ -462,4 +637,66 @@ mod tests {
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), Poll::Failed);
}
#[test]
fn detail_reads_every_agent_session_and_its_state() {
let sessions = r#"{"success":true,"data":[
{"id":"s1","name":"webshop","status":"busy","mode":"claude"},
{"id":"s2","name":"billing","status":"idle","mode":"claude"},
{"id":"s3","name":"docs","status":"idle","mode":"codex"},
{"id":"s4","name":"shell","status":"busy","mode":"shell"},
{"id":"s5","status":"idle","mode":"claude"},
{"id":"s6","name":" two\n lines\u0007 ","status":"idle","mode":"claude"}]}"#;
let approvals = r#"{"success":true,"data":{"approvals":[
{"sessionId":"s2","kind":"permission","toolName":"Bash","toolSummary":"npm run migrate"},
{"sessionId":"s3","kind":"idle"},
{"sessionId":"s5","kind":"idle","acknowledgedAt":1},
{"sessionId":"s6","kind":"question","message":"Which locale first?"}]}}"#;
let detail = read_detail(sessions, Some(approvals)).unwrap();
let states: Vec<(&str, SessionState, &str)> = detail
.sessions
.iter()
.map(|s| (s.id.as_str(), s.state, s.text.as_str()))
.collect();
assert_eq!(
states,
[
("s1", SessionState::Working, "Working"),
("s2", SessionState::Needs, "Bash: npm run migrate"),
("s3", SessionState::Waiting, "Finished, waiting for you"),
("s5", SessionState::Idle, "Idle"),
("s6", SessionState::Needs, "Which locale first?"),
]
);
// A session with no name shows its id's start; server text is folded.
assert_eq!(detail.sessions[3].name, "s5");
assert_eq!(detail.sessions[4].name, "two lines");
assert_eq!(
detail.activity(),
Activity {
working: true,
waiting: true,
needs_you: true
}
);
}
#[test]
fn detail_without_approvals_and_from_junk() {
let bare = r#"[{"id":"a","name":"x","status":"busy","mode":"claude"}]"#;
let detail = read_detail(bare, None).unwrap();
assert_eq!(detail.sessions.len(), 1);
assert_eq!(detail.sessions[0].state, SessionState::Working);
assert_eq!(read_detail("not json", None), None);
assert_eq!(read_detail(r#"{"success":true,"data":{}}"#, None), None);
// A junk approvals answer counts as none.
assert_eq!(read_detail(bare, Some("<html>")).unwrap().sessions.len(), 1);
let long = format!(
r#"[{{"id":"a","name":"{}","status":"idle","mode":"claude"}}]"#,
"n".repeat(500)
);
let name = &read_detail(&long, None).unwrap().sessions[0].name;
assert_eq!(name.chars().count(), MAX_ROW_NAME + 1);
assert!(name.ends_with('\u{2026}'));
}
}
+44
View File
@@ -5,6 +5,10 @@ use std::borrow::Cow;
use wry::http::{header::CONTENT_TYPE, Request, Response, StatusCode};
const CONNECT_HTML: &[u8] = include_bytes!("../assets/connect.html");
/// The tab strip across the top of the window, and the overview of every
/// server (tabs.rs).
const TABS_HTML: &[u8] = include_bytes!("../assets/tabs.html");
const OVERVIEW_HTML: &[u8] = include_bytes!("../assets/overview.html");
const ICON_PNG: &[u8] = include_bytes!("../assets/icon.png");
/// The connect page loads nothing from outside the app.
@@ -14,6 +18,8 @@ const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; style-src 'un
pub fn respond(request: Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
let (body, mime): (&'static [u8], &str) = match request.uri().path() {
"/" | "/index.html" => (CONNECT_HTML, "text/html; charset=utf-8"),
"/tabs.html" => (TABS_HTML, "text/html; charset=utf-8"),
"/overview.html" => (OVERVIEW_HTML, "text/html; charset=utf-8"),
"/icon.png" => (ICON_PNG, "image/png"),
_ => {
return Response::builder()
@@ -220,6 +226,24 @@ pub fn refused_script(session_id: Option<&str>) -> String {
)
}
/// Hands a message to one of the app's own pages: the connect page
/// (`__codemanDesktop`), the tab strip (`__codemanTabs`) or the overview
/// (`__codemanOverview`). The message is JSON, which is also a JavaScript
/// expression.
pub fn native_message_script(global: &str, msg: &serde_json::Value) -> String {
format!("window.{global} && window.{global}.onNative({msg})")
}
/// Selects a session in Codeman's page, as a click on its tab would (the
/// overview's rows). `id` has passed `tabs::is_session_id`, and goes in as a
/// JSON string all the same.
pub fn select_session_script(id: &str) -> String {
let id = serde_json::to_string(id).unwrap_or_else(|_| "\"\"".into());
format!(
"try {{ if (window.app && typeof window.app.selectSession === 'function') window.app.selectSession({id}); }} catch (e) {{}}"
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -248,6 +272,26 @@ mod tests {
assert_eq!(get("/other.js").status(), StatusCode::NOT_FOUND);
}
#[test]
fn serves_the_strip_and_the_overview() {
for (path, global) in [("/tabs.html", "__codemanTabs"), ("/overview.html", "__codemanOverview")] {
let page = get(path);
assert_eq!(page.status(), StatusCode::OK, "{path}");
assert_eq!(page.headers()["content-security-policy"], CSP, "{path}");
assert!(String::from_utf8_lossy(page.body()).contains(global), "{path}");
}
}
#[test]
fn page_scripts_carry_json() {
let msg = serde_json::json!({ "type": "state", "name": "a\"b</script>" });
let script = native_message_script("__codemanTabs", &msg);
assert!(script.starts_with("window.__codemanTabs && window.__codemanTabs.onNative({"));
assert!(script.contains(r#""a\"b</script>""#));
let select = select_session_script("abc-1");
assert!(select.contains(r#"selectSession("abc-1")"#));
}
#[test]
fn refused_script_escapes_the_session_id() {
let js = refused_script(Some("a'\"); alert(1); //\u{2028}\\"));
+157
View File
@@ -11,6 +11,8 @@ use std::io;
use std::path::{Path, PathBuf};
const MAX_SERVERS: usize = 8;
/// How many server tabs are saved for the next launch.
pub const MAX_TABS: usize = 24;
/// How many recovery copies of an unreadable config can sit side by side:
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
const ASIDE_SLOTS: usize = 10;
@@ -59,6 +61,50 @@ pub struct Config {
/// make the whole file unreadable, which would cost the server list.
#[serde(deserialize_with = "lenient_window")]
pub window: Option<WindowFrame>,
/// The server tabs to open again at the next launch, in strip order. Only
/// tabs that belong to a server are kept. Read leniently, like `window`.
#[serde(deserialize_with = "lenient_tabs")]
pub tabs: Vec<TabEntry>,
/// The tab that was in front, an index into `tabs`.
#[serde(deserialize_with = "lenient_index")]
pub active_tab: usize,
/// The one server whose pages keep WebKit's default data store, so the
/// local storage it had before tabs existed (Codeman's per-device settings)
/// stays with it; every other server gets a store of its own
/// (`tabs::store_id`). None: not decided yet, which the first launch with
/// tabs does (the most recent server, or `Some("")` for none).
#[serde(deserialize_with = "lenient_string", skip_serializing_if = "Option::is_none")]
pub default_store_server: Option<String>,
}
/// One saved tab: the server it is for, and what the user made of it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TabEntry {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
fn lenient_tabs<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<TabEntry>, D::Error> {
let value = serde_json::Value::deserialize(d)?;
let list = value.as_array().cloned().unwrap_or_default();
Ok(list
.into_iter()
.filter_map(|v| serde_json::from_value::<TabEntry>(v).ok())
.collect())
}
fn lenient_index<'de, D: serde::Deserializer<'de>>(d: D) -> Result<usize, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(value.as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0))
}
fn lenient_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(value.as_str().map(str::to_owned))
}
fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<WindowFrame>, D::Error> {
@@ -83,6 +129,9 @@ impl Default for Config {
logo_blink: BlinkStyles::default(),
keep_awake: keep_awake::Settings::default(),
window: None,
tabs: Vec::new(),
active_tab: 0,
default_store_server: None,
}
}
}
@@ -341,6 +390,35 @@ impl Config {
self.servers.retain(|s| !s.url.is_empty() && seen.insert(s.url.clone()));
self.servers.truncate(MAX_SERVERS);
self.window = self.window.and_then(WindowFrame::sanitized);
let mut seen = std::collections::HashSet::new();
self.tabs.retain(|t| !t.url.is_empty() && seen.insert(t.url.clone()));
self.tabs.truncate(MAX_TABS);
for tab in &mut self.tabs {
tab.name = tab.name.as_deref().and_then(crate::tabs::clean_name);
tab.color = tab.color.take().filter(|c| crate::tabs::is_color(c));
}
if self.active_tab >= self.tabs.len() {
self.active_tab = 0;
}
}
/// Settle `default_store_server` once: the server used last keeps the data
/// store it always had. True when it changed (the caller saves).
pub fn settle_default_store(&mut self) -> bool {
if self.default_store_server.is_some() {
return false;
}
self.default_store_server = Some(self.servers.first().map(|s| s.url.clone()).unwrap_or_default());
true
}
/// The data store a server's pages use: None for WebKit's default one,
/// else the server's own (`tabs::store_id`).
pub fn store_for(&self, url: &str) -> Option<[u8; 16]> {
match &self.default_store_server {
Some(server) if server == url => None,
_ => Some(crate::tabs::store_id(url)),
}
}
/// Move (or add) a server to the top of the recent list. Returns the
@@ -898,4 +976,83 @@ mod tests {
};
assert!(full.fitted_to(&tiny, MIN).unwrap().fullscreen);
}
#[test]
fn tabs_round_trip_and_bad_values_are_dropped() {
let dir = tmp_dir("tabs");
let path = dir.join("desktop.json");
fs::create_dir_all(&dir).unwrap();
fs::write(
&path,
r##"{"servers":[],"activeTab":7,"tabs":[
{"url":"http://127.0.0.1:3000/","name":" Mine ","color":"#A78BFA"},
{"url":"http://127.0.0.1:3000/","name":"again"},
{"url":"https://studio.example.ts.net/","color":"red;x"},
{"url":""},
42,
{"name":"no url"}]}"##,
)
.unwrap();
let (c, can_save) = Config::load_from(&path);
assert!(can_save);
assert_eq!(
c.tabs,
[
TabEntry {
url: "http://127.0.0.1:3000/".into(),
name: Some("Mine".into()),
color: Some("#A78BFA".into()),
},
TabEntry {
url: "https://studio.example.ts.net/".into(),
name: None,
color: None,
},
]
);
// An index past the list falls back to the first tab.
assert_eq!(c.active_tab, 0);
c.save_to(&path).unwrap();
let (again, _) = Config::load_from(&path);
assert_eq!(again.tabs, c.tabs);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_bad_tab_value_never_costs_the_server_list() {
let dir = tmp_dir("badtabs");
let path = dir.join("desktop.json");
fs::create_dir_all(&dir).unwrap();
fs::write(
&path,
r#"{"servers":[{"url":"http://a/"}],"tabs":"nope","activeTab":-3,"defaultStoreServer":5}"#,
)
.unwrap();
let (c, can_save) = Config::load_from(&path);
assert!(can_save);
assert_eq!(c.servers.len(), 1);
assert!(c.tabs.is_empty());
assert_eq!(c.active_tab, 0);
assert_eq!(c.default_store_server, None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_server_used_last_keeps_the_default_store() {
let mut c = Config::default();
let _ = c.remember("http://old/", None, false);
let _ = c.remember("http://recent/", None, false);
assert!(c.settle_default_store());
assert_eq!(c.default_store_server.as_deref(), Some("http://recent/"));
// Decided once: a later connect does not move it.
let _ = c.remember("http://old/", None, false);
assert!(!c.settle_default_store());
assert_eq!(c.store_for("http://recent/"), None);
assert_eq!(c.store_for("http://old/"), Some(crate::tabs::store_id("http://old/")));
// A first run with no server keeps no server on the default store.
let mut fresh = Config::default();
assert!(fresh.settle_default_store());
assert_eq!(fresh.default_store_server.as_deref(), Some(""));
assert!(fresh.store_for("http://x/").is_some());
}
}
+103 -43
View File
@@ -1165,14 +1165,33 @@ fn watch(logo: &impl Logo, marker: &Marker, styles: BlinkStyles, signals: &mut i
}
}
/// The one line the app writes to the child.
#[derive(Serialize, Deserialize)]
/// The one line the app writes to the child: every open server tab, each with
/// its login, and the styles. The logo shows the most urgent state of all of
/// them together.
#[derive(Serialize, Deserialize, PartialEq)]
struct Connection {
server: String,
authorization: Option<String>,
servers: Vec<Target>,
styles: BlinkStyles,
}
/// One server the child asks.
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
pub struct Target {
pub server: String,
pub authorization: Option<String>,
}
/// What several servers' agents do together: each state holds when it holds on
/// any of them.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn merged(all: &[Activity]) -> Activity {
all.iter().fold(Activity::default(), |acc, a| Activity {
working: acc.working || a.working,
waiting: acc.waiting || a.waiting,
needs_you: acc.needs_you || a.needs_you,
})
}
/// The app's side: the pipe to the child, which is all there is to it.
#[derive(Default)]
pub struct Watcher {
@@ -1185,27 +1204,23 @@ impl Watcher {
self.current.is_some()
}
/// Watch this server with this login in these styles, or nothing. A child
/// for anything else ends first: its pipe closes with the old value. It is
/// back to normal well before the next child looks, which takes `SETTLE`.
pub fn sync(&mut self, want: Option<(&str, Option<&str>, BlinkStyles)>) {
let same = match (&self.current, want) {
(Some((now, _)), Some((server, authorization, styles))) => {
now.server == server && now.authorization.as_deref() == authorization && now.styles == styles
}
/// Watch these servers with their logins in these styles, or nothing (an
/// empty list is nothing too). A child for anything else ends first: its
/// pipe closes with the old value. It is back to normal well before the
/// next child looks, which takes `SETTLE`.
pub fn sync(&mut self, want: Option<(Vec<Target>, BlinkStyles)>) {
let want = want
.filter(|(servers, _)| !servers.is_empty())
.map(|(servers, styles)| Connection { servers, styles });
let same = match (&self.current, &want) {
(Some((now, _)), Some(want)) => now == want,
(None, None) => true,
_ => false,
};
if same {
return;
}
self.current = want.and_then(|(server, authorization, styles)| {
spawn_child(Connection {
server: server.to_owned(),
authorization: authorization.map(str::to_owned),
styles,
})
});
self.current = want.and_then(spawn_child);
}
}
@@ -1843,16 +1858,25 @@ mod system {
}
}
/// Runs for the life of the child. A server that does not answer counts as
/// nothing going on, and one that refuses the login is never asked again.
fn poll_until_refused(mut poller: Poller, shared: &Mutex<Activity>) {
/// Runs for the life of the child, one per server. A server that does not
/// answer counts as nothing going on, and one that refuses the login is
/// never asked again. Each writes its own slot, and `shared` is what all of
/// them say together (`merged`).
fn poll_until_refused(mut poller: Poller, index: usize, slots: &Mutex<Vec<Activity>>, shared: &Mutex<Activity>) {
loop {
let poll = poller.poll();
if let Ok(mut activity) = shared.lock() {
*activity = match poll {
Poll::Seen(seen) => seen,
_ => Activity::default(),
};
let seen = match poll {
Poll::Seen(seen) => seen,
_ => Activity::default(),
};
let all = slots.lock().ok().map(|mut slots| {
if let Some(slot) = slots.get_mut(index) {
*slot = seen;
}
super::merged(&slots)
});
if let (Some(all), Ok(mut activity)) = (all, shared.lock()) {
*activity = all;
}
if poll == Poll::Refused {
return;
@@ -1877,18 +1901,19 @@ mod system {
.read_line(&mut line)
.ok()
.and_then(|_| serde_json::from_str::<Connection>(&line).ok());
let Some(Connection {
server,
authorization,
styles,
}) = connection
else {
let Some(Connection { servers, styles }) = connection else {
std::process::exit(0);
};
let poller = Url::parse(&server)
.ok()
.and_then(|url| Poller::new(&url, authorization, styles.wants_alerts()));
let (Some(poller), Some(logo)) = (poller, SystemLogo::open()) else {
let pollers: Vec<Poller> = servers
.into_iter()
.filter_map(|target| {
Url::parse(&target.server)
.ok()
.and_then(|url| Poller::new(&url, target.authorization, styles.wants_alerts()))
})
.collect();
let logo = SystemLogo::open();
let Some(logo) = logo.filter(|_| !pollers.is_empty()) else {
std::process::exit(0);
};
@@ -1900,8 +1925,12 @@ mod system {
let _ = closed.send(());
});
let activity = Arc::new(Mutex::new(Activity::default()));
let found = activity.clone();
std::thread::spawn(move || poll_until_refused(poller, &found));
let slots = Arc::new(Mutex::new(vec![Activity::default(); pollers.len()]));
for (index, poller) in pollers.into_iter().enumerate() {
let found = activity.clone();
let slots = slots.clone();
std::thread::spawn(move || poll_until_refused(poller, index, &slots, &found));
}
let mut signals = Live {
pipe_closed,
@@ -3371,15 +3400,17 @@ mod tests {
fn the_connection_line_round_trips_with_and_without_a_login() {
for authorization in [None, Some("Basic abc".to_string())] {
let line = serde_json::to_string(&Connection {
server: "http://127.0.0.1:3000/".into(),
authorization: authorization.clone(),
servers: vec![Target {
server: "http://127.0.0.1:3000/".into(),
authorization: authorization.clone(),
}],
styles: WORK,
})
.unwrap();
assert!(!line.contains('\n'), "one line");
let back: Connection = serde_json::from_str(&line).unwrap();
assert_eq!(back.server, "http://127.0.0.1:3000/");
assert_eq!(back.authorization, authorization);
assert_eq!(back.servers[0].server, "http://127.0.0.1:3000/");
assert_eq!(back.servers[0].authorization, authorization);
assert_eq!(back.styles, WORK);
}
}
@@ -3686,4 +3717,33 @@ mod tests {
watcher.sync(None);
assert!(watcher.current.is_none());
}
#[test]
fn several_servers_merge_into_one_logo() {
assert_eq!(merged(&[]), Activity::default());
assert_eq!(merged(&[working(), Activity::default()]), working());
let all = merged(&[working(), needs_you(), waiting()]);
assert!(all.working && all.waiting && all.needs_you);
}
#[test]
fn the_child_line_names_every_server() {
let line = serde_json::to_string(&Connection {
servers: vec![
Target {
server: "http://127.0.0.1:3000/".into(),
authorization: None,
},
Target {
server: "https://studio.example.ts.net/".into(),
authorization: Some("Basic eA==".into()),
},
],
styles: WORK,
})
.unwrap();
let back: Connection = serde_json::from_str(&line).unwrap();
assert_eq!(back.servers.len(), 2);
assert_eq!(back.servers[1].authorization.as_deref(), Some("Basic eA=="));
}
}
+294 -74
View File
@@ -21,8 +21,14 @@
//! of it: every challenge comes back here and gets the login of that moment,
//! never one an earlier connect left behind.
//!
//! Two smaller helpers live here too: `clear_cookies`, which empties the web
//! views' cookie jar before a login so an earlier user's session cookie cannot
//! Each server tab hands over a login of its own (`set_credentials`, keyed by
//! the tab), and a challenge is answered with the login of the tab whose server
//! it is (`pick`), and only to a web view of that tab (`bind_view`), so two
//! tabs never answer for each other, even when one tab's page fetches from
//! the other's server.
//!
//! Two smaller helpers live here too: `clear_cookies`, which removes one
//! server's cookies before a login so an earlier user's session cookie cannot
//! win over the new login, and `content_size`, the window's content area.
//!
//! `install` adds the missing methods to the classes of the delegates wry made,
@@ -145,6 +151,40 @@ fn bare_host(host: &str) -> &str {
host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host)
}
/// Which tab's login a challenge is for. The URL that got the 401 belongs to
/// the tab whose server the protection space names and whose base path holds
/// it, the deepest one when several do (a server at `/` and another below a
/// path on the same origin, behind one proxy); then that tab answers only when
/// the URL is in its scope (`policy::challenge_in_scope`). So a web tab of the
/// deeper server never gets the login of the server above it. None: no tab's
/// server, so WebKit's own handling.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn pick<'a>(challenge: &Challenge<'_>, saved: &'a [(u64, BasicCredentials)]) -> Option<(u64, &'a BasicCredentials)> {
let failed = challenge.failed?;
let (tab, owner) = saved
.iter()
.filter(|(_, s)| {
credentials_match(s, challenge.scheme, challenge.host, challenge.port)
&& crate::policy::within_base(failed, &s.server)
})
.max_by_key(|(_, s)| s.server.path().len())?;
crate::policy::challenge_in_scope(failed, &owner.server).then_some((*tab, owner))
}
/// Whether a cookie the store keeps for `domain` goes to pages of `host`: a
/// host-only cookie of that host (Codeman's own, which names no domain), or a
/// domain cookie (`.example.ts.net`) the host lies under. Case-insensitive,
/// IPv6 brackets ignored.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn cookie_for_host(domain: &str, host: &str) -> bool {
let host = bare_host(host).to_ascii_lowercase();
let domain = bare_host(domain.trim()).to_ascii_lowercase();
match domain.strip_prefix('.') {
Some(parent) => host == parent || host.ends_with(&domain),
None => !domain.is_empty() && host == domain,
}
}
/// How a dialog names the page that opened it ("localhost:3000"), from a
/// security origin's host and port (0 = the scheme's default, left out).
fn origin_label(host: &str, port: i64) -> Option<String> {
@@ -182,25 +222,51 @@ pub fn install(webview: &wry::WebView) {
imp::install(webview);
}
/// The login HTTP Basic challenges from that server are answered with
/// (`None`: answer none), from the next challenge on: WebKit keeps no copy of
/// an earlier one. Also re-arms the refusal report. Main thread only.
/// The login HTTP Basic challenges from tab `tab`'s server are answered with
/// (`None`: that tab answers none), from the next challenge on: WebKit keeps
/// no copy of an earlier one. Also re-arms that tab's refusal report. Main
/// thread only.
#[cfg(target_os = "macos")]
pub fn set_credentials(creds: Option<BasicCredentials>) {
imp::set_credentials(creds);
pub fn set_credentials(tab: u64, creds: Option<BasicCredentials>) {
imp::set_credentials(tab, creds);
}
/// Called on the main thread, once per `set_credentials`, when the server
/// refused the saved login. It runs inside a WebKit callback, so it should
/// only post an event, never touch windows directly.
/// Called on the main thread with the tab, once per `set_credentials` of that
/// tab, when its server refused the saved login. It runs inside a WebKit
/// callback, so it should only post an event, never touch windows directly.
#[cfg(target_os = "macos")]
pub fn set_auth_rejected_handler(handler: Box<dyn Fn()>) {
pub fn set_auth_rejected_handler(handler: Box<dyn Fn(u64)>) {
imp::set_auth_rejected_handler(handler);
}
/// Removes every cookie from the app's default website data store, which all
/// of its web views use, then calls `done`. Only cookies: local storage, where
/// Codeman keeps per-device settings, stays. Main thread only.
/// Ties a web view to a tab: a Basic challenge from that web view is answered
/// with that tab's login only, never another tab's, whatever server the
/// request went to. Call it for every server page and pop-out web view. Main
/// thread only.
#[cfg(target_os = "macos")]
pub fn bind_view(webview: &wry::WebView, tab: u64) {
imp::bind_view(webview, tab);
}
/// A closed tab's web views answer no challenge any more.
#[cfg(target_os = "macos")]
pub fn forget_tab(tab: u64) {
imp::forget_tab(tab);
}
/// Whether WebKit gives each server a data store of its own
/// (`dataStoreForIdentifier:`, macOS 14 and later). wry falls back to the
/// default store without it, and so does `clear_cookies`.
#[cfg(target_os = "macos")]
pub fn own_stores_available() -> bool {
imp::os_major_version() >= 14
}
/// Removes the cookies of `host` (`cookie_for_host`) from a data store: the
/// server's own (`store`, on a macOS that has them) or the default one, then
/// calls `done`. Only that host's cookies, so the other servers' tabs stay
/// logged in, and only cookies: local storage, where Codeman keeps
/// per-device settings, stays. Main thread only.
///
/// `done` runs exactly once, on the main thread, as its own operation on the
/// main queue: never before this returns and never inside a WebKit callback.
@@ -209,8 +275,8 @@ pub fn set_auth_rejected_handler(handler: Box<dyn Fn()>) {
/// nothing and calls `done` right away on the calling thread. If WebKit never
/// answers at all, `done` never runs: the caller's own deadline covers that.
#[cfg(target_os = "macos")]
pub fn clear_cookies(done: Box<dyn FnOnce()>) {
imp::clear_cookies(done);
pub fn clear_cookies(store: Option<[u8; 16]>, host: String, done: Box<dyn FnOnce()>) {
imp::clear_cookies(store, host, done);
}
/// The window's content area in points (`contentRectForFrameRect` of its
@@ -244,28 +310,38 @@ mod imp {
use objc2::{msg_send, sel, ClassType, Message};
use objc2_app_kit::{NSAlert, NSAlertFirstButtonReturn, NSTextField, NSView, NSWindow};
use objc2_foundation::{
MainThreadMarker, NSDate, NSInteger, NSOperationQueue, NSPoint, NSRect, NSSet, NSSize, NSString,
NSURLAuthenticationChallenge, NSURLAuthenticationMethodHTTPBasic, NSURLCredential, NSURLCredentialPersistence,
NSURLProtectionSpace, NSURLRequest, NSURLResponse, NSURLSessionAuthChallengeDisposition, NSURL,
MainThreadMarker, NSArray, NSHTTPCookie, NSInteger, NSOperationQueue, NSPoint, NSProcessInfo, NSRect, NSSize,
NSString, NSURLAuthenticationChallenge, NSURLAuthenticationMethodHTTPBasic, NSURLCredential,
NSURLCredentialPersistence, NSURLProtectionSpace, NSURLRequest, NSURLResponse,
NSURLSessionAuthChallengeDisposition, NSURL, NSUUID,
};
use objc2_web_kit::{WKFrameInfo, WKSecurityOrigin, WKWebsiteDataStore, WKWebsiteDataTypeCookies};
use objc2_web_kit::{WKFrameInfo, WKSecurityOrigin, WKWebsiteDataStore};
use tao::platform::macos::WindowExtMacOS;
use url::Url;
use wry::WebViewExtMacOS;
use super::{decide, dialog_title, origin_label, BasicCredentials, Challenge, ChallengeAnswer};
use super::{
cookie_for_host, decide, dialog_title, origin_label, pick, BasicCredentials, Challenge, ChallengeAnswer,
};
/// Page text in a dialog is cut at this many UTF-16 units, so a huge
/// message cannot push the alert's buttons off the screen.
const MAX_DIALOG_UTF16: usize = 2000;
/// What the app is told, with the tab, when a server refuses its login.
type RejectedHandler = Rc<dyn Fn(u64)>;
thread_local! {
/// The login challenges are answered with; `None` answers none.
static SAVED: RefCell<Option<BasicCredentials>> = const { RefCell::new(None) };
/// The server refused `SAVED` and the app was told; `set_credentials` clears it.
static REJECTED: Cell<bool> = const { Cell::new(false) };
/// Told once when the server refuses `SAVED`.
static ON_REJECTED: RefCell<Option<Rc<dyn Fn()>>> = const { RefCell::new(None) };
/// The login of each tab that has one, by tab.
static SAVED: RefCell<Vec<(u64, BasicCredentials)>> = const { RefCell::new(Vec::new()) };
/// The tabs whose server refused their login, the app told; a tab's
/// `set_credentials` takes it out again.
static REJECTED: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
/// The tab of each server page and pop-out web view, by the address of
/// its WKWebView (`bind_view`).
static VIEWS: RefCell<Vec<(usize, u64)>> = const { RefCell::new(Vec::new()) };
/// Told once, with the tab, when a server refuses its login.
static ON_REJECTED: RefCell<Option<RejectedHandler>> = const { RefCell::new(None) };
}
// The added methods, with every object argument as a raw pointer: WebKit
@@ -365,61 +441,145 @@ mod imp {
}
}
pub(super) fn set_credentials(creds: Option<BasicCredentials>) {
pub(super) fn set_credentials(tab: u64, creds: Option<BasicCredentials>) {
if MainThreadMarker::new().is_none() {
eprintln!("codeman-desktop: macos::set_credentials called off the main thread; ignored");
return;
}
let stored = SAVED.try_with(|saved| saved.try_borrow_mut().map(|mut saved| *saved = creds).is_ok());
let stored = SAVED.try_with(|saved| {
saved
.try_borrow_mut()
.map(|mut saved| {
saved.retain(|(t, _)| *t != tab);
if let Some(creds) = creds {
saved.push((tab, creds));
}
})
.is_ok()
});
if stored != Ok(true) {
eprintln!("codeman-desktop: could not store the login for Basic challenges");
}
let _ = REJECTED.try_with(|rejected| rejected.set(false));
let _ = REJECTED.try_with(|rejected| {
if let Ok(mut rejected) = rejected.try_borrow_mut() {
rejected.retain(|t| *t != tab);
}
});
}
pub(super) fn set_auth_rejected_handler(handler: Box<dyn Fn()>) {
pub(super) fn bind_view(webview: &wry::WebView, tab: u64) {
if MainThreadMarker::new().is_none() {
return;
}
let address = Retained::as_ptr(&WebViewExtMacOS::webview(webview)) as usize;
let _ = VIEWS.try_with(|views| {
if let Ok(mut views) = views.try_borrow_mut() {
// A WKWebView freed earlier may have left its address behind.
views.retain(|(a, _)| *a != address);
views.push((address, tab));
}
});
}
pub(super) fn forget_tab(tab: u64) {
let _ = VIEWS.try_with(|views| {
if let Ok(mut views) = views.try_borrow_mut() {
views.retain(|(_, t)| *t != tab);
}
});
}
/// The tab a WKWebView belongs to, by its address.
fn tab_of_view(web_view: *mut AnyObject) -> Option<u64> {
let address = web_view as usize;
VIEWS
.try_with(|views| {
views
.try_borrow()
.ok()
.and_then(|views| views.iter().find(|(a, _)| *a == address).map(|(_, t)| *t))
})
.ok()
.flatten()
}
pub(super) fn os_major_version() -> isize {
NSProcessInfo::processInfo().operatingSystemVersion().majorVersion
}
pub(super) fn set_auth_rejected_handler(handler: Box<dyn Fn(u64)>) {
if MainThreadMarker::new().is_none() {
eprintln!("codeman-desktop: macos::set_auth_rejected_handler called off the main thread; ignored");
return;
}
let handler: Rc<dyn Fn()> = Rc::from(handler);
let handler: RejectedHandler = Rc::from(handler);
let stored = ON_REJECTED.try_with(|slot| slot.try_borrow_mut().map(|mut slot| *slot = Some(handler)).is_ok());
if stored != Ok(true) {
eprintln!("codeman-desktop: could not store the login-refused handler");
}
}
pub(super) fn clear_cookies(done: Box<dyn FnOnce()>) {
if MainThreadMarker::new().is_none() {
pub(super) fn clear_cookies(store: Option<[u8; 16]>, host: String, done: Box<dyn FnOnce()>) {
let Some(mtm) = MainThreadMarker::new() else {
eprintln!("codeman-desktop: macos::clear_cookies called off the main thread; cookies kept");
// `done` was made on this thread, so it runs here instead of being
// sent to another one.
run_done(done);
return;
}
};
// Every way out of here, a panic included, either hands `pending` to
// WebKit's completion handler or drops it, and both pass `done` on.
let pending = Pending(Cell::new(Some(done)));
let _ = catch_unwind(AssertUnwindSafe(|| autoreleasepool(|_| remove_cookies(pending))));
// WebKit's completion handlers or drops it, and both pass `done` on.
let pending = Rc::new(Pending(Cell::new(Some(done))));
let _ = catch_unwind(AssertUnwindSafe(|| {
autoreleasepool(|_| remove_cookies(mtm, store, host, pending))
}));
}
fn remove_cookies(pending: Pending) {
// SAFETY: class methods called on the main thread (checked by the
// caller), with nil handled by the `Option` return types.
let store: Option<Retained<WKWebsiteDataStore>> =
unsafe { msg_send![WKWebsiteDataStore::class(), defaultDataStore] };
let since: Option<Retained<NSDate>> = unsafe { msg_send![NSDate::class(), distantPast] };
let (Some(store), Some(since)) = (store, since) else {
eprintln!("codeman-desktop: no website data store; cookies kept");
return;
fn remove_cookies(mtm: MainThreadMarker, store: Option<[u8; 16]>, host: String, pending: Rc<Pending>) {
// The same store wry gave the server's web view: its own on a macOS that
// has them, else the default one. `dataStoreForIdentifier:` does not
// exist before macOS 14, and an all-zero identifier throws.
let own = store.filter(|id| os_major_version() >= 14 && id.iter().any(|b| *b != 0));
// SAFETY: class methods called on the main thread (the marker).
let data_store: Retained<WKWebsiteDataStore> = match own {
Some(id) => unsafe { WKWebsiteDataStore::dataStoreForIdentifier(&NSUUID::from_bytes(id), mtm) },
None => unsafe { WKWebsiteDataStore::defaultDataStore(mtm) },
};
// SAFETY: an immutable string constant exported by WebKit.
let types = NSSet::from_slice(&[unsafe { WKWebsiteDataTypeCookies }]);
let completion = RcBlock::new(move || pending.finish());
// SAFETY: main thread; WebKit copies the block, keeps it until the
// cookies are gone and then calls it. Dropping our reference after the
// call is fine.
unsafe { store.removeDataOfTypes_modifiedSince_completionHandler(&types, &since, &completion) };
// SAFETY: a property read on the main thread.
let cookies = unsafe { data_store.httpCookieStore() };
let delete_from = cookies.clone();
let fetched = RcBlock::new(move |all: std::ptr::NonNull<NSArray<NSHTTPCookie>>| {
// SAFETY: WebKit hands a valid array for the length of this call.
let all = unsafe { all.as_ref() };
let mine: Vec<Retained<NSHTTPCookie>> = autoreleasepool(|_| {
all.to_vec()
.into_iter()
.filter(|cookie| rust_string(&cookie.domain()).is_some_and(|d| cookie_for_host(&d, &host)))
.collect()
});
if mine.is_empty() {
pending.finish();
return;
}
// `done` goes once the last of them is deleted.
let left = Rc::new(Cell::new(mine.len()));
for cookie in &mine {
let left = left.clone();
let pending = pending.clone();
let deleted = RcBlock::new(move || {
left.set(left.get().saturating_sub(1));
if left.get() == 0 {
pending.finish();
}
});
// SAFETY: main thread (WebKit calls this handler there); WebKit
// copies the block and calls it once the cookie is gone.
unsafe { delete_from.deleteCookie_completionHandler(cookie, Some(&deleted)) };
}
});
// SAFETY: main thread; WebKit copies the block, calls it with the
// store's cookies, and releases it.
unsafe { cookies.getAllCookies(&fetched) };
}
/// `done` until it is passed on: by the completion handler, or, when
@@ -692,7 +852,7 @@ mod imp {
extern "C-unwind" fn did_receive_challenge(
_this: *mut AnyObject,
_cmd: Sel,
_web_view: *mut AnyObject,
web_view: *mut AnyObject,
challenge: *mut NSURLAuthenticationChallenge,
handler: *mut ChallengeHandler,
) {
@@ -700,22 +860,24 @@ mod imp {
let Some(handler) = (unsafe { handler.as_ref() }) else {
return;
};
let reply = catch_unwind(AssertUnwindSafe(|| reply_to(unsafe { challenge.as_ref() }))).unwrap_or_default();
let tab = tab_of_view(web_view);
let reply = catch_unwind(AssertUnwindSafe(|| reply_to(unsafe { challenge.as_ref() }, tab))).unwrap_or_default();
let credential = reply
.credential
.as_ref()
.map_or(ptr::null_mut(), |c| Retained::as_ptr(c).cast_mut());
handler.call((reply.disposition, credential));
// Told after WebKit has its answer, so the app's handler cannot delay it.
if reply.report {
notify_rejected();
if let Some(tab) = reply.report {
notify_rejected(tab);
}
}
struct Reply {
disposition: NSURLSessionAuthChallengeDisposition,
credential: Option<Retained<NSURLCredential>>,
report: bool,
/// The tab whose server refused its login, to tell the app.
report: Option<u64>,
}
impl Default for Reply {
@@ -723,12 +885,14 @@ mod imp {
Reply {
disposition: NSURLSessionAuthChallengeDisposition::PerformDefaultHandling,
credential: None,
report: false,
report: None,
}
}
}
fn reply_to(challenge: Option<&NSURLAuthenticationChallenge>) -> Reply {
/// `tab`: the tab of the web view that was challenged, whose login is the
/// only one it may get. None (a view of no tab): no login at all.
fn reply_to(challenge: Option<&NSURLAuthenticationChallenge>, tab: Option<u64>) -> Reply {
let Some(challenge) = challenge else {
return Reply::default();
};
@@ -762,10 +926,13 @@ mod imp {
let failed = failure_url(challenge);
let saved = SAVED
.try_with(|saved| saved.try_borrow().ok().and_then(|saved| saved.clone()))
.try_with(|saved| saved.try_borrow().ok().map(|saved| saved.clone()))
.ok()
.flatten();
let rejected = REJECTED.try_with(Cell::get).unwrap_or(false);
.flatten()
.unwrap_or_default()
.into_iter()
.filter(|(t, _)| Some(*t) == tab)
.collect::<Vec<_>>();
let asked = Challenge {
basic,
proxy,
@@ -775,8 +942,14 @@ mod imp {
previous_failures: previous_failures as i64,
failed: failed.as_ref(),
};
match (decide(&asked, saved.as_ref(), rejected), saved) {
(ChallengeAnswer::UseCredential, Some(saved)) => {
let picked = pick(&asked, &saved);
let rejected = picked.is_some_and(|(tab, _)| {
REJECTED
.try_with(|rejected| rejected.try_borrow().is_ok_and(|r| r.contains(&tab)))
.unwrap_or(false)
});
match (decide(&asked, picked.map(|(_, s)| s), rejected), picked) {
(ChallengeAnswer::UseCredential, Some((_, saved))) => {
let user = NSString::from_str(&saved.username);
let password = NSString::from_str(&saved.password);
// None: WebKit uses it for this request only and stores
@@ -797,26 +970,32 @@ mod imp {
Some(credential) => Reply {
disposition: NSURLSessionAuthChallengeDisposition::UseCredential,
credential: Some(credential),
report: false,
report: None,
},
None => Reply::default(),
}
}
(ChallengeAnswer::CancelAndReport, _) => {
let _ = REJECTED.try_with(|rejected| rejected.set(true));
(ChallengeAnswer::CancelAndReport, Some((tab, _))) => {
let _ = REJECTED.try_with(|rejected| {
if let Ok(mut rejected) = rejected.try_borrow_mut() {
rejected.push(tab);
}
});
eprintln!("codeman-desktop: {host}:{port} refused the saved login; not sending it again");
Reply {
disposition: NSURLSessionAuthChallengeDisposition::CancelAuthenticationChallenge,
credential: None,
report: true,
report: Some(tab),
}
}
(ChallengeAnswer::Cancel, _) => Reply {
disposition: NSURLSessionAuthChallengeDisposition::CancelAuthenticationChallenge,
credential: None,
report: false,
report: None,
},
(ChallengeAnswer::Default | ChallengeAnswer::UseCredential, _) => Reply::default(),
(ChallengeAnswer::Default | ChallengeAnswer::UseCredential | ChallengeAnswer::CancelAndReport, _) => {
Reply::default()
}
}
})
}
@@ -833,7 +1012,7 @@ mod imp {
Url::parse(&rust_string(&*text?)?).ok()
}
fn notify_rejected() {
fn notify_rejected(tab: u64) {
// Cloned out first: the handler may call `set_credentials` or
// `set_auth_rejected_handler`, which would find the cell borrowed.
let handler = ON_REJECTED
@@ -841,7 +1020,7 @@ mod imp {
.ok()
.flatten();
if let Some(handler) = handler {
if catch_unwind(AssertUnwindSafe(|| handler())).is_err() {
if catch_unwind(AssertUnwindSafe(|| handler(tab))).is_err() {
eprintln!("codeman-desktop: the login-refused handler panicked");
}
}
@@ -1074,4 +1253,45 @@ mod tests {
// gets a first line of the app's own, never its own text in bold.
assert_eq!(dialog_title(None), "An embedded page says:");
}
#[test]
fn each_tab_answers_for_its_own_server() {
let saved = vec![
(1, saved("http://127.0.0.1:3000/")),
(2, saved("http://127.0.0.1:5000/")),
(3, saved("http://h:3000/")),
(4, saved("http://h:3000/codeman/")),
];
let at = |port: i64, host: &'static str, failed: &Url| {
let c = challenge("http", host, port, 0, Some(failed));
pick(&c, &saved).map(|(tab, _)| tab)
};
assert_eq!(at(3000, "127.0.0.1", &url("http://127.0.0.1:3000/api/status")), Some(1));
assert_eq!(at(5000, "127.0.0.1", &url("http://127.0.0.1:5000/api/status")), Some(2));
assert_eq!(at(4000, "127.0.0.1", &url("http://127.0.0.1:4000/api/status")), None);
// Below a base path the deeper server wins; elsewhere the root one.
assert_eq!(at(3000, "h", &url("http://h:3000/codeman/api/status")), Some(4));
assert_eq!(at(3000, "h", &url("http://h:3000/api/status")), Some(3));
// A web tab under either is nobody's.
assert_eq!(at(3000, "h", &url("http://h:3000/webview/cap/")), None);
assert_eq!(at(3000, "h", &url("http://h:3000/codeman/webview/cap/")), None);
let none: Vec<(u64, BasicCredentials)> = Vec::new();
let c = challenge("http", "h", 3000, 0, None);
assert!(pick(&c, &none).is_none());
}
#[test]
fn cookies_go_with_their_host() {
assert!(cookie_for_host("127.0.0.1", "127.0.0.1"));
assert!(cookie_for_host("Studio.Example.ts.net", "studio.example.ts.net"));
assert!(cookie_for_host(".example.ts.net", "studio.example.ts.net"));
assert!(cookie_for_host(".example.ts.net", "example.ts.net"));
assert!(cookie_for_host("::1", "[::1]"));
assert!(!cookie_for_host("atlas.example.ts.net", "studio.example.ts.net"));
assert!(!cookie_for_host(".other.ts.net", "studio.example.ts.net"));
assert!(!cookie_for_host("example.ts.net", "studio.example.ts.net"));
assert!(!cookie_for_host("localhost", "127.0.0.1"));
assert!(!cookie_for_host("", "127.0.0.1"));
assert!(!cookie_for_host(".ample.ts.net", "studio.example.ts.net"));
}
}
+2235 -689
View File
File diff suppressed because it is too large. Load diff
+225 -2
View File
@@ -3,6 +3,8 @@
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::tabs::{PALETTE, PALETTE_NAMES};
use crate::config::Config;
use crate::keep_awake::{self, Mode};
use crate::logo::{self, BlinkStyle, Signal};
@@ -21,11 +23,124 @@ pub mod menu_id {
pub const DEVTOOLS: &str = "devtools";
pub const HELP_DOCS: &str = "help-docs";
pub const HELP_GITHUB: &str = "help-github";
pub const TAB_NEW: &str = "tab-new";
pub const TAB_CLOSE: &str = "tab-close";
pub const TAB_REOPEN: &str = "tab-reopen";
pub const TAB_OVERVIEW: &str = "tab-overview";
pub const TAB_NEXT: &str = "tab-next";
pub const TAB_PREVIOUS: &str = "tab-previous";
pub const WINDOW_CLOSE: &str = "window-close";
/// `tab-key-<n>`: the tab ⌘n selects (tabs::index_of_key).
pub const TAB_KEY_PREFIX: &str = "tab-key-";
/// `tab-menu:<tab>:<action>`: an item of a tab's right-click menu.
pub const TAB_MENU_PREFIX: &str = "tab-menu:";
}
/// What a tab's right-click menu asks for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabAction {
Rename,
/// A palette colour, by index into `tabs::PALETTE`.
Color(usize),
Reload,
/// Show the connect page in this tab (another server, or the same with
/// another login).
Reconnect,
Close,
CloseOthers,
}
impl TabAction {
fn id(self, tab: u64) -> String {
let action = match self {
TabAction::Rename => "rename".to_string(),
TabAction::Color(i) => format!("color-{i}"),
TabAction::Reload => "reload".to_string(),
TabAction::Reconnect => "reconnect".to_string(),
TabAction::Close => "close".to_string(),
TabAction::CloseOthers => "close-others".to_string(),
};
format!("{}{tab}:{action}", menu_id::TAB_MENU_PREFIX)
}
/// The tab and action of a right-click menu item's id.
pub fn parse(id: &str) -> Option<(u64, TabAction)> {
let (tab, action) = id.strip_prefix(menu_id::TAB_MENU_PREFIX)?.split_once(':')?;
let tab = tab.parse().ok()?;
let action = match action {
"rename" => TabAction::Rename,
"reload" => TabAction::Reload,
"reconnect" => TabAction::Reconnect,
"close" => TabAction::Close,
"close-others" => TabAction::CloseOthers,
other => {
let i: usize = other.strip_prefix("color-")?.parse().ok()?;
(i < PALETTE.len()).then_some(TabAction::Color(i))?
}
};
Some((tab, action))
}
}
/// The ⌘ number of a `tab-key-<n>` item.
pub fn tab_key(id: &str) -> Option<usize> {
id.strip_prefix(menu_id::TAB_KEY_PREFIX)?
.parse()
.ok()
.filter(|k| (1..=9).contains(k))
}
/// The right-click menu of one tab. `server`: the tab shows a server (Reload
/// and Connect to Another Server make sense); `others`: there are other tabs.
pub fn tab_context_menu(tab: u64, color: &str, server: bool, others: bool) -> muda::Result<Menu> {
let menu = Menu::new();
menu.append(&MenuItem::with_id(
TabAction::Rename.id(tab),
"Rename Tab\u{2026}",
true,
None,
))?;
let colors = Submenu::new("Tab Color", true);
for (i, (value, name)) in PALETTE.iter().zip(PALETTE_NAMES).enumerate() {
let chosen = value.eq_ignore_ascii_case(color);
colors.append(&CheckMenuItem::with_id(
TabAction::Color(i).id(tab),
name,
true,
chosen,
None,
))?;
}
menu.append(&colors)?;
menu.append(&PredefinedMenuItem::separator())?;
menu.append(&MenuItem::with_id(TabAction::Reload.id(tab), "Reload", server, None))?;
menu.append(&MenuItem::with_id(
TabAction::Reconnect.id(tab),
"Connect This Tab to Another Server\u{2026}",
server,
None,
))?;
menu.append(&PredefinedMenuItem::separator())?;
menu.append(&MenuItem::with_id(TabAction::Close.id(tab), "Close Tab", true, None))?;
menu.append(&MenuItem::with_id(
TabAction::CloseOthers.id(tab),
"Close Other Tabs",
others,
None,
))?;
Ok(menu)
}
pub struct AppMenu {
pub bar: Menu,
pub window_menu: Submenu,
/// Server: the tab commands, then one item per tab (`set_tabs`).
server_menu: Submenu,
/// ⌘1 to ⌘9, made once and shown as many as there are tabs.
tab_items: Vec<CheckMenuItem>,
/// How many of `tab_items` are in the menu.
tab_items_shown: usize,
pub reopen: MenuItem,
pub help_menu: Submenu,
pub start_fullscreen: CheckMenuItem,
/// The one switch for the logo. None on a Mac without a lit logo, which has no
@@ -120,6 +235,34 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
))
}
impl AppMenu {
/// One item per tab under Server, as (name, in front), with ⌘1 to ⌘8 for
/// the first eight and ⌘9 for the last (`tabs::key_of`). Items past nine
/// tabs are left out, as Safari does.
pub fn set_tabs(&mut self, tabs: &[(String, bool)]) {
let len = tabs.len();
let shown: Vec<(usize, &(String, bool))> = tabs
.iter()
.enumerate()
.filter_map(|(i, t)| crate::tabs::key_of(i, len).map(|k| (k, t)))
.collect();
for item in self.tab_items.iter().take(self.tab_items_shown) {
let _ = self.server_menu.remove(item);
}
self.tab_items_shown = 0;
// Appended in key order, which is tab order.
for (key, (name, active)) in shown {
if let Some(item) = self.tab_items.get(key - 1) {
item.set_text(name);
item.set_checked(*active);
if self.server_menu.append(item).is_ok() {
self.tab_items_shown = self.tab_items_shown.max(key);
}
}
}
}
}
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
let bar = Menu::new();
let about = PredefinedMenuItem::about(
@@ -243,6 +386,52 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
logo_switch = Some(switch);
logo_lid = Some(lid);
}
let cmd = |code| Some(Accelerator::new(Modifiers::META, code));
let cmd_shift = |code| Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, code));
let reopen = MenuItem::with_id(menu_id::TAB_REOPEN, "Reopen Closed Tab", false, cmd_shift(Code::KeyT));
let server_menu = Submenu::with_items(
"Server",
true,
&[
&MenuItem::with_id(menu_id::TAB_NEW, "New Server Tab", true, cmd(Code::KeyT)),
&MenuItem::with_id(menu_id::TAB_CLOSE, "Close Tab", true, cmd(Code::KeyW)),
&reopen,
&PredefinedMenuItem::separator(),
&MenuItem::with_id(menu_id::TAB_OVERVIEW, "Show All Servers", true, cmd(Code::Digit0)),
&MenuItem::with_id(
menu_id::TAB_PREVIOUS,
"Show Previous Tab",
true,
cmd_shift(Code::BracketLeft),
),
&MenuItem::with_id(menu_id::TAB_NEXT, "Show Next Tab", true, cmd_shift(Code::BracketRight)),
&PredefinedMenuItem::separator(),
],
)?;
const DIGITS: [Code; 9] = [
Code::Digit1,
Code::Digit2,
Code::Digit3,
Code::Digit4,
Code::Digit5,
Code::Digit6,
Code::Digit7,
Code::Digit8,
Code::Digit9,
];
let tab_items = DIGITS
.iter()
.enumerate()
.map(|(i, code)| {
CheckMenuItem::with_id(
format!("{}{}", menu_id::TAB_KEY_PREFIX, i + 1),
"",
true,
false,
cmd(*code),
)
})
.collect();
let window_menu = Submenu::with_items(
"Window",
true,
@@ -250,7 +439,9 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(None),
// Not the predefined Close Window: ⌘W closes a tab (Server menu),
// as in Safari, so closing the window is ⇧⌘W.
&MenuItem::with_id(menu_id::WINDOW_CLOSE, "Close Window", true, cmd_shift(Code::KeyW)),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
],
@@ -263,10 +454,14 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
&MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None),
],
)?;
bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?;
bar.append_items(&[&app_menu, &edit, &view, &server_menu, &window_menu, &help_menu])?;
Ok(AppMenu {
bar,
window_menu,
server_menu,
tab_items,
tab_items_shown: 0,
reopen,
help_menu,
start_fullscreen,
logo_switch,
@@ -275,3 +470,31 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
logo_lid,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_menu_ids_round_trip() {
for action in [
TabAction::Rename,
TabAction::Color(0),
TabAction::Color(5),
TabAction::Reload,
TabAction::Reconnect,
TabAction::Close,
TabAction::CloseOthers,
] {
assert_eq!(TabAction::parse(&action.id(42)), Some((42, action)));
}
assert_eq!(TabAction::parse("tab-menu:42:color-6"), None);
assert_eq!(TabAction::parse("tab-menu:x:close"), None);
assert_eq!(TabAction::parse("tab-menu:42:other"), None);
assert_eq!(TabAction::parse("reload"), None);
assert_eq!(tab_key("tab-key-1"), Some(1));
assert_eq!(tab_key("tab-key-9"), Some(9));
assert_eq!(tab_key("tab-key-0"), None);
assert_eq!(tab_key("tab-key-10"), None);
}
}
+229
View File
@@ -826,6 +826,141 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
Some(Ipc::Host(cmd))
}
/// The tab strip across the top of the window (assets/tabs.html).
pub const STRIP_PAGE: &str = "codemanapp://localhost/tabs.html";
/// The overview of every server (assets/overview.html).
pub const OVERVIEW_PAGE: &str = "codemanapp://localhost/overview.html";
/// What the tab strip asks for. Tab ids are the app's own numbers; a stale or
/// made-up one matches no tab and does nothing.
#[derive(Debug, PartialEq, Eq)]
pub enum StripCmd {
Ready,
Select(u64),
Close(u64),
New,
Overview,
/// Right-click on a tab: the native menu for it.
Menu(u64),
/// The name the user typed (cleaned by `tabs::clean_name`; empty puts the
/// default back).
Rename {
id: u64,
name: String,
},
Move {
id: u64,
to: usize,
},
/// A rename started: the strip takes the keyboard for its name field.
Editing,
/// A mouse-down on the strip's empty space: drag the window.
Drag,
/// A double-click there: what a title bar double-click does.
Zoom,
}
/// What the overview asks for.
#[derive(Debug, PartialEq, Eq)]
pub enum OverviewCmd {
Ready,
Open(u64),
/// Show that tab and select the session in Codeman's page
/// (`tabs::is_session_id` is checked before it goes anywhere).
OpenSession {
tab: u64,
session: String,
},
New,
Retry(u64),
}
#[derive(Deserialize)]
#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)]
enum RawStrip {
Ready,
Select { id: u64 },
Close { id: u64 },
New,
Overview,
Menu { id: u64 },
Rename { id: u64, name: String },
Move { id: u64, to: usize },
Editing,
Drag,
Zoom,
}
#[derive(Deserialize)]
#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)]
enum RawOverview {
Ready,
Open { tab: u64 },
OpenSession { tab: u64, session: String },
New,
Retry { tab: u64 },
}
/// Whether `frame_url` is exactly the bundled page `page` (a query or
/// fragment does not matter).
fn is_bundled_page(frame_url: &str, page: &str) -> bool {
let (Ok(frame), Ok(page)) = (Url::parse(frame_url), Url::parse(page)) else {
return false;
};
frame.scheme() == APP_SCHEME && frame.host_str() == page.host_str() && frame.path() == page.path()
}
/// A message from the tab strip. Only its own page in its own web view is
/// listened to: the caller passes only what came from the strip's web view,
/// and the frame must be the strip page.
pub fn parse_strip_ipc(frame_url: &str, body: &str) -> Option<StripCmd> {
if body.len() > MAX_MESSAGE_LEN || !is_bundled_page(frame_url, STRIP_PAGE) {
return None;
}
Some(match serde_json::from_str::<RawStrip>(body).ok()? {
RawStrip::Ready => StripCmd::Ready,
RawStrip::Select { id } => StripCmd::Select(id),
RawStrip::Close { id } => StripCmd::Close(id),
RawStrip::New => StripCmd::New,
RawStrip::Overview => StripCmd::Overview,
RawStrip::Menu { id } => StripCmd::Menu(id),
RawStrip::Rename { id, name } if name.len() <= MAX_FIELD_LEN => StripCmd::Rename { id, name },
RawStrip::Move { id, to } => StripCmd::Move { id, to },
RawStrip::Editing => StripCmd::Editing,
RawStrip::Drag => StripCmd::Drag,
RawStrip::Zoom => StripCmd::Zoom,
RawStrip::Rename { .. } => return None,
})
}
/// A message from the overview, under the same rule as the strip's.
pub fn parse_overview_ipc(frame_url: &str, body: &str) -> Option<OverviewCmd> {
if body.len() > MAX_MESSAGE_LEN || !is_bundled_page(frame_url, OVERVIEW_PAGE) {
return None;
}
Some(match serde_json::from_str::<RawOverview>(body).ok()? {
RawOverview::Ready => OverviewCmd::Ready,
RawOverview::Open { tab } => OverviewCmd::Open(tab),
RawOverview::OpenSession { tab, session } if session.len() <= MAX_FIELD_LEN => {
OverviewCmd::OpenSession { tab, session }
}
RawOverview::New => OverviewCmd::New,
RawOverview::Retry { tab } => OverviewCmd::Retry(tab),
RawOverview::OpenSession { .. } => return None,
})
}
/// The navigations the strip's and the overview's web views allow: their own
/// bundled page and nothing else, so neither can ever show a server's page or
/// another site.
pub fn bundled_navigation(target: &str, page: &str) -> Navigation {
if is_bundled_page(target, page) {
Navigation::Allow
} else {
Navigation::Block
}
}
/// Only http(s) URLs ever reach the default browser (never `file:`, a custom
/// app scheme, or anything `open` would hand to another program).
pub fn external_url(target: &str) -> Option<Url> {
@@ -1886,4 +2021,98 @@ mod tests {
assert!(external_url("codemanapp://localhost/").is_none());
assert!(external_url("vscode://file/x").is_none());
}
#[test]
fn strip_messages_only_from_the_strip_page() {
let strip = STRIP_PAGE;
assert_eq!(parse_strip_ipc(strip, r#"{"cmd":"ready"}"#), Some(StripCmd::Ready));
assert_eq!(
parse_strip_ipc(strip, r#"{"cmd":"select","id":3}"#),
Some(StripCmd::Select(3))
);
assert_eq!(
parse_strip_ipc(strip, r#"{"cmd":"close","id":3}"#),
Some(StripCmd::Close(3))
);
assert_eq!(
parse_strip_ipc(strip, r#"{"cmd":"menu","id":3}"#),
Some(StripCmd::Menu(3))
);
assert_eq!(
parse_strip_ipc(strip, r#"{"cmd":"rename","id":3,"name":"studio"}"#),
Some(StripCmd::Rename {
id: 3,
name: "studio".into()
})
);
assert_eq!(
parse_strip_ipc(strip, r#"{"cmd":"move","id":3,"to":0}"#),
Some(StripCmd::Move { id: 3, to: 0 })
);
assert_eq!(parse_strip_ipc(strip, r#"{"cmd":"drag"}"#), Some(StripCmd::Drag));
assert_eq!(parse_strip_ipc(strip, r#"{"cmd":"zoom"}"#), Some(StripCmd::Zoom));
assert_eq!(parse_strip_ipc(strip, r#"{"cmd":"editing"}"#), Some(StripCmd::Editing));
assert_eq!(parse_strip_ipc(strip, r#"{"cmd":"new"}"#), Some(StripCmd::New));
assert_eq!(
parse_strip_ipc(strip, r#"{"cmd":"overview"}"#),
Some(StripCmd::Overview)
);
assert_eq!(
parse_strip_ipc("codemanapp://localhost/tabs.html?x=1", r#"{"cmd":"new"}"#),
Some(StripCmd::New)
);
// Other frames, other pages, other shapes.
assert_eq!(parse_strip_ipc(CONNECT_PAGE, r#"{"cmd":"new"}"#), None);
assert_eq!(parse_strip_ipc(OVERVIEW_PAGE, r#"{"cmd":"new"}"#), None);
assert_eq!(parse_strip_ipc("http://localhost/tabs.html", r#"{"cmd":"new"}"#), None);
assert_eq!(parse_strip_ipc("codemanapp://evil/tabs.html", r#"{"cmd":"new"}"#), None);
assert_eq!(parse_strip_ipc(strip, r#"{"cmd":"select","id":-1}"#), None);
assert_eq!(parse_strip_ipc(strip, r#"{"cmd":"select","id":"3"}"#), None);
assert_eq!(parse_strip_ipc(strip, r#"{"cmd":"connect","url":"http://x/"}"#), None);
let long = format!(
r#"{{"cmd":"rename","id":1,"name":"{}"}}"#,
"x".repeat(MAX_FIELD_LEN + 1)
);
assert_eq!(parse_strip_ipc(strip, &long), None);
}
#[test]
fn overview_messages_only_from_the_overview_page() {
let page = OVERVIEW_PAGE;
assert_eq!(parse_overview_ipc(page, r#"{"cmd":"ready"}"#), Some(OverviewCmd::Ready));
assert_eq!(
parse_overview_ipc(page, r#"{"cmd":"open","tab":2}"#),
Some(OverviewCmd::Open(2))
);
assert_eq!(
parse_overview_ipc(page, r#"{"cmd":"retry","tab":2}"#),
Some(OverviewCmd::Retry(2))
);
assert_eq!(parse_overview_ipc(page, r#"{"cmd":"new"}"#), Some(OverviewCmd::New));
assert_eq!(
parse_overview_ipc(page, r#"{"cmd":"openSession","tab":2,"session":"abc-1"}"#),
Some(OverviewCmd::OpenSession {
tab: 2,
session: "abc-1".into()
})
);
assert_eq!(parse_overview_ipc(STRIP_PAGE, r#"{"cmd":"new"}"#), None);
assert_eq!(parse_overview_ipc(CONNECT_PAGE, r#"{"cmd":"ready"}"#), None);
assert_eq!(parse_overview_ipc(page, r#"{"cmd":"select","id":1}"#), None);
}
#[test]
fn bundled_pages_navigate_nowhere_else() {
assert_eq!(bundled_navigation(STRIP_PAGE, STRIP_PAGE), Navigation::Allow);
assert_eq!(bundled_navigation(OVERVIEW_PAGE, STRIP_PAGE), Navigation::Block);
assert_eq!(bundled_navigation(CONNECT_PAGE, STRIP_PAGE), Navigation::Block);
assert_eq!(
bundled_navigation("http://127.0.0.1:3000/", STRIP_PAGE),
Navigation::Block
);
assert_eq!(bundled_navigation("about:blank", OVERVIEW_PAGE), Navigation::Block);
// The connect page's own gate does not admit the two new pages.
assert!(!is_connect_page(&Url::parse(STRIP_PAGE).unwrap()));
assert!(!is_connect_page(&Url::parse(OVERVIEW_PAGE).unwrap()));
}
}
+650
View File
@@ -0,0 +1,650 @@
//! Server tabs: the pure half. One window holds a tab per Codeman server (or a
//! "New server" tab that shows the connect page), a strip across the top of
//! the window draws them (`assets/tabs.html`), and an overview of every server
//! (`assets/overview.html`) can take the place of the active tab. Everything
//! here is a decision with no window behind it, so it is unit-tested on its own:
//! names, colours, order, which data store a server's pages use, and the state
//! the two pages are sent.
use serde::Serialize;
use url::{Host, Url};
use crate::agents::{Detail, SessionState};
/// The height of the tab strip, in points. The window's other web views start
/// below it.
pub const STRIP_HEIGHT: f64 = 38.0;
/// Where the window's traffic lights go so they sit centred in the strip: the
/// close button's left edge and the buttons' centre line, in points.
pub const TRAFFIC_LIGHTS: (f64, f64) = (14.0, 19.0);
/// The longest tab name the strip accepts, in characters.
pub const MAX_NAME: usize = 40;
/// How many session rows a card of the overview gets.
pub const OVERVIEW_ROWS: usize = 4;
/// How many closed tabs Reopen Closed Tab remembers.
pub const MAX_CLOSED: usize = 10;
/// The colours a new tab is given, in turn: violet, cyan, emerald, pink,
/// amber, slate. None of them is the red, amber dot or blue of a status badge
/// at the same lightness, so a stripe never reads as an alert.
pub const PALETTE: [&str; 6] = ["#a78bfa", "#22d3ee", "#34d399", "#f472b6", "#f59e0b", "#94a3b8"];
/// The names the right-click menu gives the palette, in the same order.
pub const PALETTE_NAMES: [&str; 6] = ["Violet", "Cyan", "Green", "Pink", "Amber", "Slate"];
/// A colour the strip may draw: `#rrggbb` and nothing else, so a value read
/// from the settings file can never carry CSS.
pub fn is_color(value: &str) -> bool {
value.len() == 7 && value.starts_with('#') && value[1..].bytes().all(|b| b.is_ascii_hexdigit())
}
/// The first palette colour no open tab uses, else the palette again in turn.
pub fn next_color<'a>(used: impl IntoIterator<Item = &'a str>) -> String {
let used: Vec<String> = used.into_iter().map(str::to_ascii_lowercase).collect();
PALETTE
.iter()
.find(|c| !used.iter().any(|u| u == *c))
.copied()
.unwrap_or(PALETTE[used.len() % PALETTE.len()])
.to_string()
}
/// What a tab is called until the user names it: `This Mac` for a server on
/// loopback, else the first label of its host name (`studio` for
/// `studio.example.ts.net`), or the whole address for an IP literal. A port
/// other than Codeman's 3000 (or the scheme's own) is added, so two servers on
/// one machine do not look alike.
pub fn default_name(url: &Url) -> String {
let base = match url.host() {
_ if crate::policy::is_loopback(url) => "This Mac".to_string(),
Some(Host::Domain(name)) => name
.split('.')
.next()
.filter(|l| !l.is_empty())
.unwrap_or(name)
.to_string(),
Some(Host::Ipv4(ip)) => ip.to_string(),
Some(Host::Ipv6(ip)) => format!("[{ip}]"),
None => url.as_str().to_string(),
};
match url.port() {
Some(port) if port != 3000 => format!("{base}:{port}"),
_ => base,
}
}
/// A name the user typed, cleaned: trimmed, control characters dropped, cut at
/// `MAX_NAME` characters. None when nothing is left, which puts the default
/// name back.
pub fn clean_name(raw: &str) -> Option<String> {
let kept: String = raw.chars().filter(|c| !c.is_control()).collect();
let trimmed: String = kept.trim().chars().take(MAX_NAME).collect();
let trimmed = trimmed.trim_end().to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
/// What a rename the strip finished does to a tab's own name: None, nothing
/// (it ended without a change: Escape, or Enter on the same text, sends the
/// name the tab shows, and kept, that would pin "New server" to a tab before
/// it has a server); `Some(None)`, back to the default name (an empty name,
/// or the default typed out); `Some(Some(name))`, that name.
pub fn renamed(typed: &str, shown: &str, default: Option<&str>) -> Option<Option<String>> {
let Some(name) = clean_name(typed) else {
return Some(None);
};
if clean_name(shown).as_deref() == Some(name.as_str()) {
return None;
}
Some((default != Some(name.as_str())).then_some(name))
}
/// The identifier of the WebKit data store (cookies and local storage) that a
/// server's pages use. Each server gets one of its own, so two servers on one
/// host (a Codeman on port 3000 and a beta on 5000) never share a login cookie,
/// which carries the same name on both and is not told apart by port. Derived
/// from the server's address, so the same server finds its store, and with it
/// Codeman's per-device settings, at every launch. Two 64-bit FNV-1a hashes
/// with different starting values: stable across Rust releases, which the
/// standard library's hasher is not promised to be.
pub fn store_id(server: &str) -> [u8; 16] {
fn fnv(bytes: &[u8], basis: u64) -> u64 {
bytes
.iter()
.fold(basis, |hash, b| (hash ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
}
let bytes = server.as_bytes();
let hi = fnv(bytes, 0xcbf2_9ce4_8422_2325);
let lo = fnv(bytes, 0x8422_2325_cbf2_9ce4 ^ 0x5eed);
let mut id = [0u8; 16];
id[..8].copy_from_slice(&hi.to_be_bytes());
id[8..].copy_from_slice(&lo.to_be_bytes());
// A well-formed UUID: version 8 (custom), RFC 4122 variant.
id[6] = (id[6] & 0x0f) | 0x80;
id[8] = (id[8] & 0x3f) | 0x80;
id
}
/// Where `from` lands when the strip asks to move it to `to`, both indexes
/// into a list of `len`. None for an index out of range or no move.
pub fn moved(len: usize, from: usize, to: usize) -> Option<usize> {
(from < len && to < len && from != to).then_some(to)
}
/// The tab after (or before) `active` in a list of `len`, wrapping around.
pub fn neighbour(len: usize, active: usize, forward: bool) -> Option<usize> {
match len {
0 => None,
_ if forward => Some((active + 1) % len),
_ => Some((active + len - 1) % len),
}
}
/// Which tab to show after closing the one at `closed` (of `len` before the
/// close), when it was the active one: the tab that took its place, else the
/// one before it.
pub fn after_close(len: usize, closed: usize) -> Option<usize> {
match len {
0 | 1 => None,
_ if closed + 1 < len => Some(closed),
_ => Some(closed - 1),
}
}
/// Whether `id` can be handed to Codeman's page as a session id: what Codeman
/// makes (UUIDs) and nothing that could step out of a string.
pub fn is_session_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// What a tab shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Kind {
/// The connect page, nothing chosen yet.
Connect,
/// A server's page is on its way; the connect page still shows.
Loading,
/// A server's page.
Server,
}
/// What the badges of a tab are counted from: the last status read of its
/// server, and whether that server answers at all.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Seen {
pub detail: Option<Detail>,
/// The last few reads failed.
pub offline: bool,
/// The server refused the login: nothing is asked any more.
pub refused: bool,
}
/// One tab as the strip and the overview see it.
#[derive(Debug, Clone)]
pub struct View<'a> {
pub id: u64,
pub name: String,
pub color: &'a str,
pub kind: Kind,
pub url: Option<&'a Url>,
pub version: Option<&'a str>,
pub seen: &'a Seen,
}
#[derive(Debug, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct Counts {
pub needs_you: usize,
pub waiting: usize,
pub working: usize,
pub sessions: usize,
}
fn counts(seen: &Seen) -> Counts {
let Some(detail) = &seen.detail else {
return Counts::default();
};
let of = |state: SessionState| detail.sessions.iter().filter(|s| s.state == state).count();
Counts {
needs_you: of(SessionState::Needs),
waiting: of(SessionState::Waiting),
working: of(SessionState::Working),
sessions: detail.sessions.len(),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StripStatus {
working: usize,
waiting: usize,
needs_you: usize,
offline: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StripTab<'a> {
id: u64,
name: &'a str,
color: &'a str,
active: bool,
kind: Kind,
local: bool,
url: Option<&'a str>,
key: Option<usize>,
status: StripStatus,
}
/// The ⌘ number of the tab at `index`: ⌘1 to ⌘8 for the first eight, ⌘9 for
/// the last one, as in Safari.
pub fn key_of(index: usize, len: usize) -> Option<usize> {
match index {
_ if index + 1 == len && len > 8 => Some(9),
i if i < 8 => Some(i + 1),
_ => None,
}
}
/// The tab ⌘`key` selects in a list of `len`.
pub fn index_of_key(key: usize, len: usize) -> Option<usize> {
match key {
9 if len > 0 => Some(len - 1),
k @ 1..=8 if k <= len => Some(k - 1),
_ => None,
}
}
/// The strip's whole state (`assets/tabs.html`, message `state`).
pub fn strip_state(tabs: &[View<'_>], active: Option<usize>, overview: bool, fullscreen: bool) -> serde_json::Value {
let len = tabs.len();
let list: Vec<StripTab<'_>> = tabs
.iter()
.enumerate()
.map(|(i, t)| {
let c = counts(t.seen);
StripTab {
id: t.id,
name: &t.name,
color: t.color,
active: !overview && active == Some(i),
kind: t.kind,
local: t.url.is_some_and(crate::policy::is_loopback),
url: t.url.map(Url::as_str),
key: key_of(i, len),
status: StripStatus {
working: c.working,
waiting: c.waiting,
needs_you: c.needs_you,
offline: t.kind == Kind::Server && t.seen.offline,
},
}
})
.collect();
serde_json::json!({
"type": "state",
"tabs": list,
"overviewActive": overview,
"fullscreen": fullscreen,
})
}
#[derive(Serialize)]
struct Row<'a> {
id: &'a str,
name: &'a str,
state: SessionState,
text: &'a str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Card<'a> {
tab: u64,
name: &'a str,
color: &'a str,
url: Option<&'a str>,
local: bool,
key: Option<usize>,
version: Option<&'a str>,
kind: Kind,
offline: bool,
refused: bool,
sessions: Vec<Row<'a>>,
counts: Counts,
}
/// The overview's whole state (`assets/overview.html`, message `state`): a
/// card per tab, the servers that need someone first, then those with an agent
/// waiting, then the rest in tab order. Each card lists its sessions most
/// urgent first, `OVERVIEW_ROWS` at most.
pub fn overview_state(tabs: &[View<'_>]) -> serde_json::Value {
let len = tabs.len();
let mut cards: Vec<(usize, Card<'_>)> = tabs
.iter()
.enumerate()
.map(|(i, t)| {
let mut rows: Vec<Row<'_>> = t
.seen
.detail
.iter()
.flat_map(|d| d.sessions.iter())
.map(|s| Row {
id: &s.id,
name: &s.name,
state: s.state,
text: &s.text,
})
.collect();
rows.sort_by_key(|r| r.state.rank());
rows.truncate(OVERVIEW_ROWS);
(
i,
Card {
tab: t.id,
name: &t.name,
color: t.color,
url: t.url.map(Url::as_str),
local: t.url.is_some_and(crate::policy::is_loopback),
key: key_of(i, len),
version: t.version,
kind: t.kind,
offline: t.kind == Kind::Server && t.seen.offline,
refused: t.seen.refused,
sessions: rows,
counts: counts(t.seen),
},
)
})
.collect();
cards.sort_by_key(|(i, card)| {
let urgency = match (card.counts.needs_you, card.counts.waiting) {
(n, _) if n > 0 => 0,
(_, w) if w > 0 => 1,
_ => 2,
};
(urgency, *i)
});
let servers: Vec<Card<'_>> = cards.into_iter().map(|(_, c)| c).collect();
serde_json::json!({ "type": "state", "servers": servers })
}
/// The window title while a tab shows: the page's own title, else the tab's
/// name.
pub fn window_title(page_title: Option<&str>, name: &str) -> String {
match page_title.map(str::trim) {
Some(t) if !t.is_empty() => t.to_string(),
_ => name.to_string(),
}
}
/// Whether two server addresses are on the same host (scheme and port aside):
/// the case where, without a data store of their own, they would share a login
/// cookie.
pub fn same_host(a: &Url, b: &Url) -> bool {
match (a.host_str(), b.host_str()) {
(Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::SessionRow;
fn u(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn colours_are_hex_only() {
assert!(is_color("#a78bfa"));
assert!(is_color("#A78BFA"));
assert!(!is_color("a78bfa"));
assert!(!is_color("#a78bf"));
assert!(!is_color("#a78bfg"));
assert!(!is_color("red;background:url(x)"));
assert!(!is_color("#a78bfa;"));
}
#[test]
fn new_tabs_take_the_first_free_colour() {
assert_eq!(next_color([]), PALETTE[0]);
assert_eq!(next_color([PALETTE[0]]), PALETTE[1]);
assert_eq!(next_color([PALETTE[1], "#A78BFA"]), PALETTE[2]);
let all: Vec<&str> = PALETTE.to_vec();
assert_eq!(next_color(all.clone()), PALETTE[0]);
let mut seven = all;
seven.push(PALETTE[0]);
assert_eq!(next_color(seven), PALETTE[1]);
}
#[test]
fn default_names() {
assert_eq!(default_name(&u("http://127.0.0.1:3000/")), "This Mac");
assert_eq!(default_name(&u("http://localhost:3000/")), "This Mac");
assert_eq!(default_name(&u("http://127.0.0.1:5000/")), "This Mac:5000");
assert_eq!(default_name(&u("https://studio.example.ts.net/")), "studio");
assert_eq!(default_name(&u("http://build-01.lan:3000/")), "build-01");
assert_eq!(default_name(&u("http://192.168.1.40:3000/")), "192.168.1.40");
assert_eq!(
default_name(&u("http://192.168.1.40:8080/codeman/")),
"192.168.1.40:8080"
);
assert_eq!(default_name(&u("http://[fd00::1]:3000/")), "[fd00::1]");
}
#[test]
fn names_are_cleaned() {
assert_eq!(clean_name(" studio ").as_deref(), Some("studio"));
assert_eq!(clean_name("a\u{0}b\nc").as_deref(), Some("abc"));
assert_eq!(clean_name(" "), None);
assert_eq!(clean_name(""), None);
let long = "x".repeat(100);
assert_eq!(clean_name(&long).map(|n| n.chars().count()), Some(MAX_NAME));
// Characters, not bytes: a multi-byte name is not cut inside a character.
let wide = "ü".repeat(60);
assert_eq!(clean_name(&wide).map(|n| n.chars().count()), Some(MAX_NAME));
}
#[test]
fn renames() {
// Escape on a new tab sends back what it shows: nothing changes.
assert_eq!(renamed("New server", "New server", None), None);
assert_eq!(renamed(" studio ", "studio", Some("studio")), None);
assert_eq!(
renamed("prod box", "studio", Some("studio")),
Some(Some("prod box".into()))
);
// The default typed out, or nothing at all, is the default again.
assert_eq!(renamed("studio", "prod box", Some("studio")), Some(None));
assert_eq!(renamed(" ", "prod box", Some("studio")), Some(None));
// A shown name longer than a name may be, sent back unchanged.
let long = "x".repeat(60);
assert_eq!(renamed(&long, &long, None), None);
}
#[test]
fn store_ids_are_stable_and_distinct() {
let a = store_id("http://127.0.0.1:3000/");
assert_eq!(a, store_id("http://127.0.0.1:3000/"));
assert_ne!(a, store_id("http://127.0.0.1:5000/"));
assert_ne!(a, store_id("http://127.0.0.1:3000/x/"));
// Version 8, RFC 4122 variant.
assert_eq!(a[6] >> 4, 8);
assert_eq!(a[8] >> 6, 0b10);
// Pinned, so a change of the hash (which would cost every server its
// local storage) fails here first.
assert_eq!(
store_id(""),
[0xcb, 0xf2, 0x9c, 0xe4, 0x84, 0x22, 0x83, 0x25, 0x84, 0x22, 0x23, 0x25, 0xcb, 0xf2, 0xc2, 0x09]
);
}
#[test]
fn order_helpers() {
assert_eq!(moved(3, 0, 2), Some(2));
assert_eq!(moved(3, 1, 1), None);
assert_eq!(moved(3, 3, 0), None);
assert_eq!(moved(3, 0, 3), None);
assert_eq!(neighbour(3, 2, true), Some(0));
assert_eq!(neighbour(3, 0, false), Some(2));
assert_eq!(neighbour(0, 0, true), None);
assert_eq!(after_close(3, 0), Some(0));
assert_eq!(after_close(3, 2), Some(1));
assert_eq!(after_close(1, 0), None);
}
#[test]
fn command_numbers() {
assert_eq!(key_of(0, 3), Some(1));
assert_eq!(key_of(2, 3), Some(3));
assert_eq!(key_of(7, 12), Some(8));
assert_eq!(key_of(8, 12), None);
assert_eq!(key_of(11, 12), Some(9));
assert_eq!(key_of(8, 9), Some(9));
assert_eq!(index_of_key(1, 3), Some(0));
assert_eq!(index_of_key(3, 3), Some(2));
assert_eq!(index_of_key(4, 3), None);
assert_eq!(index_of_key(9, 3), Some(2));
assert_eq!(index_of_key(9, 0), None);
assert_eq!(index_of_key(0, 3), None);
}
#[test]
fn session_ids() {
assert!(is_session_id("0f8d0215-661e-4a1c-bfad-d7f6fd2ed4b5"));
assert!(!is_session_id(""));
assert!(!is_session_id("a'b"));
assert!(!is_session_id("a\"); alert(1); (\""));
assert!(!is_session_id(&"a".repeat(129)));
}
fn row(id: &str, state: SessionState) -> SessionRow {
SessionRow {
id: id.into(),
name: id.into(),
state,
text: String::new(),
}
}
fn seen(rows: Vec<SessionRow>) -> Seen {
Seen {
detail: Some(Detail { sessions: rows }),
offline: false,
refused: false,
}
}
#[test]
fn strip_state_marks_the_active_tab_and_counts() {
let a = u("http://127.0.0.1:3000/");
let b = u("https://studio.example.ts.net/");
let busy = seen(vec![
row("1", SessionState::Working),
row("2", SessionState::Needs),
row("3", SessionState::Needs),
row("4", SessionState::Waiting),
]);
let none = Seen {
offline: true,
..Seen::default()
};
let tabs = [
View {
id: 1,
name: "This Mac".into(),
color: PALETTE[0],
kind: Kind::Server,
url: Some(&a),
version: None,
seen: &busy,
},
View {
id: 2,
name: "studio".into(),
color: PALETTE[1],
kind: Kind::Server,
url: Some(&b),
version: None,
seen: &none,
},
];
let state = strip_state(&tabs, Some(1), false, true);
assert_eq!(state["type"], "state");
assert_eq!(state["fullscreen"], true);
assert_eq!(state["tabs"][0]["active"], false);
assert_eq!(state["tabs"][1]["active"], true);
assert_eq!(state["tabs"][0]["local"], true);
assert_eq!(state["tabs"][0]["status"]["needsYou"], 2);
assert_eq!(state["tabs"][0]["status"]["waiting"], 1);
assert_eq!(state["tabs"][0]["status"]["working"], 1);
assert_eq!(state["tabs"][1]["status"]["offline"], true);
assert_eq!(state["tabs"][1]["key"], 2);
// The overview takes the place of the active tab.
let state = strip_state(&tabs, Some(1), true, false);
assert_eq!(state["tabs"][1]["active"], false);
assert_eq!(state["overviewActive"], true);
}
#[test]
fn overview_puts_urgent_servers_and_sessions_first() {
let a = u("http://127.0.0.1:3000/");
let b = u("https://studio.example.ts.net/");
let calm = seen(vec![row("w", SessionState::Working), row("i", SessionState::Idle)]);
let urgent = seen(vec![
row("i1", SessionState::Idle),
row("i2", SessionState::Idle),
row("w1", SessionState::Working),
row("n1", SessionState::Needs),
row("y1", SessionState::Waiting),
row("i3", SessionState::Idle),
]);
let tabs = [
View {
id: 10,
name: "This Mac".into(),
color: PALETTE[0],
kind: Kind::Server,
url: Some(&a),
version: Some("1.51.0"),
seen: &calm,
},
View {
id: 11,
name: "studio".into(),
color: PALETTE[1],
kind: Kind::Server,
url: Some(&b),
version: None,
seen: &urgent,
},
];
let state = overview_state(&tabs);
let servers = state["servers"].as_array().unwrap();
assert_eq!(servers[0]["tab"], 11);
assert_eq!(servers[0]["key"], 2);
assert_eq!(servers[1]["tab"], 10);
let rows: Vec<&str> = servers[0]["sessions"]
.as_array()
.unwrap()
.iter()
.map(|r| r["id"].as_str().unwrap())
.collect();
assert_eq!(rows, ["n1", "y1", "w1", "i1"]);
assert_eq!(servers[0]["counts"]["sessions"], 6);
assert_eq!(servers[0]["sessions"][0]["state"], "needs");
assert_eq!(servers[1]["version"], "1.51.0");
}
#[test]
fn titles_and_hosts() {
assert_eq!(window_title(Some(" Codeman "), "atlas"), "Codeman");
assert_eq!(window_title(Some(""), "atlas"), "atlas");
assert_eq!(window_title(None, "atlas"), "atlas");
assert!(same_host(&u("http://127.0.0.1:3000/"), &u("http://127.0.0.1:5000/")));
assert!(same_host(&u("http://Studio.lan:3000/"), &u("https://studio.lan/")));
assert!(!same_host(&u("http://127.0.0.1:3000/"), &u("http://localhost:3000/")));
}
}