mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Adds packages/desktop, a small native app (Rust: tao, wry/WKWebView, muda, objc2) that opens Codeman in its own window. The universal app is about 5 MB (3.2 MB as a DMG) and runs on macOS 11.3 or later. - Connect screen with two tabs. This Mac finds the local Codeman and offers Open, Start or Install. Start uses launchd for a LaunchAgent install and codeman web -d otherwise. It never starts a second server while one runs, because two servers on one data dir share live sessions. Another machine connects by address (recent servers, Keychain logins) and can install Codeman there over SSH. - Installs run Codeman's install.sh in Terminal from a self-deleting .command script. The installer needs a terminal for sudo, Homebrew and ssh. A local install uses --local --service and the first free port from 3000, and the app opens Codeman once it answers. - The local Codeman's login is read from its LaunchAgent or ~/.codeman/.env. It is sent only to 127.0.0.1 on that port, only while the user's own Codeman process holds it, and is never saved. - Native JS dialogs (alert, confirm, prompt) and HTTP Basic challenge answers, both scoped to Codeman's own pages, so the page keeps working after a server restart. App cookies are cleared before every connect. - Pop-outs through window.CodemanHost, with an allowlist (webview/ never opens a window), external links in the default browser, downloads to ~/Downloads, real full screen and window state that is remembered. - The connect page refuses a WebKit older than Safari 16.4, which Codeman's terminal needs. Codeman side: viewSubagentTranscript shows a toast when the pop-up is refused instead of failing, with zh-CN strings and a test that pins them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
898 lines
41 KiB
HTML
898 lines
41 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, body { height: 100%; margin: 0; }
|
||
body {
|
||
background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg);
|
||
color: var(--text);
|
||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
|
||
display: flex; align-items: center; justify-content: center;
|
||
-webkit-user-select: none; user-select: none;
|
||
}
|
||
main { width: min(440px, calc(100vw - 48px)); }
|
||
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); }
|
||
.card {
|
||
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, var(--accent) 0%, var(--accent-2) 100%); }
|
||
button.primary:disabled { opacity: 0.55; }
|
||
button.secondary { border: 1px solid var(--line-strong); background: #16181d; color: var(--text); }
|
||
button.secondary:hover { border-color: var(--accent-2); }
|
||
button.secondary:disabled { opacity: 0.55; }
|
||
button[hidden] { display: none; }
|
||
#status { min-height: 20px; margin-top: 14px; font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
|
||
#status.bad { color: var(--bad); }
|
||
#status.warn { color: var(--warn); }
|
||
#status.ok { color: var(--ok); }
|
||
#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: 16px; }
|
||
.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: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||
#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 #status { flex: 1; }
|
||
.statusrow #cancel { margin-top: 10px; }
|
||
footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; }
|
||
footer a { color: #7c8491; text-decoration: none; cursor: default; }
|
||
footer 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>
|
||
<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" 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>
|
||
|
||
<details id="elsewhere">
|
||
<summary>Codeman is not installed there yet?</summary>
|
||
<label class="field" for="ssh">Install it over SSH</label>
|
||
<div class="inline">
|
||
<input id="ssh" type="text" spellcheck="false" autocapitalize="off" placeholder="you@machine, or a name from ~/.ssh/config">
|
||
<button class="secondary" id="ssh-install" type="button">Install</button>
|
||
</div>
|
||
<p class="sub">Terminal opens, connects with ssh and runs Codeman’s installer there. Or run this in a terminal on that machine:</p>
|
||
<div class="oneliner"><code id="oneliner">curl -fsSL https://getcodeman.com/install | bash</code><button class="secondary" id="copy" type="button">Copy</button></div>
|
||
</details>
|
||
</form>
|
||
|
||
<div class="statusrow">
|
||
<div 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>
|
||
|
||
<div class="prefs">
|
||
<label class="check"><input id="auto" type="checkbox"> 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'),
|
||
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). local: the last
|
||
// This Mac status from the app. wait: set while an install runs in
|
||
// Terminal and this page polls for the server it brings up.
|
||
var state = {
|
||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||
tab: 'local', local: null, localChecking: false, opening: false, wait: null, waitTimer: 0,
|
||
stoppedWaiting: false
|
||
};
|
||
// 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;
|
||
|
||
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
|
||
|
||
function setStatus(kind, text, detail) {
|
||
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, 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();
|
||
s = (/\.ts\.net$/.test(name) ? '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.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);
|
||
}
|
||
|
||
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; }
|
||
state.lastUrl = url;
|
||
setBusy(true);
|
||
setStatus('busy', 'Connecting to ' + url + '…');
|
||
var msg = { cmd: 'connect', url: url, remember: els.remember.checked };
|
||
if (!els.login.hidden) {
|
||
if (els.username.value.trim()) msg.username = els.username.value.trim();
|
||
if (els.password.value) msg.password = els.password.value;
|
||
}
|
||
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.'],
|
||
passwordChangeRequired: ['warn', 'Codeman asks for a new password for this account.', 'Open it in a browser to set one, then connect with the new password.']
|
||
};
|
||
|
||
// 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) {
|
||
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 (state.tab === 'local') {
|
||
// Opening the Codeman on this Mac failed: look at it again.
|
||
checkLocal('login');
|
||
}
|
||
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 + ')';
|
||
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]';
|
||
}
|
||
|
||
function selectTab(tab) {
|
||
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;
|
||
if (changed && !state.busy) {
|
||
setStatus('', '');
|
||
hideSuggestion();
|
||
}
|
||
if (local && !state.local && !state.localChecking) checkLocal('login');
|
||
}
|
||
|
||
// quick: skip the login shell (used while an install runs, when only
|
||
// "does it answer yet" matters).
|
||
// How hard to look: 'quick' (files and processes, while an install runs),
|
||
// 'login' (also the login shell; every automatic look) or 'deep' (also the
|
||
// interactive profile, only on Check again).
|
||
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 (state.localChecking || !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 === '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;
|
||
}
|
||
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;
|
||
}
|
||
renderLocal();
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// One answer while an install runs. It stops at the first answer from a
|
||
// server: a password-protected one counts every unauthenticated look as a
|
||
// failed login.
|
||
function portOf(url) {
|
||
try {
|
||
var u = new URL(url);
|
||
return Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
||
} catch (e) { return 0; }
|
||
}
|
||
|
||
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]);
|
||
// The tab of the server used last; This Mac on a first run.
|
||
selectTab(state.servers[0] && !isLocalUrl(state.servers[0].url) ? 'remote' : 'local');
|
||
if (msg.starting) {
|
||
setBusy(true, false);
|
||
setStatus('busy', 'Starting Codeman on this Mac…', msg.notice || '');
|
||
} else if (msg.notice) {
|
||
setStatus('warn', msg.notice);
|
||
}
|
||
if (msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]) connect();
|
||
else if (state.tab === 'remote') els.url.focus();
|
||
break;
|
||
case 'servers':
|
||
state.servers = msg.servers || [];
|
||
renderRecent();
|
||
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…');
|
||
}
|
||
break;
|
||
case 'error':
|
||
setBusy(false);
|
||
setStatus('bad', msg.message || 'Something went wrong.');
|
||
if (msg.needsPassword) {
|
||
selectTab('remote');
|
||
showLogin();
|
||
}
|
||
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();
|
||
setStatus('bad', msg.message || 'Could not open Terminal.');
|
||
break;
|
||
case 'copied':
|
||
setStatus('ok', 'Copied.', 'Paste it into a terminal on the other machine.');
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
els.form.addEventListener('submit', function (ev) { ev.preventDefault(); connect(); });
|
||
els.cancel.addEventListener('click', function () { post({ cmd: 'cancel' }); setBusy(false); setStatus('', ''); });
|
||
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();
|
||
});
|
||
});
|
||
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>
|