mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
1742 lines
78 KiB
HTML
1742 lines
78 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; }
|
||
#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 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>
|
||
</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'),
|
||
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.
|
||
var state = {
|
||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||
tab: 'local', askedFrom: 'local', 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 = '';
|
||
}
|
||
|
||
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.localAgents.disabled = state.busy || tooOld || state.opening;
|
||
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 };
|
||
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 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';
|
||
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;
|
||
// 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();
|
||
}
|
||
|
||
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 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', 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>
|