Compare commits

..
Author SHA1 Message Date
Codeman maintainer 3a3e2ee1a4 fix(desktop): closing the window quits when keep-awake watches nothing here
From the review of #800: the window hid the app whenever a keep-awake mode
was chosen, also with no server open (the connect page) or with a Codeman
on another machine, where keep-awake holds nothing. Closing the window then
left a hidden app in the Dock for nothing, and it never quit on close again
until Cmd+Q.

close_hides_app() now decides from the open server as well: it hides only
while keep-awake watches a Codeman on this Mac, the same server and the same
is_this_mac() rule the worker uses. It still hides while keep-awake is only
paused (on battery with Only When Plugged In, no agent at work, no answer),
since it carries on by itself once that changes.

The test now covers loopback, this Mac's own address, another machine's
address and no server, and fails with the old rule. The frame is recorded
and the config saved once, before the hide or quit choice, and the Reopen
comment says how a hidden app comes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:57:06 +01:00
Codeman maintainer 430bf6217b fix(desktop): closing the window no longer ends Keep This Mac Awake
Keep This Mac Awake lives in the app and ends when the app quits, and
closing the main window quit the app. So with a mode on, the red button
(or Cmd+W) followed by closing the lid let the MacBook sleep: the lid
request file was cleared and the helper released disablesleep at its
next run.

While a mode is on, closing the main window now hides the app the way
Cmd+H does (hide_application), so full screen and pop-outs come back as
they were on a click on the Dock icon. Cmd+Q and Quit from the Dock
still end it, through LoopDestroyed as before. With keep-awake off,
closing the window quits as it always did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:57:40 +01:00
Codeman maintainer 2f9843c94a feat(desktop): a simpler Keep This Mac Awake menu
The owner found the options confusing after the first test build. The
menu now reads Off / While Agents Work / Always, then on a MacBook
Only When Plugged In and With the Lid Closed…, then one status line.

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

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

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

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

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

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

No files matched your search

