Skip to content
Open
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
47 changes: 27 additions & 20 deletions apps/AfterRay/Sources/AfterRayApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,8 @@ private final class AfterRayMenuBar: NSObject {
private var statusItem: NSStatusItem?
private var pauseItem: NSMenuItem?
private var computeItem: NSMenuItem?
private var isRecording = false
private var recordingState: DaemonRecordingState?
private var isChangingRecording = false
private var shortcut = RecallHotKeyStore.shared.hotKey
private var preferenceObserver: NSObjectProtocol?

Expand Down Expand Up @@ -437,8 +438,8 @@ private final class AfterRayMenuBar: NSObject {
statusItem = nil
}

func setRecording(_ isRecording: Bool) {
self.isRecording = isRecording
func setRecordingState(_ recordingState: DaemonRecordingState?) {
self.recordingState = recordingState
refresh()
}

Expand All @@ -463,19 +464,20 @@ private final class AfterRayMenuBar: NSObject {

@objc private func toggleCapture() {
Task {
guard !AfterRayTerminationState.shared.isTerminating else { return }
let daemon = UnixSocketDaemonClient(socketPath: DaemonSupervisor.shared.socketPath)
do {
if isRecording {
_ = try await daemon.recordStop(reason: "menu")
isRecording = false
} else {
_ = try await daemon.recordStart()
isRecording = true
}
guard !AfterRayTerminationState.shared.isTerminating,
!isChangingRecording
else { return }
isChangingRecording = true
refresh()
defer {
isChangingRecording = false
refresh()
} catch {
AfterRayLog.error(error.localizedDescription, source: "menu")
}
let control = AfterRayServices.shared.control
let changed = await control.toggleRecording()
recordingState = control.status?.recordingState
if !changed, let message = control.message {
AfterRayLog.error(message, source: "menu")
}
}
}
Expand All @@ -502,11 +504,16 @@ private final class AfterRayMenuBar: NSObject {
guard let button = statusItem?.button else { return }
statusItem?.isVisible = true
button.image = Self.icon()
button.alphaValue = isRecording ? 1 : 0.46
let captureIsActive = switch recordingState {
case .waiting, .recording, .stopping: true
case .idle, .failed, nil: false
}
button.alphaValue = captureIsActive ? 1 : 0.46
let copy = AfterRayLocalization.shared.copy
let state = isRecording ? copy.menu.recording : copy.menu.paused
let state = captureIsActive ? copy.menu.recording : copy.menu.paused
button.toolTip = copy.menu.tooltip(state, shortcut.displayString)
pauseItem?.title = isRecording ? copy.menu.pauseCapture : copy.menu.resumeCapture
pauseItem?.title = captureIsActive ? copy.menu.pauseCapture : copy.menu.resumeCapture
pauseItem?.isEnabled = !isChangingRecording && recordingState != .stopping
}

private static func icon() -> NSImage {
Expand Down Expand Up @@ -1515,8 +1522,8 @@ private struct AfterRayRootView: View {
.onChange(of: isLive) { _, live in
if live { audioPlayer.stop() }
}
.onChange(of: control.isRecording, initial: true) { _, isRecording in
AfterRayMenuBar.shared.setRecording(isRecording)
.onChange(of: control.status?.recordingState, initial: true) { _, recordingState in
AfterRayMenuBar.shared.setRecordingState(recordingState)
}
.task {
await bootstrap()
Expand Down
8 changes: 7 additions & 1 deletion swift/AfterRayRecall/Sources/AfterRayControlModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public final class AfterRayControlModel: ObservableObject {
}
}
public var canToggleRecording: Bool {
!isChangingRecording && status?.recordingState != .stopping
!isChangingRecording
}

public func refreshStatus() async {
Expand Down Expand Up @@ -74,6 +74,12 @@ public final class AfterRayControlModel: ObservableObject {
isChangingRecording = true
defer { isChangingRecording = false }
do {
// Menu-bar and overlay actions share this model, but the daemon can
// also change state after lock/sleep or another surface's command.
// Refresh before choosing start versus stop so a stale UI snapshot
// can never send the same command twice and strand capture paused.
status = try await daemon.status()
guard status?.recordingState != .stopping else { return false }
if isCaptureSessionActive {
_ = try await daemon.recordStop(reason: "pause")
} else {
Expand Down
50 changes: 50 additions & 0 deletions swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,56 @@ final class AfterRayControlModelTests: XCTestCase {
XCTAssertEqual(commands, ["start", "stop"])
}

func testToggleRefreshesStaleRecordingStateBeforeChoosingTheCommand() async {
let daemon = ControlDaemon()
await daemon.setRecordingState(.recording)
let model = AfterRayControlModel(daemon: daemon)
await model.refreshStatus()

// Capture was stopped elsewhere after this model last refreshed. The
// next click must start from the daemon's idle state, not issue stop a
// second time from the stale local snapshot.
await daemon.setRecordingState(.idle)
let changed = await model.toggleRecording()

XCTAssertTrue(changed)
XCTAssertTrue(model.isRecording)
let commands = await daemon.recordCommands
XCTAssertEqual(commands, ["start"])
}

func testToggleRefreshesStaleIdleStateBeforeChoosingTheCommand() async {
let daemon = ControlDaemon()
let model = AfterRayControlModel(daemon: daemon)
await model.refreshStatus()

// The inverse race matters too: capture started from another surface,
// so the stale idle model must stop that live session instead of
// sending a duplicate start.
await daemon.setRecordingState(.recording)
let changed = await model.toggleRecording()

XCTAssertTrue(changed)
XCTAssertFalse(model.isRecording)
let commands = await daemon.recordCommands
XCTAssertEqual(commands, ["stop"])
}

func testToggleDoesNotStayDisabledByAStaleStoppingState() async {
let daemon = ControlDaemon()
await daemon.setRecordingState(.stopping)
let model = AfterRayControlModel(daemon: daemon)
await model.refreshStatus()

await daemon.setRecordingState(.idle)
let changed = await model.toggleRecording()

XCTAssertTrue(changed)
XCTAssertTrue(model.isRecording)
let commands = await daemon.recordCommands
XCTAssertEqual(commands, ["start"])
}

func testWaitingIsNotRecordingButSessionIsActive() async {
let daemon = ControlDaemon()
await daemon.setRecordingState(.waiting)
Expand Down