mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Found by the three-part review of this branch. Each was checked against the source first; every decision that can run without a window has a unit test. This Mac: - A Codeman that runs but listens nowhere yet (the first seconds after login, a restart) was reported as "already running ... it listens at process N, which this app cannot open", and Start was refused for it. It is now its own state: the tab says "starting", looks again every second, and opens Codeman once it is up when a connect to this Mac had just been refused. The state table is a pure function with a test (setup::local_state). - A server the app starts runs in the home folder, like one started by launchd or in a terminal. It ran in the app's own directory (`/` after a Finder launch), which Codeman then used as the default for sessions. - Server detection accepts any entry word that `web` follows, so a dev server under a folder named codeman is seen and never gets a second server beside it. - A login shell is polled from 5 ms up instead of every 100 ms, and the port after a launchd start every 100 ms instead of 500: both were pure waiting. - The last line of stdout is no longer glued to the first line of stderr. Addresses and policy: - A tailnet name typed with a port gets http://, not https://. With a port it is Codeman's own listener; https there ended in a TLS error with no hint. - within_base refuses `..%2F` and the other encoded ways out of the base path, so a sibling app behind a decoding proxy never sees the login. - A status answer whose body stalls is a timeout, not "something else answers here". - The folder pop-out pattern matches the two routes the server has. - A malformed `window` in desktop.json no longer costs the server list. Windows: - Pop-outs step off the window in use. tao centres a window with no position, so every pop-out opened exactly on top of the one before. - A window opened by a script no longer brings the app to the front: wry activated the application for it, whatever the window itself was told. - The first-run window sits between the menu bar and the Dock. Centred in the whole display, its bottom was under the Dock on a 900 pt high one. - The window frame is saved once after a move or resize ends, not once a second during the drag. Also makes the connect page independent of TextEncoder and scrollIntoView, so it runs in jsdom as well as in a web view. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1048 lines
49 KiB
HTML
1048 lines
49 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; }
|
||
#local-network-row[hidden] { display: none; }
|
||
#elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||
#elsewhere[hidden] { display: none; }
|
||
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
|
||
#elsewhere summary::-webkit-details-marker { display: none; }
|
||
#elsewhere summary::before { content: '\25B8'; display: inline-block; width: 14px; transition: transform 0.15s; }
|
||
#elsewhere[open] summary::before { transform: rotate(90deg); }
|
||
#elsewhere summary:hover { color: var(--text); }
|
||
#elsewhere .field { margin-top: 12px; }
|
||
.inline { display: flex; gap: 8px; align-items: center; }
|
||
.inline > input { flex: 1; min-width: 0; }
|
||
.inline > button { flex: none; }
|
||
.oneliner { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||
.oneliner code { flex: 1; min-width: 0; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; background: #0c0d10; border: 1px solid var(--line-strong); border-radius: 8px; padding: 9px 10px; color: #c9d1d9; -webkit-user-select: all; user-select: all; overflow-x: auto; white-space: nowrap; }
|
||
.statusrow { display: flex; gap: 10px; align-items: flex-start; }
|
||
.statusrow[hidden] { display: none; }
|
||
.statusrow #status { flex: 1; }
|
||
.statusrow #cancel { margin-top: 10px; }
|
||
footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; }
|
||
footer a { color: #aab1bb; text-decoration: none; cursor: default; }
|
||
footer a:hover { color: var(--accent); }
|
||
kbd { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 4px; padding: 2px 4px; color: #9aa1ab; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<header>
|
||
<img src="icon.png" alt="">
|
||
<h1>Codeman</h1>
|
||
<p>Run Codeman on this Mac, or connect to another machine</p>
|
||
</header>
|
||
|
||
<div class="card">
|
||
<div class="tabs" role="tablist" aria-label="Where Codeman runs">
|
||
<button type="button" role="tab" id="tab-local" aria-controls="pane-local" aria-selected="true">This Mac</button>
|
||
<button type="button" role="tab" id="tab-remote" aria-controls="form" aria-selected="false" tabindex="-1">Another machine</button>
|
||
</div>
|
||
|
||
<p id="webkit" role="alert" hidden>Codeman needs a newer WebKit than this Mac has: macOS 13.3 or later, or Safari 16.4 or later on macOS 11 and 12.</p>
|
||
|
||
<section id="pane-local" role="tabpanel" aria-labelledby="tab-local">
|
||
<div class="local">
|
||
<span class="dot busy" id="local-dot"></span>
|
||
<div role="status">
|
||
<div id="local-title">Looking for Codeman on this Mac…</div>
|
||
<div class="sub" id="local-detail"></div>
|
||
</div>
|
||
</div>
|
||
<label class="check" id="local-network-row" hidden><input id="local-network" type="checkbox"> Also reach it from my phone and other computers (the installer asks how)</label>
|
||
<div class="actions">
|
||
<button class="primary" id="local-open" type="button" hidden>Open Codeman</button>
|
||
<button class="primary" id="local-start" type="button" hidden>Start Codeman</button>
|
||
<button class="primary" id="local-install" type="button" hidden>Install Codeman on this Mac</button>
|
||
<button class="secondary" id="local-stop" type="button" hidden>Stop waiting</button>
|
||
<button class="secondary" id="local-check" type="button" hidden>Check again</button>
|
||
</div>
|
||
</section>
|
||
|
||
<form id="form" role="tabpanel" aria-labelledby="tab-remote" autocomplete="off" novalidate hidden>
|
||
<label class="field" for="url">Server address</label>
|
||
<input id="url" type="text" spellcheck="false" autocapitalize="off"
|
||
placeholder="http://127.0.0.1:3000 or https://your-box.ts.net" list="recent-list">
|
||
<datalist id="recent-list"></datalist>
|
||
<div id="recent" role="group" aria-label="Recent servers"></div>
|
||
|
||
<div id="login" hidden>
|
||
<div class="row">
|
||
<div>
|
||
<label class="field" for="username">Username</label>
|
||
<input id="username" type="text" spellcheck="false" autocapitalize="off" placeholder="admin">
|
||
</div>
|
||
<div>
|
||
<label class="field" for="password">Password</label>
|
||
<input id="password" type="password" placeholder="">
|
||
</div>
|
||
</div>
|
||
<label class="check"><input id="remember" type="checkbox" checked> Remember the password in the Keychain</label>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button class="primary" id="connect" type="submit">Connect</button>
|
||
<button class="secondary" id="start" type="button" hidden>Start Codeman on this Mac</button>
|
||
</div>
|
||
|
||
</form>
|
||
|
||
<div class="statusrow" id="status-row" hidden>
|
||
<div id="status" role="status" aria-live="polite"><span class="spinner" id="spinner" hidden></span><span id="status-text"></span></div>
|
||
<button class="secondary" id="cancel" type="button" hidden>Cancel</button>
|
||
</div>
|
||
<button class="secondary" id="suggest" type="button" hidden></button>
|
||
|
||
<details id="elsewhere" hidden>
|
||
<summary>Codeman is not installed there yet?</summary>
|
||
<label class="field" for="ssh">Install it over SSH</label>
|
||
<div class="inline">
|
||
<!-- 300 is what setup.rs takes for a login, and at most 900 bytes: under the cap of a message the app accepts. -->
|
||
<input id="ssh" type="text" maxlength="300" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
|
||
<button class="secondary" id="ssh-install" type="button">Install</button>
|
||
</div>
|
||
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
|
||
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
|
||
</details>
|
||
|
||
<div class="prefs">
|
||
<label class="check"><input id="auto" type="checkbox" checked> Connect automatically when the app opens</label>
|
||
<label class="check"><input id="fullscreen" type="checkbox"> Always open in full screen <kbd>⌃⌘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'),
|
||
localNetworkRow: $('local-network-row'), localNetwork: $('local-network'),
|
||
localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'),
|
||
localStop: $('local-stop'), localCheck: $('local-check'),
|
||
elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy')
|
||
};
|
||
var manual = /[?&]manual=1/.test(location.search);
|
||
// startUrl: the address the user asked to start Codeman for ('' when this
|
||
// page did not ask, e.g. it was reloaded while a start was running).
|
||
// suggestion: the address the suggest button connects to.
|
||
// tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab
|
||
// the connect under way was asked from, where its answer belongs. local: the
|
||
// last This Mac status from the app. wait: set while an install runs in
|
||
// Terminal and this page polls for the server it brings up. reopen: a
|
||
// connect to this Mac was refused, so open it if it turns out to be starting.
|
||
// startingLooks: how often the page has looked again at a starting server.
|
||
var state = {
|
||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||
tab: 'local', askedFrom: 'local', local: null, localChecking: false, opening: false,
|
||
wait: null, waitTimer: 0, stoppedWaiting: false,
|
||
reopen: false, startingLooks: 0, startingTimer: 0
|
||
};
|
||
// While an install runs: how often to look, how long a finished install may
|
||
// take to answer before Start is offered, and when to stop looking.
|
||
var WAIT_POLL_MS = 3000;
|
||
var WAIT_START_GRACE_MS = 90 * 1000;
|
||
var WAIT_MAX_MS = 30 * 60 * 1000;
|
||
// A server that is still starting: how often to look again, and how many times.
|
||
var STARTING_POLL_MS = 1000;
|
||
var STARTING_MAX_LOOKS = 20;
|
||
// What the app takes in one message, in UTF-8 bytes (MAX_URL_LEN and
|
||
// MAX_FIELD_LEN in policy.rs). It drops a longer one without an answer, so
|
||
// the page has to be the one that says so.
|
||
var MAX_URL = 4096;
|
||
var MAX_FIELD = 1024;
|
||
function tooLong(text, max) {
|
||
var bytes = 0;
|
||
for (var i = 0; i < text.length; i++) {
|
||
var c = text.charCodeAt(i);
|
||
if (c < 0x80) bytes += 1;
|
||
else if (c < 0x800) bytes += 2;
|
||
else if (c >= 0xd800 && c < 0xdc00) { bytes += 4; i++; } // a surrogate pair: one character
|
||
else bytes += 3;
|
||
}
|
||
return bytes > max;
|
||
}
|
||
|
||
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
|
||
|
||
function setStatus(kind, text, detail) {
|
||
els.statusRow.hidden = !text;
|
||
// In a small window the line may be below the fold: bring it into view.
|
||
if (text && els.statusRow.scrollIntoView) els.statusRow.scrollIntoView({ block: 'nearest' });
|
||
els.status.className = kind || '';
|
||
els.spinner.hidden = kind !== 'busy';
|
||
els.statusText.textContent = text || '';
|
||
if (detail) {
|
||
var d = document.createElement('span');
|
||
d.className = 'detail';
|
||
d.textContent = detail;
|
||
els.statusText.appendChild(d);
|
||
}
|
||
}
|
||
|
||
// One request at a time: every way to start one (Connect, a recent server,
|
||
// the suggest button) is disabled while busy, never hidden, so the second
|
||
// click of a double click cannot land on whatever moved into its place.
|
||
// Cancel only where the native side can really cancel: a probe or a page
|
||
// load, never a server start.
|
||
function setBusy(busy, cancellable) {
|
||
state.busy = busy;
|
||
els.connect.disabled = busy || tooOld;
|
||
els.suggest.disabled = busy || tooOld;
|
||
els.cancel.hidden = !(busy && cancellable !== false);
|
||
if (busy) els.start.hidden = true;
|
||
disableChips();
|
||
syncLocalButtons();
|
||
}
|
||
|
||
// Forgetting a server stays possible when the WebKit is too old; connecting
|
||
// to one does not.
|
||
function disableChips() {
|
||
Array.prototype.forEach.call(els.recent.querySelectorAll('button'), function (b) {
|
||
b.disabled = state.busy || (tooOld && !b.classList.contains('x'));
|
||
});
|
||
}
|
||
|
||
// Nothing to suggest when this WebKit cannot open Codeman at any address.
|
||
function suggest(url) {
|
||
if (tooOld) return;
|
||
state.suggestion = url;
|
||
els.suggest.textContent = 'Use ' + url;
|
||
els.suggest.title = 'Connect to ' + url;
|
||
els.suggest.hidden = false;
|
||
}
|
||
|
||
function hideSuggestion() {
|
||
state.suggestion = '';
|
||
els.suggest.hidden = true;
|
||
}
|
||
|
||
function host(url) {
|
||
try { var u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '')); } catch (e) { return url; }
|
||
}
|
||
|
||
// The address as the app normalizes it (policy::normalize_server_url):
|
||
// http:// by default, https:// for a .ts.net name without a port, no query
|
||
// or fragment, a trailing slash. Only used to tell whether two addresses
|
||
// are one server.
|
||
function normalize(raw) {
|
||
var s = String(raw || '').trim();
|
||
if (!s) return '';
|
||
if (s.indexOf('://') < 0) {
|
||
var name = s.split(/[\/:]/)[0].toLowerCase();
|
||
var tailscaleServe = /\.ts\.net$/.test(name) && s.charAt(name.length) !== ':';
|
||
s = (tailscaleServe ? 'https' : 'http') + '://' + s;
|
||
}
|
||
try {
|
||
var u = new URL(s);
|
||
u.search = '';
|
||
u.hash = '';
|
||
if (!/\/$/.test(u.pathname)) u.pathname += '/';
|
||
return u.href;
|
||
} catch (e) { return ''; }
|
||
}
|
||
|
||
function sameServer(a, b) {
|
||
var na = normalize(a);
|
||
return na !== '' && na === normalize(b);
|
||
}
|
||
|
||
function renderRecent() {
|
||
els.recent.textContent = '';
|
||
els.list.textContent = '';
|
||
state.servers.forEach(function (s) {
|
||
var opt = document.createElement('option');
|
||
opt.value = s.url;
|
||
els.list.appendChild(opt);
|
||
|
||
var chip = document.createElement('span');
|
||
chip.className = 'chip';
|
||
var pick = document.createElement('button');
|
||
pick.type = 'button';
|
||
pick.textContent = host(s.url) + (s.username ? ' (' + s.username + ')' : '');
|
||
pick.title = 'Connect to ' + s.url;
|
||
pick.addEventListener('click', function () {
|
||
if (state.busy) return;
|
||
choose(s);
|
||
connect();
|
||
});
|
||
var x = document.createElement('button');
|
||
x.type = 'button';
|
||
x.className = 'x';
|
||
x.textContent = '×';
|
||
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
|
||
x.setAttribute('aria-label', 'Forget ' + host(s.url));
|
||
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
|
||
chip.appendChild(pick);
|
||
chip.appendChild(x);
|
||
els.recent.appendChild(chip);
|
||
});
|
||
disableChips();
|
||
}
|
||
|
||
function choose(s) {
|
||
els.url.value = s.url;
|
||
els.username.value = s.username || '';
|
||
els.password.value = '';
|
||
els.password.placeholder = s.hasSavedPassword ? 'saved in Keychain' : '';
|
||
els.login.hidden = !s.username;
|
||
}
|
||
|
||
// The saved server the address field names, if any, with its login.
|
||
function matchSaved() {
|
||
var match = state.servers.filter(function (s) { return s.url === els.url.value.trim(); })[0];
|
||
if (match) choose(match);
|
||
else els.password.placeholder = '';
|
||
}
|
||
|
||
function connect() {
|
||
if (state.busy || tooOld) return;
|
||
var url = els.url.value.trim();
|
||
if (!url) { setStatus('bad', 'Enter the address of your Codeman server.'); els.url.focus(); return; }
|
||
var username = els.login.hidden ? '' : els.username.value.trim();
|
||
var password = els.login.hidden ? '' : els.password.value;
|
||
if (tooLong(url, MAX_URL)) { setStatus('bad', 'That address is too long.'); els.url.focus(); return; }
|
||
if (tooLong(username, MAX_FIELD) || tooLong(password, MAX_FIELD)) {
|
||
setStatus('bad', 'That username or password is too long.');
|
||
return;
|
||
}
|
||
state.lastUrl = url;
|
||
state.askedFrom = state.tab;
|
||
setBusy(true);
|
||
setStatus('busy', 'Connecting to ' + url + '…');
|
||
var msg = { cmd: 'connect', url: url, remember: els.remember.checked };
|
||
if (username) msg.username = username;
|
||
if (password) msg.password = password;
|
||
post(msg);
|
||
}
|
||
|
||
function showLogin() {
|
||
els.login.hidden = false;
|
||
if (!els.username.value) els.username.value = 'admin';
|
||
els.password.focus();
|
||
}
|
||
|
||
function hostName(url) {
|
||
try { return new URL(url).hostname.toLowerCase(); } catch (e) { return ''; }
|
||
}
|
||
|
||
var LOCAL_NETWORK = 'On macOS 15 and later the app may also need Local Network access: System Settings > Privacy & Security > Local Network, then turn on Codeman.';
|
||
|
||
var explain = {
|
||
needsLogin: ['warn', 'This server asks for a login.', 'Codeman’s default username is admin; the password is CODEMAN_PASSWORD on the server.'],
|
||
badCredentials: ['bad', 'Wrong username or password.', 'After 10 failed attempts Codeman locks logins from this address for 15 minutes.'],
|
||
rateLimited: ['bad', 'Too many failed logins from this address.', 'If you know the password, enter it and connect: a single-user Codeman accepts the right password even now. Otherwise, or on a multi-user server, wait up to 15 minutes and try again.'],
|
||
hostNotAllowed: ['bad', 'Codeman refused this host name.', 'Codeman only answers names it knows. Add this one to CODEMAN_ALLOWED_HOSTS on the server, or use its tailnet name or 127.0.0.1.'],
|
||
refused: ['bad', 'Nothing is answering at this address.', ''],
|
||
timeout: ['bad', 'The server did not answer within 5 seconds.', 'Check that the machine is awake and reachable (Tailscale connected, same network).'],
|
||
unreachable: ['bad', 'This Mac cannot reach that address.', 'Check that the machine is on and on the same network (or that Tailscale is connected). ' + LOCAL_NETWORK],
|
||
unresolvable: ['bad', 'That host name could not be found.', 'Check the spelling, or that Tailscale is connected for a .ts.net name.'],
|
||
maybeHttps: ['bad', 'The server closed the connection without answering.', 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address, or your tailnet name (https://….ts.net). Codeman’s own self-signed certificate names only localhost and 127.0.0.1, so the app cannot accept it at this address: serve Codeman through tailscale serve (a real ts.net certificate) or a reverse proxy with a real certificate.'],
|
||
tlsUntrusted: ['bad', 'The server’s HTTPS certificate is not trusted.', 'This is usually Codeman’s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.']
|
||
};
|
||
|
||
// The same two at localhost or 127.0.0.1, the only names Codeman's
|
||
// self-signed certificate carries, so the only ones where trusting it in
|
||
// Keychain Access can help.
|
||
var explainLoopback = {
|
||
maybeHttps: 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address. The app’s web view refuses Codeman’s self-signed certificate, though: marking it as trusted in Keychain Access may work, or run Codeman without --https.',
|
||
tlsUntrusted: 'This is usually Codeman’s self-signed certificate (codeman web --https), which the app’s web view refuses. Run Codeman without --https and use http://127.0.0.1:PORT instead. Marking the certificate as trusted in Keychain Access may also work.'
|
||
};
|
||
|
||
function loopbackName(url) {
|
||
var h = hostName(url);
|
||
return h === 'localhost' || h === '127.0.0.1';
|
||
}
|
||
|
||
function onProbeResult(msg) {
|
||
// The answer belongs to the tab its connect was asked from, whichever one
|
||
// shows by now; with no connect under way, to the one in view.
|
||
var asked = state.busy ? state.askedFrom : state.tab;
|
||
setBusy(false);
|
||
hideSuggestion();
|
||
var o = msg.outcome || {};
|
||
var e = explain[o.kind];
|
||
if (o.kind === 'needsLogin' || o.kind === 'badCredentials' || o.kind === 'rateLimited') {
|
||
// The login fields live on the Another machine tab.
|
||
selectTab('remote');
|
||
showLogin();
|
||
} else if (asked === 'local') {
|
||
// Opening the Codeman on this Mac failed: look at it again.
|
||
state.reopen = o.kind === 'refused';
|
||
selectTab('local');
|
||
// selectTab starts the look itself when there has been none yet.
|
||
if (!state.localChecking) checkLocal('login');
|
||
} else {
|
||
selectTab('remote');
|
||
}
|
||
if (o.kind === 'notCodeman') {
|
||
setStatus('bad', 'Something answered, but it is not a Codeman server (HTTP ' + o.status + ').', 'Check the port and, behind a proxy, the path Codeman is mounted under.');
|
||
} else if (o.kind === 'redirect') {
|
||
var where = o.location ? 'It points to ' + o.location + '.' : '';
|
||
if (o.suggested) {
|
||
setStatus('warn', 'This address redirects to another one.', where + '\nCodeman seems to answer at ' + o.suggested + '.');
|
||
suggest(o.suggested);
|
||
} else {
|
||
setStatus('bad', 'This address redirects somewhere else.', where + '\nThe app connects only to the address Codeman itself answers on. Check the address and, behind a proxy, the path Codeman is mounted under.');
|
||
}
|
||
} else if (o.kind === 'failed') {
|
||
setStatus('bad', 'Could not connect.', o.detail || '');
|
||
} else if (e) {
|
||
var detail = e[2];
|
||
if (o.kind === 'refused' && msg.canStart) detail = 'Codeman is not running on this Mac. Start it here, or check the port.';
|
||
if (o.kind === 'rateLimited' && o.sentCredentials) detail = 'The saved or typed password may be wrong. ' + detail;
|
||
if (o.kind === 'unresolvable' && /\.local\.?$/.test(hostName(msg.url))) {
|
||
detail = 'Check the spelling, and that the machine is on and on the same network. ' + LOCAL_NETWORK;
|
||
}
|
||
if (explainLoopback[o.kind] && loopbackName(msg.url)) detail = explainLoopback[o.kind];
|
||
if (o.kind === 'tlsUntrusted' && o.detail) detail += '\n(' + o.detail + ')';
|
||
if ((o.kind === 'needsLogin' || o.kind === 'badCredentials') && /^http:/i.test(normalize(msg.url)) && !isLocalUrl(msg.url)) {
|
||
detail += ' This address is http://, so the password crosses the network unencrypted: fine on a tailnet or a network you trust.';
|
||
}
|
||
setStatus(e[0], e[1], detail);
|
||
if (o.kind === 'maybeHttps' && /^http:/i.test(msg.url || '')) suggest(String(msg.url).replace(/^http:/i, 'https:'));
|
||
} else {
|
||
setStatus('bad', 'Could not connect.');
|
||
}
|
||
els.start.hidden = !(o.kind === 'refused' && msg.canStart);
|
||
}
|
||
|
||
function onStartResult(msg) {
|
||
setBusy(false);
|
||
// Connect only to the server this page asked to start; after a reload
|
||
// mid-start, only to the one the address field shows.
|
||
var asked = state.startUrl || els.url.value;
|
||
state.startUrl = '';
|
||
if (!msg.ok) { setStatus('bad', 'Codeman did not start.', msg.output || ''); return; }
|
||
setStatus('ok', 'Codeman started.', msg.output || '');
|
||
if (!msg.url || !sameServer(msg.url, asked)) return;
|
||
if (!sameServer(msg.url, els.url.value)) { els.url.value = msg.url; matchSaved(); }
|
||
connect();
|
||
}
|
||
|
||
function isLocalUrl(url) {
|
||
var h = hostName(normalize(url));
|
||
return h === 'localhost' || /^127\./.test(h) || h === '[::1]';
|
||
}
|
||
|
||
// Look at this Mac, once, when the This Mac tab shows and nothing has yet.
|
||
function ensureLocalChecked() {
|
||
if (state.tab === 'local' && !state.local && !state.localChecking) checkLocal('login');
|
||
}
|
||
|
||
// later: leave the look at this Mac to whoever asked (ensureLocalChecked).
|
||
function selectTab(tab, later) {
|
||
if (tab !== 'local' && tab !== 'remote') return;
|
||
var changed = state.tab !== tab;
|
||
state.tab = tab;
|
||
var local = tab === 'local';
|
||
els.tabLocal.setAttribute('aria-selected', String(local));
|
||
els.tabRemote.setAttribute('aria-selected', String(!local));
|
||
els.tabLocal.tabIndex = local ? 0 : -1;
|
||
els.tabRemote.tabIndex = local ? -1 : 0;
|
||
els.paneLocal.hidden = !local;
|
||
els.form.hidden = local;
|
||
els.elsewhere.hidden = local;
|
||
if (changed && !state.busy) {
|
||
setStatus('', '');
|
||
hideSuggestion();
|
||
}
|
||
if (!later) ensureLocalChecked();
|
||
}
|
||
|
||
// How hard to look: 'quick' (files and processes, while an install runs),
|
||
// 'login' (also the login shell; every automatic look) or 'deep' (also the
|
||
// interactive profile, only on Check again).
|
||
function checkLocal(how) {
|
||
if (how !== 'quick') {
|
||
state.localChecking = true;
|
||
renderLocal();
|
||
}
|
||
var msg = { cmd: 'localStatus', quick: how === 'quick' };
|
||
if (how === 'deep') msg.deep = true;
|
||
post(msg);
|
||
}
|
||
|
||
function syncLocalButtons() {
|
||
els.localOpen.disabled = state.busy || tooOld;
|
||
els.localStart.disabled = state.busy || tooOld;
|
||
els.localInstall.disabled = state.busy || tooOld || state.opening;
|
||
els.localCheck.disabled = state.busy || state.localChecking;
|
||
els.sshInstall.disabled = state.busy || state.opening;
|
||
}
|
||
|
||
function renderLocal() {
|
||
var s = state.local;
|
||
var show = { open: false, start: false, install: false, stop: false, check: false, network: false };
|
||
var dot = '';
|
||
var title = '';
|
||
var detail = '';
|
||
if (state.wait) {
|
||
dot = 'busy';
|
||
title = 'Installing Codeman in Terminal…';
|
||
detail = 'Answer the installer’s questions there. This window opens Codeman when it is done.';
|
||
show.stop = true;
|
||
} else if (!s) {
|
||
dot = 'busy';
|
||
title = 'Looking for Codeman on this Mac…';
|
||
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
|
||
dot = 'busy';
|
||
title = 'Codeman is being installed in Terminal.';
|
||
detail = 'Finish the installer there, then Check again.';
|
||
show.check = true;
|
||
} else if (s.state === 'starting') {
|
||
dot = 'busy';
|
||
title = 'Codeman is starting on this Mac…';
|
||
detail = 'It will answer at ' + s.url + ' in a moment.';
|
||
show.check = true;
|
||
} else if (s.state === 'runningElsewhere') {
|
||
dot = 'warn';
|
||
title = 'A Codeman server is already running on this Mac.';
|
||
detail = 'It listens at ' + (s.elsewhere || []).join(', ') + ', which this app cannot open as ' + s.url +
|
||
'. Connect to its address under Another machine. Starting another server here would take over its sessions.';
|
||
show.check = true;
|
||
} else if (s.state === 'running') {
|
||
dot = 'ok';
|
||
title = 'Codeman is running on this Mac.';
|
||
detail = s.url + (s.version ? ' · version ' + s.version : '');
|
||
show.open = true;
|
||
} else if (s.state === 'needsLogin') {
|
||
dot = 'ok';
|
||
title = 'Codeman is running on this Mac.';
|
||
detail = s.url + ' asks for a login: Open asks for the password.';
|
||
show.open = true;
|
||
} else if (s.state === 'stopped') {
|
||
title = 'Codeman is installed on this Mac, but not running.';
|
||
if (s.daemon) {
|
||
detail = 'A system LaunchDaemon runs it, which an administrator starts: sudo launchctl kickstart -k system/com.codeman.web';
|
||
} else if (/^https:/i.test(s.url)) {
|
||
detail = 'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.';
|
||
} else {
|
||
detail = s.service
|
||
? 'It is set up to start when you log in, but nothing answers at ' + s.url + '. Start asks launchd to run it now.'
|
||
: 'Start runs it in the background at ' + s.url + '.';
|
||
show.start = true;
|
||
}
|
||
show.check = true;
|
||
} else if (s.state === 'notInstalled') {
|
||
title = 'Codeman is not installed on this Mac.';
|
||
detail = 'Install runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password. ' +
|
||
'Codeman then runs in the background and starts when you log in, so your sessions keep running when this app is closed.';
|
||
show.install = true;
|
||
show.network = true;
|
||
show.check = true;
|
||
} else if (!s.installed) {
|
||
// Something else holds Codeman's port; a fresh install takes the next free one.
|
||
var other = s.outcome || {};
|
||
title = 'Codeman is not installed on this Mac.';
|
||
detail = 'Something else answers at ' + s.url + (other.kind === 'notCodeman' ? ' (HTTP ' + other.status + ')' : '') +
|
||
', so Install sets Codeman up on the next free port. It runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password.';
|
||
show.install = true;
|
||
show.network = true;
|
||
show.check = true;
|
||
} else {
|
||
var o = s.outcome || {};
|
||
dot = 'warn';
|
||
if (o.kind === 'notCodeman') {
|
||
title = 'Something else answers at ' + s.url;
|
||
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to Codeman’s real address under Another machine.';
|
||
} else {
|
||
var e = explain[o.kind];
|
||
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
|
||
detail = (explainLoopback[o.kind]) || (e ? e[2] : (o.detail || ''));
|
||
}
|
||
show.check = true;
|
||
}
|
||
// Looking again: what was found last stays on screen, so nothing jumps.
|
||
if (state.localChecking && s && !state.wait) dot = 'busy';
|
||
els.localDot.className = 'dot' + (dot ? ' ' + dot : '');
|
||
els.localTitle.textContent = title;
|
||
els.localDetail.textContent = detail;
|
||
els.localOpen.hidden = !show.open;
|
||
els.localStart.hidden = !show.start;
|
||
els.localInstall.hidden = !show.install;
|
||
els.localStop.hidden = !show.stop;
|
||
els.localCheck.hidden = !show.check;
|
||
els.localNetworkRow.hidden = !show.network;
|
||
syncLocalButtons();
|
||
}
|
||
|
||
function onLocalStatus(s) {
|
||
state.localChecking = false;
|
||
state.local = s || null;
|
||
// An install from this app is still running (this page was shown again
|
||
// while it ran): wait for it, unless the user stopped waiting.
|
||
if (!state.wait && s && s.installRunning && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
|
||
state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 };
|
||
renderLocal();
|
||
scheduleWait();
|
||
}
|
||
if (state.wait) {
|
||
onWaitStatus(state.local || {});
|
||
return;
|
||
}
|
||
if (followStarting(s)) return;
|
||
renderLocal();
|
||
}
|
||
|
||
// A Codeman that is still starting (its process runs, but listens nowhere
|
||
// yet: the first seconds after login, a restart): look again every second
|
||
// for a while. Once it is up, open it if a connect to this Mac was refused
|
||
// on the way here, which is what the user had asked for. True when it opened.
|
||
function followStarting(s) {
|
||
clearTimeout(state.startingTimer);
|
||
if (s && s.state === 'starting') {
|
||
if (state.startingLooks < STARTING_MAX_LOOKS) {
|
||
state.startingLooks++;
|
||
state.startingTimer = setTimeout(function () {
|
||
if (state.local && state.local.state === 'starting' && !state.wait) checkLocal('quick');
|
||
}, STARTING_POLL_MS);
|
||
}
|
||
return false;
|
||
}
|
||
var up = !!s && (s.state === 'running' || s.state === 'needsLogin');
|
||
var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy;
|
||
state.startingLooks = 0;
|
||
state.reopen = false;
|
||
if (open) {
|
||
renderLocal();
|
||
openLocal();
|
||
}
|
||
return open;
|
||
}
|
||
|
||
// Point the Another machine form at `url` with only the login saved for it:
|
||
// never the username or a typed password of the server the form showed
|
||
// before, which connect() would otherwise send along.
|
||
function useAddress(url) {
|
||
els.url.value = url || '';
|
||
els.login.hidden = true;
|
||
els.username.value = '';
|
||
els.password.value = '';
|
||
els.password.placeholder = '';
|
||
matchSaved();
|
||
}
|
||
|
||
function openLocal() {
|
||
if (state.busy || tooOld || !state.local) return;
|
||
useAddress(state.local.url);
|
||
connect();
|
||
}
|
||
|
||
function startLocal() {
|
||
if (state.busy || tooOld || !state.local) return;
|
||
useAddress(state.local.url);
|
||
state.startUrl = state.local.url;
|
||
post({ cmd: 'startServer', url: state.startUrl });
|
||
}
|
||
|
||
function installLocal() {
|
||
if (state.busy || tooOld || state.opening) return;
|
||
state.opening = true;
|
||
syncLocalButtons();
|
||
setStatus('busy', 'Opening Terminal…');
|
||
post({ cmd: 'installLocal', network: els.localNetwork.checked });
|
||
}
|
||
|
||
function scheduleWait() {
|
||
clearTimeout(state.waitTimer);
|
||
state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS);
|
||
}
|
||
|
||
function endWait() {
|
||
clearTimeout(state.waitTimer);
|
||
state.wait = null;
|
||
}
|
||
|
||
function portOf(url) {
|
||
try {
|
||
var u = new URL(url);
|
||
return Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
||
} catch (e) { return 0; }
|
||
}
|
||
|
||
// One answer while an install runs. It stops at the first answer from a
|
||
// server: a password-protected one counts every unauthenticated look as a
|
||
// failed login.
|
||
function onWaitStatus(s) {
|
||
var w = state.wait;
|
||
var now = Date.now();
|
||
if (s.installPort) w.port = s.installPort;
|
||
// Until the installer writes its LaunchAgent, the app looks at port 3000,
|
||
// where something else may answer when the install took another port.
|
||
var ours = portOf(s.url) === w.port;
|
||
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
|
||
endWait();
|
||
selectTab('local');
|
||
renderLocal();
|
||
openLocal();
|
||
return;
|
||
}
|
||
if (ours && s.state === 'other') {
|
||
// Something answers that is not a working Codeman: say what.
|
||
endWait();
|
||
renderLocal();
|
||
return;
|
||
}
|
||
if (s.installComplete && (s.state === 'stopped' || !ours)) {
|
||
if (!w.finishedAt) {
|
||
w.finishedAt = now;
|
||
} else if (now - w.finishedAt > WAIT_START_GRACE_MS) {
|
||
endWait();
|
||
renderLocal();
|
||
setStatus('warn', 'The install finished, but Codeman is not answering.', 'Start runs it now. If the installer reported a problem, its log is ~/.codeman/install.log.');
|
||
return;
|
||
}
|
||
}
|
||
if (now - w.since > WAIT_MAX_MS) {
|
||
endWait();
|
||
renderLocal();
|
||
setStatus('warn', 'Stopped waiting for the install.', 'Check again once the installer in Terminal is done.');
|
||
return;
|
||
}
|
||
scheduleWait();
|
||
}
|
||
|
||
function installRemote() {
|
||
if (state.busy || state.opening) return;
|
||
var target = els.ssh.value.trim();
|
||
if (!target) {
|
||
setStatus('bad', 'Enter the machine to install Codeman on, as user@host.');
|
||
els.ssh.focus();
|
||
return;
|
||
}
|
||
state.opening = true;
|
||
syncLocalButtons();
|
||
setStatus('busy', 'Opening Terminal…');
|
||
post({ cmd: 'installRemote', target: target });
|
||
}
|
||
|
||
window.__codemanDesktop = {
|
||
onNative: function (msg) {
|
||
switch (msg.type) {
|
||
case 'init':
|
||
state.servers = msg.servers || [];
|
||
state.startUrl = '';
|
||
setBusy(false);
|
||
hideSuggestion();
|
||
setStatus('', '');
|
||
els.start.hidden = true;
|
||
els.auto.checked = !!msg.autoConnect;
|
||
els.fullscreen.checked = !!msg.startFullscreen;
|
||
els.version.textContent = 'Codeman Desktop ' + (msg.version || '');
|
||
renderRecent();
|
||
if (state.servers[0]) choose(state.servers[0]);
|
||
var auto = !!(msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]);
|
||
// The tab of the server used last; This Mac on a first run. An
|
||
// automatic connect opens Codeman without looking at this Mac first:
|
||
// that look runs a few processes and asks the server once more, and
|
||
// it is only needed when the connect fails, which then asks for it.
|
||
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local', auto);
|
||
if (msg.starting) {
|
||
setBusy(true, false);
|
||
setStatus('busy', 'Starting Codeman on this Mac…', msg.notice || '');
|
||
} else if (msg.notice) {
|
||
setStatus('warn', msg.notice);
|
||
}
|
||
if (auto) connect();
|
||
else if (state.tab === 'remote') els.url.focus();
|
||
break;
|
||
case 'servers':
|
||
state.servers = msg.servers || [];
|
||
renderRecent();
|
||
if (!state.busy) matchSaved();
|
||
break;
|
||
case 'prefs':
|
||
if (typeof msg.startFullscreen === 'boolean') els.fullscreen.checked = msg.startFullscreen;
|
||
if (typeof msg.autoConnect === 'boolean') els.auto.checked = msg.autoConnect;
|
||
break;
|
||
case 'probeResult':
|
||
onProbeResult(msg);
|
||
break;
|
||
case 'opening':
|
||
// Still busy: Cancel abandons the load and brings this page back.
|
||
if (msg.passwordChange) {
|
||
setStatus('ok', 'Opening Codeman…', 'Codeman will ask you for a new password for this account. Connect with the new password next time.');
|
||
} else {
|
||
setStatus('ok', msg.version ? 'Opening Codeman ' + msg.version + '…' : 'Opening Codeman…');
|
||
}
|
||
// The server page is still loading: this is not a finished state.
|
||
els.spinner.hidden = false;
|
||
break;
|
||
case 'error':
|
||
setBusy(false);
|
||
// The tab first: switching it clears the status line.
|
||
if (msg.needsPassword) {
|
||
selectTab('remote');
|
||
showLogin();
|
||
}
|
||
setStatus('bad', msg.message || 'Something went wrong.');
|
||
ensureLocalChecked();
|
||
break;
|
||
case 'serverStarting':
|
||
setBusy(true, false);
|
||
setStatus('busy', 'Starting Codeman on this Mac…');
|
||
break;
|
||
case 'serverStartResult':
|
||
onStartResult(msg);
|
||
break;
|
||
case 'localStatus':
|
||
onLocalStatus(msg.status);
|
||
break;
|
||
case 'installStarted':
|
||
state.opening = false;
|
||
setStatus('', '');
|
||
state.wait = { since: Date.now(), finishedAt: 0, port: Number(msg.port) || 3000 };
|
||
selectTab('local');
|
||
renderLocal();
|
||
scheduleWait();
|
||
break;
|
||
case 'remoteInstallStarted':
|
||
state.opening = false;
|
||
syncLocalButtons();
|
||
selectTab('remote');
|
||
useAddress(msg.url);
|
||
setStatus('ok', 'Terminal is installing Codeman on ' + msg.host + '.',
|
||
'Answer the installer’s questions there. At the end it prints the address of Codeman on that machine: enter it above and connect (' + msg.url + ' is the usual one).');
|
||
break;
|
||
case 'installFailed':
|
||
state.opening = false;
|
||
syncLocalButtons();
|
||
// The tab first: switching it clears the status line.
|
||
if (msg.remote) selectTab('remote');
|
||
setStatus('bad', msg.message || 'Could not open Terminal.');
|
||
break;
|
||
case 'copied':
|
||
setStatus('ok', 'Copied.', 'Paste it into a terminal on the other machine.');
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
// The second click of a double click acts only on the control that took the
|
||
// first: the first may have moved another one under the pointer.
|
||
var firstControl = null;
|
||
document.addEventListener('click', function (ev) {
|
||
var t = ev.target && ev.target.closest ? ev.target : null;
|
||
var c = t && (t.closest('label') || t.closest('button, summary, a, input'));
|
||
if (ev.detail < 2) { firstControl = c; return; }
|
||
if (c !== firstControl) { ev.preventDefault(); ev.stopPropagation(); }
|
||
}, true);
|
||
els.form.addEventListener('submit', function (ev) { ev.preventDefault(); connect(); });
|
||
els.cancel.addEventListener('click', function () {
|
||
post({ cmd: 'cancel' });
|
||
setBusy(false);
|
||
setStatus('', '');
|
||
ensureLocalChecked();
|
||
});
|
||
els.start.addEventListener('click', function () {
|
||
if (state.busy) return;
|
||
state.startUrl = state.lastUrl || els.url.value.trim();
|
||
post({ cmd: 'startServer', url: state.startUrl });
|
||
});
|
||
els.suggest.addEventListener('click', function () {
|
||
if (state.busy || !state.suggestion) return;
|
||
els.url.value = state.suggestion;
|
||
matchSaved();
|
||
connect();
|
||
});
|
||
els.auto.addEventListener('change', function () { post({ cmd: 'setAutoConnect', value: els.auto.checked }); });
|
||
els.fullscreen.addEventListener('change', function () { post({ cmd: 'setStartFullscreen', value: els.fullscreen.checked }); });
|
||
els.url.addEventListener('input', function () {
|
||
matchSaved();
|
||
els.start.hidden = true;
|
||
hideSuggestion();
|
||
});
|
||
els.tabLocal.addEventListener('click', function () { selectTab('local'); });
|
||
els.tabRemote.addEventListener('click', function () { selectTab('remote'); });
|
||
[els.tabLocal, els.tabRemote].forEach(function (tab) {
|
||
tab.addEventListener('keydown', function (ev) {
|
||
if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return;
|
||
ev.preventDefault();
|
||
var next = state.tab === 'local' ? 'remote' : 'local';
|
||
selectTab(next);
|
||
(next === 'local' ? els.tabLocal : els.tabRemote).focus();
|
||
});
|
||
});
|
||
// Return opens or starts the Codeman on this Mac and Esc cancels a connect,
|
||
// as in a Mac dialog. Install is left to a click: a stray Return must not
|
||
// start an installer.
|
||
document.addEventListener('keydown', function (ev) {
|
||
if (ev.defaultPrevented) return;
|
||
if (ev.key === 'Escape' && !els.cancel.hidden) { ev.preventDefault(); els.cancel.click(); return; }
|
||
if (ev.key !== 'Enter' || state.tab !== 'local') return;
|
||
if (ev.target && ev.target.closest && ev.target.closest('button, a, input, summary')) return;
|
||
// offsetParent: really on screen, whatever hides it (its own `hidden`, or
|
||
// a container that is not shown).
|
||
var main = [els.localOpen, els.localStart].filter(function (b) { return b.offsetParent && !b.disabled; })[0];
|
||
if (main) { ev.preventDefault(); main.click(); }
|
||
});
|
||
els.localOpen.addEventListener('click', openLocal);
|
||
els.localStart.addEventListener('click', startLocal);
|
||
els.localInstall.addEventListener('click', installLocal);
|
||
els.localStop.addEventListener('click', function () { state.stoppedWaiting = true; endWait(); checkLocal('login'); });
|
||
els.localCheck.addEventListener('click', function () { if (!state.busy && !state.localChecking) checkLocal('deep'); });
|
||
els.sshInstall.addEventListener('click', installRemote);
|
||
// Enter in the SSH field installs; it must not submit the form (Connect).
|
||
els.ssh.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter') { ev.preventDefault(); installRemote(); }
|
||
});
|
||
els.copy.addEventListener('click', function () { post({ cmd: 'copyInstallCommand' }); });
|
||
$('docs').addEventListener('click', function (ev) { ev.preventDefault(); post({ cmd: 'openExternal', url: 'https://github.com/Ark0N/Codeman/wiki' }); });
|
||
|
||
if (tooOld) {
|
||
els.webkit.hidden = false;
|
||
setBusy(false);
|
||
}
|
||
post({ cmd: 'ready' });
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|