mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(desktop): find the Codeman servers running on this Mac and offer to connect
When a Codeman server of this user ran at an address the app could not open as its default one, the This Mac tab only named host and port and sent the user to Another machine to type the address again. A second instance beside the one that answers (a beta, a `codeman web --port 4000` in a terminal) was not shown at all. The look at this Mac now works out an address for every listening socket of this user's Codeman servers (`found_addresses`, pure): 127.0.0.1 for a bind to loopback or to every interface, `[::1]` when that is the only loopback socket, the address itself for a bind to one; `https` and the base path come from `--https` and `--base-url` on the command line. A server at the default address with no `--base-url` of its own keeps the default URL, since install.sh's launchd job has the base path in its environment, where `ps` does not show it. The default address comes first, then the others by port. Each address other than the default one is asked once, all at the same time, with no login. The default probe's answer is reused for its own row. When nothing answers at the default address, the first found server that answers is still what Open is for, so each gets the probe's usual 5 s; next to a Codeman that answers they are only listed and get 2 s. `LocalStatus.found` replaces `elsewhere`. The tab lists them: "Codeman is already running on this Mac." with a row each when none answers at the default address, and "Also running on this Mac" under Open otherwise. A row has the address, the version and Connect, or the reason the app cannot open it. Connect fills the address in and runs the page's one connect(), so the app checks it, uses its saved login and remembers it like an address typed under Another machine. Nothing connects by itself, and a refused connect from a row never arms the reopen that Open has for a starting server. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
84d5c8de52
commit
59af305e84
6 files changed
+1104
-118
No files matched your search
@@ -28,9 +28,9 @@ cannot do.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. 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. |
|
||||
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). The tab also finds every Codeman server of yours that already runs on this Mac (with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, a beta instance beside the installed one) and recommends connecting to it. Each one's address is worked out from its listening socket and its command line (`127.0.0.1` for a bind to loopback or to every interface, its own address for a bind to one, `https` with `--https`, the path of `--base-url`), and each is asked once whether Codeman answers there. When nothing answers at the usual address (the LaunchAgent's, or `http://127.0.0.1:3000` without one), *Open Codeman* is for the first found server that does; when none of them can be opened, the tab says *Codeman is already running on this Mac* and lists them. Next to a Codeman that answers, the others are listed under *Also running on this Mac*. A row shows the address, the version, and a *Connect* button, or the reason the app cannot open that one (HTTPS with Codeman's own certificate, no answer). *Connect* is the connect of the Another machine tab with that address filled in: the same check, the login saved for it, the same entry under recent servers. Finding a server connects to nothing by itself. The tab never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. *Add a coding agent*, on this tab and in the app menu whenever Codeman is on this Mac, is the same chooser after the install: pick one agent, watch it install, open Codeman. It needs no password, leaves a running Codeman alone, and can be cancelled or left running like an install. After *Skip for now* the app says Codeman has no agent yet and offers this before it opens Codeman. |
|
||||
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<host>:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. |
|
||||
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac 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). |
|
||||
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac (or connects to the one found here, when that is the tab's main button) and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
|
||||
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<port>` or `http://localhost:<port>` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. |
|
||||
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
|
||||
| **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. |
|
||||
@@ -206,7 +206,8 @@ with Codeman stopped, whatever else listens there never gets it.
|
||||
data directory and tmux socket attach to each other's live sessions, and
|
||||
`codeman web -d` only notices a server it started itself or one at the same
|
||||
address. So the app looks for running Codeman servers of this user (`ps`)
|
||||
before it offers or runs a start, and opens the one it finds instead.
|
||||
before it offers or runs a start, and offers to connect to the ones it finds
|
||||
instead (`found_addresses` in `src/setup.rs`, unit-tested).
|
||||
|
||||
**Closing the main window quits the app.** Codeman's sessions keep running on
|
||||
the server, so nothing is lost, and reopening takes about a second.
|
||||
@@ -214,7 +215,9 @@ the server, so nothing is lost, and reopening takes about a second.
|
||||
**One reachability probe per user action, and one to check a refused login.**
|
||||
Codeman counts a request without valid credentials against its login rate
|
||||
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
|
||||
never retries in a loop. Once the server refuses the login in a challenge, the
|
||||
never retries in a loop. A look at this Mac asks each Codeman server found
|
||||
here once; the app has a login only for the one at the usual address, so the
|
||||
others are asked without one. Once the server refuses the login in a challenge, the
|
||||
app sends it only once more, in the single probe that checks the refusal. If
|
||||
that probe is refused or rate limited, the connect screen comes back; the
|
||||
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
|
||||
@@ -240,6 +243,17 @@ and the Dock icon bounces.
|
||||
The nodejs.org installer leaves `/usr/local/lib/node_modules` to root, so those
|
||||
agents would be expected to fail there, with npm's permission error shown, while
|
||||
the ones with their own installer still work. This has not been tried.
|
||||
- **Only your own Codeman servers on this Mac are found, by their command
|
||||
line.** A server another macOS user runs, or one in a container or a virtual
|
||||
machine, is not listed: connect to it by address under Another machine. A
|
||||
base path that a server started by hand has only in its environment
|
||||
(`CODEMAN_BASE_URL`) is not visible either, so its row has the address
|
||||
without the path; the LaunchAgent's base path is read from its job.
|
||||
- **A found server that is slow to answer shows as not answering.** Next to a
|
||||
Codeman that answers, each other server gets two seconds (five when nothing
|
||||
answers at the usual address). A password-protected one counts the
|
||||
look as a failed login, like any look at a server the app has no login for.
|
||||
*Check again* asks again.
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
||||
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
|
||||
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
|
||||
|
||||
@@ -125,6 +125,20 @@
|
||||
@media (prefers-reduced-motion: reduce) { .dot.busy { animation: none; } }
|
||||
#local-title { font-weight: 600; }
|
||||
.sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; }
|
||||
/* The Codeman servers found on this Mac: a row each, in the look of a choice
|
||||
in the install, with the row's own Connect button. */
|
||||
#local-found-note { margin: 8px 0 0; }
|
||||
#local-also { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#local-also-head { color: var(--muted); font-size: 12px; margin: 0 0 6px; }
|
||||
.found {
|
||||
display: flex; gap: 10px; align-items: center; padding: 9px 11px; border-radius: 9px;
|
||||
border: 1px solid var(--line-strong); background: #0f1013;
|
||||
}
|
||||
.found + .found { margin-top: 6px; }
|
||||
.found > span { flex: 1; min-width: 0; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; }
|
||||
.found b { display: block; font-weight: 600; }
|
||||
.found small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }
|
||||
.found button { flex: none; }
|
||||
#elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
#elsewhere[hidden] { display: none; }
|
||||
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
|
||||
@@ -287,6 +301,8 @@
|
||||
<div class="sub" id="local-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="local-found" role="list" aria-labelledby="local-title" hidden></div>
|
||||
<p class="sub" id="local-found-note" hidden>Starting another server here would take over its sessions.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" id="local-open" type="button" hidden>Open Codeman</button>
|
||||
<button class="primary" id="local-start" type="button" hidden>Start Codeman</button>
|
||||
@@ -295,6 +311,10 @@
|
||||
<button class="secondary" id="local-check" type="button" hidden>Check again</button>
|
||||
</div>
|
||||
<button class="wiz-link" id="local-agents" type="button" hidden>Add a coding agent</button>
|
||||
<div id="local-also" hidden>
|
||||
<div id="local-also-head">Also running on this Mac</div>
|
||||
<div id="local-also-list" role="list" aria-labelledby="local-also-head"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="form" role="tabpanel" aria-labelledby="tab-remote" autocomplete="off" novalidate hidden>
|
||||
@@ -386,6 +406,8 @@
|
||||
wizTerminal: $('wiz-terminal'),
|
||||
localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'),
|
||||
localStop: $('local-stop'), localCheck: $('local-check'), localAgents: $('local-agents'),
|
||||
localFound: $('local-found'), localFoundNote: $('local-found-note'),
|
||||
localAlso: $('local-also'), localAlsoList: $('local-also-list'),
|
||||
elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy')
|
||||
};
|
||||
var manual = /[?&]manual=1/.test(location.search);
|
||||
@@ -399,10 +421,11 @@
|
||||
// graphical install while its screens are up (newWizard), null otherwise.
|
||||
// reopen: a connect to this Mac was refused, so open it if it turns out to
|
||||
// be starting. startingLooks: how often the page has looked again at a
|
||||
// starting server.
|
||||
// starting server. askedFound: the connect under way was asked from the row
|
||||
// of a server found on this Mac.
|
||||
var state = {
|
||||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||||
tab: 'local', askedFrom: 'local', local: null, localChecking: false, opening: false,
|
||||
tab: 'local', askedFrom: 'local', askedFound: false, local: null, localChecking: false, opening: false,
|
||||
wait: null, waitTimer: 0, stoppedWaiting: false, gui: null, wantAgents: false,
|
||||
reopen: false, startingLooks: 0, startingTimer: 0
|
||||
};
|
||||
@@ -566,7 +589,8 @@
|
||||
else els.password.placeholder = '';
|
||||
}
|
||||
|
||||
function connect() {
|
||||
// found: asked from the row of a server found on this Mac (connectFound).
|
||||
function connect(found) {
|
||||
if (state.busy || tooOld) return;
|
||||
var url = els.url.value.trim();
|
||||
if (!url) { setStatus('bad', 'Enter the address of your Codeman server.'); els.url.focus(); return; }
|
||||
@@ -579,6 +603,7 @@
|
||||
}
|
||||
state.lastUrl = url;
|
||||
state.askedFrom = state.tab;
|
||||
state.askedFound = found === true;
|
||||
setBusy(true);
|
||||
setStatus('busy', 'Connecting to ' + url + '…');
|
||||
var msg = { cmd: 'connect', url: url, remember: els.remember.checked };
|
||||
@@ -638,8 +663,10 @@
|
||||
selectTab('remote');
|
||||
showLogin();
|
||||
} else if (asked === 'local') {
|
||||
// Opening the Codeman on this Mac failed: look at it again.
|
||||
state.reopen = o.kind === 'refused';
|
||||
// Opening the Codeman on this Mac failed: look at it again. Opening it
|
||||
// once it is up is for the one the tab's first line shows (openLocal),
|
||||
// never for a found server's row, which may be another server.
|
||||
state.reopen = o.kind === 'refused' && !state.askedFound;
|
||||
selectTab('local');
|
||||
// selectTab starts the look itself when there has been none yet.
|
||||
if (!state.localChecking) checkLocal('login');
|
||||
@@ -741,6 +768,66 @@
|
||||
els.localCheck.disabled = state.busy || state.localChecking;
|
||||
els.localAgents.disabled = state.busy || tooOld || state.opening;
|
||||
els.sshInstall.disabled = state.busy || state.opening;
|
||||
Array.prototype.forEach.call(els.paneLocal.querySelectorAll('.found button'), function (b) {
|
||||
b.disabled = state.busy || tooOld;
|
||||
});
|
||||
}
|
||||
|
||||
// A Codeman on this Mac that serves HTTPS, stopped or running.
|
||||
var HTTPS_REFUSED = 'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
|
||||
|
||||
// The Codeman servers the app found on this Mac (setup.rs, inspect), other
|
||||
// than the one the tab's first line is about.
|
||||
function otherServers(s) {
|
||||
return (s.found || []).filter(function (f) { return f && f.url !== s.url; });
|
||||
}
|
||||
|
||||
function canConnect(f) {
|
||||
return f.state === 'running' || f.state === 'needsLogin';
|
||||
}
|
||||
|
||||
// What a found server's row says under its address: its version, or why the
|
||||
// app cannot open it.
|
||||
function foundNote(f) {
|
||||
if (f.state === 'running') return f.version ? 'version ' + f.version : '';
|
||||
if (f.state === 'needsLogin') return 'Asks for a login.';
|
||||
if (f.state === 'certificate') return HTTPS_REFUSED;
|
||||
return 'It does not answer at this address.';
|
||||
}
|
||||
|
||||
// One row per found server: its address, a note, and Connect when the app
|
||||
// can open it. primary: the first Connect is the tab's main action.
|
||||
function renderFound(list, servers, primary) {
|
||||
list.textContent = '';
|
||||
servers.forEach(function (f) {
|
||||
var url = String(f.url || '');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'found';
|
||||
row.setAttribute('role', 'listitem');
|
||||
var text = document.createElement('span');
|
||||
var where = document.createElement('b');
|
||||
where.textContent = url;
|
||||
text.appendChild(where);
|
||||
var note = foundNote(f);
|
||||
if (note) {
|
||||
var small = document.createElement('small');
|
||||
small.textContent = note;
|
||||
text.appendChild(small);
|
||||
}
|
||||
row.appendChild(text);
|
||||
if (canConnect(f)) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = primary ? 'primary' : 'secondary';
|
||||
primary = false;
|
||||
button.textContent = 'Connect';
|
||||
button.title = 'Connect to ' + url;
|
||||
button.setAttribute('aria-label', 'Connect to ' + url);
|
||||
button.addEventListener('click', function () { connectFound(url); });
|
||||
row.appendChild(button);
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
@@ -749,6 +836,10 @@
|
||||
var dot = '';
|
||||
var title = '';
|
||||
var detail = '';
|
||||
// Found servers: `main` when they are what the tab is about, `also` when
|
||||
// they run beside the one its first line shows.
|
||||
var main = [];
|
||||
var also = [];
|
||||
if (state.wait) {
|
||||
dot = 'busy';
|
||||
title = 'Installing Codeman in Terminal…';
|
||||
@@ -768,27 +859,34 @@
|
||||
detail = 'It will answer at ' + s.url + ' in a moment.';
|
||||
show.check = true;
|
||||
} else if (s.state === 'runningElsewhere') {
|
||||
dot = 'warn';
|
||||
title = 'A Codeman server is already running on this Mac.';
|
||||
detail = 'It listens at ' + (s.elsewhere || []).join(', ') + ', which this app cannot open as ' + s.url +
|
||||
'. Connect to its address under Another machine. Starting another server here would take over its sessions.';
|
||||
// Not at the address the app opens by default: each one gets a row.
|
||||
main = (s.found || []).filter(Boolean);
|
||||
dot = main.some(canConnect) ? 'ok' : 'warn';
|
||||
title = 'Codeman is already running on this Mac.';
|
||||
// No row: the app found the server's process, but no address to try.
|
||||
if (!main.length) {
|
||||
detail = 'The app could not find the address it listens at: connect to it under Another machine. ' +
|
||||
'Starting another server here would take over its sessions.';
|
||||
}
|
||||
show.check = true;
|
||||
} else if (s.state === 'running') {
|
||||
dot = 'ok';
|
||||
title = 'Codeman is running on this Mac.';
|
||||
detail = s.url + (s.version ? ' · version ' + s.version : '');
|
||||
show.open = true;
|
||||
also = otherServers(s);
|
||||
} else if (s.state === 'needsLogin') {
|
||||
dot = 'ok';
|
||||
title = 'Codeman is running on this Mac.';
|
||||
detail = s.url + ' asks for a login: Open asks for the password.';
|
||||
show.open = true;
|
||||
also = otherServers(s);
|
||||
} else if (s.state === 'stopped') {
|
||||
title = 'Codeman is installed on this Mac, but not running.';
|
||||
if (s.daemon) {
|
||||
detail = 'A system LaunchDaemon runs it, which an administrator starts: sudo launchctl kickstart -k system/com.codeman.web';
|
||||
} else if (/^https:/i.test(s.url)) {
|
||||
detail = 'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
|
||||
detail = HTTPS_REFUSED;
|
||||
} else {
|
||||
detail = s.service
|
||||
? 'It is set up to start when you log in, but nothing answers at ' + s.url + '. Start asks launchd to run it now.'
|
||||
@@ -813,9 +911,11 @@
|
||||
} else {
|
||||
var o = s.outcome || {};
|
||||
dot = 'warn';
|
||||
also = otherServers(s);
|
||||
if (o.kind === 'notCodeman') {
|
||||
title = 'Something else answers at ' + s.url;
|
||||
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to Codeman’s real address under Another machine.';
|
||||
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to ' +
|
||||
(also.some(canConnect) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.');
|
||||
} else {
|
||||
var e = explain[o.kind];
|
||||
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
|
||||
@@ -833,6 +933,11 @@
|
||||
els.localInstall.hidden = !show.install;
|
||||
els.localStop.hidden = !show.stop;
|
||||
els.localCheck.hidden = !show.check;
|
||||
renderFound(els.localFound, main, true);
|
||||
els.localFound.hidden = !main.length;
|
||||
els.localFoundNote.hidden = !main.length;
|
||||
renderFound(els.localAlsoList, also, false);
|
||||
els.localAlso.hidden = !also.length;
|
||||
// Codeman is here, in whatever state: adding an agent asks nothing of it.
|
||||
els.localAgents.hidden = !s || !s.installed || !!state.wait || state.localChecking || (s.installRunning && !show.open);
|
||||
syncLocalButtons();
|
||||
@@ -913,6 +1018,15 @@
|
||||
connect();
|
||||
}
|
||||
|
||||
// Connect on a found server's row. It is the connect of an address typed
|
||||
// under Another machine, and of Open: the app checks the address, uses the
|
||||
// login saved for it and remembers the server the same way.
|
||||
function connectFound(url) {
|
||||
if (state.busy || tooOld) return;
|
||||
useAddress(url);
|
||||
connect(true);
|
||||
}
|
||||
|
||||
function startLocal() {
|
||||
if (state.busy || tooOld || !state.local) return;
|
||||
useAddress(state.local.url);
|
||||
@@ -1677,7 +1791,8 @@
|
||||
(next === 'local' ? els.tabLocal : els.tabRemote).focus();
|
||||
});
|
||||
});
|
||||
// Return opens or starts the Codeman on this Mac and Esc cancels a connect,
|
||||
// Return opens or starts the Codeman on this Mac, or connects to the one
|
||||
// found here when that is the tab's main action, and Esc cancels a connect,
|
||||
// as in a Mac dialog. Install is left to a click: a stray Return must not
|
||||
// start an installer.
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
@@ -1687,7 +1802,8 @@
|
||||
if (ev.target && ev.target.closest && ev.target.closest('button, a, input, summary')) return;
|
||||
// offsetParent: really on screen, whatever hides it (its own `hidden`, or
|
||||
// a container that is not shown).
|
||||
var main = [els.localOpen, els.localStart].filter(function (b) { return b.offsetParent && !b.disabled; })[0];
|
||||
var main = [els.localOpen, els.localStart, els.localFound.querySelector('button.primary')]
|
||||
.filter(function (b) { return b && b.offsetParent && !b.disabled; })[0];
|
||||
if (main) { ev.preventDefault(); main.click(); }
|
||||
});
|
||||
els.localOpen.addEventListener('click', openLocal);
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// The path the probe asks for, relative to the server's base URL.
|
||||
@@ -232,7 +232,7 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
}
|
||||
|
||||
/// `probe` with the time the whole exchange may take.
|
||||
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
|
||||
pub fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
|
||||
+592
-89
@@ -1,6 +1,7 @@
|
||||
//! The connect screen's "This Mac" tab and its Terminal installs: where the
|
||||
//! Codeman on this Mac listens, whether it is installed, its login, and the
|
||||
//! installer runs in Terminal for this Mac and for another machine over SSH.
|
||||
//! Codeman on this Mac listens, whether it is installed, its login, which
|
||||
//! other Codeman servers of this user run here, and the installer runs in
|
||||
//! Terminal for this Mac and for another machine over SSH.
|
||||
//! The install most people get runs in the app, in `gui_install.rs`.
|
||||
//!
|
||||
//! The Terminal route is for what that one cannot do. `install.sh` asks for
|
||||
@@ -93,46 +94,65 @@ pub fn parse_service(job: &serde_json::Value) -> ServiceConfig {
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut port = var("CODEMAN_PORT");
|
||||
let mut base = var("CODEMAN_BASE_URL");
|
||||
let mut https = false;
|
||||
// Only what follows the `web` subcommand: node's own flags come before it.
|
||||
let after_web = args
|
||||
.iter()
|
||||
.position(|a| *a == "web")
|
||||
.map_or(&[][..], |i| &args[i + 1..]);
|
||||
let mut it = after_web.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
match *arg {
|
||||
"-p" | "--port" => port = it.next().map(|s| s.to_string()),
|
||||
"--base-url" => base = it.next().map(|s| s.to_string()),
|
||||
"--https" => https = true,
|
||||
// Options that take a value: skip it, so it is never read as a flag.
|
||||
"-H" | "--host" | "--title-hostname" => {
|
||||
it.next();
|
||||
}
|
||||
other => {
|
||||
if let Some(v) = other.strip_prefix("--port=") {
|
||||
port = Some(v.to_string());
|
||||
} else if let Some(v) = other.strip_prefix("--base-url=") {
|
||||
base = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let flags = WebFlags {
|
||||
port: var("CODEMAN_PORT"),
|
||||
base: var("CODEMAN_BASE_URL"),
|
||||
https: false,
|
||||
}
|
||||
.read(after_web);
|
||||
ServiceConfig {
|
||||
port: port
|
||||
port: flags
|
||||
.port
|
||||
.and_then(|p| p.trim().parse::<u16>().ok())
|
||||
.filter(|p| *p > 0)
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
base_path: normalize_base_path(base.as_deref().unwrap_or("")),
|
||||
https,
|
||||
base_path: normalize_base_path(flags.base.as_deref().unwrap_or("")),
|
||||
https: flags.https,
|
||||
username: var("CODEMAN_USERNAME"),
|
||||
password: var("CODEMAN_PASSWORD"),
|
||||
daemon: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// What Codeman's `web` command takes from the arguments after `web`
|
||||
/// (src/cli.ts). The fields start as what the environment says, or empty.
|
||||
#[derive(Debug, Default)]
|
||||
struct WebFlags {
|
||||
port: Option<String>,
|
||||
base: Option<String>,
|
||||
https: bool,
|
||||
}
|
||||
|
||||
impl WebFlags {
|
||||
fn read(mut self, after_web: &[&str]) -> Self {
|
||||
let mut it = after_web.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
match *arg {
|
||||
"-p" | "--port" => self.port = it.next().map(|s| s.to_string()),
|
||||
"--base-url" => self.base = it.next().map(|s| s.to_string()),
|
||||
"--https" => self.https = true,
|
||||
// Options that take a value: skip it, so it is never read as a flag.
|
||||
"-H" | "--host" | "--title-hostname" => {
|
||||
it.next();
|
||||
}
|
||||
other => {
|
||||
if let Some(v) = other.strip_prefix("--port=") {
|
||||
self.port = Some(v.to_string());
|
||||
} else if let Some(v) = other.strip_prefix("--base-url=") {
|
||||
self.base = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Codeman's `normalizeBasePath`, plus its character check: a value Codeman
|
||||
/// would refuse at startup counts as the root here.
|
||||
fn normalize_base_path(input: &str) -> String {
|
||||
@@ -325,6 +345,12 @@ fn parse_ps(text: &str) -> Vec<(u32, String)> {
|
||||
/// `install.sh` clone's `dist/index.js web`, a source checkout's
|
||||
/// `src/index.ts web`), not one of its short-lived control commands.
|
||||
pub fn is_codeman_server(command: &str) -> bool {
|
||||
server_args(command).is_some()
|
||||
}
|
||||
|
||||
/// What follows `web` on a Codeman server's command line, or None for any
|
||||
/// other command.
|
||||
fn server_args(command: &str) -> Option<Vec<&str>> {
|
||||
let words: Vec<&str> = command.split_whitespace().collect();
|
||||
let is_entry = |w: &str| {
|
||||
let name = w.rsplit('/').next().unwrap_or(w);
|
||||
@@ -332,11 +358,13 @@ pub fn is_codeman_server(command: &str) -> bool {
|
||||
};
|
||||
// Any entry word that `web` follows: an earlier word may only look like
|
||||
// one (a folder named codeman in `tsx watch --cwd ~/codeman src/index.ts web`).
|
||||
(0..words.len()).any(|i| {
|
||||
is_entry(words[i])
|
||||
&& words.get(i + 1) == Some(&"web")
|
||||
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
|
||||
})
|
||||
(0..words.len())
|
||||
.find(|&i| {
|
||||
is_entry(words[i])
|
||||
&& words.get(i + 1) == Some(&"web")
|
||||
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
|
||||
})
|
||||
.map(|i| words[i + 2..].to_vec())
|
||||
}
|
||||
|
||||
/// Whether a command line is an install the app started on this Mac (the
|
||||
@@ -438,14 +466,138 @@ fn serves_loopback_port(found: &[Listener], port: u16) -> bool {
|
||||
.any(|l| l.port == port && matches!(l.host.as_str(), "*" | "127.0.0.1" | "0.0.0.0"))
|
||||
}
|
||||
|
||||
/// The address a listener is reached at: 127.0.0.1 for a loopback or
|
||||
/// all-interfaces bind, else its own address.
|
||||
fn listener_url(l: &Listener) -> Option<Url> {
|
||||
let host = match l.host.as_str() {
|
||||
"*" | "0.0.0.0" => "127.0.0.1",
|
||||
h => h,
|
||||
/// Where the app can try each of this user's Codeman servers, from their
|
||||
/// command lines (`procs`) and listening sockets. `default` is the address the
|
||||
/// app opens by default (local_url); it comes first, then the others by port.
|
||||
///
|
||||
/// One address per server and port: 127.0.0.1 when the server listens there
|
||||
/// or on every interface, `[::1]` when its only loopback socket is the IPv6
|
||||
/// one, else each address it is bound to. A socket beside a loopback one on
|
||||
/// the same port is left out (the Docker bridge listener, which serves hook
|
||||
/// endpoints only). `https` when the command line carries `--https`, and its
|
||||
/// `--base-url` is the path. `ps` does not show a base path kept in the
|
||||
/// environment, which is where install.sh's launchd job keeps it, so a server
|
||||
/// at the default address with no `--base-url` of its own is the default one.
|
||||
pub fn found_addresses(procs: &[(u32, String)], listeners: &[Listener], default: &Url) -> Vec<Url> {
|
||||
let mut urls: Vec<Url> = Vec::new();
|
||||
for (pid, command) in procs {
|
||||
let Some(args) = server_args(command) else {
|
||||
continue;
|
||||
};
|
||||
let flags = WebFlags::default().read(&args);
|
||||
let scheme = if flags.https { "https" } else { "http" };
|
||||
let base = flags.base.as_deref().map(normalize_base_path);
|
||||
let own: Vec<&Listener> = listeners.iter().filter(|l| l.pid == *pid).collect();
|
||||
for l in &own {
|
||||
let beside = |hosts: &[&str]| own.iter().any(|o| o.port == l.port && hosts.contains(&o.host.as_str()));
|
||||
let host = if beside(&["*", "0.0.0.0", "[::]", "127.0.0.1"]) {
|
||||
"127.0.0.1"
|
||||
} else if beside(&["[::1]"]) {
|
||||
"[::1]"
|
||||
} else {
|
||||
l.host.as_str()
|
||||
};
|
||||
let path = base.as_deref().unwrap_or("");
|
||||
let Ok(mut url) = Url::parse(&format!("{scheme}://{host}:{}{path}/", l.port)) else {
|
||||
continue;
|
||||
};
|
||||
if base.is_none() && policy::same_origin(&url, default) {
|
||||
url = default.clone();
|
||||
}
|
||||
if !urls.contains(&url) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
urls.sort_by_key(|u| (u != default, u.port_or_known_default(), u.to_string()));
|
||||
urls
|
||||
}
|
||||
|
||||
/// What the This Mac tab says about one found server.
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FoundState {
|
||||
/// Codeman answers there: Connect opens it.
|
||||
Running,
|
||||
/// Codeman answers there and wants a login.
|
||||
NeedsLogin,
|
||||
/// It serves HTTPS with a certificate the app refuses (Codeman's own
|
||||
/// self-signed one, from `codeman web --https`).
|
||||
Certificate,
|
||||
/// Codeman does not answer there.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// One of this user's Codeman servers on this Mac, for the This Mac tab's
|
||||
/// rows.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FoundServer {
|
||||
/// Where the app can try it (found_addresses).
|
||||
pub url: String,
|
||||
pub version: Option<String>,
|
||||
pub state: FoundState,
|
||||
/// It is at the address the app opens by default (local_url).
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
/// The row for the server at `url`, from what it answered.
|
||||
fn found_server(url: &Url, answer: &ProbeOutcome, default: &Url) -> FoundServer {
|
||||
let state = match answer {
|
||||
ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => FoundState::Running,
|
||||
_ if answers(answer) => FoundState::NeedsLogin,
|
||||
// A Codeman started with --https closes a plain http connection.
|
||||
ProbeOutcome::TlsUntrusted { .. } | ProbeOutcome::MaybeHttps => FoundState::Certificate,
|
||||
_ => FoundState::Unreachable,
|
||||
};
|
||||
Url::parse(&format!("http://{host}:{}/", l.port)).ok()
|
||||
FoundServer {
|
||||
url: url.to_string(),
|
||||
version: match answer {
|
||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
||||
_ => None,
|
||||
},
|
||||
state,
|
||||
is_default: url == default,
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a found server gets to answer when the tab only lists it.
|
||||
const FOUND_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Ask the servers at `urls`, all at the same time, so the look takes as long
|
||||
/// as the slowest answer and no longer. Without a login: the one the app can
|
||||
/// read belongs to the default address (login_for).
|
||||
fn ask_found(urls: &[Url], patience: Duration) -> Vec<ProbeOutcome> {
|
||||
std::thread::scope(|scope| {
|
||||
let asking: Vec<_> = urls
|
||||
.iter()
|
||||
.map(|url| scope.spawn(move || probe::probe_within(url, None, patience)))
|
||||
.collect();
|
||||
asking
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
a.join().unwrap_or_else(|_| ProbeOutcome::Failed {
|
||||
detail: "The check stopped.".into(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The address the This Mac tab is about, with its answer: the default one,
|
||||
/// or, when nothing answers there, the first found server that does (a
|
||||
/// `codeman web --port 4000` in a terminal, a bind to one network address).
|
||||
fn shown<'a>(
|
||||
default: &'a Url,
|
||||
answer: &'a ProbeOutcome,
|
||||
found: &'a [(Url, ProbeOutcome)],
|
||||
) -> (&'a Url, &'a ProbeOutcome) {
|
||||
if *answer == ProbeOutcome::Refused {
|
||||
if let Some((url, answer)) = found.iter().find(|(_, a)| answers(a)) {
|
||||
return (url, answer);
|
||||
}
|
||||
}
|
||||
(default, answer)
|
||||
}
|
||||
|
||||
/// What the This Mac tab shows.
|
||||
@@ -459,8 +611,8 @@ pub enum LocalState {
|
||||
/// A Codeman server of this user runs but listens nowhere yet: it is
|
||||
/// still starting (the LaunchAgent at login, a restart).
|
||||
Starting,
|
||||
/// A Codeman server of this user runs, but not at an address the app can
|
||||
/// open (bound to one network address, say): see `elsewhere`.
|
||||
/// A Codeman server of this user runs, but none answers at an address the
|
||||
/// app can open (HTTPS with Codeman's own certificate, say): see `found`.
|
||||
RunningElsewhere,
|
||||
/// Nothing answers, and Codeman is installed.
|
||||
Stopped,
|
||||
@@ -498,8 +650,9 @@ pub struct LocalStatus {
|
||||
/// A coding agent is being added the same way. Not an install: it says
|
||||
/// nothing about Codeman, which may well be running.
|
||||
pub agent_install_in_app: bool,
|
||||
/// For RunningElsewhere: the addresses that Codeman listens on.
|
||||
pub elsewhere: Vec<String>,
|
||||
/// Every Codeman server of this user that listens on this Mac, the one at
|
||||
/// the default address first: the tab offers to connect to each.
|
||||
pub found: Vec<FoundServer>,
|
||||
/// The probe's outcome, for the connect page's explanations.
|
||||
pub outcome: ProbeOutcome,
|
||||
}
|
||||
@@ -546,57 +699,44 @@ pub enum Depth {
|
||||
pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let service = read_service();
|
||||
let url = local_url(service.as_ref());
|
||||
let default = local_url(service.as_ref());
|
||||
let procs = list_processes();
|
||||
let servers = codeman_servers(&procs);
|
||||
let found = listeners(&servers);
|
||||
let listening = listeners(&servers);
|
||||
let install = running_install(&procs);
|
||||
let install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Codeman.matches(c));
|
||||
let agent_install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Agent.matches(c));
|
||||
|
||||
// The login goes only to this user's own Codeman on that port.
|
||||
let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) {
|
||||
login_for(&url, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p))
|
||||
let auth = if serves_loopback_port(&listening, default.port_or_known_default().unwrap_or(0)) {
|
||||
login_for(&default, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut outcome = probe::probe(&url, auth.as_deref());
|
||||
let mut url = url;
|
||||
let mut elsewhere = Vec::new();
|
||||
let answer = probe::probe(&default, auth.as_deref());
|
||||
|
||||
// Nothing at the usual address, but a Codeman server of this user runs:
|
||||
// find where (a `codeman web --port 4000` in a terminal, a bind to one
|
||||
// network address), so the tab never offers to start a second server on
|
||||
// the same sessions.
|
||||
if outcome == ProbeOutcome::Refused && !servers.is_empty() {
|
||||
let mut ports_seen = Vec::new();
|
||||
for l in &found {
|
||||
if ports_seen.contains(&(l.host.clone(), l.port)) {
|
||||
continue;
|
||||
}
|
||||
ports_seen.push((l.host.clone(), l.port));
|
||||
let Some(candidate) = listener_url(l) else {
|
||||
continue;
|
||||
};
|
||||
let answer = probe::probe(&candidate, None);
|
||||
if matches!(
|
||||
answer,
|
||||
ProbeOutcome::Ok { .. }
|
||||
| ProbeOutcome::NeedsLogin
|
||||
| ProbeOutcome::BadCredentials
|
||||
| ProbeOutcome::RateLimited { .. }
|
||||
| ProbeOutcome::PasswordChangeRequired
|
||||
) {
|
||||
url = candidate;
|
||||
outcome = answer;
|
||||
break;
|
||||
}
|
||||
elsewhere.push(format!("{}:{}", l.host, l.port));
|
||||
}
|
||||
if outcome == ProbeOutcome::Refused && elsewhere.is_empty() {
|
||||
elsewhere = servers.iter().map(|pid| format!("process {pid}")).collect();
|
||||
}
|
||||
// Every address one of this user's Codeman servers listens at. The one at
|
||||
// the default address has just answered; the others are asked now, once
|
||||
// each. With nothing at the default address, the first of them that
|
||||
// answers is what the tab opens, as it always was, so each gets the
|
||||
// probe's usual time; otherwise they are only listed, and get less.
|
||||
let patience = if answer == ProbeOutcome::Refused {
|
||||
probe::TIMEOUT
|
||||
} else {
|
||||
FOUND_TIMEOUT
|
||||
};
|
||||
let addresses = found_addresses(&procs, &listening, &default);
|
||||
let others: Vec<Url> = addresses.iter().filter(|u| **u != default).cloned().collect();
|
||||
let mut answered: Vec<(Url, ProbeOutcome)> = others.iter().cloned().zip(ask_found(&others, patience)).collect();
|
||||
// found_addresses puts the default address first.
|
||||
if addresses.first() == Some(&default) {
|
||||
answered.insert(0, (default.clone(), answer.clone()));
|
||||
}
|
||||
let found: Vec<FoundServer> = answered.iter().map(|(u, a)| found_server(u, a, &default)).collect();
|
||||
// The tab never offers to start a second server on the same sessions: it
|
||||
// opens the one that answers, or lists where they listen.
|
||||
let (url, outcome) = shown(&default, &answer, &answered);
|
||||
let (url, outcome) = (url.clone(), outcome.clone());
|
||||
|
||||
let app = home.join(".codeman/app");
|
||||
let install_complete = app.join(".install-complete").is_file();
|
||||
@@ -614,10 +754,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
Depth::Login => local_server::installed(false),
|
||||
Depth::Interactive => local_server::installed(true),
|
||||
};
|
||||
let state = local_state(&outcome, !servers.is_empty(), !found.is_empty(), installed);
|
||||
if state != LocalState::RunningElsewhere {
|
||||
elsewhere.clear();
|
||||
}
|
||||
let state = local_state(&outcome, !servers.is_empty(), !listening.is_empty(), installed);
|
||||
let version = match &outcome {
|
||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
||||
_ => None,
|
||||
@@ -634,7 +771,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
||||
install_port: install.flatten(),
|
||||
install_in_app,
|
||||
agent_install_in_app,
|
||||
elsewhere,
|
||||
found,
|
||||
outcome,
|
||||
}
|
||||
}
|
||||
@@ -1409,9 +1546,375 @@ mod tests {
|
||||
assert!(serves_loopback_port(&found, 4000));
|
||||
assert!(!serves_loopback_port(&found, 3999));
|
||||
assert!(!serves_loopback_port(&found, 5000));
|
||||
assert_eq!(listener_url(&found[1]).unwrap().as_str(), "http://172.17.0.1:3999/");
|
||||
assert_eq!(listener_url(&found[2]).unwrap().as_str(), "http://127.0.0.1:4000/");
|
||||
assert_eq!(listener_url(&found[3]).unwrap().as_str(), "http://[::1]:5000/");
|
||||
// The same four sockets as the addresses the app can try, by port.
|
||||
assert_eq!(
|
||||
addresses(&procs, &found, "http://127.0.0.1:3000/"),
|
||||
[
|
||||
"http://127.0.0.1:3000/",
|
||||
"http://172.17.0.1:3999/",
|
||||
"http://127.0.0.1:4000/",
|
||||
"http://[::1]:5000/"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn server(pid: u32, flags: &str) -> (u32, String) {
|
||||
(pid, format!("node /opt/homebrew/bin/codeman web{flags}"))
|
||||
}
|
||||
|
||||
fn listener(pid: u32, host: &str, port: u16) -> Listener {
|
||||
Listener {
|
||||
pid,
|
||||
host: host.into(),
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
/// found_addresses, as strings. The page sends one back in a `connect`,
|
||||
/// so each must be an address the connect takes as it is: the app saves
|
||||
/// a server, and finds its saved login, under that exact string.
|
||||
fn addresses(procs: &[(u32, String)], listeners: &[Listener], default: &str) -> Vec<String> {
|
||||
found_addresses(procs, listeners, &u(default))
|
||||
.iter()
|
||||
.map(|url| {
|
||||
assert_eq!(policy::normalize_server_url(url.as_str()).as_ref(), Ok(url));
|
||||
url.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_found_server_becomes_an_address_the_app_can_try() {
|
||||
let default = "http://127.0.0.1:3000/";
|
||||
let one = |flags: &str, listeners: &[Listener]| addresses(&[server(1, flags)], listeners, default);
|
||||
|
||||
// Every interface and IPv4 loopback are reached at 127.0.0.1.
|
||||
for host in ["*", "0.0.0.0", "[::]", "127.0.0.1"] {
|
||||
assert_eq!(
|
||||
one(" --port 4000", &[listener(1, host, 4000)]),
|
||||
["http://127.0.0.1:4000/"],
|
||||
"{host}"
|
||||
);
|
||||
}
|
||||
// IPv6 loopback keeps its own address only when it is the server's
|
||||
// only loopback socket.
|
||||
assert_eq!(one("", &[listener(1, "[::1]", 5000)]), ["http://[::1]:5000/"]);
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "[::1]", 5000), listener(1, "127.0.0.1", 5000)]),
|
||||
["http://127.0.0.1:5000/"]
|
||||
);
|
||||
// A dual-stack bind shows as two sockets, both `*`: one address.
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "*", 5000), listener(1, "*", 5000)]),
|
||||
["http://127.0.0.1:5000/"]
|
||||
);
|
||||
// A bind to one address stays that address.
|
||||
assert_eq!(
|
||||
one(" --host 192.168.1.5", &[listener(1, "192.168.1.5", 3000)]),
|
||||
["http://192.168.1.5:3000/"]
|
||||
);
|
||||
|
||||
// --https and --base-url come from the command line, in both spellings.
|
||||
assert_eq!(
|
||||
one(" --https --port 4000 --base-url /codeman/", &[listener(1, "*", 4000)]),
|
||||
["https://127.0.0.1:4000/codeman/"]
|
||||
);
|
||||
assert_eq!(
|
||||
one(" -p 4000 --base-url=cm", &[listener(1, "127.0.0.1", 4000)]),
|
||||
["http://127.0.0.1:4000/cm/"]
|
||||
);
|
||||
// The value of --host is never read as a flag, and a base path Codeman
|
||||
// would refuse counts as none.
|
||||
assert_eq!(
|
||||
one(" --host --https -p 4000", &[listener(1, "127.0.0.1", 4000)]),
|
||||
["http://127.0.0.1:4000/"]
|
||||
);
|
||||
assert_eq!(
|
||||
one(" -p 4000 --base-url /a?b", &[listener(1, "127.0.0.1", 4000)]),
|
||||
["http://127.0.0.1:4000/"]
|
||||
);
|
||||
// What comes before `web` is node's business.
|
||||
assert_eq!(
|
||||
addresses(
|
||||
&[(1, "node --https /Users/k/.codeman/app/dist/index.js web".to_string())],
|
||||
&[listener(1, "*", 4000)],
|
||||
default
|
||||
),
|
||||
["http://127.0.0.1:4000/"]
|
||||
);
|
||||
|
||||
// A server with two listeners. Beside a loopback socket on the same
|
||||
// port, another one is the same server (the Docker bridge listener);
|
||||
// on another port, or with no loopback socket, each is an address.
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "127.0.0.1", 3001), listener(1, "172.17.0.1", 3001)]),
|
||||
["http://127.0.0.1:3001/"]
|
||||
);
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "172.17.0.1", 3999), listener(1, "127.0.0.1", 3001)]),
|
||||
["http://127.0.0.1:3001/", "http://172.17.0.1:3999/"]
|
||||
);
|
||||
assert_eq!(
|
||||
one("", &[listener(1, "192.168.1.5", 3001), listener(1, "100.64.0.7", 3001)]),
|
||||
["http://100.64.0.7:3001/", "http://192.168.1.5:3001/"]
|
||||
);
|
||||
|
||||
// Only the sockets of Codeman servers count: not a control command's,
|
||||
// another program's, or one whose process `ps` did not list.
|
||||
let procs = [
|
||||
server(1, " --port 4000"),
|
||||
(2, "node /opt/homebrew/bin/codeman attach w1".to_string()),
|
||||
(3, "python3 -m http.server 8000".to_string()),
|
||||
];
|
||||
let sockets = [
|
||||
listener(2, "*", 4100),
|
||||
listener(3, "*", 8000),
|
||||
listener(9, "*", 9000),
|
||||
listener(1, "*", 4000),
|
||||
];
|
||||
assert_eq!(addresses(&procs, &sockets, default), ["http://127.0.0.1:4000/"]);
|
||||
assert!(addresses(&procs, &sockets[..3], default).is_empty());
|
||||
// An address that makes no URL is left out.
|
||||
assert!(one("", &[listener(1, "not a host", 3001)]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn found_addresses_start_with_the_default_one_then_go_by_port() {
|
||||
// install.sh's launchd job: a bare `web`, the base path in its environment.
|
||||
let default = "http://127.0.0.1:3001/codeman/";
|
||||
let procs = [
|
||||
(
|
||||
10,
|
||||
"node /x/tsx/cli.mjs /Users/k/codeman/src/index.ts web --port 5000".to_string(),
|
||||
),
|
||||
(
|
||||
20,
|
||||
"/opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web".to_string(),
|
||||
),
|
||||
server(30, " -p 4000 --https"),
|
||||
// The same address twice: a second process on the port of the first.
|
||||
server(40, " --port 5000"),
|
||||
];
|
||||
let sockets = [
|
||||
listener(10, "*", 5000),
|
||||
listener(20, "*", 3001),
|
||||
listener(30, "127.0.0.1", 4000),
|
||||
listener(40, "127.0.0.1", 5000),
|
||||
];
|
||||
assert_eq!(
|
||||
addresses(&procs, &sockets, default),
|
||||
[
|
||||
"http://127.0.0.1:3001/codeman/",
|
||||
"https://127.0.0.1:4000/",
|
||||
"http://127.0.0.1:5000/"
|
||||
]
|
||||
);
|
||||
// The same list whatever order `ps` and `lsof` gave.
|
||||
let mut reversed_procs = procs.to_vec();
|
||||
reversed_procs.reverse();
|
||||
let mut reversed_sockets = sockets.to_vec();
|
||||
reversed_sockets.reverse();
|
||||
assert_eq!(
|
||||
addresses(&reversed_procs, &reversed_sockets, default),
|
||||
addresses(&procs, &sockets, default)
|
||||
);
|
||||
|
||||
// On the default port, a command line with its own base path or
|
||||
// scheme is taken at its word: it may be another server than the job's.
|
||||
let on_3001 = |flags: &str| addresses(&[server(1, flags)], &[listener(1, "*", 3001)], default);
|
||||
assert_eq!(on_3001(" --port 3001"), [default]);
|
||||
assert_eq!(on_3001(" --port 3001 --base-url /codeman"), [default]);
|
||||
assert_eq!(
|
||||
on_3001(" --port 3001 --base-url /other"),
|
||||
["http://127.0.0.1:3001/other/"]
|
||||
);
|
||||
assert_eq!(on_3001(" --port 3001 --base-url /"), ["http://127.0.0.1:3001/"]);
|
||||
assert_eq!(on_3001(" --port 3001 --https"), ["https://127.0.0.1:3001/"]);
|
||||
// 127.0.0.1 only: a bind to another address is not the default one.
|
||||
assert_eq!(
|
||||
addresses(&[server(1, "")], &[listener(1, "192.168.1.5", 3001)], default),
|
||||
["http://192.168.1.5:3001/"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_found_server_row_says_what_answered() {
|
||||
let default = u("http://127.0.0.1:3000/");
|
||||
let at = u("http://127.0.0.1:4000/");
|
||||
let row = |answer: ProbeOutcome| found_server(&at, &answer, &default);
|
||||
assert_eq!(
|
||||
row(ProbeOutcome::Ok {
|
||||
version: "1.49.0".into()
|
||||
}),
|
||||
FoundServer {
|
||||
url: "http://127.0.0.1:4000/".into(),
|
||||
version: Some("1.49.0".into()),
|
||||
state: FoundState::Running,
|
||||
is_default: false,
|
||||
}
|
||||
);
|
||||
let state = |answer: ProbeOutcome| {
|
||||
let row = row(answer);
|
||||
assert_eq!(row.version, None);
|
||||
row.state
|
||||
};
|
||||
assert_eq!(state(ProbeOutcome::PasswordChangeRequired), FoundState::Running);
|
||||
for login in [
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::BadCredentials,
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: Some(60),
|
||||
},
|
||||
] {
|
||||
assert_eq!(state(login), FoundState::NeedsLogin);
|
||||
}
|
||||
for certificate in [
|
||||
ProbeOutcome::TlsUntrusted {
|
||||
detail: "invalid peer certificate: UnknownIssuer".into(),
|
||||
},
|
||||
ProbeOutcome::MaybeHttps,
|
||||
] {
|
||||
assert_eq!(state(certificate), FoundState::Certificate);
|
||||
}
|
||||
for silent in [
|
||||
ProbeOutcome::Refused,
|
||||
ProbeOutcome::Timeout,
|
||||
ProbeOutcome::Unreachable,
|
||||
ProbeOutcome::HostNotAllowed,
|
||||
ProbeOutcome::NotCodeman { status: 403 },
|
||||
ProbeOutcome::Failed { detail: "x".into() },
|
||||
] {
|
||||
assert_eq!(state(silent), FoundState::Unreachable);
|
||||
}
|
||||
assert!(found_server(&default, &ProbeOutcome::Refused, &default).is_default);
|
||||
|
||||
// As the connect page reads it.
|
||||
assert_eq!(
|
||||
serde_json::to_value(row(ProbeOutcome::Ok {
|
||||
version: "1.49.0".into()
|
||||
}))
|
||||
.unwrap(),
|
||||
json!({ "url": "http://127.0.0.1:4000/", "version": "1.49.0", "state": "running", "isDefault": false })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(found_server(&default, &ProbeOutcome::NeedsLogin, &default)).unwrap(),
|
||||
json!({ "url": "http://127.0.0.1:3000/", "version": null, "state": "needsLogin", "isDefault": true })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value([FoundState::Certificate, FoundState::Unreachable]).unwrap(),
|
||||
json!(["certificate", "unreachable"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tab_is_about_the_default_address_unless_nothing_answers_there() {
|
||||
let default = u("http://127.0.0.1:3000/");
|
||||
let ok = ProbeOutcome::Ok {
|
||||
version: "1.49.0".into(),
|
||||
};
|
||||
let refused = ProbeOutcome::Refused;
|
||||
let found = vec![
|
||||
(
|
||||
u("https://127.0.0.1:4000/"),
|
||||
ProbeOutcome::TlsUntrusted { detail: "x".into() },
|
||||
),
|
||||
(u("http://127.0.0.1:5000/"), ProbeOutcome::NeedsLogin),
|
||||
(u("http://127.0.0.1:6000/"), ok.clone()),
|
||||
];
|
||||
// Nothing at the default address: the first found server that
|
||||
// answers, one that wants a login included.
|
||||
assert_eq!(
|
||||
shown(&default, &refused, &found),
|
||||
(&found[1].0, &ProbeOutcome::NeedsLogin)
|
||||
);
|
||||
assert_eq!(shown(&default, &refused, &found[2..]), (&found[2].0, &ok));
|
||||
// None of them answers: the tab stays on the default address, and
|
||||
// local_state makes that RunningElsewhere.
|
||||
assert_eq!(shown(&default, &refused, &found[..1]), (&default, &refused));
|
||||
assert_eq!(shown(&default, &refused, &[]), (&default, &refused));
|
||||
// Whatever else the default address says is what the tab shows; the
|
||||
// servers found elsewhere are only listed.
|
||||
for answer in [
|
||||
ok.clone(),
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::NotCodeman { status: 404 },
|
||||
ProbeOutcome::Timeout,
|
||||
ProbeOutcome::MaybeHttps,
|
||||
] {
|
||||
assert_eq!(shown(&default, &answer, &found), (&default, &answer));
|
||||
}
|
||||
}
|
||||
|
||||
/// Against real sockets: every address is asked, at the same time, and
|
||||
/// the answers come back in the order of the addresses.
|
||||
#[test]
|
||||
fn found_servers_are_asked_together() {
|
||||
use std::io::Read;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// A server for one request: it reads it, and answers with `response`
|
||||
/// if `ready` says so.
|
||||
fn serve(response: String, ready: impl FnOnce() -> bool + Send + 'static) -> Url {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
let mut head = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => head.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
if ready() {
|
||||
let _ = s.write_all(response.as_bytes());
|
||||
}
|
||||
}
|
||||
});
|
||||
u(&format!("http://127.0.0.1:{port}/"))
|
||||
}
|
||||
fn response(status_line: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
// The first server answers only once the second has been asked. Asked
|
||||
// one after the other, it would hang up without an answer.
|
||||
let (asked, was_asked) = mpsc::channel::<()>();
|
||||
let first = serve(
|
||||
response("200 OK", r#"{"success":true,"data":{"version":"9.9.9"}}"#),
|
||||
move || was_asked.recv_timeout(Duration::from_secs(4)).is_ok(),
|
||||
);
|
||||
let second = serve(response("401 Unauthorized", "Unauthorized"), move || {
|
||||
asked.send(()).is_ok()
|
||||
});
|
||||
let closed = {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
u(&format!("http://127.0.0.1:{}/", l.local_addr().unwrap().port()))
|
||||
};
|
||||
assert_eq!(
|
||||
ask_found(&[first, second, closed], probe::TIMEOUT),
|
||||
[
|
||||
ProbeOutcome::Ok {
|
||||
version: "9.9.9".into()
|
||||
},
|
||||
ProbeOutcome::NeedsLogin,
|
||||
ProbeOutcome::Refused,
|
||||
]
|
||||
);
|
||||
|
||||
// One that does not answer in the time it is given.
|
||||
let slow = serve(String::new(), || {
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
false
|
||||
});
|
||||
assert_eq!(ask_found(&[slow], Duration::from_millis(300)), [ProbeOutcome::Timeout]);
|
||||
assert!(ask_found(&[], FOUND_TIMEOUT).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @fileoverview The graphical install's screens in the Codeman Desktop connect page.
|
||||
* @fileoverview The graphical install's screens in the Codeman Desktop connect page, and the This
|
||||
* Mac tab's rows for the Codeman servers the app found on this Mac.
|
||||
*
|
||||
* Runs the real packages/desktop/assets/connect.html in jsdom with a fake native side:
|
||||
* `window.ipc` records what the page asks the app for, and `onNative` is what the app
|
||||
* calls. The Rust side of the same messages is pinned in packages/desktop/src/policy.rs
|
||||
* (`ipc_graphical_install_commands`) and src/gui_install.rs, which do not build on Linux
|
||||
* CI, so the spelling of every message the page sends is asserted here.
|
||||
* (`ipc_graphical_install_commands`), src/gui_install.rs and src/setup.rs (the `found` list:
|
||||
* `a_found_server_row_says_what_answered`), which do not build on Linux CI, so the spelling of
|
||||
* every message the page sends, and of what it reads, is asserted here.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
@@ -45,7 +47,7 @@ const NOT_INSTALLED = {
|
||||
installComplete: false,
|
||||
installRunning: false,
|
||||
installInApp: false,
|
||||
elsewhere: [],
|
||||
found: [],
|
||||
outcome: { kind: 'refused' },
|
||||
};
|
||||
|
||||
@@ -482,7 +484,11 @@ describe('adding a coding agent to a Mac that has Codeman', () => {
|
||||
RUNNING,
|
||||
STOPPED,
|
||||
{ ...RUNNING, state: 'needsLogin' },
|
||||
{ ...RUNNING, state: 'runningElsewhere', elsewhere: ['127.0.0.1:4000'] },
|
||||
{
|
||||
...RUNNING,
|
||||
state: 'runningElsewhere',
|
||||
found: [{ url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false }],
|
||||
},
|
||||
]) {
|
||||
t.native({ type: 'localStatus', status });
|
||||
expect(t.visible('local-agents'), String(status.state)).toBe(true);
|
||||
@@ -735,3 +741,262 @@ describe('after Skip for now', () => {
|
||||
expect(t.sentOf('connect')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codeman servers found on this Mac', () => {
|
||||
type Found = { url: string; version: string | null; state: string; isDefault: boolean };
|
||||
const found = (url: string, state = 'running', version: string | null = '1.49.0'): Found => ({
|
||||
url,
|
||||
version: state === 'running' ? version : null,
|
||||
state,
|
||||
isDefault: false,
|
||||
});
|
||||
const ELSEWHERE = {
|
||||
...NOT_INSTALLED,
|
||||
state: 'runningElsewhere',
|
||||
installed: true,
|
||||
installComplete: true,
|
||||
found: [found('http://127.0.0.1:4000/'), found('http://192.168.1.5:5000/cm/', 'needsLogin')],
|
||||
};
|
||||
const RUNNING = {
|
||||
...NOT_INSTALLED,
|
||||
state: 'running',
|
||||
version: '1.49.0',
|
||||
installed: true,
|
||||
installComplete: true,
|
||||
outcome: { kind: 'ok', version: '1.49.0' },
|
||||
found: [{ ...found('http://127.0.0.1:3000/'), isDefault: true }],
|
||||
};
|
||||
|
||||
function at(status: Record<string, unknown>, first?: Record<string, unknown>) {
|
||||
const t = open(first ?? { servers: [] });
|
||||
t.native({ type: 'localStatus', status });
|
||||
const rows = (list: string) =>
|
||||
Array.from(t.$(list).querySelectorAll('.found')).map((row) => ({
|
||||
address: row.querySelector('b')?.textContent,
|
||||
note: row.querySelector('small')?.textContent ?? '',
|
||||
button: row.querySelector('button'),
|
||||
}));
|
||||
return { ...t, rows };
|
||||
}
|
||||
|
||||
it('recommends the server that runs at another address, a row each, with no trip to the other tab', () => {
|
||||
const t = at(ELSEWHERE);
|
||||
expect(t.$('local-title').textContent).toBe('Codeman is already running on this Mac.');
|
||||
expect(t.$('local-detail').textContent).toBe('');
|
||||
const rows = t.rows('local-found');
|
||||
expect(rows.map((r) => [r.address, r.note])).toEqual([
|
||||
['http://127.0.0.1:4000/', 'version 1.49.0'],
|
||||
['http://192.168.1.5:5000/cm/', 'Asks for a login.'],
|
||||
]);
|
||||
expect(rows.map((r) => r.button?.textContent)).toEqual(['Connect', 'Connect']);
|
||||
// The first row the app can open is the tab's main action.
|
||||
expect(rows.map((r) => r.button?.className)).toEqual(['primary', 'secondary']);
|
||||
expect(rows[0].button?.getAttribute('aria-label')).toBe('Connect to http://127.0.0.1:4000/');
|
||||
expect(t.visible('local-found-note')).toBe(true);
|
||||
expect(t.$('local-found-note').textContent).toBe('Starting another server here would take over its sessions.');
|
||||
expect(t.$('pane-local').textContent).not.toContain('Another machine');
|
||||
expect(t.visible('local-check')).toBe(true);
|
||||
for (const id of ['local-open', 'local-start', 'local-install', 'local-also']) {
|
||||
expect(t.visible(id), id).toBe(false);
|
||||
}
|
||||
expect(t.$('local-dot').className).toBe('dot ok');
|
||||
// Finding a server connects to nothing by itself.
|
||||
expect(t.sentOf('connect')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Connect is the connect of an address typed under Another machine', () => {
|
||||
const t = at(ELSEWHERE);
|
||||
t.rows('local-found')[1].button!.click();
|
||||
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://192.168.1.5:5000/cm/', remember: true }]);
|
||||
expect(t.input('url').value).toBe('http://192.168.1.5:5000/cm/');
|
||||
expect(t.$('status-text').textContent).toContain('Connecting to http://192.168.1.5:5000/cm/');
|
||||
// One request at a time, as everywhere on the page.
|
||||
expect(t.rows('local-found').every((r) => r.button!.disabled)).toBe(true);
|
||||
t.rows('local-found')[0].button!.click();
|
||||
expect(t.sentOf('connect')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses the login saved for that server, and never the one of the server shown before', () => {
|
||||
const servers = [
|
||||
{ url: 'https://box.ts.net/', username: 'other', hasSavedPassword: true },
|
||||
{ url: 'http://127.0.0.1:4000/', username: 'kay', hasSavedPassword: true },
|
||||
];
|
||||
const t = at(ELSEWHERE, { servers });
|
||||
t.click('tab-local');
|
||||
t.rows('local-found')[0].button!.click();
|
||||
expect(t.sentOf('connect')).toEqual([
|
||||
{ cmd: 'connect', url: 'http://127.0.0.1:4000/', remember: true, username: 'kay' },
|
||||
]);
|
||||
const u = at(ELSEWHERE, { servers: servers.slice(0, 1) });
|
||||
u.click('tab-local');
|
||||
u.rows('local-found')[0].button!.click();
|
||||
expect(u.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://127.0.0.1:4000/', remember: true }]);
|
||||
});
|
||||
|
||||
it('a server that asks for a login gets the login fields, as any connect does', () => {
|
||||
const t = at(ELSEWHERE);
|
||||
t.rows('local-found')[1].button!.click();
|
||||
t.native({
|
||||
type: 'probeResult',
|
||||
url: 'http://192.168.1.5:5000/cm/',
|
||||
outcome: { kind: 'needsLogin' },
|
||||
canStart: false,
|
||||
});
|
||||
expect(t.$('tab-remote').getAttribute('aria-selected')).toBe('true');
|
||||
expect(t.visible('login')).toBe(true);
|
||||
expect(t.input('url').value).toBe('http://192.168.1.5:5000/cm/');
|
||||
});
|
||||
|
||||
it('says why instead of offering Connect for a server the app cannot open', () => {
|
||||
const t = at({
|
||||
...ELSEWHERE,
|
||||
found: [
|
||||
found('https://127.0.0.1:4000/', 'certificate'),
|
||||
found('https://192.168.1.5:4100/', 'certificate'),
|
||||
found('http://[::1]:5000/', 'unreachable'),
|
||||
found('http://127.0.0.1:6000/'),
|
||||
],
|
||||
});
|
||||
const rows = t.rows('local-found');
|
||||
expect(rows.map((r) => !!r.button)).toEqual([false, false, false, true]);
|
||||
// The wording of a stopped Codeman that is set up with HTTPS.
|
||||
const https =
|
||||
'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
|
||||
expect(rows.slice(0, 2).map((r) => r.note)).toEqual([https, https]);
|
||||
expect(rows[2].note).toBe('It does not answer at this address.');
|
||||
const stopped = at({ ...NOT_INSTALLED, state: 'stopped', installed: true, url: 'https://127.0.0.1:3000/' });
|
||||
expect(stopped.$('local-detail').textContent).toBe(https);
|
||||
// The first one it can open is still the main action.
|
||||
expect(rows[3].button?.className).toBe('primary');
|
||||
expect(t.$('local-dot').className).toBe('dot ok');
|
||||
});
|
||||
|
||||
it('with no server it can open, offers no Connect and keeps Check again', () => {
|
||||
const t = at({ ...ELSEWHERE, found: [found('https://127.0.0.1:4000/', 'certificate')] });
|
||||
expect(t.$('pane-local').querySelectorAll('.found button')).toHaveLength(0);
|
||||
expect(t.$('local-dot').className).toBe('dot warn');
|
||||
expect(t.visible('local-found-note')).toBe(true);
|
||||
expect(t.visible('local-check')).toBe(true);
|
||||
t.click('local-check');
|
||||
expect(t.sentOf('localStatus').at(-1)).toEqual({ cmd: 'localStatus', quick: false, deep: true });
|
||||
});
|
||||
|
||||
it('a server process with no address to try still says what to do', () => {
|
||||
const t = at({ ...ELSEWHERE, found: [] });
|
||||
expect(t.$('local-title').textContent).toBe('Codeman is already running on this Mac.');
|
||||
expect(t.$('local-detail').textContent).toContain('could not find the address it listens at');
|
||||
expect(t.$('local-detail').textContent).toContain('take over its sessions');
|
||||
expect(t.visible('local-found')).toBe(false);
|
||||
expect(t.visible('local-found-note')).toBe(false);
|
||||
// A status from before the list existed reads the same.
|
||||
const old = at({ ...ELSEWHERE, found: undefined });
|
||||
expect(old.$('local-detail').textContent).toContain('could not find the address it listens at');
|
||||
});
|
||||
|
||||
it('keeps Open for the Codeman that answers, and lists the others under it', () => {
|
||||
const t = at({
|
||||
...RUNNING,
|
||||
found: [
|
||||
...RUNNING.found,
|
||||
found('http://127.0.0.1:5000/', 'running', '1.50.0-beta'),
|
||||
found('https://127.0.0.1:6000/', 'certificate'),
|
||||
],
|
||||
});
|
||||
expect(t.$('local-title').textContent).toBe('Codeman is running on this Mac.');
|
||||
expect(t.$('local-detail').textContent).toBe('http://127.0.0.1:3000/ · version 1.49.0');
|
||||
expect(t.visible('local-open')).toBe(true);
|
||||
expect(t.visible('local-also')).toBe(true);
|
||||
expect(t.$('local-also-head').textContent).toBe('Also running on this Mac');
|
||||
const rows = t.rows('local-also-list');
|
||||
// Not the one the first line is about.
|
||||
expect(rows.map((r) => [r.address, r.note.slice(0, 19)])).toEqual([
|
||||
['http://127.0.0.1:5000/', 'version 1.50.0-beta'],
|
||||
['https://127.0.0.1:6000/', 'It is set up with H'],
|
||||
]);
|
||||
// Open stays the main action: a row's Connect is never the primary one here.
|
||||
expect(rows.map((r) => r.button?.className)).toEqual(['secondary', undefined]);
|
||||
expect(t.visible('local-found')).toBe(false);
|
||||
expect(t.visible('local-found-note')).toBe(false);
|
||||
rows[0].button!.click();
|
||||
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: 'http://127.0.0.1:5000/', remember: true }]);
|
||||
});
|
||||
|
||||
it('lists them beside a Codeman that asks for a login, and beside something else on its port', () => {
|
||||
const other = found('http://127.0.0.1:5000/');
|
||||
const login = at({ ...RUNNING, state: 'needsLogin', outcome: { kind: 'needsLogin' }, found: [other] });
|
||||
expect(login.visible('local-open')).toBe(true);
|
||||
expect(login.rows('local-also-list').map((r) => r.address)).toEqual(['http://127.0.0.1:5000/']);
|
||||
|
||||
const taken = { ...RUNNING, state: 'other', version: null, outcome: { kind: 'notCodeman', status: 404 } };
|
||||
const t = at({ ...taken, found: [other] });
|
||||
expect(t.$('local-title').textContent).toBe('Something else answers at http://127.0.0.1:3000/');
|
||||
expect(t.$('local-detail').textContent).toContain('or connect to the Codeman listed below.');
|
||||
expect(t.rows('local-also-list').map((r) => r.button?.textContent)).toEqual(['Connect']);
|
||||
// With nothing to connect to, the old pointer stays.
|
||||
const none = at({ ...taken, found: [] });
|
||||
expect(none.$('local-detail').textContent).toContain('under Another machine');
|
||||
expect(none.visible('local-also')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows no list when this Mac runs only the Codeman the tab is about', () => {
|
||||
const t = at(RUNNING);
|
||||
expect(t.visible('local-also')).toBe(false);
|
||||
expect(t.$('pane-local').querySelectorAll('.found')).toHaveLength(0);
|
||||
// The rows of an earlier look go when the next one finds none.
|
||||
t.native({ type: 'localStatus', status: ELSEWHERE });
|
||||
expect(t.rows('local-found')).toHaveLength(2);
|
||||
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, state: 'stopped', installed: true } });
|
||||
expect(t.$('pane-local').querySelectorAll('.found')).toHaveLength(0);
|
||||
expect(t.visible('local-found')).toBe(false);
|
||||
expect(t.visible('local-found-note')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows what the app found as text, never as markup', () => {
|
||||
const hostile = '"><img src=x onerror=alert(1)><script>alert(2)</script>';
|
||||
const t = at({
|
||||
...ELSEWHERE,
|
||||
found: [{ url: hostile, version: '<b onclick=alert(3)>9</b>', state: 'running', isDefault: false }],
|
||||
});
|
||||
const rows = t.rows('local-found');
|
||||
expect(t.$('pane-local').querySelector('img, script, small b')).toBeNull();
|
||||
expect(rows[0].address).toBe(hostile);
|
||||
expect(rows[0].note).toBe('version <b onclick=alert(3)>9</b>');
|
||||
expect(rows[0].button?.title).toBe('Connect to ' + hostile);
|
||||
// The app gets it as it is, and refuses what is not an address (policy::normalize_server_url).
|
||||
rows[0].button!.click();
|
||||
expect(t.sentOf('connect')).toEqual([{ cmd: 'connect', url: hostile, remember: true }]);
|
||||
});
|
||||
|
||||
it('holds a found address to the length the app accepts (MAX_URL_LEN in policy.rs)', () => {
|
||||
const long = 'http://127.0.0.1:4000/' + 'a'.repeat(4096);
|
||||
const t = at({ ...ELSEWHERE, found: [found(long)] });
|
||||
t.rows('local-found')[0].button!.click();
|
||||
expect(t.sentOf('connect')).toHaveLength(0);
|
||||
expect(t.$('status-text').textContent).toContain('too long');
|
||||
});
|
||||
|
||||
it('looks again when the server it connected to refuses, and opens nothing by itself', () => {
|
||||
const t = at(ELSEWHERE);
|
||||
const looks = t.sentOf('localStatus').length;
|
||||
t.rows('local-found')[0].button!.click();
|
||||
t.native({ type: 'probeResult', url: 'http://127.0.0.1:4000/', outcome: { kind: 'refused' }, canStart: true });
|
||||
expect(t.$('tab-local').getAttribute('aria-selected')).toBe('true');
|
||||
expect(t.$('status-text').textContent).toContain('Nothing is answering at this address.');
|
||||
expect(t.sentOf('localStatus')).toHaveLength(looks + 1);
|
||||
// A server that then turns out to be starting is shown, not opened: the
|
||||
// one that comes up may not be the one this row was about.
|
||||
t.native({ type: 'localStatus', status: { ...ELSEWHERE, state: 'starting', found: [] } });
|
||||
t.native({ type: 'localStatus', status: RUNNING });
|
||||
expect(t.sentOf('connect')).toHaveLength(1);
|
||||
expect(t.visible('local-open')).toBe(true);
|
||||
});
|
||||
|
||||
it('still opens the Codeman of the first line once it is up, after Open was refused', () => {
|
||||
const t = at(RUNNING);
|
||||
t.click('local-open');
|
||||
t.native({ type: 'probeResult', url: RUNNING.url, outcome: { kind: 'refused' }, canStart: true });
|
||||
t.native({ type: 'localStatus', status: { ...RUNNING, state: 'starting', found: [] } });
|
||||
t.native({ type: 'localStatus', status: RUNNING });
|
||||
expect(t.sentOf('connect').map((m) => m.url)).toEqual([RUNNING.url, RUNNING.url]);
|
||||
});
|
||||
});
|
||||
@@ -874,15 +874,85 @@ async function everyState(browser: Browser) {
|
||||
p.length === 1 && p[0].cmd === 'localStatus' && p[0].deep === true && p[0].quick === false,
|
||||
p
|
||||
);
|
||||
await native({ type: 'localStatus', status: st16('runningElsewhere', { elsewhere: ['192.168.1.5:3000'] }) });
|
||||
const elsewhere = [
|
||||
{ url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false },
|
||||
{ url: 'http://192.168.1.5:3000/', version: '1.49.0', state: 'running', isDefault: false },
|
||||
];
|
||||
await native({ type: 'localStatus', status: st16('runningElsewhere', { found: elsewhere }) });
|
||||
check(
|
||||
'a server running elsewhere: no Start, no Install, says where',
|
||||
'a server running elsewhere: no Start, no Install, a row for each address',
|
||||
!(await visible('#local-start')) &&
|
||||
!(await visible('#local-install')) &&
|
||||
!(await visible('#local-open')) &&
|
||||
(await page.textContent('#local-detail')).includes('192.168.1.5:3000') &&
|
||||
(await page.textContent('#local-found')).includes('http://192.168.1.5:3000/') &&
|
||||
(await page.locator('#local-found .found').count()) === 2 &&
|
||||
(await page.textContent('#local-title')).includes('already running')
|
||||
);
|
||||
const foundBox = await page.evaluate(() => {
|
||||
const r = (sel) => document.querySelector(sel).getBoundingClientRect();
|
||||
const card = r('.card');
|
||||
const rows = [...document.querySelectorAll('#local-found .found')].map((row) => row.getBoundingClientRect());
|
||||
return {
|
||||
order: [r('.local').bottom, rows[0].top, rows[1].bottom, r('#local-found-note').top, r('#local-check').top],
|
||||
inside: rows.every((row) => row.left >= card.left && row.right <= card.right),
|
||||
buttons: [...document.querySelectorAll('#local-found .found')].map((row) => {
|
||||
const b = row.querySelector('button');
|
||||
return b ? b.className : '';
|
||||
}),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'the rows sit under the title, then the sentence about a second server, then Check again',
|
||||
foundBox.order.every((y, i) => i === 0 || y >= foundBox.order[i - 1]) && foundBox.inside,
|
||||
foundBox
|
||||
);
|
||||
check(
|
||||
'a server the app cannot open has no Connect; the first it can open is the main action',
|
||||
foundBox.buttons[0] === '' && foundBox.buttons[1] === 'primary',
|
||||
foundBox.buttons
|
||||
);
|
||||
n0 = (await all()).length;
|
||||
await page.locator('#local-found .found button').dblclick();
|
||||
p = (await all()).slice(n0);
|
||||
check(
|
||||
'Connect on a found server connects once, to its address',
|
||||
p.length === 1 && p[0].cmd === 'connect' && p[0].url === 'http://192.168.1.5:3000/' && !('password' in p[0]),
|
||||
p
|
||||
);
|
||||
await native({
|
||||
type: 'probeResult',
|
||||
url: 'http://192.168.1.5:3000/',
|
||||
outcome: { kind: 'refused' },
|
||||
canStart: false,
|
||||
});
|
||||
await native({
|
||||
type: 'localStatus',
|
||||
status: st16('running', {
|
||||
version: '1.49.0',
|
||||
outcome: { kind: 'ok', version: '1.49.0' },
|
||||
found: [{ url: 'http://127.0.0.1:3000/', version: '1.49.0', state: 'running', isDefault: true }, elsewhere[1]],
|
||||
}),
|
||||
});
|
||||
const alsoBox = await page.evaluate(() => {
|
||||
const r = (sel) => document.querySelector(sel).getBoundingClientRect();
|
||||
return {
|
||||
open: r('#local-open').bottom,
|
||||
also: r('#local-also').top,
|
||||
rows: document.querySelectorAll('#local-also .found').length,
|
||||
head: document.querySelector('#local-also-head').textContent,
|
||||
button: document.querySelector('#local-also .found button').className,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'running with another server on this Mac: it is listed under Open, as a secondary action',
|
||||
(await visible('#local-open')) &&
|
||||
alsoBox.also >= alsoBox.open &&
|
||||
alsoBox.rows === 1 &&
|
||||
alsoBox.head === 'Also running on this Mac' &&
|
||||
alsoBox.button === 'secondary' &&
|
||||
!(await visible('#local-found')),
|
||||
alsoBox
|
||||
);
|
||||
await native({ type: 'localStatus', status: st16('stopped', { url: 'https://127.0.0.1:3000/', service: true }) });
|
||||
check(
|
||||
'stopped with HTTPS: no Start (the app refuses its certificate), explained',
|
||||
@@ -1058,7 +1128,7 @@ async function layoutAndLimits(browser: Browser) {
|
||||
installComplete: true,
|
||||
installRunning: false,
|
||||
installPort: null,
|
||||
elsewhere: [],
|
||||
found: [],
|
||||
outcome: { kind: 'ok', version: '1.48.0' },
|
||||
},
|
||||
s
|
||||
@@ -1295,7 +1365,7 @@ async function clicksKeysAndStates(browser: Browser) {
|
||||
installComplete: true,
|
||||
installRunning: false,
|
||||
installPort: null,
|
||||
elsewhere: [],
|
||||
found: [],
|
||||
outcome: { kind: 'ok', version: '1.48.0' },
|
||||
},
|
||||
s
|
||||
@@ -1425,6 +1495,24 @@ async function clicksKeysAndStates(browser: Browser) {
|
||||
'Return never starts an install',
|
||||
(await t.posted('installLocal')).length === 0 && (await t.page.locator('#local-install').isVisible())
|
||||
);
|
||||
await t.native(
|
||||
localStatus({
|
||||
state: 'runningElsewhere',
|
||||
version: null,
|
||||
outcome: { kind: 'refused' },
|
||||
found: [
|
||||
{ url: 'https://127.0.0.1:4000/', version: null, state: 'certificate', isDefault: false },
|
||||
{ url: 'http://127.0.0.1:5000/', version: '1.49.0', state: 'running', isDefault: false },
|
||||
],
|
||||
})
|
||||
);
|
||||
await t.page.keyboard.press('Enter');
|
||||
check(
|
||||
'Return connects to the server found on this Mac when that is the main action',
|
||||
(await t.posted('connect')).length === 2 && (await t.posted('connect'))[1].url === 'http://127.0.0.1:5000/',
|
||||
await t.posted('connect')
|
||||
);
|
||||
await t.page.keyboard.press('Escape');
|
||||
await t.native(localStatus({ state: 'stopped', version: null, outcome: { kind: 'refused' } }));
|
||||
await t.page.focus('#local-check');
|
||||
await t.page.keyboard.press('Enter');
|
||||
|
||||
Reference in new issue
Block a user