diff --git a/apps/AfterRay/AGENTS.md b/apps/AfterRay/AGENTS.md index 6f2d333..3f40fef 100644 --- a/apps/AfterRay/AGENTS.md +++ b/apps/AfterRay/AGENTS.md @@ -12,7 +12,8 @@ The shipped macOS app (`AfterRayApp` executable target in the root `Package.swif ## Invariants -- The app never opens the database or touches encryption keys — all data flows through `UnixSocketDaemonClient` over the versioned Unix socket (`swift/AfterRayRecall/Sources/DaemonClient.swift:175`, `protocolVersion = 10`, must match `crates/afterray-protocol/src/lib.rs:18`). +- The app never opens the database or touches encryption keys — all data flows through `UnixSocketDaemonClient` over the versioned Unix socket (`swift/AfterRayRecall/Sources/DaemonClient.swift:175`, `protocolVersion = 11`, must match `crates/afterray-protocol/src/lib.rs:18`). +- Settings enumerates attached screens with `NSScreen`, but persists only their stable ColorSync UUID through the daemon. ScreenCaptureKit selection stays inside the capture shim. - Sensitive-state teardown on screen lock/sleep: `.afterRaySystemSessionWillSuspend` → `store`/`control`/`chat.clearSensitiveState()` + `images.clearSensitiveData()` (`AfterRayApp.swift:1097-1104`). Hook any new decrypted-content cache into this. - The overlay and the history window must share `AfterRayServices.shared` stores — never construct a private `RecallStore`. - `AfterRaySettingsController.show()` forces the overlay visible first (`AfterRaySettings.swift:33-38`); settings render inside the recall panel. diff --git a/apps/AfterRay/Sources/AfterRaySettings.swift b/apps/AfterRay/Sources/AfterRaySettings.swift index 1277716..a7f63fd 100644 --- a/apps/AfterRay/Sources/AfterRaySettings.swift +++ b/apps/AfterRay/Sources/AfterRaySettings.swift @@ -1,5 +1,6 @@ import AfterRayRecall import AppKit +import ColorSync import SwiftUI extension Notification.Name { @@ -60,6 +61,7 @@ final class AfterRaySettingsModel: ObservableObject, AfterRaySettingsModeling { @Published var downloadRateBytesPerSecond: Double? @Published var isControllingDownload = false @Published var isUpdatingAudio = false + @Published var isUpdatingCaptureDisplay = false @Published var isUpdatingStorageLimit = false @Published var isUpdatingLanguage = false @Published var isUpdatingExclusions = false @@ -71,6 +73,7 @@ final class AfterRaySettingsModel: ObservableObject, AfterRaySettingsModeling { @Published var draftLlmBaseUrl = "" @Published var draftLlmModel = "" @Published var draftLlmApiKey = "" + @Published var captureDisplays: [CaptureDisplayOption] = [.mainDisplay] @Published var isInstallingCli = false @Published private(set) var cliStatus = AfterRayCliInstall.statusSummary @Published private(set) var cliInstalled = AfterRayCliInstall.isInstalled @@ -127,6 +130,7 @@ final class AfterRaySettingsModel: ObservableObject, AfterRaySettingsModeling { async let nextJobs = daemon.jobs() let loaded = try await (nextSettings, nextLibrary, nextJobs) settings = loaded.0 + refreshCaptureDisplays() library = loaded.1 message = nil applyDownloadState(loaded.1.download) @@ -341,6 +345,66 @@ final class AfterRaySettingsModel: ObservableObject, AfterRaySettingsModeling { } } + func setCaptureDisplay(uuid: String) async { + guard uuid != settings?.captureDisplayUUID else { return } + let displayName = captureDisplays.first { $0.uuid == uuid }?.name ?? "selected display" + isUpdatingCaptureDisplay = true + defer { isUpdatingCaptureDisplay = false } + do { + settings = try await UnixSocketDaemonClient( + socketPath: DaemonSupervisor.shared.socketPath + ).updateCaptureDisplay(uuid: uuid) + refreshCaptureDisplays() + message = uuid.isEmpty + ? "AfterRay now follows the main display." + : "AfterRay now captures \(displayName)." + } catch { + message = error.localizedDescription + } + } + + private func refreshCaptureDisplays() { + let selectedUUID = settings?.captureDisplayUUID ?? "" + var displays = NSScreen.screens.compactMap { screen -> CaptureDisplayOption? in + guard + let number = screen.deviceDescription[.init("NSScreenNumber")] as? NSNumber, + let unmanaged = CGDisplayCreateUUIDFromDisplayID(number.uint32Value) + else { return nil } + let displayID = number.uint32Value + let isMain = displayID == CGMainDisplayID() + let uuid = unmanaged.takeRetainedValue() + let uuidString = (CFUUIDCreateString(nil, uuid) as String).uppercased() + let mode = CGDisplayCopyDisplayMode(displayID) + let detail = mode.map { + "\($0.pixelWidth) × \($0.pixelHeight)" + } + return CaptureDisplayOption( + uuid: isMain && selectedUUID != uuidString ? "" : uuidString, + name: screen.localizedName, + detail: detail, + isMain: isMain, + displayID: displayID, + pixelWidth: mode?.pixelWidth, + pixelHeight: mode?.pixelHeight + ) + } + displays.sort { lhs, rhs in + if lhs.isMain != rhs.isMain { return lhs.isMain } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + if let selected = settings?.captureDisplayUUID, + !selected.isEmpty, + !displays.contains(where: { $0.uuid == selected }) + { + displays.append(CaptureDisplayOption( + uuid: selected, + name: "Unavailable display", + detail: selected + )) + } + captureDisplays = displays + } + func setUiLanguage(_ code: String) async { guard code != settings?.uiLanguage else { return } await persistLanguage(uiLanguage: code, summaryLanguage: nil) diff --git a/apps/AfterRayCaptureShim/AGENTS.md b/apps/AfterRayCaptureShim/AGENTS.md index bd1e9f3..da89938 100644 --- a/apps/AfterRayCaptureShim/AGENTS.md +++ b/apps/AfterRayCaptureShim/AGENTS.md @@ -4,7 +4,7 @@ The ScreenCaptureKit boundary for the Rust daemon. It exists because the Rust wo ## Key anchors -- `main.swift:25` `Options` (`parse` at :31) — CLI flags (`--output-dir`, `--jpeg-quality`, audio, …) +- `main.swift:27` `Options` (`parse` at :34) — CLI flags (`--output-dir`, `--jpeg-quality`, `--display-uuid`, audio, …) - `main.swift:99` `Event` — JSON-line event protocol emitted on stdout (`ready`, `artifact`, `warning`, `failed`, `stopped`) - `main.swift:1213` `InputCommand` — stdin commands; main loop at :1289-1320 handles `capture_screen` (requires `request_id`), `set_excluded_bundles` (carries `bundle_ids`), and `stop` - `main.swift:894` `ExcludedAudioGate` — drops audio while an excluded app is frontmost (see Invariants) @@ -16,6 +16,7 @@ The ScreenCaptureKit boundary for the Rust daemon. It exists because the Rust wo - Screenshots are pull-based: Rust decides timing (`capture_screen`), the shim adds no hidden frame scheduler. - Output dir is hardened to `0700`, artifact files to `0600` (`main.swift:13,20`). - The shim excludes AfterRay's own windows from capture (`main.swift:1258-1265`). +- An empty display UUID follows `CGMainDisplayID`; a selected display uses its stable ColorSync UUID. If that display is unavailable, capture falls back to the main display and emits `display_unavailable` after `ready`. - **A screen artifact is never emitted without its accessibility artifact** (`main.swift:1157`). The daemon's only exclusion check lives in the accessibility branch, so an unpaired screenshot can never be evaluated and would be kept whatever the user excluded. Every path that cannot produce a snapshot returns before the screenshot — keep it that way. - **Audio exclusions are enforced here, screen exclusions in the daemon.** A moment can be deleted once the snapshot names the app; a finished five-minute `m4a` cannot be sliced. `ExcludedAudioGate` (`main.swift:901`) therefore answers "which stretch of the recent past had no excluded app in front", not "is one in front now": samples are **held** (`AudioSegmentWriter.hold`) until a check vouches for the moment they arrived, and dropped otherwise. Writing first and cutting on the next check would leave every sample since the previous check inside a file the daemon imports and transcribes. The frontmost app is polled (100 ms — latency, not exposure) because the main thread blocks in `readLine` and never services a run loop, so `NSWorkspace` notifications would not arrive; the helper also holds all audio until the daemon's list arrives, since an app in front before that cannot be judged. - Requires **macOS 15** (`Package.swift:6`) while the rest of the app targets macOS 14 — intentional, not a bug. diff --git a/apps/AfterRayCaptureShim/Sources/AfterRayCaptureShim/main.swift b/apps/AfterRayCaptureShim/Sources/AfterRayCaptureShim/main.swift index 5a9358f..1afa973 100644 --- a/apps/AfterRayCaptureShim/Sources/AfterRayCaptureShim/main.swift +++ b/apps/AfterRayCaptureShim/Sources/AfterRayCaptureShim/main.swift @@ -2,6 +2,7 @@ import AfterRayCapturePolicy import ApplicationServices import AppKit +import ColorSync import CoreGraphics import CoreMedia import Foundation @@ -28,12 +29,14 @@ private struct Options { let audioSegmentSeconds: Double let jpegQuality: Double let recordAudio: Bool + let displayUUID: String? static func parse(_ arguments: [String]) throws -> Self { var outputDirectory: URL? var audioSegmentSeconds = 300.0 var jpegQuality = 0.95 var recordAudio = true + var displayUUID: String? var index = 1 while index < arguments.count { let key = arguments[index] @@ -59,6 +62,12 @@ private struct Options { throw ShimError.invalidArguments("JPEG quality must be between zero and one") } jpegQuality = parsed + case "--display-uuid": + let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { + throw ShimError.invalidArguments("display UUID must not be empty") + } + displayUUID = cleaned.uppercased() default: throw ShimError.invalidArguments("unknown option \(key)") } @@ -71,7 +80,8 @@ private struct Options { outputDirectory: outputDirectory, audioSegmentSeconds: audioSegmentSeconds, jpegQuality: jpegQuality, - recordAudio: recordAudio + recordAudio: recordAudio, + displayUUID: displayUUID ) } } @@ -1332,8 +1342,24 @@ private enum AfterRayCaptureShim { false, onScreenWindowsOnly: true ) - guard let display = content.displays.first else { throw ShimError.noDisplay } - log("got display id=\(display.displayID) \(display.width)x\(display.height) apps=\(content.applications.count)") + guard !content.displays.isEmpty else { throw ShimError.noDisplay } + let preferredDisplay = options.displayUUID.flatMap { preferredUUID in + content.displays.first { + displayUUID(for: $0.displayID) == preferredUUID + } + } + let display = preferredDisplay + ?? content.displays.first { $0.displayID == CGMainDisplayID() } + ?? content.displays[0] + let displayFallbackMessage = options.displayUUID.flatMap { requestedUUID in + preferredDisplay == nil + ? "Display \(requestedUUID) is unavailable; capturing the main display instead" + : nil + } + log( + "got display id=\(display.displayID) uuid=\(displayUUID(for: display.displayID) ?? "unknown") " + + "\(display.width)x\(display.height) apps=\(content.applications.count)" + ) let configuration = SCStreamConfiguration() configuration.width = display.width @@ -1378,6 +1404,9 @@ private enum AfterRayCaptureShim { try await stream.startCapture() log("startCapture returned, sending ready") events.send(.ready(display: display)) + if let displayFallbackMessage { + events.send(.warning(code: "display_unavailable", message: displayFallbackMessage)) + } let decoder = JSONDecoder() while let line = readLine(strippingNewline: true) { @@ -1447,3 +1476,9 @@ private func nativePixelSize(for display: SCDisplay) -> (width: Int, height: Int height: Int((CGFloat(display.height) * scale).rounded()) ) } + +private func displayUUID(for displayID: CGDirectDisplayID) -> String? { + guard let unmanaged = CGDisplayCreateUUIDFromDisplayID(displayID) else { return nil } + let uuid = unmanaged.takeRetainedValue() + return (CFUUIDCreateString(nil, uuid) as String).uppercased() +} diff --git a/context/capture-pipeline.md b/context/capture-pipeline.md index 5367ce7..7336a0a 100644 --- a/context/capture-pipeline.md +++ b/context/capture-pipeline.md @@ -9,6 +9,7 @@ End-to-end map of how a captured frame becomes searchable, summarizable history. - Screen capture is **not** in Rust. `apps/AfterRayCaptureShim` is a standalone SwiftPM package (macOS 15, not a target of the root `Package.swift`) using ScreenCaptureKit; the whole shim is one file, `Sources/AfterRayCaptureShim/main.swift`. - Pull-based: Rust decides timing. stdin commands `capture_screen` (requires `request_id`) and `stop` (main.swift:962-990); stdout carries JSON-line `Event`s only (`ready`/`artifact`/`warning`/`failed`/`stopped`); logs go to stderr. - Output dir is `0700`, artifact files `0600`; the shim excludes AfterRay's own windows from capture. +- Display selection is persisted as a stable ColorSync UUID. Empty follows the macOS main display; a concrete UUID selects that display. The shim resolves the UUID against `SCShareableContent`, falls back to the main display when it is unavailable, and uses the same display filter for its audio stream and pull-based screenshots. This remains single-display capture. - The shim exists because the Rust workspace denies `unsafe_code` and ScreenCaptureKit delegates need unsafe FFI. Build it with `make capture-shim`. ## 2. Shim process ownership — afterray-platform-macos @@ -63,3 +64,12 @@ End-to-end map of how a captured frame becomes searchable, summarizable history. - `afterray-core` is only two trait definitions — the real store is `afterray-store::Vault`, the real capture is `MacOsCaptureBackend`. - Background LLM submitters (T2, backfills, agent loops) must use `JobPriority::Background` and multi-round loops must hold `ModelQueue::hold_llm_lease()` (queue.rs:351), or rounds starve behind rivals. - Timestamps are epoch-ms `i64` everywhere; "day" is local-calendar; slot alignment is wall-clock 30-minute boundaries. + +## Multi-display extension + +- Keep one scheduler tick and one audio stream. Never duplicate system/microphone audio per display. +- Replace the singular screen artifact with one atomic capture-batch event: one `request_id`, one timestamp, and one child image per display carrying stable UUID, pixel size, and desktop frame. Import the batch transactionally; timestamp-nearest pairing is ambiguous once several images share a tick. +- Store images separately and compose a desktop mosaic only in Recall. Different scale factors and display arrangements make a single stitched source image a poor storage and OCR format. +- Apply privacy before committing the batch. App exclusions should enter every `SCContentFilter`; browser/private-window policy must either classify each visible browser window or conservatively drop the whole batch when it cannot. +- Persist selection as `main | selected(UUID set) | all`. Keep unavailable selected UUIDs so reconnecting a display restores intent; `all` resolves against the topology observed for each batch. +- Legacy moments have no display metadata and should decode as a one-image legacy batch rather than requiring a destructive migration. diff --git a/context/wire-protocol.md b/context/wire-protocol.md index b0027d9..11e8c00 100644 --- a/context/wire-protocol.md +++ b/context/wire-protocol.md @@ -12,7 +12,7 @@ AfterRay has **three separate JSON protocols**: the versioned control socket bet - artifact reads (`ReadArtifact` / `ReadGopSegment` / `ReadGopFrame` / `ReadThumbnail`) — a JSON header line (`ArtifactMeta`) followed by exactly `byte_length` raw bytes; - `ChatStream` — NDJSON `ChatStreamEvent` lines until `done`/`error`. - Binary/streaming requests are intercepted in the daemon's `handle` loop (main.rs:529) *before* `dispatch`; `dispatch` fails them if reached. New binary or streaming requests must follow that split. -- Swift mirror: `swift/AfterRayRecall/Sources/DaemonClient.swift` — `UnixSocketDaemonClient` (line 148, actor), a hand-declared `WireRequest` (line 442) with snake_case CodingKeys, and `protocolVersion = 10` (line 175) enforced on every response (lines 426, 790). **Bump Rust and Swift together — there is no negotiation; a mismatch fails every request with `protocolMismatch`.** +- Swift mirror: `swift/AfterRayRecall/Sources/DaemonClient.swift` — `UnixSocketDaemonClient` (actor), a hand-declared `WireRequest` with snake_case CodingKeys, and `protocolVersion = 11` enforced on every response. **Bump Rust and Swift together — there is no negotiation; a mismatch fails every request with `protocolMismatch`.** - Evolution rules: additive-only; new optional fields use `#[serde(default, skip_serializing_if = "Option::is_none")]`. Never rename variants/fields — the `*_wire_shape_is_stable` tests in protocol lib.rs pin exact JSON bytes. For enums persisted in settings, follow `LlmProvider`: lenient custom `Deserialize` mapping retired/unknown labels to the default, strict serialization. Mirror every new field in Swift's `WireRequest` and add a wire-shape test (Swift side: `DaemonWireTests` / `ChatWireTests`). - Socket path resolution lives only in `crates/afterray-protocol/src/socket.rs` (`default_socket_path`, line 22): `AFTERRAY_SOCKET` env → `/.afterray-dev/afterray.sock` (only when the executable sits under `target/{debug,release}`) → `~/Library/Application Support/AfterRay/afterray.sock`. Daemon, CLI, and app must all resolve through this — they used to diverge. - Security: the daemon binds the socket `0600` inside a `0700` directory, rejects symlink/non-socket/foreign-owned paths, and re-checks the peer uid per connection (`bind_control_socket`, afterrayd main.rs:57; peer check main.rs:251). Artifact bytes travel the socket **already decrypted** — the filesystem boundary is the entire access control. `ArtifactPayload` zeroizes its bytes on `Drop` (protocol lib.rs:761). diff --git a/crates/afterray-cli/src/main.rs b/crates/afterray-cli/src/main.rs index 2366a18..fdfc1ea 100644 --- a/crates/afterray-cli/src/main.rs +++ b/crates/afterray-cli/src/main.rs @@ -448,6 +448,7 @@ async fn request_from_command( summary_language, } => Request::UpdateSettings { record_audio: None, + capture_display_uuid: None, ui_language, summary_language, storage_limit_bytes: None, diff --git a/crates/afterray-platform-macos/AGENTS.md b/crates/afterray-platform-macos/AGENTS.md index a2b88b8..e6c2fb4 100644 --- a/crates/afterray-platform-macos/AGENTS.md +++ b/crates/afterray-platform-macos/AGENTS.md @@ -5,6 +5,7 @@ macOS platform glue for the daemon: owns the `AfterRayCaptureShim` child process ## Key anchors - `lib.rs:151 MacOsCaptureBackend` — spawns/owns the shim child; commands `capture_screen`/`set_excluded_bundles`/`stop` to stdin, `CaptureEvent` stream (`ready`/`artifact`/`warning`/`failed`/`stopped`) from stdout. Bounded channel of 128 (`EVENT_BUFFER_CAPACITY`, lib.rs:31) for backpressure; single-consumer `next_event`. +- `CaptureConfig.capture_display_uuid` and `set_capture_display_uuid` pass the persisted ColorSync UUID as `--display-uuid`; empty means the macOS main display. Changing it is applied by the daemon restarting the shim. - `set_excluded_bundle_ids` — remembers the list and pushes it to a running shim; `start_capture` writes it into the child's stdin *before* returning, so the helper has it before the first audio sample buffer. Screen exclusions are not sent here — they stay in the daemon. - `lib.rs:108 ArtifactKind` — `screen | system_audio | microphone | accessibility`. - `power.rs` — `on_ac_power`, `battery_fraction`, `seconds_since_user_input`, `load_per_core`, `apply_background_qos` (used by the T2 gate and the GOP packer thread). diff --git a/crates/afterray-platform-macos/src/lib.rs b/crates/afterray-platform-macos/src/lib.rs index 2c35377..b07b5ee 100644 --- a/crates/afterray-platform-macos/src/lib.rs +++ b/crates/afterray-platform-macos/src/lib.rs @@ -40,6 +40,8 @@ pub struct CaptureConfig { pub audio_segment_seconds: u64, pub jpeg_quality: f64, pub record_audio: bool, + /// Stable `ColorSync` display UUID. Empty follows the macOS main display. + pub capture_display_uuid: String, } impl CaptureConfig { @@ -51,6 +53,7 @@ impl CaptureConfig { audio_segment_seconds: 300, jpeg_quality: 0.95, record_audio: true, + capture_display_uuid: String::new(), } } @@ -162,6 +165,7 @@ struct RunningShim { pub struct MacOsCaptureBackend { config: CaptureConfig, record_audio: AtomicBool, + capture_display_uuid: std::sync::Mutex, excluded_bundle_ids: std::sync::Mutex>, running: Mutex>, events_tx: mpsc::Sender>, @@ -173,9 +177,11 @@ impl MacOsCaptureBackend { pub fn new(config: CaptureConfig) -> Arc { let (events_tx, events_rx) = mpsc::channel(EVENT_BUFFER_CAPACITY); let record_audio = AtomicBool::new(config.record_audio); + let capture_display_uuid = config.capture_display_uuid.clone(); Arc::new(Self { config, record_audio, + capture_display_uuid: std::sync::Mutex::new(capture_display_uuid), excluded_bundle_ids: std::sync::Mutex::new(Vec::new()), running: Mutex::new(None), events_tx, @@ -192,6 +198,21 @@ impl MacOsCaptureBackend { self.record_audio.load(Ordering::Relaxed) } + pub fn set_capture_display_uuid(&self, uuid: String) { + *self + .capture_display_uuid + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = uuid; + } + + #[must_use] + pub fn capture_display_uuid(&self) -> String { + self.capture_display_uuid + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + /// Replaces the audio exclusion list and pushes it to a running helper. /// /// The list is remembered so that [`Self::start_capture`] can hand it to @@ -262,11 +283,20 @@ impl MacOsCaptureBackend { if !self.record_audio() { command.arg("--no-audio"); } + let display_uuid = self.capture_display_uuid(); + if !display_uuid.is_empty() { + command.arg("--display-uuid").arg(&display_uuid); + } eprintln!( - "capture: spawning {} --output-dir {} audio={}", + "capture: spawning {} --output-dir {} audio={} display={}", self.config.shim_path.display(), self.config.output_dir.display(), - self.record_audio() + self.record_audio(), + if display_uuid.is_empty() { + "main" + } else { + &display_uuid + } ); let mut child = command .stdin(Stdio::piped()) @@ -442,6 +472,14 @@ mod tests { )); } + #[test] + fn display_selection_defaults_to_main_and_can_be_replaced() { + let backend = MacOsCaptureBackend::new(CaptureConfig::new("shim", "/tmp/output")); + assert!(backend.capture_display_uuid().is_empty()); + backend.set_capture_display_uuid("DISPLAY-UUID".to_owned()); + assert_eq!(backend.capture_display_uuid(), "DISPLAY-UUID"); + } + #[test] fn command_is_one_json_line() { let bytes = serde_json::to_vec(&ShimCommand::CaptureScreen { diff --git a/crates/afterray-protocol/src/lib.rs b/crates/afterray-protocol/src/lib.rs index 8fcbd67..bb7a91d 100644 --- a/crates/afterray-protocol/src/lib.rs +++ b/crates/afterray-protocol/src/lib.rs @@ -14,8 +14,9 @@ pub const DEFAULT_STORAGE_LIMIT_BYTES: u64 = 100_000_000_000; /// runs to completion with nothing said. 8 adds `ChatAbort` and the `started`, /// `usage`, `progress` and `compaction` stream events. 9 adds /// `CaptureSetPaused`. 10 adds `CancelModelDownload`, which drops one pack from -/// the download queue instead of tearing the whole queue down. -pub const PROTOCOL_VERSION: u32 = 10; +/// the download queue instead of tearing the whole queue down. 11 adds the +/// persisted capture-display UUID. +pub const PROTOCOL_VERSION: u32 = 11; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -199,6 +200,10 @@ pub enum Request { UpdateSettings { #[serde(skip_serializing_if = "Option::is_none")] record_audio: Option, + /// Stable `ColorSync` UUID of one display, or an empty string to follow + /// the macOS main display. `None` leaves the preference unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + capture_display_uuid: Option, #[serde(default, skip_serializing_if = "Option::is_none")] ui_language: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -435,6 +440,10 @@ pub struct AppSettings { pub model_dir: String, pub record_audio: bool, pub capture_interval_seconds: u64, + /// Stable `ColorSync` UUID of the selected display. Empty follows the macOS + /// main display whenever a capture helper starts. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub capture_display_uuid: String, #[serde(default = "default_storage_limit_bytes")] pub storage_limit_bytes: u64, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1114,6 +1123,7 @@ mod tests { assert_eq!( serde_json::to_string(&Request::UpdateSettings { record_audio: Some(false), + capture_display_uuid: None, ui_language: None, summary_language: None, storage_limit_bytes: None, @@ -1131,6 +1141,7 @@ mod tests { assert_eq!( serde_json::to_string(&Request::UpdateSettings { record_audio: None, + capture_display_uuid: None, ui_language: None, summary_language: None, storage_limit_bytes: None, @@ -1153,6 +1164,24 @@ mod tests { .unwrap(), r#"{"type":"llm_probe","provider":"ollama"}"# ); + assert_eq!( + serde_json::to_string(&Request::UpdateSettings { + record_audio: None, + capture_display_uuid: Some("2B3C-display".into()), + ui_language: None, + summary_language: None, + storage_limit_bytes: None, + excluded_bundle_ids: None, + excluded_domains: None, + llm_provider: None, + llm_base_url: None, + llm_model: None, + llm_api_key: None, + model_download_endpoint: None, + }) + .unwrap(), + r#"{"type":"update_settings","capture_display_uuid":"2B3C-display"}"# + ); } #[test] @@ -1165,6 +1194,7 @@ mod tests { assert!(settings.llm_base_url.is_empty()); assert!(settings.llm_model.is_empty()); assert!(!settings.llm_api_key_set); + assert!(settings.capture_display_uuid.is_empty()); assert_eq!(settings.storage_limit_bytes, DEFAULT_STORAGE_LIMIT_BYTES); assert!( settings.model_download_endpoint.is_empty(), @@ -1188,6 +1218,7 @@ mod tests { fn storage_limit_update_wire_shape_is_stable() { let json = serde_json::to_string(&Request::UpdateSettings { record_audio: None, + capture_display_uuid: None, ui_language: None, summary_language: None, storage_limit_bytes: Some(250_000_000_000), diff --git a/crates/afterrayd/src/main.rs b/crates/afterrayd/src/main.rs index 2119c20..8c229f8 100644 --- a/crates/afterrayd/src/main.rs +++ b/crates/afterrayd/src/main.rs @@ -173,6 +173,7 @@ async fn main() -> anyhow::Result<()> { ); let mut capture_config = CaptureConfig::new(shim_path, staging_dir.clone()); capture_config.record_audio = persisted.record_audio; + capture_config.capture_display_uuid = persisted.capture_display_uuid.clone(); let capture = MacOsCaptureBackend::new(capture_config); let worker_path = std::env::var_os("AFTERRAY_MODEL_WORKER").map_or_else( @@ -469,6 +470,9 @@ struct AppState { struct PersistedSettings { #[serde(default = "default_record_audio")] record_audio: bool, + /// Empty follows the main display; otherwise a stable ColorSync UUID. + #[serde(default)] + capture_display_uuid: String, #[serde(default = "default_storage_limit_bytes")] storage_limit_bytes: u64, #[serde(default = "default_excluded_bundle_ids")] @@ -538,6 +542,7 @@ impl Default for PersistedSettings { fn default() -> Self { Self { record_audio: true, + capture_display_uuid: String::new(), storage_limit_bytes: DEFAULT_STORAGE_LIMIT_BYTES, excluded_bundle_ids: default_excluded_bundle_ids(), excluded_domains: Vec::new(), @@ -904,6 +909,7 @@ async fn dispatch(request: Request, state: &Arc) -> Response { Request::Settings => Response::success(current_settings(state)), Request::UpdateSettings { record_audio, + capture_display_uuid, ui_language, summary_language, storage_limit_bytes, @@ -919,6 +925,7 @@ async fn dispatch(request: Request, state: &Arc) -> Response { state, SettingsPatch { record_audio, + capture_display_uuid, ui_language, summary_language, storage_limit_bytes, @@ -1225,6 +1232,7 @@ fn current_settings(state: &AppState) -> AppSettings { model_dir: model_directory().display().to_string(), record_audio: state.capture.record_audio(), capture_interval_seconds: state.capture_interval.as_secs(), + capture_display_uuid: state.capture.capture_display_uuid(), storage_limit_bytes: state.store.storage_limit_bytes(), excluded_bundle_ids: state .excluded_bundle_ids @@ -1269,6 +1277,7 @@ fn persisted_settings(state: &AppState) -> PersistedSettings { let llm = current_llm_config(state); PersistedSettings { record_audio: state.capture.record_audio(), + capture_display_uuid: state.capture.capture_display_uuid(), storage_limit_bytes: state.store.storage_limit_bytes(), excluded_bundle_ids: state .excluded_bundle_ids @@ -1305,6 +1314,7 @@ fn persisted_settings(state: &AppState) -> PersistedSettings { /// one parameter as the surface grows. struct SettingsPatch { record_audio: Option, + capture_display_uuid: Option, ui_language: Option, summary_language: Option, storage_limit_bytes: Option, @@ -1320,6 +1330,7 @@ struct SettingsPatch { async fn update_settings(state: &Arc, patch: SettingsPatch) -> Response { let SettingsPatch { record_audio, + capture_display_uuid, ui_language, summary_language, storage_limit_bytes, @@ -1331,6 +1342,25 @@ async fn update_settings(state: &Arc, patch: SettingsPatch) -> Respons llm_api_key, model_download_endpoint, } = patch; + if let Some(uuid) = capture_display_uuid { + let cleaned = uuid.trim().to_ascii_uppercase(); + if !cleaned.is_empty() && Uuid::parse_str(&cleaned).is_err() { + return Response::failure("capture display UUID is invalid"); + } + let previous = state.capture.capture_display_uuid(); + state.capture.set_capture_display_uuid(cleaned.clone()); + if let Err(error) = persist_current_settings(state) { + state.capture.set_capture_display_uuid(previous); + return Response::failure(format!("could not save display preference: {error}")); + } + if previous != cleaned + && let Err(error) = restart_capture_runtime(state).await + { + return Response::failure(format!( + "display preference saved, but capture could not restart: {error}" + )); + } + } if let Some(endpoint) = model_download_endpoint { let cleaned = endpoint.trim().trim_end_matches('/').to_owned(); // Same origin policy as the LLM endpoint: https, or plain http only to @@ -3917,6 +3947,25 @@ mod tests { assert!(legacy.model_download_endpoint.is_empty()); } + #[test] + fn capture_display_round_trips_and_legacy_settings_follow_main() { + let directory = tempfile::tempdir().unwrap(); + let settings = PersistedSettings { + capture_display_uuid: "4E4A790B-74CE-47DE-A62A-1F0F2F79A958".to_owned(), + ..PersistedSettings::default() + }; + save_persisted_settings(directory.path(), &settings).unwrap(); + let reloaded = load_persisted_settings(directory.path()); + assert_eq!(reloaded.capture_display_uuid, settings.capture_display_uuid); + + std::fs::write(settings_path(directory.path()), br#"{"record_audio":true}"#).unwrap(); + assert!( + load_persisted_settings(directory.path()) + .capture_display_uuid + .is_empty() + ); + } + /// The field is gone from what we write but has to survive what we read, /// or a user upgrading from V0 silently loses their configured key. #[test] diff --git a/swift/AfterRayMockData/Sources/SettingsPreviewModel.swift b/swift/AfterRayMockData/Sources/SettingsPreviewModel.swift index ec93673..d0e6ae7 100644 --- a/swift/AfterRayMockData/Sources/SettingsPreviewModel.swift +++ b/swift/AfterRayMockData/Sources/SettingsPreviewModel.swift @@ -23,11 +23,36 @@ public final class SettingsPreviewModel: ObservableObject, AfterRaySettingsModel @Published public var downloadRateBytesPerSecond: Double? @Published public var isControllingDownload = false @Published public var isUpdatingAudio = false + @Published public var isUpdatingCaptureDisplay = false @Published public var isUpdatingStorageLimit = false @Published public var isUpdatingLanguage = false @Published public var isUpdatingExclusions = false @Published public var isClearingHistory = false @Published public var recordAudio = true + @Published public var captureDisplays: [CaptureDisplayOption] = [ + CaptureDisplayOption( + uuid: "", + name: "Built-in Display", + detail: "3024 × 1964", + isMain: true, + pixelWidth: 3024, + pixelHeight: 1964 + ), + CaptureDisplayOption( + uuid: "DISPLAY-STUDIO", + name: "Studio Display", + detail: "5120 × 2880", + pixelWidth: 5120, + pixelHeight: 2880 + ), + CaptureDisplayOption( + uuid: "DISPLAY-PORTRAIT", + name: "Portrait Display", + detail: "2160 × 3840", + pixelWidth: 2160, + pixelHeight: 3840 + ), + ] @Published public var excludedBundleIds: [String] = [] @Published public var excludedDomains: [String] = [] @Published public var llmProbe: LlmEndpointStatus? = LlmEndpointStatus( @@ -142,6 +167,15 @@ public final class SettingsPreviewModel: ObservableObject, AfterRaySettingsModel message = enabled ? "Audio recording is on." : "Audio recording is off." } + public func setCaptureDisplay(uuid: String) async { + guard let current = settings else { return } + isUpdatingCaptureDisplay = true + settings = replacing(current, captureDisplayUUID: uuid) + isUpdatingCaptureDisplay = false + let name = captureDisplays.first { $0.uuid == uuid }?.name ?? "selected display" + message = uuid.isEmpty ? "Preview follows the main display." : "Preview captures \(name)." + } + public func setStorageLimitBytes(_ bytes: UInt64) async { guard let current = settings else { return } isUpdatingStorageLimit = true @@ -316,6 +350,7 @@ public final class SettingsPreviewModel: ObservableObject, AfterRaySettingsModel modelDir: current.modelDir, recordAudio: current.recordAudio, captureIntervalSeconds: current.captureIntervalSeconds, + captureDisplayUUID: current.captureDisplayUUID, storageLimitBytes: current.storageLimitBytes, excludedBundleIds: current.excludedBundleIds, protectedBundleIds: current.protectedBundleIds, @@ -475,6 +510,7 @@ public final class SettingsPreviewModel: ObservableObject, AfterRaySettingsModel modelDir: current?.modelDir ?? modelDirectoryPath, recordAudio: recordAudio, captureIntervalSeconds: 10, + captureDisplayUUID: current?.captureDisplayUUID ?? "", storageLimitBytes: current?.storageLimitBytes ?? AppSettings.defaultStorageLimitBytes, excludedBundleIds: current?.excludedBundleIds ?? excludedBundleIds, protectedBundleIds: current?.protectedBundleIds ?? [], @@ -491,6 +527,7 @@ public final class SettingsPreviewModel: ObservableObject, AfterRaySettingsModel private func replacing( _ current: AppSettings, + captureDisplayUUID: String? = nil, storageLimitBytes: UInt64? = nil, uiLanguage: String? = nil, summaryLanguage: String? = nil @@ -500,6 +537,7 @@ public final class SettingsPreviewModel: ObservableObject, AfterRaySettingsModel modelDir: current.modelDir, recordAudio: current.recordAudio, captureIntervalSeconds: current.captureIntervalSeconds, + captureDisplayUUID: captureDisplayUUID ?? current.captureDisplayUUID, storageLimitBytes: storageLimitBytes ?? current.storageLimitBytes, excludedBundleIds: current.excludedBundleIds, protectedBundleIds: current.protectedBundleIds, diff --git a/swift/AfterRayRecall/AGENTS.md b/swift/AfterRayRecall/AGENTS.md index cc95965..76eab48 100644 --- a/swift/AfterRayRecall/AGENTS.md +++ b/swift/AfterRayRecall/AGENTS.md @@ -18,7 +18,7 @@ Dependency-free (system frameworks only) SwiftUI library holding everything cros ## Invariants - The UI never opens the database or reads encryption keys (`docs/development.md:112-113`) — everything arrives via `UnixSocketDaemonClient`. -- `protocolVersion` must stay in lockstep with `PROTOCOL_VERSION: u32 = 10` (`crates/afterray-protocol/src/lib.rs:18`); bump both on any wire change or every request fails with `protocolMismatch`. +- `protocolVersion` must stay in lockstep with `PROTOCOL_VERSION: u32 = 11` (`crates/afterray-protocol/src/lib.rs:18`); bump both on any wire change or every request fails with `protocolMismatch`. - Concurrency: stores are `@MainActor` `ObservableObject`s; socket client and image repository are actors; daemon I/O runs in `Task.detached(priority: .userInitiated)` (`DaemonClient.swift:394,416`). Never block the main thread — the HangWatchdog kills the app. - Unary socket reads have a 30s receive deadline (`DaemonClient.swift:587`, postmortem in the comment above); streaming reads deliberately stay deadline-free. Do not remove. - Every async load guards completion with a generation counter (`sensitiveGeneration`, `RecallStore.swift:16`); new load paths must follow the same capture-and-compare pattern. diff --git a/swift/AfterRayRecall/Sources/AfterRaySettingsChrome.swift b/swift/AfterRayRecall/Sources/AfterRaySettingsChrome.swift index f150150..794d2f6 100644 --- a/swift/AfterRayRecall/Sources/AfterRaySettingsChrome.swift +++ b/swift/AfterRayRecall/Sources/AfterRaySettingsChrome.swift @@ -16,9 +16,11 @@ public protocol AfterRaySettingsModeling: ObservableObject { var downloadRateBytesPerSecond: Double? { get } var isControllingDownload: Bool { get } var isUpdatingAudio: Bool { get } + var isUpdatingCaptureDisplay: Bool { get } var isUpdatingStorageLimit: Bool { get } var isUpdatingLanguage: Bool { get } var recordAudio: Bool { get } + var captureDisplays: [CaptureDisplayOption] { get } var excludedBundleIds: [String] { get } var excludedDomains: [String] { get } var isUpdatingExclusions: Bool { get } @@ -47,6 +49,7 @@ public protocol AfterRaySettingsModeling: ObservableObject { func refresh() async func setRecordAudio(_ enabled: Bool) async + func setCaptureDisplay(uuid: String) async func setStorageLimitBytes(_ bytes: UInt64) async func setUiLanguage(_ code: String) async func setSummaryLanguage(_ code: String) async @@ -530,6 +533,12 @@ public struct AfterRaySettingsView: View { } SettingsSection(title: "Capture") { + CaptureDisplayPicker( + displays: model.captureDisplays, + selection: captureDisplayBinding, + isUpdating: model.isUpdatingCaptureDisplay + ) + SettingsSeparator() SettingsRow( title: "Record audio", subtitle: "System audio and microphone for transcripts. Recordings already in the vault stay." @@ -766,6 +775,13 @@ public struct AfterRaySettingsView: View { ) } + private var captureDisplayBinding: Binding { + Binding( + get: { model.settings?.captureDisplayUUID ?? "" }, + set: { uuid in Task { await model.setCaptureDisplay(uuid: uuid) } } + ) + } + private var uiLanguageBinding: Binding { Binding( get: { model.settings?.uiLanguage ?? AppSettings.defaultLanguage }, @@ -1749,6 +1765,19 @@ public struct AfterRaySettingsView: View { // MARK: - Building blocks +func captureDisplayPreviewSize(aspectRatio: CGFloat, container: CGSize) -> CGSize { + guard + aspectRatio.isFinite, + aspectRatio > 0, + container.width > 0, + container.height > 0 + else { return .zero } + if aspectRatio > container.width / container.height { + return CGSize(width: container.width, height: container.width / aspectRatio) + } + return CGSize(width: container.height * aspectRatio, height: container.height) +} + /// Header + one card. The card wraps `content` in a single container, so a /// section with several children stays one surface instead of one card each. private struct SettingsSection: View { @@ -1849,6 +1878,166 @@ private struct SettingsRow: View { } } +private struct CaptureDisplayPicker: View { + let displays: [CaptureDisplayOption] + @Binding var selection: String + let isUpdating: Bool + + private let columns = [ + GridItem(.adaptive(minimum: 138, maximum: 172), spacing: 12, alignment: .top), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("Display") + .font(.settingsRowTitle) + .foregroundStyle(SettingsPalette.label) + Text("Choose which display AfterRay includes in screenshots.") + .font(.settingsRowSubtitle) + .foregroundStyle(SettingsPalette.secondaryLabel) + } + Spacer(minLength: 12) + if isUpdating { + ProgressView().controlSize(.mini) + } + } + + LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { + ForEach(displays) { display in + displayButton(display) + } + } + } + .padding(.horizontal, SettingsMetrics.rowInset) + .padding(.vertical, 13) + } + + private func displayButton(_ display: CaptureDisplayOption) -> some View { + let isSelected = display.uuid == selection + return Button { + selection = display.uuid + } label: { + VStack(spacing: 8) { + DisplayWallpaperPreview(display: display, isSelected: isSelected) + VStack(spacing: 2) { + Text(display.name) + .font(.settingsRowTitle) + .foregroundStyle(SettingsPalette.label) + .lineLimit(1) + Text(display.isMain ? "Main Display" : (display.detail ?? "Display")) + .font(.settingsCaption) + .foregroundStyle(SettingsPalette.tertiaryLabel) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isUpdating) + .accessibilityLabel(display.name) + .accessibilityValue(isSelected ? "Selected" : "") + } +} + +private struct DisplayWallpaperPreview: View { + let display: CaptureDisplayOption + let isSelected: Bool + + var body: some View { + GeometryReader { proxy in + let previewSize = captureDisplayPreviewSize( + aspectRatio: displayAspectRatio, + container: proxy.size + ) + previewSurface(size: previewSize) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + .frame(height: 82) + } + + private func previewSurface(size: CGSize) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(SettingsPalette.controlFill) + if let wallpaper { + Image(nsImage: wallpaper) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + LinearGradient( + colors: [SettingsPalette.controlFill, SettingsPalette.cardStroke.opacity(0.7)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Image(systemName: "display") + .font(.system(size: 24, weight: .light)) + .foregroundStyle(SettingsPalette.tertiaryLabel) + } + } + .frame(width: size.width, height: size.height) + .clipShape(previewShape) + .overlay { + previewShape.strokeBorder( + isSelected ? SettingsPalette.accent : SettingsPalette.cardStroke, + lineWidth: isSelected ? 3 : 1 + ) + } + .overlay(alignment: .topTrailing) { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white, SettingsPalette.accent) + .padding(6) + } + } + .shadow(color: .black.opacity(0.16), radius: 3, y: 1) + } + + private var previewShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 8, style: .continuous) + } + + private var displayAspectRatio: CGFloat { + guard + let width = display.pixelWidth, + let height = display.pixelHeight, + width > 0, + height > 0 + else { return 16.0 / 10.0 } + return CGFloat(width) / CGFloat(height) + } + + private var wallpaper: NSImage? { + guard let displayID = display.displayID else { + return Self.fixtureWallpaper + } + guard + let screen = NSScreen.screens.first(where: { screen in + (screen.deviceDescription[.init("NSScreenNumber")] as? NSNumber)?.uint32Value + == displayID + }), + let url = NSWorkspace.shared.desktopImageURL(for: screen) + else { return nil } + return NSImage(contentsOf: url) + } + + /// Visual Lab has no attached-screen identity. A large deterministic image + /// keeps its preview on the same layout path as a real desktop wallpaper. + private static let fixtureWallpaper = NSImage( + size: NSSize(width: 1920, height: 1080), + flipped: false + ) { bounds in + NSGradient(colors: [ + NSColor(calibratedRed: 0.14, green: 0.18, blue: 0.26, alpha: 1), + NSColor(calibratedRed: 0.52, green: 0.26, blue: 0.30, alpha: 1), + ])?.draw(in: bounds, angle: -18) + return true + } +} + private struct SettingsPathRow: View { let title: String let path: String diff --git a/swift/AfterRayRecall/Sources/DaemonClient.swift b/swift/AfterRayRecall/Sources/DaemonClient.swift index 74056c1..6aec2dd 100644 --- a/swift/AfterRayRecall/Sources/DaemonClient.swift +++ b/swift/AfterRayRecall/Sources/DaemonClient.swift @@ -118,6 +118,7 @@ public protocol AfterRayDaemonServing: RecallDaemonServing, AfterRayChatServing func shutdown() async throws -> DaemonShutdownResult func modelLibrary() async throws -> ModelLibrary func settings() async throws -> AppSettings + func updateCaptureDisplay(uuid: String) async throws -> AppSettings func updateSettings( recordAudio: Bool?, excludedBundleIds: [String]?, @@ -162,6 +163,10 @@ public extension AfterRayDaemonServing { throw DaemonClientError.rejected("changing the download endpoint is not available") } + func updateCaptureDisplay(uuid _: String) async throws -> AppSettings { + throw DaemonClientError.rejected("changing the capture display is not available") + } + func updateSettings(recordAudio: Bool) async throws -> AppSettings { try await updateSettings( recordAudio: recordAudio, @@ -179,7 +184,7 @@ public extension AfterRayDaemonServing { } public actor UnixSocketDaemonClient: AfterRayDaemonServing { - public static let protocolVersion = 10 + public static let protocolVersion = 11 public nonisolated let socketPath: String public init(socketPath: String? = nil) { @@ -234,6 +239,13 @@ public actor UnixSocketDaemonClient: AfterRayDaemonServing { try await request(WireRequest(type: "settings"), as: AppSettings.self) } + public func updateCaptureDisplay(uuid: String) async throws -> AppSettings { + try await request( + WireRequest(type: "update_settings", captureDisplayUUID: uuid), + as: AppSettings.self + ) + } + public func updateSettings( recordAudio: Bool?, excludedBundleIds: [String]?, @@ -529,6 +541,7 @@ struct WireRequest: Encodable, Equatable { var dayMs: Int64? var beforeMs: Int64? var recordAudio: Bool? + var captureDisplayUUID: String? var reason: String? var paused: Bool? var packID: String? @@ -569,6 +582,7 @@ struct WireRequest: Encodable, Equatable { case dayMs = "day_ms" case beforeMs = "before_ms" case recordAudio = "record_audio" + case captureDisplayUUID = "capture_display_uuid" case reason case paused case packID = "pack_id" @@ -611,6 +625,7 @@ struct WireRequest: Encodable, Equatable { try container.encodeIfPresent(dayMs, forKey: .dayMs) try container.encodeIfPresent(beforeMs, forKey: .beforeMs) try container.encodeIfPresent(recordAudio, forKey: .recordAudio) + try container.encodeIfPresent(captureDisplayUUID, forKey: .captureDisplayUUID) try container.encodeIfPresent(reason, forKey: .reason) try container.encodeIfPresent(paused, forKey: .paused) try container.encodeIfPresent(packID, forKey: .packID) diff --git a/swift/AfterRayRecall/Sources/RecallModels.swift b/swift/AfterRayRecall/Sources/RecallModels.swift index 1fbd61c..21066ef 100644 --- a/swift/AfterRayRecall/Sources/RecallModels.swift +++ b/swift/AfterRayRecall/Sources/RecallModels.swift @@ -575,6 +575,42 @@ public struct LanguageOption: Codable, Equatable, Identifiable, Sendable { } } +public struct CaptureDisplayOption: Identifiable, Equatable, Sendable { + public static let mainDisplay = CaptureDisplayOption( + uuid: "", + name: "Main display", + detail: "Follows the display selected as main in macOS", + isMain: true + ) + + public let uuid: String + public let name: String + public let detail: String? + public let isMain: Bool + public let displayID: UInt32? + public let pixelWidth: Int? + public let pixelHeight: Int? + public var id: String { uuid.isEmpty ? "main" : uuid } + + public init( + uuid: String, + name: String, + detail: String? = nil, + isMain: Bool = false, + displayID: UInt32? = nil, + pixelWidth: Int? = nil, + pixelHeight: Int? = nil + ) { + self.uuid = uuid + self.name = name + self.detail = detail + self.isMain = isMain + self.displayID = displayID + self.pixelWidth = pixelWidth + self.pixelHeight = pixelHeight + } +} + public struct AppSettings: Codable, Equatable, Sendable { public static let defaultStorageLimitBytes: UInt64 = 100_000_000_000 public static let defaultLanguage = LanguageOption.autoCode @@ -583,6 +619,8 @@ public struct AppSettings: Codable, Equatable, Sendable { public let modelDir: String public let recordAudio: Bool public let captureIntervalSeconds: UInt64 + /// Stable ColorSync UUID. Empty follows the macOS main display. + public let captureDisplayUUID: String public let storageLimitBytes: UInt64 public let excludedBundleIds: [String] /// Credential-bearing and system apps the daemon never captures. @@ -604,6 +642,7 @@ public struct AppSettings: Codable, Equatable, Sendable { modelDir: String, recordAudio: Bool, captureIntervalSeconds: UInt64, + captureDisplayUUID: String = "", storageLimitBytes: UInt64 = Self.defaultStorageLimitBytes, excludedBundleIds: [String] = [], protectedBundleIds: [String] = [], @@ -621,6 +660,7 @@ public struct AppSettings: Codable, Equatable, Sendable { self.modelDir = modelDir self.recordAudio = recordAudio self.captureIntervalSeconds = captureIntervalSeconds + self.captureDisplayUUID = captureDisplayUUID self.storageLimitBytes = storageLimitBytes self.excludedBundleIds = excludedBundleIds self.protectedBundleIds = protectedBundleIds @@ -640,6 +680,7 @@ public struct AppSettings: Codable, Equatable, Sendable { case modelDir = "model_dir" case recordAudio = "record_audio" case captureIntervalSeconds = "capture_interval_seconds" + case captureDisplayUUID = "capture_display_uuid" case storageLimitBytes = "storage_limit_bytes" case excludedBundleIds = "excluded_bundle_ids" case protectedBundleIds = "protected_bundle_ids" @@ -660,6 +701,7 @@ public struct AppSettings: Codable, Equatable, Sendable { modelDir = try container.decode(String.self, forKey: .modelDir) recordAudio = try container.decode(Bool.self, forKey: .recordAudio) captureIntervalSeconds = try container.decode(UInt64.self, forKey: .captureIntervalSeconds) + captureDisplayUUID = try container.decodeIfPresent(String.self, forKey: .captureDisplayUUID) ?? "" storageLimitBytes = try container.decodeIfPresent(UInt64.self, forKey: .storageLimitBytes) ?? Self.defaultStorageLimitBytes excludedBundleIds = try container.decodeIfPresent([String].self, forKey: .excludedBundleIds) ?? [] diff --git a/swift/AfterRayRecall/Tests/DaemonWireTests.swift b/swift/AfterRayRecall/Tests/DaemonWireTests.swift index 040fc22..ab12bb3 100644 --- a/swift/AfterRayRecall/Tests/DaemonWireTests.swift +++ b/swift/AfterRayRecall/Tests/DaemonWireTests.swift @@ -215,6 +215,7 @@ final class DaemonWireTests: XCTestCase { XCTAssertEqual(settings.uiLanguage, "auto") XCTAssertEqual(settings.summaryLanguage, "auto") XCTAssertTrue(settings.languageOptions.isEmpty) + XCTAssertTrue(settings.captureDisplayUUID.isEmpty) let picker = settings.languagePickerOptions(selected: settings.uiLanguage) XCTAssertEqual(picker.map(\.code), ["auto"]) @@ -268,6 +269,25 @@ final class DaemonWireTests: XCTestCase { XCTAssertEqual(json["summary_language"] as? String, "ja") } + func testUpdateSettingsRequestIncludesCaptureDisplayUUID() throws { + let uuid = "4E4A790B-74CE-47DE-A62A-1F0F2F79A958" + let data = try JSONEncoder().encode( + WireRequest(type: "update_settings", captureDisplayUUID: uuid) + ) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["type"] as? String, "update_settings") + XCTAssertEqual(json["capture_display_uuid"] as? String, uuid) + } + + func testAppSettingsDecodesCaptureDisplayUUID() throws { + let uuid = "4E4A790B-74CE-47DE-A62A-1F0F2F79A958" + let json = """ + {"data_dir":"/tmp/data","model_dir":"/tmp/models","record_audio":true,"capture_interval_seconds":10,"capture_display_uuid":"\(uuid)"} + """ + let settings = try JSONDecoder().decode(AppSettings.self, from: Data(json.utf8)) + XCTAssertEqual(settings.captureDisplayUUID, uuid) + } + func testUpdateSettingsRequestIncludesStorageLimit() throws { let data = try JSONEncoder().encode( WireRequest(type: "update_settings", storageLimitBytes: 250_000_000_000) @@ -588,7 +608,7 @@ final class DaemonWireTests: XCTestCase { func testClientSpeaksTheCurrentProtocolVersion() throws { // Must move in lockstep with PROTOCOL_VERSION in afterray-protocol. - XCTAssertEqual(UnixSocketDaemonClient.protocolVersion, 10) + XCTAssertEqual(UnixSocketDaemonClient.protocolVersion, 11) } func testCaptureSetPausedRequestMatchesRustShape() throws { diff --git a/swift/AfterRayRecall/Tests/SettingsDisplayPickerTests.swift b/swift/AfterRayRecall/Tests/SettingsDisplayPickerTests.swift new file mode 100644 index 0000000..629a56c --- /dev/null +++ b/swift/AfterRayRecall/Tests/SettingsDisplayPickerTests.swift @@ -0,0 +1,27 @@ +import CoreGraphics +import XCTest + +@testable import AfterRayRecall + +final class SettingsDisplayPickerTests: XCTestCase { + func testPreviewFitsLandscapeAndPortraitInsideTheSameCell() { + let container = CGSize(width: 172, height: 82) + + let landscape = captureDisplayPreviewSize( + aspectRatio: 16.0 / 9.0, + container: container + ) + XCTAssertEqual(landscape.width, 145.78, accuracy: 0.01) + XCTAssertEqual(landscape.height, 82, accuracy: 0.01) + + let portrait = captureDisplayPreviewSize( + aspectRatio: 9.0 / 16.0, + container: container + ) + XCTAssertEqual(portrait.width, 46.13, accuracy: 0.01) + XCTAssertEqual(portrait.height, 82, accuracy: 0.01) + + XCTAssertLessThanOrEqual(landscape.width, container.width) + XCTAssertLessThanOrEqual(portrait.width, container.width) + } +}