mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
The Mac app showed one server at a time, and moving to another machine meant Switch Server and a fresh connect. Now every server gets a tab in the title bar, and every tab stays live. - Tab strip: a small bundled page (assets/tabs.html) in its own web view across the top of the window, with the traffic lights inset into it. Each tab has a colour and a status badge: a red count when sessions need you, an amber dot when they are waiting, a blue ring while they work, a dashed circle while the server does not answer. - The status comes from the app, not from the page: one thread per tab reads GET /api/sessions every 2 seconds (macOS stalls hidden web views), backs off for 60 s on a 429 and stops on 401/403, so a wrong password is never retried in a loop. - + or ⌘T opens the connect screen in a new tab. A server that is already open just brings its tab forward instead of opening twice, also when two connects race (checked again when the probe ends). - Double-click or the right-click menu renames a tab; the same menu sets the colour, reloads, reconnects, closes, or closes the others. Tabs can be dragged. A new Server menu carries ⌘T, ⌘W, ⇧⌘T (reopen closed tab, last 10), ⌘0, ⇧⌘[ / ⇧⌘] and ⌘1 to ⌘8 (⌘9 is the last tab once there are nine). Close Window moves to ⇧⌘W. Ctrl+Tab and Alt+1..9 stay with Codeman's own session switching. - ⌘0 shows every server at once (assets/overview.html): the ones that need you first, up to four sessions each with what they ask, and a click on a session opens its tab and selects it. - Tabs are saved in desktop.json (tabs, activeTab) and come back at launch, connecting by themselves when auto-connect is on. - Logins: each tab keeps its own Basic credentials, and a challenge is answered only with the login of the tab whose web view asked (macos.rs binds each WKWebView to its tab). - Cookies: on macOS 14 and later every server gets its own WebKit data store (WKWebsiteDataStore dataStoreForIdentifier, with a stable id hashed from the server URL), so a server on port 3000 and a beta on 5000 of the same host cannot log each other out. Before macOS 14 a second tab on a host that is already open is refused with a message. The server used last before this change keeps the default store, so its per-device settings (font size, skin) survive; every other server starts those fresh once. - The lid logo blink watches all open servers, and Keep This Mac Awake follows the tab that runs on this Mac. New pure model in src/tabs.rs (names, colours, order, shortcuts, strip and overview state) with unit tests; agents.rs reads the per-session detail for the badges and the overview; policy.rs parses the strip and overview IPC and refuses it from any other frame. Browser tests drive both bundled pages (test/desktop-tabs-pages.browser.test.ts) and the connect page's restored-tab path. Not yet run in a real window: the Mac mini has no screen session. The owner checks the traffic-light placement, clicks under the transparent title bar, window drag and zoom from the strip, keyboard focus after strip clicks, and two logged-in servers side by side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3644 lines
144 KiB
Rust
3644 lines
144 KiB
Rust
//! Codeman Desktop: a native window around Codeman servers, one tab each.
|
|
//!
|
|
//! 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`).
|
|
//!
|
|
//! The main window holds a tab per server (`tabs`): a strip across the top that
|
|
//! draws them (`assets/tabs.html`), and below it each tab's own web views, of
|
|
//! which only the tab in front is visible. The others stay loaded, so a switch
|
|
//! is instant, and the strip's badges come from a status read the app makes of
|
|
//! each server itself (`agents::Poller::detail`), since WebKit stalls a hidden
|
|
//! page. An overview of every server (`assets/overview.html`) can take the
|
|
//! place of the tab in front (⌘0).
|
|
//!
|
|
//! Every URL and message decision is in `policy` (pure, unit-tested); this file
|
|
//! only wires those decisions to windows. Each tab moves through
|
|
//! `policy::Phase`: the connect page (Connect), a server load that has not
|
|
//! committed yet (Loading, abandoned after `LOAD_TIMEOUT` or on Cancel; the
|
|
//! server's page loads in a second, hidden web view behind the connect page),
|
|
//! and a committed server page (Loaded). A tab's phase is shared with its web
|
|
//! views' handlers, which run on this same thread; no `RefCell` borrow of it
|
|
//! is ever held across a call into wry or tao. Each server's pages use a
|
|
//! WebKit data store of their own (`tabs::store_id`, macOS 14 and later), so
|
|
//! two servers never share a login cookie, and the challenge handler answers
|
|
//! each tab with that tab's login (`macos::set_credentials`).
|
|
|
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
mod agents;
|
|
mod assets;
|
|
mod config;
|
|
mod credentials;
|
|
mod gui_install;
|
|
mod keep_awake;
|
|
// The pure half is only used by the macOS half and the tests.
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|
mod lid_awake;
|
|
mod local_server;
|
|
mod login;
|
|
mod logo;
|
|
#[cfg(target_os = "macos")]
|
|
mod macos;
|
|
mod menu;
|
|
mod policy;
|
|
mod probe;
|
|
mod setup;
|
|
mod tabs;
|
|
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::{AtomicBool, 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, Rect as ViewRect, WebView, WebViewBuilder};
|
|
|
|
use agents::{Detail, DetailPoll, Poller};
|
|
use config::{Config, Rect, TabEntry, WindowFrame};
|
|
use credentials::Keychain;
|
|
use login::{choose_login, update_keychain, KeychainUpdate, ProbedLogin};
|
|
use logo::{BlinkStyle, Signal};
|
|
use menu::{build_menu, menu_id, AppMenu, TabAction};
|
|
use policy::{
|
|
ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, OverviewCmd, Phase, Recovery, StripCmd, 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 many tabs the window holds at most (as many as are saved).
|
|
const MAX_TABS: usize = config::MAX_TABS;
|
|
/// 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 view 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 view 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;
|
|
/// How many status reads of a server in a row must fail before its tab says
|
|
/// it is not answering.
|
|
const OFFLINE_AFTER: u32 = 2;
|
|
/// How long a tab's status read waits after a 429 before it asks again.
|
|
const RATE_LIMITED_BACKOFF: Duration = Duration::from_secs(60);
|
|
|
|
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 = "An older LogoLight is driving the Apple logo, so Codeman leaves it alone. Update LogoLight to 1.3 or later (or quit it) 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.";
|
|
const ALREADY_OPEN: &str = "That server is already open in another tab.";
|
|
/// Before macOS 14 every page shares one data store, and Codeman's login
|
|
/// cookie has the same name on every port of a host.
|
|
const SAME_HOST_NEEDS_MACOS_14: &str = "Another tab is open on a Codeman of the same machine. On this version of macOS \
|
|
the two would share one login cookie and log each other out, so close that tab first (macOS 14 and later keep them apart).";
|
|
const TOO_MANY_TABS: &str = "The window holds 24 tabs at most. Close one to open another.";
|
|
/// The name of a tab that has no server yet.
|
|
const NEW_TAB_NAME: &str = "New server";
|
|
const OVERVIEW_TITLE: &str = "All Servers";
|
|
|
|
enum UserEvent {
|
|
Menu(MenuEvent),
|
|
/// A message from a tab's connect page, its server page or a pop-out.
|
|
Ipc {
|
|
view: ViewId,
|
|
msg: Ipc,
|
|
},
|
|
Strip(StripCmd),
|
|
Overview(OverviewCmd),
|
|
ProbeDone(Box<ProbeDone>),
|
|
ServerStartDone {
|
|
tab: TabId,
|
|
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 {
|
|
tab: TabId,
|
|
url: Url,
|
|
},
|
|
/// A page opened another site (window.open); it goes to the browser if the
|
|
/// open budget allows.
|
|
OpenExternal(Url),
|
|
/// A server's cookies were cleared before its load (begin_loading).
|
|
CookiesCleared {
|
|
tab: TabId,
|
|
generation: u64,
|
|
url: Url,
|
|
},
|
|
/// What the server said to the login the challenge handler had sent when
|
|
/// it reported a refusal (recheck_login).
|
|
LoginChecked {
|
|
tab: TabId,
|
|
generation: u64,
|
|
outcome: ProbeOutcome,
|
|
},
|
|
Title {
|
|
view: ViewId,
|
|
title: String,
|
|
},
|
|
/// A main-frame navigation committed (wry's PageLoadEvent::Started, from
|
|
/// WebKit's didCommitNavigation): `url` is the view's document now.
|
|
PageCommitted {
|
|
view: ViewId,
|
|
url: String,
|
|
},
|
|
DownloadStarted {
|
|
view: ViewId,
|
|
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 {
|
|
view: ViewId,
|
|
url: String,
|
|
path: Option<PathBuf>,
|
|
ok: bool,
|
|
},
|
|
/// The challenge handler sent a tab's login and got challenged again
|
|
/// (macOS). Not trusted on its own: recheck_login asks the server first.
|
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
|
AuthRejected(TabId),
|
|
/// The web content process of a view ended (macOS): a crash, or the
|
|
/// system taking it back under memory pressure. The view is blank until
|
|
/// something loads in it again (on_content_process_gone).
|
|
ContentProcessGone {
|
|
view: ViewId,
|
|
},
|
|
/// A background step for a tab's connect page finished (This Mac status,
|
|
/// an install opened in Terminal, the install command copied): the message
|
|
/// goes to that page if it is still the one choosing a server.
|
|
ToConnectPage {
|
|
tab: TabId,
|
|
msg: serde_json::Value,
|
|
},
|
|
/// A status read of a tab's server (watch_status), for the login numbered
|
|
/// `generation`.
|
|
TabStatus {
|
|
tab: TabId,
|
|
generation: u64,
|
|
status: StatusUpdate,
|
|
},
|
|
/// 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 removing the lid helper of 1.50.1 was answered,
|
|
/// and its command ran (keep_awake::remove_old_helper).
|
|
OldHelperRemoved(keep_awake::AdminOutcome),
|
|
/// What the closed lid did with the logo last (logo::watch_lid_status).
|
|
LogoLidStatus(String),
|
|
}
|
|
|
|
/// What a status read of a server found.
|
|
enum StatusUpdate {
|
|
Seen(Detail),
|
|
/// Several reads in a row failed.
|
|
Offline,
|
|
/// The server refused the login; nothing is asked any more.
|
|
Refused,
|
|
}
|
|
|
|
struct ProbeDone {
|
|
tab: TabId,
|
|
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,
|
|
}
|
|
|
|
type TabId = u64;
|
|
|
|
/// The app's own number for each web view it builds. A tab's views share the
|
|
/// main window, so the window's id does not tell them apart.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
struct ViewId(u64);
|
|
|
|
/// What a web view is for: it decides its handlers.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Part {
|
|
Strip,
|
|
Overview,
|
|
Connect,
|
|
Server,
|
|
Popout,
|
|
}
|
|
|
|
/// Who owns a web view.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Owner {
|
|
Strip,
|
|
Overview,
|
|
Connect(TabId),
|
|
Server(TabId),
|
|
Popout(WindowId, TabId),
|
|
}
|
|
|
|
/// A web view inside the main window.
|
|
struct ChildView {
|
|
id: ViewId,
|
|
webview: WebView,
|
|
/// The view'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 view last reloaded itself because its session expired.
|
|
session_reload_at: Option<Instant>,
|
|
/// When the view's web content process last ended.
|
|
gone_at: Option<Instant>,
|
|
}
|
|
|
|
impl ChildView {
|
|
fn new(id: ViewId, webview: WebView) -> Self {
|
|
ChildView {
|
|
id,
|
|
webview,
|
|
committed: None,
|
|
session_reload_at: None,
|
|
gone_at: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One server tab.
|
|
struct Tab {
|
|
id: TabId,
|
|
/// The connect page: shows in Connect and Loading, dropped once the
|
|
/// server's page commits.
|
|
connect: Option<ChildView>,
|
|
/// The server's page: loads hidden behind the connect page (Loading), then
|
|
/// is the tab (Loaded).
|
|
server: Option<ChildView>,
|
|
/// Shared with the web view handlers of this tab's views and pop-outs.
|
|
phase: Rc<RefCell<Phase>>,
|
|
/// The server this tab is for: saved for the next launch, kept while the
|
|
/// connect page shows again after a refused login.
|
|
home: Option<Url>,
|
|
/// The name the user gave the tab, else a default (tabs::default_name).
|
|
name: Option<String>,
|
|
color: String,
|
|
/// The server's Codeman version, from the probe.
|
|
version: Option<String>,
|
|
/// The page's own title, for the window title.
|
|
page_title: Option<String>,
|
|
/// The data store of the server view and its pop-outs.
|
|
store: Option<[u8; 16]>,
|
|
/// 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 or a
|
|
/// status read that comes back after another connect does nothing.
|
|
login_generation: u64,
|
|
/// The login generation a recheck is running for, if any.
|
|
login_recheck: Option<u64>,
|
|
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>,
|
|
/// The connect page opens the agent chooser when it next loads.
|
|
add_agent_on_load: bool,
|
|
/// What the connect page does when it asks for its init: show this server,
|
|
/// and connect to it when the flag says so (a tab opened again at launch).
|
|
connect_to: Option<(String, bool)>,
|
|
/// The last status reads of the server.
|
|
seen: tabs::Seen,
|
|
/// The thread that reads the server's status (watch_status).
|
|
watch: Option<StatusWatch>,
|
|
/// Whether the login of this connect can read the server's status. Not
|
|
/// for a one-time password that Codeman's own page has the user change
|
|
/// first: the server answers every other request 403 until then, and the
|
|
/// new password is never seen by the app.
|
|
status_login: bool,
|
|
}
|
|
|
|
impl Tab {
|
|
fn kind(&self) -> tabs::Kind {
|
|
match &*self.phase.borrow() {
|
|
Phase::Connect => tabs::Kind::Connect,
|
|
Phase::Loading(_) => tabs::Kind::Loading,
|
|
Phase::Loaded(_) => tabs::Kind::Server,
|
|
}
|
|
}
|
|
|
|
/// The server the tab is about: loaded or loading, else its home.
|
|
fn shown_url(&self) -> Option<Url> {
|
|
self.phase.borrow().server().cloned().or_else(|| self.home.clone())
|
|
}
|
|
|
|
fn display_name(&self) -> String {
|
|
match (&self.name, self.shown_url()) {
|
|
(Some(name), _) => name.clone(),
|
|
(None, Some(url)) => tabs::default_name(&url),
|
|
(None, None) => NEW_TAB_NAME.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The view that shows when this tab is in front.
|
|
fn front(&self) -> Option<&ChildView> {
|
|
match (self.phase.borrow().loaded(), &self.server, &self.connect) {
|
|
(Some(_), Some(server), _) => Some(server),
|
|
(_, _, Some(connect)) => Some(connect),
|
|
(_, server, None) => server.as_ref(),
|
|
}
|
|
}
|
|
|
|
fn views(&self) -> impl Iterator<Item = &ChildView> {
|
|
self.connect.iter().chain(self.server.iter())
|
|
}
|
|
|
|
fn entry(&self) -> Option<TabEntry> {
|
|
Some(TabEntry {
|
|
url: self.home.as_ref()?.to_string(),
|
|
name: self.name.clone(),
|
|
color: Some(self.color.clone()),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Stops its status thread when dropped.
|
|
struct StatusWatch {
|
|
stop: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl Drop for StatusWatch {
|
|
fn drop(&mut self) {
|
|
self.stop.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
/// Reads a server's agent sessions every `agents::POLL_EVERY` on a thread of
|
|
/// its own, for a tab's badges and the overview. Ends when the watch is
|
|
/// dropped or the server refuses the login.
|
|
fn watch_status(
|
|
proxy: &EventLoopProxy<UserEvent>,
|
|
tab: TabId,
|
|
generation: u64,
|
|
server: &Url,
|
|
authorization: Option<String>,
|
|
) -> Option<StatusWatch> {
|
|
let mut poller = Poller::new(server, authorization, true)?;
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let stopped = stop.clone();
|
|
let proxy = proxy.clone();
|
|
std::thread::Builder::new()
|
|
.name("tab-status".into())
|
|
.spawn(move || {
|
|
let mut failures = 0u32;
|
|
while !stopped.load(Ordering::SeqCst) {
|
|
let status = match poller.detail() {
|
|
DetailPoll::Seen(detail) => {
|
|
failures = 0;
|
|
Some(StatusUpdate::Seen(detail))
|
|
}
|
|
DetailPoll::Failed => {
|
|
failures += 1;
|
|
(failures == OFFLINE_AFTER).then_some(StatusUpdate::Offline)
|
|
}
|
|
DetailPoll::Refused => Some(StatusUpdate::Refused),
|
|
// Too many failed logins from this address (other clients'
|
|
// too, on a multi-user server): ask again much later, and
|
|
// keep what the tab shows meanwhile.
|
|
DetailPoll::RateLimited => {
|
|
sleep_unless_stopped(&stopped, RATE_LIMITED_BACKOFF);
|
|
None
|
|
}
|
|
};
|
|
let refused = matches!(status, Some(StatusUpdate::Refused));
|
|
if let Some(status) = status {
|
|
if stopped.load(Ordering::SeqCst)
|
|
|| proxy
|
|
.send_event(UserEvent::TabStatus {
|
|
tab,
|
|
generation,
|
|
status,
|
|
})
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
if refused {
|
|
return;
|
|
}
|
|
sleep_unless_stopped(&stopped, agents::POLL_EVERY);
|
|
}
|
|
})
|
|
.ok()?;
|
|
Some(StatusWatch { stop })
|
|
}
|
|
|
|
/// Sleeps `total`, a second at a time, and stops early once `stopped` is set.
|
|
fn sleep_unless_stopped(stopped: &AtomicBool, total: Duration) {
|
|
let step = Duration::from_secs(1);
|
|
let mut left = total;
|
|
while !left.is_zero() && !stopped.load(Ordering::SeqCst) {
|
|
let nap = left.min(step);
|
|
std::thread::sleep(nap);
|
|
left -= nap;
|
|
}
|
|
}
|
|
|
|
/// A pop-out: an app window of its own, for one of the tab's server pages.
|
|
struct Popout {
|
|
// Declared first so it is dropped before its window.
|
|
webview: WebView,
|
|
window: Window,
|
|
view: ViewId,
|
|
tab: TabId,
|
|
/// 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,
|
|
committed: Option<Url>,
|
|
session_reload_at: Option<Instant>,
|
|
gone_at: Option<Instant>,
|
|
}
|
|
|
|
enum Load<'a> {
|
|
/// The connect page; `manual` keeps it from connecting by itself.
|
|
ConnectPage {
|
|
manual: bool,
|
|
},
|
|
/// One of the app's other pages (the strip, the overview).
|
|
Bundled(&'static str),
|
|
Server {
|
|
url: &'a str,
|
|
auth: Option<&'a str>,
|
|
},
|
|
/// Nothing yet: a server view waits for its cookies to be cleared.
|
|
Nothing,
|
|
}
|
|
|
|
/// Where a web view goes and what it is.
|
|
struct ViewSpec<'a> {
|
|
id: ViewId,
|
|
part: Part,
|
|
tab: TabId,
|
|
/// The tab's phase (connect, server and pop-out views).
|
|
phase: Option<Rc<RefCell<Phase>>>,
|
|
load: Load<'a>,
|
|
/// The data store: None for WebKit's default one.
|
|
store: Option<[u8; 16]>,
|
|
/// For a view inside the main window: its frame, in points. None fills a
|
|
/// window of its own (a pop-out).
|
|
bounds: Option<(f64, f64, f64, f64)>,
|
|
visible: bool,
|
|
/// The view takes the keyboard and brings the app to the front (wry's
|
|
/// default). False for a window a script opened, and every hidden view.
|
|
focused: bool,
|
|
}
|
|
|
|
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,
|
|
main_window: Window,
|
|
strip: ChildView,
|
|
/// Built the first time it is asked for.
|
|
overview: Option<ChildView>,
|
|
overview_shown: bool,
|
|
tabs: Vec<Tab>,
|
|
/// The tab in front, an index into `tabs`.
|
|
active: usize,
|
|
next_tab: TabId,
|
|
next_view: u64,
|
|
/// Tabs closed this run, newest last, for Reopen Closed Tab.
|
|
closed: Vec<TabEntry>,
|
|
popouts: HashMap<WindowId, Popout>,
|
|
/// The strip, the overview and the Server menu need drawing again; done
|
|
/// once per turn of the event loop.
|
|
strip_dirty: bool,
|
|
overview_dirty: bool,
|
|
menu_dirty: bool,
|
|
/// 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 view and URL, oldest first.
|
|
downloads: HashMap<(ViewId, 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>,
|
|
/// 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)>,
|
|
/// What the strip was last told about full screen.
|
|
fullscreen: bool,
|
|
/// The child that watches the open servers and blinks the logo.
|
|
logo_watch: logo::Watcher,
|
|
/// The worker that keeps this Mac awake while its agents work.
|
|
keep_awake: keep_awake::KeepAwake,
|
|
/// The administrator prompt for removing the old lid helper is open.
|
|
old_helper_busy: bool,
|
|
/// Shown by the first connect page that asks for its init.
|
|
launch_notice: Option<String>,
|
|
}
|
|
|
|
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();
|
|
}
|
|
// So is the lid keeper (lid_awake.rs).
|
|
if std::env::args().nth(1).as_deref() == Some(lid_awake::LID_ARG) {
|
|
lid_awake::child_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 |tab| {
|
|
let _ = auth_proxy.send_event(UserEvent::AuthRejected(tab));
|
|
}));
|
|
}
|
|
|
|
let config_path = Config::default_path();
|
|
let (mut 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 launch_notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
|
|
// Before any tab opens: which server keeps the store it had before tabs.
|
|
let store_settled = config.settle_default_store();
|
|
logo::recover_at_launch();
|
|
// A lid keeper that was killed left lid sleep off; and 1.50.1's helper, where
|
|
// it is still installed, gets nothing to act on.
|
|
lid_awake::heal();
|
|
keep_awake::forget_legacy_request();
|
|
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
|
|
// 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 (width, _) = content_size(&window);
|
|
let strip_spec = ViewSpec {
|
|
id: ViewId(0),
|
|
part: Part::Strip,
|
|
tab: 0,
|
|
phase: None,
|
|
load: Load::Bundled(policy::STRIP_PAGE),
|
|
store: None,
|
|
bounds: Some((0.0, 0.0, width, tabs::STRIP_HEIGHT)),
|
|
visible: true,
|
|
focused: false,
|
|
};
|
|
let strip = build_webview(&window, strip_spec, &proxy)
|
|
.unwrap_or_else(|e| fatal(&format!("Could not create the web view: {e}")));
|
|
|
|
let keep_awake_proxy = proxy.clone();
|
|
let mut app = App {
|
|
proxy,
|
|
config,
|
|
config_path,
|
|
config_dirty: store_settled,
|
|
save_held_since: Instant::now(),
|
|
main_window: window,
|
|
strip: ChildView::new(ViewId(0), strip),
|
|
overview: None,
|
|
overview_shown: false,
|
|
tabs: Vec::new(),
|
|
active: 0,
|
|
next_tab: 1,
|
|
next_view: 1,
|
|
closed: Vec::new(),
|
|
popouts: HashMap::new(),
|
|
strip_dirty: true,
|
|
overview_dirty: false,
|
|
menu_dirty: true,
|
|
open_budget: OpenBudget::new(),
|
|
script_window_budget: OpenBudget::new(),
|
|
downloads: HashMap::new(),
|
|
menu,
|
|
keychain,
|
|
install_reader: Arc::new(AtomicU64::new(0)),
|
|
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
|
|
fullscreen: wants_fullscreen,
|
|
logo_watch: logo::Watcher::default(),
|
|
keep_awake: keep_awake::KeepAwake::new(move |generation, text| {
|
|
let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text });
|
|
}),
|
|
old_helper_busy: false,
|
|
launch_notice,
|
|
};
|
|
app.open_launch_tabs();
|
|
|
|
if app.menu.logo_lid.is_some() {
|
|
let lid_proxy = app.proxy.clone();
|
|
logo::watch_lid_status(move |line| {
|
|
let _ = lid_proxy.send_event(UserEvent::LogoLidStatus(line));
|
|
});
|
|
}
|
|
|
|
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 {
|
|
let builder = WindowBuilder::new()
|
|
.with_title(APP_NAME)
|
|
.with_theme(Some(Theme::Dark))
|
|
.with_min_inner_size(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1));
|
|
// The app has tabs of its own: macOS's automatic window tabbing would
|
|
// merge pop-outs into a tab bar of the system's (in full screen above all).
|
|
#[cfg(target_os = "macos")]
|
|
let builder = {
|
|
use tao::platform::macos::WindowBuilderExtMacOS;
|
|
builder.with_automatic_window_tabbing(false)
|
|
};
|
|
builder
|
|
}
|
|
|
|
/// 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's content area in points. On macOS the content runs under
|
|
/// the transparent title bar, so this is the whole window.
|
|
fn content_size(window: &Window) -> (f64, f64) {
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(size) = macos::content_size(window) {
|
|
return size;
|
|
}
|
|
let size = window.inner_size().to_logical::<f64>(window.scale_factor());
|
|
(size.width, size.height)
|
|
}
|
|
|
|
/// The main window, and whether it was asked into full screen (App's
|
|
/// `fullscreen_retry` then makes sure it gets there). On macOS its content runs
|
|
/// under a transparent title bar: the tab strip is the title bar, with the
|
|
/// traffic lights moved down to sit centred in it.
|
|
fn create_main_window(
|
|
target: &EventLoopWindowTarget<UserEvent>,
|
|
config: &Config,
|
|
) -> Result<(Window, bool), tao::error::OsError> {
|
|
let mut builder = window_builder();
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use tao::platform::macos::WindowBuilderExtMacOS;
|
|
builder = builder
|
|
.with_titlebar_transparent(true)
|
|
.with_fullsize_content_view(true)
|
|
.with_title_hidden(true)
|
|
.with_traffic_light_inset(LogicalPosition::new(tabs::TRAFFIC_LIGHTS.0, tabs::TRAFFIC_LIGHTS.1));
|
|
}
|
|
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 tab `tab`'s 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 tab's refusal report.
|
|
fn set_challenge_login(tab: TabId, login: Option<(&Url, &str, &str)>) {
|
|
#[cfg(target_os = "macos")]
|
|
macos::set_credentials(
|
|
tab,
|
|
login.map(|(url, username, password)| macos::BasicCredentials {
|
|
server: url.clone(),
|
|
username: username.to_string(),
|
|
password: password.to_string(),
|
|
}),
|
|
);
|
|
#[cfg(not(target_os = "macos"))]
|
|
let _ = (tab, login);
|
|
}
|
|
|
|
/// Remove the server's cookies from the store its pages use, 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>,
|
|
tab: TabId,
|
|
generation: u64,
|
|
url: Url,
|
|
store: Option<[u8; 16]>,
|
|
) {
|
|
let host = url.host_str().unwrap_or_default().to_string();
|
|
let proxy = proxy.clone();
|
|
let done = move || {
|
|
let _ = proxy.send_event(UserEvent::CookiesCleared { tab, generation, url });
|
|
};
|
|
#[cfg(target_os = "macos")]
|
|
macos::clear_cookies(store, host, Box::new(done));
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
let _ = (store, host);
|
|
done();
|
|
}
|
|
}
|
|
|
|
/// Whether each server can have a data store of its own here (macOS 14 and
|
|
/// later). Without it every page shares WebKit's default store.
|
|
fn own_stores() -> bool {
|
|
#[cfg(target_os = "macos")]
|
|
return macos::own_stores_available();
|
|
#[cfg(not(target_os = "macos"))]
|
|
return false;
|
|
}
|
|
|
|
/// Build a web view with every handler its part needs (`spec.part`): the
|
|
/// strip and the overview listen only to their own page and go nowhere else;
|
|
/// a tab's connect page and server page, and a pop-out, go through `policy`
|
|
/// with the tab's phase.
|
|
fn build_webview(window: &Window, spec: ViewSpec<'_>, proxy: &EventLoopProxy<UserEvent>) -> wry::Result<WebView> {
|
|
let ViewSpec {
|
|
id,
|
|
part,
|
|
tab,
|
|
phase,
|
|
load,
|
|
store,
|
|
bounds,
|
|
visible,
|
|
focused,
|
|
} = spec;
|
|
let phase = phase.unwrap_or_else(|| Rc::new(RefCell::new(Phase::Connect)));
|
|
let mut builder = WebViewBuilder::new()
|
|
.with_devtools(true)
|
|
.with_focused(focused)
|
|
.with_visible(visible)
|
|
.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 matches!(part, Part::Server | Part::Popout) {
|
|
builder = builder.with_initialization_script(assets::HOST_SHIM);
|
|
}
|
|
if let Some((x, y, w, h)) = bounds {
|
|
builder = builder.with_bounds(ViewRect {
|
|
position: LogicalPosition::new(x, y).into(),
|
|
size: LogicalSize::new(w, h).into(),
|
|
});
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(store) = store {
|
|
use wry::WebViewBuilderExtDarwin;
|
|
builder = builder.with_data_store_identifier(store);
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
let _ = store;
|
|
// The app's own pages come from its own scheme; a server's pages and
|
|
// pop-outs never load from it.
|
|
if matches!(part, Part::Strip | Part::Overview | Part::Connect) {
|
|
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| match part {
|
|
Part::Strip => policy::bundled_navigation(&target, policy::STRIP_PAGE) == Navigation::Allow,
|
|
Part::Overview => policy::bundled_navigation(&target, policy::OVERVIEW_PAGE) == Navigation::Allow,
|
|
// The connect page goes nowhere: its links leave through IPC.
|
|
Part::Connect => nav_phase.try_borrow().is_ok_and(|phase| {
|
|
phase.connect_page_live()
|
|
&& Url::parse(&target).is_ok_and(|u| u.scheme() == policy::APP_SCHEME)
|
|
&& policy::navigation(&target, &phase) == Navigation::Allow
|
|
}),
|
|
Part::Server | Part::Popout => nav_phase.try_borrow().is_ok_and(|phase| {
|
|
!target.starts_with(policy::APP_SCHEME) && 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| {
|
|
if matches!(part, Part::Server | Part::Popout) {
|
|
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 { tab, 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 event = match part {
|
|
Part::Strip => policy::parse_strip_ipc(&frame_url, request.body()).map(UserEvent::Strip),
|
|
Part::Overview => policy::parse_overview_ipc(&frame_url, request.body()).map(UserEvent::Overview),
|
|
Part::Connect | Part::Server | Part::Popout => {
|
|
let is_tab_view = part != Part::Popout;
|
|
let msg = match ipc_phase.try_borrow() {
|
|
Ok(phase) => policy::parse_ipc(&frame_url, request.body(), &phase, is_tab_view),
|
|
Err(_) => None,
|
|
};
|
|
// A server page speaks only as a server page, the connect page
|
|
// only as the connect page.
|
|
msg.filter(|msg| match msg {
|
|
Ipc::Connect(_) => part == Part::Connect,
|
|
Ipc::Host(_) => part != Part::Connect,
|
|
})
|
|
.map(|msg| UserEvent::Ipc { view: id, msg })
|
|
}
|
|
};
|
|
if let Some(event) = event {
|
|
let _ = ipc_proxy.send_event(event);
|
|
}
|
|
});
|
|
|
|
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) || matches!(part, Part::Strip | Part::Overview) {
|
|
return;
|
|
}
|
|
// Started is didCommitNavigation. A server view'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 part == Part::Server {
|
|
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 { view: id, url });
|
|
});
|
|
|
|
if matches!(part, Part::Server | Part::Popout) {
|
|
let title_proxy = proxy.clone();
|
|
builder = builder.with_document_title_changed_handler(move |title| {
|
|
let _ = title_proxy.send_event(UserEvent::Title { view: 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 {
|
|
view: 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 {
|
|
view: id,
|
|
url,
|
|
path,
|
|
ok,
|
|
});
|
|
});
|
|
} else {
|
|
// The app's own pages download nothing.
|
|
builder = builder.with_download_started_handler(|_, _| false);
|
|
}
|
|
|
|
// wry answers WebKit's "web content process ended" notice itself, which
|
|
// turns WebKit's own reload off: without a handler the view 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 { view: id });
|
|
});
|
|
}
|
|
|
|
builder = match load {
|
|
Load::ConnectPage { manual } => builder.with_url(connect_page_url(manual)),
|
|
Load::Bundled(page) => builder.with_url(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),
|
|
Load::Nothing => builder,
|
|
};
|
|
let webview = match bounds {
|
|
Some(_) => builder.build_as_child(window)?,
|
|
None => builder.build(window)?,
|
|
};
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
macos::install(&webview);
|
|
// Only a server page or pop-out ever gets a login, and only its tab's.
|
|
if matches!(part, Part::Server | Part::Popout) {
|
|
macos::bind_view(&webview, tab);
|
|
}
|
|
}
|
|
Ok(webview)
|
|
}
|
|
|
|
/// The connect page's address; `manual` keeps it from connecting by itself.
|
|
fn connect_page_url(manual: bool) -> String {
|
|
if manual {
|
|
format!("{}?manual=1", policy::CONNECT_PAGE)
|
|
} else {
|
|
policy::CONNECT_PAGE.to_string()
|
|
}
|
|
}
|
|
|
|
/// 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, control_flow),
|
|
// 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. An app hidden by closing its window
|
|
// (`hide_app`) needs nothing here: AppKit unhides it, with every
|
|
// window as it was, before it reports the click.
|
|
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 timed_out: Vec<TabId> = self
|
|
.tabs
|
|
.iter_mut()
|
|
.filter_map(|t| {
|
|
if !matches!(*t.phase.borrow(), Phase::Loading(_)) {
|
|
t.loading_deadline = None;
|
|
}
|
|
t.loading_deadline.is_some_and(|due| now >= due).then_some(t.id)
|
|
})
|
|
.collect();
|
|
for tab in timed_out {
|
|
self.show_connect_page(tab, Some(LOAD_TIMED_OUT.into()), false);
|
|
}
|
|
if self.fullscreen_retry.is_some_and(|(due, _)| now >= due) {
|
|
self.retry_fullscreen(now);
|
|
}
|
|
self.flush();
|
|
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);
|
|
let loading_due = self.tabs.iter().filter_map(|t| t.loading_deadline).min();
|
|
*control_flow = match [save_due, loading_due, fullscreen_due].into_iter().flatten().min() {
|
|
Some(due) => ControlFlow::WaitUntil(due),
|
|
None => ControlFlow::Wait,
|
|
};
|
|
}
|
|
|
|
/// Draws again what changed this turn: the strip, the overview while it
|
|
/// shows, the Server menu's tab items.
|
|
fn flush(&mut self) {
|
|
let fullscreen = self.main_window.fullscreen().is_some();
|
|
if fullscreen != self.fullscreen {
|
|
self.fullscreen = fullscreen;
|
|
self.strip_dirty = true;
|
|
}
|
|
if std::mem::take(&mut self.strip_dirty) {
|
|
let msg = {
|
|
let views = self.tab_views();
|
|
tabs::strip_state(&views, Some(self.active), self.overview_shown, self.fullscreen)
|
|
};
|
|
let _ = self
|
|
.strip
|
|
.webview
|
|
.evaluate_script(&assets::native_message_script("__codemanTabs", &msg));
|
|
}
|
|
if std::mem::take(&mut self.overview_dirty) {
|
|
if let Some(overview) = &self.overview {
|
|
let msg = tabs::overview_state(&self.tab_views());
|
|
let _ = overview
|
|
.webview
|
|
.evaluate_script(&assets::native_message_script("__codemanOverview", &msg));
|
|
}
|
|
}
|
|
if std::mem::take(&mut self.menu_dirty) {
|
|
let items: Vec<(String, bool)> = self
|
|
.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, t)| (t.display_name(), !self.overview_shown && i == self.active))
|
|
.collect();
|
|
self.menu.set_tabs(&items);
|
|
self.menu.reopen.set_enabled(!self.closed.is_empty());
|
|
}
|
|
}
|
|
|
|
/// Every tab as the strip and the overview see it.
|
|
fn tab_views(&self) -> Vec<tabs::View<'_>> {
|
|
self.tabs
|
|
.iter()
|
|
.map(|t| tabs::View {
|
|
id: t.id,
|
|
name: t.display_name(),
|
|
color: &t.color,
|
|
kind: t.kind(),
|
|
url: t.home.as_ref(),
|
|
version: t.version.as_deref(),
|
|
seen: &t.seen,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The tabs that change, drawn again at the end of this turn.
|
|
fn tabs_changed(&mut self) {
|
|
self.strip_dirty = true;
|
|
self.overview_dirty = true;
|
|
self.menu_dirty = true;
|
|
}
|
|
|
|
/// 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 w = &self.main_window;
|
|
if w.fullscreen().is_some() || w.is_minimized() || attempts >= FULLSCREEN_ATTEMPTS {
|
|
return;
|
|
}
|
|
w.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,
|
|
) {
|
|
let main = id == self.main_window.id();
|
|
match event {
|
|
WindowEvent::CloseRequested if !main => self.remove_popout(id),
|
|
WindowEvent::CloseRequested => self.close_main_window(target, control_flow),
|
|
WindowEvent::Moved(_) if main => self.record_main_frame(),
|
|
WindowEvent::Resized(_) if main => {
|
|
self.record_main_frame();
|
|
self.layout();
|
|
self.strip_dirty = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// The red button or Close Window on the main window.
|
|
fn close_main_window(&mut self, target: &EventLoopWindowTarget<UserEvent>, control_flow: &mut ControlFlow) {
|
|
self.record_main_frame();
|
|
self.save_config();
|
|
let mode = self.config.keep_awake.mode;
|
|
let local = self.local_server();
|
|
if keep_awake::available() && keep_awake::close_hides_app(mode, local.as_ref(), keep_awake::local_addresses) {
|
|
// Keep This Mac Awake ends with the app, so closing the window
|
|
// while it watches a Codeman on this Mac 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.
|
|
hide_app(target);
|
|
} else {
|
|
// Closing the main window quits; the servers and their sessions
|
|
// keep running, so nothing is lost.
|
|
self.keep_awake.shutdown();
|
|
*control_flow = ControlFlow::Exit;
|
|
}
|
|
}
|
|
|
|
fn on_user_event(
|
|
&mut self,
|
|
event: UserEvent,
|
|
target: &EventLoopWindowTarget<UserEvent>,
|
|
control_flow: &mut ControlFlow,
|
|
) {
|
|
match event {
|
|
UserEvent::Menu(e) => self.on_menu(e.id.0.as_str(), target, control_flow),
|
|
UserEvent::Ipc { view, msg } => match (self.owner(view), msg) {
|
|
(Some(Owner::Connect(tab)), Ipc::Connect(cmd)) => self.on_connect_cmd(tab, cmd),
|
|
(Some(owner @ (Owner::Server(_) | Owner::Popout(..))), Ipc::Host(cmd)) => {
|
|
self.on_host_cmd(view, owner, cmd, target)
|
|
}
|
|
_ => {}
|
|
},
|
|
UserEvent::Strip(cmd) => self.on_strip_cmd(cmd),
|
|
UserEvent::Overview(cmd) => self.on_overview_cmd(cmd),
|
|
UserEvent::ProbeDone(done) => self.on_probe_done(*done),
|
|
UserEvent::ToConnectPage { tab, msg } => {
|
|
if self.choosing(tab) {
|
|
self.to_connect_page(tab, msg);
|
|
}
|
|
}
|
|
UserEvent::LogoLidStatus(line) => self.show_lid_status(line),
|
|
UserEvent::ServerStartDone {
|
|
tab,
|
|
generation,
|
|
url,
|
|
result,
|
|
} => {
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
t.starting_server = None;
|
|
if generation != t.start_generation {
|
|
return; // cancelled
|
|
}
|
|
let ok = result.is_ok();
|
|
let output = result.unwrap_or_else(|e| e);
|
|
self.to_connect_page(
|
|
tab,
|
|
json!({
|
|
"type": "serverStartResult",
|
|
"ok": ok,
|
|
"output": output,
|
|
"url": url.as_str(),
|
|
}),
|
|
);
|
|
}
|
|
UserEvent::OpenAppWindow { tab, 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, tab, &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 { tab, generation, url } => {
|
|
// Only the load this clear was for, and only while it is
|
|
// still wanted: a Cancel, a timeout, a closed tab or another
|
|
// connect in between moved the generation or the phase on.
|
|
let Some(t) = self.tab(tab) else {
|
|
return;
|
|
};
|
|
let current = generation == t.probe_generation
|
|
&& matches!(&*t.phase.borrow(), Phase::Loading(loading) if *loading == url);
|
|
if current {
|
|
if let Some(view) = t.server.as_ref().map(|v| v.id) {
|
|
self.load_in(view, &policy::webkit_safe_url(&url), true);
|
|
}
|
|
}
|
|
}
|
|
UserEvent::LoginChecked {
|
|
tab,
|
|
generation,
|
|
outcome,
|
|
} => self.on_login_checked(tab, generation, outcome),
|
|
UserEvent::Title { view, title } => self.on_title(view, title),
|
|
UserEvent::PageCommitted { view, url } => self.on_page_committed(view, url),
|
|
UserEvent::DownloadStarted { view, 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(Owner::Popout(window, _)) = self.owner(view) {
|
|
if let Some(p) = self.popouts.get_mut(&window) {
|
|
if p.opened_url == url {
|
|
p.hidden_for_download = true;
|
|
p.window.set_visible(false);
|
|
}
|
|
}
|
|
}
|
|
self.downloads
|
|
.entry((view, url))
|
|
.or_default()
|
|
.push_back(file_name.unwrap_or_else(|| UNNAMED_FILE.into()));
|
|
}
|
|
UserEvent::DownloadDone { view, url, path, ok } => self.on_download_done(view, url, path, ok),
|
|
UserEvent::AuthRejected(tab) => self.recheck_login(tab),
|
|
UserEvent::ContentProcessGone { view } => self.on_content_process_gone(view),
|
|
UserEvent::TabStatus {
|
|
tab,
|
|
generation,
|
|
status,
|
|
} => self.on_tab_status(tab, generation, status),
|
|
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::OldHelperRemoved(outcome) => self.on_old_helper_removed(outcome),
|
|
}
|
|
}
|
|
|
|
// ---- tabs and views ----
|
|
|
|
fn tab_index(&self, id: TabId) -> Option<usize> {
|
|
self.tabs.iter().position(|t| t.id == id)
|
|
}
|
|
|
|
fn tab(&self, id: TabId) -> Option<&Tab> {
|
|
self.tabs.iter().find(|t| t.id == id)
|
|
}
|
|
|
|
fn tab_mut(&mut self, id: TabId) -> Option<&mut Tab> {
|
|
self.tabs.iter_mut().find(|t| t.id == id)
|
|
}
|
|
|
|
fn active_tab(&self) -> Option<&Tab> {
|
|
self.tabs.get(self.active)
|
|
}
|
|
|
|
/// Who a web view belongs to.
|
|
fn owner(&self, view: ViewId) -> Option<Owner> {
|
|
if view == self.strip.id {
|
|
return Some(Owner::Strip);
|
|
}
|
|
if self.overview.as_ref().is_some_and(|o| o.id == view) {
|
|
return Some(Owner::Overview);
|
|
}
|
|
for t in &self.tabs {
|
|
if t.connect.as_ref().is_some_and(|v| v.id == view) {
|
|
return Some(Owner::Connect(t.id));
|
|
}
|
|
if t.server.as_ref().is_some_and(|v| v.id == view) {
|
|
return Some(Owner::Server(t.id));
|
|
}
|
|
}
|
|
self.popouts
|
|
.iter()
|
|
.find(|(_, p)| p.view == view)
|
|
.map(|(w, p)| Owner::Popout(*w, p.tab))
|
|
}
|
|
|
|
/// The web view `view` is, wherever it lives.
|
|
fn webview(&self, view: ViewId) -> Option<&WebView> {
|
|
if view == self.strip.id {
|
|
return Some(&self.strip.webview);
|
|
}
|
|
if let Some(o) = self.overview.as_ref().filter(|o| o.id == view) {
|
|
return Some(&o.webview);
|
|
}
|
|
self.tabs
|
|
.iter()
|
|
.flat_map(Tab::views)
|
|
.find(|v| v.id == view)
|
|
.map(|v| &v.webview)
|
|
.or_else(|| self.popouts.values().find(|p| p.view == view).map(|p| &p.webview))
|
|
}
|
|
|
|
fn child_view_mut(&mut self, view: ViewId) -> Option<&mut ChildView> {
|
|
self.tabs
|
|
.iter_mut()
|
|
.flat_map(|t| t.connect.iter_mut().chain(t.server.iter_mut()))
|
|
.find(|v| v.id == view)
|
|
}
|
|
|
|
fn next_view_id(&mut self) -> ViewId {
|
|
let id = ViewId(self.next_view);
|
|
self.next_view += 1;
|
|
id
|
|
}
|
|
|
|
/// Where a tab's views and the overview go: everything below the strip.
|
|
fn content_bounds(&self) -> (f64, f64, f64, f64) {
|
|
let (width, height) = content_size(&self.main_window);
|
|
(0.0, tabs::STRIP_HEIGHT, width, (height - tabs::STRIP_HEIGHT).max(0.0))
|
|
}
|
|
|
|
/// Build a view inside the main window, below the strip, hidden.
|
|
fn build_child(
|
|
&mut self,
|
|
part: Part,
|
|
tab: TabId,
|
|
phase: Option<Rc<RefCell<Phase>>>,
|
|
load: Load<'_>,
|
|
store: Option<[u8; 16]>,
|
|
) -> Option<ChildView> {
|
|
let id = self.next_view_id();
|
|
let spec = ViewSpec {
|
|
id,
|
|
part,
|
|
tab,
|
|
phase,
|
|
load,
|
|
store,
|
|
bounds: Some(self.content_bounds()),
|
|
visible: false,
|
|
focused: false,
|
|
};
|
|
match build_webview(&self.main_window, spec, &self.proxy) {
|
|
Ok(webview) => Some(ChildView::new(id, webview)),
|
|
Err(e) => {
|
|
eprintln!("codeman-desktop: could not create a web view: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every view in its place after the window changed size.
|
|
fn layout(&self) {
|
|
let (x, y, w, h) = self.content_bounds();
|
|
let rect = |x: f64, y: f64, w: f64, h: f64| ViewRect {
|
|
position: LogicalPosition::new(x, y).into(),
|
|
size: LogicalSize::new(w, h).into(),
|
|
};
|
|
let _ = self.strip.webview.set_bounds(rect(0.0, 0.0, w, tabs::STRIP_HEIGHT));
|
|
for view in self.tabs.iter().flat_map(Tab::views).chain(self.overview.iter()) {
|
|
let _ = view.webview.set_bounds(rect(x, y, w, h));
|
|
}
|
|
}
|
|
|
|
/// Show the view of the tab in front (or the overview) and hide the rest.
|
|
/// `focus`: the shown view takes the keyboard.
|
|
fn show_front(&mut self, focus: bool) {
|
|
let front = match self.overview_shown {
|
|
true => self.overview.as_ref().map(|o| o.id),
|
|
false => self.active_tab().and_then(Tab::front).map(|v| v.id),
|
|
};
|
|
for view in self.tabs.iter().flat_map(Tab::views).chain(self.overview.iter()) {
|
|
let _ = view.webview.set_visible(Some(view.id) == front);
|
|
}
|
|
if focus {
|
|
if let Some(webview) = front.and_then(|id| self.webview(id)) {
|
|
let _ = webview.focus();
|
|
}
|
|
}
|
|
self.update_title();
|
|
self.tabs_changed();
|
|
}
|
|
|
|
fn update_title(&self) {
|
|
let title = match (self.overview_shown, self.active_tab()) {
|
|
(true, _) => OVERVIEW_TITLE.to_string(),
|
|
(false, Some(t)) if t.kind() == tabs::Kind::Server => {
|
|
tabs::window_title(t.page_title.as_deref(), &t.display_name())
|
|
}
|
|
_ => APP_NAME.to_string(),
|
|
};
|
|
self.main_window.set_title(&title);
|
|
}
|
|
|
|
/// The tabs of the last run, or one tab with the connect page.
|
|
fn open_launch_tabs(&mut self) {
|
|
let saved = self.config.tabs.clone();
|
|
let auto = self.config.auto_connect;
|
|
for entry in saved {
|
|
self.create_tab(Some(entry), auto, true);
|
|
}
|
|
if self.tabs.is_empty() {
|
|
// Before tabs, and on a first run: the connect page as it always
|
|
// was, connecting to the server used last on its own.
|
|
self.create_tab(None, false, false);
|
|
}
|
|
self.active = self.config.active_tab.min(self.tabs.len().saturating_sub(1));
|
|
if let Some(t) = self.tabs.first_mut() {
|
|
t.notice = self.launch_notice.take();
|
|
}
|
|
self.show_front(true);
|
|
}
|
|
|
|
/// A new tab with the connect page. `entry`: a server it is for (a tab
|
|
/// opened again), shown on the page and connected to when `connect_now`.
|
|
/// `manual`: the page does not connect on its own.
|
|
fn create_tab(&mut self, entry: Option<TabEntry>, connect_now: bool, manual: bool) -> Option<TabId> {
|
|
if self.tabs.len() >= MAX_TABS {
|
|
return None;
|
|
}
|
|
let id = self.next_tab;
|
|
self.next_tab += 1;
|
|
let phase = Rc::new(RefCell::new(Phase::Connect));
|
|
let connect = self.build_child(
|
|
Part::Connect,
|
|
id,
|
|
Some(phase.clone()),
|
|
Load::ConnectPage { manual },
|
|
None,
|
|
)?;
|
|
let home = entry.as_ref().and_then(|e| Url::parse(&e.url).ok());
|
|
let color = entry
|
|
.as_ref()
|
|
.and_then(|e| e.color.clone())
|
|
.filter(|c| tabs::is_color(c))
|
|
.unwrap_or_else(|| tabs::next_color(self.tabs.iter().map(|t| t.color.as_str())));
|
|
self.tabs.push(Tab {
|
|
id,
|
|
connect: Some(connect),
|
|
server: None,
|
|
phase,
|
|
connect_to: entry.as_ref().map(|e| (e.url.clone(), connect_now)),
|
|
home,
|
|
name: entry.and_then(|e| e.name),
|
|
color,
|
|
version: None,
|
|
page_title: None,
|
|
store: None,
|
|
loading_deadline: None,
|
|
auth: None,
|
|
login: None,
|
|
login_generation: 0,
|
|
login_recheck: None,
|
|
probe_generation: 0,
|
|
start_generation: 0,
|
|
starting_server: None,
|
|
notice: None,
|
|
add_agent_on_load: false,
|
|
seen: tabs::Seen::default(),
|
|
watch: None,
|
|
status_login: true,
|
|
});
|
|
self.tabs_changed();
|
|
Some(id)
|
|
}
|
|
|
|
/// ⌘T, the + button, Switch Server: a new tab with the connect page, in
|
|
/// front.
|
|
fn new_tab(&mut self) -> Option<TabId> {
|
|
let Some(id) = self.create_tab(None, false, true) else {
|
|
if let Some(t) = self.active_tab().map(|t| t.id) {
|
|
self.toast_in_tab(t, TOO_MANY_TABS, "warning");
|
|
}
|
|
return None;
|
|
};
|
|
self.select_tab(self.tabs.len() - 1);
|
|
Some(id)
|
|
}
|
|
|
|
/// Bring the tab at `index` to the front, the overview out of the way,
|
|
/// with the keyboard in its page.
|
|
fn select_tab(&mut self, index: usize) {
|
|
if index >= self.tabs.len() {
|
|
return;
|
|
}
|
|
self.active = index;
|
|
self.overview_shown = false;
|
|
self.show_front(true);
|
|
self.save_tabs();
|
|
self.sync_keep_awake();
|
|
}
|
|
|
|
fn select_tab_id(&mut self, id: TabId) {
|
|
if let Some(index) = self.tab_index(id) {
|
|
self.select_tab(index);
|
|
}
|
|
}
|
|
|
|
/// ⌘0 and the grid button: the overview in front, or the tab again.
|
|
fn toggle_overview(&mut self) {
|
|
if self.overview_shown {
|
|
self.overview_shown = false;
|
|
self.show_front(true);
|
|
return;
|
|
}
|
|
if self.overview.is_none() {
|
|
self.overview = self.build_child(Part::Overview, 0, None, Load::Bundled(policy::OVERVIEW_PAGE), None);
|
|
}
|
|
if self.overview.is_some() {
|
|
self.overview_shown = true;
|
|
self.show_front(true);
|
|
}
|
|
}
|
|
|
|
/// Close a tab: its pop-outs, its views, its status thread and its login
|
|
/// go. Its server and sessions keep running. The last tab gives way to a
|
|
/// new tab with the connect page, never an empty window.
|
|
fn close_tab(&mut self, id: TabId) {
|
|
let Some(index) = self.tab_index(id) else {
|
|
return;
|
|
};
|
|
if self.lone_blank_tab() {
|
|
return; // it would only come back
|
|
}
|
|
self.close_popouts_of(id);
|
|
set_challenge_login(id, None);
|
|
#[cfg(target_os = "macos")]
|
|
macos::forget_tab(id);
|
|
let tab = self.tabs.remove(index);
|
|
if let Some(entry) = tab.entry() {
|
|
self.closed.push(entry);
|
|
if self.closed.len() > tabs::MAX_CLOSED {
|
|
self.closed.remove(0);
|
|
}
|
|
}
|
|
let views: Vec<ViewId> = tab.views().map(|v| v.id).collect();
|
|
self.downloads.retain(|(view, _), _| !views.contains(view));
|
|
drop(tab);
|
|
let before = self.tabs.len() + 1;
|
|
if index < self.active {
|
|
self.active -= 1;
|
|
} else if index == self.active {
|
|
self.active = tabs::after_close(before, index).unwrap_or(0);
|
|
}
|
|
if self.tabs.is_empty() {
|
|
self.create_tab(None, false, true);
|
|
self.active = 0;
|
|
}
|
|
self.show_front(!self.overview_shown);
|
|
self.save_tabs();
|
|
self.sync_logo_watch();
|
|
self.sync_keep_awake();
|
|
}
|
|
|
|
/// The window holds one tab, and it is a new tab with nothing in it.
|
|
fn lone_blank_tab(&self) -> bool {
|
|
matches!(self.tabs.as_slice(), [t] if t.home.is_none() && t.kind() == tabs::Kind::Connect)
|
|
}
|
|
|
|
fn close_popouts_of(&mut self, tab: TabId) {
|
|
let windows: Vec<WindowId> = self
|
|
.popouts
|
|
.iter()
|
|
.filter(|(_, p)| p.tab == tab)
|
|
.map(|(w, _)| *w)
|
|
.collect();
|
|
for window in windows {
|
|
self.remove_popout(window);
|
|
}
|
|
}
|
|
|
|
/// Reopen Closed Tab: the last tab closed, connected again.
|
|
fn reopen_closed(&mut self) {
|
|
let Some(entry) = self.closed.pop() else {
|
|
return;
|
|
};
|
|
if let Some(index) = self
|
|
.tabs
|
|
.iter()
|
|
.position(|t| t.home.as_ref().is_some_and(|h| h.as_str() == entry.url))
|
|
{
|
|
self.select_tab(index);
|
|
return;
|
|
}
|
|
if self.create_tab(Some(entry), true, true).is_some() {
|
|
self.select_tab(self.tabs.len() - 1);
|
|
}
|
|
}
|
|
|
|
/// The tabs to open at the next launch: those that belong to a server.
|
|
fn save_tabs(&mut self) {
|
|
let entries: Vec<TabEntry> = self.tabs.iter().filter_map(Tab::entry).collect();
|
|
let active = self
|
|
.tabs
|
|
.iter()
|
|
.take(self.active + 1)
|
|
.filter(|t| t.home.is_some())
|
|
.count()
|
|
.saturating_sub(1);
|
|
if self.config.tabs != entries || self.config.active_tab != active {
|
|
self.config.tabs = entries;
|
|
self.config.active_tab = active;
|
|
self.config_dirty = true;
|
|
}
|
|
}
|
|
|
|
fn on_strip_cmd(&mut self, cmd: StripCmd) {
|
|
match cmd {
|
|
StripCmd::Ready => self.strip_dirty = true,
|
|
StripCmd::Select(id) => {
|
|
let Some(index) = self.tab_index(id) else {
|
|
return;
|
|
};
|
|
if index == self.active && !self.overview_shown {
|
|
// Already in front: only the keyboard goes back to its
|
|
// page (a click made the strip the first responder). A
|
|
// double-click that starts a rename asks for the strip
|
|
// again afterwards (StripCmd::Editing).
|
|
self.focus_front();
|
|
return;
|
|
}
|
|
self.select_tab(index);
|
|
}
|
|
StripCmd::Close(id) => self.close_tab(id),
|
|
StripCmd::New => {
|
|
self.new_tab();
|
|
}
|
|
StripCmd::Overview => self.toggle_overview(),
|
|
StripCmd::Menu(id) => self.show_tab_menu(id),
|
|
StripCmd::Rename { id, name } => {
|
|
if let Some(t) = self.tab_mut(id) {
|
|
let default = t.shown_url().map(|u| tabs::default_name(&u));
|
|
if let Some(name) = tabs::renamed(&name, &t.display_name(), default.as_deref()) {
|
|
t.name = name;
|
|
}
|
|
}
|
|
self.tabs_changed();
|
|
self.save_tabs();
|
|
self.update_title();
|
|
self.focus_front();
|
|
}
|
|
StripCmd::Move { id, to } => {
|
|
let Some(from) = self.tab_index(id) else {
|
|
return;
|
|
};
|
|
let Some(to) = tabs::moved(self.tabs.len(), from, to) else {
|
|
return;
|
|
};
|
|
let active = self.tabs.get(self.active).map(|t| t.id);
|
|
let tab = self.tabs.remove(from);
|
|
self.tabs.insert(to, tab);
|
|
if let Some(index) = active.and_then(|id| self.tab_index(id)) {
|
|
self.active = index;
|
|
}
|
|
self.tabs_changed();
|
|
self.save_tabs();
|
|
self.focus_front();
|
|
}
|
|
StripCmd::Editing => {
|
|
let _ = self.strip.webview.focus();
|
|
}
|
|
StripCmd::Drag => {
|
|
let _ = self.main_window.drag_window();
|
|
self.focus_front();
|
|
}
|
|
StripCmd::Zoom => {
|
|
let w = &self.main_window;
|
|
if w.fullscreen().is_none() {
|
|
w.set_maximized(!w.is_maximized());
|
|
}
|
|
self.focus_front();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The keyboard back in the page in front (after a click on the strip).
|
|
fn focus_front(&self) {
|
|
let front = match self.overview_shown {
|
|
true => self.overview.as_ref().map(|o| o.id),
|
|
false => self.active_tab().and_then(Tab::front).map(|v| v.id),
|
|
};
|
|
if let Some(webview) = front.and_then(|id| self.webview(id)) {
|
|
let _ = webview.focus();
|
|
}
|
|
}
|
|
|
|
/// The right-click menu of a tab, at the pointer.
|
|
fn show_tab_menu(&mut self, id: TabId) {
|
|
let Some(t) = self.tab(id) else {
|
|
return;
|
|
};
|
|
let server = t.kind() == tabs::Kind::Server;
|
|
let Ok(menu) = menu::tab_context_menu(id, &t.color, server, self.tabs.len() > 1) else {
|
|
return;
|
|
};
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use muda::ContextMenu;
|
|
use tao::platform::macos::WindowExtMacOS;
|
|
// SAFETY: tao's live NSView of the main window; the menu runs its
|
|
// tracking loop now and is done with the view when this returns.
|
|
unsafe { menu.show_context_menu_for_nsview(self.main_window.ns_view(), None) };
|
|
}
|
|
// The right-click made the strip the first responder. The item chosen,
|
|
// if any, arrives after this (Rename takes the strip again then).
|
|
self.focus_front();
|
|
#[cfg(not(target_os = "macos"))]
|
|
drop(menu);
|
|
}
|
|
|
|
fn on_tab_menu(&mut self, id: TabId, action: TabAction) {
|
|
match action {
|
|
TabAction::Rename => {
|
|
// The strip takes the keyboard for the name field.
|
|
let _ = self.strip.webview.focus();
|
|
let msg = json!({ "type": "rename", "id": id });
|
|
let _ = self
|
|
.strip
|
|
.webview
|
|
.evaluate_script(&assets::native_message_script("__codemanTabs", &msg));
|
|
}
|
|
TabAction::Color(i) => {
|
|
if let (Some(t), Some(color)) = (self.tab_mut(id), tabs::PALETTE.get(i)) {
|
|
t.color = (*color).to_string();
|
|
}
|
|
self.tabs_changed();
|
|
self.save_tabs();
|
|
self.focus_front();
|
|
}
|
|
TabAction::Reload => {
|
|
if let Some(view) = self.tab(id).and_then(|t| t.server.as_ref()).map(|v| v.id) {
|
|
if let Some((url, with_auth)) = self.reload_target(view) {
|
|
self.load_in(view, &url, with_auth);
|
|
}
|
|
}
|
|
}
|
|
TabAction::Reconnect => self.show_connect_page(id, None, true),
|
|
TabAction::Close => self.close_tab(id),
|
|
TabAction::CloseOthers => {
|
|
let others: Vec<TabId> = self.tabs.iter().map(|t| t.id).filter(|t| *t != id).collect();
|
|
for other in others {
|
|
self.close_tab(other);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_overview_cmd(&mut self, cmd: OverviewCmd) {
|
|
match cmd {
|
|
OverviewCmd::Ready => self.overview_dirty = true,
|
|
OverviewCmd::Open(tab) => self.select_tab_id(tab),
|
|
OverviewCmd::OpenSession { tab, session } => {
|
|
self.select_tab_id(tab);
|
|
if !tabs::is_session_id(&session) {
|
|
return;
|
|
}
|
|
let loaded = self.tab(tab).filter(|t| t.kind() == tabs::Kind::Server);
|
|
if let Some(server) = loaded.and_then(|t| t.server.as_ref()) {
|
|
let _ = server.webview.evaluate_script(&assets::select_session_script(&session));
|
|
}
|
|
}
|
|
OverviewCmd::New => {
|
|
self.new_tab();
|
|
}
|
|
OverviewCmd::Retry(tab) => {
|
|
let view = self.tab(tab).and_then(|t| t.server.as_ref()).map(|v| v.id);
|
|
match view.and_then(|v| self.reload_target(v).map(|r| (v, r))) {
|
|
Some((view, (url, with_auth))) => self.load_in(view, &url, with_auth),
|
|
None => self.select_tab_id(tab),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_tab_status(&mut self, tab: TabId, generation: u64, status: StatusUpdate) {
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
if generation != t.login_generation {
|
|
return;
|
|
}
|
|
match status {
|
|
StatusUpdate::Seen(detail) => {
|
|
t.seen.detail = Some(detail);
|
|
t.seen.offline = false;
|
|
}
|
|
StatusUpdate::Offline => t.seen.offline = true,
|
|
StatusUpdate::Refused => {
|
|
t.seen.refused = true;
|
|
t.watch = None;
|
|
}
|
|
}
|
|
self.strip_dirty = true;
|
|
self.overview_dirty = true;
|
|
}
|
|
|
|
fn on_title(&mut self, view: ViewId, title: String) {
|
|
match self.owner(view) {
|
|
Some(Owner::Server(tab)) => {
|
|
if let Some(t) = self.tab_mut(tab) {
|
|
t.page_title = Some(title);
|
|
}
|
|
self.update_title();
|
|
}
|
|
Some(Owner::Popout(window, _)) => {
|
|
if let Some(p) = self.popouts.get(&window) {
|
|
let title = title.trim();
|
|
p.window.set_title(if title.is_empty() { APP_NAME } else { title });
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn on_page_committed(&mut self, view: ViewId, url: String) {
|
|
match self.owner(view) {
|
|
Some(Owner::Server(tab)) => {
|
|
let Some(t) = self.tab(tab) else {
|
|
return;
|
|
};
|
|
let next = policy::loaded_on_commit(&t.phase.borrow(), &url);
|
|
if let Some(next) = next {
|
|
*t.phase.borrow_mut() = next;
|
|
}
|
|
if let Some(v) = self.child_view_mut(view) {
|
|
v.committed = Url::parse(&url).ok();
|
|
}
|
|
// The connect page is still there the first time the server's
|
|
// page commits: the load got through.
|
|
let first = self
|
|
.tab(tab)
|
|
.is_some_and(|t| t.connect.is_some() && t.phase.borrow().loaded().is_some());
|
|
if first {
|
|
self.on_server_loaded(tab);
|
|
}
|
|
}
|
|
Some(Owner::Connect(_)) => {
|
|
if let Some(v) = self.child_view_mut(view) {
|
|
v.committed = Url::parse(&url).ok();
|
|
}
|
|
}
|
|
Some(Owner::Popout(window, _)) => {
|
|
if let Some(p) = self.popouts.get_mut(&window) {
|
|
p.committed = Url::parse(&url).ok();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// A tab's server page committed: it replaces the connect page, the tab
|
|
/// is for this server from now on, and its status is read.
|
|
fn on_server_loaded(&mut self, tab: TabId) {
|
|
let proxy = self.proxy.clone();
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
t.connect = None;
|
|
let server = t.phase.borrow().loaded().cloned();
|
|
if let Some(server) = server {
|
|
t.home = Some(server.clone());
|
|
t.seen = tabs::Seen::default();
|
|
t.watch = t
|
|
.status_login
|
|
.then(|| watch_status(&proxy, tab, t.login_generation, &server, t.auth.clone()))
|
|
.flatten();
|
|
}
|
|
// The server page takes the keyboard from the connect page it replaces,
|
|
// when this tab is the one in front.
|
|
let in_front = self.active_tab().is_some_and(|t| t.id == tab) && !self.overview_shown;
|
|
self.show_front(in_front);
|
|
self.save_tabs();
|
|
self.sync_logo_watch();
|
|
self.sync_keep_awake();
|
|
}
|
|
|
|
fn on_download_done(&mut self, view: ViewId, url: String, path: Option<PathBuf>, ok: bool) {
|
|
let key = (view, 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());
|
|
// The toast goes where the download was started when that view still
|
|
// shows Codeman's own app (a session pop-out has it too), else to the
|
|
// tab's server page. Chosen before a hidden pop-out is dropped.
|
|
let owner = self.owner(view);
|
|
let tab = match owner {
|
|
Some(Owner::Server(tab) | Owner::Popout(_, tab)) => tab,
|
|
_ => return,
|
|
};
|
|
let server = self.tab(tab).and_then(|t| t.phase.borrow().loaded().cloned());
|
|
let shows_app = |committed: Option<&Url>| {
|
|
server
|
|
.as_ref()
|
|
.zip(committed)
|
|
.is_some_and(|(server, url)| policy::trusted_frame(url, server))
|
|
};
|
|
let toast_in = match owner {
|
|
Some(Owner::Popout(window, _)) => {
|
|
let p = self.popouts.get(&window);
|
|
let here = p.is_some_and(|p| !p.hidden_for_download && shows_app(p.committed.as_ref()));
|
|
if p.is_some_and(|p| p.hidden_for_download) {
|
|
self.remove_popout(window);
|
|
}
|
|
here.then_some(view)
|
|
}
|
|
_ => None,
|
|
};
|
|
let toast_in = toast_in.or_else(|| self.tab(tab).and_then(|t| t.server.as_ref()).map(|v| v.id));
|
|
if let Some(webview) = toast_in.and_then(|id| self.webview(id)) {
|
|
let _ = webview.evaluate_script(&assets::download_toast_script(&name, ok));
|
|
}
|
|
}
|
|
|
|
/// A toast in a tab's server page, if it shows one.
|
|
fn toast_in_tab(&self, tab: TabId, text: &str, kind: &str) {
|
|
let loaded = self.tab(tab).filter(|t| t.kind() == tabs::Kind::Server);
|
|
if let Some(server) = loaded.and_then(|t| t.server.as_ref()) {
|
|
let _ = server.webview.evaluate_script(&assets::toast_script(text, kind));
|
|
}
|
|
}
|
|
|
|
/// The web content process of `view` ended, so the view 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, view: ViewId) {
|
|
let now = Instant::now();
|
|
let again_after =
|
|
|at: Option<Instant>| at.is_some_and(|at| now.saturating_duration_since(at) < CRASH_RELOAD_COOLDOWN);
|
|
match self.owner(view) {
|
|
Some(Owner::Strip) => {
|
|
let _ = self.strip.webview.load_url(policy::STRIP_PAGE);
|
|
}
|
|
Some(Owner::Overview) => {
|
|
if let Some(o) = &self.overview {
|
|
let _ = o.webview.load_url(policy::OVERVIEW_PAGE);
|
|
}
|
|
}
|
|
Some(Owner::Connect(tab)) => {
|
|
let again = self.child_view_mut(view).map(|v| again_after(v.gone_at.replace(now)));
|
|
if again == Some(false) {
|
|
if let Some(webview) = self.webview(view) {
|
|
let _ = webview.load_url(&connect_page_url(true));
|
|
}
|
|
} else {
|
|
eprintln!("codeman-desktop: tab {tab}'s connect page stopped again and was left as it is");
|
|
}
|
|
}
|
|
Some(Owner::Server(tab)) => {
|
|
let Some(again) = self.child_view_mut(view).map(|v| again_after(v.gone_at.replace(now))) else {
|
|
return;
|
|
};
|
|
let on_server = self.tab(tab).is_some_and(|t| t.phase.borrow().loaded().is_some());
|
|
match policy::recovery(true, on_server, again) {
|
|
Recovery::Reload => {
|
|
if let Some((url, with_auth)) = self.reload_target(view) {
|
|
self.load_in(view, &url, with_auth);
|
|
}
|
|
}
|
|
Recovery::ConnectPage { gave_up } => {
|
|
self.show_connect_page(tab, gave_up.then(|| PAGE_KEEPS_STOPPING.to_string()), false);
|
|
}
|
|
Recovery::Close => {}
|
|
Recovery::Nothing => eprintln!("codeman-desktop: a page stopped again and was left as it is"),
|
|
}
|
|
}
|
|
Some(Owner::Popout(window, tab)) => {
|
|
let Some(p) = self.popouts.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 p.hidden_for_download {
|
|
return;
|
|
}
|
|
let again = again_after(p.gone_at.replace(now));
|
|
let on_server = self.tab(tab).is_some_and(|t| t.phase.borrow().loaded().is_some());
|
|
match policy::recovery(false, on_server, again) {
|
|
Recovery::Reload => {
|
|
if let Some((url, with_auth)) = self.reload_target(view) {
|
|
self.load_in(view, &url, with_auth);
|
|
}
|
|
}
|
|
Recovery::Close => self.remove_popout(window),
|
|
Recovery::ConnectPage { .. } | Recovery::Nothing => {
|
|
eprintln!("codeman-desktop: a page stopped again and was left as it is")
|
|
}
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
/// The challenge handler reported that a tab's 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, tab: TabId) {
|
|
let proxy = self.proxy.clone();
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
let Some(server) = t.phase.borrow().server().cloned() else {
|
|
return; // the connect page: nothing to recheck
|
|
};
|
|
let Some(auth) = t.auth.clone() else {
|
|
return;
|
|
};
|
|
let generation = t.login_generation;
|
|
if t.login_recheck == Some(generation) {
|
|
return;
|
|
}
|
|
t.login_recheck = Some(generation);
|
|
std::thread::spawn(move || {
|
|
let outcome = probe::probe(&server, Some(&auth));
|
|
let _ = proxy.send_event(UserEvent::LoginChecked {
|
|
tab,
|
|
generation,
|
|
outcome,
|
|
});
|
|
});
|
|
}
|
|
|
|
/// The server's answer to recheck_login. A refused login brings the tab's
|
|
/// 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, tab: TabId, generation: u64, outcome: ProbeOutcome) {
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
if t.login_recheck == Some(generation) {
|
|
t.login_recheck = None;
|
|
}
|
|
if generation != t.login_generation {
|
|
return; // another login, or none, since the recheck started
|
|
}
|
|
let Some(server) = t.phase.borrow().server().cloned() else {
|
|
return;
|
|
};
|
|
match outcome {
|
|
ProbeOutcome::BadCredentials | ProbeOutcome::NeedsLogin => {
|
|
self.show_connect_page(tab, Some(AUTH_REJECTED.into()), false);
|
|
}
|
|
ProbeOutcome::RateLimited { .. } => {
|
|
self.show_connect_page(tab, Some(AUTH_RATE_LIMITED.into()), false);
|
|
}
|
|
_ => {
|
|
if let Some((username, password)) = &t.login {
|
|
set_challenge_login(tab, Some((&server, username, password)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The servers of every tab that shows one, with their logins.
|
|
fn loaded_servers(&self) -> Vec<(Url, Option<String>)> {
|
|
self.tabs
|
|
.iter()
|
|
.filter_map(|t| t.phase.borrow().loaded().cloned().map(|url| (url, t.auth.clone())))
|
|
.collect()
|
|
}
|
|
|
|
/// The logo is watched while the switch is on, a state has a style and a
|
|
/// server is open, for every open server together.
|
|
fn sync_logo_watch(&mut self) {
|
|
let styles = self.config.logo_blink;
|
|
let want = (styles.active() && logo::available()).then(|| {
|
|
let servers = self
|
|
.loaded_servers()
|
|
.into_iter()
|
|
.map(|(url, authorization)| logo::Target {
|
|
server: url.to_string(),
|
|
authorization,
|
|
})
|
|
.collect();
|
|
(servers, styles)
|
|
});
|
|
self.logo_watch.sync(want);
|
|
self.show_lid_status(logo::lid_status_line());
|
|
}
|
|
|
|
/// The line under the logo's styles: what the closed lid did last, or that
|
|
/// nothing watches it now.
|
|
fn show_lid_status(&self, line: String) {
|
|
if let Some(item) = &self.menu.logo_lid {
|
|
if self.logo_watch.watching() {
|
|
item.set_text(line);
|
|
} else {
|
|
item.set_text(logo::LID_NOT_WATCHED);
|
|
}
|
|
}
|
|
}
|
|
|
|
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(t) = self.active_tab().map(|t| t.id) {
|
|
self.toast_in_tab(t, warning, "warning");
|
|
}
|
|
}
|
|
|
|
/// The Codeman on this Mac among the open tabs, if any: the tab in front
|
|
/// when it is one, else the first. Keep This Mac Awake watches it.
|
|
fn local_server(&self) -> Option<Url> {
|
|
let servers = self.loaded_servers();
|
|
if servers.is_empty() {
|
|
return None;
|
|
}
|
|
let addresses = keep_awake::local_addresses();
|
|
let active = self.active_tab().and_then(|t| t.phase.borrow().loaded().cloned());
|
|
active
|
|
.into_iter()
|
|
.chain(servers.into_iter().map(|(url, _)| url))
|
|
.find(|url| keep_awake::is_this_mac(url, &addresses))
|
|
}
|
|
|
|
/// Keep-awake watches the Codeman on this Mac among the tabs while a mode
|
|
/// is chosen. With none, it says so.
|
|
fn sync_keep_awake(&mut self) {
|
|
let mut settings = self.config.keep_awake;
|
|
// The lid item only exists on a Mac with a lid; a setting copied from one
|
|
// does nothing elsewhere.
|
|
settings.lid &= keep_awake::has_lid();
|
|
let watch = (settings.mode != keep_awake::Mode::Off)
|
|
.then(|| self.local_server())
|
|
.flatten()
|
|
.map(|server| {
|
|
let authorization = self
|
|
.tabs
|
|
.iter()
|
|
.find(|t| t.phase.borrow().loaded() == Some(&server))
|
|
.and_then(|t| t.auth.clone());
|
|
keep_awake::Watch {
|
|
server,
|
|
authorization,
|
|
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();
|
|
}
|
|
|
|
fn toggle_keep_awake_lid(&mut self) {
|
|
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.lid.as_ref()) else {
|
|
return;
|
|
};
|
|
self.config.keep_awake.lid = item.is_checked();
|
|
self.config_dirty = true;
|
|
self.sync_keep_awake();
|
|
}
|
|
|
|
/// Codeman 1.50.1 kept the lid awake through a root helper. Nothing uses it any
|
|
/// more, and taking it away is the one thing left that asks for the
|
|
/// administrator password. The prompt runs off the event loop.
|
|
fn remove_old_helper(&mut self) {
|
|
if self.old_helper_busy {
|
|
return;
|
|
}
|
|
self.old_helper_busy = true;
|
|
let proxy = self.proxy.clone();
|
|
std::thread::spawn(move || {
|
|
let _ = proxy.send_event(UserEvent::OldHelperRemoved(keep_awake::remove_old_helper()));
|
|
});
|
|
}
|
|
|
|
fn on_old_helper_removed(&mut self, outcome: keep_awake::AdminOutcome) {
|
|
use keep_awake::AdminOutcome;
|
|
self.old_helper_busy = false;
|
|
let failure = match (outcome, keep_awake::old_helper_installed()) {
|
|
(AdminOutcome::Cancelled, _) => None,
|
|
(AdminOutcome::Failed(error), _) => Some(error),
|
|
(AdminOutcome::Done, false) => {
|
|
if let Some(items) = &mut self.menu.keep_awake {
|
|
items.old_helper_removed();
|
|
}
|
|
None
|
|
}
|
|
(AdminOutcome::Done, true) => Some("The removal ran, but the helper's files are still there.".to_owned()),
|
|
};
|
|
if let Some(detail) = failure {
|
|
let title = "The old lid helper could not be removed.";
|
|
eprintln!("codeman-desktop: {title} {detail}");
|
|
#[cfg(target_os = "macos")]
|
|
macos::show_message(title, &detail);
|
|
}
|
|
}
|
|
|
|
fn on_menu(&mut self, id: &str, target: &EventLoopWindowTarget<UserEvent>, control_flow: &mut ControlFlow) {
|
|
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;
|
|
}
|
|
if let Some((tab, action)) = TabAction::parse(id) {
|
|
self.on_tab_menu(tab, action);
|
|
return;
|
|
}
|
|
if let Some(key) = menu::tab_key(id) {
|
|
if let Some(index) = tabs::index_of_key(key, self.tabs.len()) {
|
|
self.select_tab(index);
|
|
}
|
|
return;
|
|
}
|
|
match id {
|
|
keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(),
|
|
keep_awake::LID_ID => self.toggle_keep_awake_lid(),
|
|
keep_awake::REMOVE_OLD_HELPER_ID => self.remove_old_helper(),
|
|
menu_id::SWITCH | menu_id::TAB_NEW => {
|
|
self.new_tab();
|
|
}
|
|
menu_id::TAB_CLOSE => match self.focused_popout() {
|
|
Some(window) => self.remove_popout(window),
|
|
None if self.overview_shown => self.toggle_overview(),
|
|
// As in Safari: ⌘W on the last tab closes the window.
|
|
None if self.lone_blank_tab() => self.close_main_window(target, control_flow),
|
|
None => {
|
|
if let Some(t) = self.active_tab().map(|t| t.id) {
|
|
self.close_tab(t);
|
|
}
|
|
}
|
|
},
|
|
menu_id::WINDOW_CLOSE => match self.focused_popout() {
|
|
Some(window) => self.remove_popout(window),
|
|
None => self.close_main_window(target, control_flow),
|
|
},
|
|
menu_id::TAB_REOPEN => self.reopen_closed(),
|
|
menu_id::TAB_OVERVIEW => self.toggle_overview(),
|
|
menu_id::TAB_NEXT | menu_id::TAB_PREVIOUS => {
|
|
let forward = id == menu_id::TAB_NEXT;
|
|
if let Some(index) = tabs::neighbour(self.tabs.len(), self.active, forward) {
|
|
self.select_tab(index);
|
|
}
|
|
}
|
|
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_every_connect_page(json!({ "type": "prefs", "startFullscreen": value }));
|
|
}
|
|
menu_id::DEVTOOLS => {
|
|
if let Some(window) = self.focused_popout() {
|
|
if let Some(p) = self.popouts.get(&window) {
|
|
p.webview.open_devtools();
|
|
}
|
|
} else if self.main_window.is_focused() {
|
|
let front = match self.overview_shown {
|
|
true => self.overview.as_ref().map(|o| o.id),
|
|
false => self.active_tab().and_then(Tab::front).map(|v| v.id),
|
|
};
|
|
if let Some(webview) = front.and_then(|id| self.webview(id)) {
|
|
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, view: ViewId, owner: Owner, cmd: HostCmd, target: &EventLoopWindowTarget<UserEvent>) {
|
|
let tab = match owner {
|
|
Owner::Server(tab) | Owner::Popout(_, tab) => tab,
|
|
_ => return,
|
|
};
|
|
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 Some(phase) = self.tab(tab).map(|t| t.phase.clone()) else {
|
|
return;
|
|
};
|
|
let session = 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, &phase.borrow(), Via::Host);
|
|
let opened = match decision {
|
|
NewWindow::AppWindow(url) => self.build_popout(target, tab, &url, true),
|
|
NewWindow::External(url) => self.open_external_budgeted(&url),
|
|
NewWindow::Deny => false,
|
|
};
|
|
if !opened {
|
|
self.tell_refused(view, session.as_deref());
|
|
}
|
|
}
|
|
HostCmd::CloseWindow => {
|
|
if let Owner::Popout(window, _) = owner {
|
|
self.remove_popout(window);
|
|
}
|
|
}
|
|
HostCmd::FocusWindow => match owner {
|
|
Owner::Popout(window, _) => {
|
|
if let Some(p) = self.popouts.get(&window) {
|
|
p.window.set_visible(true);
|
|
p.window.set_minimized(false);
|
|
p.window.set_focus();
|
|
}
|
|
}
|
|
_ => {
|
|
self.select_tab_id(tab);
|
|
self.show_main_window();
|
|
}
|
|
},
|
|
HostCmd::OpenExternal(raw) => self.open_external_raw(&raw),
|
|
HostCmd::SessionExpired => self.on_session_expired(view, tab),
|
|
}
|
|
}
|
|
|
|
fn on_connect_cmd(&mut self, tab: TabId, 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(tab);
|
|
let loading = self
|
|
.tab(tab)
|
|
.is_some_and(|t| matches!(*t.phase.borrow(), Phase::Loading(_)));
|
|
match cmd {
|
|
ConnectCmd::Ready => self.send_init(tab),
|
|
ConnectCmd::Connect {
|
|
url,
|
|
username,
|
|
password,
|
|
remember,
|
|
} => {
|
|
if choosing {
|
|
self.start_probe(tab, url, username, password, remember);
|
|
}
|
|
}
|
|
ConnectCmd::Cancel => {
|
|
if let Some(t) = self.tab_mut(tab) {
|
|
t.probe_generation += 1;
|
|
t.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(tab, Some(LOAD_CANCELLED.into()), true);
|
|
}
|
|
}
|
|
ConnectCmd::StartServer { url } => {
|
|
if choosing {
|
|
self.start_server(tab, 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_every_connect_page(json!({ "type": "servers", "servers": self.config.servers }));
|
|
}
|
|
ConnectCmd::SetAutoConnect(v) => {
|
|
self.config.auto_connect = v;
|
|
self.config_dirty = true;
|
|
self.to_every_connect_page(json!({ "type": "prefs", "autoConnect": v }));
|
|
}
|
|
ConnectCmd::SetStartFullscreen(v) => {
|
|
self.config.start_fullscreen = v;
|
|
self.menu.start_fullscreen.set_checked(v);
|
|
self.config_dirty = true;
|
|
self.to_every_connect_page(json!({ "type": "prefs", "startFullscreen": v }));
|
|
}
|
|
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(
|
|
tab,
|
|
move || json!({ "type": "localStatus", "status": setup::inspect(depth) }),
|
|
);
|
|
}
|
|
}
|
|
ConnectCmd::InstallLocal { network } => {
|
|
if choosing {
|
|
self.in_background(tab, 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(tab, || plan_message(gui_install::prepare()));
|
|
}
|
|
}
|
|
ConnectCmd::InstallStart {
|
|
network_password,
|
|
ai_cli,
|
|
} => {
|
|
if choosing {
|
|
let send = self.page_sender(tab);
|
|
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(tab, gui_install::Run::Codeman);
|
|
}
|
|
}
|
|
ConnectCmd::InstallCancel => {
|
|
std::thread::spawn(gui_install::cancel);
|
|
}
|
|
ConnectCmd::AgentPrepare => {
|
|
if choosing {
|
|
self.in_background(tab, || plan_message(gui_install::prepare_agent()));
|
|
}
|
|
}
|
|
ConnectCmd::AgentStart { ai_cli } => {
|
|
if choosing {
|
|
let send = self.page_sender(tab);
|
|
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(tab, gui_install::Run::Agent);
|
|
}
|
|
}
|
|
ConnectCmd::InstallRemote { target } => {
|
|
if choosing {
|
|
match terminal_install::SshTarget::parse(&target) {
|
|
Err(message) => self.to_connect_page(tab, remote_install_failed(message)),
|
|
Ok(target) => self.in_background(tab, 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(tab, || {
|
|
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 tab's page, from its first line.
|
|
fn attach_to_run(&self, tab: TabId, run: gui_install::Run) {
|
|
let send = self.page_sender(tab);
|
|
let stale = self.next_install_reader();
|
|
std::thread::spawn(move || gui_install::follow(run, stale, send));
|
|
}
|
|
|
|
/// The agent chooser lives on the connect page: the tab in front shows it
|
|
/// when that is a connect page, else a new tab does.
|
|
fn add_agent(&mut self) {
|
|
let front = self.active_tab().map(|t| t.id).filter(|_| !self.overview_shown);
|
|
if let Some(tab) = front.filter(|t| self.choosing(*t)) {
|
|
self.show_main_window();
|
|
self.to_connect_page(tab, json!({ "type": "addAgent" }));
|
|
return;
|
|
}
|
|
if let Some(tab) = self.new_tab() {
|
|
if let Some(t) = self.tab_mut(tab) {
|
|
t.add_agent_on_load = 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 tab's connect page.
|
|
fn in_background(&self, tab: TabId, work: impl FnOnce() -> serde_json::Value + Send + 'static) {
|
|
let send = self.page_sender(tab);
|
|
std::thread::spawn(move || send(work()));
|
|
}
|
|
|
|
/// Hands a message to a tab's connect page from another thread
|
|
/// (UserEvent::ToConnectPage).
|
|
fn page_sender(&self, tab: TabId) -> impl Fn(serde_json::Value) + Send + 'static {
|
|
let proxy = self.proxy.clone();
|
|
move |msg| {
|
|
let _ = proxy.send_event(UserEvent::ToConnectPage { tab, msg });
|
|
}
|
|
}
|
|
|
|
fn send_init(&mut self, tab: TabId) {
|
|
let starting = self.start_running(tab);
|
|
let servers = self.config.servers.clone();
|
|
let auto_connect = self.config.auto_connect;
|
|
let start_fullscreen = self.config.start_fullscreen;
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
let notice = t.notice.take();
|
|
let add_agent = std::mem::take(&mut t.add_agent_on_load);
|
|
let (target, connect_now) = match t.connect_to.take() {
|
|
Some((url, now)) => (Some(url), now),
|
|
None => (None, false),
|
|
};
|
|
self.to_connect_page(
|
|
tab,
|
|
json!({
|
|
"type": "init",
|
|
"servers": servers,
|
|
"autoConnect": auto_connect,
|
|
"startFullscreen": start_fullscreen,
|
|
"version": VERSION,
|
|
"starting": starting,
|
|
"notice": notice,
|
|
"addAgent": add_agent,
|
|
"target": target,
|
|
"connectNow": connect_now,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Whether a tab's connect page is up with no server chosen: the only
|
|
/// time it may run anything.
|
|
fn choosing(&self, tab: TabId) -> bool {
|
|
self.tab(tab)
|
|
.is_some_and(|t| matches!(*t.phase.borrow(), Phase::Connect) && t.connect.is_some())
|
|
}
|
|
|
|
/// Whether a Start Codeman run is going whose result the tab's page will get.
|
|
fn start_running(&self, tab: TabId) -> bool {
|
|
self.tab(tab)
|
|
.is_some_and(|t| t.starting_server.is_some() && t.starting_server == Some(t.start_generation))
|
|
}
|
|
|
|
/// Why this tab cannot open `url` while the other tabs are as they are:
|
|
/// another tab has it already (that tab), or, before macOS 14, a server on
|
|
/// the same machine is open in another tab (a message).
|
|
fn conflict(&self, tab: TabId, url: &Url) -> Option<Result<TabId, &'static str>> {
|
|
let others = self.tabs.iter().filter(|t| t.id != tab);
|
|
for other in others {
|
|
if other.shown_url().as_ref() == Some(url) {
|
|
return Some(Ok(other.id));
|
|
}
|
|
// Only a server another tab has reached or is loading shares the
|
|
// cookie: a tab opened again at launch that has not connected
|
|
// yet must not keep this one from connecting, or two of them
|
|
// would keep each other out for good.
|
|
let live = other.phase.borrow().server().cloned();
|
|
if !own_stores() && live.is_some_and(|theirs| tabs::same_host(&theirs, url)) {
|
|
return Some(Err(SAME_HOST_NEEDS_MACOS_14));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Whether `tab` must not open `url` (`conflict`), having dealt with it:
|
|
/// one tab per server, so the tab that has it comes to the front (a new
|
|
/// tab with nothing in it gives way), or the page says why not.
|
|
fn refuse_conflict(&mut self, tab: TabId, url: &Url) -> bool {
|
|
match self.conflict(tab, url) {
|
|
Some(Ok(other)) => {
|
|
let blank = self.tab(tab).is_some_and(|t| t.home.is_none());
|
|
self.select_tab_id(other);
|
|
if blank {
|
|
self.close_tab(tab);
|
|
} else {
|
|
self.to_connect_page(tab, json!({ "type": "error", "message": ALREADY_OPEN }));
|
|
}
|
|
true
|
|
}
|
|
Some(Err(message)) => {
|
|
self.to_connect_page(tab, json!({ "type": "error", "message": message }));
|
|
true
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
fn start_probe(
|
|
&mut self,
|
|
tab: TabId,
|
|
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(tab, json!({ "type": "error", "message": message }));
|
|
return;
|
|
}
|
|
};
|
|
if self.refuse_conflict(tab, &url) {
|
|
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);
|
|
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
t.probe_generation += 1;
|
|
let generation = t.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 {
|
|
tab,
|
|
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();
|
|
let current = self
|
|
.tab(done.tab)
|
|
.is_some_and(|t| t.probe_generation == done.generation);
|
|
if !current || !self.choosing(done.tab) {
|
|
// Cancelled, superseded, the tab closed, 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;
|
|
}
|
|
let tab = done.tab;
|
|
match done.result {
|
|
ProbeResult::SavedPasswordUnreadable => {
|
|
// needsPassword: the page shows the login fields, which the
|
|
// This Mac tab does not have.
|
|
self.to_connect_page(
|
|
tab,
|
|
json!({ "type": "error", "message": KEYCHAIN_UNREADABLE, "needsPassword": true }),
|
|
);
|
|
}
|
|
ProbeResult::Probed(ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired)
|
|
if self.refuse_conflict(tab, &done.url) =>
|
|
{
|
|
// Another tab got to the same server (or, before macOS 14, the
|
|
// same machine) while this probe was out.
|
|
self.note_deleted_passwords(&key, &done.deleted);
|
|
}
|
|
ProbeResult::Probed(outcome @ (ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired)) => {
|
|
let password_change = outcome == ProbeOutcome::PasswordChangeRequired;
|
|
let (opening, saved, version) = match outcome {
|
|
ProbeOutcome::Ok { version } => (
|
|
json!({ "type": "opening", "version": version }),
|
|
done.saved,
|
|
Some(version),
|
|
),
|
|
// 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,
|
|
None,
|
|
),
|
|
};
|
|
let orphans = self.config.remember(&key, done.username.as_deref(), saved);
|
|
self.delete_accounts(&orphans);
|
|
self.config_dirty = true;
|
|
if let Some(t) = self.tab_mut(tab) {
|
|
t.version = version;
|
|
t.status_login = !password_change;
|
|
}
|
|
self.to_connect_page(tab, opening);
|
|
self.begin_loading(tab, 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(
|
|
tab,
|
|
json!({
|
|
"type": "probeResult",
|
|
"url": done.url.as_str(),
|
|
"outcome": outcome,
|
|
"canStart": can_start,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Point a tab at the server: a server view of its own data store is built
|
|
/// hidden behind the connect page, the server's cookies are cleared from
|
|
/// that store first (an earlier login's session cookie, another user, a
|
|
/// Forget, a password changed since, would win over this login's
|
|
/// Authorization header, since Codeman checks the cookie first), and the
|
|
/// load starts once the clear is done (UserEvent::CookiesCleared). The
|
|
/// connect page stays in front 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, tab: TabId, url: Url, username: Option<String>, password: Option<String>) {
|
|
let store = self.config.store_for(url.as_str());
|
|
let Some(phase) = self.tab(tab).map(|t| t.phase.clone()) else {
|
|
return;
|
|
};
|
|
// Before the view is built and loads: the navigation handler allows
|
|
// the server's origin only once the phase names it.
|
|
*phase.borrow_mut() = Phase::Loading(url.clone());
|
|
let view = self.build_child(Part::Server, tab, Some(phase), Load::Nothing, store);
|
|
let auth = basic_auth(username.as_deref(), password.as_deref());
|
|
let proxy = self.proxy.clone();
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
t.server = view;
|
|
t.store = store;
|
|
t.auth = auth;
|
|
t.loading_deadline = Some(Instant::now() + LOAD_TIMEOUT);
|
|
let generation = t.probe_generation;
|
|
// Set before the load: its first response may already be a challenge.
|
|
self.set_login(tab, username.zip(password).map(|(u, p)| (&url, u, p)));
|
|
self.tabs_changed();
|
|
clear_cookies_then_load(&proxy, tab, generation, url, store);
|
|
}
|
|
|
|
/// Hand the challenge handler a tab's new login, as (server, username,
|
|
/// password), or none, and start a new login generation for the tab, so a
|
|
/// recheck or a status read of the one before it is ignored when it comes
|
|
/// back.
|
|
fn set_login(&mut self, tab: TabId, login: Option<(&Url, String, String)>) {
|
|
set_challenge_login(
|
|
tab,
|
|
login.as_ref().map(|(server, u, p)| (*server, u.as_str(), p.as_str())),
|
|
);
|
|
if let Some(t) = self.tab_mut(tab) {
|
|
t.login = login.map(|(_, u, p)| (u, p));
|
|
t.login_generation += 1;
|
|
}
|
|
}
|
|
|
|
fn start_server(&mut self, tab: TabId, raw: String) {
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
if let Some(running) = t.starting_server {
|
|
// A start is still going, maybe one the page cancelled: its result
|
|
// goes to the page that asked again.
|
|
t.start_generation = running;
|
|
self.to_connect_page(tab, 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(tab, json!({ "type": "error", "message": START_REFUSED }));
|
|
return;
|
|
}
|
|
Err(message) => {
|
|
self.to_connect_page(tab, json!({ "type": "error", "message": message }));
|
|
return;
|
|
}
|
|
};
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
t.start_generation += 1;
|
|
let generation = t.start_generation;
|
|
t.starting_server = Some(generation);
|
|
self.to_connect_page(tab, json!({ "type": "serverStarting" }));
|
|
let proxy = self.proxy.clone();
|
|
std::thread::spawn(move || {
|
|
let result = setup::start_local(&url);
|
|
let _ = proxy.send_event(UserEvent::ServerStartDone {
|
|
tab,
|
|
generation,
|
|
url,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Leave whatever a tab shows for its connect page: Connect This Tab to
|
|
/// Another 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 (the menu item, Cancel): the tab
|
|
/// comes forward and takes the keyboard. A timeout or a refused login can
|
|
/// happen while the user works elsewhere, so then the tab stays where it
|
|
/// is and the Dock icon bounces once instead, unless the window is the one
|
|
/// in use.
|
|
fn show_connect_page(&mut self, tab: TabId, notice: Option<String>, front: bool) {
|
|
self.close_popouts_of(tab);
|
|
self.set_login(tab, None);
|
|
let needs_view = self.tab(tab).is_some_and(|t| t.connect.is_none());
|
|
let phase = self.tab(tab).map(|t| t.phase.clone());
|
|
let Some(phase) = phase else {
|
|
return;
|
|
};
|
|
// Before the load: the navigation handler allows the connect page
|
|
// only while the phase says it is live.
|
|
*phase.borrow_mut() = Phase::Connect;
|
|
let built = needs_view
|
|
.then(|| {
|
|
self.build_child(
|
|
Part::Connect,
|
|
tab,
|
|
Some(phase),
|
|
Load::ConnectPage { manual: true },
|
|
None,
|
|
)
|
|
})
|
|
.flatten();
|
|
let Some(t) = self.tab_mut(tab) else {
|
|
return;
|
|
};
|
|
t.probe_generation += 1;
|
|
t.loading_deadline = None;
|
|
t.auth = None;
|
|
t.notice = notice;
|
|
t.connect_to = None;
|
|
t.server = None;
|
|
t.watch = None;
|
|
t.seen = tabs::Seen::default();
|
|
t.page_title = None;
|
|
match built {
|
|
Some(view) => t.connect = Some(view),
|
|
None => {
|
|
if let Some(connect) = &t.connect {
|
|
let _ = connect.webview.load_url(&connect_page_url(true));
|
|
}
|
|
}
|
|
}
|
|
if front {
|
|
self.select_tab_id(tab);
|
|
self.show_main_window();
|
|
} else {
|
|
let in_front = self.active_tab().is_some_and(|t| t.id == tab);
|
|
self.show_front(in_front && self.main_window.is_focused());
|
|
if !self.main_window.is_focused() || self.main_window.is_minimized() {
|
|
self.main_window
|
|
.request_user_attention(Some(UserAttentionType::Informational));
|
|
}
|
|
}
|
|
self.save_tabs();
|
|
self.sync_logo_watch();
|
|
self.sync_keep_awake();
|
|
}
|
|
|
|
fn show_main_window(&self) {
|
|
let w = &self.main_window;
|
|
w.set_minimized(false);
|
|
w.set_visible(true);
|
|
w.set_focus();
|
|
}
|
|
|
|
/// The URL a server view or pop-out reloads, handed to wry as is, and
|
|
/// whether the login goes with it. A tab's server view: its committed page
|
|
/// if that is Codeman's own app shell, else the server root (a raw file
|
|
/// that replaced the page, for example). A 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, view: ViewId) -> Option<(String, bool)> {
|
|
let (tab, target) = match self.owner(view)? {
|
|
Owner::Server(tab) => {
|
|
let t = self.tab(tab)?;
|
|
let server = t.phase.borrow().loaded().cloned()?;
|
|
let committed = t.server.as_ref()?.committed.clone();
|
|
let target = match committed {
|
|
Some(current) if policy::trusted_frame(¤t, &server) => current,
|
|
_ => server,
|
|
};
|
|
(tab, target)
|
|
}
|
|
Owner::Popout(window, tab) => {
|
|
let p = self.popouts.get(&window)?;
|
|
let target = p.committed.clone().or_else(|| Url::parse(&p.opened_url).ok())?;
|
|
(tab, target)
|
|
}
|
|
_ => return None,
|
|
};
|
|
let server = self.tab(tab)?.phase.borrow().loaded().cloned()?;
|
|
let with_auth = policy::within_base(&target, &server);
|
|
Some((policy::webkit_safe_url(&target), with_auth))
|
|
}
|
|
|
|
/// Load `url` in `view`, with its tab's login when `with_auth` and there
|
|
/// is one.
|
|
fn load_in(&self, view: ViewId, url: &str, with_auth: bool) {
|
|
let tab = match self.owner(view) {
|
|
Some(Owner::Server(tab) | Owner::Popout(_, tab) | Owner::Connect(tab)) => tab,
|
|
_ => return,
|
|
};
|
|
let Some(webview) = self.webview(view) else {
|
|
return;
|
|
};
|
|
let auth = self.tab(tab).and_then(|t| t.auth.clone()).filter(|_| with_auth);
|
|
let result = match auth {
|
|
Some(auth) => webview.load_url_with_headers(url, auth_headers(&auth)),
|
|
None => webview.load_url(url),
|
|
};
|
|
if let Err(e) = result {
|
|
eprintln!("codeman-desktop: could not load a page: {e}");
|
|
}
|
|
}
|
|
|
|
/// Reload: the pop-out in use, or the tab in front. A tab that has not
|
|
/// reached its server yet starts over on its connect page.
|
|
fn reload_focused(&mut self) {
|
|
if let Some(window) = self.focused_popout() {
|
|
let view = self.popouts.get(&window).map(|p| p.view);
|
|
if let Some((view, (url, with_auth))) = view.and_then(|v| self.reload_target(v).map(|r| (v, r))) {
|
|
self.load_in(view, &url, with_auth);
|
|
}
|
|
return;
|
|
}
|
|
if !self.main_window.is_focused() {
|
|
return;
|
|
}
|
|
if self.overview_shown {
|
|
if let Some(o) = &self.overview {
|
|
let _ = o.webview.load_url(policy::OVERVIEW_PAGE);
|
|
}
|
|
return;
|
|
}
|
|
let Some(t) = self.active_tab() else {
|
|
return;
|
|
};
|
|
let tab = t.id;
|
|
let server_view = t
|
|
.server
|
|
.as_ref()
|
|
.map(|v| v.id)
|
|
.filter(|_| t.kind() == tabs::Kind::Server);
|
|
match server_view {
|
|
Some(view) => {
|
|
if let Some((url, with_auth)) = self.reload_target(view) {
|
|
self.load_in(view, &url, with_auth);
|
|
}
|
|
}
|
|
None => self.show_connect_page(tab, None, true),
|
|
}
|
|
}
|
|
|
|
/// The page in `view` 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, view: ViewId, tab: TabId) {
|
|
if self.tab(tab).is_none_or(|t| t.auth.is_none()) {
|
|
return;
|
|
}
|
|
let now = Instant::now();
|
|
let recent =
|
|
|at: Option<Instant>| at.is_some_and(|at| now.saturating_duration_since(at) < SESSION_RELOAD_COOLDOWN);
|
|
let last = match self.owner(view) {
|
|
Some(Owner::Server(_)) => self.child_view_mut(view).map(|v| v.session_reload_at),
|
|
Some(Owner::Popout(window, _)) => self.popouts.get(&window).map(|p| p.session_reload_at),
|
|
_ => None,
|
|
};
|
|
let Some(last) = last else {
|
|
return;
|
|
};
|
|
if recent(last) {
|
|
return;
|
|
}
|
|
let Some((url, true)) = self.reload_target(view) else {
|
|
return;
|
|
};
|
|
match self.owner(view) {
|
|
Some(Owner::Server(_)) => {
|
|
if let Some(v) = self.child_view_mut(view) {
|
|
v.session_reload_at = Some(now);
|
|
}
|
|
}
|
|
Some(Owner::Popout(window, _)) => {
|
|
if let Some(p) = self.popouts.get_mut(&window) {
|
|
p.session_reload_at = Some(now);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
self.load_in(view, &url, true);
|
|
}
|
|
|
|
/// Open one of Codeman's pop-out pages of a tab's server 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>,
|
|
tab: TabId,
|
|
url: &Url,
|
|
focused: bool,
|
|
) -> bool {
|
|
let Some(t) = self.tab(tab) else {
|
|
return false;
|
|
};
|
|
let Some(server) = t.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.popouts.len();
|
|
if popouts >= MAX_POPOUTS {
|
|
return false;
|
|
}
|
|
let phase = t.phase.clone();
|
|
let store = t.store;
|
|
let opened_url = policy::webkit_safe_url(url);
|
|
let auth = t.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_popout()
|
|
.and_then(|w| self.popouts.get(&w))
|
|
.map(|p| &p.window)
|
|
.unwrap_or(&self.main_window);
|
|
let position = placed_position(anchor).ok().map(|p| {
|
|
let p = p.to_logical::<f64>(anchor.scale_factor());
|
|
let step = POPOUT_STEP * (1 + popouts % 8) as f64;
|
|
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 view = self.next_view_id();
|
|
let spec = ViewSpec {
|
|
id: view,
|
|
part: Part::Popout,
|
|
tab,
|
|
phase: Some(phase),
|
|
load: Load::Server {
|
|
url: &opened_url,
|
|
auth: auth.as_deref(),
|
|
},
|
|
store,
|
|
bounds: None,
|
|
visible: true,
|
|
focused,
|
|
};
|
|
let webview = match build_webview(&window, spec, &self.proxy) {
|
|
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;
|
|
}
|
|
};
|
|
self.popouts.insert(
|
|
window.id(),
|
|
Popout {
|
|
webview,
|
|
window,
|
|
view,
|
|
tab,
|
|
opened_url,
|
|
hidden_for_download: false,
|
|
committed: None,
|
|
session_reload_at: None,
|
|
gone_at: None,
|
|
},
|
|
);
|
|
true
|
|
}
|
|
|
|
/// Tell the page in `view` 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, view: ViewId, session_id: Option<&str>) {
|
|
if let Some(webview) = self.webview(view) {
|
|
let _ = 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 and forget its downloads.
|
|
fn remove_popout(&mut self, window: WindowId) {
|
|
if let Some(p) = self.popouts.remove(&window) {
|
|
self.downloads.retain(|(view, _), _| *view != p.view);
|
|
}
|
|
}
|
|
|
|
/// The pop-out that is key, if one is.
|
|
fn focused_popout(&self) -> Option<WindowId> {
|
|
self.popouts
|
|
.iter()
|
|
.find(|(_, p)| p.window.is_focused())
|
|
.map(|(id, _)| *id)
|
|
}
|
|
|
|
/// Message a tab's connect page (only while it is that tab's live page).
|
|
fn to_connect_page(&self, tab: TabId, msg: serde_json::Value) {
|
|
let Some(t) = self.tab(tab) else {
|
|
return;
|
|
};
|
|
if !t.phase.borrow().connect_page_live() {
|
|
return;
|
|
}
|
|
if let Some(connect) = &t.connect {
|
|
let _ = connect
|
|
.webview
|
|
.evaluate_script(&assets::native_message_script("__codemanDesktop", &msg));
|
|
}
|
|
}
|
|
|
|
fn to_every_connect_page(&self, msg: serde_json::Value) {
|
|
for t in &self.tabs {
|
|
self.to_connect_page(t.id, msg.clone());
|
|
}
|
|
}
|
|
|
|
fn record_main_frame(&mut self) {
|
|
let w = &self.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;
|
|
}
|
|
// The content size in points, which (under the transparent title
|
|
// bar) is the whole window, and is what `with_inner_size` takes
|
|
// back at the next launch.
|
|
let (width, height) = content_size(w);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|