Compare commits

...
Author SHA1 Message Date
Codeman maintainer 979275af1f fix(desktop): blink under a closed lid the way LogoLight 1.3.1 measured it
LogoLight 1.3.1 lights the logo behind a closed lid on a real MacBook kept
awake with no display attached, and its research notes say why the earlier
tries here stayed dark and what must not be written:

- Kept awake like that, the built-in display stays online and
  CGDisplayIsAsleep says 0, so the lid path was never taken ("not tried
  yet"). The lid sensor decides now (in since the last commit).
- DisplayPowerStateEvent is gone. A client's DidSleep keeps the logo dark,
  lid open too, until a DidWake is written; the last commit wrote DidSleep
  when a blink ended, and at launch with a marker and the lid closed.
- After ClamshellUpdate 0, each display in CBDisplayList is told the lid
  is closed again (setProperty:withKey:andDisplay:), as each keyboard is:
  told it is open, True Tone tints an external display warm and dim.
- The logo is told once, when the daemon has handled the lid
  (CBClamshellState, bounded to two seconds of waiting), not at every
  cycle, which would toggle those display modules with every beat. It is
  told again when it is found dark at the start of a beat, or when a rise
  did not move it: three times in a row, then every half minute, with
  "stayed dark" in the menu line.
- Unplugging under the lid tells the logo the lid is closed again.
- "The Mac went to sleep" is only said while the lid is closed.

The keep-awake side needs nothing new: pmset disablesleep holds the Mac at
full wake, which is the state LogoLight measured; an assertion against
system sleep alone gives a dark wake, where the logo stays dark.

221 tests pass on the Mac mini, and 13 deliberate breaks of the lid rules
each fail one. Not seen from this app on a real lid yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:06:29 +01:00
Codeman maintainer ede99b309c fix(desktop): the lid sensor decides, and every lid close leaves a reason
On RC4 the owner closed the lid with LogoLight not running and the menu
line still said "not tried yet": the child never wrote anything. Three
ways that could happen left no trace: the display list still showing the
built-in display awake under the closed lid (the blink then never took
the lid path), no blink running because no agent was at work, and the
Mac going to sleep at the lid close.

The lid sensor now wins: a closed lid counts as closed whatever the
display list says. While no blink runs and the lid is closed, the line
says why (nothing to show, no agent at work; the logo switched off;
LogoLight has the logo; a refused change). The child notices a sleep as
wall-clock time that passed while Instant stood still, and the line then
says "the Mac went to sleep". With no child at all (switch off, no
server open) the app says nothing watches the lid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:36:50 +01:00
Codeman maintainer c307d63d3b fix(desktop): under a closed lid, just keep blinking, and say what happened
RC3's lid path went dark on the real MacBook without a word: it waited for
CBClamshellState, gave up after two tries, and wrote ClamshellUpdate 1
back on every failed proof, and none of its silent paths left a trace
without a Terminal launch.

Now, while the lid sensor (AppleClamshellState) says closed, the built-in
display is off and the Mac is on its power adapter, the logo is told the
lid is open (and each keyboard that it is closed) at the start of every
cycle, with no tries and no give-up: monotone, so nothing is toggled in
between. With no other display on the display sleeps, which also keeps
the logo dark, so if the first rise does not move it the logo is told the
display woke (DisplayPowerStateEvent 2); DidSleep is written back at the
end only if that was sent. The end of a blink, and the marker recovery,
still put the logo back to dark as a closed lid has it.

What the closed lid did last is a line in the View menu, under Apple Logo
Blink Styles: "Lid Closed: blinking", "blinking (display told awake)",
"stayed dark, macOS keeps the logo off", or why it was not tried (on
battery, no keyboard to keep dark, no lid sensor), with the time. The
child writes it to logo-lid next to desktop.json, with the readings on a
second line; the app reads it every two seconds.

The battery and keyboard gates stay: the lid helper only holds on the
adapter, and a keyboard told the lid is open would light under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:25:22 +01:00
Codeman maintainer 4c53d424a7 fix(desktop): a firmer proof under the closed lid, and a stderr note that cannot abort
The proof that the logo moves under the closed lid now needs a fade level
above 0.01, not just above 0, so no drift of a fade held at 0 passes for a
rise; the slowest rise (the breath's) is past it in about 80 ms. The note
on stderr is a writeln! whose error is dropped: eprintln! panics on a
failed write, and the blink child has panic = "abort".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:41:49 +01:00
Codeman maintainer 39f2e847d8 feat(desktop): the lid logo keeps blinking with the lid closed
With the lid closed, corebrightnessd tells every module so and keeps the
logo dark, so the blink stopped as soon as the lid came down, which is
exactly when the logo on the lid is the one thing in view.

While the Mac stays awake with the lid closed on its power adapter (Keep
This Mac Awake's lid helper, or an external display), the blink child now
does what LogoLight's Lit with Lid Closed measured on the real MacBook:
once the daemon has handled the lid (CBClamshellState), it tells the logo
the lid is open (ClamshellUpdate 0) and each keyboard alone that it is
closed, so the keyboard stays dark and is never relit after a Blank.

The first rise after that has to move the logo within 1.5 s. If it does
not (with no display on, the daemon may still hold the logo dark), the
lid is told closed again, tried once more, then left dark until the lid
opens, a display comes on or the agents' state changes. A logo the daemon
takes back (it resends the lid state when a display or keyboard comes up)
is told again. Ending a blink under the closed lid hands the logo back to
normal and then writes ClamshellUpdate 1, since normal there is dark; the
marker recovery does the same for a killed child. Never on battery (a
dark wake must not light the logo in a bag), never without the keyboard
IDs, and nothing is written when the lid opens. Every step is a line on
stderr so a real-Mac run shows which one stopped it.

Not seen on a real MacBook yet; the README says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:36:32 +01:00
Codeman maintainer 7d96b3dcf6 feat(desktop): the lid logo breathes, blinks and flashes on its own fades, behind one switch
The 1.50.0 blink switched LEBacklightMode on and off. Every style was a lit
logo with short dark blips that differed only in how many there were, and
Working (Heartbeat) looked busier than Waiting for you (Wink), so urgency
read backwards. Each change was also a write to a setting corebrightnessd
keeps in root's preferences, and new users had to pick a style in three
submenus before anything happened.

Now one rule: the more the logo moves, the more it needs you.

- View > Blink Apple Logo is one switch, off until turned on. It shows
  Breathe while agents work, Calm for a yellow tab and Alarm for a red one.
  Each state can take another style under View > Apple Logo Blink Styles.
  Picking a style turns the switch on, and turning it on with every state
  off brings back those three.
- Breathe is new: a slow fade out and back in, every 6.3 s.
- The logo moves on the LED's own hardware fades (LEPWMFactorFadeDuration,
  then DisplayLogicalState Blank or Unblank), as LogoLight measured them on
  the real logo (MIT, reimplemented in Rust, not copied). A blink writes no
  setting and leaves nothing behind a restart. LEBacklightMode is only read,
  so a logo the user switched off stays off.
- The direction of a fade is tracked, because the daemon ignores a write in
  the direction it already heads and the state can't be read back. When the
  direction is unknown, the logo is turned round with a 1e9 s fade first.
- After every Blank the keyboards alone are told Unblank, and only while
  the built-in display is awake (CGDisplayIsAsleep). They are told Blank when
  it sleeps and Unblank when it wakes. When it sleeps, the fade duration
  also goes back to automatic, as LogoLight does.
- A rise from exactly dark waits for a display frame, up to 0.5 s. Short
  dark beats (up to 0.35 s) turn round before their fall ends, so they never
  reach 0. Long ones time their light from when the rise starts.
- Every end puts the logo back: lit, with the automatic fade duration. A
  new marker file (logo-fade) covers a killed child. 1.50.0's marker
  (logo-blink) still switches back on a logo that version left off.
- While LogoLight runs, Codeman leaves the logo to it (process-name check),
  and the app says so when the switch is turned on.
- Settings from 1.50.0 keep their styles, with the switch on if any was
  chosen. All three off was that version's default, so it reads as
  untouched.

The tests run the blink against a fake corebrightnessd that moves in
17.5 ms frames on the measured spring curve. It starts each fade 19 ms
after the write, ignores a write in the direction it already heads, and
holds a rise from exactly dark back by a configurable delay. They pin these
cases:
- dips never reach 0
- long dark beats keep their light
- no keyboard is told to light while the display sleeps
- every end leaves the automatic duration and a lit logo
- a logo switched off or driven by LogoLight is left alone
- old settings are migrated

An ignored probe (what_this_mac_answers) reads a Mac's displays,
processes, keyboards and logo without writing anything.

Not measured on the real logo, since there was no MacBook to test on:
- how the 0.12 to 0.36 s fades of the hard styles look
- whether a rise from a dip (about 7%) really skips the frame wait
- the keyboard during Alarm
- the keyboards through a real display sleep

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:55:59 +01:00
Codeman maintainer f62c1f7e2f fix(local-model): a view read while the job ends no longer shows paused
The install window polls GET /api/local-model every second. A read of the
state file that was still out while the job wrote its last state and let
go of this.job came back with the old `installing`, and settling that with
no job in the process read it as an interrupted job: `paused` with Resume
for one tick before `failed`. Under CI load this made
test/local-model-install-e2e.test.ts fail on a different failure case per
run (master 86502b69, PR #790).

currentState() now reads again when the job ended during the read. The job
writes before it lets go, so the second read has the final state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:51:07 +01:00
Ark0N 86502b6904 Merge pull request #789 from Ark0N/chore/desktop-cleanup
refactor(desktop): clean up the Mac app's code, no behavior change
2026-11-09 02:27:43 +01:00
10 changed files with 2614 additions and 425 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** 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.
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.
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 会问一次是否打开这个从互联网下载的应用:点**打开**。
+4 -3
View File
@@ -248,9 +248,10 @@ 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** 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.
**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.
### First launch
+90 -35
View File
@@ -40,7 +40,7 @@ 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, 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. |
| **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. |
| **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)
@@ -225,31 +225,79 @@ 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 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.
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.
**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.
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 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:
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:
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
@@ -308,17 +356,24 @@ 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
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.
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.
- **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
+10 -5
View File
@@ -44,9 +44,10 @@ pub struct Config {
pub auto_connect: bool,
/// Always open in full screen (otherwise the last state is restored).
pub start_fullscreen: bool,
/// 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.
/// 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.
#[serde(deserialize_with = "lenient_blink")]
pub logo_blink: BlinkStyles,
/// Anything that is not a whole frame reads as None: a bad frame must not
@@ -415,9 +416,12 @@ mod tests {
use crate::logo::BlinkStyle;
let mut c = Config::default();
assert_eq!(c.logo_blink, BlinkStyles::default());
c.logo_blink.needs_you = BlinkStyle::Alarm;
assert!(!c.logo_blink.enabled, "off until the user turns it on");
c.logo_blink.enabled = true;
c.logo_blink.needs_you = BlinkStyle::Wink;
let text = serde_json::to_string(&c).unwrap();
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
assert!(text.contains(r#""needsYou":"wink""#), "{text}");
assert!(text.contains(r#""enabled":true"#), "{text}");
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
for bad in [
r#"{"logoBlink":3}"#,
@@ -434,6 +438,7 @@ mod tests {
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]
+2348 -357
View File
File diff suppressed because it is too large. Load diff
+73 -17
View File
@@ -110,6 +110,8 @@ 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.";
@@ -182,6 +184,8 @@ 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),
}
struct ProbeDone {
@@ -399,6 +403,13 @@ fn main() {
logo_watch: logo::Watcher::default(),
};
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));
}
@@ -821,6 +832,7 @@ impl App {
self.to_connect_page(msg);
}
}
UserEvent::LogoLidStatus(line) => self.show_lid_status(line),
UserEvent::ServerStartDone {
generation,
url,
@@ -1030,34 +1042,77 @@ impl App {
}
}
/// The logo is watched while any style is chosen and a server is open.
/// The logo is watched while the switch is on, a state has a style 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.any() && logo::available());
let server = phase.loaded().filter(|_| styles.active() && 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();
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;
}
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);
}
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"));
}
// 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 {
return;
};
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning"));
}
}
@@ -1070,6 +1125,7 @@ impl App {
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;
+27 -4
View File
@@ -16,6 +16,7 @@ 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";
@@ -26,9 +27,14 @@ pub struct AppMenu {
pub window_menu: Submenu,
pub help_menu: Submenu,
pub start_fullscreen: CheckMenuItem,
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// The one switch for the logo. None 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>,
}
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
@@ -110,10 +116,20 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
),
],
)?;
// Only a Mac with a lit Apple logo has this menu at all.
// Only a Mac with a lit Apple logo has these items at all: the switch, and the
// style for each state behind it.
let mut logo_styles = Vec::new();
let mut logo_switch = None;
let mut logo_lid = None;
if logo::available() {
let submenu = Submenu::new("Blink Apple Logo", true);
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);
for signal in Signal::ALL {
let group = Submenu::new(signal.label(), true);
for style in BlinkStyle::ALL {
@@ -128,7 +144,12 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
submenu.append(&group)?;
}
// Right under "Always Open in Full Screen", before the separator.
view.insert(&submenu, 4)?;
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);
}
let window_menu = Submenu::with_items(
"Window",
@@ -156,6 +177,8 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
window_menu,
help_menu,
start_fullscreen,
logo_switch,
logo_styles,
logo_lid,
})
}
+9 -2
View File
@@ -1053,9 +1053,16 @@ class LocalModelServiceImpl implements LocalModelService {
return progress ? { ...next, progress } : next;
}
/** The stored state as a view or a verb must see it: settled, unless a verb is just starting a job. */
/**
* The stored state as a view or a verb must see it: settled, unless a verb is just starting a
* job. A job writes its last state BEFORE it lets go of `this.job`, so when the job ended while
* this read was out, the read may hold the `installing` from before that write; it is read
* again rather than settled, or it would show as an interrupted (`paused`) job for a tick.
*/
private async currentState(): Promise<LocalModelStateFile> {
const state = await this.deps.readState();
const jobAtRead = this.job;
let state = await this.deps.readState();
if (jobAtRead && !this.job) state = await this.deps.readState();
return this.jobStarting ? state : this.settled(state);
}
+51
View File
@@ -1938,6 +1938,57 @@ describe('view()', () => {
await h.service.cancel();
});
it('a view whose read began while the job ran shows how the job ended, never paused', async () => {
// The window polls every second, so a read of the state file can be under way while the
// job writes its last state and lets go. That read returns the old `installing`, and
// settling it with no job in this process read it as an interrupted job: the window (and
// the e2e test, under CI load) saw `paused` with Resume for one tick instead of `failed`.
const slow = {
read: undefined as undefined | (() => Promise<LocalModelStateFile>),
hold: undefined as undefined | Promise<void>,
entered: undefined as undefined | (() => void),
};
const h = setup({
overrides: {
readState: async () => {
const snapshot = await slow.read!();
const hold = slow.hold;
slow.hold = undefined;
if (hold) {
slow.entered?.();
await hold;
}
return snapshot;
},
},
});
slow.read = h.store.readState;
let failVerify!: () => void;
const verifying = new Promise<void>((resolve) => (failVerify = resolve));
h.deps.verifyModel.mockImplementationOnce(async () => {
await verifying;
return { ok: false, code: 'no-tool-call' } as never;
});
await h.service.install(REQUEST, ADMIN);
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
// A view starts reading while the job is still in the verify step...
let releaseRead!: () => void;
slow.hold = new Promise<void>((resolve) => (releaseRead = resolve));
const entered = new Promise<void>((resolve) => (slow.entered = resolve));
const viewing = h.service.view();
await entered;
// ...the job fails, writes it and ends while that read is still out...
failVerify();
await h.service.idle();
expect(h.store.get().state).toBe('failed');
// ...and only then does the read come back, with the file as it was before.
releaseRead();
const view = await viewing;
expect(view).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'no-tool-call' } });
});
it('names the model to try next only when the catalogue still has it', async () => {
const state: LocalModelStateFile = {
...jobAt('verify', 'paused'),