mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9843c94a | ||
|
|
79a6c03363 | ||
|
|
f62c1f7e2f |
No files matched your search
@@ -62,7 +62,6 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/phone-press-feedback.browser.test.ts',
|
||||
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
||||
'test/local-model-ui.browser.test.ts',
|
||||
'test/home-breakpoint-swap.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,6 +50,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
"NSProcessInfo",
|
||||
"NSRange",
|
||||
"NSSet",
|
||||
"NSString",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -1053,9 +1053,16 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
return progress ? { ...next, progress } : next;
|
||||
}
|
||||
|
||||
/** The stored state as a view or a verb must see it: settled, unless a verb is just starting a job. */
|
||||
/**
|
||||
* The stored state as a view or a verb must see it: settled, unless a verb is just starting a
|
||||
* job. A job writes its last state BEFORE it lets go of `this.job`, so when the job ended while
|
||||
* this read was out, the read may hold the `installing` from before that write; it is read
|
||||
* again rather than settled, or it would show as an interrupted (`paused`) job for a tick.
|
||||
*/
|
||||
private async currentState(): Promise<LocalModelStateFile> {
|
||||
const state = await this.deps.readState();
|
||||
const jobAtRead = this.job;
|
||||
let state = await this.deps.readState();
|
||||
if (jobAtRead && !this.job) state = await this.deps.readState();
|
||||
return this.jobStarting ? state : this.settled(state);
|
||||
}
|
||||
|
||||
|
||||
@@ -1232,11 +1232,6 @@ class CodemanApp {
|
||||
// and then applies the sidebar variant on top — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyTabOrientation();
|
||||
// The home screens' breakpoint listeners, once, before any home surface is
|
||||
// shown: a fold or unfold must swap the surface whichever width the page
|
||||
// loaded at (#702). Both self-gate on a home screen being up.
|
||||
this._wireMobileOverviewBreakpoint?.();
|
||||
this._wireHomeSessionsBreakpoint?.();
|
||||
this.initTabRailResize?.();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
|
||||
@@ -126,31 +126,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._stopHomeSessionsClock();
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-render only when showing (called from the tab renderer's tail). Asked
|
||||
* again when the debounce fires: `renderHomeSessions()` unhides the column,
|
||||
* so a render queued just before a fold to phone width hid it (#702) would
|
||||
* otherwise bring it back behind the phone overview.
|
||||
*/
|
||||
/** Re-render only when showing (called from the tab renderer's tail). */
|
||||
_refreshHomeSessionsIfVisible() {
|
||||
if (!this.isHomeSessionsVisible()) return;
|
||||
this._debouncedCall(
|
||||
'homeSessions',
|
||||
() => {
|
||||
if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions();
|
||||
},
|
||||
150
|
||||
);
|
||||
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row. The width listener lives in
|
||||
* `_wireHomeSessionsBreakpoint()`, installed at startup.
|
||||
* One delegated click listener for every row, plus a width listener so
|
||||
* resizing the window while on the home screen adds or drops the column
|
||||
* instead of leaving it overlapping the content it was sized to clear.
|
||||
*/
|
||||
_wireHomeSessions(el) {
|
||||
if (this._homeSessionsWired) return;
|
||||
this._homeSessionsWired = true;
|
||||
this._wireHomeSessionsBreakpoint();
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target?.closest?.('[data-hs-action]');
|
||||
@@ -161,29 +150,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
void this.openWebview?.(target.dataset.hsWebview);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Add or drop the column when the window crosses its width gate, so it never
|
||||
* overlaps the content it was sized to clear. Installed ONCE at startup
|
||||
* (`init()` in app.js) like the phone overview's breakpoint listener, so a
|
||||
* page that loads on a phone and is unfolded to desktop width gets it too
|
||||
* (#702). Idempotent.
|
||||
*/
|
||||
_wireHomeSessionsBreakpoint() {
|
||||
if (this._homeSessionsBreakpointWired || !window.matchMedia) return;
|
||||
this._homeSessionsBreakpointWired = true;
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -385,13 +385,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row. The breakpoint listener lives
|
||||
* in `_wireMobileOverviewBreakpoint()`, installed at startup.
|
||||
* One delegated click listener for every row, plus a breakpoint listener so
|
||||
* rotating or unfolding while on the home screen swaps to the right surface
|
||||
* instead of stranding a phone layout on a tablet-width viewport.
|
||||
*/
|
||||
_wireMobileOverview(el) {
|
||||
if (this._mobileOverviewWired) return;
|
||||
this._mobileOverviewWired = true;
|
||||
this._wireMobileOverviewBreakpoint();
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
|
||||
@@ -426,34 +426,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showWebviewModal();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Swap the home surface when the width crosses the phone breakpoint (rotate,
|
||||
* fold, unfold), so a phone layout is never stranded on a tablet-width
|
||||
* viewport and the welcome screen never on a phone-width one.
|
||||
*
|
||||
* Installed ONCE at startup (`init()` in app.js), not on the first show: a
|
||||
* page that loads wide never shows the phone overview, so a listener wired
|
||||
* from there was missing on exactly the fold that needed it (#702). Idempotent,
|
||||
* so the call from `_wireMobileOverview()` adds nothing after startup.
|
||||
*/
|
||||
_wireMobileOverviewBreakpoint() {
|
||||
if (this._mobileOverviewBreakpointWired || !window.matchMedia) return;
|
||||
this._mobileOverviewBreakpointWired = true;
|
||||
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
|
||||
const onChange = () => {
|
||||
// Only relevant while a home surface is up; entering a session re-decides
|
||||
// through hideWelcome()/showWelcome() anyway, and before the first
|
||||
// showWelcome() there is nothing on screen to swap.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
const welcomeUp = !!overlay && overlay.classList.contains('visible');
|
||||
if (!welcomeUp && !this.isMobileOverviewVisible()) return;
|
||||
if (typeof this.showWelcome === 'function') this.showWelcome();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
|
||||
const onChange = () => {
|
||||
// Only relevant while a home surface is up; entering a session re-decides
|
||||
// through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
if (typeof this.showWelcome === 'function') this.showWelcome();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
},
|
||||
|
||||
_toggleMobileOverviewRunMenu() {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
// Port: none (the real home-screen modules in a vm context with a fake
|
||||
// matchMedia; no browser, no server).
|
||||
//
|
||||
// #702: folding a phone on the home screen must swap the home surface even when
|
||||
// the page loaded wide. The phone overview's breakpoint listener used to be
|
||||
// wired from its first show, which a wide-loaded page never reaches. Both home
|
||||
// screens' listeners are now installed once at startup and self-gate on a home
|
||||
// screen being up. The real resize walk lives in
|
||||
// test/home-breakpoint-swap.browser.test.ts (browser suite, not in the gate);
|
||||
// this file keeps the wiring itself under `npm test`.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function loadHomeApp(innerWidth = 1440) {
|
||||
const listeners = new Map<string, Listener[]>();
|
||||
const elements: Record<string, any> = {
|
||||
welcomeOverlay: { classList: new Set<string>(), hidden: false },
|
||||
mobileOverview: { classList: new Set<string>(), hidden: true },
|
||||
homeSessions: { hidden: true },
|
||||
};
|
||||
for (const el of Object.values(elements)) {
|
||||
if (el.classList) {
|
||||
const set = el.classList as Set<string>;
|
||||
el.classList = {
|
||||
contains: (c: string) => set.has(c),
|
||||
add: (c: string) => set.add(c),
|
||||
remove: (c: string) => set.delete(c),
|
||||
};
|
||||
}
|
||||
}
|
||||
const timers: Array<() => void> = [];
|
||||
const window: any = {
|
||||
innerWidth,
|
||||
matchMedia: (query: string) => ({
|
||||
matches: false,
|
||||
addEventListener: (_type: string, fn: Listener) => {
|
||||
listeners.set(query, [...(listeners.get(query) || []), fn]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window,
|
||||
document: {
|
||||
documentElement: { getAttribute: () => null },
|
||||
getElementById: (id: string) => elements[id] || null,
|
||||
createElement: () => ({ dataset: {}, style: {}, appendChild() {}, setAttribute() {} }),
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => (window.innerWidth < 600 ? 'mobile' : 'desktop') },
|
||||
});
|
||||
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
const app = new (CodemanApp as any)();
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.activeSessionId = null;
|
||||
app.showWelcomeCalls = 0;
|
||||
app.showWelcome = () => app.showWelcomeCalls++;
|
||||
app.renderHomeSessionsCalls = 0;
|
||||
app.renderHomeSessions = () => {
|
||||
app.renderHomeSessionsCalls++;
|
||||
elements.homeSessions.hidden = false;
|
||||
};
|
||||
app._debouncedCall = (_key: string, fn: () => void) => timers.push(fn);
|
||||
const fire = (query: string) => (listeners.get(query) || []).forEach((fn) => fn());
|
||||
const flushTimers = () => timers.splice(0).forEach((fn) => fn());
|
||||
return { app, window, elements, listeners, fire, flushTimers };
|
||||
}
|
||||
|
||||
const PHONE_QUERY = '(max-width: 599px)';
|
||||
const RAIL_QUERY = '(min-width: 1180px)';
|
||||
|
||||
describe('home-screen breakpoint listeners (#702)', () => {
|
||||
it('installs each listener once, with nothing shown yet', () => {
|
||||
const { app, listeners } = loadHomeApp();
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
app._wireHomeSessionsBreakpoint();
|
||||
// The lazy wiring from a first show must not add a second copy.
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
app._wireHomeSessionsBreakpoint();
|
||||
expect(listeners.get(PHONE_QUERY)).toHaveLength(1);
|
||||
expect(listeners.get(RAIL_QUERY)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a fold on a wide-loaded welcome screen re-decides the surface', () => {
|
||||
const { app, window, elements, fire } = loadHomeApp(1440);
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
window.innerWidth = 390;
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('an unfold on the phone overview re-decides the surface', () => {
|
||||
const { app, window, elements, fire } = loadHomeApp(390);
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
elements.mobileOverview.classList.add('visible');
|
||||
window.innerWidth = 820;
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('stays out of the way inside a session and before any home screen is up', () => {
|
||||
const { app, elements, fire } = loadHomeApp();
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
fire(PHONE_QUERY); // page still loading: no home surface yet
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
app.activeSessionId = 'abc';
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('a rail render queued before a fold does not bring the hidden rail back', () => {
|
||||
const { app, window, elements, flushTimers } = loadHomeApp(1440);
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
elements.homeSessions.hidden = false;
|
||||
app._refreshHomeSessionsIfVisible(); // queued while the rail is still up
|
||||
window.innerWidth = 390; // the fold
|
||||
elements.homeSessions.hidden = true; // showWelcome()'s phone path hid it
|
||||
flushTimers();
|
||||
expect(app.renderHomeSessionsCalls).toBe(0);
|
||||
expect(elements.homeSessions.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('is wired from init(), not from the first show', () => {
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const start = appJs.indexOf('\n init() {');
|
||||
const end = appJs.indexOf('\n }\n', start);
|
||||
const init = appJs.slice(start, end);
|
||||
expect(init).toContain('this._wireMobileOverviewBreakpoint?.()');
|
||||
expect(init).toContain('this._wireHomeSessionsBreakpoint?.()');
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Folding or unfolding a phone on the home screen swaps the home
|
||||
* surface in place, whichever width the page loaded at (#702).
|
||||
*
|
||||
* Both breakpoint listeners (the phone overview's `(max-width: 599px)` and the
|
||||
* desktop rail's `(min-width: 1180px)`) used to be wired from the first show of
|
||||
* their own surface. A page that loaded wide never showed the phone overview,
|
||||
* so folding it to phone width left the welcome screen up until something
|
||||
* called `showWelcome()` again. They are now installed once at startup.
|
||||
*
|
||||
* Real server, real Chromium, one page per direction, resized across 599/600
|
||||
* and 767/768 in both directions with no navigation, plus a height-only resize
|
||||
* (the on-screen keyboard) that must not re-decide the surface.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
type Surface = { welcome: boolean; overview: boolean; rail: boolean };
|
||||
|
||||
describe('home surface follows a fold or unfold without a reload', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let workDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
// One session, so the desktop rail has a row to show (it hides when empty).
|
||||
workDir = mkdtempSync(join(tmpdir(), 'home-breakpoint-'));
|
||||
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: workDir }),
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
async function open(width: number, height: number): Promise<[BrowserContext, Page]> {
|
||||
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
// The load selects the one session; Home is where a fold has to swap surfaces.
|
||||
await page.waitForFunction(() => (window as any).app?.sessions?.size > 0, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.goHome());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('welcomeOverlay')?.classList.contains('visible') ||
|
||||
document.getElementById('mobileOverview')?.classList.contains('visible'),
|
||||
null,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
return [context, page];
|
||||
}
|
||||
|
||||
const surface = (page: Page): Promise<Surface> =>
|
||||
page.evaluate(() => {
|
||||
const overview = document.getElementById('mobileOverview')!;
|
||||
const rail = document.getElementById('homeSessions')!;
|
||||
return {
|
||||
welcome: document.getElementById('welcomeOverlay')!.classList.contains('visible'),
|
||||
overview: !overview.hidden && overview.classList.contains('visible'),
|
||||
rail: !rail.hidden,
|
||||
};
|
||||
});
|
||||
|
||||
/** Resize, then wait until the surface settles on what that width should show. */
|
||||
async function resizeTo(page: Page, width: number, height: number, expected: Surface) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect
|
||||
.poll(() => surface(page), { timeout: 5000, message: `surface at ${width}x${height}` })
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
const PHONE: Surface = { welcome: false, overview: true, rail: false };
|
||||
const TABLET: Surface = { welcome: true, overview: false, rail: false };
|
||||
const DESKTOP: Surface = { welcome: true, overview: false, rail: true };
|
||||
|
||||
it('loaded wide: folding to phone width shows the phone overview, unfolding brings the welcome back', async () => {
|
||||
const [context, page] = await open(1440, 900);
|
||||
try {
|
||||
expect(await surface(page)).toEqual(DESKTOP);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 599, 900, PHONE);
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 1440, 900, DESKTOP);
|
||||
// A straight fold, the way a real device changes posture, in one step.
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('loaded narrow: unfolding to desktop width shows the welcome and the rail, folding brings the overview back', async () => {
|
||||
const [context, page] = await open(390, 844);
|
||||
try {
|
||||
expect(await surface(page)).toEqual(PHONE);
|
||||
await resizeTo(page, 599, 844, PHONE);
|
||||
await resizeTo(page, 600, 844, TABLET);
|
||||
await resizeTo(page, 767, 844, TABLET);
|
||||
await resizeTo(page, 768, 844, TABLET);
|
||||
await resizeTo(page, 1440, 900, DESKTOP);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 599, 900, PHONE);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('a height-only resize (the on-screen keyboard) does not re-decide the home surface', async () => {
|
||||
const [context, page] = await open(1440, 900);
|
||||
try {
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
app.__showWelcomeCalls = 0;
|
||||
const original = app.showWelcome;
|
||||
app.showWelcome = function (...args: unknown[]) {
|
||||
app.__showWelcomeCalls++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 480 });
|
||||
await page.waitForTimeout(400);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(400);
|
||||
expect(await surface(page)).toEqual(PHONE);
|
||||
expect(await page.evaluate(() => (window as any).app.__showWelcomeCalls)).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
@@ -1938,6 +1938,57 @@ describe('view()', () => {
|
||||
await h.service.cancel();
|
||||
});
|
||||
|
||||
it('a view whose read began while the job ran shows how the job ended, never paused', async () => {
|
||||
// The window polls every second, so a read of the state file can be under way while the
|
||||
// job writes its last state and lets go. That read returns the old `installing`, and
|
||||
// settling it with no job in this process read it as an interrupted job: the window (and
|
||||
// the e2e test, under CI load) saw `paused` with Resume for one tick instead of `failed`.
|
||||
const slow = {
|
||||
read: undefined as undefined | (() => Promise<LocalModelStateFile>),
|
||||
hold: undefined as undefined | Promise<void>,
|
||||
entered: undefined as undefined | (() => void),
|
||||
};
|
||||
const h = setup({
|
||||
overrides: {
|
||||
readState: async () => {
|
||||
const snapshot = await slow.read!();
|
||||
const hold = slow.hold;
|
||||
slow.hold = undefined;
|
||||
if (hold) {
|
||||
slow.entered?.();
|
||||
await hold;
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
},
|
||||
});
|
||||
slow.read = h.store.readState;
|
||||
let failVerify!: () => void;
|
||||
const verifying = new Promise<void>((resolve) => (failVerify = resolve));
|
||||
h.deps.verifyModel.mockImplementationOnce(async () => {
|
||||
await verifying;
|
||||
return { ok: false, code: 'no-tool-call' } as never;
|
||||
});
|
||||
|
||||
await h.service.install(REQUEST, ADMIN);
|
||||
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
|
||||
|
||||
// A view starts reading while the job is still in the verify step...
|
||||
let releaseRead!: () => void;
|
||||
slow.hold = new Promise<void>((resolve) => (releaseRead = resolve));
|
||||
const entered = new Promise<void>((resolve) => (slow.entered = resolve));
|
||||
const viewing = h.service.view();
|
||||
await entered;
|
||||
// ...the job fails, writes it and ends while that read is still out...
|
||||
failVerify();
|
||||
await h.service.idle();
|
||||
expect(h.store.get().state).toBe('failed');
|
||||
// ...and only then does the read come back, with the file as it was before.
|
||||
releaseRead();
|
||||
const view = await viewing;
|
||||
expect(view).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'no-tool-call' } });
|
||||
});
|
||||
|
||||
it('names the model to try next only when the catalogue still has it', async () => {
|
||||
const state: LocalModelStateFile = {
|
||||
...jobAt('verify', 'paused'),
|
||||
|
||||
Reference in new issue
Block a user