Files
Codeman/packages/desktop/assets/connect.html
T
Codeman maintainer a42ce9c79f fix(desktop): a starting server, windows that open where expected, and the review's smaller findings
Found by the three-part review of this branch. Each was checked against the
source first; every decision that can run without a window has a unit test.

This Mac:
- A Codeman that runs but listens nowhere yet (the first seconds after login,
  a restart) was reported as "already running ... it listens at process N,
  which this app cannot open", and Start was refused for it. It is now its own
  state: the tab says "starting", looks again every second, and opens Codeman
  once it is up when a connect to this Mac had just been refused. The state
  table is a pure function with a test (setup::local_state).
- A server the app starts runs in the home folder, like one started by launchd
  or in a terminal. It ran in the app's own directory (`/` after a Finder
  launch), which Codeman then used as the default for sessions.
- Server detection accepts any entry word that `web` follows, so a dev server
  under a folder named codeman is seen and never gets a second server beside
  it.
- A login shell is polled from 5 ms up instead of every 100 ms, and the port
  after a launchd start every 100 ms instead of 500: both were pure waiting.
- The last line of stdout is no longer glued to the first line of stderr.

Addresses and policy:
- A tailnet name typed with a port gets http://, not https://. With a port it
  is Codeman's own listener; https there ended in a TLS error with no hint.
- within_base refuses `..%2F` and the other encoded ways out of the base path,
  so a sibling app behind a decoding proxy never sees the login.
- A status answer whose body stalls is a timeout, not "something else answers
  here".
- The folder pop-out pattern matches the two routes the server has.
- A malformed `window` in desktop.json no longer costs the server list.

Windows:
- Pop-outs step off the window in use. tao centres a window with no position,
  so every pop-out opened exactly on top of the one before.
- A window opened by a script no longer brings the app to the front: wry
  activated the application for it, whatever the window itself was told.
- The first-run window sits between the menu bar and the Dock. Centred in the
  whole display, its bottom was under the Dock on a 900 pt high one.
- The window frame is saved once after a move or resize ends, not once a
  second during the drag.

Also makes the connect page independent of TextEncoder and scrollIntoView, so
it runs in jsdom as well as in a web view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:40:53 +01:00

