Files
openless/Sources/OpenLessHotkey/HotkeyMonitor.swift
T
TRIPandbaiqing 56c3e09983 feat: hold-to-talk + UX fixes + structured prompt overhaul
* feat: add hold-to-talk hotkey mode (closes #1)

OpenLessHotkey now emits explicit .pressed / .released edge events on
the modifier-key trigger; the coordinator interprets them per the new
HotkeyMode preference (toggle stays the default, hold = press-and-hold).

Hold mode rules: .pressed on idle starts the session; .released stops
it from .listening; releasing during .starting cancels (no audio
useful was captured yet). Esc-cancel sets a one-shot suppress flag so
the trailing .released after a cancel does not re-trigger end.

Settings hub gets a "录音方式" segmented picker and a one-line hint
explaining the tradeoff. Bump version to 1.1.0; update README roadmap
and USAGE walkthrough to document the new mode.

* test: update HotkeyEventTests for pressed/released split

* chore: tag hold-to-talk build as 1.0.01 / A1003

* fix: stop window-background drag eating text selection; add eye toggle on secret fields

Settings window had isMovableByWindowBackground = true, so any drag
inside the content view dragged the whole window — making it
impossible to select words inside a TextField. Disable it; the native
title-bar strip (with the traffic lights) still drags the window.

PasteableCredentialField now exposes an eye / eye.slash toggle when
secure: true, so users can verify pasted API keys before saving.

* fix: stop pretending polish ran when it was skipped or errored

When the user removes the Ark API key (or the Ark call errors out),
DictationCoordinator was still showing the green "已插入" capsule and
recording the session in history under the originally-selected polish
mode. From the user's side it looked like polish had succeeded, even
though only the raw transcript reached the cursor.

Track a PolishOutcome (ok / skippedNoCredentials / failed) through
insertText. Capsule now uses a new CapsuleState.warning (orange) state
to surface honest messages — "已插入原文 · 未润色" / "润色失败 · 已用原文" —
and history saves the session as PolishMode.raw whenever polish
didn't actually run. The error capsule + 1.5s artificial sleep that
preceded a fake "success" insert is gone; warning state stays for the
normal 2.5s.

* feat(polish): make Structured mode prompt force a 1./1)/a. hierarchy

Some weaker chat models would emit a single flat list when the user
asked for Structured polish, even when the dictation clearly covered
multiple topics. The mode is meant to produce something pasteable as
an AI prompt or a working doc, not a wall of bullets.

Rewrite the .structured system prompt to be prescriptive: when the
input contains ≥2 themes, emit three explicit levels — top "1./2./3."
sections, "1)/2)/3)" sub-points indented 3 spaces, and "a./b./c."
detail items indented another 3 — and to fall back to a plain
paragraph when the input is short / single-topic. Add a concrete
format example so weaker models have a pattern to copy.

---------

Co-authored-by: baiqing <lbx12309@icloud.com>
2026-04-27 18:57:41 +08:00

177 lines
5.5 KiB
Swift

import AppKit
import CoreGraphics
import OpenLessCore
@MainActor
public final class HotkeyMonitor: HotkeyServiceProtocol {
public let events: AsyncStream<HotkeyEvent>
public private(set) var isRunning = false
private let continuation: AsyncStream<HotkeyEvent>.Continuation
private var binding: HotkeyBinding = .default
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var triggerHeld = false
/// 边沿事件:触发键按下时发 .pressed,松开时发 .released。
/// toggle / hold 的解释由协调器侧(DictationCoordinator)按用户偏好做。
public init() {
var captured: AsyncStream<HotkeyEvent>.Continuation!
self.events = AsyncStream(bufferingPolicy: .unbounded) { continuation in
captured = continuation
}
self.continuation = captured
}
public func start(binding: HotkeyBinding) throws {
guard !isRunning else { throw HotkeyError.alreadyRunning }
guard AccessibilityPermission.isGranted() else {
throw HotkeyError.accessibilityNotGranted
}
self.binding = binding
let mask: CGEventMask =
(1 << CGEventType.flagsChanged.rawValue) |
(1 << CGEventType.keyDown.rawValue)
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: mask,
callback: hotkeyEventCallback,
userInfo: selfPtr
) else {
throw HotkeyError.eventTapCreateFailed
}
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
self.eventTap = tap
self.runLoopSource = source
self.isRunning = true
}
public func stop() {
guard isRunning else { return }
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
}
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
eventTap = nil
runLoopSource = nil
triggerHeld = false
isRunning = false
}
public func updateBinding(_ binding: HotkeyBinding) {
self.binding = binding
triggerHeld = false
}
fileprivate func reenableTap() {
guard let tap = eventTap else { return }
CGEvent.tapEnable(tap: tap, enable: true)
}
fileprivate func handle(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
switch type {
case .flagsChanged:
return handleFlagsChanged(event)
case .keyDown:
return handleKeyDown(event)
default:
return Unmanaged.passUnretained(event)
}
}
private func handleFlagsChanged(_ event: CGEvent) -> Unmanaged<CGEvent>? {
let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode))
let triggerKeyCode = expectedKeyCode(for: binding.trigger)
guard keyCode == triggerKeyCode else {
return Unmanaged.passUnretained(event)
}
let triggerMask = expectedFlagMask(for: binding.trigger)
let triggerActive = event.flags.contains(triggerMask)
if triggerActive && !triggerHeld {
triggerHeld = true
continuation.yield(.pressed)
} else if !triggerActive && triggerHeld {
triggerHeld = false
continuation.yield(.released)
}
// fn 默认拦截,规避系统 Globe 行为
if shouldInterceptTrigger() {
return nil
}
return Unmanaged.passUnretained(event)
}
private func handleKeyDown(_ event: CGEvent) -> Unmanaged<CGEvent>? {
let escapeKeyCode: Int64 = 53
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
if keyCode == escapeKeyCode {
continuation.yield(.cancelled)
}
return Unmanaged.passUnretained(event)
}
private func shouldInterceptTrigger() -> Bool {
binding.trigger == .fn
}
private func expectedKeyCode(for trigger: HotkeyBinding.Trigger) -> Int {
switch trigger {
case .leftControl: return 59
case .rightCommand: return 54
case .leftOption: return 58
case .rightOption: return 61
case .rightControl: return 62
case .fn: return 63
}
}
private func expectedFlagMask(for trigger: HotkeyBinding.Trigger) -> CGEventFlags {
switch trigger {
case .leftControl, .rightControl: return .maskControl
case .rightCommand: return .maskCommand
case .leftOption, .rightOption: return .maskAlternate
case .fn: return .maskSecondaryFn
}
}
}
private func hotkeyEventCallback(
proxy: CGEventTapProxy,
type: CGEventType,
event: CGEvent,
refcon: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {
guard let refcon = refcon else {
return Unmanaged.passUnretained(event)
}
let monitor = Unmanaged<HotkeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
MainActor.assumeIsolated {
monitor.reenableTap()
}
return Unmanaged.passUnretained(event)
}
return MainActor.assumeIsolated {
monitor.handle(type: type, event: event)
}
}