fix(desktop): a connect page that holds still, answers a press and never strands the user

Layout:
- The header no longer moves. The page was centred with align-items, so every
  change of the card's height moved everything (up to 150px between states),
  and a page taller than its window lost its top above the scroll origin,
  where no scrolling reaches it. The top margin is now fixed, the card grows
  downward and has a minimum height, and the page scrolls from its top. A
  short window gets a compact header.
- The status line sits right under the action it reports on, above the
  "not installed there yet?" section, takes no room while empty, and is
  scrolled into view in a small window. It was below the fold there.
- Looking at this Mac again keeps what was found on screen (the pane used to
  collapse to "Looking..." and grow back).

Feedback and keys:
- Buttons answer hover and press, every control shows keyboard focus, the
  primary button and the footer meet WCAG AA contrast, "Opening Codeman" keeps
  its spinner while the server page loads.
- Return opens or starts the Codeman on this Mac, Esc cancels a connect.
  Install always takes a click.
- The This Mac result is a live region, Forget buttons are named after their
  server, a long unbroken token wraps inside the card, the status text can be
  selected.

Correctness:
- The second click of a double click acts only on the control that took the
  first. It could connect to the server that slid under the pointer after a
  Forget, or flip "Connect automatically".
- An address, username or password too long for the app is refused with a
  reason. The app drops such a message without an answer, which left the page
  on "Connecting..." (the limits are pinned to policy.rs by a test).
- A connect answer goes to the tab that asked, a failed SSH install to its
  tab, and the login block no longer claims a saved password for another
  server.
- An automatic connect to the Codeman on this Mac no longer looks at this Mac
  first; a failed connect, Cancel or an error asks for that look.
