Compare commits

..
Author SHA1 Message Date
Codeman maintainer 2f9843c94a feat(desktop): a simpler Keep This Mac Awake menu
The owner found the options confusing after the first test build. The
menu now reads Off / While Agents Work / Always, then on a MacBook
Only When Plugged In and With the Lid Closed…, then one status line.

- "Always While Codeman Is Open" is now "Always"; the status line says
  "Awake: Codeman is open".
- "Only on Power Adapter" is now "Only When Plugged In".
- "Remove the Lid Helper…" is gone: the lid item is the helper.
  Ticking it installs the helper behind the administrator prompt,
  unticking it removes it behind another. The setting changes only
  once the prompt's command ran, so a cancelled prompt leaves the item
  as it was.
- Shorter status lines ("Awake: agents working, lid closed too",
  "Paused: on battery", "Sleeps as usual: no agent working").

Config keys and values are unchanged, so a saved choice carries over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:44:26 +01:00
Codeman maintainer 79a6c03363 feat(desktop): keep the Mac awake while agents work, lid closed included
Codeman > Keep This Mac Awake in the macOS app: While Agents Work (an
agent at work, plus 5 minutes after the last one stops) or Always While
Codeman Is Open. Off by default, and only for a Codeman on this Mac.

- Idle sleep needs no password: the app holds an NSProcessInfo activity
  with IdleSystemSleepDisabled, which the kernel drops with the process.
  While it only watches for work, an activity that still allows sleep
  keeps App Nap from slowing its look at the server.
- On a MacBook, Only on Power Adapter (on by default) pauses it on
  battery, and Also With the Lid Closed installs a small LaunchDaemon
  behind macOS's administrator prompt. It sets pmset disablesleep only
  while the app keeps a fresh request file with its live pid, only on
  the power adapter, and undoes only what it set itself. The app writes
  the script and plist as root from the copies built into it.
- A status line at the bottom of the submenu says what it does now.

