Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/AfterRay/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions apps/AfterRay/Sources/AfterRaySettings.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AfterRayRecall
import AppKit
import ColorSync
import SwiftUI

extension Notification.Name {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion apps/AfterRayCaptureShim/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
41 changes: 38 additions & 3 deletions apps/AfterRayCaptureShim/Sources/AfterRayCaptureShim/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import AfterRayCapturePolicy
import ApplicationServices
import AppKit
import ColorSync
import CoreGraphics
import CoreMedia
import Foundation
Expand All @@ -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]
Expand All @@ -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)")
}
Expand All @@ -71,7 +80,8 @@ private struct Options {
outputDirectory: outputDirectory,
audioSegmentSeconds: audioSegmentSeconds,
jpegQuality: jpegQuality,
recordAudio: recordAudio
recordAudio: recordAudio,
displayUUID: displayUUID
)
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
}
10 changes: 10 additions & 0 deletions context/capture-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion context/wire-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 → `<checkout>/.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).
Expand Down
1 change: 1 addition & 0 deletions crates/afterray-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/afterray-platform-macos/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading