mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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>
1858 lines
83 KiB
HTML
1858 lines
83 KiB
HTML
<!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; }
|
||
/* 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; }
|
||
#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; }
|
||
/* The graphical install takes over the card. Its steps differ in height, and
|
||
the page is anchored from the top (see `main`), so the logo stays put. */
|
||
main:has(.card.wizard-on) header p { display: none; }
|
||
.card.wizard-on > .tabs, .card.wizard-on > #pane-local, .card.wizard-on > #form,
|
||
.card.wizard-on > .statusrow, .card.wizard-on > #suggest, .card.wizard-on > #elsewhere,
|
||
.card.wizard-on > .prefs { display: none; }
|
||
#wizard[hidden], #wizard [hidden] { display: none; }
|
||
.wiz-head { display: flex; align-items: center; gap: 9px; }
|
||
.wiz-head .spinner { margin-top: 0; }
|
||
#wiz-title { font-size: 17px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
|
||
#wiz-title:focus { outline: none; }
|
||
#wiz-lead { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
|
||
#wiz-message {
|
||
margin: 12px 0 0; padding: 10px 12px; border: 1px solid rgba(248,113,113,0.4); border-radius: 9px;
|
||
background: rgba(248,113,113,0.08); color: var(--bad); font-size: 13px; white-space: pre-wrap;
|
||
overflow-wrap: anywhere; -webkit-user-select: text; user-select: text;
|
||
}
|
||
#wiz-message.warn { border-color: rgba(251,191,36,0.4); background: rgba(251,191,36,0.08); color: var(--warn); }
|
||
.wiz-group { margin-top: 14px; }
|
||
#wiz-agents { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||
.choice {
|
||
display: flex; gap: 9px; align-items: flex-start; padding: 9px 11px; border-radius: 9px;
|
||
border: 1px solid var(--line-strong); background: #0f1013; cursor: default;
|
||
}
|
||
.choice:hover { border-color: #454a55; }
|
||
.choice:has(input:checked) { border-color: var(--accent-2); background: rgba(59,130,246,0.1); }
|
||
.choice:has(input:focus-visible) { outline: 2px solid var(--accent-2); outline-offset: 1px; }
|
||
.choice input { accent-color: var(--accent-2); margin: 2px 0 0; flex: none; }
|
||
.choice b { display: block; font-weight: 600; }
|
||
.choice small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }
|
||
.choice:has(input:disabled) { border-color: var(--line); background: none; color: var(--muted); }
|
||
#wiz-reach .choice + .choice { margin-top: 6px; }
|
||
#wiz-password { margin-top: 10px; }
|
||
#wiz-agent-note { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
|
||
#wiz-agent-note a, .wiz-link { all: unset; color: #7c8491; cursor: default; }
|
||
#wiz-agent-note a { color: var(--accent); white-space: nowrap; }
|
||
#wiz-agent-note a:hover, .wiz-link:hover { color: var(--text); }
|
||
.wiz-link:focus-visible, #wiz-agent-note a:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; border-radius: 3px; }
|
||
.wiz-link { display: block; margin: 12px auto 0; font-size: 12px; text-align: center; }
|
||
#wiz-summary { margin: 14px 0 0; padding: 12px 0 0; border-top: 1px solid var(--line); list-style: none; color: var(--muted); font-size: 12px; }
|
||
#wiz-summary li { padding: 2px 0 2px 14px; position: relative; }
|
||
#wiz-summary li::before { content: ''; position: absolute; left: 2px; top: 9px; width: 4px; height: 4px; border-radius: 50%; background: #5d626b; }
|
||
#wiz-summary li.warn { color: var(--warn); }
|
||
/* The install's rail: one node per phase, joined by a line that fills in as
|
||
the phases finish. The running node carries the phase's own clock, because
|
||
the longest phases print nothing while they work. */
|
||
#wiz-rail { margin: 16px 0 0; padding: 0; list-style: none; }
|
||
#wiz-rail li { position: relative; display: flex; align-items: baseline; gap: 10px; padding: 0 0 13px 24px; color: #6b717c; }
|
||
#wiz-rail li:last-child { padding-bottom: 0; }
|
||
#wiz-rail li::before {
|
||
content: ''; position: absolute; left: 4px; top: 6px; width: 8px; height: 8px; box-sizing: content-box;
|
||
border-radius: 50%; background: var(--panel); border: 1px solid #454a55; z-index: 1;
|
||
}
|
||
#wiz-rail li::after { content: ''; position: absolute; left: 8.5px; top: 12px; bottom: -6px; width: 1px; background: var(--line-strong); }
|
||
#wiz-rail li:last-child::after { display: none; }
|
||
#wiz-rail li.done { color: var(--text); }
|
||
#wiz-rail li.done::before { background: var(--ok); border-color: var(--ok); }
|
||
#wiz-rail li.done::after { background: rgba(74,222,128,0.45); }
|
||
#wiz-rail li.now { color: var(--text); font-weight: 600; }
|
||
#wiz-rail li.now::before { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px rgba(96,165,250,0.18); animation: pulse 1.2s ease-in-out infinite; }
|
||
#wiz-rail li.failed { color: var(--bad); font-weight: 600; }
|
||
#wiz-rail li.failed::before { background: var(--bad); border-color: var(--bad); }
|
||
@media (prefers-reduced-motion: reduce) { #wiz-rail li.now::before { animation: none; } }
|
||
#wiz-rail .clock { margin-left: auto; color: var(--muted); font-size: 12px; font-weight: 400; font-variant-numeric: tabular-nums; }
|
||
#wiz-details { margin-top: 14px; }
|
||
#wiz-details summary { color: var(--muted); font-size: 12px; cursor: default; list-style: none; }
|
||
#wiz-details summary::-webkit-details-marker { display: none; }
|
||
#wiz-details summary::before { content: '\25B8'; display: inline-block; width: 13px; transition: transform 0.15s; }
|
||
#wiz-details[open] summary::before { transform: rotate(90deg); }
|
||
#wiz-details summary:hover { color: var(--text); }
|
||
#wiz-log {
|
||
margin: 8px 0 0; height: 124px; overflow: auto; padding: 8px 10px; border-radius: 8px; background: #0c0d10;
|
||
border: 1px solid var(--line); color: #aab1bb; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text;
|
||
}
|
||
#wizard .actions .wiz-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
|
||
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>
|
||
|
||
<section id="wizard" aria-labelledby="wiz-title" hidden>
|
||
<div class="wiz-head"><span class="spinner" id="wiz-spinner" hidden></span><h2 id="wiz-title" tabindex="-1"></h2></div>
|
||
<p id="wiz-lead"></p>
|
||
<div id="wiz-message" role="alert" hidden></div>
|
||
|
||
<div class="wiz-group" id="wiz-agent-step" hidden>
|
||
<div id="wiz-agents" role="radiogroup" aria-labelledby="wiz-title"></div>
|
||
<p id="wiz-agent-note"></p>
|
||
</div>
|
||
|
||
<div class="wiz-group" id="wiz-reach-step" hidden>
|
||
<div id="wiz-reach" role="radiogroup" aria-labelledby="wiz-title">
|
||
<label class="choice"><input type="radio" name="reach" value="local" checked>
|
||
<span><b>Only this Mac</b><small>The safest choice. You can open it to other devices later.</small></span></label>
|
||
<label class="choice"><input type="radio" name="reach" value="network">
|
||
<span><b>This Mac and my local network</b><small>Phones and laptops on the same Wi-Fi sign in with a password.</small></span></label>
|
||
</div>
|
||
<div id="wiz-password" class="row" hidden>
|
||
<div><label class="field" for="wiz-pw">Password</label><input id="wiz-pw" type="password" autocomplete="new-password"></div>
|
||
<div><label class="field" for="wiz-pw2">Repeat it</label><input id="wiz-pw2" type="password" autocomplete="new-password"></div>
|
||
</div>
|
||
<ul id="wiz-summary"></ul>
|
||
</div>
|
||
|
||
<ol id="wiz-rail" hidden></ol>
|
||
<details id="wiz-details" hidden>
|
||
<summary>Show the installer’s output</summary>
|
||
<pre id="wiz-log"></pre>
|
||
</details>
|
||
|
||
<div class="actions">
|
||
<button class="secondary" id="wiz-back" type="button" hidden>Back</button>
|
||
<button class="secondary" id="wiz-skip" type="button" hidden>Skip for now</button>
|
||
<button class="primary" id="wiz-next" type="button" hidden></button>
|
||
<button class="secondary" id="wiz-cancel" type="button" hidden>Cancel install</button>
|
||
</div>
|
||
<button class="wiz-link" id="wiz-terminal" type="button" hidden>Install in Terminal instead (asks for your Mac password)</button>
|
||
</section>
|
||
|
||
<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>
|
||
<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>
|
||
<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>
|
||
<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>
|
||
<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>⌃⌘F</kbd></label>
|
||
</div>
|
||
</div>
|
||
|
||
<footer>
|
||
<span id="version">Codeman Desktop</span> ·
|
||
<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'),
|
||
card: document.querySelector('.card'),
|
||
wizard: $('wizard'), wizSpinner: $('wiz-spinner'), wizTitle: $('wiz-title'), wizLead: $('wiz-lead'), wizMessage: $('wiz-message'),
|
||
wizAgentStep: $('wiz-agent-step'), wizAgents: $('wiz-agents'), wizAgentNote: $('wiz-agent-note'),
|
||
wizReachStep: $('wiz-reach-step'), wizPassword: $('wiz-password'), wizPw: $('wiz-pw'), wizPw2: $('wiz-pw2'),
|
||
wizSummary: $('wiz-summary'), wizRail: $('wiz-rail'), wizDetails: $('wiz-details'), wizLog: $('wiz-log'),
|
||
wizBack: $('wiz-back'), wizSkip: $('wiz-skip'), wizNext: $('wiz-next'), wizCancel: $('wiz-cancel'),
|
||
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);
|
||
// 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. gui: the
|
||
// 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. 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', askedFound: false, local: null, localChecking: false, opening: false,
|
||
wait: null, waitTimer: 0, stoppedWaiting: false, gui: null, wantAgents: 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;
|
||
// The phases `install.sh --progress` reports, in its order.
|
||
var PHASES = ['runtime', 'agent', 'source', 'packages', 'build', 'service'];
|
||
var MAX_LOG_LINES = 600;
|
||
// 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 = '';
|
||
}
|
||
|
||
// 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; }
|
||
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;
|
||
state.askedFound = found === true;
|
||
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. 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');
|
||
} 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.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() {
|
||
var s = state.local;
|
||
var show = { open: false, start: false, install: false, stop: false, check: false };
|
||
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…';
|
||
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') {
|
||
// 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 = 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.'
|
||
: '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 sets it up here in a few minutes, with no password and no Terminal. ' +
|
||
'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.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: a few minutes, with no password and no Terminal.';
|
||
show.install = true;
|
||
show.check = true;
|
||
} 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 ' +
|
||
(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.';
|
||
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;
|
||
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();
|
||
}
|
||
|
||
function onLocalStatus(s) {
|
||
var wanted = state.wantAgents;
|
||
state.wantAgents = false;
|
||
state.localChecking = false;
|
||
state.local = s || null;
|
||
// A graphical run is going without this page (it was reloaded, or the app
|
||
// was quit and opened again): show it from its first line.
|
||
if (s && (s.installInApp || s.agentInstallInApp) && !state.gui) {
|
||
state.gui = newWizard('installing', s.installInApp ? 'install' : 'agent');
|
||
state.gui.port = s.installPort || 3000;
|
||
post({ cmd: s.installInApp ? 'installAttach' : 'agentAttach' });
|
||
renderWizard();
|
||
return;
|
||
}
|
||
// 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 && !s.installInApp && !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();
|
||
if (wanted && s && s.installed && !state.gui) openAgentChooser();
|
||
else if (state.gui && state.gui.step === 'added') renderWizard();
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
// 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);
|
||
state.startUrl = state.local.url;
|
||
post({ cmd: 'startServer', url: state.startUrl });
|
||
}
|
||
|
||
// The Terminal route: what the graphical install cannot do, and what anyone
|
||
// may prefer. The installer asks its own questions there.
|
||
function installInTerminal() {
|
||
if (state.busy || tooOld || state.opening) return;
|
||
var network = !!state.gui && state.gui.reach === 'network';
|
||
closeWizard();
|
||
selectTab('local');
|
||
state.opening = true;
|
||
syncLocalButtons();
|
||
setStatus('busy', 'Opening Terminal…');
|
||
post({ cmd: 'installLocal', network: network });
|
||
}
|
||
|
||
// mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac
|
||
// that has Codeman, on the same screens, with no question about Codeman.
|
||
function newWizard(step, mode) {
|
||
return {
|
||
step: step, mode: mode || 'install', plan: null, agent: '', reach: 'local', port: 3000, message: '', phase: '',
|
||
phaseSince: 0, seen: {}, log: [], ok: false, noAgent: false
|
||
};
|
||
}
|
||
|
||
function openWizard() {
|
||
if (state.busy || tooOld || state.opening) return;
|
||
state.gui = newWizard('preparing');
|
||
post({ cmd: 'installPrepare' });
|
||
renderWizard();
|
||
}
|
||
|
||
function openAgentChooser() {
|
||
if (state.busy || tooOld || state.opening) return;
|
||
state.gui = newWizard('preparing', 'agent');
|
||
post({ cmd: 'agentPrepare' });
|
||
renderWizard();
|
||
}
|
||
|
||
// The menu's way in. It can arrive before the first look at this Mac, and a
|
||
// Mac with no Codeman gets This Mac's Install instead.
|
||
function addAgent() {
|
||
selectTab('local');
|
||
if (state.gui) return;
|
||
if (!state.local || state.localChecking) state.wantAgents = true;
|
||
else if (state.local.installed) openAgentChooser();
|
||
}
|
||
|
||
function closeWizard() {
|
||
clearInterval(state.clock);
|
||
state.gui = null;
|
||
renderWizard();
|
||
renderLocal();
|
||
}
|
||
|
||
function retryWizard() {
|
||
var reach = state.gui ? state.gui.reach : 'local';
|
||
var agent = state.gui ? state.gui.agent : '';
|
||
var mode = state.gui ? state.gui.mode : 'install';
|
||
state.gui = newWizard('preparing', mode);
|
||
state.gui.reach = reach;
|
||
state.gui.agent = agent;
|
||
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
|
||
renderWizard();
|
||
}
|
||
|
||
function installedAgents() {
|
||
var g = state.gui;
|
||
return g && g.plan ? g.plan.clis.filter(function (c) { return c.installed; }) : [];
|
||
}
|
||
|
||
function offeredAgents() {
|
||
var g = state.gui;
|
||
return g && g.plan ? g.plan.clis.filter(function (c) { return c.installable; }) : [];
|
||
}
|
||
|
||
function agentById(id) {
|
||
return offeredAgents().filter(function (c) { return c.id === id; })[0] || null;
|
||
}
|
||
|
||
// A run this page did not start has no plan, so no name for the agent.
|
||
function agentName() {
|
||
var c = agentById(state.gui.agent);
|
||
return c ? c.label : 'a coding agent';
|
||
}
|
||
|
||
// The first step the installer's answer calls for. In an install the agent
|
||
// step exists only when this Mac has no coding agent at all.
|
||
function onInstallPlan(plan) {
|
||
var g = state.gui;
|
||
if (!g || g.step !== 'preparing') return;
|
||
g.plan = plan;
|
||
if (g.mode === 'agent') {
|
||
// `blocker` says why Codeman cannot be installed, which is not the question.
|
||
if (!agentById(g.agent)) g.agent = offeredAgents().length ? offeredAgents()[0].id : '';
|
||
g.step = 'agent';
|
||
} else if (plan.blocker) {
|
||
g.step = 'blocked';
|
||
} else if (!installedAgents().length && offeredAgents().length) {
|
||
// Kept across Try again; otherwise the first of the catalogue's order.
|
||
if (!agentById(g.agent)) g.agent = offeredAgents()[0].id;
|
||
g.step = 'agent';
|
||
} else {
|
||
g.step = 'reach';
|
||
}
|
||
renderWizard();
|
||
}
|
||
|
||
function renderAgents() {
|
||
var g = state.gui;
|
||
els.wizAgents.textContent = '';
|
||
var rows = g.mode === 'agent' ? offeredAgents().concat(installedAgents()) : offeredAgents();
|
||
rows.forEach(function (c) {
|
||
var label = document.createElement('label');
|
||
label.className = 'choice';
|
||
var input = document.createElement('input');
|
||
input.type = 'radio';
|
||
input.name = 'agent';
|
||
input.value = c.id;
|
||
input.checked = c.id === g.agent;
|
||
input.disabled = c.installed;
|
||
input.addEventListener('change', function () {
|
||
g.agent = c.id;
|
||
renderAgentNote();
|
||
renderWizardButtons();
|
||
});
|
||
var name = document.createElement('b');
|
||
name.textContent = c.label;
|
||
var text = document.createElement('span');
|
||
text.appendChild(name);
|
||
if (c.installed) {
|
||
var here = document.createElement('small');
|
||
here.textContent = 'Installed';
|
||
text.appendChild(here);
|
||
}
|
||
label.appendChild(input);
|
||
label.appendChild(text);
|
||
els.wizAgents.appendChild(label);
|
||
});
|
||
renderAgentNote();
|
||
}
|
||
|
||
function renderAgentNote() {
|
||
var c = agentById(state.gui.agent);
|
||
els.wizAgentNote.textContent = '';
|
||
if (!c) return;
|
||
els.wizAgentNote.appendChild(document.createTextNode('You sign in to ' + c.label + ' the first time you start it. '));
|
||
if (/^https:\/\//.test(c.docs)) {
|
||
var a = document.createElement('a');
|
||
a.href = '#';
|
||
a.textContent = 'About ' + c.label;
|
||
a.addEventListener('click', function (ev) {
|
||
ev.preventDefault();
|
||
post({ cmd: 'openExternal', url: c.docs });
|
||
});
|
||
els.wizAgentNote.appendChild(a);
|
||
}
|
||
}
|
||
|
||
function renderSummary() {
|
||
var g = state.gui;
|
||
var plan = g.plan;
|
||
var lines = [{ text: 'Installs into your home folder (about 1 GB), with no administrator password' }];
|
||
var needs = [];
|
||
if (plan.needs.node) needs.push('Node.js');
|
||
if (plan.needs.tmux) needs.push('tmux');
|
||
if (needs.length) lines.push({ text: 'Downloads ' + needs.join(' and ') });
|
||
var found = installedAgents();
|
||
var chosen = agentById(g.agent);
|
||
if (found.length) {
|
||
lines.push({ text: 'Uses ' + found.map(function (c) { return c.label; }).join(', ') + ', already on this Mac' });
|
||
} else if (chosen) {
|
||
lines.push({ text: 'Installs ' + chosen.label });
|
||
} else {
|
||
lines.push({ text: 'No coding agent yet: Codeman opens, but sessions need one to run', warn: true });
|
||
}
|
||
lines.push({ text: 'Runs in the background and starts when you log in' });
|
||
if (plan.needs.git) lines.push({ text: 'This Mac has no git, so you update Codeman by running this install again' });
|
||
els.wizSummary.textContent = '';
|
||
lines.forEach(function (line) {
|
||
var li = document.createElement('li');
|
||
li.textContent = line.text;
|
||
if (line.warn) li.className = 'warn';
|
||
els.wizSummary.appendChild(li);
|
||
});
|
||
}
|
||
|
||
// Which rows the rail has. A run this page did not start has no plan, so it
|
||
// shows what the installer reported instead.
|
||
function railRows() {
|
||
var g = state.gui;
|
||
var plan = g.plan;
|
||
var chosen = agentById(g.agent);
|
||
if (g.mode === 'agent') return [{ id: 'agent', label: 'Installing ' + agentName() }];
|
||
var rows = [];
|
||
PHASES.forEach(function (id) {
|
||
var label = {
|
||
runtime: 'Getting Node.js and tmux',
|
||
agent: 'Installing ' + (chosen ? chosen.label : 'a coding agent'),
|
||
source: 'Downloading Codeman',
|
||
packages: 'Installing packages',
|
||
build: 'Building Codeman',
|
||
service: 'Starting Codeman'
|
||
}[id];
|
||
if (id === 'runtime' && plan) {
|
||
if (!plan.needs.node && !plan.needs.tmux) return;
|
||
if (!plan.needs.tmux) label = 'Getting Node.js';
|
||
if (!plan.needs.node) label = 'Getting tmux';
|
||
}
|
||
if (id === 'agent' && (plan ? !chosen : !g.seen.agent)) return;
|
||
rows.push({ id: id, label: label });
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
function clockText(since) {
|
||
var s = Math.max(0, Math.floor((Date.now() - since) / 1000));
|
||
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
|
||
}
|
||
|
||
function renderRail() {
|
||
var g = state.gui;
|
||
var current = PHASES.indexOf(g.phase);
|
||
els.wizRail.textContent = '';
|
||
railRows().forEach(function (row) {
|
||
var at = PHASES.indexOf(row.id);
|
||
var li = document.createElement('li');
|
||
var name = document.createElement('span');
|
||
name.textContent = row.label;
|
||
li.appendChild(name);
|
||
if (g.ok || at < current) {
|
||
li.className = 'done';
|
||
} else if (at === current && g.step === 'failed') {
|
||
li.className = 'failed';
|
||
} else if (at === current) {
|
||
li.className = 'now';
|
||
li.setAttribute('aria-current', 'step');
|
||
var clock = document.createElement('span');
|
||
clock.className = 'clock';
|
||
clock.id = 'wiz-clock';
|
||
clock.textContent = clockText(g.phaseSince);
|
||
li.appendChild(clock);
|
||
}
|
||
els.wizRail.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function renderLog() {
|
||
var log = els.wizLog;
|
||
var atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 8;
|
||
log.textContent = state.gui.log.join('\n');
|
||
if (atEnd) log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
function appendLog(lines) {
|
||
var g = state.gui;
|
||
if (!lines || !lines.length) return;
|
||
g.log = g.log.concat(lines).slice(-MAX_LOG_LINES);
|
||
renderLog();
|
||
}
|
||
|
||
function setPhase(phase) {
|
||
var g = state.gui;
|
||
if (!phase || phase === g.phase || PHASES.indexOf(phase) < 0) return;
|
||
g.phase = phase;
|
||
g.phaseSince = Date.now();
|
||
g.seen[phase] = true;
|
||
renderRail();
|
||
}
|
||
|
||
function onInstallUpdate(msg) {
|
||
var g = state.gui;
|
||
if (!g || g.step !== 'installing') return;
|
||
setPhase(msg.phase);
|
||
appendLog(msg.log);
|
||
}
|
||
|
||
function onInstallFinished(msg) {
|
||
var g = state.gui;
|
||
if (!g) return;
|
||
appendLog(msg.log);
|
||
clearInterval(state.clock);
|
||
if (msg.ok && g.mode === 'agent') {
|
||
g.ok = true;
|
||
g.step = 'added';
|
||
renderWizard();
|
||
// Whether there is a Codeman to open from here.
|
||
checkLocal('quick');
|
||
return;
|
||
}
|
||
if (msg.ok) {
|
||
g.ok = true;
|
||
g.step = 'finishing';
|
||
renderWizard();
|
||
// The same watch as a Terminal install: it opens Codeman at the first
|
||
// answer, and offers Start when none comes.
|
||
state.wait = { since: Date.now(), finishedAt: 0, port: g.port };
|
||
checkLocal('quick');
|
||
return;
|
||
}
|
||
if (msg.cancelled) {
|
||
var stopped = g.mode === 'agent' ? 'Stopped installing ' + agentName() + '.' : 'Install cancelled.';
|
||
var again = g.mode === 'agent' ? '' : 'Install again to pick up where it stopped.';
|
||
closeWizard();
|
||
checkLocal('login');
|
||
setStatus('', stopped, again);
|
||
return;
|
||
}
|
||
g.step = 'failed';
|
||
g.message = msg.error || 'The installer stopped without saying why.';
|
||
renderWizard();
|
||
els.wizDetails.open = true;
|
||
renderLog();
|
||
}
|
||
|
||
// Each screen of the graphical install, drawn from state.gui alone.
|
||
function renderWizard() {
|
||
var g = state.gui;
|
||
els.card.classList.toggle('wizard-on', !!g);
|
||
els.wizard.hidden = !g;
|
||
if (!g) return;
|
||
var step = g.step;
|
||
var name = agentName();
|
||
var installingLead = 'You can close this window. The install keeps going, and the app picks it up when you open it again.';
|
||
var copy = g.mode === 'agent' ? {
|
||
preparing: ['Looking at this Mac', 'Getting the list of coding agents and checking which ones are here.'],
|
||
prepareFailed: ['Could not get the list of coding agents', ''],
|
||
agent: ['Add a coding agent', offeredAgents().length
|
||
? 'Pick one to install on this Mac. It needs no password, and Codeman keeps running.'
|
||
: installedAgents().length
|
||
? 'Every coding agent this app can install is already on this Mac.'
|
||
: 'This app cannot install a coding agent on this Mac.'],
|
||
installing: ['Installing ' + name, installingLead],
|
||
added: [name === 'a coding agent' ? 'The coding agent is installed' : name + ' is installed',
|
||
'Codeman notices it within a few minutes: reload Codeman if it does not show up. You sign in the first time you start it.'],
|
||
failed: ['The install stopped', 'Its output is below. Try again, or follow the instructions on the agent’s own site.']
|
||
}[step] : {
|
||
preparing: ['Looking at this Mac', 'Getting the installer and checking what is already here.'],
|
||
blocked: ['This Mac needs something first', ''],
|
||
prepareFailed: ['Could not get the installer', ''],
|
||
agent: ['Choose a coding agent', 'Codeman runs coding agents, and this Mac has none yet. Pick one to install with Codeman. You can add more later.'],
|
||
reach: ['Who can reach Codeman?', ''],
|
||
installing: ['Installing Codeman', installingLead],
|
||
finishing: ['Starting Codeman', 'Codeman opens here as soon as it answers.'],
|
||
ready: ['Codeman is ready', 'It has no coding agent yet, and sessions need one to run. You can add one now, or later from the Codeman menu.'],
|
||
failed: ['The install stopped', 'Its full log is in ~/.codeman/install.log. Try again picks up where it stopped.']
|
||
}[step];
|
||
els.wizSpinner.hidden = !(step === 'preparing' || step === 'finishing');
|
||
els.wizTitle.textContent = copy[0];
|
||
els.wizLead.textContent = copy[1];
|
||
els.wizLead.hidden = !copy[1];
|
||
|
||
var message = step === 'blocked' ? g.plan.blocker : (step === 'prepareFailed' || step === 'failed') ? g.message : '';
|
||
els.wizMessage.textContent = message;
|
||
els.wizMessage.hidden = !message;
|
||
els.wizMessage.className = step === 'blocked' ? 'warn' : '';
|
||
|
||
els.wizAgentStep.hidden = step !== 'agent';
|
||
els.wizReachStep.hidden = step !== 'reach';
|
||
var running = step === 'installing' || step === 'finishing' || step === 'failed' || step === 'added';
|
||
els.wizRail.hidden = !running;
|
||
els.wizDetails.hidden = !running;
|
||
|
||
if (step === 'agent') renderAgents();
|
||
if (step === 'reach') {
|
||
renderSummary();
|
||
syncReach();
|
||
}
|
||
if (running) renderRail();
|
||
clearInterval(state.clock);
|
||
if (step === 'installing') {
|
||
state.clock = setInterval(function () {
|
||
var clock = $('wiz-clock');
|
||
if (clock) clock.textContent = clockText(state.gui.phaseSince);
|
||
}, 1000);
|
||
}
|
||
|
||
renderWizardButtons();
|
||
els.wizCancel.disabled = false;
|
||
|
||
var checked = step === 'agent' && els.wizAgents.querySelector('input:checked');
|
||
if (checked) checked.focus();
|
||
else if (!els.wizNext.hidden) els.wizNext.focus();
|
||
else if (!els.wizBack.hidden) els.wizBack.focus();
|
||
else els.wizTitle.focus();
|
||
}
|
||
|
||
function localAnswers() {
|
||
return !!state.local && (state.local.state === 'running' || state.local.state === 'needsLogin');
|
||
}
|
||
|
||
function renderWizardButtons() {
|
||
var g = state.gui;
|
||
var step = g.step;
|
||
var agentMode = g.mode === 'agent';
|
||
var chosen = agentById(g.agent);
|
||
var next = {
|
||
agent: agentMode ? (chosen ? 'Install ' + chosen.label : '') : 'Continue',
|
||
reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal',
|
||
ready: 'Open Codeman', added: localAnswers() ? 'Open Codeman' : ''
|
||
}[step];
|
||
els.wizNext.hidden = !next;
|
||
els.wizNext.textContent = next || '';
|
||
// Not while preparing: a second prepare would clear the folder of the first.
|
||
els.wizBack.hidden = !(step === 'blocked' || step === 'prepareFailed' || step === 'agent' || step === 'reach' ||
|
||
(agentMode && (step === 'failed' || step === 'added')));
|
||
els.wizBack.textContent = step === 'added' ? 'Done' : 'Back';
|
||
var aside = step === 'ready' ? 'Add a coding agent'
|
||
: step === 'added' && offeredAgents().length > 1 ? 'Add another'
|
||
: step === 'agent' && !agentMode ? 'Skip for now' : '';
|
||
els.wizSkip.hidden = !aside;
|
||
els.wizSkip.textContent = aside;
|
||
els.wizCancel.hidden = step !== 'installing';
|
||
els.wizTerminal.hidden = agentMode || !(step === 'reach' || step === 'failed' || step === 'prepareFailed');
|
||
}
|
||
|
||
function syncReach() {
|
||
var g = state.gui;
|
||
var network = g.reach === 'network';
|
||
els.wizPassword.hidden = !network;
|
||
Array.prototype.forEach.call(document.getElementsByName('reach'), function (r) { r.checked = r.value === g.reach; });
|
||
}
|
||
|
||
function startInstall() {
|
||
var g = state.gui;
|
||
var password = '';
|
||
if (g.reach === 'network') {
|
||
password = els.wizPw.value;
|
||
if (!password) {
|
||
els.wizMessage.textContent = 'Set a password for the other devices to sign in with.';
|
||
els.wizMessage.hidden = false;
|
||
els.wizPw.focus();
|
||
return;
|
||
}
|
||
if (password !== els.wizPw2.value) {
|
||
els.wizMessage.textContent = 'The two passwords are different.';
|
||
els.wizMessage.hidden = false;
|
||
els.wizPw2.focus();
|
||
return;
|
||
}
|
||
}
|
||
var msg = { cmd: 'installStart' };
|
||
if (password) msg.networkPassword = password;
|
||
if (!installedAgents().length && agentById(g.agent)) msg.aiCli = g.agent;
|
||
els.wizPw.value = '';
|
||
els.wizPw2.value = '';
|
||
// Skipped: the first thing to say once Codeman answers.
|
||
g.noAgent = !installedAgents().length && !msg.aiCli && offeredAgents().length > 0;
|
||
beginRun();
|
||
post(msg);
|
||
}
|
||
|
||
function startAgent() {
|
||
var g = state.gui;
|
||
if (!agentById(g.agent)) return;
|
||
beginRun();
|
||
post({ cmd: 'agentStart', aiCli: g.agent });
|
||
}
|
||
|
||
function beginRun() {
|
||
var g = state.gui;
|
||
g.step = 'installing';
|
||
g.phase = '';
|
||
g.seen = {};
|
||
g.log = [];
|
||
renderLog();
|
||
renderWizard();
|
||
}
|
||
|
||
function wizardNext() {
|
||
var g = state.gui;
|
||
if (!g) return;
|
||
if (g.step === 'agent' && g.mode === 'agent') {
|
||
startAgent();
|
||
} else if (g.step === 'agent') {
|
||
g.step = 'reach';
|
||
renderWizard();
|
||
} else if (g.step === 'reach') {
|
||
startInstall();
|
||
} else if (g.step === 'ready' || g.step === 'added') {
|
||
closeWizard();
|
||
selectTab('local');
|
||
openLocal();
|
||
} else if (g.step === 'failed' || g.step === 'prepareFailed') {
|
||
retryWizard();
|
||
} else if (g.step === 'blocked') {
|
||
installInTerminal();
|
||
}
|
||
}
|
||
|
||
function wizardBack() {
|
||
var g = state.gui;
|
||
if (!g) return;
|
||
if (g.step === 'reach' && !installedAgents().length && offeredAgents().length && !g.plan.blocker) {
|
||
g.step = 'agent';
|
||
renderWizard();
|
||
} else {
|
||
closeWizard();
|
||
}
|
||
}
|
||
|
||
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;
|
||
if (state.gui && state.gui.step === 'finishing') closeWizard();
|
||
}
|
||
|
||
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')) {
|
||
var g = state.gui;
|
||
if (g && g.step === 'finishing' && g.noAgent) {
|
||
clearTimeout(state.waitTimer);
|
||
state.wait = null;
|
||
g.step = 'ready';
|
||
renderWizard();
|
||
return;
|
||
}
|
||
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();
|
||
if (msg.addAgent) addAgent();
|
||
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 'addAgent':
|
||
addAgent();
|
||
break;
|
||
case 'installPlan':
|
||
onInstallPlan(msg.plan);
|
||
break;
|
||
case 'installPrepareFailed':
|
||
if (state.gui && state.gui.step === 'preparing') {
|
||
state.gui.step = 'prepareFailed';
|
||
state.gui.message = msg.message || 'Could not get the installer.';
|
||
renderWizard();
|
||
}
|
||
break;
|
||
case 'installRunning':
|
||
if (state.gui) state.gui.port = Number(msg.port) || 3000;
|
||
break;
|
||
case 'installStartFailed':
|
||
if (state.gui) {
|
||
state.gui.step = 'failed';
|
||
state.gui.message = msg.message || 'Could not start the installer.';
|
||
renderWizard();
|
||
}
|
||
break;
|
||
case 'installUpdate':
|
||
onInstallUpdate(msg);
|
||
break;
|
||
case 'installFinished':
|
||
onInstallFinished(msg);
|
||
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, 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) {
|
||
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, 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);
|
||
els.localStart.addEventListener('click', startLocal);
|
||
els.localInstall.addEventListener('click', openWizard);
|
||
els.localAgents.addEventListener('click', openAgentChooser);
|
||
els.wizNext.addEventListener('click', wizardNext);
|
||
els.wizBack.addEventListener('click', wizardBack);
|
||
els.wizSkip.addEventListener('click', function () {
|
||
var g = state.gui;
|
||
if (!g) return;
|
||
if (g.step === 'ready' || g.step === 'added') {
|
||
openAgentChooser();
|
||
return;
|
||
}
|
||
g.agent = '';
|
||
g.step = 'reach';
|
||
renderWizard();
|
||
});
|
||
els.wizCancel.addEventListener('click', function () {
|
||
els.wizCancel.disabled = true;
|
||
post({ cmd: 'installCancel' });
|
||
});
|
||
els.wizTerminal.addEventListener('click', installInTerminal);
|
||
Array.prototype.forEach.call(document.getElementsByName('reach'), function (radio) {
|
||
radio.addEventListener('change', function () {
|
||
if (!state.gui) return;
|
||
state.gui.reach = radio.value;
|
||
els.wizMessage.hidden = true;
|
||
syncReach();
|
||
});
|
||
});
|
||
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>
|