1048 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codeman</title>
<style>
:root {
color-scheme: dark;
--bg: #0a0a0a;
--panel: #121316;
--line: #23252b;
--line-strong: #30333b;
--text: #e8eaed;
--muted: #8b9099;
--accent: #60a5fa;
--accent-2: #3b82f6;
--bad: #f87171;
--warn: #fbbf24;
--ok: #4ade80;
}
* { box-sizing: border-box; }
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; flex-direction: column; align-items: center;
-webkit-user-select: none; user-select: none;
}
/* 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);
}
label.field { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; }
input[type=text], input[type=password], input[type=url] {
width: 100%; height: 38px; padding: 0 12px; border-radius: 9px;
border: 1px solid var(--line-strong); background: #0c0d10; color: var(--text);
font: inherit; outline: none; -webkit-user-select: text; user-select: text;
}
input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59,130,246,0.25); }
.row { display: flex; gap: 10px; }
.row > * { flex: 1; }
.gap { height: 14px; }
#recent { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
#recent:empty { display: none; }
.chip {
display: inline-flex; align-items: center; gap: 2px; max-width: 100%;
border: 1px solid var(--line-strong); border-radius: 999px; background: #0f1013;
color: var(--text); font-size: 12px;
}
.chip button { all: unset; cursor: default; padding: 4px 4px 4px 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px; }
.chip button.x { padding: 4px 9px 4px 4px; color: var(--muted); }
.chip button.x:hover { color: var(--bad); }
.chip:hover { border-color: var(--accent-2); }
.chip button:disabled { opacity: 0.5; }
#login[hidden] { display: none; }
#login { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
.check { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; margin-top: 10px; }
.check input { accent-color: var(--accent-2); margin: 0; }
.actions { display: flex; gap: 10px; margin-top: 18px; }
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, #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: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 { 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; }
#webkit { margin: 0 0 14px; padding: 10px 12px; border: 1px solid rgba(251,191,36,0.4); border-radius: 9px; background: rgba(251,191,36,0.08); color: var(--warn); font-size: 13px; -webkit-user-select: text; user-select: text; }
#webkit[hidden] { display: none; }
.spinner { width: 14px; height: 14px; flex: none; margin-top: 2px; border-radius: 50%; border: 2px solid rgba(96,165,250,0.25); border-top-color: var(--accent); animation: spin 0.8s linear infinite; }
.spinner[hidden] { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } }
.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); }
.tabs button[aria-selected="true"] { background: #1c1f26; color: var(--text); box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 1px 3px rgba(0,0,0,0.45); }
.tabs button:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 1px; }
[role=tabpanel][hidden] { display: none; }
.local { display: flex; gap: 12px; align-items: flex-start; min-height: 42px; }
.dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 5px; flex: none; background: #4b5059; }
.dot.ok { background: var(--ok); box-shadow: 0 0 0 4px rgba(74,222,128,0.14); }
.dot.warn { background: var(--warn); }
.dot.busy { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.35; } }
@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; }
#local-network-row[hidden] { display: 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; }
#elsewhere summary::-webkit-details-marker { display: none; }
#elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; }
#elsewhere[open] summary::before { transform: rotate(90deg); }
#elsewhere summary:hover { color: var(--text); }
#elsewhere .field { margin-top: 12px; }
.inline { display: flex; gap: 8px; align-items: center; }
.inline > input { flex: 1; min-width: 0; }
.inline > button { flex: none; }
.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: 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>
</head>
<body>
<main>
<header>
<img src="icon.png" alt="">
<h1>Codeman</h1>
<p>Run Codeman on this Mac, or connect to another machine</p>
</header>
<div class="card">
<div class="tabs" role="tablist" aria-label="Where Codeman runs">
<button type="button" role="tab" id="tab-local" aria-controls="pane-local" aria-selected="true">This Mac</button>
<button type="button" role="tab" id="tab-remote" aria-controls="form" aria-selected="false" tabindex="-1">Another machine</button>
</div>
<p id="webkit" role="alert" hidden>Codeman needs a newer WebKit than this Mac has: macOS 13.3 or later, or Safari 16.4 or later on macOS 11 and 12.</p>
<section id="pane-local" role="tabpanel" aria-labelledby="tab-local">
<div class="local">
<span class="dot busy" id="local-dot"></span>
<div role="status">
<div id="local-title">Looking for Codeman on this Mac…</div>
<div class="sub" id="local-detail"></div>
</div>
</div>
<label class="check" id="local-network-row" hidden><input id="local-network" type="checkbox"> Also reach it from my phone and other computers (the installer asks how)</label>
<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>
<button class="primary" id="local-install" type="button" hidden>Install Codeman on this Mac</button>
<button class="secondary" id="local-stop" type="button" hidden>Stop waiting</button>
<button class="secondary" id="local-check" type="button" hidden>Check again</button>
</div>
</section>
<form id="form" role="tabpanel" aria-labelledby="tab-remote" autocomplete="off" novalidate hidden>
<label class="field" for="url">Server address</label>
<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" role="group" aria-label="Recent servers"></div>
<div id="login" hidden>
<div class="row">
<div>
<label class="field" for="username">Username</label>
<input id="username" type="text" spellcheck="false" autocapitalize="off" placeholder="admin">
</div>
<div>
<label class="field" for="password">Password</label>
<input id="password" type="password" placeholder="">
</div>
</div>
<label class="check"><input id="remember" type="checkbox" checked> Remember the password in the Keychain</label>
</div>
<div class="actions">
<button class="primary" id="connect" type="submit">Connect</button>
<button class="secondary" id="start" type="button" hidden>Start Codeman on this Mac</button>
</div>
</form>
<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" 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>
<footer>
<span id="version">Codeman Desktop</span> &middot;
<a href="#" id="docs">Help</a>
</footer>
</main>
<script>
// Codeman's terminal (xterm 6) uses class static blocks, which WebKit parses
// from Safari 16.4 on. An older WebKit rejects this whole script, so the flag
// stays unset. This is its own script because the page's CSP has no
// 'unsafe-eval', so new Function cannot run the test.
void class { static { window.__codemanStaticBlocks = true; } };
</script>
<script>
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
// Codeman would not load in this web view: say so, never connect, and keep
// Connect disabled. The page still answers the app (ready, preferences).
var tooOld = window.__codemanStaticBlocks !== true;
var els = {
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'),
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'),
localNetworkRow: $('local-network-row'), localNetwork: $('local-network'),
localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'),
localStop: $('local-stop'), localCheck: $('local-check'),
elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy')
};
var manual = /[?&]manual=1/.test(location.search);
// 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). 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. 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.
var state = {
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
tab: 'local', askedFrom: 'local', local: null, localChecking: false, opening: false,
wait: null, waitTimer: 0, stoppedWaiting: false,
reopen: false, startingLooks: 0, startingTimer: 0
};
// 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;
// A server that is still starting: how often to look again, and how many times.
var STARTING_POLL_MS = 1000;
var STARTING_MAX_LOOKS = 20;
// 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;
function tooLong(text, max) {
var bytes = 0;
for (var i = 0; i < text.length; i++) {
var c = text.charCodeAt(i);
if (c < 0x80) bytes += 1;
else if (c < 0x800) bytes += 2;
else if (c >= 0xd800 && c < 0xdc00) { bytes += 4; i++; } // a surrogate pair: one character
else bytes += 3;
}
return bytes > 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) els.statusRow.scrollIntoView({ block: 'nearest' });
els.status.className = kind || '';
els.spinner.hidden = kind !== 'busy';
els.statusText.textContent = text || '';
if (detail) {
var d = document.createElement('span');
d.className = 'detail';
d.textContent = detail;
els.statusText.appendChild(d);
}
}
// One request at a time: every way to start one (Connect, a recent server,
// the suggest button) is disabled while busy, never hidden, so the second
// click of a double click cannot land on whatever moved into its place.
// Cancel only where the native side can really cancel: a probe or a page
// load, never a server start.
function setBusy(busy, cancellable) {
state.busy = busy;
els.connect.disabled = busy || tooOld;
els.suggest.disabled = busy || tooOld;
els.cancel.hidden = !(busy && cancellable !== false);
if (busy) els.start.hidden = true;
disableChips();
syncLocalButtons();
}
// Forgetting a server stays possible when the WebKit is too old; connecting
// to one does not.
function disableChips() {
Array.prototype.forEach.call(els.recent.querySelectorAll('button'), function (b) {
b.disabled = state.busy || (tooOld && !b.classList.contains('x'));
});
}
// Nothing to suggest when this WebKit cannot open Codeman at any address.
function suggest(url) {
if (tooOld) return;
state.suggestion = url;
els.suggest.textContent = 'Use ' + url;
els.suggest.title = 'Connect to ' + url;
els.suggest.hidden = false;
}
function hideSuggestion() {
state.suggestion = '';
els.suggest.hidden = true;
}
function host(url) {
try { var u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '')); } catch (e) { return url; }
}
// The address as the app normalizes it (policy::normalize_server_url):
// http:// by default, https:// for a .ts.net name without a port, no query
// or fragment, a trailing slash. Only used to tell whether two addresses
// are one server.
function normalize(raw) {
var s = String(raw || '').trim();
if (!s) return '';
if (s.indexOf('://') < 0) {
var name = s.split(/[\/:]/)[0].toLowerCase();
var tailscaleServe = /\.ts\.net$/.test(name) && s.charAt(name.length) !== ':';
s = (tailscaleServe ? 'https' : 'http') + '://' + s;
}
try {
var u = new URL(s);
u.search = '';
u.hash = '';
if (!/\/$/.test(u.pathname)) u.pathname += '/';
return u.href;
} catch (e) { return ''; }
}
function sameServer(a, b) {
var na = normalize(a);
return na !== '' && na === normalize(b);
}
function renderRecent() {
els.recent.textContent = '';
els.list.textContent = '';
state.servers.forEach(function (s) {
var opt = document.createElement('option');
opt.value = s.url;
els.list.appendChild(opt);
var chip = document.createElement('span');
chip.className = 'chip';
var pick = document.createElement('button');
pick.type = 'button';
pick.textContent = host(s.url) + (s.username ? ' (' + s.username + ')' : '');
pick.title = 'Connect to ' + s.url;
pick.addEventListener('click', function () {
if (state.busy) return;
choose(s);
connect();
});
var x = document.createElement('button');
x.type = 'button';
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);
els.recent.appendChild(chip);
});
disableChips();
}
function choose(s) {
els.url.value = s.url;
els.username.value = s.username || '';
els.password.value = '';
els.password.placeholder = s.hasSavedPassword ? 'saved in Keychain' : '';
els.login.hidden = !s.username;
}
// The saved server the address field names, if any, with its login.
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 (username) msg.username = username;
if (password) msg.password = password;
post(msg);
}
function showLogin() {
els.login.hidden = false;
if (!els.username.value) els.username.value = 'admin';
els.password.focus();
}
function hostName(url) {
try { return new URL(url).hostname.toLowerCase(); } catch (e) { return ''; }
}
var LOCAL_NETWORK = 'On macOS 15 and later the app may also need Local Network access: System Settings > Privacy & Security > Local Network, then turn on Codeman.';
var explain = {
needsLogin: ['warn', 'This server asks for a login.', 'Codeman’s default username is admin; the password is CODEMAN_PASSWORD on the server.'],
badCredentials: ['bad', 'Wrong username or password.', 'After 10 failed attempts Codeman locks logins from this address for 15 minutes.'],
rateLimited: ['bad', 'Too many failed logins from this address.', 'If you know the password, enter it and connect: a single-user Codeman accepts the right password even now. Otherwise, or on a multi-user server, wait up to 15 minutes and try again.'],
hostNotAllowed: ['bad', 'Codeman refused this host name.', 'Codeman only answers names it knows. Add this one to CODEMAN_ALLOWED_HOSTS on the server, or use its tailnet name or 127.0.0.1.'],
refused: ['bad', 'Nothing is answering at this address.', ''],
timeout: ['bad', 'The server did not answer within 5 seconds.', 'Check that the machine is awake and reachable (Tailscale connected, same network).'],
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.']
};
// The same two at localhost or 127.0.0.1, the only names Codeman's
// self-signed certificate carries, so the only ones where trusting it in
// Keychain Access can help.
var explainLoopback = {
maybeHttps: 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address. The app’s web view refuses Codeman’s self-signed certificate, though: marking it as trusted in Keychain Access may work, or run Codeman without --https.',
tlsUntrusted: 'This is usually Codeman’s self-signed certificate (codeman web --https), which the app’s web view refuses. Run Codeman without --https and use http://127.0.0.1:PORT instead. Marking the certificate as trusted in Keychain Access may also work.'
};
function loopbackName(url) {
var h = hostName(url);
return h === 'localhost' || h === '127.0.0.1';
}
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 || {};
var e = explain[o.kind];
if (o.kind === 'needsLogin' || o.kind === 'badCredentials' || o.kind === 'rateLimited') {
// The login fields live on the Another machine tab.
selectTab('remote');
showLogin();
} else if (asked === 'local') {
// Opening the Codeman on this Mac failed: look at it again.
state.reopen = o.kind === 'refused';
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.');
} else if (o.kind === 'redirect') {
var where = o.location ? 'It points to ' + o.location + '.' : '';
if (o.suggested) {
setStatus('warn', 'This address redirects to another one.', where + '\nCodeman seems to answer at ' + o.suggested + '.');
suggest(o.suggested);
} else {
setStatus('bad', 'This address redirects somewhere else.', where + '\nThe app connects only to the address Codeman itself answers on. Check the address and, behind a proxy, the path Codeman is mounted under.');
}
} else if (o.kind === 'failed') {
setStatus('bad', 'Could not connect.', o.detail || '');
} else if (e) {
var detail = e[2];
if (o.kind === 'refused' && msg.canStart) detail = 'Codeman is not running on this Mac. Start it here, or check the port.';
if (o.kind === 'rateLimited' && o.sentCredentials) detail = 'The saved or typed password may be wrong. ' + detail;
if (o.kind === 'unresolvable' && /\.local\.?$/.test(hostName(msg.url))) {
detail = 'Check the spelling, and that the machine is on and on the same network. ' + LOCAL_NETWORK;
}
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 {
setStatus('bad', 'Could not connect.');
}
els.start.hidden = !(o.kind === 'refused' && msg.canStart);
}
function onStartResult(msg) {
setBusy(false);
// Connect only to the server this page asked to start; after a reload
// mid-start, only to the one the address field shows.
var asked = state.startUrl || els.url.value;
state.startUrl = '';
if (!msg.ok) { setStatus('bad', 'Codeman did not start.', msg.output || ''); return; }
setStatus('ok', 'Codeman started.', msg.output || '');
if (!msg.url || !sameServer(msg.url, asked)) return;
if (!sameServer(msg.url, els.url.value)) { els.url.value = msg.url; matchSaved(); }
connect();
}
function isLocalUrl(url) {
var h = hostName(normalize(url));
return h === 'localhost' || /^127\./.test(h) || h === '[::1]';
}
// 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;
var local = tab === 'local';
els.tabLocal.setAttribute('aria-selected', String(local));
els.tabRemote.setAttribute('aria-selected', String(!local));
els.tabLocal.tabIndex = local ? 0 : -1;
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 (!later) ensureLocalChecked();
}
// 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).
function checkLocal(how) {
if (how !== 'quick') {
state.localChecking = true;
renderLocal();
}
var msg = { cmd: 'localStatus', quick: how === 'quick' };
if (how === 'deep') msg.deep = true;
post(msg);
}
function syncLocalButtons() {
els.localOpen.disabled = state.busy || tooOld;
els.localStart.disabled = state.busy || tooOld;
els.localInstall.disabled = state.busy || tooOld || state.opening;
els.localCheck.disabled = state.busy || state.localChecking;
els.sshInstall.disabled = state.busy || state.opening;
}
function renderLocal() {
var s = state.local;
var show = { open: false, start: false, install: false, stop: false, check: false, network: false };
var dot = '';
var title = '';
var detail = '';
if (state.wait) {
dot = 'busy';
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 (!s) {
dot = 'busy';
title = 'Looking for Codeman on this Mac…';
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
dot = 'busy';
title = 'Codeman is being installed in Terminal.';
detail = 'Finish the installer there, then Check again.';
show.check = true;
} else if (s.state === 'starting') {
dot = 'busy';
title = 'Codeman is starting on this Mac…';
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.';
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;
} 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;
} 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.';
} 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.'
: 'Start runs it in the background at ' + s.url + '.';
show.start = true;
}
show.check = true;
} else if (s.state === 'notInstalled') {
title = 'Codeman is not installed on this Mac.';
detail = 'Install runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password. ' +
'Codeman then runs in the background and starts when you log in, so your sessions keep running when this app is closed.';
show.install = true;
show.network = true;
show.check = true;
} else if (!s.installed) {
// Something else holds Codeman's port; a fresh install takes the next free one.
var other = s.outcome || {};
title = 'Codeman is not installed on this Mac.';
detail = 'Something else answers at ' + s.url + (other.kind === 'notCodeman' ? ' (HTTP ' + other.status + ')' : '') +
', so Install sets Codeman up on the next free port. It runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password.';
show.install = true;
show.network = true;
show.check = true;
} else {
var o = s.outcome || {};
dot = 'warn';
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.';
} else {
var e = explain[o.kind];
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
detail = (explainLoopback[o.kind]) || (e ? e[2] : (o.detail || ''));
}
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;
els.localOpen.hidden = !show.open;
els.localStart.hidden = !show.start;
els.localInstall.hidden = !show.install;
els.localStop.hidden = !show.stop;
els.localCheck.hidden = !show.check;
els.localNetworkRow.hidden = !show.network;
syncLocalButtons();
}
function onLocalStatus(s) {
state.localChecking = false;
state.local = s || null;
// An install from this app is still running (this page was shown again
// while it ran): wait for it, unless the user stopped waiting.
if (!state.wait && s && s.installRunning && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 };
renderLocal();
scheduleWait();
}
if (state.wait) {
onWaitStatus(state.local || {});
return;
}
if (followStarting(s)) return;
renderLocal();
}
// A Codeman that is still starting (its process runs, but listens nowhere
// yet: the first seconds after login, a restart): look again every second
// for a while. Once it is up, open it if a connect to this Mac was refused
// on the way here, which is what the user had asked for. True when it opened.
function followStarting(s) {
clearTimeout(state.startingTimer);
if (s && s.state === 'starting') {
if (state.startingLooks < STARTING_MAX_LOOKS) {
state.startingLooks++;
state.startingTimer = setTimeout(function () {
if (state.local && state.local.state === 'starting' && !state.wait) checkLocal('quick');
}, STARTING_POLL_MS);
}
return false;
}
var up = !!s && (s.state === 'running' || s.state === 'needsLogin');
var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy;
state.startingLooks = 0;
state.reopen = false;
if (open) {
renderLocal();
openLocal();
}
return open;
}
// Point the Another machine form at `url` with only the login saved for it:
// never the username or a typed password of the server the form showed
// before, which connect() would otherwise send along.
function useAddress(url) {
els.url.value = url || '';
els.login.hidden = true;
els.username.value = '';
els.password.value = '';
els.password.placeholder = '';
matchSaved();
}
function openLocal() {
if (state.busy || tooOld || !state.local) return;
useAddress(state.local.url);
connect();
}
function startLocal() {
if (state.busy || tooOld || !state.local) return;
useAddress(state.local.url);
state.startUrl = state.local.url;
post({ cmd: 'startServer', url: state.startUrl });
}
function installLocal() {
if (state.busy || tooOld || state.opening) return;
state.opening = true;
syncLocalButtons();
setStatus('busy', 'Opening Terminal…');
post({ cmd: 'installLocal', network: els.localNetwork.checked });
}
function scheduleWait() {
clearTimeout(state.waitTimer);
state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS);
}
function endWait() {
clearTimeout(state.waitTimer);
state.wait = null;
}
function portOf(url) {
try {
var u = new URL(url);
return Number(u.port || (u.protocol === 'https:' ? 443 : 80));
} 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();
if (s.installPort) w.port = s.installPort;
// Until the installer writes its LaunchAgent, the app looks at port 3000,
// where something else may answer when the install took another port.
var ours = portOf(s.url) === w.port;
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
endWait();
selectTab('local');
renderLocal();
openLocal();
return;
}
if (ours && s.state === 'other') {
// Something answers that is not a working Codeman: say what.
endWait();
renderLocal();
return;
}
if (s.installComplete && (s.state === 'stopped' || !ours)) {
if (!w.finishedAt) {
w.finishedAt = now;
} else if (now - w.finishedAt > WAIT_START_GRACE_MS) {
endWait();
renderLocal();
setStatus('warn', 'The install finished, but Codeman is not answering.', 'Start runs it now. If the installer reported a problem, its log is ~/.codeman/install.log.');
return;
}
}
if (now - w.since > WAIT_MAX_MS) {
endWait();
renderLocal();
setStatus('warn', 'Stopped waiting for the install.', 'Check again once the installer in Terminal is done.');
return;
}
scheduleWait();
}
function installRemote() {
if (state.busy || state.opening) return;
var target = els.ssh.value.trim();
if (!target) {
setStatus('bad', 'Enter the machine to install Codeman on, as user@host.');
els.ssh.focus();
return;
}
state.opening = true;
syncLocalButtons();
setStatus('busy', 'Opening Terminal…');
post({ cmd: 'installRemote', target: target });
}
window.__codemanDesktop = {
onNative: function (msg) {
switch (msg.type) {
case 'init':
state.servers = msg.servers || [];
state.startUrl = '';
setBusy(false);
hideSuggestion();
setStatus('', '');
els.start.hidden = true;
els.auto.checked = !!msg.autoConnect;
els.fullscreen.checked = !!msg.startFullscreen;
els.version.textContent = 'Codeman Desktop ' + (msg.version || '');
renderRecent();
if (state.servers[0]) choose(state.servers[0]);
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 (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;
if (typeof msg.autoConnect === 'boolean') els.auto.checked = msg.autoConnect;
break;
case 'probeResult':
onProbeResult(msg);
break;
case 'opening':
// Still busy: Cancel abandons the load and brings this page back.
if (msg.passwordChange) {
setStatus('ok', 'Opening Codeman…', 'Codeman will ask you for a new password for this account. Connect with the new password next time.');
} 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);
// The tab first: switching it clears the status line.
if (msg.needsPassword) {
selectTab('remote');
showLogin();
}
setStatus('bad', msg.message || 'Something went wrong.');
ensureLocalChecked();
break;
case 'serverStarting':
setBusy(true, false);
setStatus('busy', 'Starting Codeman on this Mac…');
break;
case 'serverStartResult':
onStartResult(msg);
break;
case 'localStatus':
onLocalStatus(msg.status);
break;
case 'installStarted':
state.opening = false;
setStatus('', '');
state.wait = { since: Date.now(), finishedAt: 0, port: Number(msg.port) || 3000 };
selectTab('local');
renderLocal();
scheduleWait();
break;
case 'remoteInstallStarted':
state.opening = false;
syncLocalButtons();
selectTab('remote');
useAddress(msg.url);
setStatus('ok', 'Terminal is installing Codeman on ' + msg.host + '.',
'Answer the installer’s questions there. At the end it prints the address of Codeman on that machine: enter it above and connect (' + msg.url + ' is the usual one).');
break;
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':
setStatus('ok', 'Copied.', 'Paste it into a terminal on the other machine.');
break;
}
}
};
// 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('', '');
ensureLocalChecked();
});
els.start.addEventListener('click', function () {
if (state.busy) return;
state.startUrl = state.lastUrl || els.url.value.trim();
post({ cmd: 'startServer', url: state.startUrl });
});
els.suggest.addEventListener('click', function () {
if (state.busy || !state.suggestion) return;
els.url.value = state.suggestion;
matchSaved();
connect();
});
els.auto.addEventListener('change', function () { post({ cmd: 'setAutoConnect', value: els.auto.checked }); });
els.fullscreen.addEventListener('change', function () { post({ cmd: 'setStartFullscreen', value: els.fullscreen.checked }); });
els.url.addEventListener('input', function () {
matchSaved();
els.start.hidden = true;
hideSuggestion();
});
els.tabLocal.addEventListener('click', function () { selectTab('local'); });
els.tabRemote.addEventListener('click', function () { selectTab('remote'); });
[els.tabLocal, els.tabRemote].forEach(function (tab) {
tab.addEventListener('keydown', function (ev) {
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return;
ev.preventDefault();
var next = state.tab === 'local' ? 'remote' : 'local';
selectTab(next);
(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;
// 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];
if (main) { ev.preventDefault(); main.click(); }
});
els.localOpen.addEventListener('click', openLocal);
els.localStart.addEventListener('click', startLocal);
els.localInstall.addEventListener('click', installLocal);
els.localStop.addEventListener('click', function () { state.stoppedWaiting = true; endWait(); checkLocal('login'); });
els.localCheck.addEventListener('click', function () { if (!state.busy && !state.localChecking) checkLocal('deep'); });
els.sshInstall.addEventListener('click', installRemote);
// Enter in the SSH field installs; it must not submit the form (Connect).
els.ssh.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') { ev.preventDefault(); installRemote(); }
});
els.copy.addEventListener('click', function () { post({ cmd: 'copyInstallCommand' }); });
$('docs').addEventListener('click', function (ev) { ev.preventDefault(); post({ cmd: 'openExternal', url: 'https://github.com/Ark0N/Codeman/wiki' }); });
if (tooOld) {
els.webkit.hidden = false;
setBusy(false);
}
post({ cmd: 'ready' });
})();
</script>
</body>
</html>