diff --git a/packages/desktop/src/gui_install.rs b/packages/desktop/src/gui_install.rs index d0525d19..831e6355 100644 --- a/packages/desktop/src/gui_install.rs +++ b/packages/desktop/src/gui_install.rs @@ -6,8 +6,8 @@ //! tmux in the home folder, so nothing in it asks for a password, and the two //! questions only a person can answer (which coding agent, who may reach //! Codeman) are asked by the page and passed as flags. The Terminal route in -//! `setup.rs` stays for what this one cannot do, a Mac older than macOS 15 with -//! no tmux first of all. +//! `terminal_install.rs` stays for what this one cannot do, a Mac older than +//! macOS 15 with no tmux first of all. //! //! `prepare` downloads the installer once into a private folder and asks it //! what is on this Mac (`install.sh preflight`); `start` runs the same file with diff --git a/packages/desktop/src/login.rs b/packages/desktop/src/login.rs new file mode 100644 index 00000000..f2b4a76a --- /dev/null +++ b/packages/desktop/src/login.rs @@ -0,0 +1,256 @@ +//! The login a connect goes out with, and what the Keychain keeps once the +//! server has answered it. + +use crate::credentials::{self, Keychain}; +use crate::probe::ProbeOutcome; + +/// A login the probe just tried, and what the user asked for it. +/// The login a connect goes out with, and where its password came from. A +/// typed password wins, then the one the Keychain holds for this user. Last +/// comes the login of the Codeman on this Mac, which keeps it where its own +/// clients find it (setup::local_login: its launchd job, then +/// ~/.codeman/.env) and only answers it at its own loopback address. That one +/// is used only when the config expected no password in the Keychain +/// (`expect_saved`: one that should be there and is not is reported, not +/// papered over), only for the username in use when there is one, and it is +/// never saved anywhere. `local` is asked only then: it runs processes. +pub fn choose_login( + user: Option, + typed_password: Option, + saved: Option, + expect_saved: bool, + local: impl FnOnce() -> Option<(String, String)>, +) -> (Option, Option, PasswordSource) { + if let Some(password) = typed_password { + return (user, Some(password), PasswordSource::Typed); + } + if let Some(password) = saved { + return (user, Some(password), PasswordSource::Keychain); + } + if !expect_saved { + if let Some((local_user, password)) = local() { + if user.as_deref().is_none_or(|current| current == local_user) { + return (Some(local_user), Some(password), PasswordSource::LocalServer); + } + } + } + (user, None, PasswordSource::Absent) +} + +pub struct ProbedLogin<'a> { + /// The server, as the config and the Keychain accounts key it. + pub server: &'a str, + /// The username the config named for this server before the connect. + pub previous_user: Option<&'a str>, + pub username: Option<&'a str>, + pub password: Option<&'a str>, + /// Where the password came from. + pub source: PasswordSource, + pub remember: bool, +} + +/// Where a connect's password came from, which decides what the Keychain +/// does with it (update_keychain). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PasswordSource { + /// Typed on the connect page this time: saved if Remember is on. + Typed, + /// From the Keychain: there already. + Keychain, + /// The local Codeman's own login (setup::local_login): it stays where it + /// is, and the Keychain never gets a copy. + LocalServer, + /// No password. + Absent, +} + +/// What update_keychain did: whether the Keychain now holds the login's +/// password, and whose items it deleted. +#[derive(Default)] +pub struct KeychainUpdate { + pub saved: bool, + pub deleted: Vec, +} + +/// Bring the Keychain in line with a probe the server answered, on the probe +/// worker. Only an accepted login changes anything (Ok, or a password the +/// server wants changed), since only then does the config get the new entry. +/// +/// When that entry names another user than the config did (compared exactly, +/// as the account string is), the old user's item is deleted FIRST, before any +/// save: the Keychain worker runs jobs in order, so a Keychain that treats two +/// spellings of a name as one item cannot lose the new password to that +/// delete. Then the typed password is saved when Remember is on and deleted +/// when it is off, and a password the server wants changed is never kept. +pub fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLogin<'_>) -> KeychainUpdate { + let mut update = KeychainUpdate::default(); + if !matches!(outcome, ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) { + return update; + } + let mut delete = |user: &str| { + keychain.delete(&credentials::account(login.server, user)); + update.deleted.push(user.to_string()); + }; + if let Some(old) = login.previous_user.filter(|old| login.username != Some(*old)) { + delete(old); + } + let saved = match (outcome, login.username, login.password) { + (ProbeOutcome::Ok { .. }, Some(user), Some(password)) if login.source == PasswordSource::Typed => { + if login.remember { + keychain + .save_blocking(&credentials::account(login.server, user), password) + .is_ok() + } else { + delete(user); + false + } + } + // A password that came from the Keychain is saved there already. + (ProbeOutcome::Ok { .. }, Some(_), Some(_)) => login.source == PasswordSource::Keychain, + (ProbeOutcome::PasswordChangeRequired, Some(user), _) => { + delete(user); + false + } + _ => false, + }; + update.saved = saved; + update +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::credentials::memory; + + fn none() -> Option<(String, String)> { + panic!("the local login must not be looked up here"); + } + + #[test] + fn the_login_a_connect_uses() { + let s = |v: &str| Some(v.to_string()); + let local = || Some(("admin".to_string(), "from-this-mac".to_string())); + + // A typed password wins over everything, and nothing else is asked. + assert_eq!( + choose_login(s("kay"), s("typed"), s("kept"), true, none), + (s("kay"), s("typed"), PasswordSource::Typed) + ); + // Then the Keychain's. + assert_eq!( + choose_login(s("kay"), None, s("kept"), true, none), + (s("kay"), s("kept"), PasswordSource::Keychain) + ); + // The Keychain should hold one and does not: reported as absent, the + // local login is not tried in its place. + assert_eq!( + choose_login(s("kay"), None, None, true, none), + (s("kay"), None, PasswordSource::Absent) + ); + // Nothing typed, nothing expected: the Codeman on this Mac's own + // login, for no username or the same one. + assert_eq!( + choose_login(None, None, None, false, local), + (s("admin"), s("from-this-mac"), PasswordSource::LocalServer) + ); + assert_eq!( + choose_login(s("admin"), None, None, false, local), + (s("admin"), s("from-this-mac"), PasswordSource::LocalServer) + ); + // Never for another user: the names are compared exactly. + assert_eq!( + choose_login(s("kay"), None, None, false, local), + (s("kay"), None, PasswordSource::Absent) + ); + assert_eq!( + choose_login(s("Admin"), None, None, false, local), + (s("Admin"), None, PasswordSource::Absent) + ); + // No local login either. + assert_eq!( + choose_login(None, None, None, false, || None), + (None, None, PasswordSource::Absent) + ); + } + + /// update_keychain for one probe answer; the log is what the Keychain did, + /// in order (the last load only waits for the queue to drain). + fn keychain_after(outcome: ProbeOutcome, login: ProbedLogin<'_>) -> (KeychainUpdate, Vec) { + let (keychain, store) = memory::keychain(); + let update = update_keychain(&keychain, &outcome, &login); + keychain.load_blocking("done"); + let mut log = store.log(); + assert_eq!(log.pop().as_deref(), Some("load done")); + (update, log) + } + + fn login<'a>( + previous_user: Option<&'a str>, + username: Option<&'a str>, + source: PasswordSource, + remember: bool, + ) -> ProbedLogin<'a> { + ProbedLogin { + server: "http://a/", + previous_user, + username, + password: (source != PasswordSource::Absent).then_some("secret"), + source, + remember, + } + } + + #[test] + fn what_the_keychain_keeps_after_a_connect() { + let ok = || ProbeOutcome::Ok { + version: "1.48.0".into(), + }; + let item = |user: &str| credentials::account("http://a/", user); + + // A typed password is saved when Remember is on, deleted when it is off. + let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, true)); + assert!(update.saved && update.deleted.is_empty()); + assert_eq!(log, [format!("save {}", item("kay"))]); + let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, false)); + assert!(!update.saved); + assert_eq!(update.deleted, ["kay"]); + assert_eq!(log, [format!("delete {}", item("kay"))]); + + // One that came from the Keychain is there already: nothing is written. + let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Keychain, true)); + assert!(update.saved && log.is_empty()); + + // The local Codeman's own login never gets a copy, Remember or not. + let (update, log) = keychain_after(ok(), login(None, Some("admin"), PasswordSource::LocalServer, true)); + assert!(!update.saved && update.deleted.is_empty() && log.is_empty()); + + // Another user than before: the old user's item goes FIRST, then the save. + let (update, log) = keychain_after(ok(), login(Some("old"), Some("new"), PasswordSource::Typed, true)); + assert!(update.saved); + assert_eq!(update.deleted, ["old"]); + assert_eq!( + log, + [format!("delete {}", item("old")), format!("save {}", item("new"))] + ); + + // A password the server wants changed is never kept. + let (update, log) = keychain_after( + ProbeOutcome::PasswordChangeRequired, + login(Some("kay"), Some("kay"), PasswordSource::Typed, true), + ); + assert!(!update.saved); + assert_eq!(update.deleted, ["kay"]); + assert_eq!(log, [format!("delete {}", item("kay"))]); + + // A login the server did not accept changes nothing. + for refused in [ + ProbeOutcome::BadCredentials, + ProbeOutcome::NeedsLogin, + ProbeOutcome::Refused, + ProbeOutcome::Timeout, + ] { + let (update, log) = keychain_after(refused, login(Some("old"), Some("new"), PasswordSource::Typed, true)); + assert!(!update.saved && update.deleted.is_empty() && log.is_empty()); + } + } +} diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index dc017ec8..300cba9b 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -26,12 +26,15 @@ mod config; mod credentials; mod gui_install; mod local_server; +mod login; mod logo; #[cfg(target_os = "macos")] mod macos; +mod menu; mod policy; mod probe; mod setup; +mod terminal_install; use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; @@ -42,8 +45,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use muda::accelerator::{Accelerator, Code, Modifiers}; -use muda::{AboutMetadata, CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use muda::MenuEvent; use serde_json::json; use tao::dpi::{LogicalPosition, LogicalSize}; use tao::event::{Event, StartCause, WindowEvent}; @@ -56,7 +58,9 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView use config::{Config, Rect, WindowFrame}; use credentials::Keychain; +use login::{choose_login, update_keychain, KeychainUpdate, ProbedLogin}; use logo::{BlinkStyle, Signal}; +use menu::{build_menu, menu_id, AppMenu}; use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via}; use probe::ProbeOutcome; @@ -105,16 +109,6 @@ const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink."; const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address."; -mod menu_id { - pub const SWITCH: &str = "switch-server"; - pub const ADD_AGENT: &str = "add-agent"; - pub const RELOAD: &str = "reload"; - pub const START_FULLSCREEN: &str = "start-fullscreen"; - pub const DEVTOOLS: &str = "devtools"; - pub const HELP_DOCS: &str = "help-docs"; - pub const HELP_GITHUB: &str = "help-github"; -} - enum UserEvent { Menu(MenuEvent), Ipc { @@ -237,16 +231,6 @@ struct AppWindow { content_process_gone_at: Option, } -struct AppMenu { - bar: Menu, - window_menu: Submenu, - help_menu: Submenu, - start_fullscreen: CheckMenuItem, - /// One item per state and style. Empty on a Mac without a lit logo, which has no - /// such menu. - logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>, -} - enum Load<'a> { ConnectPage { manual: bool }, Server { url: &'a str, auth: Option<&'a str> }, @@ -408,135 +392,6 @@ fn fatal(message: &str) -> ! { std::process::exit(1); } -fn build_menu(config: &Config) -> muda::Result { - let bar = Menu::new(); - let about = PredefinedMenuItem::about( - Some("About Codeman"), - Some(AboutMetadata { - name: Some(APP_NAME.into()), - version: Some(VERSION.into()), - comments: Some("A native window for your Codeman server.".into()), - website: Some("https://github.com/Ark0N/Codeman".into()), - ..Default::default() - }), - ); - let switch = MenuItem::with_id( - menu_id::SWITCH, - "Switch Server\u{2026}", - true, - Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyO)), - ); - let app_menu = Submenu::with_items( - APP_NAME, - true, - &[ - &about, - &PredefinedMenuItem::separator(), - &switch, - &MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::services(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::hide(None), - &PredefinedMenuItem::hide_others(None), - &PredefinedMenuItem::show_all(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), - ], - )?; - // Without these items Cmd+C / Cmd+V do nothing inside a WKWebView. - let edit = Submenu::with_items( - "Edit", - true, - &[ - &PredefinedMenuItem::undo(None), - &PredefinedMenuItem::redo(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::cut(None), - &PredefinedMenuItem::copy(None), - &PredefinedMenuItem::paste(None), - &PredefinedMenuItem::select_all(None), - ], - )?; - let start_fullscreen = CheckMenuItem::with_id( - menu_id::START_FULLSCREEN, - "Always Open in Full Screen", - true, - config.start_fullscreen, - None, - ); - let view = Submenu::with_items( - "View", - true, - &[ - &MenuItem::with_id( - menu_id::RELOAD, - "Reload", - true, - Some(Accelerator::new(Modifiers::META, Code::KeyR)), - ), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::fullscreen(None), - &start_fullscreen, - &PredefinedMenuItem::separator(), - &MenuItem::with_id( - menu_id::DEVTOOLS, - "Developer Tools", - true, - Some(Accelerator::new(Modifiers::META | Modifiers::ALT, Code::KeyI)), - ), - ], - )?; - // Only a Mac with a lit Apple logo has this menu at all. - let mut logo_styles = Vec::new(); - if logo::available() { - let submenu = Submenu::new("Blink Apple Logo", true); - for signal in Signal::ALL { - let group = Submenu::new(signal.label(), true); - for style in BlinkStyle::ALL { - let chosen = style == config.logo_blink.get(signal); - let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None); - group.append(&item)?; - if style == BlinkStyle::Off { - group.append(&PredefinedMenuItem::separator())?; - } - logo_styles.push((signal, style, item)); - } - submenu.append(&group)?; - } - // Right under "Always Open in Full Screen", before the separator. - view.insert(&submenu, 4)?; - } - let window_menu = Submenu::with_items( - "Window", - true, - &[ - &PredefinedMenuItem::minimize(None), - &PredefinedMenuItem::maximize(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::close_window(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::bring_all_to_front(None), - ], - )?; - let help_menu = Submenu::with_items( - "Help", - true, - &[ - &MenuItem::with_id(menu_id::HELP_DOCS, "Codeman Documentation", true, None), - &MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None), - ], - )?; - bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?; - Ok(AppMenu { - bar, - window_menu, - help_menu, - start_fullscreen, - logo_styles, - }) -} - /// A display's whole area in points (the menu bar and the Dock are inside it). fn display_rect(monitor: &tao::monitor::MonitorHandle) -> Rect { let sf = monitor.scale_factor(); @@ -648,16 +503,6 @@ fn clear_cookies_then_load(proxy: &EventLoopProxy, generation: u64, u done(); } -/// The phase a commit of `committed` in the main window moves to: Loaded once -/// a page from the server that is loading is the document. -fn loaded_on_commit(phase: &Phase, committed: &str) -> Option { - let Phase::Loading(server) = phase else { - return None; - }; - let url = Url::parse(committed).ok()?; - policy::same_origin(&url, server).then(|| Phase::Loaded(server.clone())) -} - /// `focused`: the web view takes the keyboard and brings the app to the front /// (wry's default). False for a window a script opened: it comes up without /// pulling the user out of the app they are typing in. @@ -746,7 +591,7 @@ fn build_webview( // event arrives, in case the borrow was not free. if is_main { if let Ok(mut phase) = load_phase.try_borrow_mut() { - if let Some(next) = loaded_on_commit(&phase, &url) { + if let Some(next) = policy::loaded_on_commit(&phase, &url) { *phase = next; } } @@ -970,7 +815,7 @@ impl App { } UserEvent::PageCommitted { window, url } => { if window == self.main_id { - let next = loaded_on_commit(&self.phase.borrow(), &url); + let next = policy::loaded_on_commit(&self.phase.borrow(), &url); if let Some(next) = next { *self.phase.borrow_mut() = next; } @@ -1320,8 +1165,9 @@ impl App { if choosing { self.in_background(move || { let port = setup::pick_install_port(); - let script = setup::local_install_script(setup::LocalInstall { port, network }); - match setup::open_in_terminal(setup::LOCAL_SCRIPT_NAME, &script) { + let script = + terminal_install::local_install_script(terminal_install::LocalInstall { port, network }); + match terminal_install::open_in_terminal(terminal_install::LOCAL_SCRIPT_NAME, &script) { Ok(()) => json!({ "type": "installStarted", "port": port }), Err(message) => json!({ "type": "installFailed", "message": message }), } @@ -1400,15 +1246,15 @@ impl App { } ConnectCmd::InstallRemote { target } => { if choosing { - match setup::SshTarget::parse(&target) { + match terminal_install::SshTarget::parse(&target) { Err(message) => self.to_connect_page(json!({ "type": "installFailed", "remote": true, "message": message, })), Ok(target) => self.in_background(move || { - let script = setup::remote_install_script(&target); - match setup::open_in_terminal(setup::REMOTE_SCRIPT_NAME, &script) { + let script = terminal_install::remote_install_script(&target); + match terminal_install::open_in_terminal(terminal_install::REMOTE_SCRIPT_NAME, &script) { Ok(()) => json!({ "type": "remoteInstallStarted", "host": target.host, @@ -1421,7 +1267,7 @@ impl App { } } ConnectCmd::CopyInstallCommand => { - self.in_background(|| match setup::copy_to_clipboard(setup::INSTALL_ONE_LINER) { + self.in_background(|| match terminal_install::copy_to_clipboard(setup::INSTALL_ONE_LINER) { Ok(()) => json!({ "type": "copied" }), Err(message) => json!({ "type": "installFailed", "remote": true, "message": message }), }); @@ -2033,280 +1879,3 @@ impl App { } } } - -/// A login the probe just tried, and what the user asked for it. -/// The login a connect goes out with, and where its password came from. A -/// typed password wins, then the one the Keychain holds for this user. Last -/// comes the login of the Codeman on this Mac, which keeps it where its own -/// clients find it (setup::local_login: its launchd job, then -/// ~/.codeman/.env) and only answers it at its own loopback address. That one -/// is used only when the config expected no password in the Keychain -/// (`expect_saved`: one that should be there and is not is reported, not -/// papered over), only for the username in use when there is one, and it is -/// never saved anywhere. `local` is asked only then: it runs processes. -fn choose_login( - user: Option, - typed_password: Option, - saved: Option, - expect_saved: bool, - local: impl FnOnce() -> Option<(String, String)>, -) -> (Option, Option, PasswordSource) { - if let Some(password) = typed_password { - return (user, Some(password), PasswordSource::Typed); - } - if let Some(password) = saved { - return (user, Some(password), PasswordSource::Keychain); - } - if !expect_saved { - if let Some((local_user, password)) = local() { - if user.as_deref().is_none_or(|current| current == local_user) { - return (Some(local_user), Some(password), PasswordSource::LocalServer); - } - } - } - (user, None, PasswordSource::Absent) -} - -struct ProbedLogin<'a> { - /// The server, as the config and the Keychain accounts key it. - server: &'a str, - /// The username the config named for this server before the connect. - previous_user: Option<&'a str>, - username: Option<&'a str>, - password: Option<&'a str>, - /// Where the password came from. - source: PasswordSource, - remember: bool, -} - -/// Where a connect's password came from, which decides what the Keychain -/// does with it (update_keychain). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PasswordSource { - /// Typed on the connect page this time: saved if Remember is on. - Typed, - /// From the Keychain: there already. - Keychain, - /// The local Codeman's own login (setup::local_login): it stays where it - /// is, and the Keychain never gets a copy. - LocalServer, - /// No password. - Absent, -} - -/// What update_keychain did: whether the Keychain now holds the login's -/// password, and whose items it deleted. -#[derive(Default)] -struct KeychainUpdate { - saved: bool, - deleted: Vec, -} - -/// Bring the Keychain in line with a probe the server answered, on the probe -/// worker. Only an accepted login changes anything (Ok, or a password the -/// server wants changed), since only then does the config get the new entry. -/// -/// When that entry names another user than the config did (compared exactly, -/// as the account string is), the old user's item is deleted FIRST, before any -/// save: the Keychain worker runs jobs in order, so a Keychain that treats two -/// spellings of a name as one item cannot lose the new password to that -/// delete. Then the typed password is saved when Remember is on and deleted -/// when it is off, and a password the server wants changed is never kept. -fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLogin<'_>) -> KeychainUpdate { - let mut update = KeychainUpdate::default(); - if !matches!(outcome, ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) { - return update; - } - let mut delete = |user: &str| { - keychain.delete(&credentials::account(login.server, user)); - update.deleted.push(user.to_string()); - }; - if let Some(old) = login.previous_user.filter(|old| login.username != Some(*old)) { - delete(old); - } - let saved = match (outcome, login.username, login.password) { - (ProbeOutcome::Ok { .. }, Some(user), Some(password)) if login.source == PasswordSource::Typed => { - if login.remember { - keychain - .save_blocking(&credentials::account(login.server, user), password) - .is_ok() - } else { - delete(user); - false - } - } - // A password that came from the Keychain is saved there already. - (ProbeOutcome::Ok { .. }, Some(_), Some(_)) => login.source == PasswordSource::Keychain, - (ProbeOutcome::PasswordChangeRequired, Some(user), _) => { - delete(user); - false - } - _ => false, - }; - update.saved = saved; - update -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::credentials::memory; - - fn none() -> Option<(String, String)> { - panic!("the local login must not be looked up here"); - } - - #[test] - fn the_login_a_connect_uses() { - let s = |v: &str| Some(v.to_string()); - let local = || Some(("admin".to_string(), "from-this-mac".to_string())); - - // A typed password wins over everything, and nothing else is asked. - assert_eq!( - choose_login(s("kay"), s("typed"), s("kept"), true, none), - (s("kay"), s("typed"), PasswordSource::Typed) - ); - // Then the Keychain's. - assert_eq!( - choose_login(s("kay"), None, s("kept"), true, none), - (s("kay"), s("kept"), PasswordSource::Keychain) - ); - // The Keychain should hold one and does not: reported as absent, the - // local login is not tried in its place. - assert_eq!( - choose_login(s("kay"), None, None, true, none), - (s("kay"), None, PasswordSource::Absent) - ); - // Nothing typed, nothing expected: the Codeman on this Mac's own - // login, for no username or the same one. - assert_eq!( - choose_login(None, None, None, false, local), - (s("admin"), s("from-this-mac"), PasswordSource::LocalServer) - ); - assert_eq!( - choose_login(s("admin"), None, None, false, local), - (s("admin"), s("from-this-mac"), PasswordSource::LocalServer) - ); - // Never for another user: the names are compared exactly. - assert_eq!( - choose_login(s("kay"), None, None, false, local), - (s("kay"), None, PasswordSource::Absent) - ); - assert_eq!( - choose_login(s("Admin"), None, None, false, local), - (s("Admin"), None, PasswordSource::Absent) - ); - // No local login either. - assert_eq!( - choose_login(None, None, None, false, || None), - (None, None, PasswordSource::Absent) - ); - } - - /// update_keychain for one probe answer; the log is what the Keychain did, - /// in order (the last load only waits for the queue to drain). - fn keychain_after(outcome: ProbeOutcome, login: ProbedLogin<'_>) -> (KeychainUpdate, Vec) { - let (keychain, store) = memory::keychain(); - let update = update_keychain(&keychain, &outcome, &login); - keychain.load_blocking("done"); - let mut log = store.log(); - assert_eq!(log.pop().as_deref(), Some("load done")); - (update, log) - } - - fn login<'a>( - previous_user: Option<&'a str>, - username: Option<&'a str>, - source: PasswordSource, - remember: bool, - ) -> ProbedLogin<'a> { - ProbedLogin { - server: "http://a/", - previous_user, - username, - password: (source != PasswordSource::Absent).then_some("secret"), - source, - remember, - } - } - - #[test] - fn what_the_keychain_keeps_after_a_connect() { - let ok = || ProbeOutcome::Ok { - version: "1.48.0".into(), - }; - let item = |user: &str| credentials::account("http://a/", user); - - // A typed password is saved when Remember is on, deleted when it is off. - let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, true)); - assert!(update.saved && update.deleted.is_empty()); - assert_eq!(log, [format!("save {}", item("kay"))]); - let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, false)); - assert!(!update.saved); - assert_eq!(update.deleted, ["kay"]); - assert_eq!(log, [format!("delete {}", item("kay"))]); - - // One that came from the Keychain is there already: nothing is written. - let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Keychain, true)); - assert!(update.saved && log.is_empty()); - - // The local Codeman's own login never gets a copy, Remember or not. - let (update, log) = keychain_after(ok(), login(None, Some("admin"), PasswordSource::LocalServer, true)); - assert!(!update.saved && update.deleted.is_empty() && log.is_empty()); - - // Another user than before: the old user's item goes FIRST, then the save. - let (update, log) = keychain_after(ok(), login(Some("old"), Some("new"), PasswordSource::Typed, true)); - assert!(update.saved); - assert_eq!(update.deleted, ["old"]); - assert_eq!( - log, - [format!("delete {}", item("old")), format!("save {}", item("new"))] - ); - - // A password the server wants changed is never kept. - let (update, log) = keychain_after( - ProbeOutcome::PasswordChangeRequired, - login(Some("kay"), Some("kay"), PasswordSource::Typed, true), - ); - assert!(!update.saved); - assert_eq!(update.deleted, ["kay"]); - assert_eq!(log, [format!("delete {}", item("kay"))]); - - // A login the server did not accept changes nothing. - for refused in [ - ProbeOutcome::BadCredentials, - ProbeOutcome::NeedsLogin, - ProbeOutcome::Refused, - ProbeOutcome::Timeout, - ] { - let (update, log) = keychain_after(refused, login(Some("old"), Some("new"), PasswordSource::Typed, true)); - assert!(!update.saved && update.deleted.is_empty() && log.is_empty()); - } - } - - #[test] - fn a_commit_from_the_loading_server_is_the_loaded_page() { - let server = Url::parse("https://box.ts.net/codeman/").unwrap(); - let loading = Phase::Loading(server.clone()); - assert_eq!( - loaded_on_commit(&loading, "https://box.ts.net/codeman/"), - Some(Phase::Loaded(server.clone())) - ); - // Any page of that origin counts: the server may redirect within it. - assert_eq!( - loaded_on_commit(&loading, "https://box.ts.net/codeman/session/abc"), - Some(Phase::Loaded(server.clone())) - ); - // Another origin, the connect page, or junk: still loading. - assert_eq!(loaded_on_commit(&loading, "https://other.ts.net/codeman/"), None); - assert_eq!(loaded_on_commit(&loading, "http://box.ts.net/codeman/"), None); - assert_eq!(loaded_on_commit(&loading, policy::CONNECT_PAGE), None); - assert_eq!(loaded_on_commit(&loading, "not a url"), None); - // Only a load under way can finish. - assert_eq!(loaded_on_commit(&Phase::Connect, "https://box.ts.net/codeman/"), None); - assert_eq!( - loaded_on_commit(&Phase::Loaded(server), "https://box.ts.net/codeman/"), - None - ); - } -} diff --git a/packages/desktop/src/menu.rs b/packages/desktop/src/menu.rs new file mode 100644 index 00000000..3385a333 --- /dev/null +++ b/packages/desktop/src/menu.rs @@ -0,0 +1,157 @@ +//! The menu bar. + +use muda::accelerator::{Accelerator, Code, Modifiers}; +use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; + +use crate::config::Config; +use crate::logo::{self, BlinkStyle, Signal}; +use crate::{APP_NAME, VERSION}; + +pub mod menu_id { + pub const SWITCH: &str = "switch-server"; + pub const ADD_AGENT: &str = "add-agent"; + pub const RELOAD: &str = "reload"; + pub const START_FULLSCREEN: &str = "start-fullscreen"; + pub const DEVTOOLS: &str = "devtools"; + pub const HELP_DOCS: &str = "help-docs"; + pub const HELP_GITHUB: &str = "help-github"; +} + +pub struct AppMenu { + pub bar: Menu, + pub window_menu: Submenu, + pub help_menu: Submenu, + pub start_fullscreen: CheckMenuItem, + /// One item per state and style. Empty on a Mac without a lit logo, which has no + /// such menu. + pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>, +} + +pub fn build_menu(config: &Config) -> muda::Result { + let bar = Menu::new(); + let about = PredefinedMenuItem::about( + Some("About Codeman"), + Some(AboutMetadata { + name: Some(APP_NAME.into()), + version: Some(VERSION.into()), + comments: Some("A native window for your Codeman server.".into()), + website: Some("https://github.com/Ark0N/Codeman".into()), + ..Default::default() + }), + ); + let switch = MenuItem::with_id( + menu_id::SWITCH, + "Switch Server\u{2026}", + true, + Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyO)), + ); + let app_menu = Submenu::with_items( + APP_NAME, + true, + &[ + &about, + &PredefinedMenuItem::separator(), + &switch, + &MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::services(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::hide(None), + &PredefinedMenuItem::hide_others(None), + &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::quit(None), + ], + )?; + // Without these items Cmd+C / Cmd+V do nothing inside a WKWebView. + let edit = Submenu::with_items( + "Edit", + true, + &[ + &PredefinedMenuItem::undo(None), + &PredefinedMenuItem::redo(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::cut(None), + &PredefinedMenuItem::copy(None), + &PredefinedMenuItem::paste(None), + &PredefinedMenuItem::select_all(None), + ], + )?; + let start_fullscreen = CheckMenuItem::with_id( + menu_id::START_FULLSCREEN, + "Always Open in Full Screen", + true, + config.start_fullscreen, + None, + ); + let view = Submenu::with_items( + "View", + true, + &[ + &MenuItem::with_id( + menu_id::RELOAD, + "Reload", + true, + Some(Accelerator::new(Modifiers::META, Code::KeyR)), + ), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::fullscreen(None), + &start_fullscreen, + &PredefinedMenuItem::separator(), + &MenuItem::with_id( + menu_id::DEVTOOLS, + "Developer Tools", + true, + Some(Accelerator::new(Modifiers::META | Modifiers::ALT, Code::KeyI)), + ), + ], + )?; + // Only a Mac with a lit Apple logo has this menu at all. + let mut logo_styles = Vec::new(); + if logo::available() { + let submenu = Submenu::new("Blink Apple Logo", true); + for signal in Signal::ALL { + let group = Submenu::new(signal.label(), true); + for style in BlinkStyle::ALL { + let chosen = style == config.logo_blink.get(signal); + let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None); + group.append(&item)?; + if style == BlinkStyle::Off { + group.append(&PredefinedMenuItem::separator())?; + } + logo_styles.push((signal, style, item)); + } + submenu.append(&group)?; + } + // Right under "Always Open in Full Screen", before the separator. + view.insert(&submenu, 4)?; + } + let window_menu = Submenu::with_items( + "Window", + true, + &[ + &PredefinedMenuItem::minimize(None), + &PredefinedMenuItem::maximize(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::close_window(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::bring_all_to_front(None), + ], + )?; + let help_menu = Submenu::with_items( + "Help", + true, + &[ + &MenuItem::with_id(menu_id::HELP_DOCS, "Codeman Documentation", true, None), + &MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None), + ], + )?; + bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?; + Ok(AppMenu { + bar, + window_menu, + help_menu, + start_fullscreen, + logo_styles, + }) +} diff --git a/packages/desktop/src/policy.rs b/packages/desktop/src/policy.rs index e83a3280..01cfc7e0 100644 --- a/packages/desktop/src/policy.rs +++ b/packages/desktop/src/policy.rs @@ -61,6 +61,16 @@ impl Phase { } } +/// The phase a commit of `committed` in the main window moves to: Loaded once +/// a page from the server that is loading is the document. +pub fn loaded_on_commit(phase: &Phase, committed: &str) -> Option { + let Phase::Loading(server) = phase else { + return None; + }; + let url = Url::parse(committed).ok()?; + same_origin(&url, server).then(|| Phase::Loaded(server.clone())) +} + /// Turn what the user typed into a server URL: scheme added when missing, /// query and fragment dropped, and the path ending in `/` so `api/status` /// joins below a base path. The scheme is `https` for a tailnet `.ts.net` @@ -631,7 +641,7 @@ pub enum ConnectCmd { /// Show the agent install that is running already. AgentAttach, /// Run the installer on another machine over SSH, in Terminal. `target` - /// is what the user typed; setup::SshTarget::parse checks it. + /// is what the user typed; terminal_install::SshTarget::parse checks it. InstallRemote { target: String, }, @@ -959,6 +969,32 @@ mod tests { assert!(!Phase::Loaded(srv).connect_page_live()); } + #[test] + fn a_commit_from_the_loading_server_is_the_loaded_page() { + let server = Url::parse("https://box.ts.net/codeman/").unwrap(); + let loading = Phase::Loading(server.clone()); + assert_eq!( + loaded_on_commit(&loading, "https://box.ts.net/codeman/"), + Some(Phase::Loaded(server.clone())) + ); + // Any page of that origin counts: the server may redirect within it. + assert_eq!( + loaded_on_commit(&loading, "https://box.ts.net/codeman/session/abc"), + Some(Phase::Loaded(server.clone())) + ); + // Another origin, the connect page, or junk: still loading. + assert_eq!(loaded_on_commit(&loading, "https://other.ts.net/codeman/"), None); + assert_eq!(loaded_on_commit(&loading, "http://box.ts.net/codeman/"), None); + assert_eq!(loaded_on_commit(&loading, CONNECT_PAGE), None); + assert_eq!(loaded_on_commit(&loading, "not a url"), None); + // Only a load under way can finish. + assert_eq!(loaded_on_commit(&Phase::Connect, "https://box.ts.net/codeman/"), None); + assert_eq!( + loaded_on_commit(&Phase::Loaded(server), "https://box.ts.net/codeman/"), + None + ); + } + #[test] fn navigation_policy() { let srv = u("http://127.0.0.1:3000/"); diff --git a/packages/desktop/src/setup.rs b/packages/desktop/src/setup.rs index 2b53750e..2d01b12f 100644 --- a/packages/desktop/src/setup.rs +++ b/packages/desktop/src/setup.rs @@ -1,25 +1,12 @@ -//! The connect screen's "This Mac" tab and its Terminal installs: where the -//! Codeman on this Mac listens, whether it is installed, its login, which -//! other Codeman servers of this user run here, and the installer runs in -//! Terminal for this Mac and for another machine over SSH. -//! The install most people get runs in the app, in `gui_install.rs`. -//! -//! The Terminal route is for what that one cannot do. `install.sh` asks for -//! the administrator password on a terminal (Homebrew, its sudo keep-alive), -//! the Xcode tools arrive as a system dialog, and ssh asks about host keys and -//! passwords on a terminal too. So the app writes a small script that deletes itself and -//! hands it to Terminal (`open -b com.apple.Terminal`), which needs no -//! permission, unlike scripting Terminal through osascript (an Automation -//! prompt). Everything the scripts contain is built here and unit-tested; the -//! only text from the user is the SSH login, which `SshTarget::parse` limits to -//! the characters of a user and host name before it is quoted. +//! The connect screen's "This Mac" tab: where the Codeman on this Mac listens, +//! whether it is installed, its login, and which other Codeman servers of this +//! user run here. The install most people get runs in the app +//! (`gui_install.rs`); the Terminal installs are in `terminal_install.rs`. use std::collections::HashMap; -use std::io::Write; use std::net::{Ipv4Addr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use serde::Serialize; @@ -29,6 +16,7 @@ use crate::gui_install; use crate::local_server; use crate::policy; use crate::probe::{self, ProbeOutcome}; +use crate::terminal_install::LOCAL_SCRIPT_NAME; /// Codeman's published installer (a redirect to `install.sh` on master). A test /// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is @@ -49,11 +37,6 @@ pub const INSTALL_ONE_LINER: &str = "curl -fsSL https://getcodeman.com/install | /// Codeman's default port, and the first one a fresh install tries. pub const DEFAULT_PORT: u16 = 3000; -/// The Terminal scripts' file names (`--.command`). The local -/// one is also how `inspect` recognizes an install that is still running. -pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local"; -pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote"; - /// How many ports above 3000 a fresh install tries when 3000 is taken. const PORT_SEARCH: u16 = 100; @@ -896,290 +879,6 @@ fn pick_port(free: impl Fn(u16) -> bool) -> u16 { .unwrap_or(DEFAULT_PORT) } -/// `s` as one bash word. -pub fn sh_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', r"'\''")) -} - -/// What the This Mac install asks `install.sh` for. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct LocalInstall { - pub port: u16, - /// Let the installer ask how other devices reach Codeman (Tailscale, the - /// local network with a password). Off: this Mac only (`--local`). - pub network: bool, -} - -/// `install.sh`'s flags for a This Mac install. Always `--service`: a -/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts -/// again at login, and the app finds it there (read_service). No `--yes`: the -/// user answers the installer's questions (Homebrew, AI coding tools). -pub fn local_install_args(opts: LocalInstall) -> Vec { - let mut args = Vec::new(); - if !opts.network { - args.push("--local".to_string()); - } - args.push("--service".to_string()); - args.push("--port".to_string()); - args.push(opts.port.to_string()); - args -} - -/// The Terminal script for a This Mac install. It downloads the installer to a -/// temporary file and runs it from there, so the installer's input is the -/// terminal itself. -pub fn local_install_script(opts: LocalInstall) -> String { - let args = local_install_args(opts); - let shown = format!("bash install.sh {}", args.join(" ")); - let quoted: Vec = args.iter().map(|a| sh_quote(a)).collect(); - format!( - r#"#!/bin/bash -rm -f -- "$0" -printf '\033]0;%s\007' 'Install Codeman' -cat <<'CODEMAN_MSG' -Installing Codeman on this Mac. - -The installer may ask for your Mac password (it installs Homebrew, Node.js and -tmux when they are missing) and which AI coding tools to set up. Codeman then -runs in the background and starts when you log in, so your sessions keep -running when the app is closed. The Codeman app opens it by itself when the -install is done. - -CODEMAN_MSG -tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1 -trap 'rm -f "$tmp"' EXIT -echo {from} -if ! curl -fsSL {url} -o "$tmp"; then - echo - echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.' - exit 1 -fi -echo {shown} -echo -bash "$tmp" {args} -status=$? -echo -if [ "$status" -eq 0 ]; then - echo 'Done. You can close this window.' -else - echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log" -fi -exit "$status" -"#, - from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")), - url = sh_quote(INSTALLER_URL), - shown = sh_quote(&format!("$ {shown}")), - args = quoted.join(" "), - ) -} - -/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a -/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SshTarget { - pub user: Option, - pub host: String, - pub port: Option, -} - -const SSH_FORMAT: &str = - "Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config."; - -impl SshTarget { - /// Only the characters of a user and host name, never a leading `-` (ssh - /// would read it as an option) and never an IPv6 literal (ambiguous with - /// the port; a host name or an IPv4 address works). - pub fn parse(input: &str) -> Result { - let s = input.trim(); - if s.is_empty() || s.len() > 300 { - return Err(SSH_FORMAT.into()); - } - let (user, rest) = match s.split_once('@') { - Some((u, r)) => (Some(u), r), - None => (None, s), - }; - let (host, port) = match rest.split_once(':') { - Some((h, p)) => { - let port = p.parse::().ok().filter(|p| *p > 0).ok_or_else(|| { - "The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string() - })?; - (h, Some(port)) - } - None => (rest, None), - }; - let name_ok = |v: &str, max: usize| { - !v.is_empty() - && v.len() <= max - && !v.starts_with(['-', '.']) - && v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) - }; - if let Some(u) = user { - if !name_ok(u, 64) { - return Err(SSH_FORMAT.into()); - } - } - if !name_ok(host, 253) { - return Err(SSH_FORMAT.into()); - } - Ok(SshTarget { - user: user.map(str::to_string), - host: host.to_string(), - port, - }) - } - - /// `user@host`, or `host`. - pub fn destination(&self) -> String { - match &self.user { - Some(u) => format!("{u}@{}", self.host), - None => self.host.clone(), - } - } - - /// The address the connect screen suggests once the install is done: the - /// installer's default port on that host. Tailscale and a sub-path give - /// another address, which the installer prints at the end. - pub fn suggested_url(&self) -> String { - format!("http://{}:{DEFAULT_PORT}", self.host) - } -} - -/// What the remote login shell runs. It may be fish or tcsh, so the work is -/// one single-quoted `bash -c` word with nothing in it those shells treat -/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the -/// documented install and the installer asks its questions on ssh's terminal -/// (`-t`); `pipefail` makes a failed download fail the whole command, where a -/// plain pipe would report bash's success on an empty script. The installer -/// needs bash anyway. -pub fn remote_command() -> String { - format!( - "bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \ - elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \ - else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'", - u = INSTALLER_URL - ) -} - -/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the -/// installer's questions, the port, and `--` before the destination. -pub fn ssh_args(target: &SshTarget) -> Vec { - let mut args = vec!["-t".to_string()]; - if let Some(port) = target.port { - args.push("-p".into()); - args.push(port.to_string()); - } - args.push("--".into()); - args.push(target.destination()); - args.push(remote_command()); - args -} - -/// The Terminal script for an install on another machine over SSH. -pub fn remote_install_script(target: &SshTarget) -> String { - let args: Vec = ssh_args(target).iter().map(|a| sh_quote(a)).collect(); - let dest = target.destination(); - format!( - r#"#!/bin/bash -rm -f -- "$0" -printf '\033]0;%s\007' {title} -echo {what} -cat <<'CODEMAN_MSG' - -ssh may ask you to confirm the machine and for its password. Then the installer -asks how Codeman should be reached (Tailscale, the local network with a password, -or that machine only) and whether to run it in the background. At the end it -prints the address of Codeman there: connect to it in the Codeman app, under -Another machine. - -CODEMAN_MSG -ssh {args} -status=$? -echo -if [ "$status" -eq 0 ]; then - echo 'Done. You can close this window.' -else - echo "ssh or the installer stopped (exit $status)." -fi -exit "$status" -"#, - title = sh_quote(&format!("Install Codeman on {}", target.host)), - what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")), - args = args.join(" "), - ) -} - -static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0); - -/// Run `script` in a new Terminal window. The script file is private to this -/// user (the per-user temporary folder, mode 0700, created fresh) and deletes -/// itself as it starts. -#[cfg(target_os = "macos")] -pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> { - use std::os::unix::fs::OpenOptionsExt; - - let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id())); - let written = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o700) - .open(&path) - .and_then(|mut f| f.write_all(script.as_bytes())); - if let Err(e) = written { - let _ = std::fs::remove_file(&path); - return Err(format!("Could not write the install script: {e}")); - } - let out = Command::new("/usr/bin/open") - .args(["-b", "com.apple.Terminal"]) - .arg(&path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .output(); - match out { - Ok(o) if o.status.success() => Ok(()), - Ok(o) => { - let _ = std::fs::remove_file(&path); - let said = String::from_utf8_lossy(&o.stderr).trim().to_string(); - Err(if said.is_empty() { - "Could not open Terminal.".into() - } else { - format!("Could not open Terminal: {said}") - }) - } - Err(e) => { - let _ = std::fs::remove_file(&path); - Err(format!("Could not open Terminal: {e}")) - } - } -} - -#[cfg(not(target_os = "macos"))] -pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> { - let _ = &SCRIPT_COUNTER; - Err("Installing from the app is only available on macOS so far.".into()) -} - -/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure -/// context, so it cannot use the web clipboard API itself. -pub fn copy_to_clipboard(text: &str) -> Result<(), String> { - let mut child = Command::new("/usr/bin/pbcopy") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("Could not copy: {e}"))?; - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(text.as_bytes()) - .map_err(|e| format!("Could not copy: {e}"))?; - } - match child.wait() { - Ok(s) if s.success() => Ok(()), - _ => Err("Could not copy the command.".into()), - } -} - #[cfg(test)] mod tests { use super::*; @@ -1217,8 +916,7 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn plutil_reads_both_launch_agent_shapes() { - let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}-{n}", std::process::id())); + let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let install_sh = r#" @@ -1850,7 +1548,7 @@ mod tests { /// the answers come back in the order of the addresses. #[test] fn found_servers_are_asked_together() { - use std::io::Read; + use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::mpsc; @@ -1923,238 +1621,4 @@ mod tests { assert_eq!(pick_port(|p| p >= 3002), 3002); assert_eq!(pick_port(|_| false), 3000); } - - #[test] - fn quoting() { - assert_eq!(sh_quote("plain"), "'plain'"); - assert_eq!(sh_quote("it's"), r"'it'\''s'"); - assert_eq!(sh_quote(""), "''"); - } - - #[test] - fn local_install_flags() { - assert_eq!( - local_install_args(LocalInstall { - port: 3000, - network: false - }), - ["--local", "--service", "--port", "3000"] - ); - assert_eq!( - local_install_args(LocalInstall { - port: 3007, - network: true - }), - ["--service", "--port", "3007"] - ); - } - - /// Run a script under bash with `curl` and `bash`... replaced, and report - /// what it did. The real installer never runs in a test. - #[cfg(unix)] - fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) { - use std::os::unix::fs::PermissionsExt; - let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("bin")).unwrap(); - for (name, body) in fakes { - let p = dir.join("bin").join(name); - std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap(); - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - let file = dir.join("run.command"); - std::fs::write(&file, script).unwrap(); - let out = Command::new("/bin/bash") - .arg(&file) - .env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display())) - .env("TMPDIR", &dir) - .stdin(Stdio::null()) - .output() - .unwrap(); - let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default(); - let self_deleted = !file.exists(); - let _ = std::fs::remove_dir_all(&dir); - (log, out.status.code().unwrap_or(-1), self_deleted) - } - - #[cfg(unix)] - #[test] - fn local_script_downloads_then_runs_the_installer_with_its_flags() { - let script = local_install_script(LocalInstall { - port: 3001, - network: false, - }); - let (log, code, self_deleted) = run_script( - &script, - &[( - "curl", - r#"#!/bin/sh -echo "curl $*" >> DIR/log -out="" -while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done -cat > "$out" <<'INSTALLER' -for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done -echo >> DIR/log -exit 7 -INSTALLER -"#, - )], - ); - assert!(self_deleted, "the script must delete itself"); - assert!( - log.starts_with("curl -fsSL https://getcodeman.com/install -o "), - "{log}" - ); - assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}"); - assert_eq!(code, 7, "the installer's exit status is the script's"); - } - - #[cfg(unix)] - #[test] - fn local_script_stops_when_the_download_fails() { - let script = local_install_script(LocalInstall { - port: 3000, - network: true, - }); - let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]); - assert_eq!(log, "curl\n"); - assert_eq!(code, 1); - } - - #[test] - fn ssh_targets() { - assert_eq!( - SshTarget::parse(" kay@build-box ").unwrap(), - SshTarget { - user: Some("kay".into()), - host: "build-box".into(), - port: None - } - ); - assert_eq!( - SshTarget::parse("pi@192.168.1.20:2222").unwrap(), - SshTarget { - user: Some("pi".into()), - host: "192.168.1.20".into(), - port: Some(2222) - } - ); - assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini"); - assert_eq!( - SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(), - "a.b@tnode.tailnet.ts.net" - ); - for bad in [ - "", - "-oProxyCommand=touch /tmp/x", - "kay@-oProxyCommand=x", - "-l@host", - "kay@host name", - "kay@host;rm", - "kay@host$(x)", - "k'y@host", - "kay@", - "@host", - "kay@host:", - "kay@host:0", - "kay@host:x", - "kay@fe80::1", - "kay@[::1]", - "a@b@c", - ".hidden", - ] { - assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}"); - } - assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000"); - } - - #[test] - fn ssh_arguments() { - let t = SshTarget::parse("kay@box:2222").unwrap(); - let args = ssh_args(&t); - assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]); - assert_eq!(args.len(), 6); - let remote = &args[5]; - let head = "bash -o pipefail -c '"; - assert!(remote.starts_with(head) && remote.ends_with('\'')); - // One word for fish and tcsh: no quote inside, nothing they expand. - let inner = &remote[head.len()..remote.len() - 1]; - assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}"); - assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash")); - assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]); - } - - #[cfg(unix)] - #[test] - fn remote_script_runs_ssh_with_exactly_those_arguments() { - let t = SshTarget::parse("kay@box:2222").unwrap(); - let (log, code, self_deleted) = run_script( - &remote_install_script(&t), - &[( - "ssh", - "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n", - )], - ); - assert!(self_deleted); - let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect(); - assert_eq!(log, format!("{expected}\n")); - assert_eq!(code, 3); - } - - /// The remote command does what it says under a POSIX shell: curl first, - /// wget without curl, and a clear error with neither. - #[cfg(unix)] - #[test] - fn remote_command_picks_a_downloader() { - use std::os::unix::fs::PermissionsExt; - let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("bin")).unwrap(); - let exe = |name: &str, body: &str| { - let p = dir.join("bin").join(name); - std::fs::write(&p, body).unwrap(); - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); - }; - // The outer `bash -o pipefail` is the real one; the `bash` the - // download is piped into only reports what it was fed. - exe( - "bash", - "#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n", - ); - exe("wget", "#!/bin/sh\necho \"wget $*\"\n"); - let run = || { - let out = Command::new("/bin/sh") - .arg("-c") - .arg(remote_command()) - .env("PATH", dir.join("bin")) - .output() - .unwrap(); - ( - String::from_utf8_lossy(&out.stdout).into_owned(), - String::from_utf8_lossy(&out.stderr).into_owned(), - out.status.code(), - ) - }; - let (out, _, _) = run(); - assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n"); - exe("curl", "#!/bin/sh\necho \"curl $*\"\n"); - let (out, _, _) = run(); - assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n"); - // A failed download fails the command (pipefail), not "Done". - exe( - "curl", - "#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n", - ); - let (_, _, code) = run(); - assert_ne!(code, Some(0)); - std::fs::remove_file(dir.join("bin/curl")).unwrap(); - std::fs::remove_file(dir.join("bin/wget")).unwrap(); - let (out, err, code) = run(); - assert_eq!(out, ""); - assert!(err.contains("needs curl or wget"), "{err}"); - assert_eq!(code, Some(1)); - let _ = std::fs::remove_dir_all(&dir); - } } diff --git a/packages/desktop/src/terminal_install.rs b/packages/desktop/src/terminal_install.rs new file mode 100644 index 00000000..52ff79d6 --- /dev/null +++ b/packages/desktop/src/terminal_install.rs @@ -0,0 +1,547 @@ +//! The installs that run in Terminal: on this Mac, and on another machine over +//! SSH. +//! +//! The Terminal route is for what the in-app install (`gui_install.rs`) cannot +//! do. `install.sh` asks for the administrator password on a terminal +//! (Homebrew, its sudo keep-alive), the Xcode tools arrive as a system dialog, +//! and ssh asks about host keys and passwords on a terminal too. So the app +//! writes a small script that deletes itself and hands it to Terminal +//! (`open -b com.apple.Terminal`), which needs no permission, unlike scripting +//! Terminal through osascript (an Automation prompt). Everything the scripts +//! contain is built here and unit-tested; the only text from the user is the +//! SSH login, which `SshTarget::parse` limits to the characters of a user and +//! host name before it is quoted. + +use std::io::Write; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::setup::{DEFAULT_PORT, INSTALLER_URL}; + +/// The Terminal scripts' file names (`--.command`). The local +/// one is also how `inspect` recognizes an install that is still running. +pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local"; +pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote"; + +/// `s` as one bash word. +pub fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// What the This Mac install asks `install.sh` for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocalInstall { + pub port: u16, + /// Let the installer ask how other devices reach Codeman (Tailscale, the + /// local network with a password). Off: this Mac only (`--local`). + pub network: bool, +} + +/// `install.sh`'s flags for a This Mac install. Always `--service`: a +/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts +/// again at login, and the app finds it there (read_service). No `--yes`: the +/// user answers the installer's questions (Homebrew, AI coding tools). +pub fn local_install_args(opts: LocalInstall) -> Vec { + let mut args = Vec::new(); + if !opts.network { + args.push("--local".to_string()); + } + args.push("--service".to_string()); + args.push("--port".to_string()); + args.push(opts.port.to_string()); + args +} + +/// The Terminal script for a This Mac install. It downloads the installer to a +/// temporary file and runs it from there, so the installer's input is the +/// terminal itself. +pub fn local_install_script(opts: LocalInstall) -> String { + let args = local_install_args(opts); + let shown = format!("bash install.sh {}", args.join(" ")); + let quoted: Vec = args.iter().map(|a| sh_quote(a)).collect(); + format!( + r#"#!/bin/bash +rm -f -- "$0" +printf '\033]0;%s\007' 'Install Codeman' +cat <<'CODEMAN_MSG' +Installing Codeman on this Mac. + +The installer may ask for your Mac password (it installs Homebrew, Node.js and +tmux when they are missing) and which AI coding tools to set up. Codeman then +runs in the background and starts when you log in, so your sessions keep +running when the app is closed. The Codeman app opens it by itself when the +install is done. + +CODEMAN_MSG +tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1 +trap 'rm -f "$tmp"' EXIT +echo {from} +if ! curl -fsSL {url} -o "$tmp"; then + echo + echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.' + exit 1 +fi +echo {shown} +echo +bash "$tmp" {args} +status=$? +echo +if [ "$status" -eq 0 ]; then + echo 'Done. You can close this window.' +else + echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log" +fi +exit "$status" +"#, + from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")), + url = sh_quote(INSTALLER_URL), + shown = sh_quote(&format!("$ {shown}")), + args = quoted.join(" "), + ) +} + +/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a +/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SshTarget { + pub user: Option, + pub host: String, + pub port: Option, +} + +const SSH_FORMAT: &str = + "Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config."; + +impl SshTarget { + /// Only the characters of a user and host name, never a leading `-` (ssh + /// would read it as an option) and never an IPv6 literal (ambiguous with + /// the port; a host name or an IPv4 address works). + pub fn parse(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() || s.len() > 300 { + return Err(SSH_FORMAT.into()); + } + let (user, rest) = match s.split_once('@') { + Some((u, r)) => (Some(u), r), + None => (None, s), + }; + let (host, port) = match rest.split_once(':') { + Some((h, p)) => { + let port = p.parse::().ok().filter(|p| *p > 0).ok_or_else(|| { + "The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string() + })?; + (h, Some(port)) + } + None => (rest, None), + }; + let name_ok = |v: &str, max: usize| { + !v.is_empty() + && v.len() <= max + && !v.starts_with(['-', '.']) + && v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + }; + if let Some(u) = user { + if !name_ok(u, 64) { + return Err(SSH_FORMAT.into()); + } + } + if !name_ok(host, 253) { + return Err(SSH_FORMAT.into()); + } + Ok(SshTarget { + user: user.map(str::to_string), + host: host.to_string(), + port, + }) + } + + /// `user@host`, or `host`. + pub fn destination(&self) -> String { + match &self.user { + Some(u) => format!("{u}@{}", self.host), + None => self.host.clone(), + } + } + + /// The address the connect screen suggests once the install is done: the + /// installer's default port on that host. Tailscale and a sub-path give + /// another address, which the installer prints at the end. + pub fn suggested_url(&self) -> String { + format!("http://{}:{DEFAULT_PORT}", self.host) + } +} + +/// What the remote login shell runs. It may be fish or tcsh, so the work is +/// one single-quoted `bash -c` word with nothing in it those shells treat +/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the +/// documented install and the installer asks its questions on ssh's terminal +/// (`-t`); `pipefail` makes a failed download fail the whole command, where a +/// plain pipe would report bash's success on an empty script. The installer +/// needs bash anyway. +pub fn remote_command() -> String { + format!( + "bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \ + elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \ + else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'", + u = INSTALLER_URL + ) +} + +/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the +/// installer's questions, the port, and `--` before the destination. +pub fn ssh_args(target: &SshTarget) -> Vec { + let mut args = vec!["-t".to_string()]; + if let Some(port) = target.port { + args.push("-p".into()); + args.push(port.to_string()); + } + args.push("--".into()); + args.push(target.destination()); + args.push(remote_command()); + args +} + +/// The Terminal script for an install on another machine over SSH. +pub fn remote_install_script(target: &SshTarget) -> String { + let args: Vec = ssh_args(target).iter().map(|a| sh_quote(a)).collect(); + let dest = target.destination(); + format!( + r#"#!/bin/bash +rm -f -- "$0" +printf '\033]0;%s\007' {title} +echo {what} +cat <<'CODEMAN_MSG' + +ssh may ask you to confirm the machine and for its password. Then the installer +asks how Codeman should be reached (Tailscale, the local network with a password, +or that machine only) and whether to run it in the background. At the end it +prints the address of Codeman there: connect to it in the Codeman app, under +Another machine. + +CODEMAN_MSG +ssh {args} +status=$? +echo +if [ "$status" -eq 0 ]; then + echo 'Done. You can close this window.' +else + echo "ssh or the installer stopped (exit $status)." +fi +exit "$status" +"#, + title = sh_quote(&format!("Install Codeman on {}", target.host)), + what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")), + args = args.join(" "), + ) +} + +static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Run `script` in a new Terminal window. The script file is private to this +/// user (the per-user temporary folder, mode 0700, created fresh) and deletes +/// itself as it starts. +#[cfg(target_os = "macos")] +pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> { + use std::os::unix::fs::OpenOptionsExt; + + let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id())); + let written = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o700) + .open(&path) + .and_then(|mut f| f.write_all(script.as_bytes())); + if let Err(e) = written { + let _ = std::fs::remove_file(&path); + return Err(format!("Could not write the install script: {e}")); + } + let out = Command::new("/usr/bin/open") + .args(["-b", "com.apple.Terminal"]) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output(); + match out { + Ok(o) if o.status.success() => Ok(()), + Ok(o) => { + let _ = std::fs::remove_file(&path); + let said = String::from_utf8_lossy(&o.stderr).trim().to_string(); + Err(if said.is_empty() { + "Could not open Terminal.".into() + } else { + format!("Could not open Terminal: {said}") + }) + } + Err(e) => { + let _ = std::fs::remove_file(&path); + Err(format!("Could not open Terminal: {e}")) + } + } +} + +#[cfg(not(target_os = "macos"))] +pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> { + let _ = &SCRIPT_COUNTER; + Err("Installing from the app is only available on macOS so far.".into()) +} + +/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure +/// context, so it cannot use the web clipboard API itself. +pub fn copy_to_clipboard(text: &str) -> Result<(), String> { + let mut child = Command::new("/usr/bin/pbcopy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Could not copy: {e}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(text.as_bytes()) + .map_err(|e| format!("Could not copy: {e}"))?; + } + match child.wait() { + Ok(s) if s.success() => Ok(()), + _ => Err("Could not copy the command.".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quoting() { + assert_eq!(sh_quote("plain"), "'plain'"); + assert_eq!(sh_quote("it's"), r"'it'\''s'"); + assert_eq!(sh_quote(""), "''"); + } + + #[test] + fn local_install_flags() { + assert_eq!( + local_install_args(LocalInstall { + port: 3000, + network: false + }), + ["--local", "--service", "--port", "3000"] + ); + assert_eq!( + local_install_args(LocalInstall { + port: 3007, + network: true + }), + ["--service", "--port", "3007"] + ); + } + + /// Run a script under bash with `curl` and `bash`... replaced, and report + /// what it did. The real installer never runs in a test. + #[cfg(unix)] + fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) { + use std::os::unix::fs::PermissionsExt; + let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("bin")).unwrap(); + for (name, body) in fakes { + let p = dir.join("bin").join(name); + std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let file = dir.join("run.command"); + std::fs::write(&file, script).unwrap(); + let out = Command::new("/bin/bash") + .arg(&file) + .env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display())) + .env("TMPDIR", &dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default(); + let self_deleted = !file.exists(); + let _ = std::fs::remove_dir_all(&dir); + (log, out.status.code().unwrap_or(-1), self_deleted) + } + + #[cfg(unix)] + #[test] + fn local_script_downloads_then_runs_the_installer_with_its_flags() { + let script = local_install_script(LocalInstall { + port: 3001, + network: false, + }); + let (log, code, self_deleted) = run_script( + &script, + &[( + "curl", + r#"#!/bin/sh +echo "curl $*" >> DIR/log +out="" +while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done +cat > "$out" <<'INSTALLER' +for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done +echo >> DIR/log +exit 7 +INSTALLER +"#, + )], + ); + assert!(self_deleted, "the script must delete itself"); + assert!( + log.starts_with("curl -fsSL https://getcodeman.com/install -o "), + "{log}" + ); + assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}"); + assert_eq!(code, 7, "the installer's exit status is the script's"); + } + + #[cfg(unix)] + #[test] + fn local_script_stops_when_the_download_fails() { + let script = local_install_script(LocalInstall { + port: 3000, + network: true, + }); + let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]); + assert_eq!(log, "curl\n"); + assert_eq!(code, 1); + } + + #[test] + fn ssh_targets() { + assert_eq!( + SshTarget::parse(" kay@build-box ").unwrap(), + SshTarget { + user: Some("kay".into()), + host: "build-box".into(), + port: None + } + ); + assert_eq!( + SshTarget::parse("pi@192.168.1.20:2222").unwrap(), + SshTarget { + user: Some("pi".into()), + host: "192.168.1.20".into(), + port: Some(2222) + } + ); + assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini"); + assert_eq!( + SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(), + "a.b@tnode.tailnet.ts.net" + ); + for bad in [ + "", + "-oProxyCommand=touch /tmp/x", + "kay@-oProxyCommand=x", + "-l@host", + "kay@host name", + "kay@host;rm", + "kay@host$(x)", + "k'y@host", + "kay@", + "@host", + "kay@host:", + "kay@host:0", + "kay@host:x", + "kay@fe80::1", + "kay@[::1]", + "a@b@c", + ".hidden", + ] { + assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}"); + } + assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000"); + } + + #[test] + fn ssh_arguments() { + let t = SshTarget::parse("kay@box:2222").unwrap(); + let args = ssh_args(&t); + assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]); + assert_eq!(args.len(), 6); + let remote = &args[5]; + let head = "bash -o pipefail -c '"; + assert!(remote.starts_with(head) && remote.ends_with('\'')); + // One word for fish and tcsh: no quote inside, nothing they expand. + let inner = &remote[head.len()..remote.len() - 1]; + assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}"); + assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash")); + assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]); + } + + #[cfg(unix)] + #[test] + fn remote_script_runs_ssh_with_exactly_those_arguments() { + let t = SshTarget::parse("kay@box:2222").unwrap(); + let (log, code, self_deleted) = run_script( + &remote_install_script(&t), + &[( + "ssh", + "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n", + )], + ); + assert!(self_deleted); + let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect(); + assert_eq!(log, format!("{expected}\n")); + assert_eq!(code, 3); + } + + /// The remote command does what it says under a POSIX shell: curl first, + /// wget without curl, and a clear error with neither. + #[cfg(unix)] + #[test] + fn remote_command_picks_a_downloader() { + use std::os::unix::fs::PermissionsExt; + let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("bin")).unwrap(); + let exe = |name: &str, body: &str| { + let p = dir.join("bin").join(name); + std::fs::write(&p, body).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + // The outer `bash -o pipefail` is the real one; the `bash` the + // download is piped into only reports what it was fed. + exe( + "bash", + "#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n", + ); + exe("wget", "#!/bin/sh\necho \"wget $*\"\n"); + let run = || { + let out = Command::new("/bin/sh") + .arg("-c") + .arg(remote_command()) + .env("PATH", dir.join("bin")) + .output() + .unwrap(); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.code(), + ) + }; + let (out, _, _) = run(); + assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n"); + exe("curl", "#!/bin/sh\necho \"curl $*\"\n"); + let (out, _, _) = run(); + assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n"); + // A failed download fails the command (pipefail), not "Done". + exe( + "curl", + "#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n", + ); + let (_, _, code) = run(); + assert_ne!(code, Some(0)); + std::fs::remove_file(dir.join("bin/curl")).unwrap(); + std::fs::remove_file(dir.join("bin/wget")).unwrap(); + let (out, err, code) = run(); + assert_eq!(out, ""); + assert!(err.contains("needs curl or wget"), "{err}"); + assert_eq!(code, Some(1)); + let _ = std::fs::remove_dir_all(&dir); + } +}