Files
Codeman/packages/desktop/src/main.rs
T

2107 lines
88 KiB
Rust

//! Codeman Desktop: a native window around a Codeman server.
//!
//! Codeman is a web app, so the app is a thin shell: the OS web view (WKWebView
//! on macOS) shows the server's own page, and the native side adds what a
//! browser tab cannot: a connect screen that remembers servers (`config`,
//! `login`), runs or installs Codeman on this machine (`setup`,
//! `local_server`, `gui_install`) and installs it on another one over SSH
//! (`terminal_install`), real full screen, native menus (`menu`), pop-outs as
//! app windows (Codeman's `window.CodemanHost` bridge), downloads to
//! ~/Downloads, passwords in the Keychain (`credentials`), on macOS the
//! JavaScript dialogs and HTTP Basic challenges wry leaves out (`macos`), and
//! on a MacBook Pro with a lit Apple logo a blink that says what the agents
//! are doing (`logo`, `agents`).
//!
//! Every URL and message decision is in `policy` (pure, unit-tested); this file
//! only wires those decisions to windows. The main window moves through
//! `policy::Phase`: the connect page (Connect), a server load that has not
//! committed yet (Loading, abandoned after `LOAD_TIMEOUT` or on Cancel), and a
//! committed server page (Loaded). The phase is shared with the web view
//! handlers, which run on this same thread; no `RefCell` borrow of it is ever
//! held across a call into wry or tao.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod agents;
mod assets;
mod config;
mod credentials;
mod gui_install;
mod keep_awake;
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};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use muda::MenuEvent;
use serde_json::json;
use tao::dpi::{LogicalPosition, LogicalSize, PhysicalPosition};
use tao::error::NotSupportedError;
use tao::event::{Event, StartCause, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy, EventLoopWindowTarget};
use tao::window::{Fullscreen, Theme, UserAttentionType, Window, WindowBuilder, WindowId};
use url::Url;
use wry::http::header::AUTHORIZATION;
use wry::http::{HeaderMap, HeaderValue};
use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebViewBuilder};
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;
const APP_NAME: &str = "Codeman";
const VERSION: &str = env!("CARGO_PKG_VERSION");
const DEFAULT_SIZE: (f64, f64) = (1280.0, 820.0);
const POPOUT_SIZE: (f64, f64) = (960.0, 680.0);
const MIN_SIZE: (f64, f64) = (config::MIN_WIDTH, config::MIN_HEIGHT);
const MAX_POPOUTS: usize = 24;
/// How far a pop-out opens down and to the right of the window in use, per
/// pop-out already open (it starts over after eight).
const POPOUT_STEP: f64 = 28.0;
const SAVE_DEBOUNCE: Duration = Duration::from_secs(1);
/// How long a server load may take to commit before the app gives up on it
/// and brings the connect page back.
const LOAD_TIMEOUT: Duration = Duration::from_secs(15);
/// A window reloads itself with the login at most once in this long after its
/// page reported that its session is gone, so a login the server keeps
/// refusing can never turn into a reload loop.
const SESSION_RELOAD_COOLDOWN: Duration = Duration::from_secs(60);
/// A window loads its page again at most once in this long after its web
/// content process ended, so a page that cannot stay up never becomes a reload
/// loop.
const CRASH_RELOAD_COOLDOWN: Duration = Duration::from_secs(30);
/// When the app first looks whether the main window made it into the full
/// screen it asked for at launch, how often it asks again, and how many times.
/// tao retries a failed entry only for a window BUILT in full screen, which
/// this one is not (create_main_window), and entering fails while macOS is
/// still switching away from another app's full-screen Space.
const FULLSCREEN_FIRST_CHECK: Duration = Duration::from_millis(700);
const FULLSCREEN_RETRY_EVERY: Duration = Duration::from_millis(500);
const FULLSCREEN_ATTEMPTS: u8 = 6;
const SETTINGS_UNREADABLE: &str = "Settings could not be read, so changes will not be saved this time.";
const LOAD_TIMED_OUT: &str = "Codeman did not open. Check that the server is still running, then connect again.";
const LOAD_CANCELLED: &str = "Opening was cancelled.";
const AUTH_REJECTED: &str = "The server did not accept the saved login. Enter the password again.";
/// A recheck that got 429. The recheck always sends the login, and a
/// single-user Codeman accepts a matching login before its rate limit, so this
/// almost always means the login is wrong; a multi-user Codeman can also refuse
/// a correct one from a busy address, so the text does not say which.
const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The saved password may be wrong: \
enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes.";
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
const LOGO_DRIVEN_ELSEWHERE: &str =
"LogoLight is driving the Apple logo, so Codeman leaves it alone. Quit LogoLight to see your agents on it.";
/// What a download's toast calls a file whose name is not known.
const UNNAMED_FILE: &str = "the file";
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
enum UserEvent {
Menu(MenuEvent),
Ipc {
window: WindowId,
msg: Ipc,
},
ProbeDone(Box<ProbeDone>),
ServerStartDone {
generation: u64,
url: Url,
result: Result<String, String>,
},
/// A `window.open` or `target=_blank` asked for one of the file or
/// download pages Codeman opens that way (policy::new_window, Via::Script).
/// Any frame can ask like this, so the window opens without taking the
/// keyboard, only while the script window budget allows, and nothing is
/// reported back when it does not open.
OpenAppWindow(Url),
/// A page opened another site (window.open); it goes to the browser if the
/// open budget allows.
OpenExternal(Url),
/// The cookies were cleared before a server load (begin_loading).
CookiesCleared {
generation: u64,
url: Url,
},
/// What the server said to the login the challenge handler had sent when
/// it reported a refusal (recheck_login).
LoginChecked {
generation: u64,
outcome: ProbeOutcome,
},
Title {
window: WindowId,
title: String,
},
/// A main-frame navigation committed (wry's PageLoadEvent::Started, from
/// WebKit's didCommitNavigation): `url` is the window's document now.
PageCommitted {
window: WindowId,
url: String,
},
DownloadStarted {
window: WindowId,
url: String,
/// The file name wry chose in ~/Downloads (de-duplicated). wry passes
/// no path when the download ends, so this is the toast's only source.
file_name: Option<String>,
},
DownloadDone {
window: WindowId,
url: String,
path: Option<PathBuf>,
ok: bool,
},
/// The challenge handler sent its login and got challenged again (macOS).
/// Not trusted on its own: recheck_login asks the server first.
AuthRejected,
/// The web content process of a window ended (macOS): a crash, or the
/// system taking it back under memory pressure. The window is blank until
/// something loads in it again (on_content_process_gone).
ContentProcessGone {
window: WindowId,
},
/// A background step for the connect page finished (This Mac status, an
/// install opened in Terminal, the install command copied): the message
/// goes to the page if it is still the one choosing a server.
ToConnectPage(serde_json::Value),
/// The keep-awake worker's status line, for the value numbered `generation`
/// (keep_awake::KeepAwake::is_current).
KeepAwakeStatus {
generation: u64,
text: String,
},
/// The administrator prompt for the lid helper was answered, and its command
/// ran (keep_awake::install_helper, keep_awake::remove_helper).
LidHelperDone {
install: bool,
outcome: keep_awake::AdminOutcome,
},
}
struct ProbeDone {
generation: u64,
url: Url,
result: ProbeResult,
/// The username the probe sent, when it sent a password.
username: Option<String>,
/// The password the probe sent: typed, from the Keychain, or the local
/// Codeman's own login.
password: Option<String>,
/// Whether the Keychain holds `password` for `username` now, after the
/// worker's own save or delete (update_keychain).
saved: bool,
/// The usernames whose Keychain items for this server the worker deleted.
deleted: Vec<String>,
}
enum ProbeResult {
Probed(ProbeOutcome),
/// The config says a password is saved for this user, but the Keychain
/// gave none (access denied, or the item is gone). No probe was sent: an
/// unauthenticated one would only come back as "needs login".
SavedPasswordUnreadable,
}
enum Kind {
Main,
Popout {
/// The URL the pop-out was opened with, as handed to wry
/// (`policy::webkit_safe_url`), which is also the form WebKit reports
/// for a download.
opened_url: String,
hidden_for_download: bool,
},
}
struct AppWindow {
// Declared first so it is dropped before its window.
webview: WebView,
window: Window,
kind: Kind,
/// The window's last committed URL. Never read from wry's `url()`: that
/// unwraps WKWebView.URL, which is nil until a load commits, and the app
/// would abort.
committed: Option<Url>,
/// When the window last reloaded itself because its session expired.
session_reload_at: Option<Instant>,
/// When the window's web content process last ended.
content_process_gone_at: Option<Instant>,
}
impl AppWindow {
fn new(webview: WebView, window: Window, kind: Kind) -> Self {
AppWindow {
webview,
window,
kind,
committed: None,
session_reload_at: None,
content_process_gone_at: None,
}
}
/// A pop-out hidden because it only fetched a file (DownloadStarted).
fn hidden_for_download(&self) -> bool {
matches!(
self.kind,
Kind::Popout {
hidden_for_download: true,
..
}
)
}
}
enum Load<'a> {
ConnectPage,
Server { url: &'a str, auth: Option<&'a str> },
}
struct App {
proxy: EventLoopProxy<UserEvent>,
config: Config,
/// None: no config directory, or the file could not be read or moved
/// aside, in which case it must not be overwritten.
config_path: Option<PathBuf>,
config_dirty: bool,
/// The settings are written SAVE_DEBOUNCE after this: the last write, or
/// the last move or resize of the main window, so a drag writes the file
/// once when it ends, not once a second while it goes on.
save_held_since: Instant,
/// Where the main window is in reaching a server. Shared with the web view
/// handlers, which run on this same thread.
phase: Rc<RefCell<Phase>>,
/// While Loading: when to give up on the load.
loading_deadline: Option<Instant>,
/// Basic-auth header for (re)loading server pages; the server answers a
/// load with its session cookie, which carries the requests after it.
auth: Option<String>,
/// The username and password behind `auth`, which the challenge handler
/// answers with (set_login).
login: Option<(String, String)>,
/// Counts the logins handed to the challenge handler, so a recheck that
/// comes back after Switch Server or another connect does nothing.
login_generation: u64,
/// The login generation a recheck is running for, if any.
login_recheck: Option<u64>,
windows: HashMap<WindowId, AppWindow>,
main_id: WindowId,
probe_generation: u64,
start_generation: u64,
/// The generation of the Start Codeman run still going, if any.
starting_server: Option<u64>,
/// Shown by the next connect page that asks for its init.
notice: Option<String>,
/// What hands pages to the default browser draws on.
open_budget: OpenBudget,
/// What the app windows a script opens draw on (UserEvent::OpenAppWindow),
/// apart from `open_budget`, so a frame cannot use up the user's links.
script_window_budget: OpenBudget,
/// File names of downloads under way, per window and URL, oldest first.
downloads: HashMap<(WindowId, String), VecDeque<String>>,
menu: AppMenu,
/// The one Keychain worker; every Keychain job goes through its queue.
keychain: Keychain,
/// Which reader of the install's output is the current one.
install_reader: Arc<AtomicU64>,
/// The connect page opens the agent chooser when it next loads: the menu
/// asked for it from a Codeman page.
add_agent_on_load: bool,
/// While the main window may still have to be put into the full screen it
/// asked for at launch: when to look next, and how many retries were made.
fullscreen_retry: Option<(Instant, u8)>,
/// The child that watches the connected server and blinks the logo.
logo_watch: logo::Watcher,
/// The worker that keeps this Mac awake while its agents work.
keep_awake: keep_awake::KeepAwake,
/// An administrator prompt for the lid helper is open.
lid_helper_busy: bool,
}
fn main() {
// The blinker is this binary without a window (logo.rs).
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
logo::blink_main();
}
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
{
let menu_proxy = Mutex::new(proxy.clone());
MenuEvent::set_event_handler(Some(move |event| {
if let Ok(p) = menu_proxy.lock() {
let _ = p.send_event(UserEvent::Menu(event));
}
}));
}
#[cfg(target_os = "macos")]
{
// Runs inside a WebKit callback: only post an event.
let auth_proxy = proxy.clone();
macos::set_auth_rejected_handler(Box::new(move || {
let _ = auth_proxy.send_event(UserEvent::AuthRejected);
}));
}
let config_path = Config::default_path();
let (config, can_save) = match config_path.as_deref() {
Some(path) => Config::load_from(path),
None => (Config::default(), true),
};
let config_path = config_path.filter(|_| can_save);
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
logo::recover_at_launch();
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
let phase = Rc::new(RefCell::new(Phase::Connect));
// Started once: the probe workers and the event loop share this queue,
// and a second one would not keep its jobs in order with the first.
let keychain = Keychain::spawn();
let (window, wants_fullscreen) =
create_main_window(&event_loop, &config).unwrap_or_else(|e| fatal(&format!("Could not open a window: {e}")));
let webview = build_webview(&window, true, true, Load::ConnectPage, &proxy, &phase)
.unwrap_or_else(|e| fatal(&format!("Could not create the web view: {e}")));
let main_id = window.id();
let mut windows = HashMap::new();
windows.insert(main_id, AppWindow::new(webview, window, Kind::Main));
let keep_awake_proxy = proxy.clone();
let mut app = App {
proxy,
config,
config_path,
config_dirty: false,
save_held_since: Instant::now(),
phase,
loading_deadline: None,
auth: None,
login: None,
login_generation: 0,
login_recheck: None,
windows,
main_id,
probe_generation: 0,
start_generation: 0,
starting_server: None,
notice,
open_budget: OpenBudget::new(),
script_window_budget: OpenBudget::new(),
downloads: HashMap::new(),
menu,
keychain,
install_reader: Arc::new(AtomicU64::new(0)),
add_agent_on_load: false,
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
logo_watch: logo::Watcher::default(),
keep_awake: keep_awake::KeepAwake::new(move |generation, text| {
let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text });
}),
lid_helper_busy: false,
};
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
}
fn fatal(message: &str) -> ! {
eprintln!("codeman-desktop: {message}");
std::process::exit(1);
}
/// 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();
let p = monitor.position().to_logical::<f64>(sf);
let s = monitor.size().to_logical::<f64>(sf);
Rect {
x: p.x,
y: p.y,
width: s.width,
height: s.height,
}
}
fn monitors(target: &EventLoopWindowTarget<UserEvent>) -> Vec<Rect> {
target.available_monitors().map(|m| display_rect(&m)).collect()
}
/// What every app window starts from.
fn window_builder() -> WindowBuilder {
WindowBuilder::new()
.with_title(APP_NAME)
.with_theme(Some(Theme::Dark))
.with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1))
}
/// The corner of `window` that tao's `with_position` places: on macOS the
/// CONTENT area's top-left, elsewhere the outer one. So it is the corner that
/// is saved, and the one a pop-out steps off from.
fn placed_position(window: &Window) -> Result<PhysicalPosition<i32>, NotSupportedError> {
if cfg!(target_os = "macos") {
window.inner_position()
} else {
window.outer_position()
}
}
/// The main window, and whether it was asked into full screen (App's
/// `fullscreen_retry` then makes sure it gets there).
fn create_main_window(
target: &EventLoopWindowTarget<UserEvent>,
config: &Config,
) -> Result<(Window, bool), tao::error::OsError> {
let mut builder = window_builder();
let screens = monitors(target);
match config.window.and_then(|f| f.fitted_to(&screens, MIN_SIZE)) {
Some(f) => {
builder = builder
.with_inner_size(LogicalSize::new(f.width, f.height))
.with_position(LogicalPosition::new(f.x, f.y));
}
None => {
builder = builder.with_inner_size(LogicalSize::new(DEFAULT_SIZE.0, DEFAULT_SIZE.1));
if let Some(m) = target.primary_monitor() {
let f = WindowFrame::first_run(&display_rect(&m), DEFAULT_SIZE, MIN_SIZE);
builder = builder
.with_inner_size(LogicalSize::new(f.width, f.height))
.with_position(LogicalPosition::new(f.x, f.y));
}
}
}
let window = builder.build(target)?;
// Full screen is entered after the window exists at its windowed frame,
// not set on the builder: a window built straight into full screen gets
// the whole screen as its frame, and that is the frame AppKit puts back
// when the user leaves full screen.
let fullscreen = config.start_fullscreen || config.window.is_some_and(|f| f.fullscreen);
if fullscreen {
window.set_fullscreen(Some(Fullscreen::Borderless(None)));
}
Ok((window, fullscreen))
}
/// The Authorization header for a login, when there is a password.
fn basic_auth(username: Option<&str>, password: Option<&str>) -> Option<String> {
username.zip(password).map(|(u, p)| policy::basic_auth_header(u, p))
}
/// A download's file name, for its toast.
fn file_name_of(path: &Path) -> Option<String> {
path.file_name().map(|n| n.to_string_lossy().into_owned())
}
fn auth_headers(auth: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(auth) {
headers.insert(AUTHORIZATION, v);
}
headers
}
/// The connect page's answer to a prepare: the installer's plan, or why not.
fn plan_message(prepared: Result<gui_install::Preflight, String>) -> serde_json::Value {
match prepared {
Ok(plan) => json!({ "type": "installPlan", "plan": plan }),
Err(message) => json!({ "type": "installPrepareFailed", "message": message }),
}
}
/// The connect page's answer to an install on another machine (or a copy of
/// its command) that did not get going.
fn remote_install_failed(message: String) -> serde_json::Value {
json!({ "type": "installFailed", "remote": true, "message": message })
}
/// Hand an http(s) URL to the default browser.
fn open_external(url: &Url) {
#[cfg(target_os = "macos")]
let child = Command::new("/usr/bin/open").arg(url.as_str()).spawn();
#[cfg(target_os = "windows")]
let child = Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", url.as_str()])
.spawn();
#[cfg(all(unix, not(target_os = "macos")))]
let child = Command::new("xdg-open").arg(url.as_str()).spawn();
if let Ok(mut child) = child {
std::thread::spawn(move || {
let _ = child.wait();
});
}
}
/// Give the native challenge handler the login for the server at `url` (its
/// base path included: the handler answers only below it), or take it away
/// (None). It answers HTTP Basic challenges from that server only, so the page
/// recovers by itself when the server restarted and forgot its session. Also
/// re-arms the handler's refusal report.
fn set_challenge_login(login: Option<(&Url, &str, &str)>) {
#[cfg(target_os = "macos")]
macos::set_credentials(login.map(|(url, username, password)| macos::BasicCredentials {
server: url.clone(),
username: username.to_string(),
password: password.to_string(),
}));
#[cfg(not(target_os = "macos"))]
let _ = login;
}
/// Empty the web views' cookie jar, then post CookiesCleared for this load.
/// On macOS the clear is asynchronous and `done` runs later on the main
/// thread; elsewhere the event is posted at once.
fn clear_cookies_then_load(proxy: &EventLoopProxy<UserEvent>, generation: u64, url: Url) {
let proxy = proxy.clone();
let done = move || {
let _ = proxy.send_event(UserEvent::CookiesCleared { generation, url });
};
#[cfg(target_os = "macos")]
macos::clear_cookies(Box::new(done));
#[cfg(not(target_os = "macos"))]
done();
}
/// `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.
fn build_webview(
window: &Window,
is_main: bool,
focused: bool,
load: Load<'_>,
proxy: &EventLoopProxy<UserEvent>,
phase: &Rc<RefCell<Phase>>,
) -> wry::Result<WebView> {
let id = window.id();
let mut builder = WebViewBuilder::new()
.with_initialization_script(assets::HOST_SHIM)
.with_devtools(true)
.with_focused(focused)
.with_accept_first_mouse(true)
.with_back_forward_navigation_gestures(false)
// false = let WebKit take the drop, so Codeman's own HTML5 drop
// (prompt uploads) and <input type=file> drops keep working.
.with_drag_drop_handler(|_| false)
// Ask the user per page instead of wry's default of granting the
// microphone and camera to every frame, sandboxed previews included.
.with_permission_handler(|_| PermissionResponse::Default);
if is_main {
builder = builder.with_custom_protocol(policy::APP_SCHEME.into(), |_id, request| assets::respond(request));
}
// The handlers below read the phase with try_borrow and fail closed: the
// app never holds a borrow across a call into wry, so it cannot fail, but
// a panic here would abort the app.
let nav_phase = phase.clone();
builder = builder.with_navigation_handler(move |target| {
nav_phase
.try_borrow()
.is_ok_and(|phase| policy::navigation(&target, &phase) == Navigation::Allow)
});
// Every answer to WebKit is Deny: the window WebKit would build carries
// none of the app's handlers. What the page asked for is done through the
// event loop. A refusal is not reported back: this path takes requests
// from any frame (wry does not say which), and a report would run in the
// dashboard. Codeman's own pop-outs, the ones it wants to hear about, come
// through CodemanHost.openWindow (on_host_cmd).
let nw_phase = phase.clone();
let nw_proxy = proxy.clone();
builder = builder.with_new_window_req_handler(move |target, _features| {
let decision = match nw_phase.try_borrow() {
Ok(phase) => policy::new_window(&target, &phase, Via::Script),
Err(_) => NewWindow::Deny,
};
let event = match decision {
NewWindow::AppWindow(url) => Some(UserEvent::OpenAppWindow(url)),
NewWindow::External(url) => Some(UserEvent::OpenExternal(url)),
NewWindow::Deny => None,
};
if let Some(event) = event {
let _ = nw_proxy.send_event(event);
}
NewWindowResponse::Deny
});
let ipc_phase = phase.clone();
let ipc_proxy = proxy.clone();
builder = builder.with_ipc_handler(move |request| {
let frame_url = request.uri().to_string();
let msg = match ipc_phase.try_borrow() {
Ok(phase) => policy::parse_ipc(&frame_url, request.body(), &phase, is_main),
Err(_) => None,
};
if let Some(msg) = msg {
let _ = ipc_proxy.send_event(UserEvent::Ipc { window: id, msg });
}
});
let load_phase = phase.clone();
let load_proxy = proxy.clone();
builder = builder.with_on_page_load_handler(move |event, url| {
if !matches!(event, PageLoadEvent::Started) {
return;
}
// Started is didCommitNavigation. The main window's phase moves to
// Loaded right here, so the gates change in the same instant the
// server page becomes the document. App repeats the step when the
// 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) = policy::loaded_on_commit(&phase, &url) {
*phase = next;
}
}
}
let _ = load_proxy.send_event(UserEvent::PageCommitted { window: id, url });
});
let title_proxy = proxy.clone();
builder = builder.with_document_title_changed_handler(move |title| {
let _ = title_proxy.send_event(UserEvent::Title { window: id, title });
});
let dl_proxy = proxy.clone();
builder = builder.with_download_started_handler(move |url, destination| {
// wry already picked ~/Downloads/<suggested name>, de-duplicated; it
// stays as it is. Only its name is passed on, for the toast.
let file_name = file_name_of(destination);
let _ = dl_proxy.send_event(UserEvent::DownloadStarted {
window: id,
url,
file_name,
});
true
});
let done_proxy = proxy.clone();
builder = builder.with_download_completed_handler(move |url, path, ok| {
let _ = done_proxy.send_event(UserEvent::DownloadDone {
window: id,
url,
path,
ok,
});
});
// wry answers WebKit's "web content process ended" notice itself, which
// turns WebKit's own reload off: without a handler the window stays blank.
#[cfg(target_os = "macos")]
{
use wry::WebViewBuilderExtDarwin;
let gone_proxy = proxy.clone();
builder = builder.with_on_web_content_process_terminate_handler(move || {
let _ = gone_proxy.send_event(UserEvent::ContentProcessGone { window: id });
});
}
builder = match load {
Load::ConnectPage => builder.with_url(policy::CONNECT_PAGE),
Load::Server { url, auth: Some(auth) } => builder.with_url_and_headers(url, auth_headers(auth)),
Load::Server { url, auth: None } => builder.with_url(url),
};
let webview = builder.build(window)?;
#[cfg(target_os = "macos")]
macos::install(&webview);
Ok(webview)
}
/// Hides the whole app, as Cmd+H does. A click on the Dock icon (or Cmd+Tab)
/// shows it again with every window as it was, a full-screen one included,
/// which hiding the main window alone would not do.
#[cfg(target_os = "macos")]
fn hide_app(target: &EventLoopWindowTarget<UserEvent>) {
use tao::platform::macos::EventLoopWindowTargetExtMacOS;
target.hide_application();
}
#[cfg(not(target_os = "macos"))]
fn hide_app(_target: &EventLoopWindowTarget<UserEvent>) {}
impl App {
fn handle(
&mut self,
event: Event<UserEvent>,
target: &EventLoopWindowTarget<UserEvent>,
control_flow: &mut ControlFlow,
) {
match event {
Event::NewEvents(StartCause::Init) => {
#[cfg(target_os = "macos")]
{
self.menu.bar.init_for_nsapp();
self.menu.window_menu.set_as_windows_menu_for_nsapp();
self.menu.help_menu.set_as_help_menu_for_nsapp();
}
}
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, target, control_flow),
Event::UserEvent(event) => self.on_user_event(event, target),
// A click on the Dock icon while no window is visible, which is
// the main window minimized: tao only reports it, so bring the
// window back here.
Event::Reopen {
has_visible_windows: false,
..
} => self.show_main_window(),
Event::MainEventsCleared => self.on_events_cleared(control_flow),
Event::LoopDestroyed => {
self.keep_awake.shutdown();
self.record_main_frame();
self.save_config();
}
_ => {}
}
}
/// The end of a loop iteration: run what is due, then sleep until the next
/// deadline. tao reads the control flow after this iteration's last event,
/// so it is only decided here, never reset per event.
fn on_events_cleared(&mut self, control_flow: &mut ControlFlow) {
let now = Instant::now();
if self.config_dirty && now >= self.save_held_since + SAVE_DEBOUNCE {
self.save_config();
}
let loading = matches!(*self.phase.borrow(), Phase::Loading(_));
if !loading {
self.loading_deadline = None;
} else if self.loading_deadline.is_some_and(|due| now >= due) {
self.show_connect_page(Some(LOAD_TIMED_OUT.into()), false);
}
if self.fullscreen_retry.is_some_and(|(due, _)| now >= due) {
self.retry_fullscreen(now);
}
if matches!(*control_flow, ControlFlow::ExitWithCode(_)) {
return;
}
let save_due = self.config_dirty.then(|| self.save_held_since + SAVE_DEBOUNCE);
let fullscreen_due = self.fullscreen_retry.map(|(due, _)| due);
*control_flow = match [save_due, self.loading_deadline, fullscreen_due]
.into_iter()
.flatten()
.min()
{
Some(due) => ControlFlow::WaitUntil(due),
None => ControlFlow::Wait,
};
}
/// The main window asked for full screen at launch: if it is not there
/// yet, ask again, a few times at most. tao reports a window as full
/// screen from the moment it is asked and puts that back only when the
/// entry fails, so `fullscreen()` being None means it failed. Stops once
/// the window is in full screen or minimized, so a user who leaves full
/// screen later is never pulled back.
fn retry_fullscreen(&mut self, now: Instant) {
let Some((_, attempts)) = self.fullscreen_retry else {
return;
};
self.fullscreen_retry = None;
let Some(main) = self.windows.get(&self.main_id) else {
return;
};
if main.window.fullscreen().is_some() || main.window.is_minimized() || attempts >= FULLSCREEN_ATTEMPTS {
return;
}
main.window.set_fullscreen(Some(Fullscreen::Borderless(None)));
self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1));
}
fn on_window_event(
&mut self,
id: WindowId,
event: WindowEvent,
target: &EventLoopWindowTarget<UserEvent>,
control_flow: &mut ControlFlow,
) {
match event {
WindowEvent::CloseRequested => {
if id != self.main_id {
self.remove_window(id);
} else if self.config.keep_awake.mode.close_hides_app() {
// Keep This Mac Awake ends with the app, so closing the
// window while it is on hides Codeman instead, as Cmd+H
// does (full screen and pop-outs included): the Mac stays
// awake, a click on the Dock icon brings it back, and
// Cmd+Q still quits.
self.record_main_frame();
self.save_config();
hide_app(target);
} else {
// Closing the main window quits; the server and its
// sessions keep running, so nothing is lost.
self.keep_awake.shutdown();
self.record_main_frame();
self.save_config();
*control_flow = ControlFlow::Exit;
}
}
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
_ => {}
}
}
fn on_user_event(&mut self, event: UserEvent, target: &EventLoopWindowTarget<UserEvent>) {
match event {
UserEvent::Menu(e) => self.on_menu(e.id.0.as_str()),
UserEvent::Ipc { window, msg } => match msg {
Ipc::Host(cmd) => self.on_host_cmd(window, cmd, target),
Ipc::Connect(cmd) => self.on_connect_cmd(cmd),
},
UserEvent::ProbeDone(done) => self.on_probe_done(*done),
UserEvent::ToConnectPage(msg) => {
if self.choosing() {
self.to_connect_page(msg);
}
}
UserEvent::ServerStartDone {
generation,
url,
result,
} => {
self.starting_server = None;
if generation != self.start_generation {
return; // cancelled
}
let ok = result.is_ok();
let output = result.unwrap_or_else(|e| e);
self.to_connect_page(json!({
"type": "serverStartResult",
"ok": ok,
"output": output,
"url": url.as_str(),
}));
}
UserEvent::OpenAppWindow(url) => {
if self.script_window_budget.allow(Instant::now()) {
// Built without focus: it comes to the front, but the
// keyboard stays with the window the user is typing in.
self.build_popout(target, &url, false);
} else {
eprintln!("codeman-desktop: too many windows opened by a page at once; dropped one");
}
}
UserEvent::OpenExternal(url) => {
self.open_external_budgeted(&url);
}
UserEvent::CookiesCleared { generation, url } => {
// Only the load this clear was for, and only while it is
// still wanted: a Cancel, Switch Server, timeout or another
// connect in between moved the generation or the phase on.
let current = generation == self.probe_generation
&& matches!(&*self.phase.borrow(), Phase::Loading(loading) if *loading == url);
if current {
self.load_in(self.main_id, &policy::webkit_safe_url(&url), true);
}
}
UserEvent::LoginChecked { generation, outcome } => self.on_login_checked(generation, outcome),
UserEvent::Title { window, title } => {
if let Some(w) = self.windows.get(&window) {
let title = title.trim();
w.window.set_title(if title.is_empty() { APP_NAME } else { title });
}
}
UserEvent::PageCommitted { window, url } => {
if window == self.main_id {
let next = policy::loaded_on_commit(&self.phase.borrow(), &url);
if let Some(next) = next {
*self.phase.borrow_mut() = next;
}
self.sync_logo_watch();
self.sync_keep_awake();
}
if let Some(w) = self.windows.get_mut(&window) {
w.committed = Url::parse(&url).ok();
}
}
UserEvent::DownloadStarted { window, url, file_name } => {
// A pop-out opened only to fetch a file would sit empty: hide
// it now, drop it when the download ends (dropping it sooner
// could cancel the download with its web view).
if let Some(w) = self.windows.get_mut(&window) {
if let Kind::Popout {
opened_url,
hidden_for_download,
} = &mut w.kind
{
if *opened_url == url {
*hidden_for_download = true;
w.window.set_visible(false);
}
}
self.downloads
.entry((window, url))
.or_default()
.push_back(file_name.unwrap_or_else(|| UNNAMED_FILE.into()));
}
}
UserEvent::DownloadDone { window, url, path, ok } => {
let key = (window, url);
let started = self.downloads.get_mut(&key).and_then(VecDeque::pop_front);
if self.downloads.get(&key).is_some_and(VecDeque::is_empty) {
self.downloads.remove(&key);
}
let name = started
.or_else(|| path.as_deref().and_then(file_name_of))
.unwrap_or_else(|| UNNAMED_FILE.into());
let server = self.phase.borrow().loaded().cloned();
// The toast goes where the download was started when that
// window still shows Codeman's own app (a session pop-out has
// it too), else to the main window. Chosen before the hidden
// pop-out below is dropped.
let toast_in = server.as_ref().map(|server| {
let shows_app = self.windows.get(&window).is_some_and(|w| {
!w.hidden_for_download()
&& w.committed
.as_ref()
.is_some_and(|url| policy::trusted_frame(url, server))
});
if shows_app {
window
} else {
self.main_id
}
});
if self.windows.get(&window).is_some_and(AppWindow::hidden_for_download) {
self.remove_window(window);
}
if let Some(w) = toast_in.and_then(|id| self.windows.get(&id)) {
let _ = w.webview.evaluate_script(&assets::download_toast_script(&name, ok));
}
}
UserEvent::AuthRejected => self.recheck_login(),
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
UserEvent::KeepAwakeStatus { generation, text } => {
if self.keep_awake.is_current(generation) {
if let Some(items) = &self.menu.keep_awake {
items.status.set_text(&text);
}
}
}
UserEvent::LidHelperDone { install, outcome } => self.on_lid_helper_done(install, outcome),
}
}
/// The web content process of `window` ended, so the window is blank
/// (policy::recovery decides what it does about it). A second end within
/// CRASH_RELOAD_COOLDOWN counts as the same page failing again.
fn on_content_process_gone(&mut self, window: WindowId) {
let now = Instant::now();
let Some(w) = self.windows.get_mut(&window) else {
return;
};
// A pop-out hidden for its download shows no page, and the download
// does not run in that process: loading again would fetch it twice.
if w.hidden_for_download() {
return;
}
let again = w
.content_process_gone_at
.is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN);
w.content_process_gone_at = Some(now);
let on_server = self.phase.borrow().loaded().is_some();
match policy::recovery(window == self.main_id, on_server, again) {
Recovery::Reload => {
if let Some((url, with_auth)) = self.reload_target(window) {
self.load_in(window, &url, with_auth);
}
}
Recovery::ConnectPage { gave_up } => {
self.show_connect_page(gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false);
}
Recovery::Close => self.remove_window(window),
Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"),
}
}
/// The challenge handler reported that the server refused its login. That
/// is not taken on trust: a 401 the handler reads as Codeman's can still
/// come from something Codeman relays (a dashboard asset a web tab fetches
/// by a root-absolute URL). So the login is checked once against the
/// server's own status API, off the event loop, and on_login_checked
/// decides.
fn recheck_login(&mut self) {
let Some(server) = self.phase.borrow().server().cloned() else {
return; // the connect page: nothing to recheck
};
let Some(auth) = self.auth.clone() else {
return;
};
let generation = self.login_generation;
if self.login_recheck == Some(generation) {
return;
}
self.login_recheck = Some(generation);
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let outcome = probe::probe(&server, Some(&auth));
let _ = proxy.send_event(UserEvent::LoginChecked { generation, outcome });
});
}
/// The server's answer to recheck_login. A refused login brings the
/// connect page back (without taking focus from another app). So does a
/// 429: the page's own requests then get 429 too, which carries no
/// challenge, so nothing else would ever notice, and re-arming would send
/// the refused login again once the limit lapses. Any other answer, a
/// network error included, leaves the page as it is and hands the handler
/// the same login again, which also re-arms its report, so a later real
/// refusal is noticed.
fn on_login_checked(&mut self, generation: u64, outcome: ProbeOutcome) {
if self.login_recheck == Some(generation) {
self.login_recheck = None;
}
if generation != self.login_generation {
return; // another login, or none, since the recheck started
}
let Some(server) = self.phase.borrow().server().cloned() else {
return;
};
match outcome {
ProbeOutcome::BadCredentials | ProbeOutcome::NeedsLogin => {
self.show_connect_page(Some(AUTH_REJECTED.into()), false);
}
ProbeOutcome::RateLimited { .. } => {
self.show_connect_page(Some(AUTH_RATE_LIMITED.into()), false);
}
_ => {
if let Some((username, password)) = &self.login {
set_challenge_login(Some((&server, username, password)));
}
}
}
}
/// The logo is watched while the switch is on, a state has a style and a
/// server is open.
fn sync_logo_watch(&mut self) {
let styles = self.config.logo_blink;
let phase = self.phase.borrow();
let server = phase.loaded().filter(|_| styles.active() && logo::available());
self.logo_watch
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
}
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
let was_active = self.config.logo_blink.active();
self.config.logo_blink.set(signal, style);
// Picking a style is asking for the logo to show it.
if style != BlinkStyle::Off {
self.config.logo_blink.enabled = true;
}
self.logo_blink_changed(was_active);
}
/// The one switch. Turned on with every state off, it brings the recommended
/// styles, so that on always shows something.
fn toggle_logo_blink(&mut self) {
let was_active = self.config.logo_blink.active();
let on = !self.config.logo_blink.enabled;
self.config.logo_blink.enabled = on;
if on && !self.config.logo_blink.any_style() {
self.config.logo_blink = logo::BlinkStyles::RECOMMENDED;
}
self.logo_blink_changed(was_active);
}
fn logo_blink_changed(&mut self, was_active: bool) {
// A click flips the item it landed on, so every item is set from the
// settings, the clicked one included.
let styles = self.config.logo_blink;
if let Some(switch) = &self.menu.logo_switch {
switch.set_checked(styles.enabled);
}
for (signal, style, item) in &self.menu.logo_styles {
item.set_checked(styles.get(*signal) == *style);
}
self.config_dirty = true;
self.sync_logo_watch();
// Only from off: with a blink running, a dark logo may be our own.
if was_active || !styles.active() {
return;
}
let warning = if logo::switched_off() {
LOGO_SWITCHED_OFF
} else if logo::driven_elsewhere() {
LOGO_DRIVEN_ELSEWHERE
} else {
return;
};
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning"));
}
}
/// Keep-awake watches the open server while a mode is chosen. Whether that
/// server is this Mac is the worker's to find out.
fn sync_keep_awake(&mut self) {
let settings = self.config.keep_awake;
let server = self.phase.borrow().loaded().cloned();
let watch = server
.filter(|_| settings.mode != keep_awake::Mode::Off)
.map(|server| keep_awake::Watch {
server,
authorization: self.auth.clone(),
settings,
});
let idle = match (settings.mode, &watch) {
(keep_awake::Mode::Off, _) => Some(keep_awake::OFF_TEXT),
(_, None) => Some(keep_awake::NO_SERVER_TEXT),
_ => None,
};
self.keep_awake.sync(watch);
if let (Some(text), Some(items)) = (idle, &self.menu.keep_awake) {
items.status.set_text(text);
}
}
fn choose_keep_awake_mode(&mut self, mode: keep_awake::Mode) {
if let Some(items) = &self.menu.keep_awake {
// A click flips the item it landed on, the chosen one included.
for (item_mode, item) in &items.modes {
item.set_checked(*item_mode == mode);
}
}
self.config.keep_awake.mode = mode;
self.config_dirty = true;
self.sync_keep_awake();
}
fn toggle_keep_awake_ac_only(&mut self) {
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.ac_only.as_ref()) else {
return;
};
self.config.keep_awake.ac_only = item.is_checked();
self.config_dirty = true;
self.sync_keep_awake();
}
/// The lid item is the helper: ticking it installs the helper behind the
/// administrator prompt (unless this version's is already in), unticking it removes
/// it behind another. The setting changes only once the prompt's command ran, so a
/// cancelled or failed prompt leaves the item as it was.
fn toggle_keep_awake_lid(&mut self) {
use keep_awake::HelperState;
let Some(wanted) = self
.menu
.keep_awake
.as_ref()
.and_then(|items| items.lid.as_ref())
.map(|item| item.is_checked())
else {
return;
};
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let prompt = match (wanted, state) {
_ if self.lid_helper_busy => None,
(true, HelperState::Installed) | (false, HelperState::Missing) => {
self.config.keep_awake.lid = wanted;
self.config_dirty = true;
None
}
(install, _) => Some(install),
};
// The click flipped the item already. Until a prompt's command ran it shows
// what is true.
if let Some(items) = &self.menu.keep_awake {
items.show_helper(state, self.config.keep_awake.lid);
}
match prompt {
Some(install) => self.run_lid_helper(install),
None => self.sync_keep_awake(),
}
}
fn run_lid_helper(&mut self, install: bool) {
if self.lid_helper_busy {
return;
}
self.lid_helper_busy = true;
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let outcome = match install {
true => keep_awake::install_helper(),
false => keep_awake::remove_helper(),
};
let _ = proxy.send_event(UserEvent::LidHelperDone { install, outcome });
});
}
fn on_lid_helper_done(&mut self, install: bool, outcome: keep_awake::AdminOutcome) {
use keep_awake::{AdminOutcome, HelperState};
self.lid_helper_busy = false;
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let failure = match (&outcome, install, state) {
(AdminOutcome::Cancelled, _, _) => None,
(AdminOutcome::Failed(error), true, _) => Some(("The lid helper could not be installed.", error.clone())),
(AdminOutcome::Failed(error), false, _) => Some(("The lid helper could not be removed.", error.clone())),
(AdminOutcome::Done, true, HelperState::Installed) => {
self.config.keep_awake.lid = true;
self.config_dirty = true;
None
}
(AdminOutcome::Done, true, _) => Some((
"The lid helper could not be installed.",
"The installation ran, but the helper's files are not what the app wrote.".to_owned(),
)),
(AdminOutcome::Done, false, HelperState::Missing) => {
self.config.keep_awake.lid = false;
self.config_dirty = true;
None
}
(AdminOutcome::Done, false, _) => Some((
"The lid helper could not be removed.",
"The removal ran, but the helper's files are still there.".to_owned(),
)),
};
if let Some(items) = &self.menu.keep_awake {
items.show_helper(state, self.config.keep_awake.lid);
}
self.keep_awake.refresh();
self.sync_keep_awake();
if let Some((title, detail)) = failure {
eprintln!("codeman-desktop: {title} {detail}");
#[cfg(target_os = "macos")]
macos::show_message(title, &detail);
}
}
fn on_menu(&mut self, id: &str) {
if let Some((signal, style)) = BlinkStyle::from_menu_id(id) {
self.choose_logo_blink(signal, style);
return;
}
if let Some(mode) = keep_awake::Mode::from_menu_id(id) {
self.choose_keep_awake_mode(mode);
return;
}
match id {
keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(),
keep_awake::LID_ID => self.toggle_keep_awake_lid(),
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
menu_id::LOGO_BLINK => self.toggle_logo_blink(),
menu_id::START_FULLSCREEN => {
let value = self.menu.start_fullscreen.is_checked();
self.config.start_fullscreen = value;
self.config_dirty = true;
self.to_connect_page(json!({ "type": "prefs", "startFullscreen": value }));
}
menu_id::DEVTOOLS => {
if let Some(w) = self.focused_window_id().and_then(|id| self.windows.get(&id)) {
w.webview.open_devtools();
}
}
// The Help items are the user's own click, so they skip the budget.
menu_id::HELP_DOCS => open_external(&Url::parse(menu::DOCS_URL).expect("static url")),
menu_id::HELP_GITHUB => open_external(&Url::parse(menu::REPO_URL).expect("static url")),
_ => {}
}
}
fn on_host_cmd(&mut self, window: WindowId, cmd: HostCmd, target: &EventLoopWindowTarget<UserEvent>) {
match cmd {
HostCmd::OpenWindow(raw) => {
// The page's openWindow already answered true, so every way
// this can fail (refused, the pop-out limit, a window that
// did not build, the browser budget) is reported back to it.
// This request comes only from Codeman's own app shell
// (parse_ipc), so a session it asked for gets its tab back.
let session = self.phase.borrow().loaded().and_then(|server| {
Url::parse(&raw)
.ok()
.and_then(|url| policy::session_id_of(&url, server))
});
let decision = policy::new_window(&raw, &self.phase.borrow(), Via::Host);
let opened = match decision {
NewWindow::AppWindow(url) => self.build_popout(target, &url, true),
NewWindow::External(url) => self.open_external_budgeted(&url),
NewWindow::Deny => false,
};
if !opened {
self.tell_refused(window, session.as_deref());
}
}
HostCmd::CloseWindow => self.remove_window(window),
HostCmd::FocusWindow => {
if let Some(w) = self.windows.get(&window) {
w.window.set_visible(true);
w.window.set_minimized(false);
w.window.set_focus();
}
}
HostCmd::OpenExternal(raw) => self.open_external_raw(&raw),
HostCmd::SessionExpired => self.on_session_expired(window),
}
}
fn on_connect_cmd(&mut self, cmd: ConnectCmd) {
// The connect page is still live while a server loads, but only
// Cancel and the settings make sense then.
let choosing = self.choosing();
let loading = matches!(*self.phase.borrow(), Phase::Loading(_));
match cmd {
ConnectCmd::Ready => self.send_init(),
ConnectCmd::Connect {
url,
username,
password,
remember,
} => {
if choosing {
self.start_probe(url, username, password, remember);
}
}
ConnectCmd::Cancel => {
self.probe_generation += 1;
self.start_generation += 1;
// Only a load that has not committed yet: once the server page
// is up, a Cancel that was still on its way changes nothing.
if loading {
self.show_connect_page(Some(LOAD_CANCELLED.into()), true);
}
}
ConnectCmd::StartServer { url } => {
if choosing {
self.start_server(url);
}
}
ConnectCmd::Forget { url } => {
if let Some(entry) = self.config.forget(&url) {
if let Some(user) = entry.username {
self.keychain.delete(&credentials::account(&url, &user));
}
self.config_dirty = true;
}
self.to_connect_page(json!({ "type": "servers", "servers": self.config.servers }));
}
ConnectCmd::SetAutoConnect(v) => {
self.config.auto_connect = v;
self.config_dirty = true;
}
ConnectCmd::SetStartFullscreen(v) => {
self.config.start_fullscreen = v;
self.menu.start_fullscreen.set_checked(v);
self.config_dirty = true;
}
ConnectCmd::OpenExternal(raw) => self.open_external_raw(&raw),
ConnectCmd::LocalStatus { quick, deep } => {
if choosing {
let depth = match (quick, deep) {
(true, _) => setup::Depth::Quick,
(false, false) => setup::Depth::Login,
(false, true) => setup::Depth::Interactive,
};
self.in_background(move || json!({ "type": "localStatus", "status": setup::inspect(depth) }));
}
}
ConnectCmd::InstallLocal { network } => {
if choosing {
self.in_background(move || {
let port = setup::pick_install_port();
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 }),
}
});
}
}
ConnectCmd::InstallPrepare => {
if choosing {
self.in_background(|| plan_message(gui_install::prepare()));
}
}
ConnectCmd::InstallStart {
network_password,
ai_cli,
} => {
if choosing {
let send = self.page_sender();
let stale = self.next_install_reader();
std::thread::spawn(move || {
let plan = gui_install::Plan {
port: setup::pick_install_port(),
network_password,
ai_cli,
};
match gui_install::start(&plan) {
Ok(()) => {
send(json!({ "type": "installRunning", "port": plan.port }));
gui_install::follow(gui_install::Run::Codeman, stale, send);
}
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
}
});
}
}
ConnectCmd::InstallAttach => {
if choosing {
self.attach_to_run(gui_install::Run::Codeman);
}
}
ConnectCmd::InstallCancel => {
std::thread::spawn(gui_install::cancel);
}
ConnectCmd::AgentPrepare => {
if choosing {
self.in_background(|| plan_message(gui_install::prepare_agent()));
}
}
ConnectCmd::AgentStart { ai_cli } => {
if choosing {
let send = self.page_sender();
let stale = self.next_install_reader();
std::thread::spawn(move || match gui_install::start_agent(&ai_cli) {
Ok(()) => gui_install::follow(gui_install::Run::Agent, stale, send),
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
});
}
}
ConnectCmd::AgentAttach => {
if choosing {
self.attach_to_run(gui_install::Run::Agent);
}
}
ConnectCmd::InstallRemote { target } => {
if choosing {
match terminal_install::SshTarget::parse(&target) {
Err(message) => self.to_connect_page(remote_install_failed(message)),
Ok(target) => self.in_background(move || {
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,
"url": target.suggested_url(),
}),
Err(message) => remote_install_failed(message),
}
}),
}
}
}
ConnectCmd::CopyInstallCommand => {
self.in_background(|| match terminal_install::copy_to_clipboard(setup::INSTALL_ONE_LINER) {
Ok(()) => json!({ "type": "copied" }),
Err(message) => remote_install_failed(message),
});
}
}
}
/// Report a run that is going already to the page, from its first line.
fn attach_to_run(&self, run: gui_install::Run) {
let send = self.page_sender();
let stale = self.next_install_reader();
std::thread::spawn(move || gui_install::follow(run, stale, send));
}
/// The agent chooser lives on the connect page: from a Codeman page, this
/// leaves it the way Switch Server does.
fn add_agent(&mut self) {
if self.choosing() {
if let Some(main) = self.windows.get(&self.main_id) {
main.window.set_visible(true);
main.window.set_focus();
}
self.to_connect_page(json!({ "type": "addAgent" }));
} else {
self.add_agent_on_load = true;
self.show_connect_page(None, true);
}
}
/// A new reader of the install's output replaces the one before it: the
/// answer is how that new reader learns it was replaced in turn.
fn next_install_reader(&self) -> impl Fn() -> bool + Send + 'static {
let generation = self.install_reader.clone();
let mine = generation.fetch_add(1, Ordering::SeqCst) + 1;
move || generation.load(Ordering::SeqCst) != mine
}
/// Run `work` off the event loop (it may run a login shell, plutil,
/// Terminal or a probe) and hand what it returns to the connect page.
fn in_background(&self, work: impl FnOnce() -> serde_json::Value + Send + 'static) {
let send = self.page_sender();
std::thread::spawn(move || send(work()));
}
/// Hands a message to the connect page from another thread
/// (UserEvent::ToConnectPage).
fn page_sender(&self) -> impl Fn(serde_json::Value) + Send + 'static {
let proxy = self.proxy.clone();
move |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
}
}
fn send_init(&mut self) {
let notice = self.notice.take();
let add_agent = std::mem::take(&mut self.add_agent_on_load);
self.to_connect_page(json!({
"type": "init",
"servers": self.config.servers,
"autoConnect": self.config.auto_connect,
"startFullscreen": self.config.start_fullscreen,
"version": VERSION,
"starting": self.start_running(),
"notice": notice,
"addAgent": add_agent,
}));
}
/// Whether the connect page is up with no server chosen: the only time it
/// may run anything.
fn choosing(&self) -> bool {
matches!(*self.phase.borrow(), Phase::Connect)
}
/// Whether a Start Codeman run is going whose result the page will get.
fn start_running(&self) -> bool {
self.starting_server == Some(self.start_generation)
}
fn start_probe(&mut self, raw: String, username: Option<String>, password: Option<String>, remember: bool) {
let url = match policy::normalize_server_url(&raw) {
Ok(u) => u,
Err(message) => {
self.to_connect_page(json!({ "type": "error", "message": message }));
return;
}
};
let key = url.to_string();
let typed_user = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
let typed_password = password.filter(|p| !p.is_empty());
let entry = self.config.find(&key);
// The user the config names for this server now. Its Keychain item
// goes once the probe accepts another one (update_keychain).
let previous_user = entry.and_then(|e| e.username.clone());
let user = typed_user.or_else(|| previous_user.clone());
// The config says the Keychain holds this user's password.
let expect_saved = typed_password.is_none()
&& user.is_some()
&& entry.is_some_and(|e| e.has_saved_password && e.username == user);
self.probe_generation += 1;
let generation = self.probe_generation;
let proxy = self.proxy.clone();
let keychain = self.keychain.clone();
// Every Keychain call for the connect happens here, never on the
// event loop: any of them can bring up the system's access prompt,
// which must not hold up the window.
std::thread::spawn(move || {
let saved = match typed_password {
Some(_) => None,
None => user
.as_ref()
.and_then(|u| keychain.load_blocking(&credentials::account(&key, u))),
};
let (user, password, source) =
choose_login(user, typed_password, saved, expect_saved, || setup::local_login(&url));
// Codeman's default username when only a password was given.
let username = password
.as_ref()
.map(|_| user.clone().unwrap_or_else(|| setup::DEFAULT_USERNAME.into()));
let auth = basic_auth(username.as_deref(), password.as_deref());
let (result, update) = if expect_saved && password.is_none() {
(ProbeResult::SavedPasswordUnreadable, KeychainUpdate::default())
} else {
let outcome = probe::probe(&url, auth.as_deref());
let login = ProbedLogin {
server: &key,
previous_user: previous_user.as_deref(),
username: username.as_deref(),
password: password.as_deref(),
source,
remember,
};
let update = update_keychain(&keychain, &outcome, &login);
(ProbeResult::Probed(outcome), update)
};
let _ = proxy.send_event(UserEvent::ProbeDone(Box::new(ProbeDone {
generation,
url,
result,
username,
password,
saved: update.saved,
deleted: update.deleted,
})));
});
}
fn on_probe_done(&mut self, done: ProbeDone) {
let key = done.url.to_string();
if done.generation != self.probe_generation || !self.choosing() {
// Cancelled, superseded, or a server is already loading. What the
// worker did to the Keychain stands all the same, so an entry
// whose password it deleted stops claiming one.
self.note_deleted_passwords(&key, &done.deleted);
return;
}
match done.result {
ProbeResult::SavedPasswordUnreadable => {
// needsPassword: the page shows the login fields, which the
// This Mac tab does not have.
self.to_connect_page(json!({ "type": "error", "message": KEYCHAIN_UNREADABLE, "needsPassword": true }));
}
ProbeResult::Probed(outcome @ (ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired)) => {
let (opening, saved) = match outcome {
ProbeOutcome::Ok { version } => (json!({ "type": "opening", "version": version }), done.saved),
// The worker already deleted the one-time password and any
// older one saved for the account (update_keychain).
_ => (
json!({ "type": "opening", "version": null, "passwordChange": true }),
false,
),
};
let orphans = self.config.remember(&key, done.username.as_deref(), saved);
self.delete_accounts(&orphans);
self.config_dirty = true;
self.to_connect_page(opening);
self.begin_loading(done.url, done.username, done.password);
}
ProbeResult::Probed(outcome) => {
let can_start = outcome == ProbeOutcome::Refused && policy::can_start_locally(&done.url);
self.to_connect_page(json!({
"type": "probeResult",
"url": done.url.as_str(),
"outcome": outcome,
"canStart": can_start,
}));
}
}
}
/// Point the main window at the server. The cookies go first: a session
/// cookie an earlier login left (another user, a Forget, a password
/// changed since) would win over this login's Authorization header, since
/// Codeman checks the cookie first, and the first load mints a fresh one
/// from the header anyway. The load itself starts once the clear is done
/// (UserEvent::CookiesCleared). The connect page stays the live document
/// until the server page commits; on_events_cleared gives up after
/// LOAD_TIMEOUT, which counts from here, so it covers the clear too.
fn begin_loading(&mut self, url: Url, username: Option<String>, password: Option<String>) {
self.auth = basic_auth(username.as_deref(), password.as_deref());
// Set before the load: its first response may already be a challenge.
self.set_login(username.zip(password).map(|(u, p)| (&url, u, p)));
// Before the load: the navigation handler allows the server's origin
// only once the phase names it.
*self.phase.borrow_mut() = Phase::Loading(url.clone());
self.loading_deadline = Some(Instant::now() + LOAD_TIMEOUT);
clear_cookies_then_load(&self.proxy, self.probe_generation, url);
}
/// Hand the challenge handler a new login, as (server, username,
/// password), or none, and start a new login generation, so a recheck of
/// the one before it is ignored when it comes back.
fn set_login(&mut self, login: Option<(&Url, String, String)>) {
set_challenge_login(login.as_ref().map(|(server, u, p)| (*server, u.as_str(), p.as_str())));
self.login = login.map(|(_, u, p)| (u, p));
self.login_generation += 1;
}
fn start_server(&mut self, raw: String) {
if let Some(running) = self.starting_server {
// A start is still going, maybe one the page cancelled: its result
// goes to the page that asked again.
self.start_generation = running;
self.to_connect_page(json!({ "type": "serverStarting" }));
return;
}
let url = match policy::normalize_server_url(&raw) {
Ok(u) if policy::can_start_locally(&u) => u,
Ok(_) => {
self.to_connect_page(json!({ "type": "error", "message": START_REFUSED }));
return;
}
Err(message) => {
self.to_connect_page(json!({ "type": "error", "message": message }));
return;
}
};
self.start_generation += 1;
let generation = self.start_generation;
self.starting_server = Some(generation);
self.to_connect_page(json!({ "type": "serverStarting" }));
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let result = setup::start_local(&url);
let _ = proxy.send_event(UserEvent::ServerStartDone {
generation,
url,
result,
});
});
}
/// Leave whatever the main window shows for the connect page: Switch
/// Server, a load that failed or was cancelled, or a refused login.
/// `notice` is shown by that page once it asks for its init.
///
/// `front` is for what the user just did (Switch Server, Reload, Cancel):
/// the main window comes forward and takes the keyboard. A timeout or a
/// refused login can happen while the user works in another app, so then
/// the window stays where it is and the Dock icon bounces once instead,
/// unless the window is already the one in use.
fn show_connect_page(&mut self, notice: Option<String>, front: bool) {
self.probe_generation += 1;
let popouts: Vec<WindowId> = self.windows.keys().copied().filter(|id| *id != self.main_id).collect();
for id in popouts {
self.remove_window(id);
}
// Before the load: the navigation handler allows the connect page
// only while the phase says it is live.
*self.phase.borrow_mut() = Phase::Connect;
self.loading_deadline = None;
self.auth = None;
self.set_login(None);
self.notice = notice;
if let Some(main) = self.windows.get_mut(&self.main_id) {
main.session_reload_at = None;
let _ = main.webview.load_url(&format!("{}?manual=1", policy::CONNECT_PAGE));
main.window.set_title(APP_NAME);
if front {
main.window.set_visible(true);
main.window.set_focus();
} else if !main.window.is_focused() || main.window.is_minimized() {
main.window
.request_user_attention(Some(UserAttentionType::Informational));
}
}
}
fn show_main_window(&self) {
if let Some(main) = self.windows.get(&self.main_id) {
main.window.set_minimized(false);
main.window.set_visible(true);
main.window.set_focus();
}
}
/// The URL a server window reloads, handed to wry as is, and whether the
/// login goes with it. Main window: its committed page if that is
/// Codeman's own app shell, else the server root (a raw file that
/// replaced the window, for example). Pop-out: its committed page, or the
/// URL it was opened with when nothing committed yet. The login goes only
/// below the server's base path.
fn reload_target(&self, id: WindowId) -> Option<(String, bool)> {
let server = self.phase.borrow().loaded().cloned()?;
let w = self.windows.get(&id)?;
let target = match &w.kind {
Kind::Main => match &w.committed {
Some(current) if policy::trusted_frame(current, &server) => current.clone(),
_ => server.clone(),
},
Kind::Popout { opened_url, .. } => w.committed.clone().or_else(|| Url::parse(opened_url).ok())?,
};
let with_auth = policy::within_base(&target, &server);
Some((policy::webkit_safe_url(&target), with_auth))
}
/// Load `url` in window `id`, with the login when `with_auth` and there is
/// one.
fn load_in(&self, id: WindowId, url: &str, with_auth: bool) {
let Some(w) = self.windows.get(&id) else {
return;
};
let result = match self.auth.as_deref().filter(|_| with_auth) {
Some(auth) => w.webview.load_url_with_headers(url, auth_headers(auth)),
None => w.webview.load_url(url),
};
if let Err(e) = result {
eprintln!("codeman-desktop: could not load a page: {e}");
}
}
fn reload_focused(&mut self) {
let Some(id) = self.focused_window_id() else {
return;
};
let loaded = self.phase.borrow().loaded().is_some();
if id == self.main_id && !loaded {
// The connect page, or a server load that has not committed:
// start over on the connect page.
self.show_connect_page(None, true);
return;
}
if let Some((url, with_auth)) = self.reload_target(id) {
self.load_in(id, &url, with_auth);
}
}
/// The page in `window` got a 401 from its own server (the HOST_SHIM
/// fallback for when the challenge handler did not recover it): reload it
/// once with the login, never again within SESSION_RELOAD_COOLDOWN.
fn on_session_expired(&mut self, window: WindowId) {
if self.auth.is_none() {
return;
}
let now = Instant::now();
let recent = self.windows.get(&window).is_some_and(|w| {
w.session_reload_at
.is_some_and(|at| now.saturating_duration_since(at) < SESSION_RELOAD_COOLDOWN)
});
if recent {
return;
}
let Some((url, true)) = self.reload_target(window) else {
return;
};
if let Some(w) = self.windows.get_mut(&window) {
w.session_reload_at = Some(now);
}
self.load_in(window, &url, true);
}
/// Open one of Codeman's pop-out pages in an app window. False when it
/// did not open: the pop-out limit, no server loaded, a URL that is not a
/// pop-out page, or a window or web view that failed to build. `focused`:
/// the window takes the keyboard; otherwise it comes to the front without
/// it.
fn build_popout(&mut self, target: &EventLoopWindowTarget<UserEvent>, url: &Url, focused: bool) -> bool {
let Some(server) = self.phase.borrow().loaded().cloned() else {
return false;
};
// The callers only pass what policy::new_window made an AppWindow;
// checked again here, where the window is built, so no other path
// can ever open a proxied dashboard or another page top-level.
if !policy::is_popout_target(url, &server) {
return false;
}
let popouts = self
.windows
.values()
.filter(|w| matches!(w.kind, Kind::Popout { .. }))
.count();
if popouts >= MAX_POPOUTS {
return false;
}
let opened_url = policy::webkit_safe_url(url);
let auth = self.auth.clone().filter(|_| policy::within_base(url, &server));
// tao centres a window that is given no position, so every pop-out
// would open exactly on top of the one before. Each one steps off the
// window in use instead.
let anchor = self.focused_window_id().unwrap_or(self.main_id);
let position = self.windows.get(&anchor).and_then(|w| {
let p = placed_position(&w.window)
.ok()?
.to_logical::<f64>(w.window.scale_factor());
let step = POPOUT_STEP * (1 + popouts % 8) as f64;
Some(LogicalPosition::new(p.x + step, p.y + step))
});
let mut builder = window_builder()
.with_inner_size(LogicalSize::new(POPOUT_SIZE.0, POPOUT_SIZE.1))
.with_focused(focused);
if let Some(position) = position {
builder = builder.with_position(position);
}
let window = match builder.build(target) {
Ok(window) => window,
Err(e) => {
eprintln!("codeman-desktop: could not open a window: {e}");
return false;
}
};
let load = Load::Server {
url: &opened_url,
auth: auth.as_deref(),
};
let webview = match build_webview(&window, false, focused, load, &self.proxy, &self.phase) {
Ok(webview) => {
// Still the one that takes the keys once the user comes to
// this window: first responder in its own window only.
if !focused {
let _ = webview.focus();
}
webview
}
Err(e) => {
eprintln!("codeman-desktop: could not create a web view: {e}");
return false;
}
};
let kind = Kind::Popout {
opened_url,
hidden_for_download: false,
};
self.windows.insert(window.id(), AppWindow::new(webview, window, kind));
true
}
/// Tell the page in `window` that the window it asked for through
/// CodemanHost.openWindow was not opened (assets::refused_script), with
/// the session id when that was a session pop-out, so the dashboard takes
/// the session's tab back. Only for that request, which parse_ipc takes
/// from Codeman's own app shell alone.
fn tell_refused(&self, window: WindowId, session_id: Option<&str>) {
if let Some(w) = self.windows.get(&window) {
let _ = w.webview.evaluate_script(&assets::refused_script(session_id));
}
}
/// Hand a URL to the default browser if the open budget allows; a page
/// can call window.open without a click, so openings over budget are
/// dropped.
fn open_external_budgeted(&mut self, url: &Url) -> bool {
if !self.open_budget.allow(Instant::now()) {
eprintln!(
"codeman-desktop: too many outside links at once; dropped one to {}",
url.host_str().unwrap_or("?")
);
return false;
}
open_external(url);
true
}
/// A link a page asked to open in the browser, if it is one
/// (policy::external_url) and the budget allows.
fn open_external_raw(&mut self, raw: &str) {
if let Some(url) = policy::external_url(raw) {
self.open_external_budgeted(&url);
}
}
/// Close a pop-out (never the main window) and forget its downloads.
fn remove_window(&mut self, id: WindowId) {
if id == self.main_id {
return;
}
self.windows.remove(&id);
self.downloads.retain(|(window, _), _| *window != id);
}
/// The app window that is key. None while a window that is not ours is
/// (the About panel, a detached inspector): Reload and Developer Tools
/// then do nothing rather than act on a window behind it.
fn focused_window_id(&self) -> Option<WindowId> {
self.windows
.iter()
.find(|(_, w)| w.window.is_focused())
.map(|(id, _)| *id)
}
/// Message the connect page (only while it is the main window's live
/// document).
fn to_connect_page(&self, msg: serde_json::Value) {
if !self.phase.borrow().connect_page_live() {
return;
}
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main.webview.evaluate_script(&format!(
"window.__codemanDesktop && window.__codemanDesktop.onNative({msg})"
));
}
}
fn record_main_frame(&mut self) {
let Some(main) = self.windows.get(&self.main_id) else {
return;
};
let w = &main.window;
let fullscreen = w.fullscreen().is_some();
let mut frame = self.config.window.unwrap_or(WindowFrame {
x: 0.0,
y: 0.0,
width: DEFAULT_SIZE.0,
height: DEFAULT_SIZE.1,
fullscreen,
});
// In full screen or minimized the live geometry is not the windowed
// frame; the saved one is kept for the next launch.
if !fullscreen && !w.is_minimized() {
let sf = w.scale_factor();
if let Ok(pos) = placed_position(w) {
let p = pos.to_logical::<f64>(sf);
frame.x = p.x;
frame.y = p.y;
}
// Not tao's inner_size(): wry replaced the window's content view,
// and tao reads the size of its own view, which stays at the
// creation size. On macOS not the web view's bounds either: a
// docked Web Inspector shrinks the web view, not the window. The
// content size is in points already.
#[cfg(target_os = "macos")]
let size = macos::content_size(w);
#[cfg(not(target_os = "macos"))]
let size = None;
let size = size.or_else(|| {
let bounds = main.webview.bounds().ok()?;
let s = bounds.size.to_logical::<f64>(sf);
Some((s.width, s.height))
});
if let Some((width, height)) = size {
frame.width = width;
frame.height = height;
}
}
// While the launch's full screen may still be retried, a window not
// in full screen yet keeps the flag that was saved (or asked for).
frame.fullscreen = if self.fullscreen_retry.is_some() && !fullscreen {
self.config.window.is_none_or(|f| f.fullscreen)
} else {
fullscreen
};
if self.config.window != Some(frame) {
self.config.window = Some(frame);
self.config_dirty = true;
self.save_held_since = Instant::now();
}
}
fn save_config(&mut self) {
self.save_held_since = Instant::now();
self.config_dirty = false;
if let Some(path) = &self.config_path {
if let Err(e) = self.config.save_to(path) {
eprintln!("codeman-desktop: could not save {}: {e}", path.display());
}
}
}
/// Queue the deletion of the Keychain items of accounts the config no
/// longer lists, as (server url, username).
fn delete_accounts(&self, accounts: &[(String, String)]) {
for (url, user) in accounts {
self.keychain.delete(&credentials::account(url, user));
}
}
/// The probe worker deleted the Keychain items of `users` for the server
/// `url`: an entry that names one of them no longer has a password saved.
fn note_deleted_passwords(&mut self, url: &str, users: &[String]) {
for entry in self.config.servers.iter_mut().filter(|s| s.url == url) {
if entry.has_saved_password && entry.username.as_ref().is_some_and(|u| users.contains(u)) {
entry.has_saved_password = false;
self.config_dirty = true;
}
}
}
}