diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index d5c33040..e3cccbd8 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -53,7 +53,7 @@ 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] { + input[type=text], input[type=password] { 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; @@ -61,7 +61,6 @@ 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 { @@ -128,7 +127,7 @@ /* The Codeman servers found on this Mac: a row each, in the look of a choice in the install, with the row's own Connect button. */ #local-found-note { margin: 8px 0 0; } - #local-also { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); } + #local-also, #elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); } #local-also-head { color: var(--muted); font-size: 12px; margin: 0 0 6px; } .found { display: flex; gap: 10px; align-items: center; padding: 9px 11px; border-radius: 9px; @@ -139,7 +138,6 @@ .found b { display: block; font-weight: 600; } .found small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; } .found button { flex: none; } - #elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); } #elsewhere[hidden] { display: none; } #elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; } #elsewhere summary::-webkit-details-marker { display: none; } @@ -231,7 +229,6 @@ border: 1px solid var(--line); color: #aab1bb; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text; } - #wizard .actions .wiz-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; } footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; } footer a { color: #aab1bb; text-decoration: none; cursor: default; } footer a:hover { color: var(--accent); } @@ -416,9 +413,11 @@ // suggestion: the address the suggest button connects to. // tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab // the connect under way was asked from, where its answer belongs. local: the - // last This Mac status from the app. wait: set while an install runs in - // Terminal and this page polls for the server it brings up. gui: the - // graphical install while its screens are up (newWizard), null otherwise. + // last This Mac status from the app. wait: set while this page polls for the + // server an install brings up (one in Terminal, or a graphical one that + // finished; newWait). gui: the graphical install or the agent chooser while + // its screens are up (newWizard), null otherwise. clock: the timer of the + // running phase's clock. // reopen: a connect to this Mac was refused, so open it if it turns out to // be starting. startingLooks: how often the page has looked again at a // starting server. askedFound: the connect under way was asked from the row @@ -427,7 +426,7 @@ servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '', tab: 'local', askedFrom: 'local', askedFound: false, local: null, localChecking: false, opening: false, wait: null, waitTimer: 0, stoppedWaiting: false, gui: null, wantAgents: false, - reopen: false, startingLooks: 0, startingTimer: 0 + reopen: false, startingLooks: 0, startingTimer: 0, clock: 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. @@ -511,14 +510,15 @@ els.suggest.hidden = true; } - function host(url) { + function displayAddress(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. + // or fragment, a trailing slash. Used to tell whether two addresses are one + // server, and which scheme and host a typed address ends up with + // (isLocalUrl, the http:// warning). function normalize(raw) { var s = String(raw || '').trim(); if (!s) return ''; @@ -553,7 +553,7 @@ chip.className = 'chip'; var pick = document.createElement('button'); pick.type = 'button'; - pick.textContent = host(s.url) + (s.username ? ' (' + s.username + ')' : ''); + pick.textContent = displayAddress(s.url) + (s.username ? ' (' + s.username + ')' : ''); pick.title = 'Connect to ' + s.url; pick.addEventListener('click', function () { if (state.busy) return; @@ -565,7 +565,7 @@ 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.setAttribute('aria-label', 'Forget ' + displayAddress(s.url)); x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); }); chip.appendChild(pick); chip.appendChild(x); @@ -782,8 +782,11 @@ return (s.found || []).filter(function (f) { return f && f.url !== s.url; }); } - function canConnect(f) { - return f.state === 'running' || f.state === 'needsLogin'; + // Codeman answers there, with or without a login, so the app can connect + // to it. Read the same way for a found server's row and for the This Mac + // status. + function answers(s) { + return s.state === 'running' || s.state === 'needsLogin'; } // What a found server's row says under its address: its version, or why the @@ -815,7 +818,7 @@ text.appendChild(small); } row.appendChild(text); - if (canConnect(f)) { + if (answers(f)) { var button = document.createElement('button'); button.type = 'button'; button.className = primary ? 'primary' : 'secondary'; @@ -848,7 +851,7 @@ } else if (!s) { dot = 'busy'; title = 'Looking for Codeman on this Mac…'; - } else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') { + } else if (s.installRunning && !answers(s)) { dot = 'busy'; title = 'Codeman is being installed in Terminal.'; detail = 'Finish the installer there, then Check again.'; @@ -861,7 +864,7 @@ } else if (s.state === 'runningElsewhere') { // Not at the address the app opens by default: each one gets a row. main = (s.found || []).filter(Boolean); - dot = main.some(canConnect) ? 'ok' : 'warn'; + dot = main.some(answers) ? 'ok' : 'warn'; title = 'Codeman is already running on this Mac.'; // No row: the app found the server's process, but no address to try. if (!main.length) { @@ -869,16 +872,12 @@ 'Starting another server here would take over its sessions.'; } show.check = true; - } else if (s.state === 'running') { + } else if (answers(s)) { dot = 'ok'; title = 'Codeman is running on this Mac.'; - detail = s.url + (s.version ? ' · version ' + s.version : ''); - show.open = true; - also = otherServers(s); - } else if (s.state === 'needsLogin') { - dot = 'ok'; - title = 'Codeman is running on this Mac.'; - detail = s.url + ' asks for a login: Open asks for the password.'; + detail = s.state === 'needsLogin' + ? s.url + ' asks for a login: Open asks for the password.' + : s.url + (s.version ? ' · version ' + s.version : ''); show.open = true; also = otherServers(s); } else if (s.state === 'stopped') { @@ -915,7 +914,7 @@ if (o.kind === 'notCodeman') { title = 'Something else answers at ' + s.url; detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to ' + - (also.some(canConnect) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.'); + (also.some(answers) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.'); } else { var e = explain[o.kind]; title = e ? e[1] : 'Could not reach Codeman on this Mac.'; @@ -959,8 +958,8 @@ } // An install from this app is still running (this page was shown again // while it ran): wait for it, unless the user stopped waiting. - if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') { - state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 }; + if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && !answers(s)) { + state.wait = newWait(s.installPort || 3000); renderLocal(); scheduleWait(); } @@ -989,7 +988,7 @@ } return false; } - var up = !!s && (s.state === 'running' || s.state === 'needsLogin'); + var up = !!s && answers(s); var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy; state.startingLooks = 0; state.reopen = false; @@ -1041,10 +1040,15 @@ var network = !!state.gui && state.gui.reach === 'network'; closeWizard(); selectTab('local'); + openTerminal({ cmd: 'installLocal', network: network }); + } + + // Both Terminal installs: no second one while Terminal opens. + function openTerminal(msg) { state.opening = true; syncLocalButtons(); setStatus('busy', 'Opening Terminal…'); - post({ cmd: 'installLocal', network: network }); + post(msg); } // mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac @@ -1056,17 +1060,22 @@ }; } + // The first screen of the graphical install ('install') or of adding an + // agent ('agent'), which asks the installer for its plan. + function prepareWizard(mode) { + state.gui = newWizard('preparing', mode); + post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' }); + } + function openWizard() { if (state.busy || tooOld || state.opening) return; - state.gui = newWizard('preparing'); - post({ cmd: 'installPrepare' }); + prepareWizard('install'); renderWizard(); } function openAgentChooser() { if (state.busy || tooOld || state.opening) return; - state.gui = newWizard('preparing', 'agent'); - post({ cmd: 'agentPrepare' }); + prepareWizard('agent'); renderWizard(); } @@ -1086,14 +1095,14 @@ renderLocal(); } + // Try again keeps what was chosen. function retryWizard() { - var reach = state.gui ? state.gui.reach : 'local'; - var agent = state.gui ? state.gui.agent : ''; - var mode = state.gui ? state.gui.mode : 'install'; - state.gui = newWizard('preparing', mode); - state.gui.reach = reach; - state.gui.agent = agent; - post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' }); + var old = state.gui; + prepareWizard(old ? old.mode : 'install'); + if (old) { + state.gui.reach = old.reach; + state.gui.agent = old.agent; + } renderWizard(); } @@ -1111,6 +1120,12 @@ return offeredAgents().filter(function (c) { return c.id === id; })[0] || null; } + // This Mac has no coding agent and the installer offers one, so an install + // asks which one. + function needsAgent() { + return !installedAgents().length && offeredAgents().length > 0; + } + // A run this page did not start has no plan, so no name for the agent. function agentName() { var c = agentById(state.gui.agent); @@ -1129,7 +1144,7 @@ g.step = 'agent'; } else if (plan.blocker) { g.step = 'blocked'; - } else if (!installedAgents().length && offeredAgents().length) { + } else if (needsAgent()) { // Kept across Try again; otherwise the first of the catalogue's order. if (!agentById(g.agent)) g.agent = offeredAgents()[0].id; g.step = 'agent'; @@ -1229,7 +1244,7 @@ PHASES.forEach(function (id) { var label = { runtime: 'Getting Node.js and tmux', - agent: 'Installing ' + (chosen ? chosen.label : 'a coding agent'), + agent: 'Installing ' + agentName(), source: 'Downloading Codeman', packages: 'Installing packages', build: 'Building Codeman', @@ -1313,21 +1328,14 @@ if (!g) return; appendLog(msg.log); clearInterval(state.clock); - if (msg.ok && g.mode === 'agent') { - g.ok = true; - g.step = 'added'; - renderWizard(); - // Whether there is a Codeman to open from here. - checkLocal('quick'); - return; - } if (msg.ok) { g.ok = true; - g.step = 'finishing'; + g.step = g.mode === 'agent' ? 'added' : 'finishing'; renderWizard(); - // The same watch as a Terminal install: it opens Codeman at the first - // answer, and offers Start when none comes. - state.wait = { since: Date.now(), finishedAt: 0, port: g.port }; + // An install gets the same watch as a Terminal install: it opens Codeman + // at the first answer, and offers Start when none comes. An added agent + // only asks whether there is a Codeman to open from here. + if (g.mode !== 'agent') state.wait = newWait(g.port); checkLocal('quick'); return; } @@ -1346,7 +1354,8 @@ renderLog(); } - // Each screen of the graphical install, drawn from state.gui alone. + // Each screen of the graphical install and of adding an agent, drawn from + // state.gui (and state.local for Open Codeman). function renderWizard() { var g = state.gui; els.card.classList.toggle('wizard-on', !!g); @@ -1418,10 +1427,6 @@ else els.wizTitle.focus(); } - function localAnswers() { - return !!state.local && (state.local.state === 'running' || state.local.state === 'needsLogin'); - } - function renderWizardButtons() { var g = state.gui; var step = g.step; @@ -1430,7 +1435,7 @@ var next = { agent: agentMode ? (chosen ? 'Install ' + chosen.label : '') : 'Continue', reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal', - ready: 'Open Codeman', added: localAnswers() ? 'Open Codeman' : '' + ready: 'Open Codeman', added: state.local && answers(state.local) ? 'Open Codeman' : '' }[step]; els.wizNext.hidden = !next; els.wizNext.textContent = next || ''; @@ -1478,7 +1483,7 @@ els.wizPw.value = ''; els.wizPw2.value = ''; // Skipped: the first thing to say once Codeman answers. - g.noAgent = !installedAgents().length && !msg.aiCli && offeredAgents().length > 0; + g.noAgent = needsAgent() && !msg.aiCli; beginRun(); post(msg); } @@ -1524,7 +1529,7 @@ function wizardBack() { var g = state.gui; if (!g) return; - if (g.step === 'reach' && !installedAgents().length && offeredAgents().length && !g.plan.blocker) { + if (g.step === 'reach' && needsAgent() && !g.plan.blocker) { g.step = 'agent'; renderWizard(); } else { @@ -1532,6 +1537,12 @@ } } + // A wait for the server an install brings up, on `port` until the + // installer says otherwise. + function newWait(port) { + return { since: Date.now(), finishedAt: 0, port: port }; + } + function scheduleWait() { clearTimeout(state.waitTimer); state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS); @@ -1560,7 +1571,7 @@ // 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')) { + if (ours && answers(s)) { var g = state.gui; if (g && g.step === 'finishing' && g.noAgent) { clearTimeout(state.waitTimer); @@ -1608,10 +1619,7 @@ els.ssh.focus(); return; } - state.opening = true; - syncLocalButtons(); - setStatus('busy', 'Opening Terminal…'); - post({ cmd: 'installRemote', target: target }); + openTerminal({ cmd: 'installRemote', target: target }); } window.__codemanDesktop = { @@ -1690,7 +1698,7 @@ case 'installStarted': state.opening = false; setStatus('', ''); - state.wait = { since: Date.now(), finishedAt: 0, port: Number(msg.port) || 3000 }; + state.wait = newWait(Number(msg.port) || 3000); selectTab('local'); renderLocal(); scheduleWait(); diff --git a/packages/desktop/src/logo.rs b/packages/desktop/src/logo.rs index 634b2938..11a75c46 100644 --- a/packages/desktop/src/logo.rs +++ b/packages/desktop/src/logo.rs @@ -270,7 +270,7 @@ const SETTLE: Duration = Duration::from_secs(3); const LOOK_EVERY: Duration = Duration::from_millis(250); /// What a blink needs from the machine, so its decisions can be tested. -pub trait Logo { +trait Logo { /// The current mode, None if it could not be read. fn mode(&self) -> Option; /// Set the mode; false if the daemon did not take it. @@ -281,7 +281,7 @@ pub trait Logo { } /// The file that says a blink may have left the logo dark. -pub struct Marker(Option); +struct Marker(Option); impl Marker { fn default_location() -> Self { @@ -363,7 +363,7 @@ fn blink(logo: &impl Logo, marker: &Marker, pattern: &[Beat], mut wait: impl FnM } /// Put back a logo that a blink could not finish with. -pub fn recover(logo: &impl Logo, marker: &Marker) { +fn recover(logo: &impl Logo, marker: &Marker) { if !marker.is_set() { return; } @@ -507,25 +507,24 @@ fn spawn_child(connection: Connection) -> Option<(Connection, ChildStdin)> { Some((connection, stdin)) } -#[cfg(target_os = "macos")] pub use system::{available, blink_main, recover_at_launch, switched_off}; +/// No lit logo to blink outside macOS. #[cfg(not(target_os = "macos"))] -pub fn available() -> bool { - false -} +mod system { + pub fn available() -> bool { + false + } -#[cfg(not(target_os = "macos"))] -pub fn blink_main() -> ! { - std::process::exit(0) -} + pub fn blink_main() -> ! { + std::process::exit(0) + } -#[cfg(not(target_os = "macos"))] -pub fn recover_at_launch() {} + pub fn recover_at_launch() {} -#[cfg(not(target_os = "macos"))] -pub fn switched_off() -> bool { - false + pub fn switched_off() -> bool { + false + } } #[cfg(target_os = "macos")] diff --git a/packages/desktop/src/policy.rs b/packages/desktop/src/policy.rs index 01cfc7e0..905a5692 100644 --- a/packages/desktop/src/policy.rs +++ b/packages/desktop/src/policy.rs @@ -21,6 +21,7 @@ pub const CONNECT_PAGE: &str = "codemanapp://localhost/index.html"; /// Caps on what a page may hand the native side. pub const MAX_URL_LEN: usize = 4096; pub const MAX_FIELD_LEN: usize = 1024; +const MAX_MESSAGE_LEN: usize = 16 * 1024; /// Where the main window is in reaching a server. The gates below take it /// instead of "is a server set", because what may talk to the native side @@ -552,12 +553,9 @@ pub fn new_window(target: &str, phase: &Phase, via: Via) -> NewWindow { if target.len() > MAX_URL_LEN { return NewWindow::Deny; } - let Ok(url) = Url::parse(target) else { + let Some(url) = external_url(target) else { return NewWindow::Deny; }; - if !matches!(url.scheme(), "http" | "https") { - return NewWindow::Deny; - } let Some(srv) = phase.loaded() else { return NewWindow::Deny; }; @@ -656,7 +654,12 @@ pub enum Ipc { } #[derive(Deserialize)] -#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)] +#[serde( + tag = "cmd", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] enum RawConnect { Ready, Connect { @@ -696,18 +699,15 @@ enum RawConnect { }, InstallPrepare, InstallStart { - // rename_all above reaches variant names only, and deny_unknown_fields - // would refuse the page's spelling of these. - #[serde(default, rename = "networkPassword")] + #[serde(default)] network_password: Option, - #[serde(default, rename = "aiCli")] + #[serde(default)] ai_cli: Option, }, InstallAttach, InstallCancel, AgentPrepare, AgentStart { - #[serde(rename = "aiCli")] ai_cli: String, }, AgentAttach, @@ -761,7 +761,7 @@ fn is_connect_page(frame: &Url) -> bool { /// - A server page is listened to only once it committed, and only from a /// frame running Codeman's own app shell (trusted_frame). pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: bool) -> Option { - if body.len() > 16 * 1024 { + if body.len() > MAX_MESSAGE_LEN { return None; } let frame = Url::parse(frame_url).ok()?; @@ -776,17 +776,12 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo username, password, remember, - } => { - if url.len() > MAX_URL_LEN { - return None; - } - ConnectCmd::Connect { - url, - username: bounded(username, MAX_FIELD_LEN)?, - password: bounded(password, MAX_FIELD_LEN)?, - remember, - } - } + } if url.len() <= MAX_URL_LEN => ConnectCmd::Connect { + url, + username: bounded(username, MAX_FIELD_LEN)?, + password: bounded(password, MAX_FIELD_LEN)?, + remember, + }, RawConnect::Cancel => ConnectCmd::Cancel, RawConnect::StartServer { url } if url.len() <= MAX_URL_LEN => ConnectCmd::StartServer { url }, RawConnect::Forget { url } if url.len() <= MAX_URL_LEN => ConnectCmd::Forget { url }, @@ -799,15 +794,10 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo RawConnect::InstallStart { network_password, ai_cli, - } => { - if ai_cli.as_deref().is_some_and(|id| !is_cli_id(id)) { - return None; - } - ConnectCmd::InstallStart { - network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()), - ai_cli, - } - } + } if ai_cli.as_deref().is_none_or(is_cli_id) => ConnectCmd::InstallStart { + network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()), + ai_cli, + }, RawConnect::InstallAttach => ConnectCmd::InstallAttach, RawConnect::InstallCancel => ConnectCmd::InstallCancel, RawConnect::AgentPrepare => ConnectCmd::AgentPrepare,