- A login over plain http:// to another machine says that it is unencrypted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 03:26:57 +01:00
1 parent 01d3379b74
commit 116db3ba66
4 files changed
+163 -53

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ cannot do.
| --- | --- |
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. |
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<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. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<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. |
+147 -50
View File
@@ -20,20 +20,35 @@
--ok: #4ade80;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
html { height: 100%; }
body {
min-height: 100%; margin: 0;
background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
display: flex; align-items: center; justify-content: center;
display: flex; flex-direction: column; align-items: center;
-webkit-user-select: none; user-select: none;
}
main { width: min(440px, calc(100vw - 48px)); }
/* The top margin centres the tallest everyday page (a saved server with its
login, 708px), so the header stays where it is while the card changes: a
taller card grows downward, and a window too small for the page scrolls it
from the top (centring with align-items would cut the top off, out of
reach). */
main { width: min(440px, calc(100vw - 48px)); margin: max(20px, calc((100vh - 708px) / 2)) 0 24px; }
header { text-align: center; margin-bottom: 26px; }
header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; }
h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
header p { margin: 4px 0 0; color: var(--muted); }
@media (max-height: 640px) {
header { margin-bottom: 16px; }
header img { width: 48px; height: 48px; margin-bottom: 8px; }
header p { display: none; }
h1 { font-size: 20px; }
}
/* As tall as its usual content at least, with the two settings at its foot,
so switching tabs or a status arriving does not resize it. */
.card {
display: flex; flex-direction: column; min-height: 304px;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
}
@@ -67,16 +82,23 @@
button.primary, button.secondary {
height: 38px; border-radius: 9px; font: inherit; font-weight: 600; padding: 0 16px; cursor: default;
}
button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, var(--accent) 0%, var(--accent-2) 100%); }
button.primary:disabled { opacity: 0.55; }
button.primary { flex: 1; border: 0; color: white; background: linear-gradient(180deg, #2f6fed 0%, #2563eb 100%); }
button.secondary { border: 1px solid var(--line-strong); background: #16181d; color: var(--text); }
/* A press answers at once, before the app does. Disabled comes last, so a
button that cannot be pressed does not look as if it could. */
button.primary:hover { filter: brightness(1.07); }
button.primary:active { filter: brightness(0.9); }
button.secondary:hover { border-color: var(--accent-2); }
button.secondary:disabled { opacity: 0.55; }
button.secondary:active { background: #1f232b; }
button.primary:disabled, button.secondary:disabled { opacity: 0.55; filter: none; }
button.secondary:disabled { border-color: var(--line-strong); background: #16181d; }
button:focus-visible, .check input:focus-visible, summary:focus-visible, footer a:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
button[hidden] { display: none; }
#status { min-height: 20px; margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
#status { margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
#status.bad { color: var(--bad); }
#status.warn { color: var(--warn); }
#status.ok { color: var(--ok); }
#status-text { min-width: 0; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; }
#status .detail { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; white-space: pre-wrap; -webkit-user-select: text; user-select: text; }
#suggest { display: block; max-width: 100%; margin-top: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#suggest[hidden] { display: none; }
@@ -86,7 +108,8 @@
.spinner[hidden] { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } }
.prefs { margin-top: 16px; }
.prefs { margin-top: auto; }
.prefs::before { content: ''; display: block; margin-top: 16px; padding-top: 4px; border-top: 1px solid var(--line); }
.tabs { display: flex; gap: 4px; padding: 4px; margin: 0 0 18px; background: #0c0d10; border: 1px solid var(--line); border-radius: 11px; }
.tabs button { all: unset; flex: 1; text-align: center; height: 30px; line-height: 30px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--muted); cursor: default; }
.tabs button:hover { color: var(--text); }
@@ -103,7 +126,8 @@
#local-title { font-weight: 600; }
.sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; }
#local-network-row[hidden] { display: none; }
#elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
#elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
#elsewhere[hidden] { display: none; }
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
#elsewhere summary::-webkit-details-marker { display: none; }
#elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; }
@@ -116,10 +140,11 @@
.oneliner { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.oneliner code { flex: 1; min-width: 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; background: #0c0d10; border: 1px solid var(--line-strong); border-radius: 8px; padding: 9px 10px; color: #c9d1d9; -webkit-user-select: all; user-select: all; overflow-x: auto; white-space: nowrap; }
.statusrow { display: flex; gap: 10px; align-items: flex-start; }
.statusrow[hidden] { display: none; }
.statusrow #status { flex: 1; }
.statusrow #cancel { margin-top: 10px; }
footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; }
footer a { color: #7c8491; text-decoration: none; cursor: default; }
footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; }
footer a { color: #aab1bb; text-decoration: none; cursor: default; }
footer a:hover { color: var(--accent); }
kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 4px; padding: 2px 4px; color: #9aa1ab; }
</style>
@@ -143,7 +168,7 @@
<section id="pane-local" role="tabpanel" aria-labelledby="tab-local">
<div class="local">
<span class="dot busy" id="local-dot"></span>
<div>
<div role="status">
<div id="local-title">Looking for Codeman on this Mac…</div>
<div class="sub" id="local-detail"></div>
</div>
@@ -163,7 +188,7 @@
<input id="url" type="text" spellcheck="false" autocapitalize="off"
placeholder="http://127.0.0.1:3000 or https://your-box.ts.net" list="recent-list">
<datalist id="recent-list"></datalist>
<div id="recent" aria-label="Recent servers"></div>
<div id="recent" role="group" aria-label="Recent servers"></div>
<div id="login" hidden>
<div class="row">
@@ -184,26 +209,28 @@
<button class="secondary" id="start" type="button" hidden>Start Codeman on this Mac</button>
</div>
<details id="elsewhere">
<summary>Codeman is not installed there yet?</summary>
<label class="field" for="ssh">Install it over SSH</label>
<div class="inline">
<input id="ssh" type="text" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
<button class="secondary" id="ssh-install" type="button">Install</button>
</div>
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
</details>
</form>
<div class="statusrow">
<div class="statusrow" id="status-row" hidden>
<div id="status" role="status" aria-live="polite"><span class="spinner" id="spinner" hidden></span><span id="status-text"></span></div>
<button class="secondary" id="cancel" type="button" hidden>Cancel</button>
</div>
<button class="secondary" id="suggest" type="button" hidden></button>
<details id="elsewhere" hidden>
<summary>Codeman is not installed there yet?</summary>
<label class="field" for="ssh">Install it over SSH</label>
<div class="inline">
<!-- 300 is what setup.rs takes for a login, and at most 900 bytes: under the cap of a message the app accepts. -->
<input id="ssh" type="text" maxlength="300" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
<button class="secondary" id="ssh-install" type="button">Install</button>
</div>
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
</details>
<div class="prefs">
<label class="check"><input id="auto" type="checkbox"> Connect automatically when the app opens</label>
<label class="check"><input id="auto" type="checkbox" checked> Connect automatically when the app opens</label>
<label class="check"><input id="fullscreen" type="checkbox"> Always open in full screen <kbd>&#8963;&#8984;F</kbd></label>
</div>
</div>
@@ -232,7 +259,7 @@
form: $('form'), url: $('url'), list: $('recent-list'), recent: $('recent'),
login: $('login'), username: $('username'), password: $('password'), remember: $('remember'),
connect: $('connect'), cancel: $('cancel'), start: $('start'), suggest: $('suggest'),
status: $('status'), spinner: $('spinner'), statusText: $('status-text'),
statusRow: $('status-row'), status: $('status'), spinner: $('spinner'), statusText: $('status-text'),
auto: $('auto'), fullscreen: $('fullscreen'), version: $('version'), webkit: $('webkit'),
tabLocal: $('tab-local'), tabRemote: $('tab-remote'), paneLocal: $('pane-local'),
localDot: $('local-dot'), localTitle: $('local-title'), localDetail: $('local-detail'),
@@ -245,23 +272,34 @@
// startUrl: the address the user asked to start Codeman for ('' when this
// page did not ask, e.g. it was reloaded while a start was running).
// suggestion: the address the suggest button connects to.
// tab: 'local' (This Mac) or 'remote' (Another machine). local: the last
// This Mac status from the app. wait: set while an install runs in
// tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab
// the connect under way was asked from, where its answer belongs. local: the
// last This Mac status from the app. wait: set while an install runs in
// Terminal and this page polls for the server it brings up.
var state = {
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
tab: 'local', local: null, localChecking: false, opening: false, wait: null, waitTimer: 0,
stoppedWaiting: false
tab: 'local', askedFrom: 'local', local: null, localChecking: false, opening: false,
wait: null, waitTimer: 0, stoppedWaiting: false
};
// While an install runs: how often to look, how long a finished install may
// take to answer before Start is offered, and when to stop looking.
var WAIT_POLL_MS = 3000;
var WAIT_START_GRACE_MS = 90 * 1000;
var WAIT_MAX_MS = 30 * 60 * 1000;
// What the app takes in one message, in UTF-8 bytes (MAX_URL_LEN and
// MAX_FIELD_LEN in policy.rs). It drops a longer one without an answer, so
// the page has to be the one that says so.
var MAX_URL = 4096;
var MAX_FIELD = 1024;
var utf8 = new TextEncoder();
function tooLong(text, max) { return utf8.encode(text).length > max; }
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
function setStatus(kind, text, detail) {
els.statusRow.hidden = !text;
// In a small window the line may be below the fold: bring it into view.
if (text) els.statusRow.scrollIntoView({ block: 'nearest' });
els.status.className = kind || '';
els.spinner.hidden = kind !== 'busy';
els.statusText.textContent = text || '';
@@ -362,6 +400,7 @@
x.className = 'x';
x.textContent = '×';
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
x.setAttribute('aria-label', 'Forget ' + host(s.url));
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
chip.appendChild(pick);
chip.appendChild(x);
@@ -382,20 +421,27 @@
function matchSaved() {
var match = state.servers.filter(function (s) { return s.url === els.url.value.trim(); })[0];
if (match) choose(match);
else els.password.placeholder = '';
}
function connect() {
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; }
var username = els.login.hidden ? '' : els.username.value.trim();
var password = els.login.hidden ? '' : els.password.value;
if (tooLong(url, MAX_URL)) { setStatus('bad', 'That address is too long.'); els.url.focus(); return; }
if (tooLong(username, MAX_FIELD) || tooLong(password, MAX_FIELD)) {
setStatus('bad', 'That username or password is too long.');
return;
}
state.lastUrl = url;
state.askedFrom = state.tab;
setBusy(true);
setStatus('busy', 'Connecting to ' + url + '…');
var msg = { cmd: 'connect', url: url, remember: els.remember.checked };
if (!els.login.hidden) {
if (els.username.value.trim()) msg.username = els.username.value.trim();
if (els.password.value) msg.password = els.password.value;
}
if (username) msg.username = username;
if (password) msg.password = password;
post(msg);
}
@@ -421,8 +467,7 @@
unreachable: ['bad', 'This Mac cannot reach that address.', 'Check that the machine is on and on the same network (or that Tailscale is connected). ' + LOCAL_NETWORK],
unresolvable: ['bad', 'That host name could not be found.', 'Check the spelling, or that Tailscale is connected for a .ts.net name.'],
maybeHttps: ['bad', 'The server closed the connection without answering.', 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address, or your tailnet name (https://….ts.net). Codeman’s own self-signed certificate names only localhost and 127.0.0.1, so the app cannot accept it at this address: serve Codeman through tailscale serve (a real ts.net certificate) or a reverse proxy with a real certificate.'],
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.'],
passwordChangeRequired: ['warn', 'Codeman asks for a new password for this account.', 'Open it in a browser to set one, then connect with the new password.']
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.']
};
// The same two at localhost or 127.0.0.1, the only names Codeman's
@@ -439,6 +484,9 @@
}
function onProbeResult(msg) {
// The answer belongs to the tab its connect was asked from, whichever one
// shows by now; with no connect under way, to the one in view.
var asked = state.busy ? state.askedFrom : state.tab;
setBusy(false);
hideSuggestion();
var o = msg.outcome || {};
@@ -447,9 +495,13 @@
// The login fields live on the Another machine tab.
selectTab('remote');
showLogin();
} else if (state.tab === 'local') {
} else if (asked === 'local') {
// Opening the Codeman on this Mac failed: look at it again.
checkLocal('login');
selectTab('local');
// selectTab starts the look itself when there has been none yet.
if (!state.localChecking) checkLocal('login');
} else {
selectTab('remote');
}
if (o.kind === 'notCodeman') {
setStatus('bad', 'Something answered, but it is not a Codeman server (HTTP ' + o.status + ').', 'Check the port and, behind a proxy, the path Codeman is mounted under.');
@@ -472,6 +524,9 @@
}
if (explainLoopback[o.kind] && loopbackName(msg.url)) detail = explainLoopback[o.kind];
if (o.kind === 'tlsUntrusted' && o.detail) detail += '\n(' + o.detail + ')';
if ((o.kind === 'needsLogin' || o.kind === 'badCredentials') && /^http:/i.test(normalize(msg.url)) && !isLocalUrl(msg.url)) {
detail += ' This address is http://, so the password crosses the network unencrypted: fine on a tailnet or a network you trust.';
}
setStatus(e[0], e[1], detail);
if (o.kind === 'maybeHttps' && /^http:/i.test(msg.url || '')) suggest(String(msg.url).replace(/^http:/i, 'https:'));
} else {
@@ -498,7 +553,13 @@
return h === 'localhost' || /^127\./.test(h) || h === '[::1]';
}
function selectTab(tab) {
// Look at this Mac, once, when the This Mac tab shows and nothing has yet.
function ensureLocalChecked() {
if (state.tab === 'local' && !state.local && !state.localChecking) checkLocal('login');
}
// later: leave the look at this Mac to whoever asked (ensureLocalChecked).
function selectTab(tab, later) {
if (tab !== 'local' && tab !== 'remote') return;
var changed = state.tab !== tab;
state.tab = tab;
@@ -509,15 +570,14 @@
els.tabRemote.tabIndex = local ? -1 : 0;
els.paneLocal.hidden = !local;
els.form.hidden = local;
els.elsewhere.hidden = local;
if (changed && !state.busy) {
setStatus('', '');
hideSuggestion();
}
if (local && !state.local && !state.localChecking) checkLocal('login');
if (!later) ensureLocalChecked();
}
// quick: skip the login shell (used while an install runs, when only
// "does it answer yet" matters).
// How hard to look: 'quick' (files and processes, while an install runs),
// 'login' (also the login shell; every automatic look) or 'deep' (also the
// interactive profile, only on Check again).
@@ -550,7 +610,7 @@
title = 'Installing Codeman in Terminal…';
detail = 'Answer the installer’s questions there. This window opens Codeman when it is done.';
show.stop = true;
} else if (state.localChecking || !s) {
} else if (!s) {
dot = 'busy';
title = 'Looking for Codeman on this Mac…';
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
@@ -616,6 +676,8 @@
}
show.check = true;
}
// Looking again: what was found last stays on screen, so nothing jumps.
if (state.localChecking && s && !state.wait) dot = 'busy';
els.localDot.className = 'dot' + (dot ? ' ' + dot : '');
els.localTitle.textContent = title;
els.localDetail.textContent = detail;
@@ -688,9 +750,6 @@
state.wait = null;
}
// One answer while an install runs. It stops at the first answer from a
// server: a password-protected one counts every unauthenticated look as a
// failed login.
function portOf(url) {
try {
var u = new URL(url);
@@ -698,6 +757,9 @@
} catch (e) { return 0; }
}
// One answer while an install runs. It stops at the first answer from a
// server: a password-protected one counts every unauthenticated look as a
// failed login.
function onWaitStatus(s) {
var w = state.wait;
var now = Date.now();
@@ -766,20 +828,25 @@
els.version.textContent = 'Codeman Desktop ' + (msg.version || '');
renderRecent();
if (state.servers[0]) choose(state.servers[0]);
// The tab of the server used last; This Mac on a first run.
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local');
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 (msg.starting) {
setBusy(true, false);
setStatus('busy', 'Starting Codeman on this Mac…', msg.notice || '');
} else if (msg.notice) {
setStatus('warn', msg.notice);
}
if (msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]) connect();
if (auto) connect();
else if (state.tab === 'remote') els.url.focus();
break;
case 'servers':
state.servers = msg.servers || [];
renderRecent();
if (!state.busy) matchSaved();
break;
case 'prefs':
if (typeof msg.startFullscreen === 'boolean') els.fullscreen.checked = msg.startFullscreen;
@@ -795,6 +862,8 @@
} else {
setStatus('ok', msg.version ? 'Opening Codeman ' + msg.version + '…' : 'Opening Codeman…');
}
// The server page is still loading: this is not a finished state.
els.spinner.hidden = false;
break;
case 'error':
setBusy(false);
@@ -804,6 +873,7 @@
showLogin();
}
setStatus('bad', msg.message || 'Something went wrong.');
ensureLocalChecked();
break;
case 'serverStarting':
setBusy(true, false);
@@ -834,6 +904,8 @@
case 'installFailed':
state.opening = false;
syncLocalButtons();
// The tab first: switching it clears the status line.
if (msg.remote) selectTab('remote');
setStatus('bad', msg.message || 'Could not open Terminal.');
break;
case 'copied':
@@ -843,8 +915,22 @@
}
};
// The second click of a double click acts only on the control that took the
// first: the first may have moved another one under the pointer.
var firstControl = null;
document.addEventListener('click', function (ev) {
var t = ev.target && ev.target.closest ? ev.target : null;
var c = t && (t.closest('label') || t.closest('button, summary, a, input'));
if (ev.detail < 2) { firstControl = c; return; }
if (c !== firstControl) { ev.preventDefault(); ev.stopPropagation(); }
}, true);
els.form.addEventListener('submit', function (ev) { ev.preventDefault(); connect(); });
els.cancel.addEventListener('click', function () { post({ cmd: 'cancel' }); setBusy(false); setStatus('', ''); });
els.cancel.addEventListener('click', function () {
post({ cmd: 'cancel' });
setBusy(false);
setStatus('', '');
ensureLocalChecked();
});
els.start.addEventListener('click', function () {
if (state.busy) return;
state.startUrl = state.lastUrl || els.url.value.trim();
@@ -874,6 +960,17 @@
(next === 'local' ? els.tabLocal : els.tabRemote).focus();
});
});
// Return opens or starts the Codeman on this Mac 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) {
if (ev.defaultPrevented) return;
if (ev.key === 'Escape' && !els.cancel.hidden) { ev.preventDefault(); els.cancel.click(); return; }
if (ev.key !== 'Enter' || state.tab !== 'local') return;
if (ev.target && ev.target.closest && ev.target.closest('button, a, input, summary')) return;
var main = [els.localOpen, els.localStart].filter(function (b) { return !b.hidden && !b.disabled; })[0];
if (main) { ev.preventDefault(); main.click(); }
});
els.localOpen.addEventListener('click', openLocal);
els.localStart.addEventListener('click', startLocal);
els.localInstall.addEventListener('click', installLocal);
+13
View File
@@ -282,6 +282,19 @@ mod tests {
assert!(!HOST_SHIM.contains("decodeURIComponent"));
}
/// The connect page refuses what the app would drop without an answer, so
/// its two limits have to be the app's own.
#[test]
fn connect_page_keeps_the_message_limits() {
let page = String::from_utf8_lossy(CONNECT_HTML);
for needle in [
format!("var MAX_URL = {};", crate::policy::MAX_URL_LEN),
format!("var MAX_FIELD = {};", crate::policy::MAX_FIELD_LEN),
] {
assert!(page.contains(&needle), "{needle}");
}
}
#[test]
fn toast_escapes_file_names() {
let js = download_toast_script("a\"); alert(1); (\".txt", true);
+2 -2
View File
@@ -19,8 +19,8 @@ pub const APP_SCHEME: &str = "codemanapp";
pub const CONNECT_PAGE: &str = "codemanapp://localhost/index.html";
/// Caps on what a page may hand the native side.
const MAX_URL_LEN: usize = 4096;
const MAX_FIELD_LEN: usize = 1024;
pub const MAX_URL_LEN: usize = 4096;
pub const MAX_FIELD_LEN: usize = 1024;
/// Where the main window is in reaching a server. The gates below take it
/// instead of "is a server set", because what may talk to the native side