diff --git a/DESIGN.md b/DESIGN.md index 43f17a9..e9b3398 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -57,7 +57,7 @@ 2. Active view content. 3. Main view: a Claude | Codex usage summary (limits plus 14-day tokens), runtime session counts, a `내 응답 대기 | 실행 중 | 턴 종료 · 검토` kanban board, then a `WORKSPACES` inventory for session-less, unknown and inactive tasks. Every card shows IDE/Terminal/Finder launchers and repo/branch Git facts without expanding; expanding adds last commits and session request/activity/response excerpts. Base repositories remain in a supporting disclosure. 4. Usage view: per-agent limits and today's token mix, one daily chart with both agents on a shared scale, and a last-7-days table. - 5. Settings view: five category tabs, each one numbered page. `01 · General` opens with a Status group (the check's outcome with the one apply button, then CLI and Companion versions in the order the update runs, the update log, and a one-line CLI/agent connection summary linking to Connections), then launch-at-login and Live Claude Limits; `02 · Connections` CLI install and agent connections, right after General because the other features build on it; `03 · Menu Bar` the tray title items and percent basis; `04 · Screen Saver` the switch, idle minutes and `Show now` preview; `05 · Appearance` agent theme, font, text size and a live preview. + 5. Settings view: five category tabs, each one numbered page. `01 · General` opens with a Status group (the check's outcome with the one apply button, then CLI and Companion versions in the order the update runs, the update log, and a one-line CLI/agent connection summary linking to Connections), then launch-at-login and Live Claude Limits; `02 · Connections` CLI install and agent connections, right after General because the other features build on it; `03 · Menu Bar` the tray title items and percent basis; `04 · Screen Saver` the switch and `Show now` preview; `05 · Appearance` agent theme, font, text size and a live preview. 6. Screen-reader live status in the agent shell; routine `Updated`/`Ready` text stays out of the visible header. ## Design principles @@ -212,9 +212,9 @@ Git facts, base repositories, repo notes and launchers remain supporting details Columns come straight from the existing runtime roles; no plan/execution phase is inferred. A task appears once, in the column of its representative role (waiting > running > finished), and other observed sessions surface as `+N ` chips. Stale or uncertain observations never enter a live column: they stay in `WORKSPACES` tagged `관측 불명`, beside `세션 없음` and `비활성`. Card borders take a faint tint of the lead session's provider (`--provider-claude-line` orange, `--provider-codex-line` cyan, `--provider-grok-line` silver); waiting cards add an outer `--runtime-waiting-ring` instead of recolouring the border, so provider and attention read together. Inventory rows stay untinted. Review cards prefer the last response excerpt over the last tool activity. Runtime state colours are theme tokens (`--runtime-waiting`, `--runtime-running`, `--runtime-finished`, `--runtime-unknown` and their soft fills) shared by both themes. Providers render as a 14px `ProviderIcon` instead of names: simplified marks (Claude spark, Codex hexagon, Grok slashed ring) coloured with the matching `--provider-claude`, `--provider-codex` and `--provider-grok` tokens, each with a `title` and `role="img"` accessible name (`Claude Code`, `Codex`, `Grok Build`). They are not brand artwork. -## Screensaver dashboard (COMP001) +## Screensaver dashboard (COMP001, COMP002) -A full-screen, view-only window on every display, shown by the opt-in screensaver while agents work and nobody touches the Mac, and read from two or three metres away. The top-left holds a 7-segment clock (hours and minutes), the date and an `입력 시 잠금` tag, shown only when input will lock the Mac (`미리 보기 · 입력하면 닫힘` in a Settings preview); the top-right holds the per-state counts in large type. Below sit the board's three live columns in the same order, with the same names, wait reasons and outcome labels (`ui/boardCopy.ts`) and the same runtime colour tokens. A card carries what the menu bar card carries and no controls: task name and time in state at title size, agent, repo · branch, one prompt line, one line-clamped detail (tool activity, or the last response on turn-ended cards, with the wait reason on waiting cards and `중단됨`/`오류로 종료` on turn-ended ones), summed repo facts (`●N`, `↑N`, `↓N`) and the notification count. Each column shows three cards, then `+n more`; sizes scale with viewport height, so nothing scrolls or overflows at any display size. Theme and font follow Settings. The window and webview background is the Claude `--surface-0` (`#131313`), so opening and closing never flash white. Behaviour is specified in `docs/specs/comp/screensaver.md`. +A full-screen, view-only window on every display, shown above the macOS screen saver and lock screen while agents work, and read from two or three metres away. The top-left holds a 7-segment clock (hours and minutes), the date and a `화면 잠김` tag, shown only when the Mac really locks under it (`미리 보기 · 입력하면 닫힘` in a Settings preview); the top-right holds the per-state counts in large type. Below sit the board's three live columns in the same order, with the same names, wait reasons and outcome labels (`ui/boardCopy.ts`) and the same runtime colour tokens. A card carries what the menu bar card carries and no controls: task name and time in state at title size, agent, repo · branch, one prompt line, one line-clamped detail (tool activity, or the last response on turn-ended cards, with the wait reason on waiting cards and `중단됨`/`오류로 종료` on turn-ended ones), summed repo facts (`●N`, `↑N`, `↓N`) and the notification count. Each column shows three cards, then `+n more`; sizes scale with viewport height, so nothing scrolls or overflows at any display size. Theme and font follow Settings. The window and webview background is the Claude `--surface-0` (`#131313`), so opening and closing never flash white. Behaviour is specified in `docs/specs/comp/screensaver.md`. ## Onboarding and Settings connections diff --git a/README.ko.md b/README.ko.md index 2cb74ed..e0e5a81 100644 --- a/README.ko.md +++ b/README.ko.md @@ -229,10 +229,10 @@ Workbranch Companion은 Main, Usage, Settings view를 제공하는 macOS menu ba - Usage: agent별 상세 — 한도, 오늘의 input/output/cache token, 두 agent를 같은 축에 놓은 14일 일별 차트, 최근 7일 표. Companion은 로그인 없이 로컬 파일만 읽습니다: `~/.claude/projects`의 Claude Code transcript와 `~/.claude.json`에 캐시된 `/usage` 결과, `~/.codex/sessions`의 Codex rollout. 한도는 각 agent가 마지막으로 관측한 값이라 관측 시각을 함께 보여주고 오래되면 흐리게 표시합니다. Claude 한도는 Claude Code가 `/usage`를 갱신할 때 바뀝니다. Settings > General > Live Claude Limits를 켜면 Claude Code 세션이 열려 있는 동안 실시간으로 바뀝니다(opt-in: `~/.claude/settings.json`의 `statusLine`을 statusline 입력의 `rate_limits`를 저장하는 relay로 바꾸고, 기존 statusline은 그대로 실행합니다. 끄면 원래대로 되돌립니다) - Settings: General(업데이트·연결 상태, login 시 자동 실행, Live Claude Limits), Connections, Menu Bar, Screen Saver, Appearance 다섯 탭으로 나눕니다. login 시 자동 실행, menu bar icon 옆 표시(Claude 5h, Claude weekly, Codex weekly, 오늘 token 중 원하는 항목을 고르고 한도를 사용 % 또는 남은 %로 표시. 예: `CL 5h 42% · W 63% CO W 18% · 31.0M`. 1분마다 갱신하고 오래된 값은 `–`로 표시), interface font와 글자 크기, Claude Code 또는 Codex theme, 화면보호기, CLI/agent 연결 설정 - 업데이트: header의 업데이트 버튼은 Settings > General로 이동해 업데이트를 확인하고, Status 그룹에 설치·최신 버전을 표시합니다. Homebrew 확인은 요청할 때만 실행하고, 업데이트 버튼 하나로 CLI를 먼저 올린 뒤 Companion을 업데이트합니다. 설치·연결·업데이트 버튼은 실행 중 spinner를 보여주고, 결과는 해당 버튼 옆에 표시합니다 -- 화면보호기(opt-in, 기본 꺼짐): Settings > Screen Saver > While agents work 를 켜면 실행 중이거나 내 응답을 기다리는 세션이 있는 동안 화면이 잠겨 있어도 Mac 이 idle 잠자기에 들어가지 않습니다. 화면이 잠기지 않은 채 정한 시간(Dashboard after, 기본 5분) 동안 아무도 Mac 을 건드리지 않으면 macOS 화면보호기 대신 모든 모니터에 아래 dashboard 를 띄웁니다. 잠금이 가능한 환경에서는 입력 시 macOS 잠금을 요청하며, 잠긴 뒤에는 macOS 암호나 Touch ID 로 돌아옵니다. 잠금이 불가능하면 잠금 표시를 숨기고 입력 시 잠금 없이 dashboard 만 닫습니다. 잠금 요청이 3회(약 6초) 모두 실패해도 잠금 없이 dashboard 만 닫습니다. 실행 중·대기 세션이 없어지면 잠자기·화면보호기·잠금은 다시 macOS 설정을 따릅니다. Show now 는 잠금 없이 dashboard 를 미리 보여 줍니다. 추가 권한을 요청하지 않습니다 +- 화면보호기(opt-in, 기본 꺼짐): Settings > Screen Saver > While agents work 를 켜면 실행 중이거나 내 응답을 기다리는 세션이 있는 동안 화면이 잠겨 있어도 Mac 이 idle 잠자기에 들어가지 않습니다. Mac 을 쓰는 동안 macOS 화면보호기와 잠금은 macOS 설정대로 시작합니다. 화면보호기가 시작되거나 화면이 잠기면 companion 은 Mac 을 잠그고(아직 안 잠겼으면), 모든 모니터의 잠금 화면 위에 아래 dashboard 를 띄우고, 디스플레이를 켜 둡니다. Mac 은 잠긴 채 잠들지 않고 진행 상황이 화면에 보입니다. 입력하면 dashboard 가 비켜 macOS 잠금 화면이 보이고, 암호나 Touch ID 로 잠금을 풉니다. 풀지 않고 15초 두면 dashboard 가 다시 뜹니다. 잠금이 불가능하면 잠금 표시를 숨기고 companion 이 직접 잠그지 않습니다. 실행 중·대기 세션이 없어지면 잠금 화면·디스플레이·잠자기는 다시 macOS 설정을 따릅니다. companion 은 꺼진 디스플레이를 켜지 않으므로 macOS 화면보호기 시간을 디스플레이 끄기 시간보다 짧게 두세요. Show now 는 잠금 없이 dashboard 를 미리 보여 줍니다. 추가 권한을 요청하지 않습니다

- 모니터에 뜬 Companion 화면보호기 dashboard: 7-segment 시계, 날짜와 입력 시 잠금 표시, 큰 상태별 개수, 그리고 내 응답 대기 · 실행 중 · 턴 종료 · 검토 열. task 카드마다 프롬프트, 기다리는 권한 승인, 지금 하는 도구 동작 또는 마지막 응답, agent, repo · branch, repo 변경 상태를 보여 줍니다. 5분 동안 입력이 없으면 모든 모니터에 뜹니다. 잠금 표시는 잠금 가능 시에만 보이며, 잠금 불가 또는 요청 실패 시 잠금 없이 dashboard 만 닫힙니다. + 모니터에 뜬 Companion 화면보호기 dashboard: 7-segment 시계, 날짜와 화면 잠김 표시, 큰 상태별 개수, 그리고 내 응답 대기 · 실행 중 · 턴 종료 · 검토 열. task 카드마다 프롬프트, 기다리는 권한 승인, 지금 하는 도구 동작 또는 마지막 응답, agent, repo · branch, repo 변경 상태를 보여 줍니다. macOS 화면보호기와 잠금 화면 위, 모든 모니터에 뜹니다. 잠금 표시는 잠금 가능 시에만 보입니다.

Companion은 hook 기반 runtime snapshot과 Git 조회 결과를 별도로 사용합니다. `TASK-WORKBRANCH.md` 기록은 필요하지 않습니다. 설치 후 `workbranch hooks install --provider claude` 또는 `--provider codex`를 사용하고 provider의 hook trust를 확인하세요. Grok Build는 명시적인 신뢰 승인이 필요합니다. `workbranch hooks describe --provider grok`로 plugin source를 확인하고, 신뢰하는 경우에만 `workbranch hooks install --provider grok --trust`를 실행하세요. Companion의 Grok 연결 버튼도 같은 source를 먼저 보여줍니다. 자세한 내용은 [Companion 설치와 agent 연결](docs/usage.ko.md#companion-설치와-agent-연결)을 참고하세요. diff --git a/README.md b/README.md index 3d7dbf0..ee1ced6 100644 --- a/README.md +++ b/README.md @@ -229,10 +229,10 @@ Workbranch Companion is a macOS menu bar app with Main, Usage, and Settings view - Usage: per-agent detail — limits, today's input/output/cache tokens, a shared 14-day daily chart, and a last-7-days table. Companion reads only local files and never signs in: Claude Code transcripts in `~/.claude/projects` and its cached `/usage` result in `~/.claude.json`, and Codex rollouts in `~/.codex/sessions`. Limits are the agents' last observation, so each shows its age and dims when stale; Claude's limits refresh when Claude Code refreshes `/usage`, or live while a Claude Code session is open once Settings > General > Live Claude Limits is on (opt-in: it points `statusLine` in `~/.claude/settings.json` at a relay that saves the status line's `rate_limits` and still runs your previous status line; turning it off restores it) - Settings: five tabs — General (update and connection status, launch at login, Live Claude Limits), Connections, Menu Bar, Screen Saver and Appearance. They control launch at login, the text beside the menu bar icon (pick any of Claude 5h, Claude weekly, Codex weekly and today's tokens, shown as used or remaining percent, for example `CL 5h 42% · W 63% CO W 18% · 31.0M`; refreshed every minute, `–` when a reading is stale), the interface font and text size, the Claude Code or Codex theme, the screen saver, and CLI/agent connections - Updates: the header's update button opens Settings > General and checks for updates; its Status group shows the installed and latest versions. It checks Homebrew only when you ask, and one update action upgrades the CLI before Companion. Setup and update buttons show a spinner while they run, and each result appears next to the button that ran it -- Screen saver (opt-in, off by default): with Settings > Screen Saver > While agents work on, the Mac stays out of idle sleep while a session is running or waiting on you, even with the screen locked. If the screen is unlocked and nobody touches the Mac for the chosen time (Dashboard after, 5 minutes by default), every display shows the dashboard below instead of the macOS screen saver. When locking is available, input requests the macOS lock screen; after it locks, your macOS password or Touch ID is required. If locking is unavailable, the lock indicator is hidden and input only closes the dashboard without locking. If lock requests fail after three attempts (about 6 seconds), the dashboard also closes without locking. Once no session is running or waiting, sleep, screen saver and lock follow macOS settings again. Show now previews the dashboard without locking. No extra permissions are requested +- Screen saver (opt-in, off by default): with Settings > Screen Saver > While agents work on, the Mac stays out of idle sleep while a session is running or waiting on you, even with the screen locked. While you use the Mac, the macOS screen saver and lock start on their own schedule. When the screen saver starts or the screen locks, Companion locks the Mac (if macOS has not yet), shows the dashboard below on every display on top of the lock screen, and keeps the display on, so the Mac stays locked and awake and its progress stays in view. Input moves the dashboard aside to reveal the macOS lock screen, where your password or Touch ID unlocks the Mac; left alone for 15 seconds, the dashboard comes back. If locking is unavailable, the lock indicator is hidden and Companion does not lock by itself. Once no session is running or waiting, the lock screen, display and sleep follow macOS settings again. Keep the macOS screen saver time shorter than the display-off time, since Companion never turns a display back on. Show now previews the dashboard without locking. No extra permissions are requested

- Companion's screen saver dashboard on a display: a 7-segment clock, the date and 입력 시 잠금 (input locks), large counts per state, and the 내 응답 대기 (waiting on you), 실행 중 (running) and 턴 종료 · 검토 (turn ended, review) columns, where each task card shows its prompt, the permission it waits on, its current tool activity or last response, the agent, repo and branch, and repo changes. No input for 5 minutes brings it up on every display. The lock indicator is shown only when locking is available; unavailable or failed locking closes the dashboard without locking. + Companion's screen saver dashboard on a display: a 7-segment clock, the date and 화면 잠김 (screen locked), large counts per state, and the 내 응답 대기 (waiting on you), 실행 중 (running) and 턴 종료 · 검토 (turn ended, review) columns, where each task card shows its prompt, the permission it waits on, its current tool activity or last response, the agent, repo and branch, and repo changes. It shows on every display over the macOS screen saver and lock screen. The lock indicator is shown only when locking is available.

Companion reads hook-driven runtime snapshots separately from Git status. No `TASK-WORKBRANCH.md` reporting is required. Install hooks with `workbranch hooks install --provider claude` or `--provider codex`, then review provider hook trust. Grok Build needs explicit trust: inspect the plugin source with `workbranch hooks describe --provider grok`, and only if you trust it run `workbranch hooks install --provider grok --trust`. Companion's Grok connect button shows the same source first. See [Companion installation and agent connections](docs/usage.md#companion-installation-and-agent-connections). diff --git a/apps/companion/README.md b/apps/companion/README.md index 586d0d6..5e62ea0 100644 --- a/apps/companion/README.md +++ b/apps/companion/README.md @@ -8,7 +8,7 @@ The companion is a presentation-first consumer of the CLI JSON contract. It read Agent runtime state comes from `RuntimeService` (`src-tauri/src/runtime_service.rs`). It watches the runtime directory, reads `workbranch runtime --json` once writes settle and at least every 60 seconds, and pushes each result to every window as the `runtime-snapshot` event and to the screensaver in process; a window that opens later asks for the latest one with `runtime_snapshot`. Windows check the document against the contract themselves. See `docs/specs/comp/runtime-snapshot.md`. -The opt-in screensaver (`src-tauri/src/screensaver.rs` and `src-tauri/src/screensaver/`) keeps the Mac from idle sleep while agents work, covers every display with the dashboard (`dashboard.html`, its own Vite entry and `dashboard` capability) after the chosen idle time, and requests the macOS lock screen on input when locking is available. If locking is unavailable, the lock indicator is hidden and input closes the dashboard without locking; three failed lock attempts also close it without locking. A pure state machine (`screensaver/machine.rs`) decides and a `Platform` trait acts, so transitions are unit-tested without the OS. It relies on two private macOS APIs, `SACLockScreenImmediate` and the webview background that Tauri's `macOSPrivateApi` controls, so the app cannot ship through the Mac App Store. `WORKBRANCH_SCREENSAVER_NO_LOCK=1` starts it as if the lock were unavailable. See `docs/specs/comp/screensaver.md`. +The opt-in screensaver (`src-tauri/src/screensaver.rs` and `src-tauri/src/screensaver/`) keeps the Mac from idle sleep while agents work. When the macOS screen saver starts or the screen locks, it locks the Mac if needed, shows the dashboard (`dashboard.html`, its own Vite entry and `dashboard` capability) on every display above the lock screen, and keeps the display on; input moves the dashboard aside to reveal the lock screen. If locking is unavailable, the lock indicator is hidden and it never locks by itself. A pure state machine (`screensaver/machine.rs`) decides and a `Platform` trait acts, so transitions are unit-tested without the OS. It relies on private macOS APIs, `SACLockScreenImmediate`, a SkyLight space above the lock screen and the webview background that Tauri's `macOSPrivateApi` controls, so the app cannot ship through the Mac App Store. `WORKBRANCH_SCREENSAVER_NO_LOCK=1` starts it as if the lock were unavailable. See `docs/specs/comp/screensaver.md`. Agent usage comes from the Rust `usage_snapshot` command (`src-tauri/src/usage.rs`), which reads only files the agents already write: Claude Code transcripts under `~/.claude/projects` (each response counted once by `message.id` + `requestId`) and the `/usage` result Claude Code caches in `~/.claude.json` (or, when the opt-in Live Claude Limits relay in `src-tauri/src/claude_statusline.rs` is on, the newer `rate_limits` Claude Code passed to its status line, saved in the companion's data directory), plus Codex rollouts under `~/.codex/sessions` and `~/.codex/archived_sessions` (token deltas from `total_token_usage`, with a file's first event counted by its `last_token_usage` and identical events shared by forked rollouts counted once; cache reads and writes split out of input; limits from `rate_limits`, whose reset may be epoch seconds or an RFC3339 string). `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honored. It never signs in or touches the network, keeps parsed files in memory keyed by size and mtime, and returns 15-minute token buckets that the frontend folds into local days. Daily totals match ccusage. diff --git a/apps/companion/src-tauri/Cargo.toml b/apps/companion/src-tauri/Cargo.toml index d0cc098..f061f32 100644 --- a/apps/companion/src-tauri/Cargo.toml +++ b/apps/companion/src-tauri/Cargo.toml @@ -40,12 +40,12 @@ undocumented_unsafe_blocks = "deny" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" -# Typed AppKit/Foundation bindings for the screensaver (COMP001); the same -# versions Tauri already pulls in, so no new crates enter the build. +# Typed AppKit/Foundation bindings for the screensaver (COMP001, COMP002); the +# same versions Tauri already pulls in, so no new crates enter the build. [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" objc2 = "0.6.4" -objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFDictionary", "CFNumber", "CFString"] } +objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] } objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["std", "CGEventSource", "CGEventTypes", "CGSession"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "block2", "NSDistributedNotificationCenter", "NSNotification", "NSObject", "NSOperation", "NSProcessInfo", "NSString"] } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] } diff --git a/apps/companion/src-tauri/src/screensaver.rs b/apps/companion/src-tauri/src/screensaver.rs index 8166270..79521ea 100644 --- a/apps/companion/src-tauri/src/screensaver.rs +++ b/apps/companion/src-tauri/src/screensaver.rs @@ -1,6 +1,8 @@ -//! Keeps the Mac awake while agents work (COMP001). `machine` decides, +//! Keeps the Mac awake while agents work and shows the dashboard over the +//! macOS screen saver and lock screen (COMP001, COMP002). `machine` decides, //! `platform` acts; this module feeds the machine from the runtime service, -//! the preference store and screen-lock notifications, on one thread. +//! the preference store, screen-lock and screen-saver notifications and +//! readings of the session, on one thread. mod machine; #[cfg(target_os = "macos")] @@ -10,7 +12,7 @@ mod windows; use std::sync::Mutex; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Runtime, State}; use tauri_plugin_store::StoreExt; @@ -26,7 +28,11 @@ const CONFIG_KEY: &str = "screensaver"; enum Message { Runtime(RuntimeSnapshot), ConfigChanged, - Locked(bool), + /// macOS said the screen locked or unlocked; the session is read for + /// the state itself. + LockChanged, + /// The macOS screen saver started or stopped. + Saver(bool), DisplaysChanged, Preview, } @@ -34,7 +40,7 @@ enum Message { pub(crate) struct Screensaver { /// Wakes the screensaver thread, e.g. right after Settings changes. wake: Mutex>, - /// Whether input on the dashboard can lock the Mac (COMP001 R7). + /// Whether the dashboard can lock the Mac (COMP001 R7). lock_available: bool, } @@ -60,9 +66,15 @@ pub(crate) fn start(app: &AppHandle, runtime: &RuntimeService) -> #[cfg(target_os = "macos")] { let on_lock = Mutex::new(sender.clone()); - macos::watch_screen_lock(app, move |locked| { + macos::watch_screen_lock(app, move || { if let Ok(sender) = on_lock.lock() { - let _ = sender.send(Message::Locked(locked)); + let _ = sender.send(Message::LockChanged); + } + }); + let on_saver = Mutex::new(sender.clone()); + macos::watch_screen_saver(app, move |running| { + if let Ok(sender) = on_saver.lock() { + let _ = sender.send(Message::Saver(running)); } }); let on_displays = Mutex::new(sender.clone()); @@ -96,27 +108,39 @@ fn run( ) { let mut machine = Machine::new(lock_available); - let locked = platform.screen_locked(); - apply(&mut platform, &machine.step(Event::Locked(locked))); + let first = sample(&platform); + apply(&mut platform, &machine.step(first)); apply( &mut platform, &machine.step(Event::Config(read_config(app))), ); + // When the session is read next. Messages in between never push it back, + // so runtime snapshots arriving faster than the poll (agents at work) + // cannot starve the readings. + let mut due: Option = None; loop { - // Idle time has no notification, so it is sampled, and only while - // the machine could act on it. While a lock is awaited, the lock - // state is sampled instead, since its notification can come late. + // Idle time has no notification, and the lock notification can come + // late or stray, so the session is read on a timer, only while the + // machine could act on it. let message = match machine.poll_interval() { - Some(interval) => match messages.recv_timeout(interval) { - Ok(message) => Some(message), - Err(RecvTimeoutError::Timeout) => None, - Err(RecvTimeoutError::Disconnected) => return, - }, - None => match messages.recv() { - Ok(message) => Some(message), - Err(_) => return, - }, + Some(interval) => { + let now = Instant::now(); + let deadline = due.map_or(now + interval, |due| due.min(now + interval)); + due = Some(deadline); + match messages.recv_timeout(deadline.saturating_duration_since(now)) { + Ok(message) => Some(message), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => return, + } + } + None => { + due = None; + match messages.recv() { + Ok(message) => Some(message), + Err(_) => return, + } + } }; let event = match message { Some(Message::Runtime(snapshot)) => match snapshot.document.as_deref() { @@ -125,7 +149,12 @@ fn run( None => Event::Unreadable(Duration::from_millis(snapshot.read_at_ms)), }, Some(Message::ConfigChanged) => Event::Config(read_config(app)), - Some(Message::Locked(locked)) => Event::Locked(locked), + Some(Message::LockChanged) | None => sample(&platform), + // Where the session can be asked, it is the truth. + Some(Message::Saver(running)) => match platform.screen_saver_running() { + Some(_) => sample(&platform), + None => Event::Saver(running), + }, Some(Message::Preview) => Event::Preview, Some(Message::DisplaysChanged) => { // A display came or went: rebuild the windows to match. @@ -136,13 +165,24 @@ fn run( } continue; } - None if machine.awaiting_lock() => Event::LockSample(platform.screen_locked()), - None => Event::Idle(platform.idle_time()), }; + if matches!(event, Event::Sample { .. }) { + // This turn read the session; the next reading is a full + // interval away. + due = None; + } apply(&mut platform, &machine.step(event)); } } +fn sample(platform: &impl Platform) -> Event { + Event::Sample { + idle: platform.idle_time(), + locked: platform.screen_locked(), + saver: platform.screen_saver_running(), + } +} + fn read_config(app: &AppHandle) -> Config { app.store(PREFERENCES_STORE_FILE).map_or_else( |_| Config::default(), @@ -164,7 +204,7 @@ pub(crate) struct ScreensaverStatus { lock_available: bool, } -/// Lets the dashboard tell whether input will lock the Mac (COMP001 R7). +/// Lets the dashboard tell whether it shows over a locked Mac (COMP001 R7). #[tauri::command] pub(crate) fn screensaver_status(screensaver: State<'_, Screensaver>) -> ScreensaverStatus { ScreensaverStatus { diff --git a/apps/companion/src-tauri/src/screensaver/machine.rs b/apps/companion/src-tauri/src/screensaver/machine.rs index c911e8b..b622125 100644 --- a/apps/companion/src-tauri/src/screensaver/machine.rs +++ b/apps/companion/src-tauri/src/screensaver/machine.rs @@ -4,51 +4,25 @@ use std::time::Duration; use serde::Deserialize; -const DEFAULT_IDLE_MINUTES: u32 = 5; -const MIN_IDLE_MINUTES: u32 = 1; -const MAX_IDLE_MINUTES: u32 = 60; - /// Mirrors `ScreensaverConfig` in application/screensaver.ts, stored under /// the `screensaver` preference key. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) struct Config { pub(crate) enabled: bool, - pub(crate) idle_minutes: u32, -} - -impl Default for Config { - fn default() -> Self { - Self { - enabled: false, - idle_minutes: DEFAULT_IDLE_MINUTES, - } - } } impl Config { - fn idle_after(self) -> Duration { - Duration::from_secs(u64::from(self.idle_minutes) * 60) - } - - /// Reads each field on its own and falls back to that field's default, - /// as `sanitizeScreensaverConfig()` does, so Settings and this service - /// always read a damaged value the same way. + /// Reads the switch and falls back to off, as + /// `sanitizeScreensaverConfig()` does, so Settings and this service + /// always read a damaged value the same way. Fields the feature no longer + /// uses (`idleMinutes`) are ignored. pub(crate) fn from_store_value(value: Option) -> Self { - let defaults = Self::default(); - let field = |name: &str| value.as_ref().and_then(|value| value.get(name)); Self { - enabled: field("enabled") + enabled: value + .as_ref() + .and_then(|value| value.get("enabled")) .and_then(serde_json::Value::as_bool) - .unwrap_or(defaults.enabled), - // Rounded, then held to the bounds, like Math.round there. - idle_minutes: field("idleMinutes") - .and_then(serde_json::Value::as_f64) - .map_or(defaults.idle_minutes, |minutes| { - minutes - .round() - .clamp(f64::from(MIN_IDLE_MINUTES), f64::from(MAX_IDLE_MINUTES)) - as u32 - }), + .unwrap_or_default(), } } } @@ -134,12 +108,20 @@ pub(crate) enum Event { Working(bool), /// A runtime read failed, at this time since the Unix epoch. Unreadable(Duration), - Locked(bool), - /// The session's lock state, read on each sample while a requested lock - /// is awaited, so the lock is confirmed even when its notification is late. - LockSample(bool), - /// How long since the last keyboard or mouse input, sampled while armed. - Idle(Duration), + /// The macOS screen saver started (`true`) or stopped (`false`), as its + /// notifications tell, where the session cannot be asked. + Saver(bool), + /// A reading of the session, taken on every poll and whenever macOS says + /// the screen locked or unlocked or the screen saver started or stopped: + /// time since the last keyboard or mouse input, whether the session reads + /// as locked, and whether its screen saver runs (`None` where macOS + /// cannot be asked). The session, not a notification, is the truth, so a + /// stray or missed notification never misleads the machine. + Sample { + idle: Duration, + locked: bool, + saver: Option, + }, /// Settings asked to show the dashboard now (COMP001 R12). Preview, } @@ -150,40 +132,66 @@ pub(crate) enum Action { /// screen locked or not (COMP001 R2). HoldAwake, ReleaseAwake, - /// Keeps the display on, which also holds off the macOS screen saver and - /// its lock, while the dashboard may show (R3, R4). + /// Keeps the display on while the screen saver or the lock screen is up, + /// so the dashboard stays in view (COMP002 R2). HoldDisplay, ReleaseDisplay, + /// Shows the dashboard above the screen saver and the lock screen. ShowDashboard, HideDashboard, - /// Hands the screen to the macOS lock (COMP001 R6). + /// Asks for the session lock, so the dashboard never covers a screen + /// saver that would end without a password (COMP002 R3). LockScreen, /// Shows the dashboard as a preview: input closes it, nothing locks. ShowPreview, } -/// How often idle time is sampled while waiting to show the dashboard. +/// How often the session is read while agents work. pub(crate) const IDLE_POLL: Duration = Duration::from_secs(1); -/// How often it is sampled while the dashboard shows, to catch input quickly. +/// How often it is read while the dashboard shows or a lock is awaited, to +/// catch input and the lock quickly. pub(crate) const INPUT_POLL: Duration = Duration::from_millis(250); +/// How long input must pause, with the screen saver or the lock screen up, +/// before the dashboard shows. Someone typing the password keeps it away; +/// the screen saver itself starts after a far longer pause, so it is +/// covered at once. +pub(crate) const SHOW_AFTER: Duration = Duration::from_secs(15); /// How long failed runtime reads keep the last reading of whether agents /// work. A read can fail for a moment (a timeout, the CLI mid-upgrade) while /// agents go on working; reads that keep failing must not pin the Mac awake. pub(crate) const UNREADABLE_GRACE: Duration = Duration::from_secs(180); /// Samples to wait for each lock request to land (~2 s). pub(crate) const LOCK_WAIT_SAMPLES: u8 = 8; -/// Times the lock is asked for before this Mac counts as having none (~6 s). +/// Times the lock is asked for before giving up on it for this screen saver +/// (~6 s). pub(crate) const LOCK_ATTEMPTS: u8 = 3; +/// Samples the dashboard ignores input for once it shows over the lock +/// screen (~1 s), so a stray reading while macOS raises the lock screen +/// never takes it straight down again. +pub(crate) const LIVE_GRACE_SAMPLES: u8 = 4; /// Samples a preview ignores input for, so the click that opened it and the /// hand still on the mouse do not close it at once (~2 s). pub(crate) const PREVIEW_GRACE_SAMPLES: u8 = 8; -#[derive(Clone, Copy, Debug)] -struct LockWait { - /// Lock requests made so far. - attempts: u8, - /// Samples taken since the latest request. - samples: u8, +/// What covers the screen, with the samples taken since it showed. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum Shown { + #[default] + Nothing, + Live(u8), + Preview(u8), +} + +/// Where the lock asked for under a running screen saver stands. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum LockRequest { + /// Nothing asked for. + #[default] + None, + /// Asked for `attempts` times, the latest `samples` readings ago. + Awaiting { attempts: u8, samples: u8 }, + /// Never landed: left to macOS until this screen saver ends. + GaveUp, } #[derive(Debug, Default)] @@ -192,21 +200,26 @@ pub(crate) struct Machine { working: bool, /// When runtime reads started failing, while they keep failing. unreadable_since: Option, + /// Whether the login session reads as locked. locked: bool, + /// Whether the macOS screen saver runs. + saver: bool, + /// Set when someone signs in. A reading that the screen saver still runs + /// is not believed again until one reads it stopped or a new one starts, + /// so a late reading never locks the Mac just after it was unlocked + /// (Touch ID leaves no keyboard or mouse input to hold the lock off). + saver_stale: bool, /// Whether the system is held awake. awake: bool, /// Whether the display is held on. display: bool, - dashboard: bool, - /// The latest idle sample; dropped whenever the machine disarms, since - /// nothing is sampled meanwhile and an old reading would misfire. + shown: Shown, + lock_request: LockRequest, + /// The latest idle sample; dropped whenever nothing is sampled, since an + /// old reading would look like input. idle: Option, /// Whether macOS offers an immediate lock here (COMP001 R7). lock_available: bool, - /// A lock asked for and not confirmed yet. - lock_wait: Option, - /// Samples taken since a preview opened, while it shows. - preview: Option, } impl Machine { @@ -218,16 +231,48 @@ impl Machine { } /// The feature is on and agents work: the system stays awake, even with - /// the screen locked, since a lock stops no agent but sleep does (R2). + /// the screen locked, since a lock stops no agent but sleep does. fn keeps_awake(&self) -> bool { self.config.enabled && self.working } - /// Armed: awake and the screen unlocked, so the display stays on and the - /// dashboard may show. The display, screen saver and lock screen are - /// otherwise left to the OS (R3, R5). - fn armed(&self) -> bool { - self.keeps_awake() && !self.locked + /// Covered: agents work and macOS has put up its screen saver or its + /// lock screen. The display then stays on and the dashboard shows on + /// top. Until then nothing here holds the display, so the screen saver, + /// the lock and display sleep follow the macOS settings. + fn covered(&self) -> bool { + self.keeps_awake() && (self.locked || self.saver) + } + + /// Whether the dashboard may show now. Where the Mac can lock, only over + /// a session that reads as locked, so the dashboard never claims a lock + /// that is not there and input never reveals the desktop without a + /// password (COMP002 R3). + fn may_show(&self) -> bool { + self.covered() && (self.locked || !self.lock_available) + } + + fn read_session(&mut self, idle: Duration, locked: bool, saver: Option) -> bool { + let input = self.idle.is_some_and(|previous| idle < previous); + self.idle = Some(idle); + if self.locked && !locked { + // Someone signed in: no screen saver runs behind the desktop. + self.saver = false; + self.saver_stale = true; + } + self.locked = locked; + if let Some(saver) = saver { + if !saver { + self.saver_stale = false; + } + self.saver = saver && !self.saver_stale; + } + if input && !self.locked { + // Input ends a screen saver that has no lock behind it, even if + // its stop notification never comes. + self.saver = false; + } + input } pub(crate) fn step(&mut self, event: Event) -> Vec { @@ -244,83 +289,73 @@ impl Machine { self.working = false; } } - Event::Locked(locked) | Event::LockSample(locked) => self.locked = locked, - Event::Idle(idle) => { - input = self.idle.is_some_and(|previous| idle < previous); - self.idle = Some(idle); + Event::Saver(running) => { + if running { + // A new screen saver, not a late reading of the old one. + self.saver_stale = false; + } + self.saver = running; } + Event::Sample { + idle, + locked, + saver, + } => input = self.read_session(idle, locked, saver), Event::Preview => {} } - let armed = self.armed(); - if !armed && self.preview.is_none() { - self.idle = None; + let covered = self.covered(); + let sampled = matches!(event, Event::Sample { .. }); + if !covered || self.locked { + // A lock request ends with the lock or with the screen saver. + self.lock_request = LockRequest::None; } let mut actions = Vec::new(); - if let Some(samples) = self.preview { - // A preview answers only to input after its grace, and to a lock. - let closes = match event { - Event::Idle(_) => { - let samples = samples.saturating_add(1); - self.preview = Some(samples); - input && samples > PREVIEW_GRACE_SAMPLES + match self.shown { + Shown::Preview(samples) if sampled => { + // A preview answers only to input after its grace, and to a + // lock. + let samples = samples.saturating_add(1); + self.shown = Shown::Preview(samples); + if (input && samples > PREVIEW_GRACE_SAMPLES) || self.locked { + self.shown = Shown::Nothing; + actions.push(Action::HideDashboard); } - Event::Locked(true) | Event::LockSample(true) => true, - _ => false, - }; - if closes { - self.preview = None; - self.dashboard = false; + } + Shown::Preview(_) => {} + Shown::Live(_) if !self.may_show() => { + self.shown = Shown::Nothing; actions.push(Action::HideDashboard); } - } else if matches!(event, Event::Preview) && !self.dashboard { - self.preview = Some(0); - self.dashboard = true; - // The first sample sets the baseline that input is measured from. - self.idle = None; - actions.push(Action::ShowPreview); - } else if self.dashboard && armed && self.lock_wait.is_some() { - // The dashboard stays up until the session reads as locked, so the - // desktop never shows and no key reaches the app underneath (R6). - // A lock that has not landed is asked for again. One that never - // lands means this Mac offers no lock after all: the dashboard - // closes as it does without one (R7), rather than cover the - // screen with no way back. - if let (Event::LockSample(_), Some(wait)) = (event, self.lock_wait) { - let samples = wait.samples.saturating_add(1); - if samples < LOCK_WAIT_SAMPLES { - self.lock_wait = Some(LockWait { samples, ..wait }); - } else if wait.attempts < LOCK_ATTEMPTS { - self.lock_wait = Some(LockWait { - attempts: wait.attempts + 1, - samples: 0, - }); - actions.push(Action::LockScreen); - } else { - self.lock_wait = None; - self.dashboard = false; + Shown::Live(samples) if sampled => { + // Input uncovers the lock screen, so its owner can sign in. + let samples = samples.saturating_add(1); + self.shown = Shown::Live(samples); + if input && samples > LIVE_GRACE_SAMPLES { + self.shown = Shown::Nothing; actions.push(Action::HideDashboard); } } - } else if self.dashboard && armed && input && self.lock_available { - self.lock_wait = Some(LockWait { - attempts: 1, - samples: 0, - }); - actions.push(Action::LockScreen); - } else if self.dashboard && (!armed || input) { - self.lock_wait = None; - self.dashboard = false; - actions.push(Action::HideDashboard); - } else if !self.dashboard - && armed - && self - .idle - .is_some_and(|idle| idle >= self.config.idle_after()) - { - self.dashboard = true; - actions.push(Action::ShowDashboard); + Shown::Live(_) => {} + Shown::Nothing if matches!(event, Event::Preview) => { + self.shown = Shown::Preview(0); + // The first sample sets the baseline input is measured from. + self.idle = None; + actions.push(Action::ShowPreview); + } + Shown::Nothing => { + let paused = self.idle.is_some_and(|idle| idle >= SHOW_AFTER); + if paused && self.may_show() { + self.shown = Shown::Live(0); + actions.push(Action::ShowDashboard); + } else if paused && covered && sampled { + // The screen saver runs and macOS has not locked yet: + // lock first, and show once the lock reads as landed. + self.ask_for_lock(&mut actions); + } + } } + let awake = self.keeps_awake(); if awake != self.awake { self.awake = awake; @@ -330,41 +365,67 @@ impl Machine { Action::ReleaseAwake }); } - if armed != self.display { - self.display = armed; - actions.push(if armed { + if covered != self.display { + self.display = covered; + actions.push(if covered { Action::HoldDisplay } else { Action::ReleaseDisplay }); } + if self.poll_interval().is_none() { + self.idle = None; + } actions } - pub(crate) fn showing(&self) -> bool { - self.dashboard + /// Asks for the lock, again every `LOCK_WAIT_SAMPLES` readings that do + /// not find it, up to `LOCK_ATTEMPTS` times. A lock that never lands is + /// left to macOS, which locks on its own once its password delay ends; + /// the dashboard waits for that, with the display kept on. + fn ask_for_lock(&mut self, actions: &mut Vec) { + self.lock_request = match self.lock_request { + LockRequest::None => { + actions.push(Action::LockScreen); + LockRequest::Awaiting { + attempts: 1, + samples: 0, + } + } + LockRequest::Awaiting { attempts, samples } => { + let samples = samples.saturating_add(1); + if samples < LOCK_WAIT_SAMPLES { + LockRequest::Awaiting { attempts, samples } + } else if attempts < LOCK_ATTEMPTS { + actions.push(Action::LockScreen); + LockRequest::Awaiting { + attempts: attempts + 1, + samples: 0, + } + } else { + LockRequest::GaveUp + } + } + LockRequest::GaveUp => LockRequest::GaveUp, + }; } - pub(crate) fn previewing(&self) -> bool { - self.preview.is_some() + pub(crate) fn showing(&self) -> bool { + self.shown != Shown::Nothing } - /// A lock was asked for and has not landed: the service then samples the - /// session's lock state instead of idle time. - pub(crate) fn awaiting_lock(&self) -> bool { - self.lock_wait.is_some() + pub(crate) fn previewing(&self) -> bool { + matches!(self.shown, Shown::Preview(_)) } - /// When to sample idle time next; `None` while there is nothing to show. + /// When to read the session next; `None` while nothing could change. pub(crate) fn poll_interval(&self) -> Option { - if self.preview.is_some() { - Some(INPUT_POLL) - } else if !self.armed() { - None - } else if self.dashboard { + if self.showing() || matches!(self.lock_request, LockRequest::Awaiting { .. }) { Some(INPUT_POLL) - } else { + } else if self.keeps_awake() { Some(IDLE_POLL) + } else { + None } } } diff --git a/apps/companion/src-tauri/src/screensaver/machine_tests.rs b/apps/companion/src-tauri/src/screensaver/machine_tests.rs index 5098fe2..3117a85 100644 --- a/apps/companion/src-tauri/src/screensaver/machine_tests.rs +++ b/apps/companion/src-tauri/src/screensaver/machine_tests.rs @@ -26,88 +26,36 @@ fn document(sessions: &[serde_json::Value]) -> String { } fn enabled() -> Config { - Config { - enabled: true, - idle_minutes: 5, - } + Config { enabled: true } } #[test] -fn config_defaults_to_off_and_five_minutes() { - assert_eq!( - Config::from_store_value(None), - Config { - enabled: false, - idle_minutes: 5 - } - ); +fn config_defaults_to_off() { + assert_eq!(Config::from_store_value(None), Config { enabled: false }); } #[test] -fn config_falls_back_to_defaults_for_a_broken_value() { +fn config_falls_back_to_off_for_a_broken_value() { assert_eq!( Config::from_store_value(Some(serde_json::json!("on"))), Config::default() ); -} - -#[test] -fn config_reads_the_preference_shape() { assert_eq!( - Config::from_store_value(Some( - serde_json::json!({ "enabled": true, "idleMinutes": 12 }) - )), - Config { - enabled: true, - idle_minutes: 12 - } + Config::from_store_value(Some(serde_json::json!({ "enabled": "yes" }))), + Config::default() ); } #[test] -fn config_reads_each_field_on_its_own_as_settings_does() { - // A damaged minutes value keeps the switch the user turned on, as - // sanitizeScreensaverConfig() keeps it in Settings. +fn config_reads_the_switch_and_ignores_the_old_idle_minutes() { assert_eq!( Config::from_store_value(Some( - serde_json::json!({ "enabled": true, "idleMinutes": "5" }) - )), - Config { - enabled: true, - idle_minutes: 5 - } - ); - assert_eq!( - Config::from_store_value(Some( - serde_json::json!({ "enabled": "yes", "idleMinutes": 9 }) + serde_json::json!({ "enabled": true, "idleMinutes": 12 }) )), - Config { - enabled: false, - idle_minutes: 9 - } + enabled() ); } -#[test] -fn config_rounds_fractional_minutes_like_settings() { - let config = Config::from_store_value(Some( - serde_json::json!({ "enabled": true, "idleMinutes": 2.6 }), - )); - assert_eq!(config.idle_minutes, 3); -} - -#[test] -fn config_clamps_idle_minutes_to_one_through_sixty() { - let low = Config::from_store_value(Some( - serde_json::json!({ "enabled": true, "idleMinutes": 0 }), - )); - let high = Config::from_store_value(Some( - serde_json::json!({ "enabled": true, "idleMinutes": 600 }), - )); - assert_eq!(low.idle_minutes, 1); - assert_eq!(high.idle_minutes, 60); -} - #[test] fn only_observed_running_or_waiting_sessions_count_as_working() { assert!(is_working(&document(&[session("running", "observed")]))); @@ -168,173 +116,301 @@ fn a_document_the_board_would_reject_is_not_working() { assert!(!is_working(&document(&[negative]))); } +fn secs(n: u64) -> Duration { + Duration::from_secs(n) +} + +fn ms(n: u64) -> Duration { + Duration::from_millis(n) +} + +fn minutes(n: u64) -> Duration { + Duration::from_secs(n * 60) +} + +/// A reading where macOS cannot be asked about its screen saver, so its +/// notifications alone tell. +fn unlocked(idle: Duration) -> Event { + Event::Sample { + idle, + locked: false, + saver: None, + } +} + +fn locked(idle: Duration) -> Event { + Event::Sample { + idle, + locked: true, + saver: None, + } +} + +/// A reading that also asks whether the screen saver runs. +fn read(idle: Duration, locked: bool, saver: bool) -> Event { + Event::Sample { + idle, + locked, + saver: Some(saver), + } +} + +/// A runtime read time, as seconds since the epoch. +fn at(seconds: u64) -> Duration { + Duration::from_secs(1_760_000_000 + seconds) +} + +/// The feature on and agents working, at the desk. +fn working(lock_available: bool) -> Machine { + let mut machine = Machine::new(lock_available); + machine.step(Event::Config(enabled())); + machine.step(Event::Working(true)); + machine.step(unlocked(secs(1))); + machine +} + +/// The dashboard up over a locked screen, past its input grace. +fn covering_the_lock_screen() -> Machine { + let mut machine = working(true); + machine.step(locked(secs(1))); + assert_eq!( + machine.step(locked(SHOW_AFTER)), + vec![Action::ShowDashboard] + ); + for n in 1..=u64::from(LIVE_GRACE_SAMPLES) { + assert_eq!(machine.step(locked(SHOW_AFTER + ms(250 * n))), vec![]); + } + machine +} + #[test] fn nothing_happens_until_the_feature_is_on() { - let mut machine = Machine::default(); + let mut machine = Machine::new(true); assert_eq!(machine.step(Event::Working(true)), vec![]); - assert_eq!(machine.step(Event::Locked(false)), vec![]); + assert_eq!(machine.step(Event::Saver(true)), vec![]); + assert_eq!(machine.step(locked(minutes(10))), vec![]); + assert_eq!(machine.poll_interval(), None); } #[test] -fn working_with_the_feature_on_keeps_the_mac_awake() { - let mut machine = Machine::default(); +fn work_keeps_the_system_awake_and_leaves_the_display_to_macos() { + let mut machine = Machine::new(true); assert_eq!(machine.step(Event::Config(enabled())), vec![]); - assert_eq!( - machine.step(Event::Working(true)), - vec![Action::HoldAwake, Action::HoldDisplay] - ); - assert_eq!(machine.step(Event::Working(true)), vec![]); + assert_eq!(machine.step(Event::Working(true)), vec![Action::HoldAwake]); + // No display hold at the desk: the macOS screen saver and its lock start + // on their own schedule. + assert_eq!(machine.step(unlocked(minutes(30))), vec![]); + assert_eq!(machine.poll_interval(), Some(IDLE_POLL)); assert_eq!( machine.step(Event::Working(false)), - vec![Action::ReleaseAwake, Action::ReleaseDisplay] + vec![Action::ReleaseAwake] ); + assert_eq!(machine.poll_interval(), None); } #[test] fn turning_the_feature_off_releases_at_once() { - let mut machine = Machine::default(); + let mut machine = Machine::new(true); machine.step(Event::Working(true)); assert_eq!( machine.step(Event::Config(enabled())), - vec![Action::HoldAwake, Action::HoldDisplay] + vec![Action::HoldAwake] ); assert_eq!( machine.step(Event::Config(Config::default())), - vec![Action::ReleaseAwake, Action::ReleaseDisplay] + vec![Action::ReleaseAwake] ); } #[test] -fn a_locked_screen_keeps_the_system_awake_and_leaves_the_display_to_the_os() { - let mut machine = Machine::default(); - machine.step(Event::Config(enabled())); - machine.step(Event::Working(true)); - assert_eq!( - machine.step(Event::Locked(true)), - vec![Action::ReleaseDisplay] - ); - assert_eq!(machine.step(Event::Working(true)), vec![]); +fn the_screen_saver_gets_a_lock_behind_it_then_the_dashboard_on_top() { + let mut machine = working(true); + assert_eq!(machine.step(Event::Saver(true)), vec![Action::HoldDisplay]); + // The screen saver starts only after a long pause, and macOS may wait + // before asking for the password: the session locks first. + assert_eq!(machine.step(unlocked(minutes(5))), vec![Action::LockScreen]); + assert!(!machine.showing()); + assert_eq!(machine.poll_interval(), Some(INPUT_POLL)); + // The dashboard shows once the lock reads as landed. assert_eq!( - machine.step(Event::Locked(false)), - vec![Action::HoldDisplay] + machine.step(locked(minutes(5) + ms(250))), + vec![Action::ShowDashboard] ); + // The screen saver stopping behind the lock changes nothing. + assert_eq!(machine.step(Event::Saver(false)), vec![]); + assert!(machine.showing()); } -#[test] -fn work_ending_while_locked_releases_the_system() { - let mut machine = Machine::default(); - machine.step(Event::Config(enabled())); - machine.step(Event::Working(true)); - machine.step(Event::Locked(true)); - assert_eq!( - machine.step(Event::Working(false)), - vec![Action::ReleaseAwake] - ); +/// Steps unlocked readings under a running screen saver until something +/// happens, returning how many readings it took and the actions. +fn until_action(machine: &mut Machine, from: Duration) -> (u8, Vec) { + for sample in 1..=LOCK_WAIT_SAMPLES { + let actions = machine.step(unlocked(from + ms(250 * u64::from(sample)))); + if !actions.is_empty() { + return (sample, actions); + } + } + (LOCK_WAIT_SAMPLES + 1, vec![]) } #[test] -fn work_starting_while_locked_holds_only_the_system() { - let mut machine = Machine::default(); - machine.step(Event::Config(enabled())); - machine.step(Event::Locked(true)); - assert_eq!(machine.step(Event::Working(true)), vec![Action::HoldAwake]); - assert_eq!(machine.poll_interval(), None); -} - -fn minutes(n: u64) -> Duration { - Duration::from_secs(n * 60) -} - -fn armed() -> Machine { - let mut machine = Machine::default(); - machine.step(Event::Config(enabled())); - machine.step(Event::Working(true)); - machine +fn a_lock_that_has_not_landed_is_asked_for_again_without_the_dashboard() { + let mut machine = working(true); + machine.step(Event::Saver(true)); + machine.step(unlocked(minutes(5))); + for attempt in 1..LOCK_ATTEMPTS { + assert_eq!( + until_action(&mut machine, minutes(5) + secs(u64::from(attempt) * 2)), + (LOCK_WAIT_SAMPLES, vec![Action::LockScreen]) + ); + // Nothing claims a lock that is not there. + assert!(!machine.showing()); + } } -/// A runtime read time, as seconds since the epoch. -fn at(seconds: u64) -> Duration { - Duration::from_secs(1_760_000_000 + seconds) +#[test] +fn a_lock_that_never_lands_leaves_the_screen_saver_to_macos() { + let mut machine = working(true); + machine.step(Event::Saver(true)); + machine.step(unlocked(minutes(5))); + for attempt in 1..LOCK_ATTEMPTS { + until_action(&mut machine, minutes(5) + secs(u64::from(attempt) * 2)); + } + // Given up: no more requests and no dashboard over an unlocked screen, + // but the display stays on for macOS to lock after its password delay. + assert_eq!( + until_action(&mut machine, minutes(6)), + (LOCK_WAIT_SAMPLES + 1, vec![]) + ); + assert_eq!(machine.step(unlocked(minutes(7))), vec![]); + assert!(!machine.showing()); + assert_eq!(machine.poll_interval(), Some(IDLE_POLL)); + // macOS locks on its own: the dashboard comes. + assert_eq!( + machine.step(locked(minutes(10))), + vec![Action::ShowDashboard] + ); } #[test] -fn a_failed_read_keeps_the_last_reading_for_a_while() { - let mut machine = armed(); - machine.step(Event::Idle(minutes(5))); - // A timeout or a CLI mid-upgrade: the dashboard and the awake hold stay. - assert_eq!(machine.step(Event::Unreadable(at(0))), vec![]); +fn signing_in_never_locks_again_on_a_late_screen_saver_reading() { + let mut machine = working(true); + machine.step(read(minutes(5), false, true)); + machine.step(read(minutes(5) + ms(250), true, true)); + assert!(machine.showing()); + // Touch ID: the session unlocks with no keyboard or mouse input, and the + // screen saver still reads as running for a moment. assert_eq!( - machine.step(Event::Unreadable(at(UNREADABLE_GRACE.as_secs() - 1))), + machine.step(read(minutes(5) + ms(500), false, true)), + vec![Action::HideDashboard, Action::ReleaseDisplay] + ); + assert_eq!( + machine.step(read(minutes(5) + secs(1), false, true)), vec![] ); - assert!(machine.showing()); + assert_eq!( + machine.step(read(minutes(5) + secs(2), false, false)), + vec![] + ); + // A screen saver that starts afterwards is a new one. + assert_eq!( + machine.step(read(minutes(15), false, true)), + vec![Action::LockScreen, Action::HoldDisplay] + ); } #[test] -fn reads_that_keep_failing_never_pin_the_mac_awake() { - let mut machine = armed(); - machine.step(Event::Idle(minutes(5))); - machine.step(Event::Unreadable(at(0))); +fn a_new_screen_saver_after_signing_in_is_believed_from_its_notification() { + let mut machine = working(true); + machine.step(read(minutes(5), false, true)); + machine.step(read(minutes(5) + ms(250), true, true)); + machine.step(read(minutes(5) + ms(500), false, true)); + assert_eq!(machine.step(Event::Saver(true)), vec![Action::HoldDisplay]); +} + +#[test] +fn a_screen_saver_already_running_is_read_from_the_session() { + // Started (or restarted) mid screen saver: no notification will come. + let mut machine = working(true); assert_eq!( - machine.step(Event::Unreadable(at(UNREADABLE_GRACE.as_secs()))), - vec![ - Action::HideDashboard, - Action::ReleaseAwake, - Action::ReleaseDisplay - ] + machine.step(read(minutes(5), false, true)), + vec![Action::LockScreen, Action::HoldDisplay] + ); + assert_eq!( + machine.step(read(minutes(5) + ms(250), true, true)), + vec![Action::ShowDashboard] ); } #[test] -fn a_good_read_ends_the_grace() { - let mut machine = armed(); - machine.step(Event::Unreadable(at(0))); - machine.step(Event::Working(true)); - // A new run of failures starts its own grace. +fn the_session_corrects_a_screen_saver_notification() { + let mut machine = working(true); + machine.step(Event::Saver(true)); + // The session says no screen saver runs: nothing is held or shown. assert_eq!( - machine.step(Event::Unreadable(at(UNREADABLE_GRACE.as_secs()))), - vec![] + machine.step(read(minutes(5), false, false)), + vec![Action::ReleaseDisplay] ); + assert!(!machine.showing()); } #[test] -fn a_failed_read_never_starts_work() { - let mut machine = Machine::default(); - machine.step(Event::Config(enabled())); - assert_eq!(machine.step(Event::Unreadable(at(0))), vec![]); - assert_eq!(machine.poll_interval(), None); +fn a_lock_shows_the_dashboard_once_input_pauses() { + let mut machine = working(true); + // Locked by hand (Ctrl-Cmd-Q): the display stays on and the dashboard + // waits until the hand leaves the keyboard. + assert_eq!(machine.step(locked(secs(1))), vec![Action::HoldDisplay]); + assert_eq!(machine.step(locked(secs(10))), vec![]); + assert_eq!( + machine.step(locked(SHOW_AFTER)), + vec![Action::ShowDashboard] + ); } #[test] -fn the_dashboard_shows_once_input_paused_for_the_configured_minutes() { - let mut machine = armed(); - assert_eq!(machine.step(Event::Idle(minutes(4))), vec![]); +fn input_uncovers_the_lock_screen_and_a_pause_brings_the_dashboard_back() { + let mut machine = covering_the_lock_screen(); + assert_eq!(machine.step(locked(ms(100))), vec![Action::HideDashboard]); + // The lock screen stays lit for its owner to sign in. + assert_eq!(machine.poll_interval(), Some(IDLE_POLL)); + assert_eq!(machine.step(locked(secs(5))), vec![]); assert_eq!( - machine.step(Event::Idle(minutes(5))), + machine.step(locked(SHOW_AFTER)), vec![Action::ShowDashboard] ); - assert_eq!(machine.step(Event::Idle(minutes(6))), vec![]); } #[test] -fn no_dashboard_without_work_without_the_feature_or_while_locked() { - let mut idle_only = Machine::default(); - idle_only.step(Event::Config(enabled())); - assert_eq!(idle_only.step(Event::Idle(minutes(30))), vec![]); - - let mut off = Machine::default(); - off.step(Event::Working(true)); - assert_eq!(off.step(Event::Idle(minutes(30))), vec![]); +fn input_right_after_the_dashboard_shows_is_ignored_over_the_lock() { + let mut machine = working(true); + machine.step(locked(secs(1))); + machine.step(locked(SHOW_AFTER)); + for _ in 0..LIVE_GRACE_SAMPLES { + assert_eq!(machine.step(locked(ms(10))), vec![]); + } + assert_eq!(machine.step(locked(ms(5))), vec![Action::HideDashboard]); +} - let mut locked = armed(); - locked.step(Event::Locked(true)); - assert_eq!(locked.step(Event::Idle(minutes(30))), vec![]); +#[test] +fn signing_in_hands_the_screen_and_the_display_back() { + let mut machine = working(true); + machine.step(Event::Saver(true)); + machine.step(unlocked(minutes(5))); + machine.step(locked(minutes(5) + ms(250))); + assert!(machine.showing()); + assert_eq!( + machine.step(unlocked(ms(100))), + vec![Action::HideDashboard, Action::ReleaseDisplay] + ); + // The screen saver's stop notification never came: signing in still + // means it is gone. + assert_eq!(machine.step(unlocked(SHOW_AFTER)), vec![]); + assert!(!machine.showing()); } #[test] -fn the_dashboard_comes_down_when_work_ends() { - let mut machine = armed(); - machine.step(Event::Idle(minutes(5))); +fn work_ending_under_the_dashboard_hands_everything_back_to_macos() { + let mut machine = covering_the_lock_screen(); assert_eq!( machine.step(Event::Working(false)), vec![ @@ -343,178 +419,136 @@ fn the_dashboard_comes_down_when_work_ends() { Action::ReleaseDisplay ] ); + assert_eq!(machine.poll_interval(), None); } #[test] -fn the_dashboard_comes_down_when_the_screen_locks_or_the_feature_turns_off() { - let mut locked = armed(); - locked.step(Event::Idle(minutes(5))); +fn work_starting_behind_the_lock_screen_brings_the_dashboard() { + let mut machine = Machine::new(true); + machine.step(Event::Config(enabled())); + assert_eq!(machine.step(locked(minutes(3))), vec![]); assert_eq!( - locked.step(Event::Locked(true)), - vec![Action::HideDashboard, Action::ReleaseDisplay] + machine.step(Event::Working(true)), + vec![Action::HoldAwake, Action::HoldDisplay] ); - - let mut off = armed(); - off.step(Event::Idle(minutes(5))); assert_eq!( - off.step(Event::Config(Config::default())), - vec![ - Action::HideDashboard, - Action::ReleaseAwake, - Action::ReleaseDisplay - ] + machine.step(locked(minutes(3))), + vec![Action::ShowDashboard] ); } #[test] -fn input_takes_the_dashboard_down_and_it_returns_after_another_pause() { - let mut machine = armed(); - machine.step(Event::Idle(minutes(5))); +fn input_ends_a_screen_saver_with_no_lock_behind_it() { + let mut machine = working(false); + machine.step(Event::Saver(true)); + // Without a lock the dashboard still covers the screen saver. assert_eq!( - machine.step(Event::Idle(Duration::from_millis(200))), - vec![Action::HideDashboard] + machine.step(unlocked(minutes(5))), + vec![Action::ShowDashboard] ); - assert_eq!(machine.step(Event::Idle(minutes(1))), vec![]); + // Input stops the screen saver, even if its stop notification is lost. assert_eq!( - machine.step(Event::Idle(minutes(5))), - vec![Action::ShowDashboard] + machine.step(unlocked(ms(100))), + vec![Action::HideDashboard, Action::ReleaseDisplay] ); + assert_eq!(machine.step(unlocked(SHOW_AFTER)), vec![]); } #[test] -fn a_reading_from_before_work_started_never_shows_the_dashboard() { - let mut machine = Machine::default(); - machine.step(Event::Config(enabled())); - machine.step(Event::Working(true)); - machine.step(Event::Idle(minutes(9))); - machine.step(Event::Working(false)); - // The user came back meanwhile; nothing sampled while work was off. +fn a_screen_saver_stopping_with_no_lock_uncovers_the_desktop() { + let mut machine = working(false); + machine.step(Event::Saver(true)); + machine.step(unlocked(minutes(5))); assert_eq!( - machine.step(Event::Working(true)), - vec![Action::HoldAwake, Action::HoldDisplay] + machine.step(Event::Saver(false)), + vec![Action::HideDashboard, Action::ReleaseDisplay] ); - assert_eq!(machine.step(Event::Idle(Duration::from_secs(3))), vec![]); } #[test] -fn idle_is_sampled_only_while_armed_and_faster_while_showing() { - let mut machine = Machine::default(); - assert_eq!(machine.poll_interval(), None); - machine.step(Event::Config(enabled())); - machine.step(Event::Working(true)); - assert_eq!(machine.poll_interval(), Some(IDLE_POLL)); - machine.step(Event::Idle(minutes(5))); - assert_eq!(machine.poll_interval(), Some(INPUT_POLL)); - machine.step(Event::Locked(true)); - assert_eq!(machine.poll_interval(), None); -} - -fn showing_with_lock(lock_available: bool) -> Machine { - let mut machine = Machine::new(lock_available); - machine.step(Event::Config(enabled())); - machine.step(Event::Working(true)); - machine.step(Event::Idle(minutes(5))); - assert!(machine.showing()); - machine +fn a_desk_in_use_never_gets_the_dashboard() { + let mut machine = working(true); + for idle in [secs(2), secs(9), secs(1), secs(14), secs(3)] { + assert_eq!(machine.step(unlocked(idle)), vec![]); + } + // Even locked, input that keeps coming keeps the dashboard away. + machine.step(locked(secs(1))); + for idle in [secs(4), secs(1), secs(14), secs(2)] { + assert_eq!(machine.step(locked(idle)), vec![]); + } + assert!(!machine.showing()); } #[test] -fn input_locks_the_mac_and_the_dashboard_stays_until_the_lock_lands() { - let mut machine = showing_with_lock(true); +fn a_failed_read_keeps_the_last_reading_for_a_while() { + let mut machine = covering_the_lock_screen(); + // A timeout or a CLI mid-upgrade: the dashboard and the holds stay. + assert_eq!(machine.step(Event::Unreadable(at(0))), vec![]); assert_eq!( - machine.step(Event::Idle(Duration::from_millis(100))), - vec![Action::LockScreen] + machine.step(Event::Unreadable(at(UNREADABLE_GRACE.as_secs() - 1))), + vec![] ); assert!(machine.showing()); - assert!(machine.awaiting_lock()); - // The lock screen is not up yet: nothing more is asked for. - assert_eq!(machine.step(Event::LockSample(false)), vec![]); - // The agents go on behind the lock screen: only the display is let go. - assert_eq!( - machine.step(Event::Locked(true)), - vec![Action::HideDashboard, Action::ReleaseDisplay] - ); - assert!(!machine.awaiting_lock()); } #[test] -fn a_lock_read_from_the_session_counts_without_its_notification() { - let mut machine = showing_with_lock(true); - machine.step(Event::Idle(Duration::from_millis(100))); +fn reads_that_keep_failing_never_pin_the_mac_awake() { + let mut machine = covering_the_lock_screen(); + machine.step(Event::Unreadable(at(0))); assert_eq!( - machine.step(Event::LockSample(true)), - vec![Action::HideDashboard, Action::ReleaseDisplay] + machine.step(Event::Unreadable(at(UNREADABLE_GRACE.as_secs()))), + vec![ + Action::HideDashboard, + Action::ReleaseAwake, + Action::ReleaseDisplay + ] ); } #[test] -fn without_a_lock_input_only_closes_the_dashboard() { - let mut machine = showing_with_lock(false); +fn a_good_read_ends_the_grace() { + let mut machine = working(true); + machine.step(Event::Unreadable(at(0))); + machine.step(Event::Working(true)); + // A new run of failures starts its own grace. assert_eq!( - machine.step(Event::Idle(Duration::from_millis(100))), - vec![Action::HideDashboard] + machine.step(Event::Unreadable(at(UNREADABLE_GRACE.as_secs()))), + vec![] ); } -/// Steps unconfirmed lock samples until something happens, returning the -/// sample number and the actions. -fn until_action(machine: &mut Machine) -> (u8, Vec) { - for sample in 1..=LOCK_WAIT_SAMPLES { - let actions = machine.step(Event::LockSample(false)); - if !actions.is_empty() { - return (sample, actions); - } - } - (LOCK_WAIT_SAMPLES + 1, vec![]) -} - #[test] -fn a_lock_that_has_not_landed_is_asked_for_again_with_the_dashboard_up() { - let mut machine = showing_with_lock(true); - machine.step(Event::Idle(Duration::from_millis(100))); - for _ in 1..LOCK_ATTEMPTS { - assert_eq!( - until_action(&mut machine), - (LOCK_WAIT_SAMPLES, vec![Action::LockScreen]) - ); - assert!(machine.showing()); - } +fn a_failed_read_never_starts_work() { + let mut machine = Machine::new(true); + machine.step(Event::Config(enabled())); + assert_eq!(machine.step(Event::Unreadable(at(0))), vec![]); + assert_eq!(machine.poll_interval(), None); } #[test] -fn a_lock_that_never_lands_counts_as_no_lock_and_closes_the_dashboard() { - let mut machine = showing_with_lock(true); - machine.step(Event::Idle(Duration::from_millis(100))); - for _ in 1..LOCK_ATTEMPTS { - until_action(&mut machine); - } - assert_eq!( - until_action(&mut machine), - (LOCK_WAIT_SAMPLES, vec![Action::HideDashboard]) - ); +fn a_reading_from_before_work_started_never_counts() { + let mut machine = Machine::new(true); + machine.step(Event::Config(enabled())); + machine.step(Event::Working(true)); + machine.step(locked(minutes(9))); + machine.step(Event::Working(false)); + // Nothing is read while no agent works, so the old baseline is gone. + machine.step(Event::Working(true)); + assert_eq!(machine.step(locked(secs(3))), vec![]); assert!(!machine.showing()); - assert!(!machine.awaiting_lock()); } #[test] -fn idle_samples_never_end_the_wait_for_the_lock() { - let mut machine = showing_with_lock(true); - machine.step(Event::Idle(Duration::from_millis(100))); - for _ in 0..u32::from(LOCK_WAIT_SAMPLES) * u32::from(LOCK_ATTEMPTS) { - assert_eq!(machine.step(Event::Idle(Duration::from_millis(10))), vec![]); - } - assert!(machine.showing()); -} - -#[test] -fn the_dashboard_keeps_sampling_quickly_while_the_lock_comes_up() { - let mut machine = showing_with_lock(true); - machine.step(Event::Idle(Duration::from_millis(100))); +fn the_session_is_read_while_agents_work_and_faster_under_the_dashboard() { + let mut machine = Machine::new(true); + assert_eq!(machine.poll_interval(), None); + machine.step(Event::Config(enabled())); + machine.step(Event::Working(true)); + assert_eq!(machine.poll_interval(), Some(IDLE_POLL)); + machine.step(locked(SHOW_AFTER)); assert_eq!(machine.poll_interval(), Some(INPUT_POLL)); -} - -fn ms(n: u64) -> Duration { - Duration::from_millis(n) + machine.step(Event::Working(false)); + assert_eq!(machine.poll_interval(), None); } #[test] @@ -522,6 +556,7 @@ fn a_preview_shows_at_once_even_with_the_feature_off_and_no_work() { let mut machine = Machine::new(true); assert_eq!(machine.step(Event::Preview), vec![Action::ShowPreview]); assert!(machine.showing()); + assert!(machine.previewing()); assert_eq!(machine.poll_interval(), Some(INPUT_POLL)); } @@ -533,10 +568,10 @@ fn a_preview_ignores_input_for_its_grace_then_closes_without_locking() { let mut idle = 5_000; for _ in 0..PREVIEW_GRACE_SAMPLES { idle -= 100; - assert_eq!(machine.step(Event::Idle(ms(idle))), vec![]); + assert_eq!(machine.step(unlocked(ms(idle))), vec![]); } assert_eq!( - machine.step(Event::Idle(ms(idle - 100))), + machine.step(unlocked(ms(idle - 100))), vec![Action::HideDashboard] ); assert!(!machine.showing()); @@ -556,14 +591,12 @@ fn a_preview_never_holds_the_mac_awake() { fn a_preview_closes_when_the_screen_locks() { let mut machine = Machine::new(true); machine.step(Event::Preview); - assert_eq!( - machine.step(Event::Locked(true)), - vec![Action::HideDashboard] - ); + assert_eq!(machine.step(locked(secs(1))), vec![Action::HideDashboard]); } #[test] -fn a_preview_is_ignored_while_the_real_dashboard_shows() { - let mut machine = showing_with_lock(true); +fn a_preview_is_ignored_while_the_dashboard_shows() { + let mut machine = covering_the_lock_screen(); assert_eq!(machine.step(Event::Preview), vec![]); + assert!(!machine.previewing()); } diff --git a/apps/companion/src-tauri/src/screensaver/macos.rs b/apps/companion/src-tauri/src/screensaver/macos.rs index 54399f6..cbd7fd7 100644 --- a/apps/companion/src-tauri/src/screensaver/macos.rs +++ b/apps/companion/src-tauri/src/screensaver/macos.rs @@ -2,17 +2,18 @@ use std::ffi::{CStr, c_void}; use std::ptr::NonNull; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use block2::RcBlock; use objc2::rc::Retained; use objc2::runtime::ProtocolObject; use objc2_app_kit::{ - NSApplicationDidChangeScreenParametersNotification, NSScreenSaverWindowLevel, NSWindow, - NSWindowCollectionBehavior, + NSApplicationDidChangeScreenParametersNotification, NSWindow, NSWindowCollectionBehavior, +}; +use objc2_core_foundation::{ + CFArray, CFBoolean, CFDictionary, CFNumber, CFRetained, CFString, CFType, }; -use objc2_core_foundation::{CFBoolean, CFDictionary, CFRetained, CFString, CFType}; use objc2_core_graphics::{ CGEventSource, CGEventSourceStateID, CGEventType, CGSessionCopyCurrentDictionary, }; @@ -20,7 +21,6 @@ use objc2_foundation::{ NSActivityOptions, NSDistributedNotificationCenter, NSNotification, NSNotificationCenter, NSObjectProtocol, NSProcessInfo, NSString, }; -use tauri::menu::Menu; use tauri::{AppHandle, Runtime, WebviewWindow}; use super::platform::Platform; @@ -33,10 +33,25 @@ const ANY_INPUT_EVENT: CGEventType = CGEventType(u32::MAX); const LOGIN_FRAMEWORK: &CStr = c"/System/Library/PrivateFrameworks/login.framework/Versions/Current/login"; const LOCK_SYMBOL: &CStr = c"SACLockScreenImmediate"; +const SAVER_SYMBOL: &CStr = c"SACScreenSaverIsRunning"; /// Set to any value to make the lock probe fail, which exercises the /// no-lock path (COMP001 R7) on a Mac that has the lock. const NO_LOCK_ENV: &str = "WORKBRANCH_SCREENSAVER_NO_LOCK"; +const SKYLIGHT_FRAMEWORK: &CStr = + c"/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight"; +/// The absolute space level macOS shows notifications at over its lock +/// screen, which sits at 300 (`LUIUnmanagedSpace: level=300` in the +/// loginwindow log). Windows in a space at this level draw above the lock +/// screen and the screen saver. +const OVERLAY_SPACE_LEVEL: i32 = 400; +/// The flags `SLSSpaceCreate` takes for an ordinary user space. +const OVERLAY_SPACE_FLAGS: i32 = 1; +/// The flags other lock-screen overlays pass to +/// `SLSSpaceAddWindowsAndRemoveFromSpaces`: move the windows out of every +/// other space. +const OVERLAY_MOVE_FLAGS: i32 = 7; + /// `SACLockScreenImmediate` from the private login framework: the lock that /// Ctrl-Cmd-Q triggers, without the Accessibility permission a synthetic /// key press would need. Private, so it is probed rather than linked. @@ -48,17 +63,9 @@ impl ScreenLock { if std::env::var_os(NO_LOCK_ENV).is_some() { return None; } - // SAFETY: a valid C path; the handle stays open for the process's life - // because the function pointer below must stay valid. - let handle = unsafe { libc::dlopen(LOGIN_FRAMEWORK.as_ptr(), libc::RTLD_LAZY) }; - if handle.is_null() { - return None; - } - // SAFETY: a live handle and a valid C symbol name. - let symbol = unsafe { libc::dlsym(handle, LOCK_SYMBOL.as_ptr()) }; - if symbol.is_null() { - return None; - } + // SAFETY: a valid C path and symbol name; the handle stays open for + // the process's life because the function pointer must stay valid. + let symbol = unsafe { private_symbol(LOGIN_FRAMEWORK, LOCK_SYMBOL) }?; // SAFETY: SACLockScreenImmediate takes no arguments and returns an // int status, matching this signature. let lock = unsafe { std::mem::transmute::<*mut c_void, extern "C" fn() -> i32>(symbol) }; @@ -70,6 +77,142 @@ impl ScreenLock { } } +/// `SACScreenSaverIsRunning` from the same private framework: asks +/// loginwindow, which runs the screen saver, whether it runs now. Reading it +/// with every sample covers a screen saver that started before the app did +/// or whose notification was missed. +#[derive(Clone, Copy)] +struct ScreenSaverQuery(extern "C" fn() -> u8); + +impl ScreenSaverQuery { + fn probe() -> Option { + // SAFETY: a valid C path and symbol name; the framework stays open. + let symbol = unsafe { private_symbol(LOGIN_FRAMEWORK, SAVER_SYMBOL) }?; + // SAFETY: SACScreenSaverIsRunning takes no arguments and returns a + // BOOL in the low byte of the return register. + let query = unsafe { std::mem::transmute::<*mut c_void, extern "C" fn() -> u8>(symbol) }; + Some(Self(query)) + } + + fn running(self) -> bool { + (self.0)() != 0 + } +} + +/// Looks up `symbol` in the private framework at `path`. +/// +/// # Safety +/// Both strings must name a framework binary and a symbol in it; the +/// framework is never closed, so the returned pointer stays valid. +unsafe fn private_symbol(path: &CStr, symbol: &CStr) -> Option<*mut c_void> { + // SAFETY: valid C strings, per the caller. + let handle = unsafe { libc::dlopen(path.as_ptr(), libc::RTLD_LAZY) }; + if handle.is_null() { + return None; + } + // SAFETY: a live handle and a valid C symbol name. + let symbol = unsafe { libc::dlsym(handle, symbol.as_ptr()) }; + (!symbol.is_null()).then_some(symbol) +} + +type MainConnectionId = unsafe extern "C" fn() -> i32; +type SpaceCreate = unsafe extern "C" fn(i32, i32, *const c_void) -> u64; +type SpaceSetAbsoluteLevel = unsafe extern "C" fn(i32, u64, i32) -> i32; +type ShowSpaces = unsafe extern "C" fn(i32, *const c_void) -> i32; +type SpaceAddWindows = unsafe extern "C" fn(i32, u64, *const c_void, i32) -> i32; + +/// A SkyLight space above the lock screen that the dashboard windows move +/// into (COMP002 R1). Private SkyLight calls, probed like the lock, with the +/// signatures other lock-screen overlays use; the space lives as long as the +/// app. +struct Overlay { + connection: i32, + space: u64, + add_windows: SpaceAddWindows, +} + +impl Overlay { + /// Builds the space once, on the main thread. `None` when SkyLight lacks + /// a call, and the dashboard then shows at the screen saver level only, + /// so it stays under the lock screen. + fn shared() -> Option<&'static Self> { + static OVERLAY: OnceLock> = OnceLock::new(); + OVERLAY.get_or_init(Self::create).as_ref() + } + + fn create() -> Option { + // SAFETY: valid C strings naming SkyLight and its exported symbols. + let symbol = |name: &CStr| unsafe { private_symbol(SKYLIGHT_FRAMEWORK, name) }; + let main_connection = symbol(c"SLSMainConnectionID")?; + let space_create = symbol(c"SLSSpaceCreate")?; + let set_level = symbol(c"SLSSpaceSetAbsoluteLevel")?; + let show_spaces = symbol(c"SLSShowSpaces")?; + let add_windows = symbol(c"SLSSpaceAddWindowsAndRemoveFromSpaces")?; + // SAFETY: each symbol is the SkyLight function of that name, whose + // C signature the matching type above spells out. + let (main_connection, space_create, set_level, show_spaces, add_windows) = unsafe { + ( + std::mem::transmute::<*mut c_void, MainConnectionId>(main_connection), + std::mem::transmute::<*mut c_void, SpaceCreate>(space_create), + std::mem::transmute::<*mut c_void, SpaceSetAbsoluteLevel>(set_level), + std::mem::transmute::<*mut c_void, ShowSpaces>(show_spaces), + std::mem::transmute::<*mut c_void, SpaceAddWindows>(add_windows), + ) + }; + // SAFETY: no arguments; returns this process's WindowServer connection. + let connection = unsafe { main_connection() }; + // SAFETY: a live connection, the user-space flags and no options. + let space = unsafe { space_create(connection, OVERLAY_SPACE_FLAGS, std::ptr::null()) }; + if space == 0 { + return None; + } + let spaces = number_array(&CFNumber::new_i64(i64::try_from(space).ok()?)); + // SAFETY: the space was just created on this connection, and the + // array holds its id as a CFNumber, as SkyLight expects. + unsafe { + set_level(connection, space, OVERLAY_SPACE_LEVEL); + show_spaces(connection, array_pointer(&spaces)); + } + Some(Self { + connection, + space, + add_windows, + }) + } + + fn add(&self, window_number: isize) { + let windows = number_array(&CFNumber::new_isize(window_number)); + // SAFETY: the overlay space belongs to this connection, and the + // array holds one window number of this process as a CFNumber. + let _status = unsafe { + (self.add_windows)( + self.connection, + self.space, + array_pointer(&windows), + OVERLAY_MOVE_FLAGS, + ) + }; + } +} + +fn number_array(number: &CFNumber) -> CFRetained> { + CFArray::from_objects(&[number]) +} + +fn array_pointer(array: &CFRetained>) -> *const c_void { + CFRetained::as_ptr(array) + .as_ptr() + .cast::() + .cast_const() +} + +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + /// The level of the shield macOS raises over every display; the + /// bindings only have it behind a feature this crate does not enable. + fn CGShieldingWindowLevel() -> i32; +} + /// A held `NSProcessInfo` activity, ended when let go. type Activity = Retained>; @@ -77,11 +220,11 @@ pub(crate) struct MacPlatform { app: AppHandle, /// `PreventUserIdleSystemSleep`, held while agents work, locked or not. system: Option, - /// `PreventUserIdleDisplaySleep`, held while the dashboard may show. + /// `PreventUserIdleDisplaySleep`, held while the screen saver or the + /// lock screen is up and agents work. display: Option, lock: Option, - /// The app menu, set aside while a dashboard covers the screen. - menu: Option>, + saver: Option, } impl MacPlatform { @@ -91,7 +234,7 @@ impl MacPlatform { system: None, display: None, lock, - menu: None, + saver: ScreenSaverQuery::probe(), } } } @@ -103,7 +246,6 @@ impl Platform for MacPlatform { } fn set_display_on(&mut self, on: bool) { - // Keeping the display on also holds off the macOS screen saver. hold( &mut self.display, on, @@ -125,22 +267,16 @@ impl Platform for MacPlatform { screen_locked() } + fn screen_saver_running(&self) -> Option { + self.saver.map(ScreenSaverQuery::running) + } + fn show_dashboard(&mut self, mode: Mode) { - // The app has no main menu while the dashboard covers the screen. - // Its key equivalents (Cmd-Q quit, Cmd-H hide, Cmd-M minimize, Cmd-W - // close) act before the next input sample, so they could take the - // cover down, or the whole app, without the Mac ever locking. - if self.menu.is_none() { - self.menu = self.app.remove_menu().ok().flatten(); - } windows::show(&self.app, mode, raise); } fn hide_dashboard(&mut self) { windows::hide(&self.app); - if let Some(menu) = self.menu.take() { - let _ = self.app.set_menu(menu); - } } fn lock_screen(&mut self) { @@ -195,20 +331,52 @@ fn screen_locked() -> bool { .is_some_and(|locked| locked.as_bool()) } -/// Calls `on_change(locked)` whenever the screen locks or unlocks. +/// Calls `on_signal()` whenever macOS says the screen locked or unlocked. +/// The caller reads the session for the state itself. pub(crate) fn watch_screen_lock( + app: &AppHandle, + on_signal: impl Fn() + Send + Sync + 'static, +) { + let on_signal = Arc::new(move |_: bool| on_signal()); + observe_distributed( + app, + &[ + ("com.apple.screenIsLocked", true), + ("com.apple.screenIsUnlocked", false), + ], + on_signal, + ); +} + +/// Calls `on_change(running)` whenever the macOS screen saver starts or +/// stops. +pub(crate) fn watch_screen_saver( app: &AppHandle, on_change: impl Fn(bool) + Send + Sync + 'static, ) { - let on_change = Arc::new(on_change); + observe_distributed( + app, + &[ + ("com.apple.screensaver.didstart", true), + ("com.apple.screensaver.didstop", false), + ], + Arc::new(on_change), + ); +} + +/// Calls `on_post(value)` with the value paired to each distributed +/// notification name, whenever one is posted. +fn observe_distributed( + app: &AppHandle, + names: &[(&'static str, bool)], + on_post: Arc, +) { + let names = names.to_vec(); let _ = app.run_on_main_thread(move || { let center = NSDistributedNotificationCenter::defaultCenter(); - for (name, locked) in [ - ("com.apple.screenIsLocked", true), - ("com.apple.screenIsUnlocked", false), - ] { - let on_change = Arc::clone(&on_change); - let block = RcBlock::new(move |_: NonNull| on_change(locked)); + for (name, value) in names { + let on_post = Arc::clone(&on_post); + let block = RcBlock::new(move |_: NonNull| on_post(value)); let name = NSString::from_str(name); // SAFETY: no sender object is matched, and a nil queue runs the // block on the posting thread; the block only forwards a bool @@ -222,9 +390,10 @@ pub(crate) fn watch_screen_lock( }); } -/// Lifts a dashboard window above the menu bar, the Dock and full-screen -/// apps on every Space, and gives the focused one the keyboard. -fn raise(window: WebviewWindow, focus: bool) { +/// Moves a dashboard window above the lock screen and the screen saver on +/// every display (COMP002 R1). The window never takes the keyboard: while +/// the screen is locked, keys belong to the lock screen's password field. +fn raise(window: WebviewWindow, mode: Mode) { let target = window.clone(); let _ = window.run_on_main_thread(move || { let Ok(pointer) = target.ns_window() else { @@ -233,16 +402,26 @@ fn raise(window: WebviewWindow, focus: bool) { // SAFETY: Tauri hands out the live NSWindow behind this window, and // this closure runs on the main thread that owns it. let ns_window = unsafe { &*pointer.cast::() }; - ns_window.setLevel(NSScreenSaverWindowLevel); + // SAFETY: a plain CoreGraphics query with no arguments. + let shield = unsafe { CGShieldingWindowLevel() }; + ns_window.setLevel(isize::try_from(shield).map_or(isize::MAX, |level| level + 1)); ns_window.setCollectionBehavior( NSWindowCollectionBehavior::CanJoinAllSpaces | NSWindowCollectionBehavior::FullScreenAuxiliary | NSWindowCollectionBehavior::Stationary | NSWindowCollectionBehavior::IgnoresCycle, ); - if focus { - // Activates the app and makes this the key window. - let _ = target.set_focus(); + ns_window.setCanBecomeVisibleWithoutLogin(true); + // Over the lock screen a click goes through to it; input takes the + // dashboard down within a sample anyway. A preview keeps its clicks, + // so they never land in an app underneath. + ns_window.setIgnoresMouseEvents(mode == Mode::Live); + ns_window.orderFrontRegardless(); + let number = ns_window.windowNumber(); + if number > 0 + && let Some(overlay) = Overlay::shared() + { + overlay.add(number); } }); } diff --git a/apps/companion/src-tauri/src/screensaver/platform.rs b/apps/companion/src-tauri/src/screensaver/platform.rs index 0cbd0fb..76510c2 100644 --- a/apps/companion/src-tauri/src/screensaver/platform.rs +++ b/apps/companion/src-tauri/src/screensaver/platform.rs @@ -10,14 +10,17 @@ pub(crate) trait Platform { /// Keeps the system from idle sleep, and the app out of App Nap, while /// held. fn set_awake(&mut self, awake: bool); - /// Keeps the display from idle sleep, which also holds off the macOS - /// screen saver, while held. + /// Keeps the display from idle sleep while held, so the dashboard stays + /// in view over the screen saver or the lock screen. fn set_display_on(&mut self, on: bool); /// Time since the last keyboard or mouse input, from any app. fn idle_time(&self) -> Duration; /// Whether the login session's screen is locked right now. fn screen_locked(&self) -> bool; - /// Covers every display with the dashboard. + /// Whether the macOS screen saver runs right now; `None` where macOS + /// cannot be asked, and its notifications alone tell. + fn screen_saver_running(&self) -> Option; + /// Covers every display with the dashboard, above the lock screen. fn show_dashboard(&mut self, mode: Mode); fn hide_dashboard(&mut self); /// Locks the login session at once, as Ctrl-Cmd-Q would. @@ -54,6 +57,9 @@ impl Platform for NoopPlatform { fn screen_locked(&self) -> bool { false } + fn screen_saver_running(&self) -> Option { + None + } fn show_dashboard(&mut self, _mode: Mode) {} fn hide_dashboard(&mut self) {} fn lock_screen(&mut self) {} @@ -79,6 +85,9 @@ mod tests { fn screen_locked(&self) -> bool { false } + fn screen_saver_running(&self) -> Option { + None + } fn show_dashboard(&mut self, mode: Mode) { self.0.push(format!("show={mode:?}")); } diff --git a/apps/companion/src-tauri/src/screensaver/windows.rs b/apps/companion/src-tauri/src/screensaver/windows.rs index e61fd85..7b57d92 100644 --- a/apps/companion/src-tauri/src/screensaver/windows.rs +++ b/apps/companion/src-tauri/src/screensaver/windows.rs @@ -12,8 +12,9 @@ const ENTRY: &str = "dashboard.html"; /// its page loads. const BACKGROUND: Color = Color(0x13, 0x13, 0x13, 0xff); -/// A live dashboard locks on input; a preview from Settings only closes -/// (COMP001 R12). The page reads which one it is from its window label. +/// A live dashboard covers the screen saver and the lock screen and steps +/// aside on input; a preview from Settings only closes on input (COMP001 +/// R12). The page reads which one it is from its window label. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum Mode { Live, @@ -37,9 +38,6 @@ pub(crate) struct Frame { pub(crate) y: f64, pub(crate) width: f64, pub(crate) height: f64, - /// The primary display's window takes keyboard focus, so keys pressed - /// before the lock land on the dashboard instead of the app underneath. - pub(crate) focus: bool, } /// A display as Tauri reports it: physical pixels plus its scale factor. @@ -50,14 +48,9 @@ pub(crate) struct Display { pub(crate) width: u32, pub(crate) height: u32, pub(crate) scale: f64, - pub(crate) primary: bool, } pub(crate) fn frames(displays: &[Display], mode: Mode) -> Vec { - let focus_index = displays - .iter() - .position(|display| display.primary) - .unwrap_or(0); displays .iter() .enumerate() @@ -73,14 +66,12 @@ pub(crate) fn frames(displays: &[Display], mode: Mode) -> Vec { y: f64::from(display.y) / scale, width: f64::from(display.width) / scale, height: f64::from(display.height) / scale, - focus: index == focus_index, } }) .collect() } fn displays(app: &AppHandle) -> Vec { - let primary = app.primary_monitor().ok().flatten(); app.available_monitors() .unwrap_or_default() .iter() @@ -90,19 +81,17 @@ fn displays(app: &AppHandle) -> Vec { width: monitor.size().width, height: monitor.size().height, scale: monitor.scale_factor(), - primary: primary - .as_ref() - .is_some_and(|primary| primary.position() == monitor.position()), }) .collect() } /// Covers every display, replacing any dashboard windows already open so the -/// set always matches the current displays. +/// set always matches the current displays. `raise` lifts each new window +/// above the lock screen. pub(crate) fn show( app: &AppHandle, mode: Mode, - raise: impl Fn(WebviewWindow, bool), + raise: impl Fn(WebviewWindow, Mode), ) { hide(app); for frame in frames(&displays(app), mode) { @@ -110,7 +99,7 @@ pub(crate) fn show( .title("Workbranch Dashboard") .decorations(false) .resizable(false) - // Nothing but the lock (or, in a preview, input) takes it down. + // Nothing but input, signing in or work ending takes it down. .minimizable(false) .closable(false) .shadow(false) @@ -120,10 +109,11 @@ pub(crate) fn show( .background_color(BACKGROUND) .position(frame.x, frame.y) .inner_size(frame.width, frame.height) - .focused(frame.focus) + // The keyboard stays with the lock screen's password field. + .focused(false) .build(); if let Ok(window) = built { - raise(window, frame.focus); + raise(window, mode); } } } @@ -150,21 +140,20 @@ pub(crate) fn hide(app: &AppHandle) { mod tests { use super::*; - fn display(x: i32, width: u32, scale: f64, primary: bool) -> Display { + fn display(x: i32, width: u32, scale: f64) -> Display { Display { x, y: 0, width, height: 1800, scale, - primary, } } #[test] fn each_display_gets_a_window_sized_in_points() { let frames = frames( - &[display(0, 2880, 2.0, true), display(2880, 1920, 1.0, false)], + &[display(0, 2880, 2.0), display(2880, 1920, 1.0)], Mode::Live, ); assert_eq!(frames.len(), 2); @@ -177,36 +166,14 @@ mod tests { } #[test] - fn the_primary_display_takes_focus() { - let frames = frames( - &[ - display(-1920, 1920, 1.0, false), - display(0, 2880, 2.0, true), - ], - Mode::Live, - ); - assert_eq!( - frames.iter().map(|frame| frame.focus).collect::>(), - vec![false, true] - ); - } - - #[test] - fn the_first_display_takes_focus_when_none_is_primary() { - let frames = frames( - &[ - display(0, 1920, 1.0, false), - display(1920, 1920, 1.0, false), - ], - Mode::Live, - ); - assert!(frames[0].focus); - assert!(!frames[1].focus); + fn a_display_without_a_scale_counts_as_one_to_one() { + let frames = frames(&[display(-1920, 1920, 0.0)], Mode::Live); + assert_eq!((frames[0].x, frames[0].width), (-1920.0, 1920.0)); } #[test] fn a_preview_labels_its_windows_apart() { - let frames = frames(&[display(0, 1920, 1.0, true)], Mode::Preview); + let frames = frames(&[display(0, 1920, 1.0)], Mode::Preview); assert_eq!(frames[0].label, "dashboard-preview-0"); assert!(frames[0].label.starts_with(LABEL_PREFIX)); } diff --git a/apps/companion/src/application/screensaver.ts b/apps/companion/src/application/screensaver.ts index 40a0e07..496e950 100644 --- a/apps/companion/src/application/screensaver.ts +++ b/apps/companion/src/application/screensaver.ts @@ -1,21 +1,20 @@ -// The screensaver setting (COMP001). The Rust side reads it from the +// The screensaver setting (COMP001, COMP002). The Rust side reads it from the // `screensaver` preference key (src-tauri/src/screensaver/machine.rs mirrors -// this shape and its bounds) after Settings asks it to refresh. +// this shape) after Settings asks it to refresh. When the dashboard shows is +// up to macOS: its screen saver and lock screen bring it on. export type ScreensaverConfig = { readonly enabled: boolean; - /** Minutes without keyboard or mouse input before the dashboard shows. */ - readonly idleMinutes: number; }; -export const SCREENSAVER_IDLE_MINUTES = { min: 1, max: 60 } as const; - export const DEFAULT_SCREENSAVER: ScreensaverConfig = { enabled: false, - idleMinutes: 5, }; -/** Unknown or missing fields fall back to their default, one at a time. */ +/** + * An unknown switch falls back to off. Stores from before COMP002 also carry + * `idleMinutes`; it counts as needing a rewrite so the stale key goes away. + */ export function sanitizeScreensaverConfig(value: unknown): { readonly config: ScreensaverConfig; readonly sanitized: boolean; @@ -29,30 +28,18 @@ export function sanitizeScreensaverConfig(value: unknown): { typeof input["enabled"] === "boolean" ? input["enabled"] : DEFAULT_SCREENSAVER.enabled, - idleMinutes: idleMinutesOr(input["idleMinutes"]), }; return { config, sanitized: config.enabled !== input["enabled"] || - config.idleMinutes !== input["idleMinutes"], + Object.keys(input).some((key) => key !== "enabled"), }; } -function idleMinutesOr(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) - return DEFAULT_SCREENSAVER.idleMinutes; - return Math.min( - SCREENSAVER_IDLE_MINUTES.max, - Math.max(SCREENSAVER_IDLE_MINUTES.min, Math.round(value)), - ); -} - export function sameScreensaverConfig( left: ScreensaverConfig, right: ScreensaverConfig, ): boolean { - return ( - left.enabled === right.enabled && left.idleMinutes === right.idleMinutes - ); + return left.enabled === right.enabled; } diff --git a/apps/companion/src/ui/Dashboard.tsx b/apps/companion/src/ui/Dashboard.tsx index e74e250..e8c3312 100644 --- a/apps/companion/src/ui/Dashboard.tsx +++ b/apps/companion/src/ui/Dashboard.tsx @@ -172,9 +172,9 @@ type DashboardProps = { readonly now: Date; readonly theme: CompanionTheme; readonly font?: CompanionFont; - /** Shown only when input really locks the Mac (COMP001 R7). */ + /** Shown only when the Mac really locks under the dashboard (COMP001 R7). */ readonly lockAvailable: boolean; - /** Opened from Settings: input closes it instead of locking (R12). */ + /** Opened from Settings: input closes it, nothing locks (R12). */ readonly preview?: boolean; }; @@ -199,7 +199,7 @@ export function Dashboard({ ) : lockAvailable ? ( - 입력 시 잠금 + 화면 잠김 ) : null} diff --git a/apps/companion/src/ui/ScreensaverSettings.tsx b/apps/companion/src/ui/ScreensaverSettings.tsx index ad58e2e..b042089 100644 --- a/apps/companion/src/ui/ScreensaverSettings.tsx +++ b/apps/companion/src/ui/ScreensaverSettings.tsx @@ -1,7 +1,4 @@ -import { - SCREENSAVER_IDLE_MINUTES, - type ScreensaverConfig, -} from "../application/screensaver"; +import type { ScreensaverConfig } from "../application/screensaver"; import { SettingsGroup, SettingsRow } from "./SettingsLayout"; type ScreensaverSettingsProps = { @@ -11,12 +8,7 @@ type ScreensaverSettingsProps = { readonly onPreview: () => void; }; -const IDLE_MINUTE_CHOICES = Array.from( - { length: SCREENSAVER_IDLE_MINUTES.max - SCREENSAVER_IDLE_MINUTES.min + 1 }, - (_, index) => SCREENSAVER_IDLE_MINUTES.min + index, -); - -/** One switch for the whole feature, plus how long input may pause. */ +/** One switch for the whole feature; macOS decides when the dashboard shows. */ export function ScreensaverSettings({ value, onChange, @@ -27,7 +19,7 @@ export function ScreensaverSettings({ - -
- -
-
{ expect(html).toMatch(/]*class="dashboard"[^>]*data-theme="claude"/); expect(html).toContain('aria-label="09:05"'); expect(html).toContain("10월 16일 금요일"); - expect(html).toContain("입력 시 잠금"); + expect(html).toContain("화면 잠김"); }); - it("hides the lock hint where input cannot lock the Mac", () => { + it("hides the lock hint where the Mac cannot lock", () => { const html = render(false); - expect(html).not.toContain("입력 시 잠금"); + expect(html).not.toContain("화면 잠김"); expect(html).not.toContain("dashboard-lock"); }); @@ -154,7 +154,7 @@ describe("Dashboard", () => { />, ); expect(html).toContain("미리 보기 · 입력하면 닫힘"); - expect(html).not.toContain("입력 시 잠금"); + expect(html).not.toContain("화면 잠김"); }); it("shows a big count per live state", () => { diff --git a/apps/companion/tests/preferences.test.ts b/apps/companion/tests/preferences.test.ts index fed8ad6..dc3b8c6 100644 --- a/apps/companion/tests/preferences.test.ts +++ b/apps/companion/tests/preferences.test.ts @@ -36,7 +36,6 @@ const CUSTOM_MENU_BAR: MenuBarConfig = { const CUSTOM_SCREENSAVER: ScreensaverConfig = { enabled: true, - idleMinutes: 12, }; vi.mock("@tauri-apps/plugin-store", () => ({ load: vi.fn() })); @@ -59,7 +58,7 @@ describe("companion preferences", () => { fontSize: "gigantic", theme: "rainbow", menuBar: { claude5h: "yes", percent: "left" }, - screensaver: { enabled: "yes", idleMinutes: "ten" }, + screensaver: { enabled: "yes" }, }), ).toEqual({ preferences: DEFAULT_COMPANION_PREFERENCES, @@ -340,8 +339,8 @@ describe("companion preferences", () => { }); describe("screensaver preference", () => { - it("defaults to off with dashboard after five idle minutes", () => { - expect(DEFAULT_SCREENSAVER).toEqual({ enabled: false, idleMinutes: 5 }); + it("defaults to off", () => { + expect(DEFAULT_SCREENSAVER).toEqual({ enabled: false }); expect(DEFAULT_COMPANION_PREFERENCES.screensaver).toEqual( DEFAULT_SCREENSAVER, ); @@ -361,26 +360,20 @@ describe("screensaver preference", () => { }); }); - it("clamps idle minutes to one through sixty whole minutes", () => { + it("keeps the switch and drops the idle minutes of older stores", () => { expect( - sanitizeScreensaverConfig({ enabled: true, idleMinutes: 0 }).config, - ).toEqual({ enabled: true, idleMinutes: 1 }); - expect( - sanitizeScreensaverConfig({ enabled: true, idleMinutes: 600 }).config, - ).toEqual({ enabled: true, idleMinutes: 60 }); - expect( - sanitizeScreensaverConfig({ enabled: true, idleMinutes: 2.6 }), - ).toEqual({ config: { enabled: true, idleMinutes: 3 }, sanitized: true }); + sanitizeScreensaverConfig({ enabled: true, idleMinutes: 12 }), + ).toEqual({ config: { enabled: true }, sanitized: true }); }); - it("falls back field by field for unusable values", () => { - expect( - sanitizeScreensaverConfig({ enabled: "yes", idleMinutes: 9 }).config, - ).toEqual({ enabled: false, idleMinutes: 9 }); - expect( - sanitizeScreensaverConfig({ enabled: true, idleMinutes: Number.NaN }) - .config, - ).toEqual({ enabled: true, idleMinutes: 5 }); + it("falls back to off for an unusable switch", () => { + expect(sanitizeScreensaverConfig({ enabled: "yes" })).toEqual({ + config: { enabled: false }, + sanitized: true, + }); + expect(sanitizeScreensaverConfig("on").config).toEqual({ + enabled: false, + }); }); it("compares configs by value", () => { diff --git a/apps/companion/tests/screensaver-settings.test.tsx b/apps/companion/tests/screensaver-settings.test.tsx index f8f3244..07f3c36 100644 --- a/apps/companion/tests/screensaver-settings.test.tsx +++ b/apps/companion/tests/screensaver-settings.test.tsx @@ -72,7 +72,7 @@ const preferences: CompanionPreferences = { }; describe("ScreensaverSettings", () => { - it("starts switched off with the idle choice parked at five minutes", () => { + it("starts switched off and leaves the Mac to macOS", () => { const html = renderToStaticMarkup( { />, ); expect(html).toMatch(/id="screensaver-enabled"(?![^>]*checked)/); - expect(html).toMatch(/]*disabled[^>]*id="screensaver-idle"/); - expect(html).toContain(''); expect(html).toContain("macOS handles sleep"); }); - it("offers every whole minute from one to sixty", () => { + it("has no idle-minutes choice: the macOS screen saver decides when", () => { const html = renderToStaticMarkup( undefined} onPreview={() => undefined} />, ); - expect(html.match(/'); - expect(html).toContain(''); - expect(html).toContain("after 12 min without input"); + expect(html).not.toContain(" { + it("turns the feature on", () => { const changes: ScreensaverConfig[] = []; const element = ScreensaverSettings({ value: DEFAULT_SCREENSAVER, @@ -110,20 +108,7 @@ describe("ScreensaverSettings", () => { findField(element, "screensaver-enabled")?.onChange?.({ currentTarget: { checked: true, value: "on" }, }); - expect(changes).toEqual([{ enabled: true, idleMinutes: 5 }]); - }); - - it("changes the idle minutes and keeps the switch", () => { - const changes: ScreensaverConfig[] = []; - const element = ScreensaverSettings({ - value: { enabled: true, idleMinutes: 5 }, - onChange: (config) => changes.push(config), - onPreview: () => undefined, - }); - findField(element, "screensaver-idle")?.onChange?.({ - currentTarget: { checked: false, value: "12" }, - }); - expect(changes).toEqual([{ enabled: true, idleMinutes: 12 }]); + expect(changes).toEqual([{ enabled: true }]); }); }); @@ -166,7 +151,7 @@ describe("SettingsPanel screen saver section", () => { onPreferencesChange: (next) => calls.push(next), onScreensaverPreview: () => undefined, }); - const edited = { enabled: true, idleMinutes: 2 }; + const edited = { enabled: true }; findPicker(element)?.onChange(edited); expect(calls).toEqual([{ ...preferences, screensaver: edited }]); }); diff --git a/assets/readme/dashboard-en.svg b/assets/readme/dashboard-en.svg index 708b172..efe7812 100644 --- a/assets/readme/dashboard-en.svg +++ b/assets/readme/dashboard-en.svg @@ -1,6 +1,6 @@ Workbranch Companion screen saver dashboard - While agents work and nobody touches the Mac, Companion covers every display with a view-only dashboard: a 7-segment clock, the date, 입력 시 잠금 (input locks), per-state counts, and the 내 응답 대기 (waiting on you), 실행 중 (running) and 턴 종료 · 검토 (turn ended, review) columns with each task's prompt, tool activity or last response, and repo changes. When locking is available, input requests the macOS lock screen. Without locking, the indicator is hidden and input only closes the dashboard. Failed lock requests also close it without locking. + While agents work and the macOS screen saver or lock screen is up, Companion shows a view-only dashboard on every display above the lock screen: a 7-segment clock, the date, 화면 잠김 (screen locked), per-state counts, and the 내 응답 대기 (waiting on you), 실행 중 (running) and 턴 종료 · 검토 (turn ended, review) columns with each task's prompt, tool activity or last response, and repo changes. Input moves it aside to reveal the macOS lock screen. Without locking, the indicator is hidden. WORKBRANCH COMPANION · SCREEN SAVER @@ -52,7 +52,7 @@ 10월 17일 금요일 - 입력 시 잠금 + 화면 잠김 1 턴 종료 2 diff --git a/assets/readme/dashboard-ko.svg b/assets/readme/dashboard-ko.svg index 166eb58..51a087d 100644 --- a/assets/readme/dashboard-ko.svg +++ b/assets/readme/dashboard-ko.svg @@ -1,6 +1,6 @@ Workbranch Companion 화면보호기 dashboard - agent 가 작업 중이고 아무도 Mac 을 건드리지 않으면 Companion 이 모든 모니터를 보기 전용 dashboard 로 덮습니다: 7-segment 시계, 날짜, 입력 시 잠금 표시, 상태별 개수, 그리고 내 응답 대기 · 실행 중 · 턴 종료 · 검토 열에 task 마다 프롬프트, 도구 동작 또는 마지막 응답, repo 변경 상태. 입력이 들어오면 macOS 잠금 화면으로 넘어갑니다. 잠금 불가 시 잠금 표시를 숨기고 입력하면 잠금 없이 dashboard 만 닫습니다. 잠금 요청이 실패해도 잠금 없이 닫습니다. + agent 가 작업 중이고 macOS 화면보호기나 잠금 화면이 뜨면 Companion 이 모든 모니터의 잠금 화면 위에 보기 전용 dashboard 를 띄웁니다: 7-segment 시계, 날짜, 화면 잠김 표시, 상태별 개수, 그리고 내 응답 대기 · 실행 중 · 턴 종료 · 검토 열에 task 마다 프롬프트, 도구 동작 또는 마지막 응답, repo 변경 상태. 입력하면 비켜서 macOS 잠금 화면이 보입니다. 잠금 불가 시 잠금 표시를 숨깁니다. WORKBRANCH COMPANION · 화면보호기 @@ -52,7 +52,7 @@ 10월 17일 금요일 - 입력 시 잠금 + 화면 잠김 1 턴 종료 2 diff --git a/docs/intents/COMP001-screensaver-feature.md b/docs/intents/COMP001-screensaver-feature.md index aa9bfdd..09d9de2 100644 --- a/docs/intents/COMP001-screensaver-feature.md +++ b/docs/intents/COMP001-screensaver-feature.md @@ -102,6 +102,7 @@ _None._ - 2026-10-16 · (plan Q4) 화면이 잠긴 동안 → companion 은 아무것도 하지 않고 macOS 기본 동작 그대로(잠자기 방지도 해제). R2 를 "잠기지 않은 동안"으로 좁힘 · 원칙: 잠자기 방지는 agent 가 동작하고 dashboard 가 동작할 수 있을 때만, 나머지는 OS 그대로 - 2026-10-16 · (구현 중 추가) Settings 확인 버튼 → A. 미리 보기: 즉시 표시, 2초 입력 무시 후 입력 시 잠금 없이 닫힘, 기능 꺼짐에서도 동작, 잠자기 방지 없음. R12(SHOULD) 로 추가 · 작업을 걸고 N분 기다리지 않고 화면·데이터를 바로 확인. 잠금 동작은 실제 흐름으로 확인 - 2026-10-16 · (구현 중 변경) dashboard 카드 정보 → 시안 E: 프롬프트 한 줄, 열별 세부(대기: 기다리는 대상, 실행 중: 도구 동작, 턴 종료: 마지막 응답·오류·중단), repo 변경 상태, 알림 수. R9 의 "프롬프트 제외" 를 뒤집고 R11 을 카드 제목 크기 + 세부 줄 수 제한으로 바꾸며, 열당 카드는 4장에서 3장으로 · 화면 전체를 쓰면서 메뉴바 카드보다 정보가 적었음. 민감 정보는 runtime 이 저장 단계에서 빼고, dashboard 가 없으면 그 자리에 터미널이 그대로 보였을 것이라 노출을 이유로 뺄 근거가 약함. 별도 표시 설정은 두지 않음(Q9 스위치 하나) +- 2026-10-18 · R3·R4·R6·R7 과 Non-goal "화면이 잠긴 동안 디스플레이를 켜 두는 것" → [COMP002](COMP002-lock-screen-dashboard.md) 로 대체 · dashboard 를 companion idle 시간 대신 macOS 화면보호기·잠금 화면이 뜰 때 잠금 화면 위에 띄우고, 그동안 디스플레이를 켜 둔다 - 2026-10-17 · (구현 중 변경) 화면이 잠긴 동안의 잠자기 방지 → 작업 중이면 잠겨 있어도 시스템 잠자기는 계속 막고, 디스플레이만 macOS 기본 동작대로 꺼지게 둔다. R2·Non-goals·Success criteria 수정 · 잠금 화면은 agent 를 멈추지 않지만 잠자기는 멈춘다. 잠기면 잠자기 방지를 풀던 기존 결정(plan Q4)으로는 R6 으로 잠긴 뒤나 직접 잠그고 자리를 뜬 뒤 Mac 이 잠들어 agent 가 멈춤 · 사용자 선택 ## Outcome diff --git a/docs/intents/COMP002-lock-screen-dashboard.md b/docs/intents/COMP002-lock-screen-dashboard.md new file mode 100644 index 0000000..d143a82 --- /dev/null +++ b/docs/intents/COMP002-lock-screen-dashboard.md @@ -0,0 +1,66 @@ +--- +id: COMP002 +slug: lock-screen-dashboard +created: 2026-10-18T04:20:00.000Z +updated: 2026-10-18T05:30:00.000Z +--- +# COMP002 · lock-screen-dashboard + +## Background + +COMP001 로 dashboard 와 잠자기 방지를 넣었지만 dashboard 가 뜬 뒤 유지가 안 된다. + +COMP001 의 흐름은 "화면을 쓰는 동안 디스플레이를 붙잡아 macOS 화면보호기를 막고 → companion 의 idle 시간 뒤 dashboard 를 띄우고 → 입력이 오면 잠그면서 dashboard 를 내리고 → 잠긴 뒤에는 디스플레이를 macOS 에 맡긴다" 였다. 그래서 + +* 누가(또는 내가) 한 번 건드리면 dashboard 는 사라지고 잠금 화면만 남으며, 디스플레이도 곧 꺼진다. +* 직접 잠그고(Ctrl+Cmd+Q) 자리를 뜨면 dashboard 는 아예 뜨지 않는다. +* 화면을 쓰는 동안 디스플레이를 붙잡으므로 macOS 화면보호기와 그 잠금이 companion 없이는 동작하지 않는다. caffeinate 로 잠자기를 막을 때 잠금 화면이 안 되던 문제와 같은 모양이다. + +원하는 동작: + +* OS screen saver 동작 시에 dashboard 가 그 위에 뜬다. + - OS screen saver: lock screen + - workbranch dashboard: 화면 표시, sleep 들지 않게 함 +* screen lock 된 상태이나 화면에는 dashboard 가 보이고, dashboard 로 인해서 sleep 에 빠지지 않고, 화면에서 진행 사항을 볼 수 있다. + +## Goal + +잠금은 macOS 화면보호기·잠금 화면이 맡고, companion 은 그 위에 dashboard 를 띄워 디스플레이와 시스템을 깨워 둔다. 자리를 비운 동안 Mac 은 잠겨 있고, 잠들지 않고, 멀리서 진행 상황이 보인다. + +## Non-goals +- companion 전용 idle 시간 설정. dashboard 를 띄우는 시점은 macOS 화면보호기 시간이 정한다. +- 화면을 쓰는 동안(잠기지 않고 화면보호기도 없을 때) 디스플레이를 붙잡는 것. +- 이미 꺼진 디스플레이를 companion 이 켜는 것. +- dashboard 에서 작업을 조작하는 것. 보기 전용이다(COMP001 과 같음). +- companion 이 macOS 의 화면보호기·잠금·잠자기 설정 값을 바꾸는 것(COMP001 과 같음). + +## Success criteria +- 작업 중에 자리를 비우면 macOS 화면보호기 시간에 Mac 이 잠기고, 잠금 화면 위에 dashboard 가 뜬다. +- dashboard 가 뜬 채로 macOS 디스플레이 끄기 시간보다 오래 두어도 디스플레이와 dashboard 가 켜져 있고, Mac 이 잠들지 않는다. +- 입력하면 dashboard 가 비켜 macOS 잠금 화면이 보이고, 암호나 Touch ID 로만 데스크톱으로 돌아온다. 풀지 않고 두면 dashboard 가 다시 뜬다. +- 작업 중이 끝나면 dashboard 가 내려가고 잠금 화면·디스플레이·잠자기가 macOS 기본 동작으로 돌아간다. + +## Requirements +- R1 (MUST) 켜짐 && 작업 중 && (화면 잠김 || macOS 화면보호기 실행 중) 이면 모든 모니터에서 잠금 화면과 화면보호기 **위에** dashboard 가 보인다. — acceptance: 작업 중에 Ctrl+Cmd+Q 로 잠그고 15초 기다리면 모든 모니터에 dashboard 가 보인다. +- R2 (MUST) 화면을 쓰는 동안에는 디스플레이를 붙잡지 않아 macOS 화면보호기와 잠금이 macOS 설정대로 시작되고, 화면보호기나 잠금 화면이 뜬 뒤 작업 중인 동안에는 디스플레이를 붙잡는다. 시스템 잠자기 방지는 COMP001 R2 그대로다. — acceptance: 잠기지 않은 상태에서 `pmset -g assertions` 에 companion 의 `PreventUserIdleDisplaySleep` 이 없고, 잠긴 뒤에는 있다. +- R3 (MUST) 잠금이 가능한 Mac 에서 dashboard 는 세션이 잠김으로 읽힐 때만 보인다. 화면보호기만 시작되었고 아직 잠기지 않았으면(macOS 의 "암호 요구까지 지연") 먼저 잠그고 잠김이 확인되면 띄운다. 잠금 요청이 끝내 안 되면(2초 간격 3번) dashboard 없이 macOS 가 스스로 잠글 때까지 기다린다. — acceptance: "암호 요구" 지연을 5분으로 둔 Mac 에서 화면보호기 위에 dashboard 가 뜬 직후 입력하면 잠금 화면이 나온다. 잠금이 안 되게 만든 환경에서는 "화면 잠김" 표시를 단 dashboard 가 잠기지 않은 화면 위에 뜨지 않는다. +- R4 (MUST) dashboard 위에서 입력하면 dashboard 만 내려가 잠금 화면이 보인다. 잠금을 풀지 않고 15초 동안 입력이 없으면 다시 뜬다. 잠금이 풀리면 내려가고 디스플레이 유지도 푼다. — acceptance: dashboard 상태에서 마우스를 움직이면 잠금 화면이 보이고, 그대로 두면 15초 뒤 dashboard 가 돌아온다. +- R5 (MUST) 잠금 불가 환경에서는 dashboard 가 같은 조건에 뜨되 "화면 잠김" 표시를 숨기고 직접 잠그지 않는다(COMP001 R7 의 갱신). — acceptance: `WORKBRANCH_SCREENSAVER_NO_LOCK=1` 로 실행하면 "화면 잠김" 표시가 없다. +- R6 (MUST) Settings › Screen Saver 에는 스위치와 Show now 만 있고, "Dashboard after" 는 없다. 이전 store 의 `idleMinutes` 는 무시하고 지운다. — acceptance: Settings 에 분 선택이 없고, 시작 후 store 의 `screensaver` 가 `{ "enabled": … }` 만 갖는다. +- R7 (MUST) companion 이 화면보호기가 이미 도는 중에 시작되거나 화면보호기 알림을 놓쳐도, 다음 세션 읽기(작업 중이면 1초 안)에서 화면보호기를 알아본다. — acceptance: 화면보호기가 도는 중(암호 요구 지연 안)에 companion 을 다시 시작하면 잠기고 dashboard 가 뜬다. + +COMP001 의 R1(스위치), R2(시스템 잠자기 방지), R5(작업 중이 끝나면 내림), R8~R12(화면 구성·카드·모니터·미리 보기)는 그대로다. R3·R4·R6·R7 은 이 intent 의 R1~R5 로 바뀐다. + +## Decision Log +- 2026-10-18 · 트리거 → macOS 화면보호기 시작(`com.apple.screensaver.didstart`)과 화면 잠김 · 사용자 요구 "OS screen saver 동작 시에 dashboard 그 위에 뜸". companion 의 idle 시간 설정(Dashboard after)은 없앤다 +- 2026-10-18 · 잠금 화면 위 표시 방법 → SkyLight 비공개 space(절대 level 400) · loginwindow 로그에서 잠금 화면이 `LUIUnmanagedSpace: level=300` 에 그려짐을 확인. 잠금 화면 위 UI 를 띄우는 공개 앱(SkyLightWindow, PC Bio Unlock)과 같은 방법. macOS 27.2 에서 심볼 존재 확인 +- 2026-10-18 · 화면보호기만 떠 있고 잠기지 않았을 때 → dashboard 를 띄우면서 바로 잠근다 · 요구가 "screen lock 된 상태이나 화면은 dashboard 가 보이고" 이고, 화면보호기를 덮어 잠김 여부가 보이지 않으므로 dashboard 가 보이면 잠겨 있어야 한다. 이 Mac 은 "암호 요구까지 지연" 300초 +- 2026-10-18 · 잠김 판단 → 알림 대신 세션의 `CGSSessionScreenIsLocked` 를 읽는다 · 2026-10-18 00:01~00:42 `pmset -g log` 에 loginwindow 잠금 기록 없이 companion 의 디스플레이 assertion 만 20~50초 간격으로 풀렸다 잡혔다. 원인은 확정하지 못했지만, 잘못 온 잠김 알림이 데스크톱 위에 dashboard 를 띄우고 잠그는 일을 막는다 +- 2026-10-18 · 다시 띄우는 시간 → 15초 · 잠금 화면에서 암호를 입력하는 동안은 뜨지 않고, 그냥 두고 가면 곧 돌아온다 + +- 2026-10-18 · (PR #245 리뷰) 잠금 확인 → dashboard 를 띄운 뒤 잠그던 것을 "잠그고, 잠김이 확인되면 띄운다" 로 바꾸고 COMP001 의 재시도(2초 간격 3번)를 되살림. 끝내 안 잠기면 dashboard 없이 macOS 의 지연 잠금을 기다림 · 한 번만 요청하고 결과를 버리면, 잠금이 실패했을 때 "화면 잠김" 을 단 dashboard 가 잠기지 않은 화면보호기를 덮고 입력하면 암호 없이 데스크톱이 드러남 +- 2026-10-18 · (PR #245 리뷰) 화면보호기 상태 초기화 → 알림 대신 `SACScreenSaverIsRunning` 을 세션 읽기마다 묻는다 · 알림만으로는 companion 이 화면보호기 도중에 시작되거나, 시작 직후 observer 등록 전에 온 알림을 알 수 없음. 백그라운드 스레드에서 평균 0.19ms +- 2026-10-18 · (PR #245 리뷰 반영 중 발견) 잠금 해제 직후의 "실행 중" 판독 → 멈춤으로 읽히거나 새로 시작될 때까지 믿지 않음 · Touch ID 로 풀면 HID 입력이 없어 idle 이 크게 남아, 늦은 판독 하나가 방금 푼 Mac 을 다시 잠글 수 있음 + +## Outcome +- 2026-10-18 · **Pending human verification** · 비공개 API 동작(잠금 화면 위 SkyLight space, 잠금 화면에서의 디스플레이 유지, 잠긴 동안의 webview 갱신)은 실제 잠금으로만 확인할 수 있다. diff --git a/docs/specs/comp/screensaver.md b/docs/specs/comp/screensaver.md index 2278aa3..8ab70a9 100644 --- a/docs/specs/comp/screensaver.md +++ b/docs/specs/comp/screensaver.md @@ -1,18 +1,17 @@ # COMP · Screensaver -설정을 켜면 companion 은 agent 가 작업 중인 동안 Mac 이 idle 잠자기에 들어가지 않게 하고, 키보드·마우스 입력 없이 설정 시간이 지나면 모든 모니터에 dashboard 를 띄우며, dashboard 에 입력이 오면 macOS 잠금 화면으로 넘긴다. 자리를 비운 동안 agent 는 계속 일하고, 멀리서 작업 상태가 보이고, 잠금 성공 후에는 macOS 계정 인증으로 돌아온다. 잠금 불가 또는 잠금 요청 실패 시에는 잠금 없이 dashboard 만 닫히므로, dashboard 표시 자체가 잠금을 보장하지는 않는다. +설정을 켜면 companion 은 agent 가 작업 중인 동안 Mac 이 idle 잠자기에 들어가지 않게 한다. 언제 화면을 덮고 잠글지는 macOS 가 정한다: macOS 화면보호기가 시작되거나 화면이 잠기면 companion 은 Mac 을 잠그고(이미 잠겼으면 그대로), 모든 모니터의 잠금 화면 **위에** dashboard 를 띄우고, 디스플레이가 꺼지지 않게 붙잡는다. 화면은 잠겨 있지만 멀리서 작업 진행이 보이고, Mac 은 잠들지 않는다. 입력이 오면 dashboard 만 비켜 macOS 잠금 화면이 드러나고, macOS 계정 암호나 Touch ID 로 돌아온다 ([COMP002](../../intents/COMP002-lock-screen-dashboard.md)). 꺼져 있으면 companion 은 잠자기·화면보호기·잠금에 관여하지 않는다. 켜져 있어도 새 권한 요청(손쉬운 사용·입력 모니터링·화면 기록)은 없다. companion 은 macOS 의 화면보호기·잠금·잠자기 설정 값을 바꾸지 않고, 별도 암호를 두지 않는다. ## 용어 - **작업 중**: runtime 문서([runtime snapshot](runtime-snapshot.md))에 `observation: "observed"` 이고 `state` 가 `running` 또는 `waiting` 인 세션이 하나 이상 있는 상태. 칸반 `runtimeRole()` 과 같은 기준이다. 턴 종료(`finished`)·`idle` 세션과 `uncertain`·`stale` 관측은 세지 않는다. -- **무장(armed)**: 기능 켜짐 && 작업 중 && 화면이 잠기지 않음. dashboard 는 무장 중에만 뜬다. +- **덮임(covered)**: 기능 켜짐 && 작업 중 && (화면 잠김 || macOS 화면보호기 실행 중). dashboard 와 디스플레이 유지는 덮임 동안에만 있다. ## 설정 -- 저장 위치: `companion-preferences.json` 의 `screensaver` 키, `{ enabled: boolean, idleMinutes: number }`. 기본값 `{ enabled: false, idleMinutes: 5 }`. -- 필드마다 따로 읽는다. `enabled` 가 boolean 이 아니면 `false`, `idleMinutes` 가 유한한 숫자가 아니면 `5`, 숫자면 반올림한 뒤 1~60 으로 자른다. Settings(`application/screensaver.ts` 의 `sanitizeScreensaverConfig`)와 Rust 서비스(`Config::from_store_value`)가 같은 규칙으로 읽는다. -- main 창은 시작할 때 보정이 필요한 값을 고쳐 저장하고 서비스에 다시 읽게 한다. -- Settings › Screen Saver: "While agents work" 스위치, "Dashboard after" 분 입력, Preview 의 "Show now" 버튼. 바꾸면 저장한 뒤 `screensaver_refresh` 로 서비스가 설정을 다시 읽는다. +- 저장 위치: `companion-preferences.json` 의 `screensaver` 키, `{ enabled: boolean }`. 기본값 `{ enabled: false }`. +- `enabled` 가 boolean 이 아니면 `false` 로 읽는다. COMP002 이전 store 의 `idleMinutes` 는 무시하고, main 창이 시작할 때 키를 지워 다시 저장한다. Settings(`application/screensaver.ts` 의 `sanitizeScreensaverConfig`)와 Rust 서비스(`Config::from_store_value`)가 같은 규칙으로 읽는다. +- Settings › Screen Saver: "While agents work" 스위치와 Preview 의 "Show now" 버튼. dashboard 를 띄우는 시간 설정은 없다. macOS 화면보호기 시간(시스템 설정 › 잠금 화면)이 그 역할을 한다. 바꾸면 저장한 뒤 `screensaver_refresh` 로 서비스가 설정을 다시 읽는다. ## 전원 유지 `NSProcessInfo` activity 두 개를 따로 건다. 이유 문자열은 "Workbranch agents are working" 이다. @@ -20,55 +19,56 @@ | 조건 | activity 옵션 | `pmset -g assertions` | |---|---|---| | 켜짐 && 작업 중 (화면 잠김과 무관) | `UserInitiated` | `PreventUserIdleSystemSleep` | -| 무장 (켜짐 && 작업 중 && 잠기지 않음) | `IdleDisplaySleepDisabled` | `PreventUserIdleDisplaySleep` | +| 덮임 (켜짐 && 작업 중 && (잠김 \|\| 화면보호기)) | `IdleDisplaySleepDisabled` | `PreventUserIdleDisplaySleep` | -- 시스템 쪽은 화면이 잠겨 있어도(직접 잠갔든 dashboard 입력으로 잠겼든) 유지한다. 잠금 화면은 agent 를 멈추지 않지만 잠자기는 멈추기 때문이다. 잠긴 동안 디스플레이 꺼짐과 잠금 화면은 macOS 기본 동작 그대로다. -- 디스플레이 쪽이 걸려 있는 동안에는 macOS 화면보호기와 그 잠금도 뜨지 않는다. 그 자리를 dashboard 가 대신한다. -- 작업 중이 끝나면(턴 종료만 남거나 세션이 없으면) 둘 다 푼다. companion 이 끝나거나 비정상 종료되면 OS 가 회수한다. +- 시스템 쪽은 화면이 잠겨 있어도 유지한다. 잠금 화면은 agent 를 멈추지 않지만 잠자기는 멈추기 때문이다. +- 화면을 쓰는 동안(잠기지 않고 화면보호기도 없을 때)에는 디스플레이 쪽을 걸지 않는다. 그래서 macOS 화면보호기와 그 잠금이 macOS 설정대로 시작된다. 디스플레이 쪽은 화면보호기나 잠금 화면이 뜬 뒤에 걸어 dashboard 가 보이는 동안 디스플레이가 꺼지지 않게 한다. caffeinate 같은 잠자기 방지 utility 가 디스플레이를 붙잡아 화면보호기와 잠금까지 막던 문제를 피한다. +- macOS 디스플레이 끄기 시간이 화면보호기 시간보다 짧으면 디스플레이가 먼저 꺼진다. 이미 꺼진 디스플레이는 companion 이 켜지 않으므로 dashboard 가 보이지 않는다. 화면보호기 시간을 디스플레이 끄기 시간보다 짧게 둔다(이 Mac: 화면보호기 5분, 디스플레이 10분). +- 작업 중이 끝나면(턴 종료만 남거나 세션이 없으면) dashboard 를 내리고 둘 다 푼다. 이후 잠금 화면의 디스플레이 꺼짐과 잠자기는 macOS 기본 동작이다. companion 이 끝나거나 비정상 종료되면 OS 가 회수한다. - `UserInitiated` activity 는 App Nap 도 끈다. 작업 중이 아닐 때는 App Nap 으로 서비스 주기가 늦어질 수 있다. - runtime 읽기가 실패하면 마지막 판단을 유지하고, 3분(`UNREADABLE_GRACE`, 60초 보조 읽기 두 번 이상) 넘게 계속 실패할 때만 작업 중이 아닌 것으로 본다. 읽기에는 성공했지만 계약(`schemaVersion` 1, 세션의 모든 필드와 열거값)을 어긴 문서는 바로 작업 중이 아니다. 실패한 읽기는 작업 중을 새로 시작하지 않는다. - 노트북 덮개를 닫으면 macOS 규칙대로 잠든다. 배터리·전원 상태에 따라 다르게 동작하지 않는다. -## Dashboard 표시와 내림 -- idle 시간은 `CGEventSourceSecondsSinceLastEventType`(HID 시스템 상태, 모든 입력 종류)로 잰다. 알림 API 가 없어 무장 중에만 polling 한다: 평소 1초, dashboard 표시 중 0.25초. 무장이 아니면 재지 않고, 무장이 풀릴 때 지난 표본을 버린다. -- 무장 중 idle 시간이 설정 시간 이상이면 dashboard 를 띄운다. 작업 중이면 이 시간이 macOS 화면보호기 시간보다 길어도 화면보호기가 먼저 뜨지 않는다. -- 무장이 풀리면(작업 중 끝, 기능 꺼짐, 화면 잠김) dashboard 를 바로 내린다. 화면이 잠긴 동안에는 띄우지 않는다. 잠금이 풀리면 다시 판정한다. -- 화면 잠김·해제는 `NSDistributedNotificationCenter` 의 `com.apple.screenIsLocked` / `com.apple.screenIsUnlocked` 로 받고, 시작할 때 세션 잠금 상태(`CGSSessionScreenIsLocked`)를 한 번 읽는다. +## 세션 읽기 +- 작업 중인 동안 1초마다(`IDLE_POLL`), dashboard 가 떠 있거나 잠금을 기다리는 동안 0.25초마다(`INPUT_POLL`) 세션을 읽는다: idle 시간은 `CGEventSourceSecondsSinceLastEventType`(HID 시스템 상태, 모든 입력 종류), 잠김은 세션 사전의 `CGSSessionScreenIsLocked`, 화면보호기 실행 여부는 비공개 `login.framework` 의 `SACScreenSaverIsRunning`(loginwindow 에 묻는 동기 호출, 약 0.2ms). 작업 중이 아니면 읽지 않고, 지난 idle 표본을 버린다. runtime snapshot 이 읽기 주기보다 자주 와도 다음 읽기 시각은 밀리지 않는다. +- `NSDistributedNotificationCenter` 의 `com.apple.screenIsLocked` / `com.apple.screenIsUnlocked` 는 바로 다시 읽으라는 신호로만 쓴다. 잠김 여부는 언제나 세션에서 읽으므로, 잘못 온 알림이 쓰고 있는 데스크톱 위에 dashboard 를 띄우지 않는다. +- `com.apple.screensaver.didstart` / `com.apple.screensaver.didstop` 도 바로 다시 읽으라는 신호다. 그래서 companion 이 화면보호기가 이미 돌고 있을 때 시작되거나 알림을 놓쳐도 다음 읽기에서 안다. `SACScreenSaverIsRunning` 을 찾지 못하면 이 알림으로만 알고, 멈춤 알림을 놓쳐도 잠금이 풀리거나 잠기지 않은 상태에서 입력이 오면 화면보호기는 끝난 것으로 본다. +- 잠금이 풀리면 화면보호기가 멈춤으로 읽히거나 새로 시작(`didstart`)될 때까지 "실행 중" 판독을 믿지 않는다. Touch ID 로 풀면 키보드·마우스 입력이 없어 idle 이 큰 채로 남으므로, 늦게 온 "실행 중" 판독이 방금 푼 Mac 을 다시 잠그지 않게 하기 위해서다. -## 입력 시 잠금 -- dashboard 는 보기 전용이다. 표시 중 idle 시간이 직전 표본보다 줄면(입력) 잠금을 요청한다. 잠금은 비공개 `login.framework` 의 `SACLockScreenImmediate`(Ctrl+Cmd+Q 와 같은 잠금)이고, 시작할 때 `dlopen`/`dlsym` 으로 찾는다. 해제는 macOS 계정 암호나 Touch ID 로만 된다. -- dashboard 는 잠김이 확인될 때까지 떠 있어 데스크톱이 드러나지 않고 키가 밑의 앱으로 가지 않는다. 기다리는 동안 0.25초마다 세션 잠금 상태를 직접 읽어, 잠금 알림이 늦어도 잠김을 확인한다. -- 2초(8 표본) 안에 잠기지 않으면 다시 요청한다. 모두 3번(약 6초) 요청해도 잠기지 않으면 잠금 없는 Mac 으로 보고 dashboard 만 닫는다. -- 잠기면 무장이 풀려 dashboard 가 내려가므로, 잠금을 풀면 원래 화면이다. -- **잠금 불가**: 잠금 심볼을 찾지 못하면 dashboard 는 똑같이 뜨되 "입력 시 잠금" 표시를 숨기고, 입력이 오면 잠금 없이 dashboard 만 닫는다. `screensaver_status` command 가 `{ lockAvailable }` 을 돌려준다. 개발용 환경 변수 `WORKBRANCH_SCREENSAVER_NO_LOCK`(값 무관)이 있으면 잠금 불가로 시작한다. +## Dashboard 표시와 내림 +- 덮임 동안 idle 시간이 15초(`SHOW_AFTER`) 이상이면 dashboard 를 띄운다. 화면보호기는 그보다 훨씬 긴 idle 뒤에 시작하므로 바로 덮이고, 직접 잠근 경우(Ctrl+Cmd+Q)에는 손을 뗀 뒤 15초 뒤에 뜬다. +- 잠금이 가능한 Mac 에서 dashboard 는 세션이 잠김으로 읽힐 때만 보인다. 화면보호기만 시작되었고 아직 잠기지 않았으면(macOS 의 "암호 요구까지 지연"이 남은 경우) 먼저 잠그고, 잠김이 확인되면 띄운다. 잠금은 비공개 `login.framework` 의 `SACLockScreenImmediate`(Ctrl+Cmd+Q 와 같은 잠금)이고, 시작할 때 `dlopen`/`dlsym` 으로 찾는다. 2초(8 표본) 안에 잠기지 않으면 다시 요청하고, 모두 3번(약 6초) 요청해도 잠기지 않으면 이번 화면보호기 동안에는 더 요청하지 않는다. 그때는 디스플레이를 켜 둔 채 dashboard 없이 기다리다가, macOS 가 지연 시간 뒤 스스로 잠그면 띄운다. 그래서 dashboard 가 보이는 동안 Mac 은 잠겨 있고 "화면 잠김" 표시가 거짓이 되지 않으며, 입력해도 암호 없이 데스크톱이 드러나지 않는다. 떠 있는 dashboard 도 세션이 잠김이 아니게 읽히면 바로 내린다. +- 입력이 오면(idle 이 직전 표본보다 줄면) dashboard 만 내린다. 그 아래 잠금 화면에서 암호나 Touch ID 로 잠금을 푼다. 디스플레이 유지는 잠긴 동안 계속되므로 잠금 화면이 켜진 채 남고, 잠금을 풀지 않고 15초 동안 입력이 없으면 dashboard 가 다시 뜬다. 잠긴 상태에서는 띄운 직후 1초(4 표본) 동안의 입력을 무시한다. 잠금 화면이 올라오며 생기는 순간적인 표본이 dashboard 를 바로 내리지 않게 하기 위해서다. +- 잠금이 풀리면(덮임이 끝나면) dashboard 를 내리고 디스플레이 유지를 푼다. 작업 중이 끝나거나 기능을 꺼도 마찬가지다. +- **잠금 불가**: 잠금 심볼을 찾지 못하면 dashboard 는 똑같이 화면보호기와 잠금 화면 위에 뜨되 "화면 잠김" 표시를 숨기고, 직접 잠그지 않는다. 화면보호기만 떠 있을 때 입력이 오면 dashboard 가 내려가고 macOS 설정대로(지연 시간 안이면 암호 없이) 화면보호기가 끝난다. `screensaver_status` command 가 `{ lockAvailable }` 을 돌려준다. 개발용 환경 변수 `WORKBRANCH_SCREENSAVER_NO_LOCK`(값 무관)이 있으면 잠금 불가로 시작한다. ## 창 - 모니터마다 창 하나: 라벨 `dashboard-`, 미리 보기는 `dashboard-preview-`. 모니터 전체 크기, 테두리·그림자 없음, 크기 변경·최소화·닫기 불가, Dock·창 순환에 나오지 않음. -- 창 level 은 `NSScreenSaverWindowLevel`, collection behavior 는 `CanJoinAllSpaces | FullScreenAuxiliary | Stationary | IgnoresCycle` 이라 메뉴바·Dock·다른 Space 의 전체 화면 앱 위를 덮는다. -- 띄울 때 companion 을 활성화하고 주 모니터의 창을 key window 로 만든다. 잠기기 전에 눌린 키는 dashboard 가 받는다. -- 덮는 동안에는 앱 메인 메뉴를 떼어 Cmd-Q·Cmd-H·Cmd-M·Cmd-W 같은 메뉴 단축키가 듣지 않고, 내릴 때 되돌린다. +- 잠금 화면 위에 그리기 위해 비공개 SkyLight space 를 쓴다. 처음 띄울 때 `SLSSpaceCreate` 로 space 를 하나 만들고 `SLSSpaceSetAbsoluteLevel` 로 절대 level 400(macOS 가 잠금 화면 위 알림에 쓰는 level, 잠금 화면은 300)을 준 뒤 `SLSShowSpaces` 로 보이고, 창을 `SLSSpaceAddWindowsAndRemoveFromSpaces` 로 그 space 에 옮긴다. space 는 앱이 끝날 때까지 둔다. SkyLight 함수를 하나라도 찾지 못하면 창은 화면보호기 위에만 뜨고 잠금 화면 아래에 가려진다. +- 창 level 은 `CGShieldingWindowLevel() + 1`, collection behavior 는 `CanJoinAllSpaces | FullScreenAuxiliary | Stationary | IgnoresCycle`, `canBecomeVisibleWithoutLogin` 은 참이다. +- dashboard 창은 키보드를 받지 않는다(focus 없음, app 활성화 없음). 잠긴 동안 키 입력은 잠금 화면의 암호 칸으로 간다. 실제 dashboard 는 마우스도 통과시켜 클릭이 잠금 화면에 닿는다. 미리 보기 창은 클릭을 받아, 밑에 있는 앱에 클릭이 가지 않는다. - 모니터 구성이 바뀌면(`NSApplicationDidChangeScreenParametersNotification`) 지금 모니터 목록에 맞춰 창을 다시 만든다. - 창과 webview 배경은 `#131313`(Claude 테마 `--surface-0`)이다. Tauri `macOSPrivateApi` 를 켜 webview 의 기본 흰 배경을 그리지 않는다. 내릴 때는 모든 dashboard 창을 먼저 숨긴 뒤 destroy 하므로 열고 닫을 때 흰 화면이 비치지 않는다. - capability `dashboard`(`dashboard-*` 창 전용)는 `core:event:allow-listen`, `core:event:allow-unlisten`, `store:allow-load`, `store:allow-get` 만 준다. ## 화면 구성 - 별도 Vite 진입점 `dashboard.html`(`src/dashboardMain.tsx` → `src/DashboardApp.tsx`)이라 dashboard 창은 dashboard 코드만 로드한다. 버튼·링크·입력이 없다. -- 상단 왼쪽: 7-segment 시계(현재 시각, 분 단위), 날짜, "입력 시 잠금" 표시. 표시는 잠금 가능할 때만 보이고, 잠금 가능 여부를 읽기 전에는 숨긴다. 미리 보기에서는 대신 "미리 보기 · 입력하면 닫힘" 을 보인다. +- 상단 왼쪽: 7-segment 시계(현재 시각, 분 단위), 날짜, "화면 잠김" 표시. 표시는 잠금 가능할 때만 보이고, 잠금 가능 여부를 읽기 전에는 숨긴다. 미리 보기에서는 대신 "미리 보기 · 입력하면 닫힘" 을 보인다. - 상단 오른쪽: 상태별 task 개수(내 응답 대기 · 실행 중 · 턴 종료). companion 칸반의 열별 개수와 같다. - 하단: 칸반과 같은 3열(내 응답 대기 · 실행 중 · 턴 종료 · 검토)과 같은 상태 색(`--runtime-waiting` / `--runtime-running` / `--runtime-finished`). 열 이름·대기 이유·종료 결과 문구는 `ui/boardCopy.ts` 를 메뉴바 칸반과 함께 쓴다. - 카드는 `buildMainViewModel()` 이 칸반 3열에 넣는 task 와 같다. 세션 없는·관측 불명·비활성 task 는 나오지 않는다. 각 카드는 lead session 기준으로 task 이름, 그 상태에 들어간 뒤 경과 시간, agent, repo(여러 개면 "+n")·branch, 프롬프트 한 줄, 세부 줄(대기·실행 중: 지금 하는 도구 동작, 턴 종료: 마지막 응답이 있으면 응답, 없으면 도구 동작), 대기 카드의 대기 이유, 턴 종료 카드의 오류로 종료·중단됨 표시, repo 변경 상태 합계(변경 파일 수 · ahead · behind), 알림 수를 보여 준다. -- 글은 runtime 이 저장한 그대로다. 민감 정보 제외와 길이 제한은 수집 단계에서 한다. +- 글은 runtime 이 저장한 그대로다. 민감 정보 제외와 길이 제한은 수집 단계에서 한다. 잠금 화면 위에 보이므로, 자리를 비운 동안 화면 앞의 누구나 이 내용을 읽을 수 있다. - 열당 카드는 3장(`DASHBOARD_CARDS_PER_COLUMN`)이고 나머지는 "+n more" 로 줄인다. 세부 줄은 줄 수를 고정해 자르고 크기는 화면 높이(vh)에 비례하므로, 카드가 잘리거나 화면 아래로 넘치지 않고 스크롤이 생기지 않는다. - 테마와 글꼴은 companion preference 를 따른다. - 데이터는 main 창과 같은 원천이다: runtime 은 `useRuntimeSessions` 구독, 목록은 workspace monitor 이벤트. ## 미리 보기 -- Settings › Screen Saver › Show now(`screensaver_preview`)는 기능 켜짐·작업 중·idle 시간과 상관없이 모든 모니터에 미리 보기 창을 바로 띄운다. 실제 dashboard 가 떠 있으면 무시한다. +- Settings › Screen Saver › Show now(`screensaver_preview`)는 기능 켜짐·작업 중·화면보호기와 상관없이 모든 모니터에 미리 보기 창을 바로 띄운다. 실제 dashboard 가 떠 있으면 무시한다. - 처음 2초(8 표본)의 입력은 무시하고, 그 뒤 입력이 오면 잠그지 않고 닫는다. 화면이 잠겨도 닫는다. -- 미리 보기 자체는 잠자기 방지를 걸지 않는다. +- 미리 보기 자체는 잠자기 방지와 디스플레이 유지를 걸지 않는다. ## 구조 -- `src-tauri/src/screensaver/machine.rs`: 순수 상태 기계. `Machine::step(Event) -> Vec` 이 설정·작업 중·읽기 실패·잠김·idle 표본·미리 보기 입력을 받아 전원 유지·창·잠금 동작을 낸다. 전이는 `machine_tests.rs` 가 OS 없이 검사한다. -- `screensaver/platform.rs`: OS 동작을 감싼 `Platform` trait. `screensaver/macos.rs`: objc2 typed binding(`objc2-foundation`·`objc2-app-kit`·`objc2-core-graphics`)과 잠금 `dlopen` 구현. unsafe 블록마다 SAFETY 주석을 둔다. `screensaver/windows.rs`: 모니터별 창 배치. -- `src-tauri/src/screensaver.rs`: 한 스레드에서 runtime snapshot·설정 변경·잠김 알림·모니터 변경·미리 보기 요청을 받아 상태 기계에 넣고, 상태 기계가 원하는 주기로 idle 시간이나 잠금 상태를 표본한다. command 는 `screensaver_refresh`, `screensaver_status`, `screensaver_preview`. -- 비공개 API(`SACLockScreenImmediate`, webview `drawsBackground`)를 쓰므로 Mac App Store 로는 배포할 수 없다. companion 은 Homebrew cask 로 배포한다. +- `src-tauri/src/screensaver/machine.rs`: 순수 상태 기계. `Machine::step(Event) -> Vec` 이 설정·작업 중·읽기 실패·화면보호기 알림·세션 표본(idle, 잠김, 화면보호기)·미리 보기를 받아 전원 유지·창·잠금 동작을 낸다. 전이는 `machine_tests.rs` 가 OS 없이 검사한다. +- `screensaver/platform.rs`: OS 동작을 감싼 `Platform` trait. `screensaver/macos.rs`: objc2 typed binding(`objc2-foundation`·`objc2-app-kit`·`objc2-core-graphics`·`objc2-core-foundation`)과 잠금·SkyLight `dlopen` 구현. unsafe 블록마다 SAFETY 주석을 둔다. `screensaver/windows.rs`: 모니터별 창 배치. +- `src-tauri/src/screensaver.rs`: 한 스레드에서 runtime snapshot·설정 변경·잠김 알림·화면보호기 알림·모니터 변경·미리 보기 요청을 받아 상태 기계에 넣고, 상태 기계가 원하는 주기로 세션을 읽는다. command 는 `screensaver_refresh`, `screensaver_status`, `screensaver_preview`. +- 비공개 API(`SACLockScreenImmediate`, `SACScreenSaverIsRunning`, SkyLight space, webview `drawsBackground`)를 쓰므로 Mac App Store 로는 배포할 수 없다. companion 은 Homebrew cask 로 배포한다. 이후 macOS 에서 사라지거나 바뀔 수 있다.