diff --git a/.changeset/desktop-server-tabs.md b/.changeset/desktop-server-tabs.md new file mode 100644 index 000000000..19738a913 --- /dev/null +++ b/.changeset/desktop-server-tabs.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +**Mac app: server tabs.** The Mac app now keeps several Codeman servers open in one window, one tab each in the title bar, and switches between them with a click or ⌘1 to ⌘8. Every tab shows whether its server needs you, is waiting, is working or is offline, and ⌘0 shows all servers at once with the ones that need you first. ⌘T opens another server, a tab can be renamed, coloured and dragged, ⇧⌘T reopens a closed one, and your tabs come back at the next launch. On macOS 14 and later every server keeps its own login and cookies; before macOS 14 a second tab on the same machine is refused. Behaviour change: only the server you used last keeps its per-device settings (font size, skin); every other server starts those fresh once. diff --git a/config/test-suites.ts b/config/test-suites.ts index 6e523cc32..86b980661 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -63,6 +63,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/terminal-touch-swipe-axis.browser.test.ts', 'test/phone-press-feedback.browser.test.ts', 'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception + 'test/desktop-tabs-pages.browser.test.ts', // no server: the pages are served by request interception 'test/local-model-ui.browser.test.ts', 'test/home-breakpoint-swap.browser.test.ts', 'test/log-viewer-reconnect.browser.test.ts', diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 53db63bf8..2e0499619 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -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", diff --git a/packages/desktop/README.md b/packages/desktop/README.md index ab780aa64..a2bb40c52 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -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://: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:` or `http://localhost:` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. | -| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. | +| **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 diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index e3cccbd8f..848c3b21e 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -1636,13 +1636,20 @@

Codeman

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 || ''); diff --git a/packages/desktop/assets/overview.html b/packages/desktop/assets/overview.html new file mode 100644 index 000000000..76004afa5 --- /dev/null +++ b/packages/desktop/assets/overview.html @@ -0,0 +1,373 @@ + + + + + +All servers + + + +
+
+

All servers

