mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-09 08:49:33 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9843c94a | ||
|
|
79a6c03363 |
No files matched your search
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user