diff --git a/apps/AfterRay/Sources/AfterRayApp.swift b/apps/AfterRay/Sources/AfterRayApp.swift index 1251f08..d40a964 100644 --- a/apps/AfterRay/Sources/AfterRayApp.swift +++ b/apps/AfterRay/Sources/AfterRayApp.swift @@ -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? @@ -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() } @@ -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") } } } @@ -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 { @@ -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() diff --git a/swift/AfterRayRecall/Sources/AfterRayControlModel.swift b/swift/AfterRayRecall/Sources/AfterRayControlModel.swift index ee669fc..aca1b45 100644 --- a/swift/AfterRayRecall/Sources/AfterRayControlModel.swift +++ b/swift/AfterRayRecall/Sources/AfterRayControlModel.swift @@ -29,7 +29,7 @@ public final class AfterRayControlModel: ObservableObject { } } public var canToggleRecording: Bool { - !isChangingRecording && status?.recordingState != .stopping + !isChangingRecording } public func refreshStatus() async { @@ -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 { diff --git a/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift b/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift index 3d2a052..7455809 100644 --- a/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift +++ b/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift @@ -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)