+ +
+
+
+ + + diff --git a/packages/desktop/assets/tabs.html b/packages/desktop/assets/tabs.html new file mode 100644 index 000000000..7fd200999 --- /dev/null +++ b/packages/desktop/assets/tabs.html @@ -0,0 +1,604 @@ + + + + + +Codeman tabs + + + +
+ +
+ +
+
+ + + diff --git a/packages/desktop/src/agents.rs b/packages/desktop/src/agents.rs index fb4da6d43..0e05d0e0c 100644 --- a/packages/desktop/src/agents.rs +++ b/packages/desktop/src/agents.rs @@ -57,6 +57,10 @@ pub enum Poll { #[derive(Deserialize)] struct Session { + #[serde(default)] + id: String, + #[serde(default)] + name: Option, #[serde(default)] status: String, #[serde(default)] @@ -80,11 +84,20 @@ fn count_working(body: &str) -> Option { } #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] struct Approval { #[serde(default)] kind: String, - #[serde(default, rename = "acknowledgedAt")] + #[serde(default)] acknowledged_at: Option, + #[serde(default)] + session_id: String, + #[serde(default)] + tool_name: Option, + #[serde(default)] + tool_summary: Option, + #[serde(default)] + message: Option, } #[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, +} + +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::>() + .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 { + let (Sessions::Envelope { data: sessions } | Sessions::Bare(sessions)) = + serde_json::from_str(sessions_body).ok()?; + let approvals: Vec = approvals_body + .and_then(|body| serde_json::from_str::(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("")).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}')); + } } diff --git a/packages/desktop/src/assets.rs b/packages/desktop/src/assets.rs index 50ab13ec6..75e281f16 100644 --- a/packages/desktop/src/assets.rs +++ b/packages/desktop/src/assets.rs @@ -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>) -> Response> { 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" }); + let script = native_message_script("__codemanTabs", &msg); + assert!(script.starts_with("window.__codemanTabs && window.__codemanTabs.onNative({")); + assert!(script.contains(r#""a\"b""#)); + 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}\\")); diff --git a/packages/desktop/src/config.rs b/packages/desktop/src/config.rs index a07214647..28749b406 100644 --- a/packages/desktop/src/config.rs +++ b/packages/desktop/src/config.rs @@ -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, + /// 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, + /// 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, +} + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +fn lenient_tabs<'de, D: serde::Deserializer<'de>>(d: D) -> Result, 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::(v).ok()) + .collect()) +} + +fn lenient_index<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + 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, 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, 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()); + } } diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index 4017ac45e..24ef7c01b 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -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, + servers: Vec, styles: BlinkStyles, } +/// One server the child asks. +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] +pub struct Target { + pub server: String, + pub authorization: Option, +} + +/// 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, 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) { + /// 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>, shared: &Mutex) { 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::(&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 = 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==")); + } } diff --git a/packages/desktop/src/macos.rs b/packages/desktop/src/macos.rs index 007f0d596..976218815 100644 --- a/packages/desktop/src/macos.rs +++ b/packages/desktop/src/macos.rs @@ -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 { @@ -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) { - imp::set_credentials(creds); +pub fn set_credentials(tab: u64, creds: Option) { + 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) { +pub fn set_auth_rejected_handler(handler: Box) { 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) { /// 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) { - imp::clear_cookies(done); +pub fn clear_cookies(store: Option<[u8; 16]>, host: String, done: Box) { + 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; + thread_local! { - /// The login challenges are answered with; `None` answers none. - static SAVED: RefCell> = const { RefCell::new(None) }; - /// The server refused `SAVED` and the app was told; `set_credentials` clears it. - static REJECTED: Cell = const { Cell::new(false) }; - /// Told once when the server refuses `SAVED`. - static ON_REJECTED: RefCell>> = const { RefCell::new(None) }; + /// The login of each tab that has one, by tab. + static SAVED: RefCell> = 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> = 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> = const { RefCell::new(Vec::new()) }; + /// Told once, with the tab, when a server refuses its login. + static ON_REJECTED: RefCell> = 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) { + pub(super) fn set_credentials(tab: u64, creds: Option) { 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) { + 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 { + 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) { if MainThreadMarker::new().is_none() { eprintln!("codeman-desktop: macos::set_auth_rejected_handler called off the main thread; ignored"); return; } - let handler: Rc = 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) { - if MainThreadMarker::new().is_none() { + pub(super) fn clear_cookies(store: Option<[u8; 16]>, host: String, done: Box) { + 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> = - unsafe { msg_send![WKWebsiteDataStore::class(), defaultDataStore] }; - let since: Option> = 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) { + // 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 = 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>| { + // SAFETY: WebKit hands a valid array for the length of this call. + let all = unsafe { all.as_ref() }; + let mine: Vec> = 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>, - report: bool, + /// The tab whose server refused its login, to tell the app. + report: Option, } 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) -> 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::>(); 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")); + } } diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index 7d5f32b55..61e394210 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -1,4 +1,4 @@ -//! Codeman Desktop: a native window around a Codeman server. +//! Codeman Desktop: a native window around Codeman servers, one tab each. //! //! Codeman is a web app, so the app is a thin shell: the OS web view (WKWebView //! on macOS) shows the server's own page, and the native side adds what a @@ -12,13 +12,25 @@ //! on a MacBook Pro with a lit Apple logo a blink that says what the agents //! are doing (`logo`, `agents`). //! +//! The main window holds a tab per server (`tabs`): a strip across the top that +//! draws them (`assets/tabs.html`), and below it each tab's own web views, of +//! which only the tab in front is visible. The others stay loaded, so a switch +//! is instant, and the strip's badges come from a status read the app makes of +//! each server itself (`agents::Poller::detail`), since WebKit stalls a hidden +//! page. An overview of every server (`assets/overview.html`) can take the +//! place of the tab in front (⌘0). +//! //! Every URL and message decision is in `policy` (pure, unit-tested); this file -//! only wires those decisions to windows. The main window moves through +//! only wires those decisions to windows. Each tab moves through //! `policy::Phase`: the connect page (Connect), a server load that has not -//! committed yet (Loading, abandoned after `LOAD_TIMEOUT` or on Cancel), and a -//! committed server page (Loaded). The phase is shared with the web view -//! handlers, which run on this same thread; no `RefCell` borrow of it is ever -//! held across a call into wry or tao. +//! committed yet (Loading, abandoned after `LOAD_TIMEOUT` or on Cancel; the +//! server's page loads in a second, hidden web view behind the connect page), +//! and a committed server page (Loaded). A tab's phase is shared with its web +//! views' handlers, which run on this same thread; no `RefCell` borrow of it +//! is ever held across a call into wry or tao. Each server's pages use a +//! WebKit data store of their own (`tabs::store_id`, macOS 14 and later), so +//! two servers never share a login cookie, and the challenge handler answers +//! each tab with that tab's login (`macos::set_credentials`). #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] @@ -40,6 +52,7 @@ mod menu; mod policy; mod probe; mod setup; +mod tabs; mod terminal_install; use std::cell::RefCell; @@ -47,7 +60,7 @@ use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::process::Command; use std::rc::Rc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -61,14 +74,17 @@ use tao::window::{Fullscreen, Theme, UserAttentionType, Window, WindowBuilder, W use url::Url; use wry::http::header::AUTHORIZATION; use wry::http::{HeaderMap, HeaderValue}; -use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebViewBuilder}; +use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, Rect as ViewRect, WebView, WebViewBuilder}; -use config::{Config, Rect, WindowFrame}; +use agents::{Detail, DetailPoll, Poller}; +use config::{Config, Rect, TabEntry, WindowFrame}; use credentials::Keychain; use login::{choose_login, update_keychain, KeychainUpdate, ProbedLogin}; use logo::{BlinkStyle, Signal}; -use menu::{build_menu, menu_id, AppMenu}; -use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via}; +use menu::{build_menu, menu_id, AppMenu, TabAction}; +use policy::{ + ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, OverviewCmd, Phase, Recovery, StripCmd, Via, +}; use probe::ProbeOutcome; const APP_NAME: &str = "Codeman"; @@ -77,6 +93,8 @@ const DEFAULT_SIZE: (f64, f64) = (1280.0, 820.0); const POPOUT_SIZE: (f64, f64) = (960.0, 680.0); const MIN_SIZE: (f64, f64) = (config::MIN_WIDTH, config::MIN_HEIGHT); const MAX_POPOUTS: usize = 24; +/// How many tabs the window holds at most (as many as are saved). +const MAX_TABS: usize = config::MAX_TABS; /// How far a pop-out opens down and to the right of the window in use, per /// pop-out already open (it starts over after eight). const POPOUT_STEP: f64 = 28.0; @@ -84,11 +102,11 @@ const SAVE_DEBOUNCE: Duration = Duration::from_secs(1); /// How long a server load may take to commit before the app gives up on it /// and brings the connect page back. const LOAD_TIMEOUT: Duration = Duration::from_secs(15); -/// A window reloads itself with the login at most once in this long after its +/// A view reloads itself with the login at most once in this long after its /// page reported that its session is gone, so a login the server keeps /// refusing can never turn into a reload loop. const SESSION_RELOAD_COOLDOWN: Duration = Duration::from_secs(60); -/// A window loads its page again at most once in this long after its web +/// A view loads its page again at most once in this long after its web /// content process ended, so a page that cannot stay up never becomes a reload /// loop. const CRASH_RELOAD_COOLDOWN: Duration = Duration::from_secs(30); @@ -100,6 +118,11 @@ const CRASH_RELOAD_COOLDOWN: Duration = Duration::from_secs(30); const FULLSCREEN_FIRST_CHECK: Duration = Duration::from_millis(700); const FULLSCREEN_RETRY_EVERY: Duration = Duration::from_millis(500); const FULLSCREEN_ATTEMPTS: u8 = 6; +/// How many status reads of a server in a row must fail before its tab says +/// it is not answering. +const OFFLINE_AFTER: u32 = 2; +/// How long a tab's status read waits after a 429 before it asks again. +const RATE_LIMITED_BACKOFF: Duration = Duration::from_secs(60); const SETTINGS_UNREADABLE: &str = "Settings could not be read, so changes will not be saved this time."; const LOAD_TIMED_OUT: &str = "Codeman did not open. Check that the server is still running, then connect again."; @@ -118,15 +141,28 @@ const LOGO_DRIVEN_ELSEWHERE: &str = "An older LogoLight is driving the Apple log /// What a download's toast calls a file whose name is not known. const UNNAMED_FILE: &str = "the file"; const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address."; +const ALREADY_OPEN: &str = "That server is already open in another tab."; +/// Before macOS 14 every page shares one data store, and Codeman's login +/// cookie has the same name on every port of a host. +const SAME_HOST_NEEDS_MACOS_14: &str = "Another tab is open on a Codeman of the same machine. On this version of macOS \ +the two would share one login cookie and log each other out, so close that tab first (macOS 14 and later keep them apart)."; +const TOO_MANY_TABS: &str = "The window holds 24 tabs at most. Close one to open another."; +/// The name of a tab that has no server yet. +const NEW_TAB_NAME: &str = "New server"; +const OVERVIEW_TITLE: &str = "All Servers"; enum UserEvent { Menu(MenuEvent), + /// A message from a tab's connect page, its server page or a pop-out. Ipc { - window: WindowId, + view: ViewId, msg: Ipc, }, + Strip(StripCmd), + Overview(OverviewCmd), ProbeDone(Box), ServerStartDone { + tab: TabId, generation: u64, url: Url, result: Result, @@ -136,57 +172,73 @@ enum UserEvent { /// Any frame can ask like this, so the window opens without taking the /// keyboard, only while the script window budget allows, and nothing is /// reported back when it does not open. - OpenAppWindow(Url), + OpenAppWindow { + tab: TabId, + url: Url, + }, /// A page opened another site (window.open); it goes to the browser if the /// open budget allows. OpenExternal(Url), - /// The cookies were cleared before a server load (begin_loading). + /// A server's cookies were cleared before its load (begin_loading). CookiesCleared { + tab: TabId, generation: u64, url: Url, }, /// What the server said to the login the challenge handler had sent when /// it reported a refusal (recheck_login). LoginChecked { + tab: TabId, generation: u64, outcome: ProbeOutcome, }, Title { - window: WindowId, + view: ViewId, title: String, }, /// A main-frame navigation committed (wry's PageLoadEvent::Started, from - /// WebKit's didCommitNavigation): `url` is the window's document now. + /// WebKit's didCommitNavigation): `url` is the view's document now. PageCommitted { - window: WindowId, + view: ViewId, url: String, }, DownloadStarted { - window: WindowId, + view: ViewId, url: String, /// The file name wry chose in ~/Downloads (de-duplicated). wry passes /// no path when the download ends, so this is the toast's only source. file_name: Option, }, DownloadDone { - window: WindowId, + view: ViewId, url: String, path: Option, ok: bool, }, - /// The challenge handler sent its login and got challenged again (macOS). - /// Not trusted on its own: recheck_login asks the server first. - AuthRejected, - /// The web content process of a window ended (macOS): a crash, or the - /// system taking it back under memory pressure. The window is blank until + /// The challenge handler sent a tab's login and got challenged again + /// (macOS). Not trusted on its own: recheck_login asks the server first. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + AuthRejected(TabId), + /// The web content process of a view ended (macOS): a crash, or the + /// system taking it back under memory pressure. The view is blank until /// something loads in it again (on_content_process_gone). ContentProcessGone { - window: WindowId, + view: ViewId, + }, + /// A background step for a tab's connect page finished (This Mac status, + /// an install opened in Terminal, the install command copied): the message + /// goes to that page if it is still the one choosing a server. + ToConnectPage { + tab: TabId, + msg: serde_json::Value, + }, + /// A status read of a tab's server (watch_status), for the login numbered + /// `generation`. + TabStatus { + tab: TabId, + generation: u64, + status: StatusUpdate, }, - /// A background step for the connect page finished (This Mac status, an - /// install opened in Terminal, the install command copied): the message - /// goes to the page if it is still the one choosing a server. - ToConnectPage(serde_json::Value), /// The keep-awake worker's status line, for the value numbered `generation` /// (keep_awake::KeepAwake::is_current). KeepAwakeStatus { @@ -200,7 +252,17 @@ enum UserEvent { LogoLidStatus(String), } +/// What a status read of a server found. +enum StatusUpdate { + Seen(Detail), + /// Several reads in a row failed. + Offline, + /// The server refused the login; nothing is asked any more. + Refused, +} + struct ProbeDone { + tab: TabId, generation: u64, url: Url, result: ProbeResult, @@ -224,75 +286,82 @@ enum ProbeResult { SavedPasswordUnreadable, } -enum Kind { - Main, - Popout { - /// The URL the pop-out was opened with, as handed to wry - /// (`policy::webkit_safe_url`), which is also the form WebKit reports - /// for a download. - opened_url: String, - hidden_for_download: bool, - }, +type TabId = u64; + +/// The app's own number for each web view it builds. A tab's views share the +/// main window, so the window's id does not tell them apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct ViewId(u64); + +/// What a web view is for: it decides its handlers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Part { + Strip, + Overview, + Connect, + Server, + Popout, } -struct AppWindow { - // Declared first so it is dropped before its window. +/// Who owns a web view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Owner { + Strip, + Overview, + Connect(TabId), + Server(TabId), + Popout(WindowId, TabId), +} + +/// A web view inside the main window. +struct ChildView { + id: ViewId, webview: WebView, - window: Window, - kind: Kind, - /// The window's last committed URL. Never read from wry's `url()`: that + /// The view's last committed URL. Never read from wry's `url()`: that /// unwraps WKWebView.URL, which is nil until a load commits, and the app /// would abort. committed: Option, - /// When the window last reloaded itself because its session expired. + /// When the view last reloaded itself because its session expired. session_reload_at: Option, - /// When the window's web content process last ended. - content_process_gone_at: Option, + /// When the view's web content process last ended. + gone_at: Option, } -impl AppWindow { - fn new(webview: WebView, window: Window, kind: Kind) -> Self { - AppWindow { +impl ChildView { + fn new(id: ViewId, webview: WebView) -> Self { + ChildView { + id, webview, - window, - kind, committed: None, session_reload_at: None, - content_process_gone_at: None, + gone_at: None, } } - - /// A pop-out hidden because it only fetched a file (DownloadStarted). - fn hidden_for_download(&self) -> bool { - matches!( - self.kind, - Kind::Popout { - hidden_for_download: true, - .. - } - ) - } } -enum Load<'a> { - ConnectPage, - Server { url: &'a str, auth: Option<&'a str> }, -} - -struct App { - proxy: EventLoopProxy, - config: Config, - /// None: no config directory, or the file could not be read or moved - /// aside, in which case it must not be overwritten. - config_path: Option, - config_dirty: bool, - /// The settings are written SAVE_DEBOUNCE after this: the last write, or - /// the last move or resize of the main window, so a drag writes the file - /// once when it ends, not once a second while it goes on. - save_held_since: Instant, - /// Where the main window is in reaching a server. Shared with the web view - /// handlers, which run on this same thread. +/// One server tab. +struct Tab { + id: TabId, + /// The connect page: shows in Connect and Loading, dropped once the + /// server's page commits. + connect: Option, + /// The server's page: loads hidden behind the connect page (Loading), then + /// is the tab (Loaded). + server: Option, + /// Shared with the web view handlers of this tab's views and pop-outs. phase: Rc>, + /// The server this tab is for: saved for the next launch, kept while the + /// connect page shows again after a refused login. + home: Option, + /// The name the user gave the tab, else a default (tabs::default_name). + name: Option, + color: String, + /// The server's Codeman version, from the probe. + version: Option, + /// The page's own title, for the window title. + page_title: Option, + /// The data store of the server view and its pop-outs. + store: Option<[u8; 16]>, /// While Loading: when to give up on the load. loading_deadline: Option, /// Basic-auth header for (re)loading server pages; the server answers a @@ -301,43 +370,265 @@ struct App { /// The username and password behind `auth`, which the challenge handler /// answers with (set_login). login: Option<(String, String)>, - /// Counts the logins handed to the challenge handler, so a recheck that - /// comes back after Switch Server or another connect does nothing. + /// Counts the logins handed to the challenge handler, so a recheck or a + /// status read that comes back after another connect does nothing. login_generation: u64, /// The login generation a recheck is running for, if any. login_recheck: Option, - windows: HashMap, - main_id: WindowId, probe_generation: u64, start_generation: u64, /// The generation of the Start Codeman run still going, if any. starting_server: Option, /// Shown by the next connect page that asks for its init. notice: Option, + /// The connect page opens the agent chooser when it next loads. + add_agent_on_load: bool, + /// What the connect page does when it asks for its init: show this server, + /// and connect to it when the flag says so (a tab opened again at launch). + connect_to: Option<(String, bool)>, + /// The last status reads of the server. + seen: tabs::Seen, + /// The thread that reads the server's status (watch_status). + watch: Option, + /// Whether the login of this connect can read the server's status. Not + /// for a one-time password that Codeman's own page has the user change + /// first: the server answers every other request 403 until then, and the + /// new password is never seen by the app. + status_login: bool, +} + +impl Tab { + fn kind(&self) -> tabs::Kind { + match &*self.phase.borrow() { + Phase::Connect => tabs::Kind::Connect, + Phase::Loading(_) => tabs::Kind::Loading, + Phase::Loaded(_) => tabs::Kind::Server, + } + } + + /// The server the tab is about: loaded or loading, else its home. + fn shown_url(&self) -> Option { + self.phase.borrow().server().cloned().or_else(|| self.home.clone()) + } + + fn display_name(&self) -> String { + match (&self.name, self.shown_url()) { + (Some(name), _) => name.clone(), + (None, Some(url)) => tabs::default_name(&url), + (None, None) => NEW_TAB_NAME.to_string(), + } + } + + /// The view that shows when this tab is in front. + fn front(&self) -> Option<&ChildView> { + match (self.phase.borrow().loaded(), &self.server, &self.connect) { + (Some(_), Some(server), _) => Some(server), + (_, _, Some(connect)) => Some(connect), + (_, server, None) => server.as_ref(), + } + } + + fn views(&self) -> impl Iterator { + self.connect.iter().chain(self.server.iter()) + } + + fn entry(&self) -> Option { + Some(TabEntry { + url: self.home.as_ref()?.to_string(), + name: self.name.clone(), + color: Some(self.color.clone()), + }) + } +} + +/// Stops its status thread when dropped. +struct StatusWatch { + stop: Arc, +} + +impl Drop for StatusWatch { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + } +} + +/// Reads a server's agent sessions every `agents::POLL_EVERY` on a thread of +/// its own, for a tab's badges and the overview. Ends when the watch is +/// dropped or the server refuses the login. +fn watch_status( + proxy: &EventLoopProxy, + tab: TabId, + generation: u64, + server: &Url, + authorization: Option, +) -> Option { + let mut poller = Poller::new(server, authorization, true)?; + let stop = Arc::new(AtomicBool::new(false)); + let stopped = stop.clone(); + let proxy = proxy.clone(); + std::thread::Builder::new() + .name("tab-status".into()) + .spawn(move || { + let mut failures = 0u32; + while !stopped.load(Ordering::SeqCst) { + let status = match poller.detail() { + DetailPoll::Seen(detail) => { + failures = 0; + Some(StatusUpdate::Seen(detail)) + } + DetailPoll::Failed => { + failures += 1; + (failures == OFFLINE_AFTER).then_some(StatusUpdate::Offline) + } + DetailPoll::Refused => Some(StatusUpdate::Refused), + // Too many failed logins from this address (other clients' + // too, on a multi-user server): ask again much later, and + // keep what the tab shows meanwhile. + DetailPoll::RateLimited => { + sleep_unless_stopped(&stopped, RATE_LIMITED_BACKOFF); + None + } + }; + let refused = matches!(status, Some(StatusUpdate::Refused)); + if let Some(status) = status { + if stopped.load(Ordering::SeqCst) + || proxy + .send_event(UserEvent::TabStatus { + tab, + generation, + status, + }) + .is_err() + { + return; + } + } + if refused { + return; + } + sleep_unless_stopped(&stopped, agents::POLL_EVERY); + } + }) + .ok()?; + Some(StatusWatch { stop }) +} + +/// Sleeps `total`, a second at a time, and stops early once `stopped` is set. +fn sleep_unless_stopped(stopped: &AtomicBool, total: Duration) { + let step = Duration::from_secs(1); + let mut left = total; + while !left.is_zero() && !stopped.load(Ordering::SeqCst) { + let nap = left.min(step); + std::thread::sleep(nap); + left -= nap; + } +} + +/// A pop-out: an app window of its own, for one of the tab's server pages. +struct Popout { + // Declared first so it is dropped before its window. + webview: WebView, + window: Window, + view: ViewId, + tab: TabId, + /// The URL the pop-out was opened with, as handed to wry + /// (`policy::webkit_safe_url`), which is also the form WebKit reports for + /// a download. + opened_url: String, + hidden_for_download: bool, + committed: Option, + session_reload_at: Option, + gone_at: Option, +} + +enum Load<'a> { + /// The connect page; `manual` keeps it from connecting by itself. + ConnectPage { + manual: bool, + }, + /// One of the app's other pages (the strip, the overview). + Bundled(&'static str), + Server { + url: &'a str, + auth: Option<&'a str>, + }, + /// Nothing yet: a server view waits for its cookies to be cleared. + Nothing, +} + +/// Where a web view goes and what it is. +struct ViewSpec<'a> { + id: ViewId, + part: Part, + tab: TabId, + /// The tab's phase (connect, server and pop-out views). + phase: Option>>, + load: Load<'a>, + /// The data store: None for WebKit's default one. + store: Option<[u8; 16]>, + /// For a view inside the main window: its frame, in points. None fills a + /// window of its own (a pop-out). + bounds: Option<(f64, f64, f64, f64)>, + visible: bool, + /// The view takes the keyboard and brings the app to the front (wry's + /// default). False for a window a script opened, and every hidden view. + focused: bool, +} + +struct App { + proxy: EventLoopProxy, + config: Config, + /// None: no config directory, or the file could not be read or moved + /// aside, in which case it must not be overwritten. + config_path: Option, + config_dirty: bool, + /// The settings are written SAVE_DEBOUNCE after this: the last write, or + /// the last move or resize of the main window, so a drag writes the file + /// once when it ends, not once a second while it goes on. + save_held_since: Instant, + main_window: Window, + strip: ChildView, + /// Built the first time it is asked for. + overview: Option, + overview_shown: bool, + tabs: Vec, + /// The tab in front, an index into `tabs`. + active: usize, + next_tab: TabId, + next_view: u64, + /// Tabs closed this run, newest last, for Reopen Closed Tab. + closed: Vec, + popouts: HashMap, + /// The strip, the overview and the Server menu need drawing again; done + /// once per turn of the event loop. + strip_dirty: bool, + overview_dirty: bool, + menu_dirty: bool, /// What hands pages to the default browser draws on. open_budget: OpenBudget, /// What the app windows a script opens draw on (UserEvent::OpenAppWindow), /// apart from `open_budget`, so a frame cannot use up the user's links. script_window_budget: OpenBudget, - /// File names of downloads under way, per window and URL, oldest first. - downloads: HashMap<(WindowId, String), VecDeque>, + /// File names of downloads under way, per view and URL, oldest first. + downloads: HashMap<(ViewId, String), VecDeque>, menu: AppMenu, /// The one Keychain worker; every Keychain job goes through its queue. keychain: Keychain, /// Which reader of the install's output is the current one. install_reader: Arc, - /// The connect page opens the agent chooser when it next loads: the menu - /// asked for it from a Codeman page. - add_agent_on_load: bool, /// While the main window may still have to be put into the full screen it /// asked for at launch: when to look next, and how many retries were made. fullscreen_retry: Option<(Instant, u8)>, - /// The child that watches the connected server and blinks the logo. + /// What the strip was last told about full screen. + fullscreen: bool, + /// The child that watches the open servers and blinks the logo. logo_watch: logo::Watcher, /// The worker that keeps this Mac awake while its agents work. keep_awake: keep_awake::KeepAwake, /// The administrator prompt for removing the old lid helper is open. old_helper_busy: bool, + /// Shown by the first connect page that asks for its init. + launch_notice: Option, } fn main() { @@ -366,71 +657,83 @@ fn main() { { // Runs inside a WebKit callback: only post an event. let auth_proxy = proxy.clone(); - macos::set_auth_rejected_handler(Box::new(move || { - let _ = auth_proxy.send_event(UserEvent::AuthRejected); + macos::set_auth_rejected_handler(Box::new(move |tab| { + let _ = auth_proxy.send_event(UserEvent::AuthRejected(tab)); })); } let config_path = Config::default_path(); - let (config, can_save) = match config_path.as_deref() { + let (mut config, can_save) = match config_path.as_deref() { Some(path) => Config::load_from(path), None => (Config::default(), true), }; let config_path = config_path.filter(|_| can_save); - let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string()); + let launch_notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string()); + // Before any tab opens: which server keeps the store it had before tabs. + let store_settled = config.settle_default_store(); logo::recover_at_launch(); // A lid keeper that was killed left lid sleep off; and 1.50.1's helper, where // it is still installed, gets nothing to act on. lid_awake::heal(); keep_awake::forget_legacy_request(); let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}"))); - let phase = Rc::new(RefCell::new(Phase::Connect)); // Started once: the probe workers and the event loop share this queue, // and a second one would not keep its jobs in order with the first. let keychain = Keychain::spawn(); let (window, wants_fullscreen) = create_main_window(&event_loop, &config).unwrap_or_else(|e| fatal(&format!("Could not open a window: {e}"))); - let webview = build_webview(&window, true, true, Load::ConnectPage, &proxy, &phase) + let (width, _) = content_size(&window); + let strip_spec = ViewSpec { + id: ViewId(0), + part: Part::Strip, + tab: 0, + phase: None, + load: Load::Bundled(policy::STRIP_PAGE), + store: None, + bounds: Some((0.0, 0.0, width, tabs::STRIP_HEIGHT)), + visible: true, + focused: false, + }; + let strip = build_webview(&window, strip_spec, &proxy) .unwrap_or_else(|e| fatal(&format!("Could not create the web view: {e}"))); - let main_id = window.id(); - - let mut windows = HashMap::new(); - windows.insert(main_id, AppWindow::new(webview, window, Kind::Main)); let keep_awake_proxy = proxy.clone(); let mut app = App { proxy, config, config_path, - config_dirty: false, + config_dirty: store_settled, save_held_since: Instant::now(), - phase, - loading_deadline: None, - auth: None, - login: None, - login_generation: 0, - login_recheck: None, - windows, - main_id, - probe_generation: 0, - start_generation: 0, - starting_server: None, - notice, + main_window: window, + strip: ChildView::new(ViewId(0), strip), + overview: None, + overview_shown: false, + tabs: Vec::new(), + active: 0, + next_tab: 1, + next_view: 1, + closed: Vec::new(), + popouts: HashMap::new(), + strip_dirty: true, + overview_dirty: false, + menu_dirty: true, open_budget: OpenBudget::new(), script_window_budget: OpenBudget::new(), downloads: HashMap::new(), menu, keychain, install_reader: Arc::new(AtomicU64::new(0)), - add_agent_on_load: false, fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)), + fullscreen: wants_fullscreen, logo_watch: logo::Watcher::default(), keep_awake: keep_awake::KeepAwake::new(move |generation, text| { let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text }); }), old_helper_busy: false, + launch_notice, }; + app.open_launch_tabs(); if app.menu.logo_lid.is_some() { let lid_proxy = app.proxy.clone(); @@ -466,10 +769,18 @@ fn monitors(target: &EventLoopWindowTarget) -> Vec { /// What every app window starts from. fn window_builder() -> WindowBuilder { - WindowBuilder::new() + let builder = WindowBuilder::new() .with_title(APP_NAME) .with_theme(Some(Theme::Dark)) - .with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1)) + .with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1)); + // The app has tabs of its own: macOS's automatic window tabbing would + // merge pop-outs into a tab bar of the system's (in full screen above all). + #[cfg(target_os = "macos")] + let builder = { + use tao::platform::macos::WindowBuilderExtMacOS; + builder.with_automatic_window_tabbing(false) + }; + builder } /// The corner of `window` that tao's `with_position` places: on macOS the @@ -483,13 +794,35 @@ fn placed_position(window: &Window) -> Result, NotSupporte } } +/// The main window's content area in points. On macOS the content runs under +/// the transparent title bar, so this is the whole window. +fn content_size(window: &Window) -> (f64, f64) { + #[cfg(target_os = "macos")] + if let Some(size) = macos::content_size(window) { + return size; + } + let size = window.inner_size().to_logical::(window.scale_factor()); + (size.width, size.height) +} + /// The main window, and whether it was asked into full screen (App's -/// `fullscreen_retry` then makes sure it gets there). +/// `fullscreen_retry` then makes sure it gets there). On macOS its content runs +/// under a transparent title bar: the tab strip is the title bar, with the +/// traffic lights moved down to sit centred in it. fn create_main_window( target: &EventLoopWindowTarget, config: &Config, ) -> Result<(Window, bool), tao::error::OsError> { let mut builder = window_builder(); + #[cfg(target_os = "macos")] + { + use tao::platform::macos::WindowBuilderExtMacOS; + builder = builder + .with_titlebar_transparent(true) + .with_fullsize_content_view(true) + .with_title_hidden(true) + .with_traffic_light_inset(LogicalPosition::new(tabs::TRAFFIC_LIGHTS.0, tabs::TRAFFIC_LIGHTS.1)); + } let screens = monitors(target); match config.window.and_then(|f| f.fitted_to(&screens, MIN_SIZE)) { Some(f) => { @@ -568,52 +901,79 @@ fn open_external(url: &Url) { } } -/// Give the native challenge handler the login for the server at `url` (its -/// base path included: the handler answers only below it), or take it away -/// (None). It answers HTTP Basic challenges from that server only, so the page -/// recovers by itself when the server restarted and forgot its session. Also -/// re-arms the handler's refusal report. -fn set_challenge_login(login: Option<(&Url, &str, &str)>) { +/// Give the native challenge handler tab `tab`'s login for the server at `url` +/// (its base path included: the handler answers only below it), or take it +/// away (None). It answers HTTP Basic challenges from that server only, so the +/// page recovers by itself when the server restarted and forgot its session. +/// Also re-arms the tab's refusal report. +fn set_challenge_login(tab: TabId, login: Option<(&Url, &str, &str)>) { #[cfg(target_os = "macos")] - macos::set_credentials(login.map(|(url, username, password)| macos::BasicCredentials { - server: url.clone(), - username: username.to_string(), - password: password.to_string(), - })); + macos::set_credentials( + tab, + login.map(|(url, username, password)| macos::BasicCredentials { + server: url.clone(), + username: username.to_string(), + password: password.to_string(), + }), + ); #[cfg(not(target_os = "macos"))] - let _ = login; + let _ = (tab, login); } -/// Empty the web views' cookie jar, then post CookiesCleared for this load. -/// On macOS the clear is asynchronous and `done` runs later on the main -/// thread; elsewhere the event is posted at once. -fn clear_cookies_then_load(proxy: &EventLoopProxy, generation: u64, url: Url) { +/// Remove the server's cookies from the store its pages use, then post +/// CookiesCleared for this load. On macOS the clear is asynchronous and `done` +/// runs later on the main thread; elsewhere the event is posted at once. +fn clear_cookies_then_load( + proxy: &EventLoopProxy, + tab: TabId, + generation: u64, + url: Url, + store: Option<[u8; 16]>, +) { + let host = url.host_str().unwrap_or_default().to_string(); let proxy = proxy.clone(); let done = move || { - let _ = proxy.send_event(UserEvent::CookiesCleared { generation, url }); + let _ = proxy.send_event(UserEvent::CookiesCleared { tab, generation, url }); }; #[cfg(target_os = "macos")] - macos::clear_cookies(Box::new(done)); + macos::clear_cookies(store, host, Box::new(done)); #[cfg(not(target_os = "macos"))] - done(); + { + let _ = (store, host); + done(); + } } -/// `focused`: the web view takes the keyboard and brings the app to the front -/// (wry's default). False for a window a script opened: it comes up without -/// pulling the user out of the app they are typing in. -fn build_webview( - window: &Window, - is_main: bool, - focused: bool, - load: Load<'_>, - proxy: &EventLoopProxy, - phase: &Rc>, -) -> wry::Result { - let id = window.id(); +/// Whether each server can have a data store of its own here (macOS 14 and +/// later). Without it every page shares WebKit's default store. +fn own_stores() -> bool { + #[cfg(target_os = "macos")] + return macos::own_stores_available(); + #[cfg(not(target_os = "macos"))] + return false; +} + +/// Build a web view with every handler its part needs (`spec.part`): the +/// strip and the overview listen only to their own page and go nowhere else; +/// a tab's connect page and server page, and a pop-out, go through `policy` +/// with the tab's phase. +fn build_webview(window: &Window, spec: ViewSpec<'_>, proxy: &EventLoopProxy) -> wry::Result { + let ViewSpec { + id, + part, + tab, + phase, + load, + store, + bounds, + visible, + focused, + } = spec; + let phase = phase.unwrap_or_else(|| Rc::new(RefCell::new(Phase::Connect))); let mut builder = WebViewBuilder::new() - .with_initialization_script(assets::HOST_SHIM) .with_devtools(true) .with_focused(focused) + .with_visible(visible) .with_accept_first_mouse(true) .with_back_forward_navigation_gestures(false) // false = let WebKit take the drop, so Codeman's own HTML5 drop @@ -622,8 +982,25 @@ fn build_webview( // Ask the user per page instead of wry's default of granting the // microphone and camera to every frame, sandboxed previews included. .with_permission_handler(|_| PermissionResponse::Default); - - if is_main { + if matches!(part, Part::Server | Part::Popout) { + builder = builder.with_initialization_script(assets::HOST_SHIM); + } + if let Some((x, y, w, h)) = bounds { + builder = builder.with_bounds(ViewRect { + position: LogicalPosition::new(x, y).into(), + size: LogicalSize::new(w, h).into(), + }); + } + #[cfg(target_os = "macos")] + if let Some(store) = store { + use wry::WebViewBuilderExtDarwin; + builder = builder.with_data_store_identifier(store); + } + #[cfg(not(target_os = "macos"))] + let _ = store; + // The app's own pages come from its own scheme; a server's pages and + // pop-outs never load from it. + if matches!(part, Part::Strip | Part::Overview | Part::Connect) { builder = builder.with_custom_protocol(policy::APP_SCHEME.into(), |_id, request| assets::respond(request)); } @@ -631,10 +1008,18 @@ fn build_webview( // app never holds a borrow across a call into wry, so it cannot fail, but // a panic here would abort the app. let nav_phase = phase.clone(); - builder = builder.with_navigation_handler(move |target| { - nav_phase - .try_borrow() - .is_ok_and(|phase| policy::navigation(&target, &phase) == Navigation::Allow) + builder = builder.with_navigation_handler(move |target| match part { + Part::Strip => policy::bundled_navigation(&target, policy::STRIP_PAGE) == Navigation::Allow, + Part::Overview => policy::bundled_navigation(&target, policy::OVERVIEW_PAGE) == Navigation::Allow, + // The connect page goes nowhere: its links leave through IPC. + Part::Connect => nav_phase.try_borrow().is_ok_and(|phase| { + phase.connect_page_live() + && Url::parse(&target).is_ok_and(|u| u.scheme() == policy::APP_SCHEME) + && policy::navigation(&target, &phase) == Navigation::Allow + }), + Part::Server | Part::Popout => nav_phase.try_borrow().is_ok_and(|phase| { + !target.starts_with(policy::APP_SCHEME) && policy::navigation(&target, &phase) == Navigation::Allow + }), }); // Every answer to WebKit is Deny: the window WebKit would build carries @@ -646,17 +1031,19 @@ fn build_webview( let nw_phase = phase.clone(); let nw_proxy = proxy.clone(); builder = builder.with_new_window_req_handler(move |target, _features| { - let decision = match nw_phase.try_borrow() { - Ok(phase) => policy::new_window(&target, &phase, Via::Script), - Err(_) => NewWindow::Deny, - }; - let event = match decision { - NewWindow::AppWindow(url) => Some(UserEvent::OpenAppWindow(url)), - NewWindow::External(url) => Some(UserEvent::OpenExternal(url)), - NewWindow::Deny => None, - }; - if let Some(event) = event { - let _ = nw_proxy.send_event(event); + if matches!(part, Part::Server | Part::Popout) { + let decision = match nw_phase.try_borrow() { + Ok(phase) => policy::new_window(&target, &phase, Via::Script), + Err(_) => NewWindow::Deny, + }; + let event = match decision { + NewWindow::AppWindow(url) => Some(UserEvent::OpenAppWindow { tab, url }), + NewWindow::External(url) => Some(UserEvent::OpenExternal(url)), + NewWindow::Deny => None, + }; + if let Some(event) = event { + let _ = nw_proxy.send_event(event); + } } NewWindowResponse::Deny }); @@ -665,84 +1052,123 @@ fn build_webview( let ipc_proxy = proxy.clone(); builder = builder.with_ipc_handler(move |request| { let frame_url = request.uri().to_string(); - let msg = match ipc_phase.try_borrow() { - Ok(phase) => policy::parse_ipc(&frame_url, request.body(), &phase, is_main), - Err(_) => None, + let event = match part { + Part::Strip => policy::parse_strip_ipc(&frame_url, request.body()).map(UserEvent::Strip), + Part::Overview => policy::parse_overview_ipc(&frame_url, request.body()).map(UserEvent::Overview), + Part::Connect | Part::Server | Part::Popout => { + let is_tab_view = part != Part::Popout; + let msg = match ipc_phase.try_borrow() { + Ok(phase) => policy::parse_ipc(&frame_url, request.body(), &phase, is_tab_view), + Err(_) => None, + }; + // A server page speaks only as a server page, the connect page + // only as the connect page. + msg.filter(|msg| match msg { + Ipc::Connect(_) => part == Part::Connect, + Ipc::Host(_) => part != Part::Connect, + }) + .map(|msg| UserEvent::Ipc { view: id, msg }) + } }; - if let Some(msg) = msg { - let _ = ipc_proxy.send_event(UserEvent::Ipc { window: id, msg }); + if let Some(event) = event { + let _ = ipc_proxy.send_event(event); } }); let load_phase = phase.clone(); let load_proxy = proxy.clone(); builder = builder.with_on_page_load_handler(move |event, url| { - if !matches!(event, PageLoadEvent::Started) { + if !matches!(event, PageLoadEvent::Started) || matches!(part, Part::Strip | Part::Overview) { return; } - // Started is didCommitNavigation. The main window's phase moves to + // Started is didCommitNavigation. A server view's phase moves to // Loaded right here, so the gates change in the same instant the // server page becomes the document. App repeats the step when the // event arrives, in case the borrow was not free. - if is_main { + if part == Part::Server { if let Ok(mut phase) = load_phase.try_borrow_mut() { if let Some(next) = policy::loaded_on_commit(&phase, &url) { *phase = next; } } } - let _ = load_proxy.send_event(UserEvent::PageCommitted { window: id, url }); + let _ = load_proxy.send_event(UserEvent::PageCommitted { view: id, url }); }); - let title_proxy = proxy.clone(); - builder = builder.with_document_title_changed_handler(move |title| { - let _ = title_proxy.send_event(UserEvent::Title { window: id, title }); - }); + if matches!(part, Part::Server | Part::Popout) { + let title_proxy = proxy.clone(); + builder = builder.with_document_title_changed_handler(move |title| { + let _ = title_proxy.send_event(UserEvent::Title { view: id, title }); + }); - let dl_proxy = proxy.clone(); - builder = builder.with_download_started_handler(move |url, destination| { - // wry already picked ~/Downloads/, de-duplicated; it - // stays as it is. Only its name is passed on, for the toast. - let file_name = file_name_of(destination); - let _ = dl_proxy.send_event(UserEvent::DownloadStarted { - window: id, - url, - file_name, + let dl_proxy = proxy.clone(); + builder = builder.with_download_started_handler(move |url, destination| { + // wry already picked ~/Downloads/, de-duplicated; it + // stays as it is. Only its name is passed on, for the toast. + let file_name = file_name_of(destination); + let _ = dl_proxy.send_event(UserEvent::DownloadStarted { + view: id, + url, + file_name, + }); + true }); - true - }); - let done_proxy = proxy.clone(); - builder = builder.with_download_completed_handler(move |url, path, ok| { - let _ = done_proxy.send_event(UserEvent::DownloadDone { - window: id, - url, - path, - ok, + let done_proxy = proxy.clone(); + builder = builder.with_download_completed_handler(move |url, path, ok| { + let _ = done_proxy.send_event(UserEvent::DownloadDone { + view: id, + url, + path, + ok, + }); }); - }); + } else { + // The app's own pages download nothing. + builder = builder.with_download_started_handler(|_, _| false); + } // wry answers WebKit's "web content process ended" notice itself, which - // turns WebKit's own reload off: without a handler the window stays blank. + // turns WebKit's own reload off: without a handler the view stays blank. #[cfg(target_os = "macos")] { use wry::WebViewBuilderExtDarwin; let gone_proxy = proxy.clone(); builder = builder.with_on_web_content_process_terminate_handler(move || { - let _ = gone_proxy.send_event(UserEvent::ContentProcessGone { window: id }); + let _ = gone_proxy.send_event(UserEvent::ContentProcessGone { view: id }); }); } builder = match load { - Load::ConnectPage => builder.with_url(policy::CONNECT_PAGE), + Load::ConnectPage { manual } => builder.with_url(connect_page_url(manual)), + Load::Bundled(page) => builder.with_url(page), Load::Server { url, auth: Some(auth) } => builder.with_url_and_headers(url, auth_headers(auth)), Load::Server { url, auth: None } => builder.with_url(url), + Load::Nothing => builder, + }; + let webview = match bounds { + Some(_) => builder.build_as_child(window)?, + None => builder.build(window)?, }; - let webview = builder.build(window)?; #[cfg(target_os = "macos")] - macos::install(&webview); + { + macos::install(&webview); + // Only a server page or pop-out ever gets a login, and only its tab's. + if matches!(part, Part::Server | Part::Popout) { + macos::bind_view(&webview, tab); + } + } Ok(webview) } +/// The connect page's address; `manual` keeps it from connecting by itself. +fn connect_page_url(manual: bool) -> String { + if manual { + format!("{}?manual=1", policy::CONNECT_PAGE) + } else { + policy::CONNECT_PAGE.to_string() + } +} + /// Hides the whole app, as Cmd+H does. A click on the Dock icon (or Cmd+Tab) /// shows it again with every window as it was, a full-screen one included, /// which hiding the main window alone would not do. @@ -772,7 +1198,7 @@ impl App { } } Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, target, control_flow), - Event::UserEvent(event) => self.on_user_event(event, target), + Event::UserEvent(event) => self.on_user_event(event, target, control_flow), // A click on the Dock icon while no window is visible, which is // the main window minimized: tao only reports it, so bring the // window back here. An app hidden by closing its window @@ -800,286 +1226,1060 @@ impl App { if self.config_dirty && now >= self.save_held_since + SAVE_DEBOUNCE { self.save_config(); } - let loading = matches!(*self.phase.borrow(), Phase::Loading(_)); - if !loading { - self.loading_deadline = None; - } else if self.loading_deadline.is_some_and(|due| now >= due) { - self.show_connect_page(Some(LOAD_TIMED_OUT.into()), false); + let timed_out: Vec = self + .tabs + .iter_mut() + .filter_map(|t| { + if !matches!(*t.phase.borrow(), Phase::Loading(_)) { + t.loading_deadline = None; + } + t.loading_deadline.is_some_and(|due| now >= due).then_some(t.id) + }) + .collect(); + for tab in timed_out { + self.show_connect_page(tab, Some(LOAD_TIMED_OUT.into()), false); } if self.fullscreen_retry.is_some_and(|(due, _)| now >= due) { self.retry_fullscreen(now); } + self.flush(); if matches!(*control_flow, ControlFlow::ExitWithCode(_)) { return; } - let save_due = self.config_dirty.then(|| self.save_held_since + SAVE_DEBOUNCE); - let fullscreen_due = self.fullscreen_retry.map(|(due, _)| due); - *control_flow = match [save_due, self.loading_deadline, fullscreen_due] - .into_iter() - .flatten() - .min() - { - Some(due) => ControlFlow::WaitUntil(due), - None => ControlFlow::Wait, - }; + let save_due = self.config_dirty.then(|| self.save_held_since + SAVE_DEBOUNCE); + let fullscreen_due = self.fullscreen_retry.map(|(due, _)| due); + let loading_due = self.tabs.iter().filter_map(|t| t.loading_deadline).min(); + *control_flow = match [save_due, loading_due, fullscreen_due].into_iter().flatten().min() { + Some(due) => ControlFlow::WaitUntil(due), + None => ControlFlow::Wait, + }; + } + + /// Draws again what changed this turn: the strip, the overview while it + /// shows, the Server menu's tab items. + fn flush(&mut self) { + let fullscreen = self.main_window.fullscreen().is_some(); + if fullscreen != self.fullscreen { + self.fullscreen = fullscreen; + self.strip_dirty = true; + } + if std::mem::take(&mut self.strip_dirty) { + let msg = { + let views = self.tab_views(); + tabs::strip_state(&views, Some(self.active), self.overview_shown, self.fullscreen) + }; + let _ = self + .strip + .webview + .evaluate_script(&assets::native_message_script("__codemanTabs", &msg)); + } + if std::mem::take(&mut self.overview_dirty) { + if let Some(overview) = &self.overview { + let msg = tabs::overview_state(&self.tab_views()); + let _ = overview + .webview + .evaluate_script(&assets::native_message_script("__codemanOverview", &msg)); + } + } + if std::mem::take(&mut self.menu_dirty) { + let items: Vec<(String, bool)> = self + .tabs + .iter() + .enumerate() + .map(|(i, t)| (t.display_name(), !self.overview_shown && i == self.active)) + .collect(); + self.menu.set_tabs(&items); + self.menu.reopen.set_enabled(!self.closed.is_empty()); + } + } + + /// Every tab as the strip and the overview see it. + fn tab_views(&self) -> Vec> { + self.tabs + .iter() + .map(|t| tabs::View { + id: t.id, + name: t.display_name(), + color: &t.color, + kind: t.kind(), + url: t.home.as_ref(), + version: t.version.as_deref(), + seen: &t.seen, + }) + .collect() + } + + /// The tabs that change, drawn again at the end of this turn. + fn tabs_changed(&mut self) { + self.strip_dirty = true; + self.overview_dirty = true; + self.menu_dirty = true; + } + + /// The main window asked for full screen at launch: if it is not there + /// yet, ask again, a few times at most. tao reports a window as full + /// screen from the moment it is asked and puts that back only when the + /// entry fails, so `fullscreen()` being None means it failed. Stops once + /// the window is in full screen or minimized, so a user who leaves full + /// screen later is never pulled back. + fn retry_fullscreen(&mut self, now: Instant) { + let Some((_, attempts)) = self.fullscreen_retry else { + return; + }; + self.fullscreen_retry = None; + let w = &self.main_window; + if w.fullscreen().is_some() || w.is_minimized() || attempts >= FULLSCREEN_ATTEMPTS { + return; + } + w.set_fullscreen(Some(Fullscreen::Borderless(None))); + self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1)); + } + + fn on_window_event( + &mut self, + id: WindowId, + event: WindowEvent, + target: &EventLoopWindowTarget, + control_flow: &mut ControlFlow, + ) { + let main = id == self.main_window.id(); + match event { + WindowEvent::CloseRequested if !main => self.remove_popout(id), + WindowEvent::CloseRequested => self.close_main_window(target, control_flow), + WindowEvent::Moved(_) if main => self.record_main_frame(), + WindowEvent::Resized(_) if main => { + self.record_main_frame(); + self.layout(); + self.strip_dirty = true; + } + _ => {} + } + } + + /// The red button or Close Window on the main window. + fn close_main_window(&mut self, target: &EventLoopWindowTarget, control_flow: &mut ControlFlow) { + self.record_main_frame(); + self.save_config(); + let mode = self.config.keep_awake.mode; + let local = self.local_server(); + if keep_awake::available() && keep_awake::close_hides_app(mode, local.as_ref(), keep_awake::local_addresses) { + // Keep This Mac Awake ends with the app, so closing the window + // while it watches a Codeman on this Mac hides Codeman instead, as + // Cmd+H does (full screen and pop-outs included): the Mac stays + // awake, a click on the Dock icon brings it back, and Cmd+Q still + // quits. + hide_app(target); + } else { + // Closing the main window quits; the servers and their sessions + // keep running, so nothing is lost. + self.keep_awake.shutdown(); + *control_flow = ControlFlow::Exit; + } + } + + fn on_user_event( + &mut self, + event: UserEvent, + target: &EventLoopWindowTarget, + control_flow: &mut ControlFlow, + ) { + match event { + UserEvent::Menu(e) => self.on_menu(e.id.0.as_str(), target, control_flow), + UserEvent::Ipc { view, msg } => match (self.owner(view), msg) { + (Some(Owner::Connect(tab)), Ipc::Connect(cmd)) => self.on_connect_cmd(tab, cmd), + (Some(owner @ (Owner::Server(_) | Owner::Popout(..))), Ipc::Host(cmd)) => { + self.on_host_cmd(view, owner, cmd, target) + } + _ => {} + }, + UserEvent::Strip(cmd) => self.on_strip_cmd(cmd), + UserEvent::Overview(cmd) => self.on_overview_cmd(cmd), + UserEvent::ProbeDone(done) => self.on_probe_done(*done), + UserEvent::ToConnectPage { tab, msg } => { + if self.choosing(tab) { + self.to_connect_page(tab, msg); + } + } + UserEvent::LogoLidStatus(line) => self.show_lid_status(line), + UserEvent::ServerStartDone { + tab, + generation, + url, + result, + } => { + let Some(t) = self.tab_mut(tab) else { + return; + }; + t.starting_server = None; + if generation != t.start_generation { + return; // cancelled + } + let ok = result.is_ok(); + let output = result.unwrap_or_else(|e| e); + self.to_connect_page( + tab, + json!({ + "type": "serverStartResult", + "ok": ok, + "output": output, + "url": url.as_str(), + }), + ); + } + UserEvent::OpenAppWindow { tab, url } => { + if self.script_window_budget.allow(Instant::now()) { + // Built without focus: it comes to the front, but the + // keyboard stays with the window the user is typing in. + self.build_popout(target, tab, &url, false); + } else { + eprintln!("codeman-desktop: too many windows opened by a page at once; dropped one"); + } + } + UserEvent::OpenExternal(url) => { + self.open_external_budgeted(&url); + } + UserEvent::CookiesCleared { tab, generation, url } => { + // Only the load this clear was for, and only while it is + // still wanted: a Cancel, a timeout, a closed tab or another + // connect in between moved the generation or the phase on. + let Some(t) = self.tab(tab) else { + return; + }; + let current = generation == t.probe_generation + && matches!(&*t.phase.borrow(), Phase::Loading(loading) if *loading == url); + if current { + if let Some(view) = t.server.as_ref().map(|v| v.id) { + self.load_in(view, &policy::webkit_safe_url(&url), true); + } + } + } + UserEvent::LoginChecked { + tab, + generation, + outcome, + } => self.on_login_checked(tab, generation, outcome), + UserEvent::Title { view, title } => self.on_title(view, title), + UserEvent::PageCommitted { view, url } => self.on_page_committed(view, url), + UserEvent::DownloadStarted { view, url, file_name } => { + // A pop-out opened only to fetch a file would sit empty: hide + // it now, drop it when the download ends (dropping it sooner + // could cancel the download with its web view). + if let Some(Owner::Popout(window, _)) = self.owner(view) { + if let Some(p) = self.popouts.get_mut(&window) { + if p.opened_url == url { + p.hidden_for_download = true; + p.window.set_visible(false); + } + } + } + self.downloads + .entry((view, url)) + .or_default() + .push_back(file_name.unwrap_or_else(|| UNNAMED_FILE.into())); + } + UserEvent::DownloadDone { view, url, path, ok } => self.on_download_done(view, url, path, ok), + UserEvent::AuthRejected(tab) => self.recheck_login(tab), + UserEvent::ContentProcessGone { view } => self.on_content_process_gone(view), + UserEvent::TabStatus { + tab, + generation, + status, + } => self.on_tab_status(tab, generation, status), + UserEvent::KeepAwakeStatus { generation, text } => { + if self.keep_awake.is_current(generation) { + if let Some(items) = &self.menu.keep_awake { + items.status.set_text(&text); + } + } + } + UserEvent::OldHelperRemoved(outcome) => self.on_old_helper_removed(outcome), + } + } + + // ---- tabs and views ---- + + fn tab_index(&self, id: TabId) -> Option { + self.tabs.iter().position(|t| t.id == id) + } + + fn tab(&self, id: TabId) -> Option<&Tab> { + self.tabs.iter().find(|t| t.id == id) + } + + fn tab_mut(&mut self, id: TabId) -> Option<&mut Tab> { + self.tabs.iter_mut().find(|t| t.id == id) + } + + fn active_tab(&self) -> Option<&Tab> { + self.tabs.get(self.active) + } + + /// Who a web view belongs to. + fn owner(&self, view: ViewId) -> Option { + if view == self.strip.id { + return Some(Owner::Strip); + } + if self.overview.as_ref().is_some_and(|o| o.id == view) { + return Some(Owner::Overview); + } + for t in &self.tabs { + if t.connect.as_ref().is_some_and(|v| v.id == view) { + return Some(Owner::Connect(t.id)); + } + if t.server.as_ref().is_some_and(|v| v.id == view) { + return Some(Owner::Server(t.id)); + } + } + self.popouts + .iter() + .find(|(_, p)| p.view == view) + .map(|(w, p)| Owner::Popout(*w, p.tab)) + } + + /// The web view `view` is, wherever it lives. + fn webview(&self, view: ViewId) -> Option<&WebView> { + if view == self.strip.id { + return Some(&self.strip.webview); + } + if let Some(o) = self.overview.as_ref().filter(|o| o.id == view) { + return Some(&o.webview); + } + self.tabs + .iter() + .flat_map(Tab::views) + .find(|v| v.id == view) + .map(|v| &v.webview) + .or_else(|| self.popouts.values().find(|p| p.view == view).map(|p| &p.webview)) + } + + fn child_view_mut(&mut self, view: ViewId) -> Option<&mut ChildView> { + self.tabs + .iter_mut() + .flat_map(|t| t.connect.iter_mut().chain(t.server.iter_mut())) + .find(|v| v.id == view) + } + + fn next_view_id(&mut self) -> ViewId { + let id = ViewId(self.next_view); + self.next_view += 1; + id + } + + /// Where a tab's views and the overview go: everything below the strip. + fn content_bounds(&self) -> (f64, f64, f64, f64) { + let (width, height) = content_size(&self.main_window); + (0.0, tabs::STRIP_HEIGHT, width, (height - tabs::STRIP_HEIGHT).max(0.0)) + } + + /// Build a view inside the main window, below the strip, hidden. + fn build_child( + &mut self, + part: Part, + tab: TabId, + phase: Option>>, + load: Load<'_>, + store: Option<[u8; 16]>, + ) -> Option { + let id = self.next_view_id(); + let spec = ViewSpec { + id, + part, + tab, + phase, + load, + store, + bounds: Some(self.content_bounds()), + visible: false, + focused: false, + }; + match build_webview(&self.main_window, spec, &self.proxy) { + Ok(webview) => Some(ChildView::new(id, webview)), + Err(e) => { + eprintln!("codeman-desktop: could not create a web view: {e}"); + None + } + } + } + + /// Every view in its place after the window changed size. + fn layout(&self) { + let (x, y, w, h) = self.content_bounds(); + let rect = |x: f64, y: f64, w: f64, h: f64| ViewRect { + position: LogicalPosition::new(x, y).into(), + size: LogicalSize::new(w, h).into(), + }; + let _ = self.strip.webview.set_bounds(rect(0.0, 0.0, w, tabs::STRIP_HEIGHT)); + for view in self.tabs.iter().flat_map(Tab::views).chain(self.overview.iter()) { + let _ = view.webview.set_bounds(rect(x, y, w, h)); + } + } + + /// Show the view of the tab in front (or the overview) and hide the rest. + /// `focus`: the shown view takes the keyboard. + fn show_front(&mut self, focus: bool) { + let front = match self.overview_shown { + true => self.overview.as_ref().map(|o| o.id), + false => self.active_tab().and_then(Tab::front).map(|v| v.id), + }; + for view in self.tabs.iter().flat_map(Tab::views).chain(self.overview.iter()) { + let _ = view.webview.set_visible(Some(view.id) == front); + } + if focus { + if let Some(webview) = front.and_then(|id| self.webview(id)) { + let _ = webview.focus(); + } + } + self.update_title(); + self.tabs_changed(); + } + + fn update_title(&self) { + let title = match (self.overview_shown, self.active_tab()) { + (true, _) => OVERVIEW_TITLE.to_string(), + (false, Some(t)) if t.kind() == tabs::Kind::Server => { + tabs::window_title(t.page_title.as_deref(), &t.display_name()) + } + _ => APP_NAME.to_string(), + }; + self.main_window.set_title(&title); + } + + /// The tabs of the last run, or one tab with the connect page. + fn open_launch_tabs(&mut self) { + let saved = self.config.tabs.clone(); + let auto = self.config.auto_connect; + for entry in saved { + self.create_tab(Some(entry), auto, true); + } + if self.tabs.is_empty() { + // Before tabs, and on a first run: the connect page as it always + // was, connecting to the server used last on its own. + self.create_tab(None, false, false); + } + self.active = self.config.active_tab.min(self.tabs.len().saturating_sub(1)); + if let Some(t) = self.tabs.first_mut() { + t.notice = self.launch_notice.take(); + } + self.show_front(true); + } + + /// A new tab with the connect page. `entry`: a server it is for (a tab + /// opened again), shown on the page and connected to when `connect_now`. + /// `manual`: the page does not connect on its own. + fn create_tab(&mut self, entry: Option, connect_now: bool, manual: bool) -> Option { + if self.tabs.len() >= MAX_TABS { + return None; + } + let id = self.next_tab; + self.next_tab += 1; + let phase = Rc::new(RefCell::new(Phase::Connect)); + let connect = self.build_child( + Part::Connect, + id, + Some(phase.clone()), + Load::ConnectPage { manual }, + None, + )?; + let home = entry.as_ref().and_then(|e| Url::parse(&e.url).ok()); + let color = entry + .as_ref() + .and_then(|e| e.color.clone()) + .filter(|c| tabs::is_color(c)) + .unwrap_or_else(|| tabs::next_color(self.tabs.iter().map(|t| t.color.as_str()))); + self.tabs.push(Tab { + id, + connect: Some(connect), + server: None, + phase, + connect_to: entry.as_ref().map(|e| (e.url.clone(), connect_now)), + home, + name: entry.and_then(|e| e.name), + color, + version: None, + page_title: None, + store: None, + loading_deadline: None, + auth: None, + login: None, + login_generation: 0, + login_recheck: None, + probe_generation: 0, + start_generation: 0, + starting_server: None, + notice: None, + add_agent_on_load: false, + seen: tabs::Seen::default(), + watch: None, + status_login: true, + }); + self.tabs_changed(); + Some(id) + } + + /// ⌘T, the + button, Switch Server: a new tab with the connect page, in + /// front. + fn new_tab(&mut self) -> Option { + let Some(id) = self.create_tab(None, false, true) else { + if let Some(t) = self.active_tab().map(|t| t.id) { + self.toast_in_tab(t, TOO_MANY_TABS, "warning"); + } + return None; + }; + self.select_tab(self.tabs.len() - 1); + Some(id) + } + + /// Bring the tab at `index` to the front, the overview out of the way, + /// with the keyboard in its page. + fn select_tab(&mut self, index: usize) { + if index >= self.tabs.len() { + return; + } + self.active = index; + self.overview_shown = false; + self.show_front(true); + self.save_tabs(); + self.sync_keep_awake(); + } + + fn select_tab_id(&mut self, id: TabId) { + if let Some(index) = self.tab_index(id) { + self.select_tab(index); + } + } + + /// ⌘0 and the grid button: the overview in front, or the tab again. + fn toggle_overview(&mut self) { + if self.overview_shown { + self.overview_shown = false; + self.show_front(true); + return; + } + if self.overview.is_none() { + self.overview = self.build_child(Part::Overview, 0, None, Load::Bundled(policy::OVERVIEW_PAGE), None); + } + if self.overview.is_some() { + self.overview_shown = true; + self.show_front(true); + } + } + + /// Close a tab: its pop-outs, its views, its status thread and its login + /// go. Its server and sessions keep running. The last tab gives way to a + /// new tab with the connect page, never an empty window. + fn close_tab(&mut self, id: TabId) { + let Some(index) = self.tab_index(id) else { + return; + }; + if self.lone_blank_tab() { + return; // it would only come back + } + self.close_popouts_of(id); + set_challenge_login(id, None); + #[cfg(target_os = "macos")] + macos::forget_tab(id); + let tab = self.tabs.remove(index); + if let Some(entry) = tab.entry() { + self.closed.push(entry); + if self.closed.len() > tabs::MAX_CLOSED { + self.closed.remove(0); + } + } + let views: Vec = tab.views().map(|v| v.id).collect(); + self.downloads.retain(|(view, _), _| !views.contains(view)); + drop(tab); + let before = self.tabs.len() + 1; + if index < self.active { + self.active -= 1; + } else if index == self.active { + self.active = tabs::after_close(before, index).unwrap_or(0); + } + if self.tabs.is_empty() { + self.create_tab(None, false, true); + self.active = 0; + } + self.show_front(!self.overview_shown); + self.save_tabs(); + self.sync_logo_watch(); + self.sync_keep_awake(); + } + + /// The window holds one tab, and it is a new tab with nothing in it. + fn lone_blank_tab(&self) -> bool { + matches!(self.tabs.as_slice(), [t] if t.home.is_none() && t.kind() == tabs::Kind::Connect) + } + + fn close_popouts_of(&mut self, tab: TabId) { + let windows: Vec = self + .popouts + .iter() + .filter(|(_, p)| p.tab == tab) + .map(|(w, _)| *w) + .collect(); + for window in windows { + self.remove_popout(window); + } + } + + /// Reopen Closed Tab: the last tab closed, connected again. + fn reopen_closed(&mut self) { + let Some(entry) = self.closed.pop() else { + return; + }; + if let Some(index) = self + .tabs + .iter() + .position(|t| t.home.as_ref().is_some_and(|h| h.as_str() == entry.url)) + { + self.select_tab(index); + return; + } + if self.create_tab(Some(entry), true, true).is_some() { + self.select_tab(self.tabs.len() - 1); + } + } + + /// The tabs to open at the next launch: those that belong to a server. + fn save_tabs(&mut self) { + let entries: Vec = self.tabs.iter().filter_map(Tab::entry).collect(); + let active = self + .tabs + .iter() + .take(self.active + 1) + .filter(|t| t.home.is_some()) + .count() + .saturating_sub(1); + if self.config.tabs != entries || self.config.active_tab != active { + self.config.tabs = entries; + self.config.active_tab = active; + self.config_dirty = true; + } + } + + fn on_strip_cmd(&mut self, cmd: StripCmd) { + match cmd { + StripCmd::Ready => self.strip_dirty = true, + StripCmd::Select(id) => { + let Some(index) = self.tab_index(id) else { + return; + }; + if index == self.active && !self.overview_shown { + // Already in front: only the keyboard goes back to its + // page (a click made the strip the first responder). A + // double-click that starts a rename asks for the strip + // again afterwards (StripCmd::Editing). + self.focus_front(); + return; + } + self.select_tab(index); + } + StripCmd::Close(id) => self.close_tab(id), + StripCmd::New => { + self.new_tab(); + } + StripCmd::Overview => self.toggle_overview(), + StripCmd::Menu(id) => self.show_tab_menu(id), + StripCmd::Rename { id, name } => { + if let Some(t) = self.tab_mut(id) { + let default = t.shown_url().map(|u| tabs::default_name(&u)); + if let Some(name) = tabs::renamed(&name, &t.display_name(), default.as_deref()) { + t.name = name; + } + } + self.tabs_changed(); + self.save_tabs(); + self.update_title(); + self.focus_front(); + } + StripCmd::Move { id, to } => { + let Some(from) = self.tab_index(id) else { + return; + }; + let Some(to) = tabs::moved(self.tabs.len(), from, to) else { + return; + }; + let active = self.tabs.get(self.active).map(|t| t.id); + let tab = self.tabs.remove(from); + self.tabs.insert(to, tab); + if let Some(index) = active.and_then(|id| self.tab_index(id)) { + self.active = index; + } + self.tabs_changed(); + self.save_tabs(); + self.focus_front(); + } + StripCmd::Editing => { + let _ = self.strip.webview.focus(); + } + StripCmd::Drag => { + let _ = self.main_window.drag_window(); + self.focus_front(); + } + StripCmd::Zoom => { + let w = &self.main_window; + if w.fullscreen().is_none() { + w.set_maximized(!w.is_maximized()); + } + self.focus_front(); + } + } + } + + /// The keyboard back in the page in front (after a click on the strip). + fn focus_front(&self) { + let front = match self.overview_shown { + true => self.overview.as_ref().map(|o| o.id), + false => self.active_tab().and_then(Tab::front).map(|v| v.id), + }; + if let Some(webview) = front.and_then(|id| self.webview(id)) { + let _ = webview.focus(); + } + } + + /// The right-click menu of a tab, at the pointer. + fn show_tab_menu(&mut self, id: TabId) { + let Some(t) = self.tab(id) else { + return; + }; + let server = t.kind() == tabs::Kind::Server; + let Ok(menu) = menu::tab_context_menu(id, &t.color, server, self.tabs.len() > 1) else { + return; + }; + #[cfg(target_os = "macos")] + { + use muda::ContextMenu; + use tao::platform::macos::WindowExtMacOS; + // SAFETY: tao's live NSView of the main window; the menu runs its + // tracking loop now and is done with the view when this returns. + unsafe { menu.show_context_menu_for_nsview(self.main_window.ns_view(), None) }; + } + // The right-click made the strip the first responder. The item chosen, + // if any, arrives after this (Rename takes the strip again then). + self.focus_front(); + #[cfg(not(target_os = "macos"))] + drop(menu); + } + + fn on_tab_menu(&mut self, id: TabId, action: TabAction) { + match action { + TabAction::Rename => { + // The strip takes the keyboard for the name field. + let _ = self.strip.webview.focus(); + let msg = json!({ "type": "rename", "id": id }); + let _ = self + .strip + .webview + .evaluate_script(&assets::native_message_script("__codemanTabs", &msg)); + } + TabAction::Color(i) => { + if let (Some(t), Some(color)) = (self.tab_mut(id), tabs::PALETTE.get(i)) { + t.color = (*color).to_string(); + } + self.tabs_changed(); + self.save_tabs(); + self.focus_front(); + } + TabAction::Reload => { + if let Some(view) = self.tab(id).and_then(|t| t.server.as_ref()).map(|v| v.id) { + if let Some((url, with_auth)) = self.reload_target(view) { + self.load_in(view, &url, with_auth); + } + } + } + TabAction::Reconnect => self.show_connect_page(id, None, true), + TabAction::Close => self.close_tab(id), + TabAction::CloseOthers => { + let others: Vec = self.tabs.iter().map(|t| t.id).filter(|t| *t != id).collect(); + for other in others { + self.close_tab(other); + } + } + } + } + + fn on_overview_cmd(&mut self, cmd: OverviewCmd) { + match cmd { + OverviewCmd::Ready => self.overview_dirty = true, + OverviewCmd::Open(tab) => self.select_tab_id(tab), + OverviewCmd::OpenSession { tab, session } => { + self.select_tab_id(tab); + if !tabs::is_session_id(&session) { + return; + } + let loaded = self.tab(tab).filter(|t| t.kind() == tabs::Kind::Server); + if let Some(server) = loaded.and_then(|t| t.server.as_ref()) { + let _ = server.webview.evaluate_script(&assets::select_session_script(&session)); + } + } + OverviewCmd::New => { + self.new_tab(); + } + OverviewCmd::Retry(tab) => { + let view = self.tab(tab).and_then(|t| t.server.as_ref()).map(|v| v.id); + match view.and_then(|v| self.reload_target(v).map(|r| (v, r))) { + Some((view, (url, with_auth))) => self.load_in(view, &url, with_auth), + None => self.select_tab_id(tab), + } + } + } } - /// The main window asked for full screen at launch: if it is not there - /// yet, ask again, a few times at most. tao reports a window as full - /// screen from the moment it is asked and puts that back only when the - /// entry fails, so `fullscreen()` being None means it failed. Stops once - /// the window is in full screen or minimized, so a user who leaves full - /// screen later is never pulled back. - fn retry_fullscreen(&mut self, now: Instant) { - let Some((_, attempts)) = self.fullscreen_retry else { + fn on_tab_status(&mut self, tab: TabId, generation: u64, status: StatusUpdate) { + let Some(t) = self.tab_mut(tab) else { return; }; - self.fullscreen_retry = None; - let Some(main) = self.windows.get(&self.main_id) else { - return; - }; - if main.window.fullscreen().is_some() || main.window.is_minimized() || attempts >= FULLSCREEN_ATTEMPTS { + if generation != t.login_generation { return; } - main.window.set_fullscreen(Some(Fullscreen::Borderless(None))); - self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1)); + match status { + StatusUpdate::Seen(detail) => { + t.seen.detail = Some(detail); + t.seen.offline = false; + } + StatusUpdate::Offline => t.seen.offline = true, + StatusUpdate::Refused => { + t.seen.refused = true; + t.watch = None; + } + } + self.strip_dirty = true; + self.overview_dirty = true; } - fn on_window_event( - &mut self, - id: WindowId, - event: WindowEvent, - target: &EventLoopWindowTarget, - control_flow: &mut ControlFlow, - ) { - match event { - WindowEvent::CloseRequested if id != self.main_id => self.remove_window(id), - WindowEvent::CloseRequested => { - self.record_main_frame(); - self.save_config(); - let server = self.phase.borrow().loaded().cloned(); - let mode = self.config.keep_awake.mode; - if keep_awake::available() - && keep_awake::close_hides_app(mode, server.as_ref(), keep_awake::local_addresses) - { - // Keep This Mac Awake ends with the app, so closing the - // window while it watches a Codeman on this Mac hides - // Codeman instead, as Cmd+H does (full screen and pop-outs - // included): the Mac stays awake, a click on the Dock icon - // brings it back, and Cmd+Q still quits. - hide_app(target); - } else { - // Closing the main window quits; the server and its - // sessions keep running, so nothing is lost. - self.keep_awake.shutdown(); - *control_flow = ControlFlow::Exit; + fn on_title(&mut self, view: ViewId, title: String) { + match self.owner(view) { + Some(Owner::Server(tab)) => { + if let Some(t) = self.tab_mut(tab) { + t.page_title = Some(title); + } + self.update_title(); + } + Some(Owner::Popout(window, _)) => { + if let Some(p) = self.popouts.get(&window) { + let title = title.trim(); + p.window.set_title(if title.is_empty() { APP_NAME } else { title }); } } - WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(), _ => {} } } - fn on_user_event(&mut self, event: UserEvent, target: &EventLoopWindowTarget) { - match event { - UserEvent::Menu(e) => self.on_menu(e.id.0.as_str()), - UserEvent::Ipc { window, msg } => match msg { - Ipc::Host(cmd) => self.on_host_cmd(window, cmd, target), - Ipc::Connect(cmd) => self.on_connect_cmd(cmd), - }, - UserEvent::ProbeDone(done) => self.on_probe_done(*done), - UserEvent::ToConnectPage(msg) => { - if self.choosing() { - self.to_connect_page(msg); + fn on_page_committed(&mut self, view: ViewId, url: String) { + match self.owner(view) { + Some(Owner::Server(tab)) => { + let Some(t) = self.tab(tab) else { + return; + }; + let next = policy::loaded_on_commit(&t.phase.borrow(), &url); + if let Some(next) = next { + *t.phase.borrow_mut() = next; } - } - UserEvent::LogoLidStatus(line) => self.show_lid_status(line), - UserEvent::ServerStartDone { - generation, - url, - result, - } => { - self.starting_server = None; - if generation != self.start_generation { - return; // cancelled + if let Some(v) = self.child_view_mut(view) { + v.committed = Url::parse(&url).ok(); } - let ok = result.is_ok(); - let output = result.unwrap_or_else(|e| e); - self.to_connect_page(json!({ - "type": "serverStartResult", - "ok": ok, - "output": output, - "url": url.as_str(), - })); - } - UserEvent::OpenAppWindow(url) => { - if self.script_window_budget.allow(Instant::now()) { - // Built without focus: it comes to the front, but the - // keyboard stays with the window the user is typing in. - self.build_popout(target, &url, false); - } else { - eprintln!("codeman-desktop: too many windows opened by a page at once; dropped one"); + // The connect page is still there the first time the server's + // page commits: the load got through. + let first = self + .tab(tab) + .is_some_and(|t| t.connect.is_some() && t.phase.borrow().loaded().is_some()); + if first { + self.on_server_loaded(tab); } } - UserEvent::OpenExternal(url) => { - self.open_external_budgeted(&url); + Some(Owner::Connect(_)) => { + if let Some(v) = self.child_view_mut(view) { + v.committed = Url::parse(&url).ok(); + } } - UserEvent::CookiesCleared { generation, url } => { - // Only the load this clear was for, and only while it is - // still wanted: a Cancel, Switch Server, timeout or another - // connect in between moved the generation or the phase on. - let current = generation == self.probe_generation - && matches!(&*self.phase.borrow(), Phase::Loading(loading) if *loading == url); - if current { - self.load_in(self.main_id, &policy::webkit_safe_url(&url), true); + Some(Owner::Popout(window, _)) => { + if let Some(p) = self.popouts.get_mut(&window) { + p.committed = Url::parse(&url).ok(); } } - UserEvent::LoginChecked { generation, outcome } => self.on_login_checked(generation, outcome), - UserEvent::Title { window, title } => { - if let Some(w) = self.windows.get(&window) { - let title = title.trim(); - w.window.set_title(if title.is_empty() { APP_NAME } else { title }); + _ => {} + } + } + + /// A tab's server page committed: it replaces the connect page, the tab + /// is for this server from now on, and its status is read. + fn on_server_loaded(&mut self, tab: TabId) { + let proxy = self.proxy.clone(); + let Some(t) = self.tab_mut(tab) else { + return; + }; + t.connect = None; + let server = t.phase.borrow().loaded().cloned(); + if let Some(server) = server { + t.home = Some(server.clone()); + t.seen = tabs::Seen::default(); + t.watch = t + .status_login + .then(|| watch_status(&proxy, tab, t.login_generation, &server, t.auth.clone())) + .flatten(); + } + // The server page takes the keyboard from the connect page it replaces, + // when this tab is the one in front. + let in_front = self.active_tab().is_some_and(|t| t.id == tab) && !self.overview_shown; + self.show_front(in_front); + self.save_tabs(); + self.sync_logo_watch(); + self.sync_keep_awake(); + } + + fn on_download_done(&mut self, view: ViewId, url: String, path: Option, ok: bool) { + let key = (view, url); + let started = self.downloads.get_mut(&key).and_then(VecDeque::pop_front); + if self.downloads.get(&key).is_some_and(VecDeque::is_empty) { + self.downloads.remove(&key); + } + let name = started + .or_else(|| path.as_deref().and_then(file_name_of)) + .unwrap_or_else(|| UNNAMED_FILE.into()); + // The toast goes where the download was started when that view still + // shows Codeman's own app (a session pop-out has it too), else to the + // tab's server page. Chosen before a hidden pop-out is dropped. + let owner = self.owner(view); + let tab = match owner { + Some(Owner::Server(tab) | Owner::Popout(_, tab)) => tab, + _ => return, + }; + let server = self.tab(tab).and_then(|t| t.phase.borrow().loaded().cloned()); + let shows_app = |committed: Option<&Url>| { + server + .as_ref() + .zip(committed) + .is_some_and(|(server, url)| policy::trusted_frame(url, server)) + }; + let toast_in = match owner { + Some(Owner::Popout(window, _)) => { + let p = self.popouts.get(&window); + let here = p.is_some_and(|p| !p.hidden_for_download && shows_app(p.committed.as_ref())); + if p.is_some_and(|p| p.hidden_for_download) { + self.remove_popout(window); } + here.then_some(view) } - UserEvent::PageCommitted { window, url } => { - if window == self.main_id { - let next = policy::loaded_on_commit(&self.phase.borrow(), &url); - if let Some(next) = next { - *self.phase.borrow_mut() = next; - } - self.sync_logo_watch(); - self.sync_keep_awake(); + _ => None, + }; + let toast_in = toast_in.or_else(|| self.tab(tab).and_then(|t| t.server.as_ref()).map(|v| v.id)); + if let Some(webview) = toast_in.and_then(|id| self.webview(id)) { + let _ = webview.evaluate_script(&assets::download_toast_script(&name, ok)); + } + } + + /// A toast in a tab's server page, if it shows one. + fn toast_in_tab(&self, tab: TabId, text: &str, kind: &str) { + let loaded = self.tab(tab).filter(|t| t.kind() == tabs::Kind::Server); + if let Some(server) = loaded.and_then(|t| t.server.as_ref()) { + let _ = server.webview.evaluate_script(&assets::toast_script(text, kind)); + } + } + + /// The web content process of `view` ended, so the view is blank + /// (policy::recovery decides what it does about it). A second end within + /// CRASH_RELOAD_COOLDOWN counts as the same page failing again. + fn on_content_process_gone(&mut self, view: ViewId) { + let now = Instant::now(); + let again_after = + |at: Option| at.is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN); + match self.owner(view) { + Some(Owner::Strip) => { + let _ = self.strip.webview.load_url(policy::STRIP_PAGE); + } + Some(Owner::Overview) => { + if let Some(o) = &self.overview { + let _ = o.webview.load_url(policy::OVERVIEW_PAGE); } - if let Some(w) = self.windows.get_mut(&window) { - w.committed = Url::parse(&url).ok(); + } + Some(Owner::Connect(tab)) => { + let again = self.child_view_mut(view).map(|v| again_after(v.gone_at.replace(now))); + if again == Some(false) { + if let Some(webview) = self.webview(view) { + let _ = webview.load_url(&connect_page_url(true)); + } + } else { + eprintln!("codeman-desktop: tab {tab}'s connect page stopped again and was left as it is"); } } - UserEvent::DownloadStarted { window, url, file_name } => { - // A pop-out opened only to fetch a file would sit empty: hide - // it now, drop it when the download ends (dropping it sooner - // could cancel the download with its web view). - if let Some(w) = self.windows.get_mut(&window) { - if let Kind::Popout { - opened_url, - hidden_for_download, - } = &mut w.kind - { - if *opened_url == url { - *hidden_for_download = true; - w.window.set_visible(false); + Some(Owner::Server(tab)) => { + let Some(again) = self.child_view_mut(view).map(|v| again_after(v.gone_at.replace(now))) else { + return; + }; + let on_server = self.tab(tab).is_some_and(|t| t.phase.borrow().loaded().is_some()); + match policy::recovery(true, on_server, again) { + Recovery::Reload => { + if let Some((url, with_auth)) = self.reload_target(view) { + self.load_in(view, &url, with_auth); } } - self.downloads - .entry((window, url)) - .or_default() - .push_back(file_name.unwrap_or_else(|| UNNAMED_FILE.into())); - } - } - UserEvent::DownloadDone { window, url, path, ok } => { - let key = (window, url); - let started = self.downloads.get_mut(&key).and_then(VecDeque::pop_front); - if self.downloads.get(&key).is_some_and(VecDeque::is_empty) { - self.downloads.remove(&key); - } - let name = started - .or_else(|| path.as_deref().and_then(file_name_of)) - .unwrap_or_else(|| UNNAMED_FILE.into()); - let server = self.phase.borrow().loaded().cloned(); - // The toast goes where the download was started when that - // window still shows Codeman's own app (a session pop-out has - // it too), else to the main window. Chosen before the hidden - // pop-out below is dropped. - let toast_in = server.as_ref().map(|server| { - let shows_app = self.windows.get(&window).is_some_and(|w| { - !w.hidden_for_download() - && w.committed - .as_ref() - .is_some_and(|url| policy::trusted_frame(url, server)) - }); - if shows_app { - window - } else { - self.main_id + Recovery::ConnectPage { gave_up } => { + self.show_connect_page(tab, gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false); } - }); - if self.windows.get(&window).is_some_and(AppWindow::hidden_for_download) { - self.remove_window(window); - } - if let Some(w) = toast_in.and_then(|id| self.windows.get(&id)) { - let _ = w.webview.evaluate_script(&assets::download_toast_script(&name, ok)); + Recovery::Close => {} + Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"), } } - UserEvent::AuthRejected => self.recheck_login(), - UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window), - UserEvent::KeepAwakeStatus { generation, text } => { - if self.keep_awake.is_current(generation) { - if let Some(items) = &self.menu.keep_awake { - items.status.set_text(&text); + Some(Owner::Popout(window, tab)) => { + let Some(p) = self.popouts.get_mut(&window) else { + return; + }; + // A pop-out hidden for its download shows no page, and the + // download does not run in that process: loading again would + // fetch it twice. + if p.hidden_for_download { + return; + } + let again = again_after(p.gone_at.replace(now)); + let on_server = self.tab(tab).is_some_and(|t| t.phase.borrow().loaded().is_some()); + match policy::recovery(false, on_server, again) { + Recovery::Reload => { + if let Some((url, with_auth)) = self.reload_target(view) { + self.load_in(view, &url, with_auth); + } + } + Recovery::Close => self.remove_popout(window), + Recovery::ConnectPage { .. } | Recovery::Nothing => { + eprintln!("codeman-desktop: a page stopped again and was left as it is") } } } - UserEvent::OldHelperRemoved(outcome) => self.on_old_helper_removed(outcome), + None => {} } } - /// The web content process of `window` ended, so the window is blank - /// (policy::recovery decides what it does about it). A second end within - /// CRASH_RELOAD_COOLDOWN counts as the same page failing again. - fn on_content_process_gone(&mut self, window: WindowId) { - let now = Instant::now(); - let Some(w) = self.windows.get_mut(&window) else { + /// The challenge handler reported that a tab's server refused its login. + /// That is not taken on trust: a 401 the handler reads as Codeman's can + /// still come from something Codeman relays (a dashboard asset a web tab + /// fetches by a root-absolute URL). So the login is checked once against + /// the server's own status API, off the event loop, and on_login_checked + /// decides. + fn recheck_login(&mut self, tab: TabId) { + let proxy = self.proxy.clone(); + let Some(t) = self.tab_mut(tab) else { return; }; - // A pop-out hidden for its download shows no page, and the download - // does not run in that process: loading again would fetch it twice. - if w.hidden_for_download() { - return; - } - let again = w - .content_process_gone_at - .is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN); - w.content_process_gone_at = Some(now); - let on_server = self.phase.borrow().loaded().is_some(); - match policy::recovery(window == self.main_id, on_server, again) { - Recovery::Reload => { - if let Some((url, with_auth)) = self.reload_target(window) { - self.load_in(window, &url, with_auth); - } - } - Recovery::ConnectPage { gave_up } => { - self.show_connect_page(gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false); - } - Recovery::Close => self.remove_window(window), - Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"), - } - } - - /// The challenge handler reported that the server refused its login. That - /// is not taken on trust: a 401 the handler reads as Codeman's can still - /// come from something Codeman relays (a dashboard asset a web tab fetches - /// by a root-absolute URL). So the login is checked once against the - /// server's own status API, off the event loop, and on_login_checked - /// decides. - fn recheck_login(&mut self) { - let Some(server) = self.phase.borrow().server().cloned() else { + let Some(server) = t.phase.borrow().server().cloned() else { return; // the connect page: nothing to recheck }; - let Some(auth) = self.auth.clone() else { + let Some(auth) = t.auth.clone() else { return; }; - let generation = self.login_generation; - if self.login_recheck == Some(generation) { + let generation = t.login_generation; + if t.login_recheck == Some(generation) { return; } - self.login_recheck = Some(generation); - let proxy = self.proxy.clone(); + t.login_recheck = Some(generation); std::thread::spawn(move || { let outcome = probe::probe(&server, Some(&auth)); - let _ = proxy.send_event(UserEvent::LoginChecked { generation, outcome }); + let _ = proxy.send_event(UserEvent::LoginChecked { + tab, + generation, + outcome, + }); }); } - /// The server's answer to recheck_login. A refused login brings the + /// The server's answer to recheck_login. A refused login brings the tab's /// connect page back (without taking focus from another app). So does a /// 429: the page's own requests then get 429 too, which carries no /// challenge, so nothing else would ever notice, and re-arming would send @@ -1087,40 +2287,58 @@ impl App { /// network error included, leaves the page as it is and hands the handler /// the same login again, which also re-arms its report, so a later real /// refusal is noticed. - fn on_login_checked(&mut self, generation: u64, outcome: ProbeOutcome) { - if self.login_recheck == Some(generation) { - self.login_recheck = None; + fn on_login_checked(&mut self, tab: TabId, generation: u64, outcome: ProbeOutcome) { + let Some(t) = self.tab_mut(tab) else { + return; + }; + if t.login_recheck == Some(generation) { + t.login_recheck = None; } - if generation != self.login_generation { + if generation != t.login_generation { return; // another login, or none, since the recheck started } - let Some(server) = self.phase.borrow().server().cloned() else { + let Some(server) = t.phase.borrow().server().cloned() else { return; }; match outcome { ProbeOutcome::BadCredentials | ProbeOutcome::NeedsLogin => { - self.show_connect_page(Some(AUTH_REJECTED.into()), false); + self.show_connect_page(tab, Some(AUTH_REJECTED.into()), false); } ProbeOutcome::RateLimited { .. } => { - self.show_connect_page(Some(AUTH_RATE_LIMITED.into()), false); + self.show_connect_page(tab, Some(AUTH_RATE_LIMITED.into()), false); } _ => { - if let Some((username, password)) = &self.login { - set_challenge_login(Some((&server, username, password))); + if let Some((username, password)) = &t.login { + set_challenge_login(tab, Some((&server, username, password))); } } } } + /// The servers of every tab that shows one, with their logins. + fn loaded_servers(&self) -> Vec<(Url, Option)> { + self.tabs + .iter() + .filter_map(|t| t.phase.borrow().loaded().cloned().map(|url| (url, t.auth.clone()))) + .collect() + } + /// The logo is watched while the switch is on, a state has a style and a - /// server is open. + /// server is open, for every open server together. fn sync_logo_watch(&mut self) { let styles = self.config.logo_blink; - let phase = self.phase.borrow(); - let server = phase.loaded().filter(|_| styles.active() && logo::available()); - self.logo_watch - .sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles))); - drop(phase); + let want = (styles.active() && logo::available()).then(|| { + let servers = self + .loaded_servers() + .into_iter() + .map(|(url, authorization)| logo::Target { + server: url.to_string(), + authorization, + }) + .collect(); + (servers, styles) + }); + self.logo_watch.sync(want); self.show_lid_status(logo::lid_status_line()); } @@ -1181,25 +2399,47 @@ impl App { } else { return; }; - if let Some(main) = self.windows.get(&self.main_id) { - let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning")); + if let Some(t) = self.active_tab().map(|t| t.id) { + self.toast_in_tab(t, warning, "warning"); + } + } + + /// The Codeman on this Mac among the open tabs, if any: the tab in front + /// when it is one, else the first. Keep This Mac Awake watches it. + fn local_server(&self) -> Option { + let servers = self.loaded_servers(); + if servers.is_empty() { + return None; } + let addresses = keep_awake::local_addresses(); + let active = self.active_tab().and_then(|t| t.phase.borrow().loaded().cloned()); + active + .into_iter() + .chain(servers.into_iter().map(|(url, _)| url)) + .find(|url| keep_awake::is_this_mac(url, &addresses)) } - /// Keep-awake watches the open server while a mode is chosen. Whether that - /// server is this Mac is the worker's to find out. + /// Keep-awake watches the Codeman on this Mac among the tabs while a mode + /// is chosen. With none, it says so. fn sync_keep_awake(&mut self) { let mut settings = self.config.keep_awake; // The lid item only exists on a Mac with a lid; a setting copied from one // does nothing elsewhere. settings.lid &= keep_awake::has_lid(); - let server = self.phase.borrow().loaded().cloned(); - let watch = server - .filter(|_| settings.mode != keep_awake::Mode::Off) - .map(|server| keep_awake::Watch { - server, - authorization: self.auth.clone(), - settings, + let watch = (settings.mode != keep_awake::Mode::Off) + .then(|| self.local_server()) + .flatten() + .map(|server| { + let authorization = self + .tabs + .iter() + .find(|t| t.phase.borrow().loaded() == Some(&server)) + .and_then(|t| t.auth.clone()); + keep_awake::Watch { + server, + authorization, + settings, + } }); let idle = match (settings.mode, &watch) { (keep_awake::Mode::Off, _) => Some(keep_awake::OFF_TEXT), @@ -1278,7 +2518,7 @@ impl App { } } - fn on_menu(&mut self, id: &str) { + fn on_menu(&mut self, id: &str, target: &EventLoopWindowTarget, control_flow: &mut ControlFlow) { if let Some((signal, style)) = BlinkStyle::from_menu_id(id) { self.choose_logo_blink(signal, style); return; @@ -1287,11 +2527,46 @@ impl App { self.choose_keep_awake_mode(mode); return; } + if let Some((tab, action)) = TabAction::parse(id) { + self.on_tab_menu(tab, action); + return; + } + if let Some(key) = menu::tab_key(id) { + if let Some(index) = tabs::index_of_key(key, self.tabs.len()) { + self.select_tab(index); + } + return; + } match id { keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(), keep_awake::LID_ID => self.toggle_keep_awake_lid(), keep_awake::REMOVE_OLD_HELPER_ID => self.remove_old_helper(), - menu_id::SWITCH => self.show_connect_page(None, true), + menu_id::SWITCH | menu_id::TAB_NEW => { + self.new_tab(); + } + menu_id::TAB_CLOSE => match self.focused_popout() { + Some(window) => self.remove_popout(window), + None if self.overview_shown => self.toggle_overview(), + // As in Safari: ⌘W on the last tab closes the window. + None if self.lone_blank_tab() => self.close_main_window(target, control_flow), + None => { + if let Some(t) = self.active_tab().map(|t| t.id) { + self.close_tab(t); + } + } + }, + menu_id::WINDOW_CLOSE => match self.focused_popout() { + Some(window) => self.remove_popout(window), + None => self.close_main_window(target, control_flow), + }, + menu_id::TAB_REOPEN => self.reopen_closed(), + menu_id::TAB_OVERVIEW => self.toggle_overview(), + menu_id::TAB_NEXT | menu_id::TAB_PREVIOUS => { + let forward = id == menu_id::TAB_NEXT; + if let Some(index) = tabs::neighbour(self.tabs.len(), self.active, forward) { + self.select_tab(index); + } + } menu_id::ADD_AGENT => self.add_agent(), menu_id::RELOAD => self.reload_focused(), menu_id::LOGO_BLINK => self.toggle_logo_blink(), @@ -1299,11 +2574,21 @@ impl App { let value = self.menu.start_fullscreen.is_checked(); self.config.start_fullscreen = value; self.config_dirty = true; - self.to_connect_page(json!({ "type": "prefs", "startFullscreen": value })); + self.to_every_connect_page(json!({ "type": "prefs", "startFullscreen": value })); } menu_id::DEVTOOLS => { - if let Some(w) = self.focused_window_id().and_then(|id| self.windows.get(&id)) { - w.webview.open_devtools(); + if let Some(window) = self.focused_popout() { + if let Some(p) = self.popouts.get(&window) { + p.webview.open_devtools(); + } + } else if self.main_window.is_focused() { + let front = match self.overview_shown { + true => self.overview.as_ref().map(|o| o.id), + false => self.active_tab().and_then(Tab::front).map(|v| v.id), + }; + if let Some(webview) = front.and_then(|id| self.webview(id)) { + webview.open_devtools(); + } } } // The Help items are the user's own click, so they skip the budget. @@ -1313,7 +2598,11 @@ impl App { } } - fn on_host_cmd(&mut self, window: WindowId, cmd: HostCmd, target: &EventLoopWindowTarget) { + fn on_host_cmd(&mut self, view: ViewId, owner: Owner, cmd: HostCmd, target: &EventLoopWindowTarget) { + let tab = match owner { + Owner::Server(tab) | Owner::Popout(_, tab) => tab, + _ => return, + }; match cmd { HostCmd::OpenWindow(raw) => { // The page's openWindow already answered true, so every way @@ -1321,41 +2610,56 @@ impl App { // did not build, the browser budget) is reported back to it. // This request comes only from Codeman's own app shell // (parse_ipc), so a session it asked for gets its tab back. - let session = self.phase.borrow().loaded().and_then(|server| { + let Some(phase) = self.tab(tab).map(|t| t.phase.clone()) else { + return; + }; + let session = phase.borrow().loaded().and_then(|server| { Url::parse(&raw) .ok() .and_then(|url| policy::session_id_of(&url, server)) }); - let decision = policy::new_window(&raw, &self.phase.borrow(), Via::Host); + let decision = policy::new_window(&raw, &phase.borrow(), Via::Host); let opened = match decision { - NewWindow::AppWindow(url) => self.build_popout(target, &url, true), + NewWindow::AppWindow(url) => self.build_popout(target, tab, &url, true), NewWindow::External(url) => self.open_external_budgeted(&url), NewWindow::Deny => false, }; if !opened { - self.tell_refused(window, session.as_deref()); + self.tell_refused(view, session.as_deref()); } } - HostCmd::CloseWindow => self.remove_window(window), - HostCmd::FocusWindow => { - if let Some(w) = self.windows.get(&window) { - w.window.set_visible(true); - w.window.set_minimized(false); - w.window.set_focus(); + HostCmd::CloseWindow => { + if let Owner::Popout(window, _) = owner { + self.remove_popout(window); } } + HostCmd::FocusWindow => match owner { + Owner::Popout(window, _) => { + if let Some(p) = self.popouts.get(&window) { + p.window.set_visible(true); + p.window.set_minimized(false); + p.window.set_focus(); + } + } + _ => { + self.select_tab_id(tab); + self.show_main_window(); + } + }, HostCmd::OpenExternal(raw) => self.open_external_raw(&raw), - HostCmd::SessionExpired => self.on_session_expired(window), + HostCmd::SessionExpired => self.on_session_expired(view, tab), } } - fn on_connect_cmd(&mut self, cmd: ConnectCmd) { + fn on_connect_cmd(&mut self, tab: TabId, cmd: ConnectCmd) { // The connect page is still live while a server loads, but only // Cancel and the settings make sense then. - let choosing = self.choosing(); - let loading = matches!(*self.phase.borrow(), Phase::Loading(_)); + let choosing = self.choosing(tab); + let loading = self + .tab(tab) + .is_some_and(|t| matches!(*t.phase.borrow(), Phase::Loading(_))); match cmd { - ConnectCmd::Ready => self.send_init(), + ConnectCmd::Ready => self.send_init(tab), ConnectCmd::Connect { url, username, @@ -1363,21 +2667,23 @@ impl App { remember, } => { if choosing { - self.start_probe(url, username, password, remember); + self.start_probe(tab, url, username, password, remember); } } ConnectCmd::Cancel => { - self.probe_generation += 1; - self.start_generation += 1; + if let Some(t) = self.tab_mut(tab) { + t.probe_generation += 1; + t.start_generation += 1; + } // Only a load that has not committed yet: once the server page // is up, a Cancel that was still on its way changes nothing. if loading { - self.show_connect_page(Some(LOAD_CANCELLED.into()), true); + self.show_connect_page(tab, Some(LOAD_CANCELLED.into()), true); } } ConnectCmd::StartServer { url } => { if choosing { - self.start_server(url); + self.start_server(tab, url); } } ConnectCmd::Forget { url } => { @@ -1387,16 +2693,18 @@ impl App { } self.config_dirty = true; } - self.to_connect_page(json!({ "type": "servers", "servers": self.config.servers })); + self.to_every_connect_page(json!({ "type": "servers", "servers": self.config.servers })); } ConnectCmd::SetAutoConnect(v) => { self.config.auto_connect = v; self.config_dirty = true; + self.to_every_connect_page(json!({ "type": "prefs", "autoConnect": v })); } ConnectCmd::SetStartFullscreen(v) => { self.config.start_fullscreen = v; self.menu.start_fullscreen.set_checked(v); self.config_dirty = true; + self.to_every_connect_page(json!({ "type": "prefs", "startFullscreen": v })); } ConnectCmd::OpenExternal(raw) => self.open_external_raw(&raw), ConnectCmd::LocalStatus { quick, deep } => { @@ -1406,12 +2714,15 @@ impl App { (false, false) => setup::Depth::Login, (false, true) => setup::Depth::Interactive, }; - self.in_background(move || json!({ "type": "localStatus", "status": setup::inspect(depth) })); + self.in_background( + tab, + move || json!({ "type": "localStatus", "status": setup::inspect(depth) }), + ); } } ConnectCmd::InstallLocal { network } => { if choosing { - self.in_background(move || { + self.in_background(tab, move || { let port = setup::pick_install_port(); let script = terminal_install::local_install_script(terminal_install::LocalInstall { port, network }); @@ -1424,7 +2735,7 @@ impl App { } ConnectCmd::InstallPrepare => { if choosing { - self.in_background(|| plan_message(gui_install::prepare())); + self.in_background(tab, || plan_message(gui_install::prepare())); } } ConnectCmd::InstallStart { @@ -1432,7 +2743,7 @@ impl App { ai_cli, } => { if choosing { - let send = self.page_sender(); + let send = self.page_sender(tab); let stale = self.next_install_reader(); std::thread::spawn(move || { let plan = gui_install::Plan { @@ -1452,7 +2763,7 @@ impl App { } ConnectCmd::InstallAttach => { if choosing { - self.attach_to_run(gui_install::Run::Codeman); + self.attach_to_run(tab, gui_install::Run::Codeman); } } ConnectCmd::InstallCancel => { @@ -1460,12 +2771,12 @@ impl App { } ConnectCmd::AgentPrepare => { if choosing { - self.in_background(|| plan_message(gui_install::prepare_agent())); + self.in_background(tab, || plan_message(gui_install::prepare_agent())); } } ConnectCmd::AgentStart { ai_cli } => { if choosing { - let send = self.page_sender(); + let send = self.page_sender(tab); let stale = self.next_install_reader(); std::thread::spawn(move || match gui_install::start_agent(&ai_cli) { Ok(()) => gui_install::follow(gui_install::Run::Agent, stale, send), @@ -1475,14 +2786,14 @@ impl App { } ConnectCmd::AgentAttach => { if choosing { - self.attach_to_run(gui_install::Run::Agent); + self.attach_to_run(tab, gui_install::Run::Agent); } } ConnectCmd::InstallRemote { target } => { if choosing { match terminal_install::SshTarget::parse(&target) { - Err(message) => self.to_connect_page(remote_install_failed(message)), - Ok(target) => self.in_background(move || { + Err(message) => self.to_connect_page(tab, remote_install_failed(message)), + Ok(target) => self.in_background(tab, move || { let script = terminal_install::remote_install_script(&target); match terminal_install::open_in_terminal(terminal_install::REMOTE_SCRIPT_NAME, &script) { Ok(()) => json!({ @@ -1497,33 +2808,36 @@ impl App { } } ConnectCmd::CopyInstallCommand => { - self.in_background(|| match terminal_install::copy_to_clipboard(setup::INSTALL_ONE_LINER) { - Ok(()) => json!({ "type": "copied" }), - Err(message) => remote_install_failed(message), + self.in_background(tab, || { + match terminal_install::copy_to_clipboard(setup::INSTALL_ONE_LINER) { + Ok(()) => json!({ "type": "copied" }), + Err(message) => remote_install_failed(message), + } }); } } } - /// Report a run that is going already to the page, from its first line. - fn attach_to_run(&self, run: gui_install::Run) { - let send = self.page_sender(); + /// Report a run that is going already to the tab's page, from its first line. + fn attach_to_run(&self, tab: TabId, run: gui_install::Run) { + let send = self.page_sender(tab); let stale = self.next_install_reader(); std::thread::spawn(move || gui_install::follow(run, stale, send)); } - /// The agent chooser lives on the connect page: from a Codeman page, this - /// leaves it the way Switch Server does. + /// The agent chooser lives on the connect page: the tab in front shows it + /// when that is a connect page, else a new tab does. fn add_agent(&mut self) { - if self.choosing() { - if let Some(main) = self.windows.get(&self.main_id) { - main.window.set_visible(true); - main.window.set_focus(); + let front = self.active_tab().map(|t| t.id).filter(|_| !self.overview_shown); + if let Some(tab) = front.filter(|t| self.choosing(*t)) { + self.show_main_window(); + self.to_connect_page(tab, json!({ "type": "addAgent" })); + return; + } + if let Some(tab) = self.new_tab() { + if let Some(t) = self.tab_mut(tab) { + t.add_agent_on_load = true; } - self.to_connect_page(json!({ "type": "addAgent" })); - } else { - self.add_agent_on_load = true; - self.show_connect_page(None, true); } } @@ -1536,55 +2850,127 @@ impl App { } /// Run `work` off the event loop (it may run a login shell, plutil, - /// Terminal or a probe) and hand what it returns to the connect page. - fn in_background(&self, work: impl FnOnce() -> serde_json::Value + Send + 'static) { - let send = self.page_sender(); + /// Terminal or a probe) and hand what it returns to the tab's connect page. + fn in_background(&self, tab: TabId, work: impl FnOnce() -> serde_json::Value + Send + 'static) { + let send = self.page_sender(tab); std::thread::spawn(move || send(work())); } - /// Hands a message to the connect page from another thread + /// Hands a message to a tab's connect page from another thread /// (UserEvent::ToConnectPage). - fn page_sender(&self) -> impl Fn(serde_json::Value) + Send + 'static { + fn page_sender(&self, tab: TabId) -> impl Fn(serde_json::Value) + Send + 'static { let proxy = self.proxy.clone(); move |msg| { - let _ = proxy.send_event(UserEvent::ToConnectPage(msg)); - } - } - - fn send_init(&mut self) { - let notice = self.notice.take(); - let add_agent = std::mem::take(&mut self.add_agent_on_load); - self.to_connect_page(json!({ - "type": "init", - "servers": self.config.servers, - "autoConnect": self.config.auto_connect, - "startFullscreen": self.config.start_fullscreen, - "version": VERSION, - "starting": self.start_running(), - "notice": notice, - "addAgent": add_agent, - })); + let _ = proxy.send_event(UserEvent::ToConnectPage { tab, msg }); + } } - /// Whether the connect page is up with no server chosen: the only time it - /// may run anything. - fn choosing(&self) -> bool { - matches!(*self.phase.borrow(), Phase::Connect) + fn send_init(&mut self, tab: TabId) { + let starting = self.start_running(tab); + let servers = self.config.servers.clone(); + let auto_connect = self.config.auto_connect; + let start_fullscreen = self.config.start_fullscreen; + let Some(t) = self.tab_mut(tab) else { + return; + }; + let notice = t.notice.take(); + let add_agent = std::mem::take(&mut t.add_agent_on_load); + let (target, connect_now) = match t.connect_to.take() { + Some((url, now)) => (Some(url), now), + None => (None, false), + }; + self.to_connect_page( + tab, + json!({ + "type": "init", + "servers": servers, + "autoConnect": auto_connect, + "startFullscreen": start_fullscreen, + "version": VERSION, + "starting": starting, + "notice": notice, + "addAgent": add_agent, + "target": target, + "connectNow": connect_now, + }), + ); + } + + /// Whether a tab's connect page is up with no server chosen: the only + /// time it may run anything. + fn choosing(&self, tab: TabId) -> bool { + self.tab(tab) + .is_some_and(|t| matches!(*t.phase.borrow(), Phase::Connect) && t.connect.is_some()) + } + + /// Whether a Start Codeman run is going whose result the tab's page will get. + fn start_running(&self, tab: TabId) -> bool { + self.tab(tab) + .is_some_and(|t| t.starting_server.is_some() && t.starting_server == Some(t.start_generation)) + } + + /// Why this tab cannot open `url` while the other tabs are as they are: + /// another tab has it already (that tab), or, before macOS 14, a server on + /// the same machine is open in another tab (a message). + fn conflict(&self, tab: TabId, url: &Url) -> Option> { + let others = self.tabs.iter().filter(|t| t.id != tab); + for other in others { + if other.shown_url().as_ref() == Some(url) { + return Some(Ok(other.id)); + } + // Only a server another tab has reached or is loading shares the + // cookie: a tab opened again at launch that has not connected + // yet must not keep this one from connecting, or two of them + // would keep each other out for good. + let live = other.phase.borrow().server().cloned(); + if !own_stores() && live.is_some_and(|theirs| tabs::same_host(&theirs, url)) { + return Some(Err(SAME_HOST_NEEDS_MACOS_14)); + } + } + None } - /// Whether a Start Codeman run is going whose result the page will get. - fn start_running(&self) -> bool { - self.starting_server == Some(self.start_generation) + /// Whether `tab` must not open `url` (`conflict`), having dealt with it: + /// one tab per server, so the tab that has it comes to the front (a new + /// tab with nothing in it gives way), or the page says why not. + fn refuse_conflict(&mut self, tab: TabId, url: &Url) -> bool { + match self.conflict(tab, url) { + Some(Ok(other)) => { + let blank = self.tab(tab).is_some_and(|t| t.home.is_none()); + self.select_tab_id(other); + if blank { + self.close_tab(tab); + } else { + self.to_connect_page(tab, json!({ "type": "error", "message": ALREADY_OPEN })); + } + true + } + Some(Err(message)) => { + self.to_connect_page(tab, json!({ "type": "error", "message": message })); + true + } + None => false, + } } - fn start_probe(&mut self, raw: String, username: Option, password: Option, remember: bool) { + fn start_probe( + &mut self, + tab: TabId, + raw: String, + username: Option, + password: Option, + remember: bool, + ) { let url = match policy::normalize_server_url(&raw) { Ok(u) => u, Err(message) => { - self.to_connect_page(json!({ "type": "error", "message": message })); + self.to_connect_page(tab, json!({ "type": "error", "message": message })); return; } }; + if self.refuse_conflict(tab, &url) { + return; + } let key = url.to_string(); let typed_user = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty()); let typed_password = password.filter(|p| !p.is_empty()); @@ -1598,8 +2984,11 @@ impl App { && user.is_some() && entry.is_some_and(|e| e.has_saved_password && e.username == user); - self.probe_generation += 1; - let generation = self.probe_generation; + let Some(t) = self.tab_mut(tab) else { + return; + }; + t.probe_generation += 1; + let generation = t.probe_generation; let proxy = self.proxy.clone(); let keychain = self.keychain.clone(); // Every Keychain call for the connect happens here, never on the @@ -1635,6 +3024,7 @@ impl App { (ProbeResult::Probed(outcome), update) }; let _ = proxy.send_event(UserEvent::ProbeDone(Box::new(ProbeDone { + tab, generation, url, result, @@ -1648,102 +3038,157 @@ impl App { fn on_probe_done(&mut self, done: ProbeDone) { let key = done.url.to_string(); - if done.generation != self.probe_generation || !self.choosing() { - // Cancelled, superseded, or a server is already loading. What the - // worker did to the Keychain stands all the same, so an entry - // whose password it deleted stops claiming one. + let current = self + .tab(done.tab) + .is_some_and(|t| t.probe_generation == done.generation); + if !current || !self.choosing(done.tab) { + // Cancelled, superseded, the tab closed, or a server is already + // loading. What the worker did to the Keychain stands all the + // same, so an entry whose password it deleted stops claiming one. self.note_deleted_passwords(&key, &done.deleted); return; } + let tab = done.tab; match done.result { ProbeResult::SavedPasswordUnreadable => { // needsPassword: the page shows the login fields, which the // This Mac tab does not have. - self.to_connect_page(json!({ "type": "error", "message": KEYCHAIN_UNREADABLE, "needsPassword": true })); + self.to_connect_page( + tab, + json!({ "type": "error", "message": KEYCHAIN_UNREADABLE, "needsPassword": true }), + ); + } + ProbeResult::Probed(ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) + if self.refuse_conflict(tab, &done.url) => + { + // Another tab got to the same server (or, before macOS 14, the + // same machine) while this probe was out. + self.note_deleted_passwords(&key, &done.deleted); } ProbeResult::Probed(outcome @ (ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired)) => { - let (opening, saved) = match outcome { - ProbeOutcome::Ok { version } => (json!({ "type": "opening", "version": version }), done.saved), + let password_change = outcome == ProbeOutcome::PasswordChangeRequired; + let (opening, saved, version) = match outcome { + ProbeOutcome::Ok { version } => ( + json!({ "type": "opening", "version": version }), + done.saved, + Some(version), + ), // The worker already deleted the one-time password and any // older one saved for the account (update_keychain). _ => ( json!({ "type": "opening", "version": null, "passwordChange": true }), false, + None, ), }; let orphans = self.config.remember(&key, done.username.as_deref(), saved); self.delete_accounts(&orphans); self.config_dirty = true; - self.to_connect_page(opening); - self.begin_loading(done.url, done.username, done.password); + if let Some(t) = self.tab_mut(tab) { + t.version = version; + t.status_login = !password_change; + } + self.to_connect_page(tab, opening); + self.begin_loading(tab, done.url, done.username, done.password); } ProbeResult::Probed(outcome) => { let can_start = outcome == ProbeOutcome::Refused && policy::can_start_locally(&done.url); - self.to_connect_page(json!({ - "type": "probeResult", - "url": done.url.as_str(), - "outcome": outcome, - "canStart": can_start, - })); - } - } - } - - /// Point the main window at the server. The cookies go first: a session - /// cookie an earlier login left (another user, a Forget, a password - /// changed since) would win over this login's Authorization header, since - /// Codeman checks the cookie first, and the first load mints a fresh one - /// from the header anyway. The load itself starts once the clear is done - /// (UserEvent::CookiesCleared). The connect page stays the live document - /// until the server page commits; on_events_cleared gives up after - /// LOAD_TIMEOUT, which counts from here, so it covers the clear too. - fn begin_loading(&mut self, url: Url, username: Option, password: Option) { - self.auth = basic_auth(username.as_deref(), password.as_deref()); + self.to_connect_page( + tab, + json!({ + "type": "probeResult", + "url": done.url.as_str(), + "outcome": outcome, + "canStart": can_start, + }), + ); + } + } + } + + /// Point a tab at the server: a server view of its own data store is built + /// hidden behind the connect page, the server's cookies are cleared from + /// that store first (an earlier login's session cookie, another user, a + /// Forget, a password changed since, would win over this login's + /// Authorization header, since Codeman checks the cookie first), and the + /// load starts once the clear is done (UserEvent::CookiesCleared). The + /// connect page stays in front until the server page commits; + /// on_events_cleared gives up after LOAD_TIMEOUT, which counts from here, + /// so it covers the clear too. + fn begin_loading(&mut self, tab: TabId, url: Url, username: Option, password: Option) { + let store = self.config.store_for(url.as_str()); + let Some(phase) = self.tab(tab).map(|t| t.phase.clone()) else { + return; + }; + // Before the view is built and loads: the navigation handler allows + // the server's origin only once the phase names it. + *phase.borrow_mut() = Phase::Loading(url.clone()); + let view = self.build_child(Part::Server, tab, Some(phase), Load::Nothing, store); + let auth = basic_auth(username.as_deref(), password.as_deref()); + let proxy = self.proxy.clone(); + let Some(t) = self.tab_mut(tab) else { + return; + }; + t.server = view; + t.store = store; + t.auth = auth; + t.loading_deadline = Some(Instant::now() + LOAD_TIMEOUT); + let generation = t.probe_generation; // Set before the load: its first response may already be a challenge. - self.set_login(username.zip(password).map(|(u, p)| (&url, u, p))); - // Before the load: the navigation handler allows the server's origin - // only once the phase names it. - *self.phase.borrow_mut() = Phase::Loading(url.clone()); - self.loading_deadline = Some(Instant::now() + LOAD_TIMEOUT); - clear_cookies_then_load(&self.proxy, self.probe_generation, url); + self.set_login(tab, username.zip(password).map(|(u, p)| (&url, u, p))); + self.tabs_changed(); + clear_cookies_then_load(&proxy, tab, generation, url, store); } - /// Hand the challenge handler a new login, as (server, username, - /// password), or none, and start a new login generation, so a recheck of - /// the one before it is ignored when it comes back. - fn set_login(&mut self, login: Option<(&Url, String, String)>) { - set_challenge_login(login.as_ref().map(|(server, u, p)| (*server, u.as_str(), p.as_str()))); - self.login = login.map(|(_, u, p)| (u, p)); - self.login_generation += 1; + /// Hand the challenge handler a tab's new login, as (server, username, + /// password), or none, and start a new login generation for the tab, so a + /// recheck or a status read of the one before it is ignored when it comes + /// back. + fn set_login(&mut self, tab: TabId, login: Option<(&Url, String, String)>) { + set_challenge_login( + tab, + login.as_ref().map(|(server, u, p)| (*server, u.as_str(), p.as_str())), + ); + if let Some(t) = self.tab_mut(tab) { + t.login = login.map(|(_, u, p)| (u, p)); + t.login_generation += 1; + } } - fn start_server(&mut self, raw: String) { - if let Some(running) = self.starting_server { + fn start_server(&mut self, tab: TabId, raw: String) { + let Some(t) = self.tab_mut(tab) else { + return; + }; + if let Some(running) = t.starting_server { // A start is still going, maybe one the page cancelled: its result // goes to the page that asked again. - self.start_generation = running; - self.to_connect_page(json!({ "type": "serverStarting" })); + t.start_generation = running; + self.to_connect_page(tab, json!({ "type": "serverStarting" })); return; } let url = match policy::normalize_server_url(&raw) { Ok(u) if policy::can_start_locally(&u) => u, Ok(_) => { - self.to_connect_page(json!({ "type": "error", "message": START_REFUSED })); + self.to_connect_page(tab, json!({ "type": "error", "message": START_REFUSED })); return; } Err(message) => { - self.to_connect_page(json!({ "type": "error", "message": message })); + self.to_connect_page(tab, json!({ "type": "error", "message": message })); return; } }; - self.start_generation += 1; - let generation = self.start_generation; - self.starting_server = Some(generation); - self.to_connect_page(json!({ "type": "serverStarting" })); + let Some(t) = self.tab_mut(tab) else { + return; + }; + t.start_generation += 1; + let generation = t.start_generation; + t.starting_server = Some(generation); + self.to_connect_page(tab, json!({ "type": "serverStarting" })); let proxy = self.proxy.clone(); std::thread::spawn(move || { let result = setup::start_local(&url); let _ = proxy.send_event(UserEvent::ServerStartDone { + tab, generation, url, result, @@ -1751,132 +3196,224 @@ impl App { }); } - /// Leave whatever the main window shows for the connect page: Switch - /// Server, a load that failed or was cancelled, or a refused login. - /// `notice` is shown by that page once it asks for its init. + /// Leave whatever a tab shows for its connect page: Connect This Tab to + /// Another Server, a load that failed or was cancelled, or a refused + /// login. `notice` is shown by that page once it asks for its init. /// - /// `front` is for what the user just did (Switch Server, Reload, Cancel): - /// the main window comes forward and takes the keyboard. A timeout or a - /// refused login can happen while the user works in another app, so then - /// the window stays where it is and the Dock icon bounces once instead, - /// unless the window is already the one in use. - fn show_connect_page(&mut self, notice: Option, front: bool) { - self.probe_generation += 1; - let popouts: Vec = self.windows.keys().copied().filter(|id| *id != self.main_id).collect(); - for id in popouts { - self.remove_window(id); - } + /// `front` is for what the user just did (the menu item, Cancel): the tab + /// comes forward and takes the keyboard. A timeout or a refused login can + /// happen while the user works elsewhere, so then the tab stays where it + /// is and the Dock icon bounces once instead, unless the window is the one + /// in use. + fn show_connect_page(&mut self, tab: TabId, notice: Option, front: bool) { + self.close_popouts_of(tab); + self.set_login(tab, None); + let needs_view = self.tab(tab).is_some_and(|t| t.connect.is_none()); + let phase = self.tab(tab).map(|t| t.phase.clone()); + let Some(phase) = phase else { + return; + }; // Before the load: the navigation handler allows the connect page // only while the phase says it is live. - *self.phase.borrow_mut() = Phase::Connect; - self.loading_deadline = None; - self.auth = None; - self.set_login(None); - self.notice = notice; - if let Some(main) = self.windows.get_mut(&self.main_id) { - main.session_reload_at = None; - let _ = main.webview.load_url(&format!("{}?manual=1", policy::CONNECT_PAGE)); - main.window.set_title(APP_NAME); - if front { - main.window.set_visible(true); - main.window.set_focus(); - } else if !main.window.is_focused() || main.window.is_minimized() { - main.window + *phase.borrow_mut() = Phase::Connect; + let built = needs_view + .then(|| { + self.build_child( + Part::Connect, + tab, + Some(phase), + Load::ConnectPage { manual: true }, + None, + ) + }) + .flatten(); + let Some(t) = self.tab_mut(tab) else { + return; + }; + t.probe_generation += 1; + t.loading_deadline = None; + t.auth = None; + t.notice = notice; + t.connect_to = None; + t.server = None; + t.watch = None; + t.seen = tabs::Seen::default(); + t.page_title = None; + match built { + Some(view) => t.connect = Some(view), + None => { + if let Some(connect) = &t.connect { + let _ = connect.webview.load_url(&connect_page_url(true)); + } + } + } + if front { + self.select_tab_id(tab); + self.show_main_window(); + } else { + let in_front = self.active_tab().is_some_and(|t| t.id == tab); + self.show_front(in_front && self.main_window.is_focused()); + if !self.main_window.is_focused() || self.main_window.is_minimized() { + self.main_window .request_user_attention(Some(UserAttentionType::Informational)); } } + self.save_tabs(); + self.sync_logo_watch(); + self.sync_keep_awake(); } fn show_main_window(&self) { - if let Some(main) = self.windows.get(&self.main_id) { - main.window.set_minimized(false); - main.window.set_visible(true); - main.window.set_focus(); - } - } - - /// The URL a server window reloads, handed to wry as is, and whether the - /// login goes with it. Main window: its committed page if that is - /// Codeman's own app shell, else the server root (a raw file that - /// replaced the window, for example). Pop-out: its committed page, or the - /// URL it was opened with when nothing committed yet. The login goes only - /// below the server's base path. - fn reload_target(&self, id: WindowId) -> Option<(String, bool)> { - let server = self.phase.borrow().loaded().cloned()?; - let w = self.windows.get(&id)?; - let target = match &w.kind { - Kind::Main => match &w.committed { - Some(current) if policy::trusted_frame(current, &server) => current.clone(), - _ => server.clone(), - }, - Kind::Popout { opened_url, .. } => w.committed.clone().or_else(|| Url::parse(opened_url).ok())?, + let w = &self.main_window; + w.set_minimized(false); + w.set_visible(true); + w.set_focus(); + } + + /// The URL a server view or pop-out reloads, handed to wry as is, and + /// whether the login goes with it. A tab's server view: its committed page + /// if that is Codeman's own app shell, else the server root (a raw file + /// that replaced the page, for example). A pop-out: its committed page, or + /// the URL it was opened with when nothing committed yet. The login goes + /// only below the server's base path. + fn reload_target(&self, view: ViewId) -> Option<(String, bool)> { + let (tab, target) = match self.owner(view)? { + Owner::Server(tab) => { + let t = self.tab(tab)?; + let server = t.phase.borrow().loaded().cloned()?; + let committed = t.server.as_ref()?.committed.clone(); + let target = match committed { + Some(current) if policy::trusted_frame(¤t, &server) => current, + _ => server, + }; + (tab, target) + } + Owner::Popout(window, tab) => { + let p = self.popouts.get(&window)?; + let target = p.committed.clone().or_else(|| Url::parse(&p.opened_url).ok())?; + (tab, target) + } + _ => return None, }; + let server = self.tab(tab)?.phase.borrow().loaded().cloned()?; let with_auth = policy::within_base(&target, &server); Some((policy::webkit_safe_url(&target), with_auth)) } - /// Load `url` in window `id`, with the login when `with_auth` and there is - /// one. - fn load_in(&self, id: WindowId, url: &str, with_auth: bool) { - let Some(w) = self.windows.get(&id) else { + /// Load `url` in `view`, with its tab's login when `with_auth` and there + /// is one. + fn load_in(&self, view: ViewId, url: &str, with_auth: bool) { + let tab = match self.owner(view) { + Some(Owner::Server(tab) | Owner::Popout(_, tab) | Owner::Connect(tab)) => tab, + _ => return, + }; + let Some(webview) = self.webview(view) else { return; }; - let result = match self.auth.as_deref().filter(|_| with_auth) { - Some(auth) => w.webview.load_url_with_headers(url, auth_headers(auth)), - None => w.webview.load_url(url), + let auth = self.tab(tab).and_then(|t| t.auth.clone()).filter(|_| with_auth); + let result = match auth { + Some(auth) => webview.load_url_with_headers(url, auth_headers(&auth)), + None => webview.load_url(url), }; if let Err(e) = result { eprintln!("codeman-desktop: could not load a page: {e}"); } } + /// Reload: the pop-out in use, or the tab in front. A tab that has not + /// reached its server yet starts over on its connect page. fn reload_focused(&mut self) { - let Some(id) = self.focused_window_id() else { + if let Some(window) = self.focused_popout() { + let view = self.popouts.get(&window).map(|p| p.view); + if let Some((view, (url, with_auth))) = view.and_then(|v| self.reload_target(v).map(|r| (v, r))) { + self.load_in(view, &url, with_auth); + } return; - }; - let loaded = self.phase.borrow().loaded().is_some(); - if id == self.main_id && !loaded { - // The connect page, or a server load that has not committed: - // start over on the connect page. - self.show_connect_page(None, true); + } + if !self.main_window.is_focused() { + return; + } + if self.overview_shown { + if let Some(o) = &self.overview { + let _ = o.webview.load_url(policy::OVERVIEW_PAGE); + } return; } - if let Some((url, with_auth)) = self.reload_target(id) { - self.load_in(id, &url, with_auth); + let Some(t) = self.active_tab() else { + return; + }; + let tab = t.id; + let server_view = t + .server + .as_ref() + .map(|v| v.id) + .filter(|_| t.kind() == tabs::Kind::Server); + match server_view { + Some(view) => { + if let Some((url, with_auth)) = self.reload_target(view) { + self.load_in(view, &url, with_auth); + } + } + None => self.show_connect_page(tab, None, true), } } - /// The page in `window` got a 401 from its own server (the HOST_SHIM + /// The page in `view` got a 401 from its own server (the HOST_SHIM /// fallback for when the challenge handler did not recover it): reload it /// once with the login, never again within SESSION_RELOAD_COOLDOWN. - fn on_session_expired(&mut self, window: WindowId) { - if self.auth.is_none() { + fn on_session_expired(&mut self, view: ViewId, tab: TabId) { + if self.tab(tab).is_none_or(|t| t.auth.is_none()) { return; } let now = Instant::now(); - let recent = self.windows.get(&window).is_some_and(|w| { - w.session_reload_at - .is_some_and(|at| now.saturating_duration_since(at) < SESSION_RELOAD_COOLDOWN) - }); - if recent { + let recent = + |at: Option| at.is_some_and(|at| now.saturating_duration_since(at) < SESSION_RELOAD_COOLDOWN); + let last = match self.owner(view) { + Some(Owner::Server(_)) => self.child_view_mut(view).map(|v| v.session_reload_at), + Some(Owner::Popout(window, _)) => self.popouts.get(&window).map(|p| p.session_reload_at), + _ => None, + }; + let Some(last) = last else { + return; + }; + if recent(last) { return; } - let Some((url, true)) = self.reload_target(window) else { + let Some((url, true)) = self.reload_target(view) else { return; }; - if let Some(w) = self.windows.get_mut(&window) { - w.session_reload_at = Some(now); + match self.owner(view) { + Some(Owner::Server(_)) => { + if let Some(v) = self.child_view_mut(view) { + v.session_reload_at = Some(now); + } + } + Some(Owner::Popout(window, _)) => { + if let Some(p) = self.popouts.get_mut(&window) { + p.session_reload_at = Some(now); + } + } + _ => {} } - self.load_in(window, &url, true); + self.load_in(view, &url, true); } - /// Open one of Codeman's pop-out pages in an app window. False when it - /// did not open: the pop-out limit, no server loaded, a URL that is not a - /// pop-out page, or a window or web view that failed to build. `focused`: - /// the window takes the keyboard; otherwise it comes to the front without - /// it. - fn build_popout(&mut self, target: &EventLoopWindowTarget, url: &Url, focused: bool) -> bool { - let Some(server) = self.phase.borrow().loaded().cloned() else { + /// Open one of Codeman's pop-out pages of a tab's server in an app window. + /// False when it did not open: the pop-out limit, no server loaded, a URL + /// that is not a pop-out page, or a window or web view that failed to + /// build. `focused`: the window takes the keyboard; otherwise it comes to + /// the front without it. + fn build_popout( + &mut self, + target: &EventLoopWindowTarget, + tab: TabId, + url: &Url, + focused: bool, + ) -> bool { + let Some(t) = self.tab(tab) else { + return false; + }; + let Some(server) = t.phase.borrow().loaded().cloned() else { return false; }; // The callers only pass what policy::new_window made an AppWindow; @@ -1885,26 +3422,26 @@ impl App { if !policy::is_popout_target(url, &server) { return false; } - let popouts = self - .windows - .values() - .filter(|w| matches!(w.kind, Kind::Popout { .. })) - .count(); + let popouts = self.popouts.len(); if popouts >= MAX_POPOUTS { return false; } + let phase = t.phase.clone(); + let store = t.store; let opened_url = policy::webkit_safe_url(url); - let auth = self.auth.clone().filter(|_| policy::within_base(url, &server)); + let auth = t.auth.clone().filter(|_| policy::within_base(url, &server)); // tao centres a window that is given no position, so every pop-out // would open exactly on top of the one before. Each one steps off the // window in use instead. - let anchor = self.focused_window_id().unwrap_or(self.main_id); - let position = self.windows.get(&anchor).and_then(|w| { - let p = placed_position(&w.window) - .ok()? - .to_logical::(w.window.scale_factor()); + let anchor = self + .focused_popout() + .and_then(|w| self.popouts.get(&w)) + .map(|p| &p.window) + .unwrap_or(&self.main_window); + let position = placed_position(anchor).ok().map(|p| { + let p = p.to_logical::(anchor.scale_factor()); let step = POPOUT_STEP * (1 + popouts % 8) as f64; - Some(LogicalPosition::new(p.x + step, p.y + step)) + LogicalPosition::new(p.x + step, p.y + step) }); let mut builder = window_builder() .with_inner_size(LogicalSize::new(POPOUT_SIZE.0, POPOUT_SIZE.1)) @@ -1919,11 +3456,22 @@ impl App { return false; } }; - let load = Load::Server { - url: &opened_url, - auth: auth.as_deref(), + let view = self.next_view_id(); + let spec = ViewSpec { + id: view, + part: Part::Popout, + tab, + phase: Some(phase), + load: Load::Server { + url: &opened_url, + auth: auth.as_deref(), + }, + store, + bounds: None, + visible: true, + focused, }; - let webview = match build_webview(&window, false, focused, load, &self.proxy, &self.phase) { + let webview = match build_webview(&window, spec, &self.proxy) { Ok(webview) => { // Still the one that takes the keys once the user comes to // this window: first responder in its own window only. @@ -1937,22 +3485,31 @@ impl App { return false; } }; - let kind = Kind::Popout { - opened_url, - hidden_for_download: false, - }; - self.windows.insert(window.id(), AppWindow::new(webview, window, kind)); + self.popouts.insert( + window.id(), + Popout { + webview, + window, + view, + tab, + opened_url, + hidden_for_download: false, + committed: None, + session_reload_at: None, + gone_at: None, + }, + ); true } - /// Tell the page in `window` that the window it asked for through + /// Tell the page in `view` that the window it asked for through /// CodemanHost.openWindow was not opened (assets::refused_script), with /// the session id when that was a session pop-out, so the dashboard takes /// the session's tab back. Only for that request, which parse_ipc takes /// from Codeman's own app shell alone. - fn tell_refused(&self, window: WindowId, session_id: Option<&str>) { - if let Some(w) = self.windows.get(&window) { - let _ = w.webview.evaluate_script(&assets::refused_script(session_id)); + fn tell_refused(&self, view: ViewId, session_id: Option<&str>) { + if let Some(webview) = self.webview(view) { + let _ = webview.evaluate_script(&assets::refused_script(session_id)); } } @@ -1979,43 +3536,44 @@ impl App { } } - /// Close a pop-out (never the main window) and forget its downloads. - fn remove_window(&mut self, id: WindowId) { - if id == self.main_id { - return; + /// Close a pop-out and forget its downloads. + fn remove_popout(&mut self, window: WindowId) { + if let Some(p) = self.popouts.remove(&window) { + self.downloads.retain(|(view, _), _| *view != p.view); } - self.windows.remove(&id); - self.downloads.retain(|(window, _), _| *window != id); } - /// The app window that is key. None while a window that is not ours is - /// (the About panel, a detached inspector): Reload and Developer Tools - /// then do nothing rather than act on a window behind it. - fn focused_window_id(&self) -> Option { - self.windows + /// The pop-out that is key, if one is. + fn focused_popout(&self) -> Option { + self.popouts .iter() - .find(|(_, w)| w.window.is_focused()) + .find(|(_, p)| p.window.is_focused()) .map(|(id, _)| *id) } - /// Message the connect page (only while it is the main window's live - /// document). - fn to_connect_page(&self, msg: serde_json::Value) { - if !self.phase.borrow().connect_page_live() { + /// Message a tab's connect page (only while it is that tab's live page). + fn to_connect_page(&self, tab: TabId, msg: serde_json::Value) { + let Some(t) = self.tab(tab) else { + return; + }; + if !t.phase.borrow().connect_page_live() { return; } - if let Some(main) = self.windows.get(&self.main_id) { - let _ = main.webview.evaluate_script(&format!( - "window.__codemanDesktop && window.__codemanDesktop.onNative({msg})" - )); + if let Some(connect) = &t.connect { + let _ = connect + .webview + .evaluate_script(&assets::native_message_script("__codemanDesktop", &msg)); + } + } + + fn to_every_connect_page(&self, msg: serde_json::Value) { + for t in &self.tabs { + self.to_connect_page(t.id, msg.clone()); } } fn record_main_frame(&mut self) { - let Some(main) = self.windows.get(&self.main_id) else { - return; - }; - let w = &main.window; + let w = &self.main_window; let fullscreen = w.fullscreen().is_some(); let mut frame = self.config.window.unwrap_or(WindowFrame { x: 0.0, @@ -2033,24 +3591,12 @@ impl App { frame.x = p.x; frame.y = p.y; } - // Not tao's inner_size(): wry replaced the window's content view, - // and tao reads the size of its own view, which stays at the - // creation size. On macOS not the web view's bounds either: a - // docked Web Inspector shrinks the web view, not the window. The - // content size is in points already. - #[cfg(target_os = "macos")] - let size = macos::content_size(w); - #[cfg(not(target_os = "macos"))] - let size = None; - let size = size.or_else(|| { - let bounds = main.webview.bounds().ok()?; - let s = bounds.size.to_logical::(sf); - Some((s.width, s.height)) - }); - if let Some((width, height)) = size { - frame.width = width; - frame.height = height; - } + // The content size in points, which (under the transparent title + // bar) is the whole window, and is what `with_inner_size` takes + // back at the next launch. + let (width, height) = content_size(w); + frame.width = width; + frame.height = height; } // While the launch's full screen may still be retried, a window not // in full screen yet keeps the flag that was saved (or asked for). diff --git a/packages/desktop/src/menu.rs b/packages/desktop/src/menu.rs index 3103ab25e..8db60fd78 100644 --- a/packages/desktop/src/menu.rs +++ b/packages/desktop/src/menu.rs @@ -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-`: the tab ⌘n selects (tabs::index_of_key). + pub const TAB_KEY_PREFIX: &str = "tab-key-"; + /// `tab-menu::`: 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-` item. +pub fn tab_key(id: &str) -> Option { + 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 { + 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, + /// 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 { let bar = Menu::new(); let about = PredefinedMenuItem::about( @@ -243,6 +386,52 @@ pub fn build_menu(config: &Config) -> muda::Result { 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 { &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 { &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 { 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); + } +} diff --git a/packages/desktop/src/policy.rs b/packages/desktop/src/policy.rs index 905a56923..576ea3ce7 100644 --- a/packages/desktop/src/policy.rs +++ b/packages/desktop/src/policy.rs @@ -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 { + if body.len() > MAX_MESSAGE_LEN || !is_bundled_page(frame_url, STRIP_PAGE) { + return None; + } + Some(match serde_json::from_str::(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 { + if body.len() > MAX_MESSAGE_LEN || !is_bundled_page(frame_url, OVERVIEW_PAGE) { + return None; + } + Some(match serde_json::from_str::(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 { @@ -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())); + } } diff --git a/packages/desktop/src/tabs.rs b/packages/desktop/src/tabs.rs new file mode 100644 index 000000000..bcb52ad0b --- /dev/null +++ b/packages/desktop/src/tabs.rs @@ -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) -> String { + let used: Vec = 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 { + 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> { + 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 { + (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 { + 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 { + 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, + /// 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, + 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 { + 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 { + 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, overview: bool, fullscreen: bool) -> serde_json::Value { + let len = tabs.len(); + let list: Vec> = 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, + version: Option<&'a str>, + kind: Kind, + offline: bool, + refused: bool, + sessions: Vec>, + 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> = 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> = 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) -> 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/"))); + } +} diff --git a/test/desktop-connect-page.browser.test.ts b/test/desktop-connect-page.browser.test.ts index 4d91179e0..a14ff735b 100644 --- a/test/desktop-connect-page.browser.test.ts +++ b/test/desktop-connect-page.browser.test.ts @@ -164,6 +164,46 @@ async function everyState(browser: Browser) { await native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, version: '0.1.0' }); check('manual=1 suppresses auto-connect', (await posted()).length === 1, await posted()); + // 3b. A server tab opened again at launch: its own server, connected to on its own + // (manual=1 holds for any other server), and only shown when connectNow is off. + const saved = [ + { url: 'http://127.0.0.1:3000/' }, + { url: 'https://box.tail.ts.net/', username: 'admin', hasSavedPassword: true }, + ]; + await page.goto('http://app.test/index.html?manual=1'); + await native({ + type: 'init', + servers: saved, + autoConnect: true, + version: '0.1.0', + target: saved[1].url, + connectNow: true, + }); + p = await posted(); + check( + 'a restored tab connects to its own server, with its saved login', + p.length === 2 && p[1].cmd === 'connect' && p[1].url === 'https://box.tail.ts.net/' && p[1].username === 'admin', + p + ); + check('and shows it on the Another machine tab', await visible('#form')); + await page.goto('http://app.test/index.html?manual=1'); + await native({ + type: 'init', + servers: saved, + autoConnect: false, + version: '0.1.0', + target: 'http://192.168.1.40:3000/', + connectNow: false, + }); + check('connectNow off only fills the address in', (await posted()).length === 1, await posted()); + check( + 'a server not in the list still fills the address', + (await page.inputValue('#url')) === 'http://192.168.1.40:3000/' + ); + // Back to step 3's page, which the steps below build on. + await page.goto('http://app.test/index.html?manual=1'); + await native({ type: 'init', servers: [{ url: 'http://127.0.0.1:3000/' }], autoConnect: true, version: '0.1.0' }); + // 4. Needs login -> login fields, then a typed password goes out. await native({ type: 'probeResult', diff --git a/test/desktop-tabs-pages.browser.test.ts b/test/desktop-tabs-pages.browser.test.ts new file mode 100644 index 000000000..6fa098a32 --- /dev/null +++ b/test/desktop-tabs-pages.browser.test.ts @@ -0,0 +1,850 @@ +/** + * @fileoverview The Codeman Desktop server-tab pages (packages/desktop/assets/tabs.html, the strip + * across the top of the window, and packages/desktop/assets/overview.html, "All servers"), driven + * in a real browser. + * + * Both pages ask the native side for things with `window.ipc.postMessage(JSON)` and hear back + * through `window.__codemanTabs.onNative(msg)` / `window.__codemanOverview.onNative(msg)`: here a + * stub records the first and the test plays the second. The pages are served with the + * Content-Security-Policy the app serves them with, read out of packages/desktop/src/assets.rs, so + * a script that policy would block fails here as it would in the app. + * + * Chromium stands in for the app's WKWebView, so what this pins is the pages' own logic and layout, + * not WebKit's rendering. + * + * Each test is one ordered walk (a later step builds on the state the earlier ones left), so a walk + * collects every check that fails and reports them together. + * + * Browser-driven, so excluded from `npm test` (config/test-suites.ts). Run locally: + * npm run test:browser -- test/desktop-tabs-pages.browser.test.ts + * + * Port: N/A (the pages are served by request interception; nothing listens). + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const PKG = resolve(import.meta.dirname, '../packages/desktop'); +const PAGES: Record = { + 'tabs.html': readFileSync(resolve(PKG, 'assets/tabs.html'), 'utf8'), + 'overview.html': readFileSync(resolve(PKG, 'assets/overview.html'), 'utf8'), +}; +const assetsRs = readFileSync(resolve(PKG, 'src/assets.rs'), 'utf8'); +// The app's real CSP (assets::CSP, shared by every bundled page). +const CSP = /const CSP: &str = "([^"]*)";/.exec(assetsRs)![1]; + +// The native side as the page sees it: `ipc.postMessage` records what the page asks for. +const IPC_STUB = `window.__posted = []; Object.defineProperty(window, 'ipc', { value: Object.freeze({ postMessage: function (s) { window.__posted.push(JSON.parse(s)); } }) });`; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +declare global { + interface Window { + __posted: any[]; + __codemanTabs: { onNative(msg: unknown): void }; + __codemanOverview: { onNative(msg: unknown): void }; + ipc: { postMessage(message: string): void }; + } +} + +/** The checks of the walk under way that did not hold, as `name -> what was seen`. */ +const failed: string[] = []; +function check(name: string, cond: unknown, extra?: unknown) { + if (!cond) failed.push(extra === undefined ? name : `${name} -> ${JSON.stringify(extra)}`); +} +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +const HOSTILE = ''; +const BAD_COLOR = 'red;background:url(x)'; +const FALLBACK_RGB = 'rgb(148, 163, 184)'; + +interface Opened { + page: Page; + close(): Promise; + /** What the page posted since the last call, then forgets it. */ + take(): Promise; + native(msg: unknown): Promise; + errors: string[]; + cspViolations: string[]; + dialogs: string[]; +} + +async function open( + browser: Browser, + file: 'tabs.html' | 'overview.html', + viewport: { width: number; height: number }, + reducedMotion: 'reduce' | 'no-preference' = 'no-preference' +): Promise { + const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1, reducedMotion }); + const page = await ctx.newPage(); + const errors: string[] = []; + const cspViolations: string[] = []; + const dialogs: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => { + if (/Content Security Policy/i.test(m.text())) cspViolations.push(m.text()); + }); + page.on('dialog', (d) => { + dialogs.push(d.message()); + void d.dismiss(); + }); + await page.route('http://app.test/**', (route) => + route.fulfill({ + body: PAGES[file], + contentType: 'text/html; charset=utf-8', + headers: { 'Content-Security-Policy': CSP }, + }) + ); + await page.addInitScript(IPC_STUB); + await page.goto('http://app.test/' + file); + const global = file === 'tabs.html' ? '__codemanTabs' : '__codemanOverview'; + return { + page, + close: () => ctx.close(), + take: () => page.evaluate(() => window.__posted.splice(0)), + native: (msg) => page.evaluate(([g, m]) => (window as any)[g as string].onNative(m), [global, msg] as const), + errors, + cspViolations, + dialogs, + }; +} + +// --------------------------------------------------------------------------------------------- +// The strip + +function tab(id: number, name: string, extra: Record = {}, status: Record = {}) { + return { + id, + name, + color: '#22d3ee', + active: false, + kind: 'server', + local: false, + url: `http://${name.replace(/\W/g, '') || 'host'}.lan:3000`, + key: id <= 9 ? id : null, + status: { working: 0, waiting: 0, needsYou: 0, offline: false, ...status }, + ...extra, + }; +} + +const STRIP_TABS = [ + tab(1, 'This Mac', { color: '#a78bfa', local: true, url: 'http://127.0.0.1:3000' }, { working: 2 }), + tab(2, 'atlas', { active: true }), + tab( + 3, + 'studio', + { color: '#34d399', url: 'https://studio.example.ts.net' }, + { + needsYou: 1, + waiting: 1, + working: 3, + offline: true, + } + ), + tab(4, 'build-01', { color: '#f472b6' }, { waiting: 1, working: 2, offline: true }), + tab(5, 'pi-lab', { color: BAD_COLOR }, { offline: true }), + tab(6, HOSTILE, { color: '#64748b' }, { working: 1, offline: true }), + tab(7, 'New server tab', { kind: 'connect', url: null }), + tab(8, 'loading.lan', { kind: 'loading' }), +]; + +async function stripWalk(browser: Browser) { + const o = await open(browser, 'tabs.html', { width: 1280, height: 38 }); + const { page, take, native } = o; + const sel = (id: number, inner = '') => `.tab[data-id="${id}"]${inner ? ' ' + inner : ''}`; + const box = async (s: string) => (await page.locator(s).boundingBox())!; + + // 1. Before the first state: only the two buttons. + check('posts ready on load', same(await take(), [{ cmd: 'ready' }])); + check( + 'before a state: no tabs, both buttons shown', + (await page.locator('[role=tab]').count()) === 0 && + (await page.isVisible('#overview')) && + (await page.isVisible('#new')) + ); + check('the strip is 38px tall', (await box('#strip')).height === 38, await box('#strip')); + check( + 'room for the traffic lights', + (await page.evaluate(() => getComputedStyle(document.getElementById('strip')!).paddingLeft)) === '78px' + ); + + // 2. A state. + await native({ type: 'state', tabs: STRIP_TABS, overviewActive: false, fullscreen: false }); + check('one tab per entry', (await page.locator('[role=tab]').count()) === 8); + check('a tablist owns them', (await page.locator('[role=tablist] [role=tab]').count()) === 8); + const selected = await page.$$eval('[role=tab]', (els) => els.map((e) => e.getAttribute('aria-selected'))); + check( + 'only the active tab is aria-selected', + same(selected, ['false', 'true', 'false', 'false', 'false', 'false', 'false', 'false']), + selected + ); + check('the active tab is marked', (await page.locator('.tab.active').getAttribute('data-id')) === '2'); + const active = await page.$eval(sel(2), (e) => { + const cs = getComputedStyle(e); + return { bg: cs.backgroundColor, shadow: cs.boxShadow, color: cs.color }; + }); + check( + 'the active tab joins the page and carries its colour stripe', + active.bg === 'rgb(11, 12, 15)' && + active.shadow.includes('rgb(34, 211, 238)') && + active.color === 'rgb(232, 234, 237)', + active + ); + check( + 'an inactive tab is transparent', + (await page.$eval(sel(1), (e) => getComputedStyle(e).backgroundColor)) === 'rgba(0, 0, 0, 0)' + ); + + // Badge precedence. + const badge = (id: number) => + page.$eval(sel(id, '.badge'), (e) => ({ + cls: e.className, + text: e.textContent, + shown: getComputedStyle(e).display, + })); + const b3 = await badge(3); + check('needs you beats waiting, working and offline', b3.cls === 'badge needs' && b3.text === '1', b3); + check( + 'the needs-you pill is red', + (await page.$eval(sel(3, '.badge'), (e) => getComputedStyle(e).backgroundColor)) === 'rgb(248, 113, 113)' + ); + check('waiting beats working and offline', (await badge(4)).cls === 'badge waiting', await badge(4)); + check('working beats offline', (await badge(6)).cls === 'badge working', await badge(6)); + check('offline alone: the dashed circle', (await badge(5)).cls === 'badge offline', await badge(5)); + check('nothing going on: no badge', (await badge(2)).shown === 'none', await badge(2)); + check( + 'an offline tab has its name dimmed, a needs-you one does not', + (await page.$eval(sel(5, '.name'), (e) => getComputedStyle(e).color)) === 'rgb(107, 112, 121)' && + (await page.$eval(sel(3, '.name'), (e) => getComputedStyle(e).color)) !== 'rgb(107, 112, 121)' + ); + check( + 'a working ring turns', + (await page.$eval(sel(1, '.badge'), (e) => getComputedStyle(e).animationName)) === 'spin' + ); + + // Icons. + const icons = await page.$$eval('.tab .ico', (els) => + els.map((e) => (e.querySelector('.spin') ? 'spin' : (e.querySelector('svg')?.childElementCount ?? 0))) + ); + check( + 'laptop for this Mac, rack for a server, + for new, spinner while loading', + same(icons, [2, 3, 3, 3, 3, 3, 1, 'spin']), + icons + ); + check( + 'the + of a new tab is blue', + (await page.$eval(sel(7, '.ico'), (e) => getComputedStyle(e).color)) === 'rgb(96, 165, 250)' + ); + + // Untrusted text and colours. + check('a hostile name is text', (await page.textContent(sel(6, '.name'))) === HOSTILE); + check('no element came out of it', (await page.locator('img').count()) === 0); + check( + 'a bad colour falls back', + (await page.$eval(sel(5, '.ico'), (e) => getComputedStyle(e).color)) === FALLBACK_RGB && + !((await page.getAttribute(sel(5), 'style')) || '').includes('url'), + await page.getAttribute(sel(5), 'style') + ); + + // Labels and the tooltip. + check( + 'aria-label carries the status', + (await page.getAttribute(sel(3, '[role=tab]'), 'aria-label'))?.startsWith('studio, 1 needs you') + ); + const title3 = await page.getAttribute(sel(3, '[role=tab]'), 'title'); + check( + 'the tooltip: name and shortcut, URL, one line per status', + title3 === 'studio ⌘3\nhttps://studio.example.ts.net\n1 needs you\n1 waiting\n3 working\nNot answering', + title3 + ); + check( + 'the loading tab says it is connecting', + (await page.getAttribute(sel(8, '[role=tab]'), 'aria-label')) === 'loading.lan, Connecting' + ); + check( + 'icon buttons are named', + (await page.getAttribute('#overview', 'aria-label')) === 'All servers' && + (await page.getAttribute('#new', 'aria-label')) === 'New server tab' && + (await page.getAttribute(sel(2, '.close'), 'aria-label')) === 'Close tab' + ); + check( + 'nothing in the strip takes the keyboard focus', + await page.$$eval('button', (els) => els.every((e) => e.getAttribute('tabindex') === '-1')) + ); + + // 3. Clicks. + await take(); + await page.click(sel(1)); + check('a click selects', same(await take(), [{ cmd: 'select', id: 1 }])); + check( + 'the strip does not take the focus on a click', + await page.evaluate(() => document.activeElement === document.body) + ); + check('the close button shows on the active tab', await page.isVisible(sel(2, '.close'))); + check('and not on an inactive one', !(await page.isVisible(sel(4, '.close')))); + await page.hover(sel(4)); + check('hovering shows it', await page.isVisible(sel(4, '.close'))); + await page.click(sel(4, '.close')); + check('the close button closes, and only that', same(await take(), [{ cmd: 'close', id: 4 }])); + await page.click(sel(3), { button: 'middle' }); + check('a middle click closes', same(await take(), [{ cmd: 'close', id: 3 }])); + await page.click(sel(5), { button: 'right' }); + check('a right click asks for the menu', same(await take(), [{ cmd: 'menu', id: 5 }])); + // WebKit's own menu never shows on the strip: every contextmenu is cancelled, and only a + // tab asks for one. Seen from window, after the page's own listener on document. + await page.evaluate(() => { + (window as any).__menus = []; + window.addEventListener('contextmenu', (e) => (window as any).__menus.push(e.defaultPrevented)); + }); + await page.click('#new', { button: 'right' }); + await page.click('#overview', { button: 'right' }); + await page.mouse.click(30, 19, { button: 'right' }); + await page.click(sel(2, '.close'), { button: 'right' }); + check( + 'a right click on a button or empty strip asks for nothing; on a close button, the tab menu', + same(await take(), [{ cmd: 'menu', id: 2 }]) + ); + check( + 'and none of them shows the WebKit menu', + same(await page.evaluate(() => (window as any).__menus), [true, true, true, true]), + await page.evaluate(() => (window as any).__menus) + ); + await page.click('#new'); + check('+ opens a new tab', same(await take(), [{ cmd: 'new' }])); + await page.click('#overview'); + check('the grid button asks for the overview', same(await take(), [{ cmd: 'overview' }])); + + // 4. Empty strip: window drag and zoom. + const fill = await box('#fill'); + check('there is empty strip to drag by', fill.width >= 48, fill); + const fx = fill.x + fill.width / 2; + await page.mouse.click(fx, 19); + check('a press on empty strip drags the window', same(await take(), [{ cmd: 'drag' }])); + await page.mouse.click(30, 19); + check('so does a press under the traffic lights', same(await take(), [{ cmd: 'drag' }])); + await page.waitForTimeout(600); + await page.mouse.dblclick(fx, 19); + check( + 'a double click zooms on its second press (one drag, for the first)', + same(await take(), [{ cmd: 'drag' }, { cmd: 'zoom' }]) + ); + const t1 = await box(sel(1)); + await page.mouse.move(t1.x + t1.width / 2, 22); + await page.mouse.down(); + await page.mouse.up(); + check('a press on a tab is not a window drag', same(await take(), [{ cmd: 'select', id: 1 }])); + + // 5. Overview on, fullscreen. + await native({ type: 'state', tabs: STRIP_TABS, overviewActive: true, fullscreen: true }); + check( + 'the grid button is lit while the overview shows', + (await page.$eval('#overview', (e) => getComputedStyle(e).backgroundColor)) === 'rgb(37, 99, 235)' && + (await page.getAttribute('#overview', 'aria-pressed')) === 'true' + ); + check( + 'fullscreen has no traffic lights to make room for', + (await page.evaluate(() => getComputedStyle(document.getElementById('strip')!).paddingLeft)) === '10px' + ); + + // 6. Rename. + await native({ type: 'state', tabs: STRIP_TABS, overviewActive: false, fullscreen: false }); + await take(); + await page.dblclick(sel(2)); + const input = sel(2, 'input.rename'); + check( + 'a double click on a tab starts a rename', + (await page.isVisible(input)) && + (await page.inputValue(input)) === 'atlas' && + (await page.evaluate(() => document.activeElement?.className)) === 'rename' + ); + await page.keyboard.type(' prod box '); + await page.keyboard.press('Enter'); + check( + 'Enter commits it, trimmed (the clicks of the double click selected)', + same(await take(), [ + { cmd: 'select', id: 2 }, + { cmd: 'select', id: 2 }, + { cmd: 'editing' }, + { cmd: 'rename', id: 2, name: 'prod box' }, + ]) + ); + check( + 'and shows it at once', + !(await page.isVisible(input)) && (await page.textContent(sel(2, '.name'))) === 'prod box' + ); + await native({ type: 'rename', id: 1 }); + check('the native menu starts a rename too', (await page.inputValue(sel(1, 'input.rename'))) === 'This Mac'); + await page.keyboard.type('zzz'); + await page.keyboard.press('Escape'); + const cancelled = await take(); + check( + 'Escape cancels, and still says the rename is over (with the old name)', + same(cancelled, [{ cmd: 'editing' }, { cmd: 'rename', id: 1, name: 'This Mac' }]) && + (await page.locator('input.rename').count()) === 0 && + (await page.textContent(sel(1, '.name'))) === 'This Mac', + cancelled + ); + await native({ type: 'rename', id: 1 }); + await page.keyboard.press('Enter'); + check( + 'an unchanged name posts the old name', + same(await take(), [{ cmd: 'editing' }, { cmd: 'rename', id: 1, name: 'This Mac' }]) + ); + await native({ type: 'rename', id: 1 }); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Enter'); + check( + 'an empty name asks for the default', + same(await take(), [{ cmd: 'editing' }, { cmd: 'rename', id: 1, name: '' }]) + ); + await native({ type: 'rename', id: 3 }); + check('a rename asks the native side for the keyboard', same(await take(), [{ cmd: 'editing' }])); + await page.keyboard.type('x'.repeat(50)); + check('at most 40 characters', (await page.inputValue(sel(3, 'input.rename'))).length === 40); + await page.click(sel(3, 'input.rename'), { button: 'right' }); + check( + 'a right click in the rename field shows no menu at all and keeps the rename going', + (await page.evaluate(() => (window as any).__menus.at(-1))) === true && + (await page.locator(sel(3, 'input.rename')).count()) === 1 && + (await take()).length === 0 + ); + // A state arriving mid-rename (statuses change all the time) keeps the field and its text. + const moved = [STRIP_TABS[2], ...STRIP_TABS.filter((t) => t.id !== 3)]; + await native({ type: 'state', tabs: moved, overviewActive: false, fullscreen: false }); + check( + 'a state mid-rename keeps the field, its text and the focus', + (await page.inputValue(sel(3, 'input.rename'))) === 'x'.repeat(40) && + (await page.evaluate(() => document.activeElement?.className)) === 'rename' && + (await page.$eval('#tabs', (e) => e.firstElementChild?.getAttribute('data-id'))) === '3' + ); + await page.mouse.click(fx, 19); + const committed = await take(); + check( + 'a click elsewhere commits it', + same(committed, [{ cmd: 'rename', id: 3, name: 'x'.repeat(40) }, { cmd: 'drag' }]), + committed + ); + + // 7. Drag to reorder. + const four = [tab(11, 'one', { active: true }), tab(12, 'two'), tab(13, 'three'), tab(14, 'four')]; + await native({ type: 'state', tabs: four, overviewActive: false, fullscreen: false }); + await take(); + const widths = await page.$$eval('.tab', (els) => els.map((e) => e.getBoundingClientRect().width)); + check( + 'a few tabs are 200px wide', + widths.every((w) => w === 200), + widths + ); + const centre = async (id: number) => { + const b = await box(sel(id)); + return b.x + b.width / 2; + }; + await page.mouse.move(await centre(11), 22); + await page.mouse.down(); + await page.mouse.move((await centre(11)) + 2, 22); + await page.mouse.up(); + check('a press that moves under 4px is still a click', same(await take(), [{ cmd: 'select', id: 11 }])); + const target = (await centre(13)) + 10; + await page.mouse.move(await centre(11), 22); + await page.mouse.down(); + await page.mouse.move(target, 22, { steps: 12 }); + const shifted = await page.$eval(sel(12), (e) => (e as HTMLElement).style.transform); + check('the tabs passed over slide aside', shifted === 'translateX(-202px)', shifted); + await page.mouse.up(); + const order = () => page.$$eval('.tab', (els) => els.map((e) => Number(e.getAttribute('data-id')))); + check('a drag posts the new place, and no select', same(await take(), [{ cmd: 'move', id: 11, to: 2 }])); + check('and shows it at once', same(await order(), [12, 13, 11, 14]), await order()); + check( + 'nothing stays shifted', + await page.$$eval('.tab', (els) => els.every((e) => !(e as HTMLElement).style.transform)) + ); + // A state that arrives during a drag waits for the drop. + await page.mouse.move(await centre(14), 22); + await page.mouse.down(); + await page.mouse.move((await centre(12)) - 20, 22, { steps: 12 }); + const renamed = [four[1], four[2], four[0], four[3]].map((t) => (t.id === 12 ? { ...t, name: 'twelve' } : t)); + await native({ type: 'state', tabs: renamed, overviewActive: false, fullscreen: false }); + check('mid-drag, the row holds still', (await page.textContent(sel(12, '.name'))) === 'two'); + await page.mouse.up(); + check('dropped at the front', same(await take(), [{ cmd: 'move', id: 14, to: 0 }])); + check( + 'then the waiting state shows, in the new order', + same(await order(), [14, 12, 13, 11]) && (await page.textContent(sel(12, '.name'))) === 'twelve', + await order() + ); + + // 8. Many tabs and long names. + const many = Array.from({ length: 30 }, (_, i) => tab(100 + i, 'server-' + i, { active: i === 0 })); + many[0].name = 'a very long server name that cannot possibly fit in a tab'; + await native({ type: 'state', tabs: many, overviewActive: false, fullscreen: false }); + const geo = await page.evaluate(() => { + const tabs = Array.from(document.querySelectorAll('.tab')).map((e) => e.getBoundingClientRect()); + const plus = document.getElementById('new')!.getBoundingClientRect(); + const name = document.querySelector('.tab .name') as HTMLElement; + return { + minWidth: Math.min(...tabs.map((r) => r.width)), + tops: new Set(tabs.map((r) => r.top)).size, + plusRight: plus.right, + plusLeft: plus.left, + pageWidth: document.documentElement.scrollWidth, + ellipsis: name.scrollWidth > name.clientWidth && getComputedStyle(name).textOverflow === 'ellipsis', + fill: document.getElementById('fill')!.getBoundingClientRect().width, + }; + }); + check( + 'thirty tabs: 72px each, one row, + still on screen, the page never scrolls', + geo.minWidth === 72 && geo.tops === 1 && geo.plusRight <= 1280 && geo.plusLeft > 0 && geo.pageWidth <= 1280, + geo + ); + check('a long name ends in an ellipsis', geo.ellipsis, geo); + check('and some strip stays empty to drag the window by', geo.fill >= 48, geo); + await native({ + type: 'state', + tabs: [tab(1, 'busy', { active: true }, { needsYou: 250 })], + overviewActive: false, + fullscreen: false, + }); + check('a big count is capped', (await page.textContent(sel(1, '.badge'))) === '99+'); + + check('no page errors', o.errors.length === 0, o.errors); + check('no CSP violations', o.cspViolations.length === 0, o.cspViolations); + check('no dialogs', o.dialogs.length === 0, o.dialogs); + await o.close(); +} + +// --------------------------------------------------------------------------------------------- +// The overview + +function server(tabId: number, name: string, extra: Record = {}) { + return { + tab: tabId, + name, + color: '#22d3ee', + url: `http://${name.replace(/\W/g, '')}.lan:3000`, + local: false, + key: tabId, + version: '1.51.0', + kind: 'server', + offline: false, + refused: false, + sessions: [] as unknown[], + counts: { needsYou: 0, waiting: 0, working: 0, sessions: 0 }, + ...extra, + }; +} +const sess = (id: string, name: string, state: string, text: string) => ({ id, name, state, text }); + +const SERVERS = [ + server(3, 'studio', { + color: '#34d399', + url: 'https://studio.example.ts.net', + sessions: [ + sess('s-billing', 'billing', 'needs', 'Allow Bash(npm run migrate)?'), + sess('s-docs', HOSTILE, 'needs', HOSTILE), + ], + counts: { needsYou: 2, waiting: 0, working: 0, sessions: 2 }, + }), + server(2, 'atlas', { + url: 'http://192.168.1.40:3000', + sessions: [ + sess('s-api', 'api-gateway', 'waiting', 'finished 3 min ago'), + sess('s-shop', 'webshop', 'working', 'Refactoring… 1m 12s'), + sess('s-ingest', 'ingest-pipeline', 'working', 'Running tests… 20s'), + sess('s-a', 'landing', 'idle', 'idle'), + sess('s-b', 'extra', 'idle', 'idle'), + ], + counts: { needsYou: 0, waiting: 1, working: 2, sessions: 7 }, + }), + server(1, 'This Mac', { + color: BAD_COLOR, + local: true, + url: 'http://127.0.0.1:3000', + sessions: [sess('s-sb', 'sandbox-3f9a', 'working', 'Writing tests… 4m 40s')], + counts: { needsYou: 0, waiting: 0, working: 1, sessions: 1 }, + }), + server(5, 'pi-lab', { offline: true, counts: { needsYou: 4, waiting: 0, working: 0, sessions: 3 } }), + server(6, 'old-nas', { refused: true, version: null }), + server(7, 'New server tab', { kind: 'connect', url: null, version: null }), + server(8, 'build-01', { kind: 'loading', version: null, key: null }), +]; + +async function overviewWalk(browser: Browser) { + const o = await open(browser, 'overview.html', { width: 1280, height: 800 }); + const { page, take, native } = o; + const card = (n: number) => `#grid > .card:nth-child(${n})`; + + check('posts ready on load', same(await take(), [{ cmd: 'ready' }])); + check( + 'before a state: just the Add card, nothing needs you', + (await page.locator('#grid > *').count()) === 1 && + (await page.isVisible('#grid > button.add')) && + (await page.textContent('#summary')) === 'Nothing needs you' + ); + + await native({ type: 'state', servers: SERVERS }); + const names = await page.$$eval('#grid > .card .name', (els) => els.map((e) => e.textContent)); + check( + 'one card per server, in the order given, then Add', + same(names, ['studio', 'atlas', 'This Mac', 'pi-lab', 'old-nas', 'New server tab', 'build-01']) && + (await page.$eval('#grid', (g) => g.lastElementChild!.classList.contains('add'))), + names + ); + check( + 'the summary counts what is up (an offline server counts as offline only)', + (await page.textContent('#summary')) === '2 need you · 1 waiting · 3 working · 1 offline', + await page.textContent('#summary') + ); + check( + 'need you and waiting are coloured', + (await page.$eval('#summary .needs', (e) => getComputedStyle(e).color)) === 'rgb(252, 165, 165)' && + (await page.$eval('#summary .waiting', (e) => getComputedStyle(e).color)) === 'rgb(252, 211, 77)' + ); + + // A card that needs you. + const studio = card(1); + check( + 'needs you: red border, Answer in red', + (await page.$eval(studio, (e) => getComputedStyle(e).borderTopColor)) === 'rgba(248, 113, 113, 0.45)' && + (await page.textContent(studio + ' .action')) === 'Answer' && + (await page.$eval(studio + ' .action', (e) => getComputedStyle(e).backgroundColor)) === 'rgb(239, 68, 68)' + ); + check( + 'header: name, URL, shortcut', + (await page.textContent(studio + ' .url')) === 'https://studio.example.ts.net' && + (await page.textContent(studio + ' .key')) === '⌘3' + ); + check( + 'its colour is the stripe', + (await page.$eval(studio, (e) => getComputedStyle(e).boxShadow)).includes('rgb(52, 211, 153)') + ); + check('footer: sessions and version', (await page.textContent(studio + ' .meta')) === '2 sessions · 1.51.0'); + check( + 'hostile session text stays text', + (await page + .locator(studio + ' .row .sname') + .nth(1) + .textContent()) === HOSTILE && + (await page + .locator(studio + ' .row .stext') + .nth(1) + .textContent()) === HOSTILE && + (await page.locator('img').count()) === 0 + ); + check( + 'the needs mark is a red !', + (await page + .locator(studio + ' .row .mark') + .first() + .textContent()) === '!' && + (await page + .locator(studio + ' .row .mark') + .first() + .evaluate((e) => getComputedStyle(e).backgroundColor)) === 'rgb(248, 113, 113)' + ); + await page.click(studio + ' .action'); + check( + 'Answer goes to the first session that needs you', + same(await take(), [{ cmd: 'openSession', tab: 3, session: 's-billing' }]) + ); + + // A busy card with more sessions than fit. + const atlas = card(2); + check('at most four rows', (await page.locator(atlas + ' .row').count()) === 4); + check('the rest as +N more', (await page.textContent(atlas + ' .more')) === '+3 more'); + check( + 'rows are buttons, in the order given', + same(await page.$$eval(atlas + ' .row', (els) => els.map((e) => e.tagName + ':' + e.className)), [ + 'BUTTON:row waiting', + 'BUTTON:row working', + 'BUTTON:row working', + 'BUTTON:row idle', + ]) + ); + check('Open when nothing needs you', (await page.textContent(atlas + ' .action')) === 'Open'); + check('no red border', (await page.$eval(atlas, (e) => getComputedStyle(e).borderTopColor)) === 'rgb(35, 37, 43)'); + await page.click(atlas + ' .action'); + await page.click(atlas + ' .head'); + await page + .locator(atlas + ' .row') + .nth(1) + .click(); + check( + 'Open, the header and a row post what they should', + same(await take(), [ + { cmd: 'open', tab: 2 }, + { cmd: 'open', tab: 2 }, + { cmd: 'openSession', tab: 2, session: 's-shop' }, + ]) + ); + + // A bad colour. + const mac = card(3); + check( + 'a bad colour falls back', + (await page.$eval(mac, (e) => getComputedStyle(e).boxShadow)).includes(FALLBACK_RGB) && + !((await page.getAttribute(mac, 'style')) || '').includes('url'), + await page.getAttribute(mac, 'style') + ); + check('one session, singular', (await page.textContent(mac + ' .meta')) === '1 session · 1.51.0'); + + // Offline, refused, new, loading. + const pi = card(4); + check( + 'offline: dashed, says so, offers Retry', + (await page.$eval(pi, (e) => getComputedStyle(e).borderTopStyle)) === 'dashed' && + (await page.textContent(pi + ' .note')) === 'Not answering, the app keeps trying' && + (await page.textContent(pi + ' .action')) === 'Retry' && + (await page.locator(pi + ' .row').count()) === 0 + ); + await page.click(pi + ' .action'); + check('Retry retries', same(await take(), [{ cmd: 'retry', tab: 5 }])); + const nas = card(5); + check( + 'refused: says so, offers Open', + (await page.textContent(nas + ' .note')) === 'The login was refused' && + (await page.textContent(nas + ' .action')) === 'Open' + ); + await page.click(nas + ' .action'); + check('which opens the tab', same(await take(), [{ cmd: 'open', tab: 6 }])); + check( + 'a new tab: a small card with Open', + (await page.textContent(card(6) + ' .name')) === 'New server tab' && + (await page.textContent(card(6) + ' .action')) === 'Open' && + (await page.locator(card(6) + ' .url').count()) === 0 + ); + check( + 'loading: Connecting with a spinner', + (await page.textContent(card(7) + ' .note')) === 'Connecting…' && + (await page.locator(card(7) + ' .spin').count()) === 1 && + (await page.$eval(card(7) + ' .spin', (e) => getComputedStyle(e).animationName)) === 'spin' + ); + await page.click('#grid > .add'); + check('Add asks for a new tab', same(await take(), [{ cmd: 'new' }])); + + // Keyboard: real buttons, a visible ring, and the focus survives a state. + await page.focus(atlas + ' .head'); + await page.keyboard.press('Tab'); + const ring = await page.evaluate(() => { + const a = document.activeElement as HTMLElement; + const cs = getComputedStyle(a); + return { cls: a.className, outline: cs.outlineStyle + ' ' + cs.outlineWidth + ' ' + cs.outlineColor }; + }); + check( + 'Tab reaches the first row, with a blue ring', + ring.cls === 'row waiting' && ring.outline === 'solid 2px rgb(59, 130, 246)', + ring + ); + await page.keyboard.press('Enter'); + check('Enter on a row opens that session', same(await take(), [{ cmd: 'openSession', tab: 2, session: 's-api' }])); + const changed = SERVERS.map((s) => + s.tab === 2 + ? { ...s, sessions: [sess('s-api', 'api-gateway', 'waiting', 'finished 4 min ago'), ...s.sessions.slice(1)] } + : s + ); + await native({ type: 'state', servers: changed }); + check( + 'a new state keeps the focus on the same row', + (await page.evaluate(() => (document.activeElement as HTMLElement).dataset.focus)) === 'row:2:s-api' && + (await page.textContent(atlas + ' .row .stext')) === 'finished 4 min ago' + ); + + // Back to nothing. + await native({ type: 'state', servers: [] }); + check( + 'no servers: just the Add card', + (await page.locator('#grid > *').count()) === 1 && (await page.textContent('#summary')) === 'Nothing needs you' + ); + const tooNarrow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth); + check('no sideways scroll', !tooNarrow); + + check('no page errors', o.errors.length === 0, o.errors); + check('no CSP violations', o.cspViolations.length === 0, o.cspViolations); + check('no dialogs', o.dialogs.length === 0, o.dialogs); + await o.close(); +} + +// --------------------------------------------------------------------------------------------- +// Both: reduced motion, the globals, the text of the files + +async function bothPages(browser: Browser) { + for (const [file, text] of Object.entries(PAGES)) { + check(`${file}: no em dash`, !text.includes(String.fromCharCode(0x2014))); + check(`${file}: no innerHTML`, !/innerHTML|insertAdjacentHTML|outerHTML/.test(text)); + } + + const strip = await open(browser, 'tabs.html', { width: 900, height: 38 }, 'reduce'); + await strip.native({ + type: 'state', + tabs: [tab(1, 'busy', { active: true }, { working: 1 }), tab(2, 'starting', { kind: 'loading' })], + overviewActive: false, + fullscreen: false, + }); + check( + 'reduced motion: the strip does not spin', + (await strip.page.$eval('.badge.working', (e) => getComputedStyle(e).animationName)) === 'none' && + (await strip.page.$eval('.spin', (e) => getComputedStyle(e).animationName)) === 'none' + ); + const desc = await strip.page.evaluate(() => { + const d = Object.getOwnPropertyDescriptor(window, '__codemanTabs')!; + const before = window.__codemanTabs; + try { + (window as any).__codemanTabs = null; + } catch { + /* strict code would throw; the page's own is the point */ + } + return { writable: d.writable, configurable: d.configurable, kept: window.__codemanTabs === before }; + }); + check('the strip global cannot be replaced', !desc.writable && !desc.configurable && desc.kept, desc); + await strip.native('{"type":"state","tabs":[{"id":9,"name":"from a string"}]}'); + check('a message as a JSON string works too', (await strip.page.textContent('.tab .name')) === 'from a string'); + await strip.native({ type: 'state', tabs: 'nonsense' }); + await strip.native(null); + check( + 'junk empties the strip without an error', + (await strip.page.locator('.tab').count()) === 0 && strip.errors.length === 0, + strip.errors + ); + await strip.close(); + + const ov = await open(browser, 'overview.html', { width: 1280, height: 800 }, 'reduce'); + await ov.native({ type: 'state', servers: SERVERS }); + check( + 'reduced motion: the overview does not spin', + (await ov.page.$eval('.row.working .mark', (e) => getComputedStyle(e, '::before').animationName)) === 'none' && + (await ov.page.$eval('.spin', (e) => getComputedStyle(e).animationName)) === 'none' + ); + const odesc = await ov.page.evaluate(() => { + const d = Object.getOwnPropertyDescriptor(window, '__codemanOverview')!; + return { writable: d.writable, configurable: d.configurable }; + }); + check('the overview global cannot be replaced', !odesc.writable && !odesc.configurable, odesc); + check('no page errors', ov.errors.length === 0, ov.errors); + await ov.close(); +} + +describe('Codeman Desktop server tab pages', () => { + let browser: Browser; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + }); + + afterAll(async () => { + await browser?.close(); + }); + + const walk = (name: string, steps: (browser: Browser) => Promise) => + it( + name, + async () => { + failed.length = 0; + await steps(browser); + expect(failed).toEqual([]); + }, + 120_000 + ); + + walk('the strip: tabs, badges, clicks, window drag, rename and reorder', stripWalk); + walk('the overview: cards, rows, buttons and the summary', overviewWalk); + walk('both: reduced motion, the globals, junk from the native side', bothPages); +});