mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(desktop): keep a closed MacBook awake without the administrator password
Keep This Mac Awake > With the Lid Closed is a plain switch now. 1.50.1 installed a root LaunchDaemon behind the administrator prompt, which set `pmset disablesleep` while the app asked for it. The app now turns off lid sleep in the kernel's power management root itself (`kPMSetClamshellSleepState`, a call any process may make), the method LogoLight ships as Lit with Lid Closed. That switch has no owner (read from Apple's sources of the kernel and of powerd): the kernel keeps it after the process that set it has gone and honours it on battery, macOS's power daemon and LogoLight write the same bit, and clearing it under a closed lid puts the Mac to sleep at once. On an Apple silicon Mac it is also what keeps a docked MacBook awake with its lid closed, set by the power daemon once, when a display comes. So a child process holds it (`src/lid_awake.rs`, this binary with `--keep-awake-lid`): - it sets the switch again every second and holds two assertions with it (system sleep and idle sleep), the three LogoLight measured together; - it reads the power source itself and holds nothing on battery; - it writes nothing at all while an external display is connected: a zero from here would put a docked Mac to sleep, or leave it without its closed-lid mode until the display is plugged in again. What the child set before the display came is cleared once the display has been gone for five seconds, and the child outlives the app for that if it has to; - it lets go when the pipe from the app closes (quit, crash, Force Quit) and when the app has not written for a minute (a hang), the assertions first, then the switch; - it ignores SIGHUP, SIGINT and SIGTERM, so the pipe is its one way out; - it leaves the switch alone at its end while LogoLight holds it for itself (LogoLight sets it once and would not notice it gone). One child at a time holds a lock on `lid-awake` next to `desktop.json`, and the file is non-empty while the switch may be set. A child that was killed leaves it marked and unlocked: the app turns lid sleep back on when it sees a child end and at its next launch, and the next child takes the mark over. The status line says what came of it: the kernel took the call, the lid is left to macOS at a display, on battery, refused, or that the Mac slept under the lid all the same and until when. A display has its own wording so a Mac that sleeps there never reads as covered. The helper of 1.50.1 is no longer installed or used. Its request file is never written and is deleted at launch, so a helper that is still there does nothing. While it is installed the menu has Remove the Old Lid Helper…, the one item left behind the administrator prompt. `macos/keep-awake-lid.sh` and its vitest suite go with the install path. Checked on a Mac mini (macOS 27.2, no lid, no display): 276 tests, clippy and fmt; the kernel takes the call from an unprivileged process; the real child holds its assertions, lets go on a closed pipe and after a minute of silence, ignores SIGTERM, waits for a child before it, writes nothing when told a display is connected, and a SIGKILLed child is replaced by the app and its mark undone. 34 deliberate breaks of these rules each fail a test. Not run yet: the app itself with this change (the mini has no session to open it in), a real closed lid, the menu items, and the removal of the old helper. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
8bedbbe793
commit
be8b96fa5d
10 files changed
+2164
-921
No files matched your search
+241
-472
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1262,7 +1262,7 @@ pub fn watch_lid_status(changed: impl Fn(String) + Send + 'static) {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use system::{available, blink_main, driven_elsewhere, recover_at_launch, switched_off};
|
||||
pub use system::{available, blink_main, clock, driven_elsewhere, recover_at_launch, switched_off};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn available() -> bool {
|
||||
@@ -1624,8 +1624,9 @@ mod system {
|
||||
}
|
||||
}
|
||||
|
||||
/// The local time as hours and minutes, for the status line.
|
||||
fn clock() -> String {
|
||||
/// The local time as hours and minutes, for the status line (the lid keeper's
|
||||
/// too, lid_awake.rs).
|
||||
pub fn clock() -> String {
|
||||
// SAFETY: time(NULL) answers the time; localtime_r fills the struct it is
|
||||
// given and answers NULL on failure.
|
||||
unsafe {
|
||||
|
||||
@@ -28,6 +28,9 @@ mod config;
|
||||
mod credentials;
|
||||
mod gui_install;
|
||||
mod keep_awake;
|
||||
// The pure half is only used by the macOS half and the tests.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
mod lid_awake;
|
||||
mod local_server;
|
||||
mod login;
|
||||
mod logo;
|
||||
@@ -190,12 +193,9 @@ enum UserEvent {
|
||||
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,
|
||||
},
|
||||
/// The administrator prompt for removing the lid helper of 1.50.1 was answered,
|
||||
/// and its command ran (keep_awake::remove_old_helper).
|
||||
OldHelperRemoved(keep_awake::AdminOutcome),
|
||||
/// What the closed lid did with the logo last (logo::watch_lid_status).
|
||||
LogoLidStatus(String),
|
||||
}
|
||||
@@ -336,8 +336,8 @@ struct App {
|
||||
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,
|
||||
/// The administrator prompt for removing the old lid helper is open.
|
||||
old_helper_busy: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -345,6 +345,10 @@ fn main() {
|
||||
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
|
||||
logo::blink_main();
|
||||
}
|
||||
// So is the lid keeper (lid_awake.rs).
|
||||
if std::env::args().nth(1).as_deref() == Some(lid_awake::LID_ARG) {
|
||||
lid_awake::child_main();
|
||||
}
|
||||
|
||||
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
|
||||
let proxy = event_loop.create_proxy();
|
||||
@@ -375,6 +379,10 @@ fn main() {
|
||||
let config_path = config_path.filter(|_| can_save);
|
||||
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
|
||||
logo::recover_at_launch();
|
||||
// A lid keeper that was killed left lid sleep off; and 1.50.1's helper, where
|
||||
// it is still installed, gets nothing to act on.
|
||||
lid_awake::heal();
|
||||
keep_awake::forget_legacy_request();
|
||||
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
|
||||
let phase = Rc::new(RefCell::new(Phase::Connect));
|
||||
// Started once: the probe workers and the event loop share this queue,
|
||||
@@ -421,7 +429,7 @@ fn main() {
|
||||
keep_awake: keep_awake::KeepAwake::new(move |generation, text| {
|
||||
let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text });
|
||||
}),
|
||||
lid_helper_busy: false,
|
||||
old_helper_busy: false,
|
||||
};
|
||||
|
||||
if app.menu.logo_lid.is_some() {
|
||||
@@ -1010,7 +1018,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
UserEvent::LidHelperDone { install, outcome } => self.on_lid_helper_done(install, outcome),
|
||||
UserEvent::OldHelperRemoved(outcome) => self.on_old_helper_removed(outcome),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1181,7 +1189,10 @@ 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 mut settings = self.config.keep_awake;
|
||||
// The lid item only exists on a Mac with a lid; a setting copied from one
|
||||
// does nothing elsewhere.
|
||||
settings.lid &= keep_awake::has_lid();
|
||||
let server = self.phase.borrow().loaded().cloned();
|
||||
let watch = server
|
||||
.filter(|_| settings.mode != keep_awake::Mode::Off)
|
||||
@@ -1222,92 +1233,45 @@ impl App {
|
||||
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 {
|
||||
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.lid.as_ref()) 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(),
|
||||
}
|
||||
self.config.keep_awake.lid = item.is_checked();
|
||||
self.config_dirty = true;
|
||||
self.sync_keep_awake();
|
||||
}
|
||||
|
||||
fn run_lid_helper(&mut self, install: bool) {
|
||||
if self.lid_helper_busy {
|
||||
/// Codeman 1.50.1 kept the lid awake through a root helper. Nothing uses it any
|
||||
/// more, and taking it away is the one thing left that asks for the
|
||||
/// administrator password. The prompt runs off the event loop.
|
||||
fn remove_old_helper(&mut self) {
|
||||
if self.old_helper_busy {
|
||||
return;
|
||||
}
|
||||
self.lid_helper_busy = true;
|
||||
self.old_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 });
|
||||
let _ = proxy.send_event(UserEvent::OldHelperRemoved(keep_awake::remove_old_helper()));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
fn on_old_helper_removed(&mut self, outcome: keep_awake::AdminOutcome) {
|
||||
use keep_awake::AdminOutcome;
|
||||
self.old_helper_busy = false;
|
||||
let failure = match (outcome, keep_awake::old_helper_installed()) {
|
||||
(AdminOutcome::Cancelled, _) => None,
|
||||
(AdminOutcome::Failed(error), _) => Some(error),
|
||||
(AdminOutcome::Done, false) => {
|
||||
if let Some(items) = &mut self.menu.keep_awake {
|
||||
items.old_helper_removed();
|
||||
}
|
||||
None
|
||||
}
|
||||
(AdminOutcome::Done, true, _) => Some((
|
||||
"The 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(),
|
||||
)),
|
||||
(AdminOutcome::Done, true) => Some("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 {
|
||||
if let Some(detail) = failure {
|
||||
let title = "The old lid helper could not be removed.";
|
||||
eprintln!("codeman-desktop: {title} {detail}");
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::show_message(title, &detail);
|
||||
@@ -1326,6 +1290,7 @@ impl App {
|
||||
match id {
|
||||
keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(),
|
||||
keep_awake::LID_ID => self.toggle_keep_awake_lid(),
|
||||
keep_awake::REMOVE_OLD_HELPER_ID => self.remove_old_helper(),
|
||||
menu_id::SWITCH => self.show_connect_page(None, true),
|
||||
menu_id::ADD_AGENT => self.add_agent(),
|
||||
menu_id::RELOAD => self.reload_focused(),
|
||||
|
||||
@@ -4,7 +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::keep_awake::{self, Mode};
|
||||
use crate::logo::{self, BlinkStyle, Signal};
|
||||
use crate::{APP_NAME, VERSION};
|
||||
|
||||
@@ -41,23 +41,26 @@ pub struct AppMenu {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// No password behind it (lid_awake.rs).
|
||||
pub lid: Option<CheckMenuItem>,
|
||||
/// Only while the root helper Codeman 1.50.1 installed for the lid is still on
|
||||
/// this Mac. Removing it asks for the administrator password, hence the ellipsis.
|
||||
remove_old_helper: Option<MenuItem>,
|
||||
submenu: Submenu,
|
||||
/// 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);
|
||||
/// The old helper is gone, and with it the item that removes it.
|
||||
pub fn old_helper_removed(&mut self) {
|
||||
if let Some(item) = self.remove_old_helper.take() {
|
||||
let _ = self.submenu.remove(&item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,8 +76,6 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
|
||||
}
|
||||
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",
|
||||
@@ -82,16 +83,24 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
|
||||
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,
|
||||
);
|
||||
let lid_item = CheckMenuItem::with_id(keep_awake::LID_ID, "With the Lid Closed", true, settings.lid, None);
|
||||
submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item])?;
|
||||
(ac_only, lid) = (Some(ac), Some(lid_item));
|
||||
}
|
||||
let mut remove_old_helper = None;
|
||||
if keep_awake::old_helper_installed() {
|
||||
let item = MenuItem::with_id(
|
||||
keep_awake::REMOVE_OLD_HELPER_ID,
|
||||
"Remove the Old Lid Helper\u{2026}",
|
||||
true,
|
||||
None,
|
||||
);
|
||||
if lid.is_none() {
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
}
|
||||
submenu.append(&item)?;
|
||||
remove_old_helper = Some(item);
|
||||
}
|
||||
let initial = match settings.mode {
|
||||
Mode::Off => keep_awake::OFF_TEXT,
|
||||
_ => keep_awake::NO_SERVER_TEXT,
|
||||
@@ -99,11 +108,13 @@ fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
|
||||
let status = MenuItem::new(initial, false, None);
|
||||
submenu.append_items(&[&PredefinedMenuItem::separator(), &status])?;
|
||||
Ok((
|
||||
submenu,
|
||||
submenu.clone(),
|
||||
KeepAwakeMenu {
|
||||
modes,
|
||||
ac_only,
|
||||
lid,
|
||||
remove_old_helper,
|
||||
submenu,
|
||||
status,
|
||||
},
|
||||
))
|
||||
|
||||
Reference in new issue
Block a user