This brings the macOS part of the keep-awake idea from PR #595 into the
app; the server-side setting and the Linux route are left out for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:51:39 +01:00
14 changed files with 2470 additions and 520 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': patch
---
The path picker's image preview can now be zoomed: pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click to toggle 2x, and zoom out / fit / zoom in buttons. A small image is shown at its own size instead of being stretched to fill the preview. Thanks to @shenlvkang-collab, whose #608 this comes from.
+9
View File
@@ -252,6 +252,15 @@ On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
your own for working, for waiting for you and for needing permission. All three are off
until you choose, and the menu only exists on such a Mac.
**Codeman > Keep This Mac Awake** stops the Mac from going to sleep while your agents work
on it (**While Agents Work**, plus 5 minutes after the last one stops), or for as long as
the app is open (**Always**). It is off until you choose, only acts for a Codeman running
on this Mac, needs no password, and ends when the app quits. On a MacBook it pauses on
battery unless you untick **Only When Plugged In**, and **With the Lid Closed…** keeps a
closed MacBook awake too: ticking it asks for your administrator password to install a
small helper, unticking it asks again and removes the helper, and it never acts on
battery.
### First launch
1. Open `Codeman.dmg` and drag Codeman into Applications.
+1
View File
@@ -50,6 +50,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"NSGeometry",
"NSObject",
"NSOperation",
"NSProcessInfo",
"NSRange",
"NSSet",
"NSString",
+33
View File
@@ -41,6 +41,7 @@ cannot do.
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always** (while the app is open). Off until you choose. Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says so. The display still sleeps. No password is needed for this part: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only When Plugged In** (on by default) pauses it on battery, and **With the Lid Closed…** keeps a closed MacBook awake as well: nothing an app holds can do that, so ticking it asks for your administrator password and installs a small helper (see Decisions), and unticking it asks again and removes the helper. The lid is never kept awake on battery, whatever the settings, and while the lid is covered *Sleep* in the Apple menu does nothing. A line at the bottom of the menu says what it does right now. It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. |
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
## Build (on a Mac)
@@ -147,6 +148,30 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
- `codeman web -d` gets the port and base path as separate arguments, never as
shell text.
**Keeping a closed MacBook awake takes a root helper, installed on request.**
macOS sleeps on lid close whatever an app asks: `caffeinate` and every power
assertion stop idle sleep only. `pmset -a disablesleep 1` keeps a closed MacBook
awake, but it is a machine-wide root setting with no owner process, so nothing
would undo it after a crash. So ticking *With the Lid Closed…* installs a
LaunchDaemon (`com.getcodeman.keepawake`) that runs `macos/keep-awake-lid.sh`
every 20 seconds. The script sets `disablesleep` only while a request file next
to `desktop.json` is younger than 2 minutes, is not a symlink, names a live
process of the file's owner, and `pmset -g batt` says the Mac runs on its power
adapter. The app writes its own pid there and rewrites it every 30 seconds while
it wants the lid covered, so an app that quits or crashes is a dead pid and the
lid is released at the next run. The script undoes only a `disablesleep` it set
itself (a marker file), never an administrator's own. The app writes the script
and the plist itself, as root, straight into `/Library/Application
Support/Codeman` and `/Library/LaunchDaemons` from the copies built into it
(base64 in the root command, so nothing user-writable is copied and no file
content needs quoting), behind macOS's administrator prompt (`do shell script
... with administrator privileges` through `osascript`, so the event loop keeps
running while the prompt is open). The idle-sleep part needs none of this: an
`NSProcessInfo` activity with `IdleSystemSleepDisabled`, released by the kernel
with the process. While the app watches for work but holds nothing, a second
kind of activity keeps App Nap from slowing its look at the server, and still
allows sleep.
**The app runs the installer itself; Terminal is the fallback.** `install.sh
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
no password and therefore no terminal, and the two questions only a person can
@@ -319,6 +344,14 @@ crosses, so the login is never on a command line.
dark phase the logo stays dark until the app next starts. `api/sessions` does
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
keep-alive turn blinks too.
- **Keeping the lid awake has not been seen on a real closed MacBook yet.**
The helper ran every case against a stand-in `pmset`, the install and removal
commands ran in a temporary folder with stand-ins for `chown` and `launchctl`,
and the power assertion was measured on a Mac mini (it appears with its name
while agents work, and goes with a clean end and with a SIGKILL). Whether a
closed MacBook goes to sleep at once when the helper lets go (the app quit, an
agent finished, the adapter came out) has not been watched. The helper is
machine-wide: on a Mac where two users install it, the last install wins.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# keep-awake-lid.sh: the root helper behind "Keep This Mac Awake > Also With the Lid
# Closed" in the Codeman app for Mac.
#
# The app keeps the Mac from idle sleep on its own, with no root (a power assertion).
# Closing the lid is another matter: only `pmset -a disablesleep 1` keeps a closed
# MacBook awake, and that is a machine-wide root setting with no owner process. So this
# helper, run by the com.getcodeman.keepawake LaunchDaemon every 20 seconds, applies it
# while the app asks for it and undoes it when the app stops asking:
#
# - The app writes its pid to the request file, and rewrites it every 30 seconds, while
# it wants the lid covered.
# - The request counts only while the file is fresh (under 2 minutes old), is not a
# symlink, its pid is alive, that process belongs to the file's owner, and the Mac
# runs on its power adapter. An app that quits or crashes is a dead pid, so the lid
# is released at the next run.
# - Never on battery, whatever the app says: a closed MacBook that cannot sleep in a
# bag only heats up and drains.
# - It undoes only a disablesleep it set itself (the .owned file), so an
# administrator's own `pmset -a disablesleep 1` is never switched off.
#
# The app writes this file to a ROOT-OWNED path itself, behind macOS's administrator
# prompt; a root daemon must never run a file a user can edit.
#
# Usage: keep-awake-lid.sh <request-file>
# Test hooks (set only by the test suite; launchd passes no environment of the user's):
# CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR.
#
# bash 3.2 (what macOS ships) and BSD tools only.
set -u
REQ="${1:?usage: keep-awake-lid.sh <request-file>}"
PMSET="${CODEMAN_KEEPAWAKE_PMSET:-/usr/bin/pmset}"
STATE_DIR="${CODEMAN_KEEPAWAKE_STATE_DIR:-/Library/Application Support/Codeman}"
OWNED="$STATE_DIR/keep-awake.owned"
want=0
# -L: refuse a symlink (root must not be steered to read some other file).
# find -mmin -2: modified within the last 2 minutes (BSD and GNU find both support it).
if [ -f "$REQ" ] && [ ! -L "$REQ" ] && [ -n "$(find "$REQ" -mmin -2 2>/dev/null)" ]; then
pid=$(head -c 32 "$REQ" 2>/dev/null | tr -dc '0-9')
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
file_uid=$(ls -ln "$REQ" 2>/dev/null | awk '{print $3}')
pid_uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d ' ')
if [ -n "$file_uid" ] && [ "$file_uid" = "$pid_uid" ]; then
want=1
fi
fi
fi
# The power adapter, or nothing: an unreadable answer counts as battery.
if [ "$want" = "1" ] && ! "$PMSET" -g batt 2>/dev/null | grep -q "'AC Power'"; then
want=0
fi
current=$("$PMSET" -g 2>/dev/null | awk '/SleepDisabled/ {print $2; exit}')
[ -n "$current" ] || current=0
if [ "$want" = "1" ]; then
# Already 1 without our marker means an administrator set it: leave it and do not
# claim it, so releasing later never switches their setting off.
if [ "$current" != "1" ]; then
if "$PMSET" -a disablesleep 1; then
mkdir -p "$STATE_DIR" && : > "$OWNED"
fi
fi
elif [ -f "$OWNED" ]; then
if [ "$current" = "1" ]; then
"$PMSET" -a disablesleep 0 || exit 1
fi
rm -f "$OWNED"
fi
exit 0
+37
View File
@@ -3,6 +3,7 @@
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
//! never go here; they live in the Keychain (see `credentials`).
use crate::keep_awake;
use crate::logo::BlinkStyles;
use serde::{Deserialize, Serialize};
use std::fs;
@@ -49,6 +50,10 @@ pub struct Config {
/// reads as off: a bad style must not make the whole file unreadable.
#[serde(deserialize_with = "lenient_blink")]
pub logo_blink: BlinkStyles,
/// Keep This Mac Awake (keep_awake.rs): when, only on the power adapter, and
/// with the lid closed too. Read field by field, like `logo_blink`.
#[serde(deserialize_with = "lenient_keep_awake")]
pub keep_awake: keep_awake::Settings,
/// Anything that is not a whole frame reads as None: a bad frame must not
/// make the whole file unreadable, which would cost the server list.
#[serde(deserialize_with = "lenient_window")]
@@ -64,6 +69,10 @@ fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles,
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
}
fn lenient_keep_awake<'de, D: serde::Deserializer<'de>>(d: D) -> Result<keep_awake::Settings, D::Error> {
Ok(keep_awake::Settings::read(&serde_json::Value::deserialize(d)?))
}
impl Default for Config {
fn default() -> Self {
Config {
@@ -71,6 +80,7 @@ impl Default for Config {
auto_connect: true,
start_fullscreen: false,
logo_blink: BlinkStyles::default(),
keep_awake: keep_awake::Settings::default(),
window: None,
}
}
@@ -429,6 +439,33 @@ mod tests {
}
}
#[test]
fn keep_awake_round_trips_and_a_bad_value_never_costs_the_file() {
use crate::keep_awake::{Mode, Settings};
let mut c = Config::default();
assert_eq!(c.keep_awake, Settings::default());
c.keep_awake.mode = Mode::WhileWorking;
c.keep_awake.lid = true;
let text = serde_json::to_string(&c).unwrap();
assert!(
text.contains(r#""keepAwake":{"mode":"whileWorking","acOnly":true,"lid":true}"#),
"{text}"
);
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().keep_awake, c.keep_awake);
for bad in [
r#"{"keepAwake":3}"#,
r#"{"keepAwake":null}"#,
r#"{"keepAwake":"always"}"#,
] {
let c: Config = serde_json::from_str(bad).unwrap();
assert_eq!(c.keep_awake, Settings::default(), "{bad}");
}
// A file from before the setting: every other field still reads.
let c: Config = serde_json::from_str(r#"{"startFullscreen":true}"#).unwrap();
assert!(c.start_fullscreen);
assert_eq!(c.keep_awake, Settings::default());
}
#[test]
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
use crate::logo::BlinkStyle;
File diff suppressed because it is too large. Load diff
+18
View File
@@ -222,6 +222,13 @@ pub fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
imp::content_size(window)
}
/// An app-modal message with an OK button, for something the user asked the app
/// itself to do that failed. Main thread only; does nothing elsewhere.
#[cfg(target_os = "macos")]
pub fn show_message(title: &str, detail: &str) {
imp::show_message(title, detail);
}
#[cfg(target_os = "macos")]
mod imp {
use std::cell::{Cell, RefCell};
@@ -456,6 +463,17 @@ mod imp {
}
}
pub(super) fn show_message(title: &str, detail: &str) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let alert = NSAlert::new(mtm);
alert.setMessageText(&NSString::from_str(title));
alert.setInformativeText(&NSString::from_str(detail));
let _ = alert.addButtonWithTitle(&NSString::from_str("OK"));
alert.runModal();
}
pub(super) fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
// NSWindow is main-thread only.
MainThreadMarker::new()?;
+175
View File
@@ -27,6 +27,7 @@ mod assets;
mod config;
mod credentials;
mod gui_install;
mod keep_awake;
mod local_server;
mod login;
mod logo;
@@ -182,6 +183,18 @@ enum UserEvent {
/// install opened in Terminal, the install command copied): the message
/// goes to the page if it is still the one choosing a server.
ToConnectPage(serde_json::Value),
/// The keep-awake worker's status line, for the value numbered `generation`
/// (keep_awake::KeepAwake::is_current).
KeepAwakeStatus {
generation: u64,
text: String,
},
/// The administrator prompt for the lid helper was answered, and its command
/// ran (keep_awake::install_helper, keep_awake::remove_helper).
LidHelperDone {
install: bool,
outcome: keep_awake::AdminOutcome,
},
}
struct ProbeDone {
@@ -318,6 +331,10 @@ struct App {
fullscreen_retry: Option<(Instant, u8)>,
/// The child that watches the connected server and blinks the logo.
logo_watch: logo::Watcher,
/// The worker that keeps this Mac awake while its agents work.
keep_awake: keep_awake::KeepAwake,
/// An administrator prompt for the lid helper is open.
lid_helper_busy: bool,
}
fn main() {
@@ -370,6 +387,7 @@ fn main() {
let mut windows = HashMap::new();
windows.insert(main_id, AppWindow::new(webview, window, Kind::Main));
let keep_awake_proxy = proxy.clone();
let mut app = App {
proxy,
config,
@@ -397,6 +415,10 @@ fn main() {
add_agent_on_load: false,
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
logo_watch: logo::Watcher::default(),
keep_awake: keep_awake::KeepAwake::new(move |generation, text| {
let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text });
}),
lid_helper_busy: false,
};
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
@@ -730,6 +752,7 @@ impl App {
} => 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();
}
@@ -796,6 +819,7 @@ impl App {
if id == self.main_id {
// Closing the main window quits; the server and its
// sessions keep running, so nothing is lost.
self.keep_awake.shutdown();
self.record_main_frame();
self.save_config();
*control_flow = ControlFlow::Exit;
@@ -875,6 +899,7 @@ impl App {
*self.phase.borrow_mut() = next;
}
self.sync_logo_watch();
self.sync_keep_awake();
}
if let Some(w) = self.windows.get_mut(&window) {
w.committed = Url::parse(&url).ok();
@@ -937,6 +962,14 @@ impl App {
}
UserEvent::AuthRejected => self.recheck_login(),
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
UserEvent::KeepAwakeStatus { generation, text } => {
if self.keep_awake.is_current(generation) {
if let Some(items) = &self.menu.keep_awake {
items.status.set_text(&text);
}
}
}
UserEvent::LidHelperDone { install, outcome } => self.on_lid_helper_done(install, outcome),
}
}
@@ -1061,12 +1094,154 @@ impl App {
}
}
/// Keep-awake watches the open server while a mode is chosen. Whether that
/// server is this Mac is the worker's to find out.
fn sync_keep_awake(&mut self) {
let settings = self.config.keep_awake;
let server = self.phase.borrow().loaded().cloned();
let watch = server
.filter(|_| settings.mode != keep_awake::Mode::Off)
.map(|server| keep_awake::Watch {
server,
authorization: self.auth.clone(),
settings,
});
let idle = match (settings.mode, &watch) {
(keep_awake::Mode::Off, _) => Some(keep_awake::OFF_TEXT),
(_, None) => Some(keep_awake::NO_SERVER_TEXT),
_ => None,
};
self.keep_awake.sync(watch);
if let (Some(text), Some(items)) = (idle, &self.menu.keep_awake) {
items.status.set_text(text);
}
}
fn choose_keep_awake_mode(&mut self, mode: keep_awake::Mode) {
if let Some(items) = &self.menu.keep_awake {
// A click flips the item it landed on, the chosen one included.
for (item_mode, item) in &items.modes {
item.set_checked(*item_mode == mode);
}
}
self.config.keep_awake.mode = mode;
self.config_dirty = true;
self.sync_keep_awake();
}
fn toggle_keep_awake_ac_only(&mut self) {
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.ac_only.as_ref()) else {
return;
};
self.config.keep_awake.ac_only = item.is_checked();
self.config_dirty = true;
self.sync_keep_awake();
}
/// The lid item is the helper: ticking it installs the helper behind the
/// administrator prompt (unless this version's is already in), unticking it removes
/// it behind another. The setting changes only once the prompt's command ran, so a
/// cancelled or failed prompt leaves the item as it was.
fn toggle_keep_awake_lid(&mut self) {
use keep_awake::HelperState;
let Some(wanted) = self
.menu
.keep_awake
.as_ref()
.and_then(|items| items.lid.as_ref())
.map(|item| item.is_checked())
else {
return;
};
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let prompt = match (wanted, state) {
_ if self.lid_helper_busy => None,
(true, HelperState::Installed) | (false, HelperState::Missing) => {
self.config.keep_awake.lid = wanted;
self.config_dirty = true;
None
}
(install, _) => Some(install),
};
// The click flipped the item already. Until a prompt's command ran it shows
// what is true.
if let Some(items) = &self.menu.keep_awake {
items.show_helper(state, self.config.keep_awake.lid);
}
match prompt {
Some(install) => self.run_lid_helper(install),
None => self.sync_keep_awake(),
}
}
fn run_lid_helper(&mut self, install: bool) {
if self.lid_helper_busy {
return;
}
self.lid_helper_busy = true;
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let outcome = match install {
true => keep_awake::install_helper(),
false => keep_awake::remove_helper(),
};
let _ = proxy.send_event(UserEvent::LidHelperDone { install, outcome });
});
}
fn on_lid_helper_done(&mut self, install: bool, outcome: keep_awake::AdminOutcome) {
use keep_awake::{AdminOutcome, HelperState};
self.lid_helper_busy = false;
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let failure = match (&outcome, install, state) {
(AdminOutcome::Cancelled, _, _) => None,
(AdminOutcome::Failed(error), true, _) => Some(("The lid helper could not be installed.", error.clone())),
(AdminOutcome::Failed(error), false, _) => Some(("The lid helper could not be removed.", error.clone())),
(AdminOutcome::Done, true, HelperState::Installed) => {
self.config.keep_awake.lid = true;
self.config_dirty = true;
None
}
(AdminOutcome::Done, true, _) => Some((
"The lid helper could not be installed.",
"The installation ran, but the helper's files are not what the app wrote.".to_owned(),
)),
(AdminOutcome::Done, false, HelperState::Missing) => {
self.config.keep_awake.lid = false;
self.config_dirty = true;
None
}
(AdminOutcome::Done, false, _) => Some((
"The lid helper could not be removed.",
"The removal ran, but the helper's files are still there.".to_owned(),
)),
};
if let Some(items) = &self.menu.keep_awake {
items.show_helper(state, self.config.keep_awake.lid);
}
self.keep_awake.refresh();
self.sync_keep_awake();
if let Some((title, detail)) = failure {
eprintln!("codeman-desktop: {title} {detail}");
#[cfg(target_os = "macos")]
macos::show_message(title, &detail);
}
}
fn on_menu(&mut self, id: &str) {
if let Some((signal, style)) = BlinkStyle::from_menu_id(id) {
self.choose_logo_blink(signal, style);
return;
}
if let Some(mode) = keep_awake::Mode::from_menu_id(id) {
self.choose_keep_awake_mode(mode);
return;
}
match id {
keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(),
keep_awake::LID_ID => self.toggle_keep_awake_lid(),
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
+82
View File
@@ -4,6 +4,7 @@ use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::config::Config;
use crate::keep_awake::{self, HelperState, Mode};
use crate::logo::{self, BlinkStyle, Signal};
use crate::{APP_NAME, VERSION};
@@ -29,6 +30,77 @@ pub struct AppMenu {
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// such menu.
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
/// None where the app cannot keep the Mac awake (a build for another OS).
pub keep_awake: Option<KeepAwakeMenu>,
}
/// App menu > Keep This Mac Awake: Off / While Agents Work / Always, then on a
/// MacBook Only When Plugged In and With the Lid Closed…, then one status line.
pub struct KeepAwakeMenu {
pub modes: Vec<(Mode, CheckMenuItem)>,
/// Only on a Mac with a lid: a desktop Mac always runs on mains and cannot close.
pub ac_only: Option<CheckMenuItem>,
/// Ticked = the helper is in and asked for. Ticking or unticking it asks for the
/// administrator password (install, remove), hence the ellipsis.
pub lid: Option<CheckMenuItem>,
/// What it does right now (keep_awake::status_line). Not clickable.
pub status: MenuItem,
}
impl KeepAwakeMenu {
/// The lid item as the helper now is: ticked only while it is installed and on.
pub fn show_helper(&self, state: HelperState, lid_on: bool) {
if let Some(item) = &self.lid {
item.set_checked(lid_on && state == HelperState::Installed);
}
}
}
fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
let settings = config.keep_awake;
let submenu = Submenu::new("Keep This Mac Awake", true);
let mut modes = Vec::new();
for mode in Mode::ALL {
let item = CheckMenuItem::with_id(mode.menu_id(), mode.label(), true, settings.mode == mode, None);
submenu.append(&item)?;
modes.push((mode, item));
}
let (mut ac_only, mut lid) = (None, None);
if keep_awake::has_lid() {
let state =
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
let ac = CheckMenuItem::with_id(
keep_awake::AC_ONLY_ID,
"Only When Plugged In",
true,
settings.ac_only,
None,
);
let lid_item = CheckMenuItem::with_id(
keep_awake::LID_ID,
"With the Lid Closed\u{2026}",
true,
settings.lid && state == HelperState::Installed,
None,
);
submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item])?;
(ac_only, lid) = (Some(ac), Some(lid_item));
}
let initial = match settings.mode {
Mode::Off => keep_awake::OFF_TEXT,
_ => keep_awake::NO_SERVER_TEXT,
};
let status = MenuItem::new(initial, false, None);
submenu.append_items(&[&PredefinedMenuItem::separator(), &status])?;
Ok((
submenu,
KeepAwakeMenu {
modes,
ac_only,
lid,
status,
},
))
}
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
@@ -67,6 +139,15 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
&PredefinedMenuItem::quit(None),
],
)?;
// Right under "Add a Coding Agent to This Mac…".
let keep_awake = match keep_awake::available() {
true => {
let (submenu, items) = build_keep_awake(config)?;
app_menu.insert(&submenu, 4)?;
Some(items)
}
false => None,
};
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
let edit = Submenu::with_items(
"Edit",
@@ -157,5 +238,6 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
help_menu,
start_fullscreen,
logo_styles,
keep_awake,
})
}
+2 -160
View File
@@ -74,8 +74,6 @@ const PathPicker = {
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_previewImageCleanup: null,
_previewImageZoom: null,
_showHidden: false,
_sortMode: PATH_PICKER_DEFAULT_SORT,
@@ -565,11 +563,6 @@ const PathPicker = {
<strong class="path-preview-title"></strong>
<span class="path-preview-path"></span>
</div>
<div class="path-preview-zoom" hidden aria-label="Image zoom controls">
<button type="button" data-zoom="out" aria-label="Zoom out">−</button>
<button type="button" data-zoom="fit" aria-label="Fit image">Fit</button>
<button type="button" data-zoom="in" aria-label="Zoom in">+</button>
</div>
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
<button type="button" class="path-preview-close" aria-label="Close preview">&times;</button>
</div>
@@ -587,30 +580,13 @@ const PathPicker = {
const body = overlay.querySelector('.path-preview-body');
if (entry.previewKind === 'image') {
const zoomControls = overlay.querySelector('.path-preview-zoom');
body.classList.add('path-preview-image-viewport');
const image = document.createElement('img');
image.className = 'path-preview-image';
image.alt = entry.name;
image.draggable = false;
// Both handlers check the sequence like the text branch: an image whose preview was already
// replaced can still finish loading, and `this._previewOverlay` and the zoom state are then
// the newer preview's. The controls show only once there is an image to zoom.
image.addEventListener('load', () => {
if (requestSequence !== this._previewRequestSequence) return;
body.querySelector('.path-preview-loading')?.remove();
zoomControls.hidden = false;
this._setPreviewImageZoom(1);
});
image.addEventListener('error', () => {
if (requestSequence !== this._previewRequestSequence) return;
zoomControls.hidden = true;
this._previewImageCleanup?.();
this.showPreviewError('Image preview failed to load');
});
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
image.src = previewUrl;
body.appendChild(image);
this._bindPreviewImageGestures(image, body, zoomControls);
} else if (entry.previewKind === 'text') {
fetch(previewUrl)
.then(async (response) => {
@@ -654,142 +630,8 @@ const PathPicker = {
body.replaceChildren(error);
},
_setPreviewImageZoom(scale, focalPoint) {
const state = this._previewImageZoom;
if (!state) return;
const nextScale = Math.max(1, Math.min(6, scale));
if (focalPoint) {
const rect = state.body.getBoundingClientRect();
const qx = focalPoint.x - (rect.left + rect.width / 2);
const qy = focalPoint.y - (rect.top + rect.height / 2);
const ratio = nextScale / state.scale;
state.x = qx + (state.x - qx) * ratio;
state.y = qy + (state.y - qy) * ratio;
}
state.scale = nextScale;
this._clampPreviewImagePan();
state.image.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
const fitButton = state.controls.querySelector('[data-zoom="fit"]');
if (fitButton) fitButton.textContent = `${Math.round(state.scale * 100)}%`;
state.image.classList.toggle('is-zoomed', state.scale > 1);
},
/** Size the image is drawn at before zoom: `object-fit: scale-down` in the viewport box, so never
* larger than its natural size. The box until the image has loaded (no natural size yet). */
_previewImageFitSize(state, rect) {
const naturalWidth = state.image.naturalWidth;
const naturalHeight = state.image.naturalHeight;
if (!naturalWidth || !naturalHeight) return { width: rect.width, height: rect.height };
const fit = Math.min(1, rect.width / naturalWidth, rect.height / naturalHeight);
return { width: naturalWidth * fit, height: naturalHeight * fit };
},
/** Keep the zoomed image covering the viewport: it may move until its edge reaches the viewport's
* edge, never further, and an axis on which it is still smaller than the viewport stays centred.
* Clamping to the image box instead let a small or letterboxed image be panned out of view. */
_clampPreviewImagePan() {
const state = this._previewImageZoom;
if (!state) return;
const rect = state.body.getBoundingClientRect();
const fitted = this._previewImageFitSize(state, rect);
const maxX = Math.max(0, (fitted.width * state.scale - rect.width) / 2);
const maxY = Math.max(0, (fitted.height * state.scale - rect.height) / 2);
state.x = Math.max(-maxX, Math.min(maxX, state.x));
state.y = Math.max(-maxY, Math.min(maxY, state.y));
},
/** Zoom factor for one wheel event, proportional to `deltaY` (d3-zoom's scale: about 15% per
* 100px mouse notch, ten times that for ctrl+wheel, which is how Chrome and Firefox deliver a
* trackpad pinch), so a trackpad's stream of small deltas zooms smoothly instead of 20% per
* event. Clamped per event so one large delta cannot jump to the maximum; no vertical delta
* (a sideways scroll) zooms nothing. */
_previewWheelZoomFactor(event) {
const deltaY = Number(event?.deltaY) || 0;
if (!deltaY) return 1;
const perUnit = event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002;
const exponent = -deltaY * perUnit * (event.ctrlKey ? 10 : 1);
return 2 ** Math.max(-0.5, Math.min(0.5, exponent));
},
_bindPreviewImageGestures(image, body, controls) {
this._previewImageZoom = { image, body, controls, scale: 1, x: 0, y: 0, pointers: new Map(), gesture: null };
const state = this._previewImageZoom;
const cleanups = [];
const listen = (target, name, handler, options) => {
target.addEventListener(name, handler, options);
cleanups.push(() => target.removeEventListener(name, handler, options));
};
const pointer = (event) => ({ x: event.clientX, y: event.clientY });
const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const startGesture = () => {
const points = Array.from(state.pointers.values());
if (points.length > 1) {
state.gesture = {
kind: 'pinch',
scale: state.scale,
distance: Math.max(1, distance(points[0], points[1])),
midpoint: midpoint(points[0], points[1]),
};
} else if (points.length === 1) {
state.gesture = { kind: 'pan', point: points[0], x: state.x, y: state.y };
}
};
listen(image, 'pointerdown', (event) => {
event.preventDefault();
image.setPointerCapture?.(event.pointerId);
state.pointers.set(event.pointerId, pointer(event));
startGesture();
});
listen(image, 'pointermove', (event) => {
if (!state.pointers.has(event.pointerId) || !state.gesture) return;
event.preventDefault();
state.pointers.set(event.pointerId, pointer(event));
if (state.pointers.size > 1) {
const points = Array.from(state.pointers.values());
this._setPreviewImageZoom(
state.gesture.scale * distance(points[0], points[1]) / state.gesture.distance,
state.gesture.midpoint
);
} else if (state.gesture.kind === 'pan' && state.scale > 1) {
const point = state.pointers.values().next().value;
state.x = state.gesture.x + point.x - state.gesture.point.x;
state.y = state.gesture.y + point.y - state.gesture.point.y;
this._setPreviewImageZoom(state.scale);
}
});
const endPointer = (event) => {
state.pointers.delete(event.pointerId);
startGesture();
};
listen(image, 'pointerup', endPointer);
listen(image, 'pointercancel', endPointer);
listen(image, 'lostpointercapture', endPointer);
listen(image, 'dblclick', (event) => {
event.preventDefault();
this._setPreviewImageZoom(state.scale > 1 ? 1 : 2, pointer(event));
});
listen(body, 'wheel', (event) => {
event.preventDefault();
const factor = this._previewWheelZoomFactor(event);
if (factor !== 1) this._setPreviewImageZoom(state.scale * factor, pointer(event));
}, { passive: false });
listen(controls, 'click', (event) => {
const button = event.target.closest('[data-zoom]');
if (!button) return;
if (button.dataset.zoom === 'fit') this._setPreviewImageZoom(1);
else this._setPreviewImageZoom(state.scale * (button.dataset.zoom === 'in' ? 1.5 : 1 / 1.5));
});
this._previewImageCleanup = () => {
cleanups.forEach((cleanup) => cleanup());
this._previewImageZoom = null;
this._previewImageCleanup = null;
};
},
closePreview(restoreFocus = true) {
this._previewRequestSequence += 1;
this._previewImageCleanup?.();
this._previewOverlay?.remove();
this._previewOverlay = null;
const previousFocus = this._previewPreviousFocus;
+4 -46
View File
@@ -16218,29 +16218,6 @@ body.touch-device.cjk-input-visible .main {
text-decoration: none;
}
.path-preview-zoom {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 3px;
}
.path-preview-zoom[hidden] {
display: none;
}
.path-preview-zoom button {
min-width: 32px;
height: 34px;
padding: 0 6px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 7px;
font-size: 0.75rem;
cursor: pointer;
}
.path-preview-close {
width: 38px;
height: 38px;
@@ -16259,12 +16236,6 @@ body.touch-device.cjk-input-visible .main {
background: #111;
}
.path-preview-body.path-preview-image-viewport {
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
}
.path-preview-loading,
.path-preview-error {
padding: 24px;
@@ -16279,23 +16250,10 @@ body.touch-device.cjk-input-visible .main {
.path-preview-image {
display: block;
width: 100%;
height: 100%;
/* scale-down, not contain: a small image keeps its own size instead of being blown up to the
viewport (the zoom controls are there to enlarge it). */
object-fit: scale-down;
touch-action: none;
user-select: none;
-webkit-user-drag: none;
cursor: zoom-in;
}
.path-preview-image.is-zoomed {
cursor: grab;
}
.path-preview-image.is-zoomed:active {
cursor: grabbing;
max-width: 100%;
max-height: 100%;
margin: auto;
object-fit: contain;
}
.path-preview-frame {
+234
View File
@@ -0,0 +1,234 @@
/**
* @fileoverview Runs the real lid helper of the Codeman app for Mac
* (packages/desktop/macos/keep-awake-lid.sh) in bash against a stub `pmset` that
* records what it was asked to do. The helper runs as root from a LaunchDaemon, so the
* cases that matter are the ones where it must NOT act: a stale, dead, foreign or
* symlinked request, a Mac on battery, and an administrator's own `disablesleep 1`,
* which it must never switch off.
*
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
* here, so this passes on Linux and macOS alike. The app embeds the same file
* (include_str! in packages/desktop/src/keep_awake.rs), so this is the script that
* gets installed. Port: none.
*/
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const HELPER = fileURLToPath(new URL('../packages/desktop/macos/keep-awake-lid.sh', import.meta.url));
let dir: string;
let stateDir: string;
let request: string;
/**
* Stub pmset: `-g` prints the stored SleepDisabled, `-g batt` the stored power source,
* `-a disablesleep N` stores N and logs it.
*/
function writeStubPmset(initial: '0' | '1' | null, power: 'AC Power' | 'Battery Power' | null = 'AC Power') {
const value = join(dir, 'sleepdisabled');
if (initial !== null) writeFileSync(value, initial);
const source = join(dir, 'power');
if (power !== null) writeFileSync(source, power);
const stub = join(dir, 'pmset');
writeFileSync(
stub,
`#!/bin/bash
if [ "$1" = "-g" ] && [ "$2" = "batt" ]; then
[ -f "${source}" ] || exit 1
echo "Now drawing from '$(cat "${source}")'"
exit 0
fi
if [ "$1" = "-g" ]; then
echo "System-wide power settings:"
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
echo "Currently in use:"
echo " sleep 1"
exit 0
fi
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
printf '%s' "$3" > "${value}"
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
exit 0
fi
exit 1
`
);
chmodSync(stub, 0o755);
return stub;
}
const setPower = (power: 'AC Power' | 'Battery Power') => writeFileSync(join(dir, 'power'), power);
function run() {
const result = spawnSync('bash', [HELPER, request], {
encoding: 'utf-8',
env: {
...process.env,
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
},
});
expect(result.status, result.stderr).toBe(0);
}
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
/** What the app writes: its own pid and a newline. This test process stands in for it. */
const requestFromLiveApp = () => writeFileSync(request, `${process.pid}\n`);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'keep-awake-lid-'));
stateDir = join(dir, 'state');
request = join(dir, 'keep-awake-lid.pid');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('keep-awake-lid.sh', () => {
it('does nothing without a request', () => {
writeStubPmset('0');
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
});
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
run(); // steady state: no repeated pmset call
expect(calls()).toBe('disablesleep 1\n');
rmSync(request);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
});
it('never acts on battery, and lets go when the adapter is pulled', () => {
writeStubPmset('0', 'Battery Power');
requestFromLiveApp();
run();
expect(calls()).toBe('');
setPower('AC Power');
run();
expect(sleepDisabled()).toBe('1');
setPower('Battery Power');
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('reads an unanswerable power source as battery', () => {
writeStubPmset('0', null);
requestFromLiveApp();
run();
expect(calls()).toBe('');
});
it("never switches off an administrator's own disablesleep", () => {
writeStubPmset('1');
requestFromLiveApp();
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
rmSync(request);
run();
expect(sleepDisabled()).toBe('1');
expect(calls()).toBe('');
});
it("leaves an administrator's own disablesleep on when the adapter is pulled", () => {
writeStubPmset('1', 'Battery Power');
requestFromLiveApp();
run();
expect(calls()).toBe('');
expect(sleepDisabled()).toBe('1');
});
it('treats an absent SleepDisabled line as 0', () => {
writeStubPmset(null);
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
});
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
run();
expect(sleepDisabled()).toBe('1');
});
it('ignores a stale request (an app that hangs) and releases', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('releases at the next run once the app is gone, however fresh its file', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('ignores a request whose pid is not running', () => {
writeStubPmset('0');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(calls()).toBe('');
});
it('ignores garbage and refuses a symlinked request', () => {
writeStubPmset('0');
writeFileSync(request, 'not-a-pid\n');
run();
expect(calls()).toBe('');
rmSync(request);
const real = join(dir, 'elsewhere.pid');
writeFileSync(real, `${process.pid}\n`);
symlinkSync(real, request);
run();
expect(calls()).toBe('');
});
});
-309
View File
@@ -1,309 +0,0 @@
/**
* @fileoverview Zoom in the path picker's image preview (salvaged from #608): pinch, one-finger pan,
* double-click, wheel and the out / fit / in buttons. Same jsdom harness as
* path-picker-hidden.test.ts (see its header for why the window is built by hand).
*
* These drive the real listeners and read the zoom state back, because what can go wrong is
* geometric, not textual:
*
* 1. the point under the cursor or between the fingers drifting while the scale changes,
* 2. a pan that carries the image out of view (a small image is drawn at its own size with
* `object-fit: scale-down`, so the image box is not the image),
* 3. a trackpad's stream of small wheel deltas jumping to the maximum, as 20% per event did,
* 4. controls left on screen for an image that failed to load,
* 5. listeners or zoom state outliving the preview, or a late `load`/`error` from a replaced
* preview acting on the current one.
*
* jsdom lays nothing out and loads no images, so the viewport's rect and the image's natural size
* are stubbed per test and `load`/`error` are dispatched by hand.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const doc = jsdomWindow.document;
/** The preview body's box: 400x300 at the page origin, so its centre is (200, 150). */
const BOX = { left: 0, top: 0, width: 400, height: 300 };
const CENTRE = { x: BOX.left + BOX.width / 2, y: BOX.top + BOX.height / 2 };
type Point = { x: number; y: number };
type ZoomState = { scale: number; x: number; y: number };
function loadPathPicker(): any {
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, doc, jsdomWindow.localStorage, vi.fn(), { isTouchDevice: () => false });
}
describe('PathPicker image preview zoom', () => {
let PathPicker: any;
beforeEach(() => {
doc.body.replaceChildren();
PathPicker = loadPathPicker();
});
afterEach(() => {
PathPicker?.closePreview?.(false);
doc.body.replaceChildren();
});
/** Open an image preview whose image has the given natural size (800x600 fills the box exactly). */
const openImage = (natural = { width: 800, height: 600 }, name = 'shot.png') => {
PathPicker.openPreview({ name, path: `/home/dev/${name}`, previewKind: 'image' });
const overlay = PathPicker._previewOverlay as HTMLElement;
const body = overlay.querySelector('.path-preview-body') as HTMLElement;
body.getBoundingClientRect = () =>
({
...BOX,
x: BOX.left,
y: BOX.top,
right: BOX.left + BOX.width,
bottom: BOX.top + BOX.height,
toJSON: () => ({}),
}) as DOMRect;
const image = body.querySelector('img.path-preview-image') as HTMLImageElement;
Object.defineProperty(image, 'naturalWidth', { value: natural.width, configurable: true });
Object.defineProperty(image, 'naturalHeight', { value: natural.height, configurable: true });
const controls = overlay.querySelector('.path-preview-zoom') as HTMLElement;
const button = (zoom: string) => controls.querySelector(`[data-zoom="${zoom}"]`) as HTMLButtonElement;
return { overlay, body, image, controls, button };
};
const fire = (target: EventTarget, type: string) => target.dispatchEvent(new jsdomWindow.Event(type));
const state = (): ZoomState => PathPicker._previewImageZoom;
/** Where, in the unzoomed image, the given screen point lands under the current transform. */
const imagePointUnder = (p: Point, s: ZoomState = state()): Point => ({
x: (p.x - CENTRE.x - s.x) / s.scale,
y: (p.y - CENTRE.y - s.y) / s.scale,
});
const wheel = (target: EventTarget, init: Record<string, unknown>) => {
const event = new jsdomWindow.WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
};
/** jsdom has no PointerEvent: a MouseEvent of the pointer type with a pointerId behaves the same. */
const pointer = (target: EventTarget, type: string, id: number, p: Point) => {
const event = new jsdomWindow.MouseEvent(type, { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y });
Object.defineProperty(event, 'pointerId', { value: id });
target.dispatchEvent(event);
};
const dblclick = (target: EventTarget, p: Point) =>
target.dispatchEvent(
new jsdomWindow.MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y })
);
it('shows the controls only once the image has loaded, and the buttons step the zoom', () => {
const { image, controls, button } = openImage();
expect(controls.hidden).toBe(true);
fire(image, 'load');
expect(controls.hidden).toBe(false);
expect(button('fit').textContent).toBe('100%');
button('in').click();
expect(state().scale).toBeCloseTo(1.5);
expect(button('fit').textContent).toBe('150%');
expect(image.classList.contains('is-zoomed')).toBe(true);
button('fit').click();
expect(state()).toMatchObject({ scale: 1 });
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
expect(button('fit').textContent).toBe('100%');
expect(image.classList.contains('is-zoomed')).toBe(false);
button('out').click();
expect(state().scale).toBe(1);
});
it('wheel zoom keeps the image point under the cursor where it was', () => {
const { body, image } = openImage();
fire(image, 'load');
for (const cursor of [
{ x: 300, y: 100 },
{ x: 120, y: 220 },
{ x: 390, y: 290 },
]) {
const before = imagePointUnder(cursor);
const scaleBefore = state().scale;
wheel(body, { deltaY: -100, clientX: cursor.x, clientY: cursor.y });
expect(state().scale).toBeCloseTo(scaleBefore * 2 ** 0.2);
const after = imagePointUnder(cursor);
expect(after.x).toBeCloseTo(before.x);
expect(after.y).toBeCloseTo(before.y);
}
expect(image.style.transform).toContain(`scale(${state().scale})`);
});
it('pinch zooms by the finger distance around the pinch centre', () => {
const { image } = openImage();
fire(image, 'load');
const centre = { x: 150, y: 150 };
const before = imagePointUnder(centre);
pointer(image, 'pointerdown', 1, { x: 100, y: 150 });
pointer(image, 'pointerdown', 2, { x: 200, y: 150 });
pointer(image, 'pointermove', 2, { x: 300, y: 150 });
expect(state().scale).toBeCloseTo(2);
const after = imagePointUnder(centre);
expect(after.x).toBeCloseTo(before.x);
expect(after.y).toBeCloseTo(before.y);
pointer(image, 'pointerup', 2, { x: 300, y: 150 });
pointer(image, 'pointerup', 1, { x: 100, y: 150 });
expect(state().pointers.size).toBe(0);
});
it('pans a zoomed image only until its edge meets the viewport edge', () => {
const { image } = openImage();
fire(image, 'load');
dblclick(image, CENTRE);
expect(state().scale).toBe(2);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 800x600 drawn at 400x300, zoomed 2x to 800x600 in a 400x300 box: 200 and 150 of slack.
expect(state()).toMatchObject({ x: 200, y: 150 });
pointer(image, 'pointermove', 1, { x: -5000, y: -5000 });
expect(state()).toMatchObject({ x: -200, y: -150 });
pointer(image, 'pointerup', 1, { x: -5000, y: -5000 });
// Without zoom there is nothing to pan.
dblclick(image, CENTRE);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 300, y: 250 });
expect(state()).toMatchObject({ scale: 1 });
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
});
it('keeps a small image in view: centred while it is smaller than the viewport, clamped to its own edges after', () => {
// 100x50 is drawn at its own size (scale-down), not stretched to the 400x300 box.
const { image } = openImage({ width: 100, height: 50 });
fire(image, 'load');
dblclick(image, { x: 380, y: 280 });
expect(state().scale).toBe(2);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 200x100 still fits inside 400x300 on both axes: it stays centred.
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
pointer(image, 'pointerup', 1, { x: 5000, y: 5000 });
PathPicker._setPreviewImageZoom(6);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 600x300 at 6x: 100 of horizontal slack, none vertically. The image box (400x300 at 6x) would
// have allowed 1000 and 750, carrying the image entirely out of the viewport.
expect(state().x).toBe(100);
expect(Math.abs(state().y)).toBe(0);
});
it('scales wheel zoom by deltaY, ignores a delta of 0 and caps one event', () => {
const factor = (init: Record<string, unknown>) => PathPicker._previewWheelZoomFactor(init);
expect(factor({ deltaY: 0, deltaX: 40 })).toBe(1);
expect(factor({ deltaY: -4 })).toBeCloseTo(2 ** 0.008);
expect(factor({ deltaY: -100 })).toBeCloseTo(2 ** 0.2);
expect(factor({ deltaY: 100 })).toBeCloseTo(2 ** -0.2);
// Lines (Firefox's mouse wheel) and ctrl+wheel (a trackpad pinch) are scaled up, not ignored.
expect(factor({ deltaY: -3, deltaMode: 1 })).toBeCloseTo(2 ** 0.15);
expect(factor({ deltaY: -10, ctrlKey: true })).toBeCloseTo(2 ** 0.2);
expect(factor({ deltaY: -100000 })).toBeCloseTo(Math.SQRT2);
expect(factor({ deltaY: 100000 })).toBeCloseTo(Math.SQRT1_2);
const { body, image } = openImage();
fire(image, 'load');
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
const sideways = wheel(body, { deltaY: 0, deltaX: 40, clientX: 300, clientY: 100 });
expect(sideways.defaultPrevented).toBe(true);
expect(zoom).not.toHaveBeenCalled();
expect(state().scale).toBe(1);
// A trackpad scroll arrives as many small deltas; at 20% per event these 30 hit the 600% cap.
for (let i = 0; i < 30; i++) wheel(body, { deltaY: -4, clientX: 300, clientY: 100 });
expect(state().scale).toBeCloseTo(2 ** 0.24);
expect(state().scale).toBeLessThan(1.25);
});
it('hides the controls and stops zooming when the image fails to load', () => {
const { body, image, controls } = openImage();
fire(image, 'error');
expect(controls.hidden).toBe(true);
expect(body.querySelector('.path-preview-error')?.textContent).toBe('Image preview failed to load');
expect(PathPicker._previewImageZoom).toBeNull();
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
wheel(body, { deltaY: -100, clientX: 200, clientY: 150 });
expect(zoom).not.toHaveBeenCalled();
});
it('closing drops the zoom state and every listener, and the next image starts at 100%', () => {
const first = openImage();
fire(first.image, 'load');
dblclick(first.image, { x: 300, y: 100 });
expect(state().scale).toBe(2);
const transformAtClose = first.image.style.transform;
PathPicker.closePreview(false);
expect(PathPicker._previewImageZoom).toBeNull();
expect(PathPicker._previewImageCleanup).toBeNull();
expect(first.overlay.isConnected).toBe(false);
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
wheel(first.body, { deltaY: -100, clientX: 200, clientY: 150 });
dblclick(first.image, CENTRE);
first.button('in').click();
expect(zoom).not.toHaveBeenCalled();
expect(first.image.style.transform).toBe(transformAtClose);
zoom.mockRestore();
const second = openImage({ width: 800, height: 600 }, 'next.png');
fire(second.image, 'load');
expect(state()).toMatchObject({ scale: 1 });
expect(second.button('fit').textContent).toBe('100%');
expect(second.image.classList.contains('is-zoomed')).toBe(false);
});
it('a late load or error from a replaced preview leaves the current one alone', () => {
const stale = openImage({ width: 800, height: 600 }, 'old.png');
const current = openImage({ width: 800, height: 600 }, 'new.png');
fire(current.image, 'load');
dblclick(current.image, { x: 300, y: 100 });
const zoomed = { ...state() };
fire(stale.image, 'load');
expect(state()).toMatchObject({ scale: zoomed.scale, x: zoomed.x, y: zoomed.y });
fire(stale.image, 'error');
expect(current.body.querySelector('.path-preview-error')).toBeNull();
expect(current.image.isConnected).toBe(true);
expect(current.controls.hidden).toBe(false);
expect(PathPicker._previewImageZoom).not.toBeNull();
});
it('draws a small image at its own size rather than stretching it (scale-down)', () => {
const rule = stylesCss.match(/\n\.path-preview-image \{([^}]*)\}/);
expect(rule?.[1]).toMatch(/object-fit:\s*scale-down;/);
expect(rule?.[1]).not.toMatch(/object-fit:\s*contain;/);
});
});