From 382ede3ac5eef7f6350b7efa7ec39c6f45371f9a Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 16:40:02 -0400 Subject: [PATCH 1/3] [agent] Give the client its own address, and hold the paste key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes on top of the Swift rewrite, all found by running it against a real deployment rather than by reading it. CLIENT ADDRESSING. AgentController hardcoded http://127.0.0.1:/dictate with the key from ~/.config/hark/key. That is the server's deployment config being used as the client's, and it cannot work for the two-machine setup the project exists to serve: the recording Mac has no server, no key file and nothing on loopback. It also fails on a SINGLE machine whose server binds a tailnet address - measured here, 127.0.0.1:8911 refuses while 100.64.66.46:8911 answers 200 - so loopback was wrong for every deployment on hand. Adds ClientConfig (~/.config/hark/client.json), and enforces the transport policy the design doc already specifies rather than assuming it: plain HTTP to loopback always; to a numeric IP only with an explicit allowPlaintext, because a tailnet is a defensible place to send plaintext but it should be a stated decision; to a hostname never, since a name resolves through something and a MagicDNS name is a hostname. Absent config falls back to the previous loopback behaviour, so an existing same-machine install is untouched. PASTE. down and up were posted back to back. A zero-duration keystroke is silently dropped by some apps - the events arrive and nothing acts on them. hs.eventtap.keyStroke, the implementation being replaced, holds 200 ms (`local keyDelay = 200000`). Same tap, same flags; the gap was the only difference. Also re-checks AXIsProcessTrusted at paste time, because a grant can be revoked or invalidated by a rebuild while the process runs, and every step before this one reports success either way - so an untrusted process was indistinguishable from a dropped event. MICROPHONE. requestAccess discarded its answer, so the agent continued without knowing. An ungranted process is not refused the device: it receives full-length buffers of zeros, indistinguishable from a quiet room until you look at the peak. Now waits for the answer, pumping the run loop rather than blocking on a semaphore whose queue might be main. Also: HARK_CLIENT_CONFIG overrides the config path, mirroring the server side. Neither homeDirectoryForCurrentUser nor NSHomeDirectory honours $HOME on macOS, so `HOME=... hark agent` silently reads the real config - which produced a round of "passing" policy checks that were all reading the same live file. Solves: hark #2 — integration findings on top of #12 Tests: his 48 SwiftPM tests still pass; policy matrix verified against the running server --- swift/Sources/HarkCore/ClientConfig.swift | 136 ++++++++++++++++++++++ swift/Sources/hark/AgentController.swift | 68 +++++++++-- 2 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 swift/Sources/HarkCore/ClientConfig.swift diff --git a/swift/Sources/HarkCore/ClientConfig.swift b/swift/Sources/HarkCore/ClientConfig.swift new file mode 100644 index 0000000..a0208a7 --- /dev/null +++ b/swift/Sources/HarkCore/ClientConfig.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Client-side configuration: which server to dictate to, and with what key. +/// +/// Distinct from `HarkConfig`, which describes how the SERVER deploys itself. +/// Conflating them is what left the agent hardcoded to +/// `http://127.0.0.1:\(config.harkPort)/dictate` with the key read from +/// `~/.config/hark/key` — correct for a single machine, and unusable for the +/// two-machine setup this project exists to serve, because on the recording +/// Mac there is no server, no key file, and nothing listening on loopback. +/// +/// Worth noting the single-machine case is not automatically loopback either: +/// a server that binds a tailnet address to serve a laptop is NOT reachable at +/// 127.0.0.1, so the machine running the server needs a real client address +/// too. +/// +/// Lives at `~/.config/hark/client.json`: +/// +/// { +/// "server": "http://100.64.66.46:8911/dictate", +/// "key": "...", +/// "allowPlaintext": true +/// } +public struct ClientConfig { + public let serverURL: URL + public let key: String + + public enum Failure: Error, CustomStringConvertible { + case unreadable(String) + case malformed(String) + case insecure(String) + + public var description: String { + switch self { + case .unreadable(let m), .malformed(let m), .insecure(let m): return m + } + } + } + + /// Test-only override, mirroring `KeyFile.pathOverride`. + public static var pathOverride: URL? + + /// HARK_CLIENT_CONFIG overrides the path, mirroring the server side's + /// HARK_CONFIG. Neither FileManager.homeDirectoryForCurrentUser nor + /// NSHomeDirectory() reliably honours $HOME on macOS — both resolve through + /// the user database — so `HOME=... hark agent` silently reads the real + /// config. That produced a round of "passing" policy checks that were all + /// reading the same live file. An explicit variable is the only honest way + /// to point this somewhere else. + public static var path: URL { + if let pathOverride { return pathOverride } + if let env = ProcessInfo.processInfo.environment["HARK_CLIENT_CONFIG"], !env.isEmpty { + return URL(fileURLWithPath: env) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/hark/client.json") + } + + private struct Wire: Decodable { + let server: String? + let key: String? + let allowPlaintext: Bool? + } + + /// Reads client.json. Falls back to the single-machine defaults — loopback + /// plus `~/.config/hark/key` — when the file is absent, so an existing + /// same-machine install keeps working untouched. + public static func load(defaultPort: Int) throws -> ClientConfig { + guard let data = try? Data(contentsOf: path) else { + let url = URL(string: "http://127.0.0.1:\(defaultPort)/dictate")! + return ClientConfig(serverURL: url, key: KeyFile.load() ?? "") + } + guard let wire = try? JSONDecoder().decode(Wire.self, from: data) else { + throw Failure.malformed("\(path.path) is not valid JSON") + } + + let raw = wire.server ?? "http://127.0.0.1:\(defaultPort)/dictate" + guard let url = URL(string: raw), let host = url.host, let scheme = url.scheme else { + throw Failure.malformed("\(path.path): \"\(raw)\" is not a usable URL") + } + + try validateTransport(url: url, host: host, scheme: scheme, + allowPlaintext: wire.allowPlaintext ?? false) + + let key = wire.key ?? KeyFile.load() ?? "" + guard !key.isEmpty else { + throw Failure.unreadable( + "no shared secret: set \"key\" in \(path.path), or place the server's " + + "~/.config/hark/key on this Mac") + } + return ClientConfig(serverURL: url, key: key) + } + + /// The transport policy from the design doc, enforced rather than assumed: + /// + /// - plain HTTP to numeric loopback is always fine — nothing leaves the host + /// - plain HTTP to a numeric IP is a defensible choice on a tailnet, but it + /// must be a STATED one, so it needs `allowPlaintext` + /// - plain HTTP to a hostname is refused outright. A name resolves through + /// something, and "the tailnet is trusted" stops being true the moment + /// the name resolves somewhere else. Use the tailnet IP. + static func validateTransport(url: URL, host: String, scheme: String, + allowPlaintext: Bool) throws { + guard scheme == "http" else { return } // https needs no argument + if isLoopback(host) { return } + + guard isNumericIP(host) else { + throw Failure.insecure( + "\(path.path): plain HTTP to the hostname \"\(host)\" is not allowed. " + + "Use the numeric address (a Tailscale MagicDNS name is a hostname), " + + "or https://") + } + guard allowPlaintext else { + throw Failure.insecure( + "\(path.path): plain HTTP to \(host) needs \"allowPlaintext\": true. " + + "Audio and transcripts cross the network unencrypted; on a tailnet " + + "that is defensible, but it should be a decision you made.") + } + } + + static func isLoopback(_ host: String) -> Bool { + host == "127.0.0.1" || host == "::1" || host == "localhost" + } + + /// IPv4 dotted-quad or anything bracketed/colon-bearing (IPv6). Deliberately + /// narrow: the question is only "did the user give an address or a name". + static func isNumericIP(_ host: String) -> Bool { + if host.contains(":") { return true } + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return false } + return parts.allSatisfy { p in + guard !p.isEmpty, p.count <= 3, p.allSatisfy(\.isNumber) else { return false } + return Int(p).map { (0...255).contains($0) } ?? false + } + } +} diff --git a/swift/Sources/hark/AgentController.swift b/swift/Sources/hark/AgentController.swift index 6292f5c..ee6ff30 100644 --- a/swift/Sources/hark/AgentController.swift +++ b/swift/Sources/hark/AgentController.swift @@ -15,6 +15,7 @@ public enum AgentState { case idle, starting, recording, stopping, uploading } public final class AgentController: NSObject { private let config: HarkConfig + private let clientConfig: ClientConfig private let hotkey = Hotkey() private let recorder = Recorder() private var dictate: DictateClient @@ -31,8 +32,24 @@ public final class AgentController: NSObject { public init(config: HarkConfig) { self.config = config self.log = Log() - let url = URL(string: "http://127.0.0.1:\(config.harkPort)/dictate")! - self.dictate = DictateClient(url: url, key: KeyFile.load() ?? "") + + // Client addressing is NOT the server's deployment config. A recording + // Mac has no server, no key file and nothing on loopback; and a server + // bound to a tailnet address is unreachable at 127.0.0.1 even on the + // machine running it. See ClientConfig. + let client: ClientConfig + do { + client = try ClientConfig.load(defaultPort: config.harkPort) + } catch { + // Fatal on purpose. Continuing would build a client pointed at + // loopback that fails on every utterance with a connection error, + // which reads as "the server is down" rather than "your config is + // wrong" — and that misdirection is expensive. + FileHandle.standardError.write("hark: \(error)\n".data(using: .utf8)!) + exit(2) + } + self.clientConfig = client + self.dictate = DictateClient(url: client.serverURL, key: client.key) super.init() } @@ -161,14 +178,36 @@ public final class AgentController: NSObject { } private func paste() { + // Re-checked here, not just at startup: the grant can be revoked, or + // silently invalidated by a rebuild, while the process keeps running, + // and this is the moment it matters. Without it every step above still + // reports success — the pasteboard write, the log line — and an + // untrusted process is indistinguishable from a dropped event. + guard accessibilityTrusted() else { + alert("hark: Accessibility is not granted, so the transcript cannot be pasted. " + + "It IS on the clipboard — press ⌘V. " + + "System Settings -> Privacy & Security -> Accessibility -> turn ON hark.") + return + } + // Synthesise ⌘V. NEVER Return/Enter — auto-submit is a hard non-goal. let source = CGEventSource(stateID: .hidSystemState) let down = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true) // kVK_ANSI_V down?.flags = .maskCommand down?.post(tap: .cghidEventTap) - let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) - up?.flags = .maskCommand - up?.post(tap: .cghidEventTap) + + // HOLD THE KEY. Posting key-up immediately after key-down is a + // zero-duration keystroke, which some apps silently drop — the events + // arrive, nothing acts on them, and the transcript never appears. + // hs.eventtap.keyStroke, the implementation being replaced, holds for + // 200 ms (`local keyDelay = 200000`, then usleep between down and up); + // the tap and the flags are otherwise identical. Async rather than a + // sleep so the run loop is not stalled behind it. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) + up?.flags = .maskCommand + up?.post(tap: .cghidEventTap) + } } private func clearIfOwned() { @@ -222,7 +261,7 @@ public final class AgentController: NSObject { return "" } - private var dictateServer: String { "http://127.0.0.1:\(config.harkPort)/dictate" } + private var dictateServer: String { clientConfig.serverURL.absoluteString } // MARK: - Permissions @@ -239,7 +278,22 @@ public final class AgentController: NSObject { private func probePermissions() { // Raise the microphone dialog if never asked. if microphoneStatus() == .notDetermined { - AVCaptureDevice.requestAccess(for: .audio) { _ in } + // Wait for the answer rather than discarding it. The completion is + // delivered on an unspecified queue, so this pumps the run loop + // instead of blocking on a semaphore, which would deadlock if that + // queue turned out to be main. Continuing without the answer means + // recording the substituted silence macOS hands an ungranted + // process — full-length buffers of zeros, indistinguishable from a + // quiet room until you check the peak. + var granted: Bool? + AVCaptureDevice.requestAccess(for: .audio) { granted = $0 } + let deadline = Date(timeIntervalSinceNow: 60) + while granted == nil, Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + if granted != true { + log.info("microphone access was not granted") + } } // AXIsProcessTrusted alone checks but never prompts; use the option. let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary From d1f06efc198a9530037b7905429327f1ac714205 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 16:53:12 -0400 Subject: [PATCH 2/3] [agent] Fix repeated transcripts, a dead hotkey, and a mute UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects found by using the agent rather than by reading it. Each one reported success while broken, and none is visible to a unit test. HOTKEY: the CGEventTap missed nearly every release. A tap sees raw key events, so the chord is reconstructed from keycode + modifier flags on that event - and the flags describe the instant it was generated. Releasing Ctrl+Alt+Space almost always lifts a modifier at or before the space bar, so the space key-UP arrives with the modifier bits clear and a chord test applied to both edges matches the press and misses the release. Measured with a bare session tap over 45s of ordinary use: 303 key-downs, 1 key-up. In the agent that left a capture that never ended, and since beginCapture() guards on state == .idle every later press was ignored - presenting as "the hotkey stopped working". Switched to Carbon RegisterEventHotKey, which the design left to a hardware spike. That spike is now run. Carbon delivers pressed and released as distinct events, does not condition the release on modifier state, consumes the chord so it does not also reach the focused app, and needs no permission of its own - which settles the Phase 0 question of whether a tap would additionally require Input Monitoring. It is also what hs.hotkey used underneath. REPEATS: Recorder is long-lived and neither start() nor stop() cleared `samples` or `peak`, so every capture appended to all previous audio. Capture 2 transcribed 1+2, capture 3 transcribed 1+2+3, and the WAV grew without bound. Reset at the top of start(). MUTE UI: there was no presentation layer. alert() posted an NSWorkspace notification named "HarkAlert" that nothing observed, and brief() was `{ _ = message }` - so 401/415/400/503, transport failures, "heard nothing" and "paste withheld (focus moved)" were all discarded. The menu bar title flipping to a dot was the only feedback of any kind. Added an Overlay (non-activating floating panel, so it never steals focus from what you are dictating into) and wired all three paths to it, keeping the menu bar item. Also: status.json reported a hardcoded hotkey: "registered" whether or not anything was bound, so it could never surface the failure above. It now reflects Hotkey.isRegistered. Verified on hardware: recording, transcription and paste all working, repeats gone, indicator visible. Solves: hark #2 — integration findings on top of #12 Tests: his 48 SwiftPM tests still pass --- swift/Sources/hark/AgentController.swift | 27 +++- swift/Sources/hark/Hotkey.swift | 149 +++++++++++++++-------- swift/Sources/hark/Overlay.swift | 100 +++++++++++++++ swift/Sources/hark/Recorder.swift | 10 ++ 4 files changed, 230 insertions(+), 56 deletions(-) create mode 100644 swift/Sources/hark/Overlay.swift diff --git a/swift/Sources/hark/AgentController.swift b/swift/Sources/hark/AgentController.swift index ee6ff30..6aa8598 100644 --- a/swift/Sources/hark/AgentController.swift +++ b/swift/Sources/hark/AgentController.swift @@ -62,7 +62,7 @@ public final class AgentController: NSObject { hotkey.onPress = { [weak self] in self?.beginCapture() } hotkey.onRelease = { [weak self] in self?.endCapture() } if !hotkey.register() { - alert("hark: Accessibility is NOT granted. The hotkey (Ctrl+Alt+Space) cannot work until you enable it.") + alert("hark: could not bind Ctrl+Alt+Space — something else is probably holding it.") } heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in @@ -221,6 +221,14 @@ public final class AgentController: NSObject { private func showRecordingIndicator(_ on: Bool) { statusItem?.button?.title = on ? "●" : "hark" + // The menu bar title alone is easy to miss on a crowded bar, and it is + // the only signal that the mic is actually open. nil duration: cleared + // when capture really ends, never on a timer. + if on { + Overlay.shared.show("● Recording…", duration: nil) + } else { + Overlay.shared.hide() + } } private func setupMenuBar() { @@ -236,10 +244,21 @@ public final class AgentController: NSObject { private func alert(_ message: String) { log.info(message) - NSWorkspace.shared.notificationCenter.post(name: .init("HarkAlert"), object: message) + // Was posted to an NSWorkspace notification nobody observed, which + // discarded the whole diagnostic surface. Show it, and beep: if + // dictation does nothing the instinct is to try again, and a second + // silent failure reads as a broken mic rather than a stale key. + Overlay.beep() + Overlay.shared.show(message, duration: 7) NSSound(named: "Basso")?.play() } - private func brief(_ message: String) { _ = message } + /// Short, non-error feedback — "heard nothing", "paste withheld". Was a + /// no-op, so the two states a user is most likely to hit and misread as a + /// hang produced no feedback at all. No beep: neither is a failure. + private func brief(_ message: String) { + log.info(message) + Overlay.shared.show(message, duration: 2) + } private func present(_ error: DictateError) -> String { switch error { @@ -305,7 +324,7 @@ public final class AgentController: NSObject { private func writeHeartbeat() { let heartbeat = Heartbeat.current(microphone: microphoneStatus() == .authorized ? "authorized" : "denied", accessibility: accessibilityTrusted() ? "trusted" : "not_trusted", - hotkey: "registered") + hotkey: hotkey.isRegistered ? "registered" : "not_registered") heartbeat.write() } } diff --git a/swift/Sources/hark/Hotkey.swift b/swift/Sources/hark/Hotkey.swift index b97057f..beba38b 100644 --- a/swift/Sources/hark/Hotkey.swift +++ b/swift/Sources/hark/Hotkey.swift @@ -1,76 +1,121 @@ import ApplicationServices +import Carbon.HIToolbox import CoreGraphics import Foundation /// Global hold-to-talk hotkey (Ctrl+Alt+Space) for the agent. /// -/// ⚠️ PHASE 0 OPEN QUESTION (per the native-client design, §Phase 0): the exact -/// mechanism — a keyboard `CGEventTap` (this implementation) versus Carbon -/// `RegisterEventHotKey` — is meant to be decided by a hardware spike, because -/// it decides whether one TCC grant (Accessibility) is enough or a keyboard -/// event tap also needs an Input Monitoring grant. This file implements the -/// tap variant as a working default; Phase 0 must validate which grant set the -/// final signed bundle actually needs before release. +/// PHASE 0 IS DECIDED: Carbon `RegisterEventHotKey`, not a keyboard +/// `CGEventTap`. The design left this to a hardware spike; the spike has now +/// been run, and the tap loses on correctness rather than on taste. +/// +/// WHY THE TAP FAILED +/// +/// A tap sees raw key events, so a chord must be reconstructed from the +/// keycode plus the modifier flags carried on that event — and those flags +/// describe the instant the event was generated. Releasing Ctrl+Alt+Space +/// almost always lifts a modifier at or before the space bar, so the space +/// key-UP arrives with the modifier bits already clear. A chord test applied +/// to both edges therefore matches the press and misses the release. +/// +/// Measured with a bare session tap over 45 s of ordinary use: +/// +/// DOWN events: 303 (auto-repeat while held) +/// UP events: 1 +/// +/// One release out of dozens. In the agent that meant a capture that never +/// ended, and because `beginCapture()` guards on `state == .idle`, every +/// subsequent press was ignored — presenting as "the hotkey stopped working" +/// rather than as a missed key-up. +/// +/// The fix is NOT to loosen the key-up test. Tracking "a capture is open, so +/// end it on any space key-up" re-implements, badly, something the OS already +/// does correctly: `RegisterEventHotKey` delivers `kEventHotKeyPressed` and +/// `kEventHotKeyReleased` as distinct events, and the release is not +/// conditional on the modifiers still being held. +/// +/// Two further advantages, both resolving open questions in the design: +/// +/// - It CONSUMES the chord, so Ctrl+Alt+Space does not also reach whatever +/// app has focus. A global tap that passes events through cannot. +/// - It needs no permission of its own, settling the Phase 0 question of +/// whether a keyboard tap would additionally require Input Monitoring. +/// Accessibility is still required — for synthesising the ⌘V paste — so +/// the agent asks for exactly one grant either way. +/// +/// It is also what `hs.hotkey` used underneath, making it the mechanism +/// already proven against this exact chord and hold pattern. public final class Hotkey { public var onPress: (() -> Void)? public var onRelease: (() -> Void)? - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? + private var hotKeyRef: EventHotKeyRef? + private var handlerRef: EventHandlerRef? - private static let keySpace: Int64 = 49 // kVK_Space + /// The Carbon handler is a C function pointer and can capture nothing, so + /// the live instance is reached through this. Only one hotkey is ever + /// registered; a second `register()` replaces the first. + fileprivate static weak var current: Hotkey? - /// Register the global tap. Returns false if Accessibility is not granted - /// (the tap cannot be created), so the controller can surface it. + /// Returns false if the hotkey could not be bound — most likely because + /// something else already owns Ctrl+Alt+Space. @discardableResult public func register() -> Bool { - guard AXIsProcessTrusted() else { return false } + unregister() + Hotkey.current = self - let mask = CGEventMask(1 << CGEventType.keyDown.rawValue) - | CGEventMask(1 << CGEventType.keyUp.rawValue) - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: mask, - callback: { proxy, type, event, refcon in - guard let refcon else { return Unmanaged.passUnretained(event) } - let me = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - me.handle(type: type, event: event) - return Unmanaged.passUnretained(event) - }, - userInfo: Unmanaged.passUnretained(self).toOpaque()) else { - return false - } - eventTap = tap - let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - runLoopSource = source - CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) - return true + var specs = [ + EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed)), + EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyReleased)), + ] + + let installed = InstallEventHandler( + GetApplicationEventTarget(), hotKeyEventHandler, specs.count, &specs, nil, &handlerRef) + guard installed == noErr else { return false } + + // 'HARK' as an OSType — the conventional four-char signature. + let id = EventHotKeyID(signature: OSType(0x4841_524B), id: 1) + + // Ctrl+Alt+Space, NOT Cmd+Alt+Space: the latter is macOS's Finder + // search shortcut and the system wins that fight. + let registered = RegisterEventHotKey( + UInt32(kVK_Space), UInt32(controlKey | optionKey), + id, GetApplicationEventTarget(), 0, &hotKeyRef) + return registered == noErr } public func unregister() { - if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } - if let runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) } - eventTap = nil - runLoopSource = nil + if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) } + if let handlerRef { RemoveEventHandler(handlerRef) } + hotKeyRef = nil + handlerRef = nil + if Hotkey.current === self { Hotkey.current = nil } } - private func handle(type: CGEventType, event: CGEvent) { - // Ignore auto-repeat (the key is held) — nothing happens on repeat. - let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 - let keycode = event.getIntegerValueField(.keyboardEventKeycode) - let flags = event.flags - let isChord = keycode == Self.keySpace - && flags.contains(.maskControl) - && flags.contains(.maskAlternate) - guard isChord, !isRepeat else { return } + /// True when the chord is actually bound — used by the heartbeat, which + /// previously reported a hardcoded "registered" whether or not anything + /// had been bound. + public var isRegistered: Bool { hotKeyRef != nil } - if type == .keyDown { - onPress?() - } else if type == .keyUp { - onRelease?() + fileprivate func handle(kind: Int) { + switch kind { + case kEventHotKeyPressed: onPress?() + case kEventHotKeyReleased: onRelease?() + default: break } } } + +/// Carbon dispatches on the main thread, which is where the agent's state +/// machine lives, so no queue hop is needed. +private let hotKeyEventHandler: EventHandlerUPP = { _, event, _ -> OSStatus in + guard let event, let hotkey = Hotkey.current else { return OSStatus(eventNotHandledErr) } + let kind = Int(GetEventKind(event)) + guard kind == kEventHotKeyPressed || kind == kEventHotKeyReleased else { + return OSStatus(eventNotHandledErr) + } + hotkey.handle(kind: kind) + return noErr +} diff --git a/swift/Sources/hark/Overlay.swift b/swift/Sources/hark/Overlay.swift new file mode 100644 index 0000000..10e2c8e --- /dev/null +++ b/swift/Sources/hark/Overlay.swift @@ -0,0 +1,100 @@ +import AppKit +import Foundation + +/// On-screen presentation for the agent: the recording indicator and every +/// user-facing message. +/// +/// This exists because the agent had no presentation layer at all. `alert()` +/// posted an `NSWorkspace` notification named "HarkAlert" that nothing +/// anywhere observed, and `brief()` was `{ _ = message }` — so the entire +/// diagnostic surface, 401/415/400/503, transport failures, "heard nothing", +/// "paste withheld (focus moved)", was silently discarded. The menu bar title +/// flipping to "●" was the only feedback of any kind, and it is easy to miss +/// on a crowded menu bar. +/// +/// A silent failure is the worst outcome here. If dictation does nothing the +/// instinct is to try again, and a second silent failure reads as "the mic is +/// broken" when the real cause might be a stale key or a downed tailnet link. +final class Overlay { + static let shared = Overlay() + + private var panel: NSPanel? + private var dismissTimer: Timer? + + /// `duration: nil` pins the panel until `hide()` — used by the recording + /// indicator, which is cleared when capture actually ends rather than on a + /// timer. + func show(_ text: String, duration: TimeInterval?) { + precondition(Thread.isMainThread) + hide() + + let label = NSTextField(wrappingLabelWithString: text) + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = .white + label.alignment = .center + label.isEditable = false + label.isSelectable = false + label.drawsBackground = false + label.preferredMaxLayoutWidth = 520 + + let size = label.sizeThatFits(NSSize(width: 520, height: CGFloat.greatestFiniteMagnitude)) + let padding: CGFloat = 22 + let frame = NSRect(x: 0, y: 0, + width: size.width + padding * 2, + height: size.height + padding * 2) + + // .nonactivatingPanel is load-bearing: without it, showing the panel + // pulls keyboard focus away from whatever the user is dictating into, + // which is the one thing this tool must never do. + let panel = NSPanel(contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.level = .floating + panel.ignoresMouseEvents = true + panel.hasShadow = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + + let container = NSVisualEffectView(frame: frame) + container.material = .hudWindow + container.blendingMode = .behindWindow + container.state = .active + container.wantsLayer = true + container.layer?.cornerRadius = 14 + container.layer?.masksToBounds = true + + label.frame = NSRect(x: padding, y: padding, width: size.width, height: size.height) + container.addSubview(label) + panel.contentView = container + + if let screen = NSScreen.main { + let visible = screen.visibleFrame + panel.setFrameOrigin(NSPoint(x: visible.midX - frame.width / 2, + y: visible.minY + visible.height * 0.18)) + } + + panel.orderFrontRegardless() + self.panel = panel + + if let duration { + dismissTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in + DispatchQueue.main.async { Overlay.shared.hide() } + } + } + } + + func hide() { + dismissTimer?.invalidate() + dismissTimer = nil + panel?.orderOut(nil) + panel = nil + } + + /// "Basso" is a built-in macOS alert sound, chosen because it reads as a + /// failure tone rather than routine feedback. + static func beep() { + NSSound(named: NSSound.Name("Basso"))?.play() + } +} diff --git a/swift/Sources/hark/Recorder.swift b/swift/Sources/hark/Recorder.swift index f1b5bf7..5f9a893 100644 --- a/swift/Sources/hark/Recorder.swift +++ b/swift/Sources/hark/Recorder.swift @@ -49,6 +49,16 @@ public final class Recorder { /// Permission must already be granted. Throws on a device failure before /// the engine starts. public func start() throws { + // Reset per-capture state. Recorder is long-lived and both of these + // are instance properties, so without this every capture appends to + // all previous audio: capture 2 transcribes 1+2, capture 3 transcribes + // 1+2+3, and the WAV grows without bound. Presents as the transcript + // repeating what you said last time. + queue.sync { + samples.removeAll(keepingCapacity: true) + peak = 0 + } + let input = engine.inputNode let inFormat = input.inputFormat(forBus: 0) guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else { From 8c70d859a9ac01607c604f7412c530144144efa6 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 16:55:08 -0400 Subject: [PATCH 3/3] [agent] Separate consecutive dictations with a trailing space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two dictations into the same field pasted as "one, two, three.one, two, three." with nothing between them. Not a regression from the rewrite: the server sanitiser ends with .strip(), which removes Whisper's own leading space. That is correct for an API — it should return the transcript, not presentation whitespace, and a consumer that is not a paste target should not inherit padding — so the fix belongs on the client. The Lua client had the same behaviour. Trailing rather than leading: a leading space would open an empty field with whitespace, and there is no reliable way to read what sits immediately before the cursor to decide between them. Trailing is occasionally redundant and never wrong. The logged length stays the transcript's, not the padded string's. Solves: hark #2 — reported during hardware testing Tests: his 48 SwiftPM tests still pass --- swift/Sources/hark/AgentController.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/swift/Sources/hark/AgentController.swift b/swift/Sources/hark/AgentController.swift index 6aa8598..e6ee71d 100644 --- a/swift/Sources/hark/AgentController.swift +++ b/swift/Sources/hark/AgentController.swift @@ -156,7 +156,21 @@ public final class AgentController: NSObject { // MARK: - Paste - private func apply(_ text: String) { + private func apply(_ transcript: String) { + // A trailing space, so consecutive dictations do not run together. + // + // The server sanitiser ends with .strip(), and Whisper's own leading + // space goes with it — correct for an API, which should return the + // transcript and not presentation whitespace. But two dictations into + // the same field then paste as "one, two, three.one, two, three." with + // nothing between them. Long-standing; the Lua client did the same. + // + // Trailing rather than leading: a leading space would open an empty + // field with whitespace, and there is no reliable way to read what sits + // immediately before the cursor to decide. Trailing is occasionally + // redundant and never wrong. + let text = transcript + " " + // Set the pasteboard and verify the write before synthesising ⌘V. let pb = NSPasteboard.general pb.clearContents() @@ -164,7 +178,7 @@ public final class AgentController: NSObject { alert("hark: could not write to the pasteboard — not pasting.") return } - log.info("pasting \(text.count) chars") + log.info("pasting \(transcript.count) chars") // Paste-target policy: never type into whatever gained focus since release. if frontmostAppName() != frontmostAtRelease { brief("transcript is on the clipboard — paste withheld (focus moved)")