+1 -1
View File
@@ -108,7 +108,7 @@ The first run asks three questions (data folder, port, password; Enter takes the
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while they work, blinks when one waits for you and flashes when one needs permission, and each state can take a style of your own. It stays off until you turn it on. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
+1 -1
View File
@@ -110,7 +110,7 @@ bash docker/Start-Codeman.sh
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志显示智能体在做的事:工作时缓慢呼吸,有智能体等你回复时闪烁,需要权限时快闪三下;每种状态也可以换成你自己选的样式。在你打开之前,它保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
+12 -4
View File
@@ -248,10 +248,18 @@ A small native app that opens Codeman in its own window. Its first screen has tw
runs the installer on that machine over SSH.
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
**View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while
they work, blinks when one waits for you and flashes three times when one needs permission.
Each state can take another style under **View > Apple Logo Blink Styles**. It is off until
you turn it on, and the menu only exists on such a Mac.
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
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
View File
@@ -50,6 +50,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"NSGeometry",
"NSObject",
"NSOperation",
"NSProcessInfo",
"NSRange",
"NSSet",
"NSString",
+68 -90
View File
@@ -40,7 +40,8 @@ cannot do.
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
| **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. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. 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. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses and writes nothing. |
| **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. That is why, while it watches a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, as Cmd+H does, also while it is paused (on battery, say), since it carries on by itself: a click on its Dock icon brings it back, and only Cmd+Q (or Quit from the Dock) ends it. With no server open, or one on another machine, closing the window quits. |
| **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
@@ -225,79 +250,31 @@ If that probe is refused or rate limited, the connect screen comes back; the
app does not send the refused login again. Keeping no copy of the login in
WebKit has a cost here: after a server restart, each request that arrives
without a cookie gets a 401 before the
login goes out, and those count until on**The logo moves on its own hardware fades, from a child process, through CoreBrightness.**
Apple documents one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`, an on/off setting that
`corebrightnessd` keeps in root's preferences. A process of the logged-in user can reach the
daemon without an administrator through `BrightnessSystemClient` in the private CoreBrightness
framework, and 1.50.0 blinked by switching that setting there. The blink now uses two other
properties of the same client, which [LogoLight](https://github.com/Ark0N/LogoLight) (MIT, its
`docs/corebrightnessd.md`) measured on the real logo and `src/logo.rs` reimplements:
`LEPWMFactorFadeDuration` sets how long the next fade takes, and `DisplayLogicalState` 1
(Blank) fades the logo to dark and 2 (Unblank) back to lit, on the LED's own spring curve.
Nothing of that is saved, so a blink writes no setting and leaves nothing behind a restart;
`LEBacklightMode` is only read.
login goes out, and those count until one succeeds. Many windows reconnecting
at once can push a request past 10, and that request then fails with a 429
instead of being answered. A refusal that lands while you are in another app
does not bring Codeman forward: the connect screen loads where the window is,
and the Dock icon bounces.
The daemon decides the rest. Writing the direction the logo already heads in is ignored, and
the state cannot be read back, so the child keeps track of it and, when it does not know,
first turns the logo round with a fade too slow to see. The keyboard backlight follows
`DisplayLogicalState` too, so after every Blank the keyboards alone are told Unblank
(`setProperty:withKey:keyboardID:`), only while the built-in display is awake
(`CGDisplayIsAsleep`); when it sleeps they are told Blank, and Unblank when it wakes. A rise
from exactly dark waits for the display to show a frame, up to half a second with nothing
changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast's) turn round
while their fall is still under way and never quite reach dark, and a long one (Calm's,
Slow's, the breath's rest) times its light from when the rise starts.
**The logo blinks from a child process, through CoreBrightness.** Apple documents
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
for every blink would need a root helper and an administrator password. The same
setting is also a property of `BrightnessSystemClient` in the private
CoreBrightness framework, and a process of the logged-in user can set it there,
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
keeps its value while the logo is dark.
With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and the logo's
target is then 0 whatever `DisplayLogicalState` says. A client's `ClamshellUpdate` reaches the
same modules, and LogoLight measured on the real MacBook what follows from that, first with an
external display and, for its 1.3.1, on a Mac kept awake with none:
- Kept awake with the lid closed and no other display, the built-in display stays online and
`CGDisplayIsAsleep` says 0. So the lid sensor (`AppleClamshellState` on `IOPMrootDomain`)
decides that the logo is out of sight, not the display list.
- Once the daemon has handled the lid (`CBClamshellState` reads 1, a moment after the sensor),
`ClamshellUpdate` 0 relights the logo and fades work as with the lid open. The child then
writes `ClamshellUpdate` 1 to each keyboard alone, which stays dark, and to each display in
`CBDisplayList` alone (`setProperty:withKey:andDisplay:`): told the lid is open, True Tone
adapts an external display to the covered light sensor and tints it warm and dim.
- The daemon sends the real lid state again when a display or a keyboard comes up. Every beat
ends lit, so a logo found dark at the start of the next one is told again.
- `DisplayPowerStateEvent` is never written. A client's DidSleep keeps the logo dark, with the
lid open too, until someone writes DidWake.
The child does this only on the power adapter (on battery a closed MacBook sleeps, and a dark
wake must not light the logo in a bag) and only with every keyboard reachable. The first rise
has to move the logo within 1.5 s. If it does not, the logo is told again, three times in a
row and then every half minute, and the menu line says it stayed dark. Ending a blink with the
lid closed hands the logo back to normal (the automatic duration, then Unblank, which a held
level needs) and then writes `ClamshellUpdate` 1, since normal for a closed lid is dark; the
marker covers a child killed in between. When the lid opens, the daemon tells every module
itself and nothing is written. What the closed lid did last (blinking, stayed dark, nothing to
show with no agent at work, not tried and why, or that the Mac went to sleep under it, which
the child sees as wall-clock time that passed while its monotonic clock stood still) goes to a
file next to `desktop.json` (`logo-lid`, with the readings behind it on its second line), and
the app shows its first line as **Lid Closed: …** under View > Apple Logo Blink Styles, or
says nothing watches the lid while no child runs.
Whether the Mac stays awake under the lid is not the logo's business: Keep This Mac Awake's
lid helper does that (`pmset disablesleep`, which holds the Mac at full wake), or LogoLight's
own Lit with Lid Closed, or an external display. An assertion against system sleep alone would
not do: LogoLight saw the Mac enter a dark wake with it, where the logo stays dark.
The app must not end with the logo dark, and the release profile has `panic = "abort"`, so no
cleanup code of the app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as the switch is on and a server is open, tied to the app by
nothing but a pipe: when the pipe closes, however the app ended, it puts the logo back (lit,
with the daemon's automatic fade duration) and exits. Switching it off closes the same pipe,
so the path a crash takes is the one used every day. A marker file (`logo-fade`, next to
`desktop.json`) covers what the child cannot survive itself: at the next launch the app puts
back a logo a blink left dark, and with 1.50.0's marker (`logo-blink`) it switches back on a
logo that version left off. The framework is loaded at run time and never linked, and
`LEBacklightMode` reading nil (a Mac mini answers nil) is what hides the option, so a macOS
that drops it loses this feature and nothing else.
e child cannot survive itself:
The setting is system-wide and survives a reboot, so the app must not end with the
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
back and exits. Switching it off closes the same pipe, so the path a crash
takes is the one used every day. A marker file
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
at the next launch the app puts back a logo a blink left dark. The framework is
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
answers nil) is what hides the option, so a macOS that drops it loses this feature
@@ -356,24 +333,25 @@ crosses, so the login is never on a command line.
look as a failed login, like any look at a server the app has no login for.
*Check again* asks again.
- **The logo blink rests on a private macOS interface.** Apple documents the
on/off setting, not the fades the app uses, so a macOS update can take them
away. The menu disappears if `LEBacklightMode` goes; if only the fade keys
change, the logo stops moving. A long dark beat (*Calm*, *Slow*, the rest in
*Breathe*) can be up to half a second longer than its style says, while the
display waits for a frame before the logo rises. Every Blank dims the
keyboard backlight a little before it is told to stay lit. A child killed
outright (SIGKILL) leaves the logo where it was, perhaps dark, until the
display next sleeps and wakes or the app next starts. Another app that drives
the logo the same way and is not LogoLight is not recognised, and the two
fight. `api/sessions` does not say which turns are Codeman's own, so with
prompt-cache keep-warm on, a keep-alive turn blinks too.
- **Blinking with the lid closed has not been seen from this app on a real
MacBook yet.** The method is LogoLight's, which its author saw working on the
real lid, on power with no display attached (LogoLight 1.3.1). This app's own
run of it, with the Mac kept awake by the lid helper, has not been watched. If
the logo does not move, the menu line says "stayed dark". Not checked either,
by LogoLight or here: the built-in display's brightness when the lid opens
again, and an Apple display with its own light sensor.
setting, not the way the app reaches it, so a macOS update can take it away;
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
from about once every two seconds (*Slow*, *Wink*) to about three times a
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
setting is on or off, so these styles are rhythms of hard changes. After a
blink the key stays there rather than being absent as on a fresh Mac. That
reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness
LEBacklightMode` removes it. After a power cut in a
dark phase the logo stays dark until the app next starts. `api/sessions` does
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
keep-alive turn blinks too.
- **Keeping the lid awake has not been seen on a real closed MacBook yet.**
The helper ran every case against a stand-in `pmset`, the install and removal
commands ran in a temporary folder with stand-ins for `chown` and `launchctl`,
and the power assertion was measured on a Mac mini (it appears with its name
while agents work, and goes with a clean end and with a SIGKILL). Whether a
closed MacBook goes to sleep at once when the helper lets go (the app quit, an
agent finished, the adapter came out) has not been watched. The helper is
machine-wide: on a Mac where two users install it, the last install wins.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# keep-awake-lid.sh: the root helper behind "Keep This Mac Awake > Also With the Lid
# Closed" in the Codeman app for Mac.
#
# The app keeps the Mac from idle sleep on its own, with no root (a power assertion).
# Closing the lid is another matter: only `pmset -a disablesleep 1` keeps a closed
# MacBook awake, and that is a machine-wide root setting with no owner process. So this
# helper, run by the com.getcodeman.keepawake LaunchDaemon every 20 seconds, applies it
# while the app asks for it and undoes it when the app stops asking:
#
# - The app writes its pid to the request file, and rewrites it every 30 seconds, while
# it wants the lid covered.
# - The request counts only while the file is fresh (under 2 minutes old), is not a
# symlink, its pid is alive, that process belongs to the file's owner, and the Mac
# runs on its power adapter. An app that quits or crashes is a dead pid, so the lid
# is released at the next run.
# - Never on battery, whatever the app says: a closed MacBook that cannot sleep in a
# bag only heats up and drains.
# - It undoes only a disablesleep it set itself (the .owned file), so an
# administrator's own `pmset -a disablesleep 1` is never switched off.
#
# The app writes this file to a ROOT-OWNED path itself, behind macOS's administrator
# prompt; a root daemon must never run a file a user can edit.
#
# Usage: keep-awake-lid.sh <request-file>
# Test hooks (set only by the test suite; launchd passes no environment of the user's):
# CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR.
#
# bash 3.2 (what macOS ships) and BSD tools only.
set -u
REQ="${1:?usage: keep-awake-lid.sh <request-file>}"
PMSET="${CODEMAN_KEEPAWAKE_PMSET:-/usr/bin/pmset}"
STATE_DIR="${CODEMAN_KEEPAWAKE_STATE_DIR:-/Library/Application Support/Codeman}"
OWNED="$STATE_DIR/keep-awake.owned"
want=0
# -L: refuse a symlink (root must not be steered to read some other file).
# find -mmin -2: modified within the last 2 minutes (BSD and GNU find both support it).
if [ -f "$REQ" ] && [ ! -L "$REQ" ] && [ -n "$(find "$REQ" -mmin -2 2>/dev/null)" ]; then
pid=$(head -c 32 "$REQ" 2>/dev/null | tr -dc '0-9')
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
file_uid=$(ls -ln "$REQ" 2>/dev/null | awk '{print $3}')
pid_uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d ' ')
if [ -n "$file_uid" ] && [ "$file_uid" = "$pid_uid" ]; then
want=1
fi
fi
fi
# The power adapter, or nothing: an unreadable answer counts as battery.
if [ "$want" = "1" ] && ! "$PMSET" -g batt 2>/dev/null | grep -q "'AC Power'"; then
want=0
fi
current=$("$PMSET" -g 2>/dev/null | awk '/SleepDisabled/ {print $2; exit}')
[ -n "$current" ] || current=0
if [ "$want" = "1" ]; then
# Already 1 without our marker means an administrator set it: leave it and do not
# claim it, so releasing later never switches their setting off.
if [ "$current" != "1" ]; then
if "$PMSET" -a disablesleep 1; then
mkdir -p "$STATE_DIR" && : > "$OWNED"
fi
fi
elif [ -f "$OWNED" ]; then
if [ "$current" = "1" ]; then
"$PMSET" -a disablesleep 0 || exit 1
fi
rm -f "$OWNED"
fi
exit 0
+42 -10
View File
@@ -3,6 +3,7 @@
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
//! never go here; they live in the Keychain (see `credentials`).
use crate::keep_awake;
use crate::logo::BlinkStyles;
use serde::{Deserialize, Serialize};
use std::fs;
@@ -44,12 +45,15 @@ pub struct Config {
pub auto_connect: bool,
/// Always open in full screen (otherwise the last state is restored).
pub start_fullscreen: bool,
/// Whether the lit Apple logo shows what the agents are doing, and its style
/// for each state (logo.rs). The menu only shows on a Mac that has one. A
/// value this version does not know reads as off: a bad style must not make
/// the whole file unreadable.
/// What the lit Apple logo does for each state of the agents (logo.rs). The
/// menu only shows on a Mac that has one. A value this version does not know
/// 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")]
@@ -65,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 {
@@ -72,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,
}
}
@@ -416,12 +425,9 @@ mod tests {
use crate::logo::BlinkStyle;
let mut c = Config::default();
assert_eq!(c.logo_blink, BlinkStyles::default());
assert!(!c.logo_blink.enabled, "off until the user turns it on");
c.logo_blink.enabled = true;
c.logo_blink.needs_you = BlinkStyle::Wink;
c.logo_blink.needs_you = BlinkStyle::Alarm;
let text = serde_json::to_string(&c).unwrap();
assert!(text.contains(r#""needsYou":"wink""#), "{text}");
assert!(text.contains(r#""enabled":true"#), "{text}");
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
for bad in [
r#"{"logoBlink":3}"#,
@@ -433,12 +439,38 @@ 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;
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
assert!(c.logo_blink.enabled);
}
#[test]
File diff suppressed because it is too large. Load diff
+368 -2359
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -222,6 +222,13 @@ pub fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
imp::content_size(window)
}
/// An app-modal message with an OK button, for something the user asked the app
/// itself to do that failed. Main thread only; does nothing elsewhere.
#[cfg(target_os = "macos")]
pub fn show_message(title: &str, detail: &str) {
imp::show_message(title, detail);
}
#[cfg(target_os = "macos")]
mod imp {
use std::cell::{Cell, RefCell};
@@ -456,6 +463,17 @@ mod imp {
}
}
pub(super) fn show_message(title: &str, detail: &str) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let alert = NSAlert::new(mtm);
alert.setMessageText(&NSString::from_str(title));
alert.setInformativeText(&NSString::from_str(detail));
let _ = alert.addButtonWithTitle(&NSString::from_str("OK"));
alert.runModal();
}
pub(super) fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
// NSWindow is main-thread only.
MainThreadMarker::new()?;
+223 -74
View File
@@ -27,6 +27,7 @@ mod assets;
mod config;
mod credentials;
mod gui_install;
mod keep_awake;
mod local_server;
mod login;
mod logo;
@@ -110,8 +111,6 @@ enter it again (a single-user Codeman accepts the right password at once), or wa
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
const LOGO_DRIVEN_ELSEWHERE: &str =
"LogoLight is driving the Apple logo, so Codeman leaves it alone. Quit LogoLight to see your agents on it.";
/// What a download's toast calls a file whose name is not known.
const UNNAMED_FILE: &str = "the file";
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
@@ -184,8 +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),
/// What the closed lid did with the logo last (logo::watch_lid_status).
LogoLidStatus(String),
/// 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 {
@@ -322,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() {
@@ -374,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,
@@ -401,15 +415,12 @@ 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,
};
if app.menu.logo_lid.is_some() {
let lid_proxy = app.proxy.clone();
logo::watch_lid_status(move |line| {
let _ = lid_proxy.send_event(UserEvent::LogoLidStatus(line));
});
}
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
}
@@ -714,6 +725,18 @@ fn build_webview(
Ok(webview)
}
/// Hides the whole app, as Cmd+H does. A click on the Dock icon (or Cmd+Tab)
/// shows it again with every window as it was, a full-screen one included,
/// which hiding the main window alone would not do.
#[cfg(target_os = "macos")]
fn hide_app(target: &EventLoopWindowTarget<UserEvent>) {
use tao::platform::macos::EventLoopWindowTargetExtMacOS;
target.hide_application();
}
#[cfg(not(target_os = "macos"))]
fn hide_app(_target: &EventLoopWindowTarget<UserEvent>) {}
impl App {
fn handle(
&mut self,
@@ -730,17 +753,20 @@ impl App {
self.menu.help_menu.set_as_help_menu_for_nsapp();
}
}
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, control_flow),
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, target, control_flow),
Event::UserEvent(event) => self.on_user_event(event, target),
// A click on the Dock icon while no window is visible, which is
// the main window minimized: tao only reports it, so bring the
// window back here.
// window back here. An app hidden by closing its window
// (`hide_app`) needs nothing here: AppKit unhides it, with every
// window as it was, before it reports the click.
Event::Reopen {
has_visible_windows: false,
..
} => self.show_main_window(),
Event::MainEventsCleared => self.on_events_cleared(control_flow),
Event::LoopDestroyed => {
self.keep_awake.shutdown();
self.record_main_frame();
self.save_config();
}
@@ -801,17 +827,34 @@ impl App {
self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1));
}
fn on_window_event(&mut self, id: WindowId, event: WindowEvent, control_flow: &mut ControlFlow) {
fn on_window_event(
&mut self,
id: WindowId,
event: WindowEvent,
target: &EventLoopWindowTarget<UserEvent>,
control_flow: &mut ControlFlow,
) {
match event {
WindowEvent::CloseRequested if id != self.main_id => self.remove_window(id),
WindowEvent::CloseRequested => {
if id == self.main_id {
self.record_main_frame();
self.save_config();
let server = self.phase.borrow().loaded().cloned();
let mode = self.config.keep_awake.mode;
if keep_awake::available()
&& keep_awake::close_hides_app(mode, server.as_ref(), keep_awake::local_addresses)
{
// Keep This Mac Awake ends with the app, so closing the
// window while it watches a Codeman on this Mac hides
// Codeman instead, as Cmd+H does (full screen and pop-outs
// included): the Mac stays awake, a click on the Dock icon
// brings it back, and Cmd+Q still quits.
hide_app(target);
} else {
// Closing the main window quits; the server and its
// sessions keep running, so nothing is lost.
self.record_main_frame();
self.save_config();
self.keep_awake.shutdown();
*control_flow = ControlFlow::Exit;
} else {
self.remove_window(id);
}
}
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
@@ -832,7 +875,6 @@ impl App {
self.to_connect_page(msg);
}
}
UserEvent::LogoLidStatus(line) => self.show_lid_status(line),
UserEvent::ServerStartDone {
generation,
url,
@@ -887,6 +929,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();
@@ -949,6 +992,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),
}
}
@@ -1042,77 +1093,170 @@ impl App {
}
}
/// The logo is watched while the switch is on, a state has a style and a
/// server is open.
/// The logo is watched while any style is chosen and a server is open.
fn sync_logo_watch(&mut self) {
let styles = self.config.logo_blink;
let phase = self.phase.borrow();
let server = phase.loaded().filter(|_| styles.active() && logo::available());
let server = phase.loaded().filter(|_| styles.any() && logo::available());
self.logo_watch
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
drop(phase);
self.show_lid_status(logo::lid_status_line());
}
/// The line under the logo's styles: what the closed lid did last, or that
/// nothing watches it now.
fn show_lid_status(&self, line: String) {
if let Some(item) = &self.menu.logo_lid {
if self.logo_watch.watching() {
item.set_text(line);
} else {
item.set_text(logo::LID_NOT_WATCHED);
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
// A click flips the item it landed on, the chosen one included, so all
// of that state's are set.
for (item_signal, item_style, item) in &self.menu.logo_styles {
if *item_signal == signal {
item.set_checked(*item_style == style);
}
}
let was_off = !self.config.logo_blink.any();
self.config.logo_blink.set(signal, style);
self.config_dirty = true;
self.sync_logo_watch();
// Only from off: with a style running, a dark logo may be our own.
if was_off && style != BlinkStyle::Off && logo::switched_off() {
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main
.webview
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
}
}
}
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
let was_active = self.config.logo_blink.active();
self.config.logo_blink.set(signal, style);
// Picking a style is asking for the logo to show it.
if style != BlinkStyle::Off {
self.config.logo_blink.enabled = true;
/// 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);
}
self.logo_blink_changed(was_active);
}
/// The one switch. Turned on with every state off, it brings the recommended
/// styles, so that on always shows something.
fn toggle_logo_blink(&mut self) {
let was_active = self.config.logo_blink.active();
let on = !self.config.logo_blink.enabled;
self.config.logo_blink.enabled = on;
if on && !self.config.logo_blink.any_style() {
self.config.logo_blink = logo::BlinkStyles::RECOMMENDED;
}
self.logo_blink_changed(was_active);
}
fn logo_blink_changed(&mut self, was_active: bool) {
// A click flips the item it landed on, so every item is set from the
// settings, the clicked one included.
let styles = self.config.logo_blink;
if let Some(switch) = &self.menu.logo_switch {
switch.set_checked(styles.enabled);
}
for (signal, style, item) in &self.menu.logo_styles {
item.set_checked(styles.get(*signal) == *style);
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_logo_watch();
// Only from off: with a blink running, a dark logo may be our own.
if was_active || !styles.active() {
return;
}
let warning = if logo::switched_off() {
LOGO_SWITCHED_OFF
} else if logo::driven_elsewhere() {
LOGO_DRIVEN_ELSEWHERE
} else {
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;
};
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning"));
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);
}
}
@@ -1121,11 +1265,16 @@ impl App {
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(),
menu_id::LOGO_BLINK => self.toggle_logo_blink(),
menu_id::START_FULLSCREEN => {
let value = self.menu.start_fullscreen.is_checked();
self.config.start_fullscreen = value;
+86 -27
View File
@@ -4,6 +4,7 @@ use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::config::Config;
use crate::keep_awake::{self, HelperState, Mode};
use crate::logo::{self, BlinkStyle, Signal};
use crate::{APP_NAME, VERSION};
@@ -16,7 +17,6 @@ pub mod menu_id {
pub const ADD_AGENT: &str = "add-agent";
pub const RELOAD: &str = "reload";
pub const START_FULLSCREEN: &str = "start-fullscreen";
pub const LOGO_BLINK: &str = "logo-blink";
pub const DEVTOOLS: &str = "devtools";
pub const HELP_DOCS: &str = "help-docs";
pub const HELP_GITHUB: &str = "help-github";
@@ -27,14 +27,80 @@ pub struct AppMenu {
pub window_menu: Submenu,
pub help_menu: Submenu,
pub start_fullscreen: CheckMenuItem,
/// The one switch for the logo. None on a Mac without a lit logo, which has no
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// such menu.
pub logo_switch: Option<CheckMenuItem>,
/// One item per state and style, empty without the switch.
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
/// What the closed lid did with the logo last, a line that cannot be
/// clicked. None without the switch.
pub logo_lid: Option<MenuItem>,
/// 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> {
@@ -73,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",
@@ -116,20 +191,10 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
),
],
)?;
// Only a Mac with a lit Apple logo has these items at all: the switch, and the
// style for each state behind it.
// Only a Mac with a lit Apple logo has this menu at all.
let mut logo_styles = Vec::new();
let mut logo_switch = None;
let mut logo_lid = None;
if logo::available() {
let switch = CheckMenuItem::with_id(
menu_id::LOGO_BLINK,
"Blink Apple Logo",
true,
config.logo_blink.enabled,
None,
);
let submenu = Submenu::new("Apple Logo Blink Styles", true);
let submenu = Submenu::new("Blink Apple Logo", true);
for signal in Signal::ALL {
let group = Submenu::new(signal.label(), true);
for style in BlinkStyle::ALL {
@@ -144,12 +209,7 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
submenu.append(&group)?;
}
// Right under "Always Open in Full Screen", before the separator.
let lid = MenuItem::new(logo::lid_status_line(), false, None);
view.insert(&switch, 4)?;
view.insert(&submenu, 5)?;
view.insert(&lid, 6)?;
logo_switch = Some(switch);
logo_lid = Some(lid);
view.insert(&submenu, 4)?;
}
let window_menu = Submenu::with_items(
"Window",
@@ -177,8 +237,7 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
window_menu,
help_menu,
start_fullscreen,
logo_switch,
logo_styles,
logo_lid,
keep_awake,
})
}
+234
View File
@@ -0,0 +1,234 @@
/**
* @fileoverview Runs the real lid helper of the Codeman app for Mac
* (packages/desktop/macos/keep-awake-lid.sh) in bash against a stub `pmset` that
* records what it was asked to do. The helper runs as root from a LaunchDaemon, so the
* cases that matter are the ones where it must NOT act: a stale, dead, foreign or
* symlinked request, a Mac on battery, and an administrator's own `disablesleep 1`,
* which it must never switch off.
*
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
* here, so this passes on Linux and macOS alike. The app embeds the same file
* (include_str! in packages/desktop/src/keep_awake.rs), so this is the script that
* gets installed. Port: none.
*/
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const HELPER = fileURLToPath(new URL('../packages/desktop/macos/keep-awake-lid.sh', import.meta.url));
let dir: string;
let stateDir: string;
let request: string;
/**
* Stub pmset: `-g` prints the stored SleepDisabled, `-g batt` the stored power source,
* `-a disablesleep N` stores N and logs it.
*/
function writeStubPmset(initial: '0' | '1' | null, power: 'AC Power' | 'Battery Power' | null = 'AC Power') {
const value = join(dir, 'sleepdisabled');
if (initial !== null) writeFileSync(value, initial);
const source = join(dir, 'power');
if (power !== null) writeFileSync(source, power);
const stub = join(dir, 'pmset');
writeFileSync(
stub,
`#!/bin/bash
if [ "$1" = "-g" ] && [ "$2" = "batt" ]; then
[ -f "${source}" ] || exit 1
echo "Now drawing from '$(cat "${source}")'"
exit 0
fi
if [ "$1" = "-g" ]; then
echo "System-wide power settings:"
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
echo "Currently in use:"
echo " sleep 1"
exit 0
fi
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
printf '%s' "$3" > "${value}"
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
exit 0
fi
exit 1
`
);
chmodSync(stub, 0o755);
return stub;
}
const setPower = (power: 'AC Power' | 'Battery Power') => writeFileSync(join(dir, 'power'), power);
function run() {
const result = spawnSync('bash', [HELPER, request], {
encoding: 'utf-8',
env: {
...process.env,
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
},
});
expect(result.status, result.stderr).toBe(0);
}
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
/** What the app writes: its own pid and a newline. This test process stands in for it. */
const requestFromLiveApp = () => writeFileSync(request, `${process.pid}\n`);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'keep-awake-lid-'));
stateDir = join(dir, 'state');
request = join(dir, 'keep-awake-lid.pid');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('keep-awake-lid.sh', () => {
it('does nothing without a request', () => {
writeStubPmset('0');
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
});
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
run(); // steady state: no repeated pmset call
expect(calls()).toBe('disablesleep 1\n');
rmSync(request);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
});
it('never acts on battery, and lets go when the adapter is pulled', () => {
writeStubPmset('0', 'Battery Power');
requestFromLiveApp();
run();
expect(calls()).toBe('');
setPower('AC Power');
run();
expect(sleepDisabled()).toBe('1');
setPower('Battery Power');
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('reads an unanswerable power source as battery', () => {
writeStubPmset('0', null);
requestFromLiveApp();
run();
expect(calls()).toBe('');
});
it("never switches off an administrator's own disablesleep", () => {
writeStubPmset('1');
requestFromLiveApp();
run();
expect(calls()).toBe('');
expect(owned()).toBe(false);
rmSync(request);
run();
expect(sleepDisabled()).toBe('1');
expect(calls()).toBe('');
});
it("leaves an administrator's own disablesleep on when the adapter is pulled", () => {
writeStubPmset('1', 'Battery Power');
requestFromLiveApp();
run();
expect(calls()).toBe('');
expect(sleepDisabled()).toBe('1');
});
it('treats an absent SleepDisabled line as 0', () => {
writeStubPmset(null);
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
expect(owned()).toBe(true);
});
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
run();
expect(sleepDisabled()).toBe('1');
});
it('ignores a stale request (an app that hangs) and releases', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('releases at the next run once the app is gone, however fresh its file', () => {
writeStubPmset('0');
requestFromLiveApp();
run();
expect(sleepDisabled()).toBe('1');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(sleepDisabled()).toBe('0');
expect(owned()).toBe(false);
});
it('ignores a request whose pid is not running', () => {
writeStubPmset('0');
const gone = spawnSync('true').pid;
writeFileSync(request, `${gone}\n`);
run();
expect(calls()).toBe('');
});
it('ignores garbage and refuses a symlinked request', () => {
writeStubPmset('0');
writeFileSync(request, 'not-a-pid\n');
run();
expect(calls()).toBe('');
rmSync(request);
const real = join(dir, 'elsewhere.pid');
writeFileSync(real, `${process.pid}\n`);
symlinkSync(real, request);
run();
expect(calls()).toBe('');
});
});