diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9de7aa33..7e8e0110 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Compile-check all targets (debug) run: swift build + - name: Unit tests + run: swift test + - name: Package "AhaKey Studio.app" (release, ad-hoc signed) run: | chmod +x scripts/package_app.sh diff --git a/ahakeyconfig-mac/Package.swift b/ahakeyconfig-mac/Package.swift index 182b1c80..fe61d53e 100644 --- a/ahakeyconfig-mac/Package.swift +++ b/ahakeyconfig-mac/Package.swift @@ -54,5 +54,10 @@ let package = Package( name: "AhaKeyConfigAgent", path: "Sources/Agent" ), + .testTarget( + name: "AhaKeyConfigTests", + dependencies: ["AhaKeyConfig", "AhaKeyConfigAgent"], + path: "Tests/AhaKeyConfigTests" + ), ] ) diff --git a/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift index dad2135a..63f7b3e8 100644 --- a/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift +++ b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift @@ -69,7 +69,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat var onLog: ((String) -> Void)? - init(socketPath: String = "/tmp/ahakey.sock") { + init(socketPath: String = AhaKeySocket.defaultPath) { self.socketPath = socketPath if let raw = UserDefaults.standard.object(forKey: Self.switchOverrideDefaultsKey) as? Int { userSwitchOverride = UInt8(clamping: raw) @@ -198,35 +198,51 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat } startWatchdog() + + do { + try AhaKeySocket.prepareDirectory() + } catch { + emit("socket 目录准备失败: \(error.localizedDescription)") + return false + } + // 清理没有监听进程的残留 socket unlink(socketPath) let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { emit("socket() 失败"); return false } - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in - let buf = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) - strcpy(buf, ptr) - } + guard var addr = AhaKeySocket.makeAddress(path: socketPath) else { + emit("socket 路径超出 sockaddr_un 容量: \(socketPath)") + close(fd) + return false } + // 权限由 bind 时的 umask 决定,事后 chmod 会留下一个宽松窗口,所以两者都做。 + let previousMask = umask(0o177) let bindResult = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in bind(fd, sockPtr, socklen_t(MemoryLayout.size)) } } + umask(previousMask) guard bindResult == 0 else { emit("bind() 失败: \(errno)"); close(fd); return false } + if chmod(socketPath, 0o600) != 0 { + emit("chmod 0600 失败: \(errno)(socket 可能对其他用户可见)") + } listen(fd, 5) - emit("监听 Unix socket: \(socketPath)") + emit("监听 Unix socket: \(socketPath)(0600,仅本用户)") DispatchQueue.global(qos: .utility).async { [weak self] in while true { let clientFd = accept(fd, nil, nil) guard clientFd >= 0 else { continue } + guard AhaKeySocket.peerIsSameUser(clientFd) else { + close(clientFd) + self?.emit("拒绝连接:对端不是本用户") + continue + } self?.handleClient(clientFd) } } @@ -264,14 +280,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat guard fd >= 0 else { return false } defer { close(fd) } - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - path.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in - let buf = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) - strcpy(buf, ptr) - } - } + guard var addr = AhaKeySocket.makeAddress(path: path) else { return false } return withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in diff --git a/ahakeyconfig-mac/Sources/Agent/AhaKeySocket.swift b/ahakeyconfig-mac/Sources/Agent/AhaKeySocket.swift new file mode 100644 index 00000000..5b71eb25 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Agent/AhaKeySocket.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Agent 与 hook 客户端之间 Unix socket 的路径与权限。 +/// +/// 不放 `/tmp`:该目录全局可写,其他用户可以在 agent 启动前抢先 bind 同名路径冒充它 +/// (`AhaKeyAgent.startSocketListener` 见到已有监听会主动让位),对每次审批请求回自动批准。 +/// 同 uid 的进程仍连得上,Unix 权限位管不了这层。 +enum AhaKeySocket { + static var defaultPath: String { + directoryURL.appendingPathComponent("agent.sock").path + } + + static var directoryURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig", isDirectory: true) + } + + /// 建目录并置为 0700,已存在时也改一次。 + static func prepareDirectory() throws { + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path) + } + + static func peerIsSameUser(_ fd: Int32) -> Bool { + var uid = uid_t(0) + var gid = gid_t(0) + guard getpeereid(fd, &uid, &gid) == 0 else { return false } + return uid == getuid() + } + + /// 填充 `sockaddr_un`,路径放不下时返回 nil。 + /// + /// `sun_path` 只有 104 字节,而这条路径含用户主目录,长度随用户名变化。 + static func makeAddress(path: String) -> sockaddr_un? { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let capacity = MemoryLayout.size(ofValue: addr.sun_path) + guard path.utf8.count < capacity else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + path.withCString { _ = strlcpy(dst, $0, capacity) } + } + return addr + } +} diff --git a/ahakeyconfig-mac/Sources/Agent/HookSupport.swift b/ahakeyconfig-mac/Sources/Agent/HookSupport.swift index fdbeadc5..2e6897b8 100644 --- a/ahakeyconfig-mac/Sources/Agent/HookSupport.swift +++ b/ahakeyconfig-mac/Sources/Agent/HookSupport.swift @@ -2,7 +2,7 @@ import Foundation enum HookSupport { static let permissionLedValue: UInt8 = 1 - static let socketPath = "/tmp/ahakey.sock" + static var socketPath: String { AhaKeySocket.defaultPath } static let stateRequestTimeout: Double = 2.0 static let permissionRequestTimeout: Double = 5.0 @@ -42,14 +42,7 @@ enum HookSupport { setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { src in - withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in - let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) - _ = strcpy(dst, src) - } - } + guard var addr = AhaKeySocket.makeAddress(path: socketPath) else { return nil } let addrLen = socklen_t(MemoryLayout.size) let connected = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { @@ -97,7 +90,7 @@ enum HookSupport { switchState: Int? ) { if switchState == nil, reply == nil { - let msg = "[ahakey-hook] \(ide) \(hookName): agent 无回包或 Unix socket 失败(/tmp/ahakey.sock 连不上/超时,超时 \(Int(permissionRequestTimeout))s)。" + let msg = "[ahakey-hook] \(ide) \(hookName): agent 无回包或 Unix socket 失败(\(socketPath) 连不上/超时,超时 \(Int(permissionRequestTimeout))s)。" + "请确认 LaunchAgent 里 ahakeyconfig-agent 在跑、且蓝牙已选「由 Agent 占用」并连上键盘。\n" FileHandle.standardError.write(Data(msg.utf8)) } else if switchState == nil, reply != nil { diff --git a/ahakeyconfig-mac/Sources/Agent/main.swift b/ahakeyconfig-mac/Sources/Agent/main.swift index c7621513..3f813c06 100644 --- a/ahakeyconfig-mac/Sources/Agent/main.swift +++ b/ahakeyconfig-mac/Sources/Agent/main.swift @@ -4,7 +4,7 @@ import Foundation // // 两种运行模式(由首个参数决定): // 1. Daemon(无参数 / 只传 --socket):常驻 LaunchAgent,维持 BLE 连接 + 监听 Unix socket -// ahakeyconfig-agent [--socket /tmp/ahakey.sock] +// ahakeyconfig-agent [--socket ] // 缺省见 AhaKeySocket.defaultPath // 2. Hook 子命令(首个参数为 hook):Claude Code / Cursor / Codex / Kimi Code CLI 会 exec 本进程 // ahakeyconfig-agent hook // 内部通过 Unix socket 联系常驻 daemon,并按需向 stdout 输出 Claude 决策 JSON。 @@ -21,7 +21,7 @@ let socketPath: String if let idx = args.firstIndex(of: "--socket"), idx + 1 < args.count { socketPath = args[idx + 1] } else { - socketPath = "/tmp/ahakey.sock" + socketPath = AhaKeySocket.defaultPath } let agent = AhaKeyAgent(socketPath: socketPath) diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift index e1669abf..d1fa9108 100644 --- a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift @@ -6,7 +6,7 @@ import Foundation // 当前最小三件套: // - host/getInfo → 返回宿主 app 元信息(bundleID / version / build / platform) // - host/log → 插件把日志打到宿主 stderr -// - host/getSwitchState → 通过 /tmp/ahakey.sock 问 daemon 拨杆状态(agent 没跑则返回 null) +// - host/getSwitchState → 通过 agent 的 Unix socket 问 daemon 拨杆状态(agent 没跑则返回 null) // // 后续要加的(如 host/showNotification、host/openURL、host/storage/*)按相同方式接到 // `registerDefaultHandlers` 即可。增加新方法时记得在 manifest 的权限白名单里同步声明。 @@ -125,14 +125,16 @@ enum HostLog { // MARK: - host/getSwitchState -/// 与 `Agent/HookClient.swift` 走同一套 `/tmp/ahakey.sock` 协议 +/// 与 `Agent/HookClient.swift` 走同一套 socket 协议 /// (`{"cmd":"permission","value":1}` → `{"switchState": Int, ...}`)。 /// agent 没跑或 BLE 没连上时返回 nil。 /// /// 没把 socket 协议抽成共用 util,是因为 Agent target 与 AhaKeyPluginKit 暂不互相依赖; /// 后续若多处都要用,再抽 `AhaKeyAgentBridge` library。 +/// 在那之前,路径必须与 `Agent/AhaKeySocket.defaultPath` 保持一致。 enum HostAgentBridge { - static let socketPath = "/tmp/ahakey.sock" + static let socketPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/agent.sock").path static let timeout: Double = 2.0 static func readSwitchState() -> Int? { @@ -152,14 +154,7 @@ enum HostAgentBridge { setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { src in - withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in - let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) - _ = strcpy(dst, src) - } - } + guard var addr = makeUnixAddress(path: socketPath) else { return nil } let len = socklen_t(MemoryLayout.size) let connected = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, len) } @@ -180,3 +175,19 @@ enum HostAgentBridge { return (try? JSONSerialization.jsonObject(with: Data(buf[0 ..< Int(n)]))) as? [String: Any] } } + +/// 填充 `sockaddr_un`,路径放不下时返回 nil。 +/// +/// `sun_path` 只有 104 字节,而 socket 路径含用户主目录,长度随用户名变化。 +/// 与 `Agent/AhaKeySocket.makeAddress` 同一份逻辑,两个 target 不共享源码。 +private func makeUnixAddress(path: String) -> sockaddr_un? { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let capacity = MemoryLayout.size(ofValue: addr.sun_path) + guard path.utf8.count < capacity else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + path.withCString { _ = strlcpy(dst, $0, capacity) } + } + return addr +} diff --git a/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift index 84a51e4b..6560137c 100644 --- a/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift +++ b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift @@ -56,7 +56,10 @@ final class AgentManager: ObservableObject { @Published private(set) var isAgentOperationInProgress = false private let label = "lab.jawa.ahakeyconfig.agent" - private let socketPath = "/tmp/ahakey.sock" + + /// 必须与 Agent target 的 `AhaKeySocket.defaultPath` 一致(两个 target 不共享源码)。 + private let socketPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/AhaKeyConfig/agent.sock").path private var launchAgentsDirectoryURL: URL { FileManager.default.homeDirectoryForCurrentUser @@ -200,13 +203,7 @@ final class AgentManager: ObservableObject { var tv = timeval(tv_sec: 2, tv_usec: 0) setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { src in - withUnsafeMutablePointer(to: &addr.sun_path) { dst in - _ = strcpy(UnsafeMutableRawPointer(dst).assumingMemoryBound(to: CChar.self), src) - } - } + guard var addr = makeUnixAddress(path: socketPath) else { return } let ok = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) @@ -236,13 +233,7 @@ final class AgentManager: ObservableObject { setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { src in - withUnsafeMutablePointer(to: &addr.sun_path) { dst in - _ = strcpy(UnsafeMutableRawPointer(dst).assumingMemoryBound(to: CChar.self), src) - } - } + guard var addr = makeUnixAddress(path: socketPath) else { return false } let ok = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) @@ -552,7 +543,7 @@ final class AgentManager: ObservableObject { self.isAgentOperationInProgress = false self.refresh() if !self.isRunning { - var m = "已执行 launchctl load / start,但尚未检测到 Agent 在运行(未出现 /tmp/ahakey.sock)。\n\n" + var m = "已执行 launchctl load / start,但尚未检测到 Agent 在运行(未出现 \(socketPath))。\n\n" if !loadRes.ok && !isBenignLaunchctlLoadMessage(loadRes.mergedOutput) { m += "load:\n\(loadRes.mergedOutput.isEmpty ? "(无输出)" : loadRes.mergedOutput)\n\n" } @@ -1384,12 +1375,12 @@ final class AgentManager: ObservableObject { } private func patchKimiApprovalPy(atPath path: String) throws -> KimiCliPatchStatus { - let marker = "_AHAKEY_SOCKET_PATH = \"/tmp/ahakey.sock\"" + let marker = "_AHAKEY_SOCKET_PATH = os.path.expanduser(" let helperAnchor = "type Response = Literal[\"approve\", \"approve_for_session\", \"reject\"]\n" let helperBlock = """ type Response = Literal["approve", "approve_for_session", "reject"] - _AHAKEY_SOCKET_PATH = "/tmp/ahakey.sock" + _AHAKEY_SOCKET_PATH = os.path.expanduser("~/Library/Application Support/AhaKeyConfig/agent.sock") _AHAKEY_APPROVAL_CACHE_TTL_S = 0.35 _ahakey_cache_at = 0.0 _ahakey_cache_value: dict[str, object] | None = None @@ -1445,6 +1436,7 @@ final class AgentManager: ObservableObject { let oldImports = "import uuid\n" let newImports = """ import json + import os import socket import time import uuid @@ -1782,3 +1774,19 @@ final class AgentManager: ObservableObject { runLaunchctlDetailed(args).ok } } + +/// 填充 `sockaddr_un`,路径放不下时返回 nil。 +/// +/// `sun_path` 只有 104 字节,而 socket 路径含用户主目录,长度随用户名变化。 +/// 与 `Agent/AhaKeySocket.makeAddress` 同一份逻辑,两个 target 不共享源码。 +private func makeUnixAddress(path: String) -> sockaddr_un? { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let capacity = MemoryLayout.size(ofValue: addr.sun_path) + guard path.utf8.count < capacity else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + path.withCString { _ = strlcpy(dst, $0, capacity) } + } + return addr +} diff --git a/ahakeyconfig-mac/Sources/Utilities/DiagnosticLogRotator.swift b/ahakeyconfig-mac/Sources/Utilities/DiagnosticLogRotator.swift new file mode 100644 index 00000000..e6c76e0c --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/DiagnosticLogRotator.swift @@ -0,0 +1,47 @@ +import Foundation + +/// 诊断日志按大小轮转:`base` → `base.1` → …,最旧的一份丢弃。 +/// +/// 语音诊断日志逐字记录每次转写,不能无限增长。 +enum DiagnosticLogRotator { + /// 单个文件的上限。 + static let maxBytes = 1024 * 1024 + /// 含当前正在写的那份在内,最多保留几个文件。占用上限约 `maxBytes * maxFiles`。 + static let maxFiles = 5 + + /// 当前文件超过上限时轮转一次;未超过或文件不存在则什么都不做。 + /// + /// 轮转后 `url` 不存在,由调用方新建。 + static func rotateIfNeeded( + at url: URL, + maxBytes: Int = maxBytes, + maxFiles: Int = maxFiles, + fileManager: FileManager = .default + ) { + guard maxFiles > 0 else { return } + guard let size = (try? fileManager.attributesOfItem(atPath: url.path))?[.size] as? Int, + size >= maxBytes else { return } + + // 历史份共 maxFiles - 1 个,编号 1...maxFiles-1;序号越大越老。 + let oldestIndex = maxFiles - 1 + if oldestIndex >= 1 { + try? fileManager.removeItem(at: rotatedURL(for: url, index: oldestIndex)) + } + for index in stride(from: oldestIndex - 1, through: 1, by: -1) { + let from = rotatedURL(for: url, index: index) + guard fileManager.fileExists(atPath: from.path) else { continue } + try? fileManager.moveItem(at: from, to: rotatedURL(for: url, index: index + 1)) + } + + if oldestIndex >= 1 { + try? fileManager.moveItem(at: url, to: rotatedURL(for: url, index: 1)) + } else { + // 只允许留一个文件时没有历史可言,直接丢弃当前这份。 + try? fileManager.removeItem(at: url) + } + } + + static func rotatedURL(for url: URL, index: Int) -> URL { + url.appendingPathExtension("\(index)") + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift b/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift index c7191f81..063ad9c6 100644 --- a/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift +++ b/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift @@ -4,6 +4,13 @@ import ApplicationServices import Foundation import Speech +/// 界面语言选择器里的一项。 +struct SpeechLocaleOption: Identifiable, Hashable { + /// 直接用 locale identifier 当 id(`SFSpeechRecognizer.supportedLocales()` 内唯一)。 + let id: String + let name: String +} + @MainActor final class NativeSpeechTranscriptionService: ObservableObject { static let shared = NativeSpeechTranscriptionService() @@ -34,6 +41,33 @@ final class NativeSpeechTranscriptionService: ObservableObject { didSet { UserDefaults.standard.set(longPressThresholdMs, forKey: "nativeSpeech.longPressThresholdMs") } } + // MARK: 识别语言 + + /// 用户显式选择的识别语言;空串 = 自动(按系统首选语言顺序推断)。 + /// + /// 必须可显式设置:系统首选语言第一项未必是用户想说的语言(例如界面英文、地区中国、 + /// 但日常口述中文),这种情况下任何自动推断都只会一直选错。 + @Published var speechLocaleIdentifier: String = UserDefaults.standard.string(forKey: "nativeSpeech.localeIdentifier") ?? "" { + didSet { + UserDefaults.standard.set(speechLocaleIdentifier, forKey: "nativeSpeech.localeIdentifier") + refreshActiveLocaleDescription() + } + } + + /// 下一次录音实际会用的识别语言,供界面展示。 + /// + /// `SFSpeechRecognizer(locale:)` 会静默归一化——传 `en-CN` 拿回的是 `en-US`—— + /// 所以最终结果必须显式呈现,否则用户无从判断自己到底在用哪个模型。 + @Published private(set) var activeLocaleDescription = "尚未确定" + + /// 本机支持的识别语言,按中文名排序(界面文案本身就是硬编码中文)。 + let availableSpeechLocales: [SpeechLocaleOption] = { + let display = Locale(identifier: "zh-Hans") + return SFSpeechRecognizer.supportedLocales() + .map { SpeechLocaleOption(id: $0.identifier, name: display.localizedString(forIdentifier: $0.identifier) ?? $0.identifier) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + }() + /// 当前是否处于长按录音模式(按住中,松手会直接发送) @Published private(set) var isLongPressRecording = false @@ -53,6 +87,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { func start() { AhaTypeTextOptimizer.shared.refreshFromDisk() + refreshActiveLocaleDescription() refreshPermissions(requestIfNeeded: false) } @@ -371,7 +406,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { } guard let recognizer = makeSpeechRecognizer() else { - statusMessage = "当前系统语言暂不支持苹果原生转写。" + statusMessage = "没有可用的识别语言,请在「语音识别语言」里选一个。" appendDiagnostic("speech recognizer unavailable") return } @@ -594,11 +629,9 @@ final class NativeSpeechTranscriptionService: ObservableObject { } private func makeSpeechRecognizer() -> SFSpeechRecognizer? { - if let preferredIdentifier = Locale.preferredLanguages.first { - let locale = Locale(identifier: preferredIdentifier) - if let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable { - return recognizer - } + if let locale = resolvedSpeechLocale(), + let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable { + return recognizer } if let recognizer = SFSpeechRecognizer(), recognizer.isAvailable { @@ -608,6 +641,25 @@ final class NativeSpeechTranscriptionService: ObservableObject { return nil } + /// 用户显式选择优先,否则按系统首选语言的顺序推断;都不中时由调用方回落到系统默认识别器。 + private func resolvedSpeechLocale() -> Locale? { + SpeechLocaleResolver.resolve( + preference: speechLocaleIdentifier, + preferredLanguages: Locale.preferredLanguages, + supported: SFSpeechRecognizer.supportedLocales() + ) + } + + private func refreshActiveLocaleDescription() { + let display = Locale(identifier: "zh-Hans") + guard let locale = resolvedSpeechLocale() ?? SFSpeechRecognizer()?.locale else { + activeLocaleDescription = "无可用识别语言" + return + } + let name = display.localizedString(forIdentifier: locale.identifier) ?? locale.identifier + activeLocaleDescription = "\(name)(\(locale.identifier))" + } + private func missingPermissionMessage( micStatus: AVAuthorizationStatus, speechStatus: SFSpeechRecognizerAuthorizationStatus, @@ -782,6 +834,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { Task.detached { do { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + DiagnosticLogRotator.rotateIfNeeded(at: url) if !FileManager.default.fileExists(atPath: url.path) { try Data(line.utf8).write(to: url) } else { @@ -796,6 +849,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { } } + private var diagnosticLogURL: URL { let directory = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/AhaKeyConfig/diagnostics", isDirectory: true) diff --git a/ahakeyconfig-mac/Sources/Utilities/SpeechLocaleResolver.swift b/ahakeyconfig-mac/Sources/Utilities/SpeechLocaleResolver.swift new file mode 100644 index 00000000..a0691569 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/SpeechLocaleResolver.swift @@ -0,0 +1,61 @@ +import Foundation +import Speech + +/// 把语言标签解析成可用的识别语言。不依赖 UI 状态与 MainActor,可单独测试。 +enum SpeechLocaleResolver { + /// - Parameters: + /// - preference: 用户显式选择的 identifier;空串表示自动。 + /// - preferredLanguages: 系统首选语言,按优先级排列。 + /// - supported: `SFSpeechRecognizer.supportedLocales()`。 + /// - normalize: 只给出语言码时,决定用哪个地区。默认交给 `SFSpeechRecognizer` 自己判断。 + static func resolve( + preference: String, + preferredLanguages: [String], + supported: Set, + normalize: (String) -> Locale? = defaultRegion(forLanguage:) + ) -> Locale? { + if !preference.isEmpty, + let chosen = supported.first(where: { $0.identifier == preference }) { + return chosen + } + + for preferred in preferredLanguages { + if let hit = match(preferred, in: supported, normalize: normalize) { + return hit + } + } + return nil + } + + /// 先按语言 + 地区精确匹配(`zh-Hans-CN` → `zh-CN`),不命中退到只按语言。 + /// + /// 退化时地区由 `normalize` 决定,不从 `supported` 里挑:那是 `Set`,`en` 有 13 个 + /// 地区变体,取出来的结果不稳定。 + static func match( + _ identifier: String, + in supported: Set, + normalize: (String) -> Locale? = defaultRegion(forLanguage:) + ) -> Locale? { + let parts = NSLocale.components(fromLocaleIdentifier: identifier) + guard let language = parts[NSLocale.Key.languageCode.rawValue], !language.isEmpty else { return nil } + + if let region = parts[NSLocale.Key.countryCode.rawValue] { + let exact = supported.first { + let c = NSLocale.components(fromLocaleIdentifier: $0.identifier) + return c[NSLocale.Key.languageCode.rawValue] == language + && c[NSLocale.Key.countryCode.rawValue] == region + } + if let exact { return exact } + } + + guard let normalized = normalize(language), + supported.contains(where: { $0.identifier == normalized.identifier }) else { + return nil + } + return normalized + } + + static func defaultRegion(forLanguage language: String) -> Locale? { + SFSpeechRecognizer(locale: Locale(identifier: language))?.locale + } +} diff --git a/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift index e2c7eb80..5f41df72 100644 --- a/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift +++ b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift @@ -710,6 +710,11 @@ struct AhaKeyStudioView: View { Text(nativeSpeech.lastPermissionCheckSummary) .font(.caption) .foregroundStyle(.secondary) + + Text("识别语言:\(nativeSpeech.activeLocaleDescription)(在语音键的「语音输入方式」里更改)") + .font(.caption) + .foregroundStyle(.secondary) + Divider() HStack(spacing: 10) { @@ -938,6 +943,28 @@ struct AhaKeyStudioView: View { Text("只要 AhaKey Studio 在后台运行,Mode 1 出厂语音键发出的 F18 就会被直接接管到苹果原生转写。现在不再依赖系统听写快捷键。") .font(.caption) .foregroundStyle(.secondary) + + Divider() + + HStack(spacing: 10) { + Text("识别语言") + .font(.callout) + Picker("", selection: $nativeSpeech.speechLocaleIdentifier) { + Text("自动(跟随系统)").tag("") + Divider() + ForEach(nativeSpeech.availableSpeechLocales) { option in + Text(option.name).tag(option.id) + } + } + .labelsHidden() + Spacer(minLength: 0) + } + Text("当前生效:\(nativeSpeech.activeLocaleDescription)") + .font(.caption) + .foregroundStyle(.secondary) + Text("「自动」按系统首选语言的顺序推断;系统语言不是你口述的语言时,在这里显式选一个。") + .font(.caption) + .foregroundStyle(.tertiary) } Text("语音键的输入方式独立于当前 Mode,在任意 Mode 下都可使用相同的语音输入设置。") .font(.caption) diff --git a/ahakeyconfig-mac/Sources/Views/ContentView.swift b/ahakeyconfig-mac/Sources/Views/ContentView.swift index 6278dc03..1a31164a 100644 --- a/ahakeyconfig-mac/Sources/Views/ContentView.swift +++ b/ahakeyconfig-mac/Sources/Views/ContentView.swift @@ -73,7 +73,10 @@ struct ContentView: View { isRecording: nativeSpeech.isRecording, transcriptPreview: nativeSpeech.transcriptPreview, lastCommittedText: nativeSpeech.lastCommittedText, - speechStatusMessage: nativeSpeech.statusMessage + speechStatusMessage: nativeSpeech.statusMessage, + speechLocaleIdentifier: nativeSpeech.speechLocaleIdentifier, + activeLocaleDescription: nativeSpeech.activeLocaleDescription, + availableSpeechLocales: nativeSpeech.availableSpeechLocales ) } @@ -118,6 +121,9 @@ struct ContentView: View { toggleTryExperience: { voiceRelay.suppressPermissionOnboarding() nativeSpeech.toggleRecordingFromVoiceKey() + }, + setSpeechLocale: { identifier in + nativeSpeech.speechLocaleIdentifier = identifier } ) } diff --git a/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift b/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift index 619be4c0..2e4b00b6 100644 --- a/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift +++ b/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift @@ -22,6 +22,10 @@ struct AhaKeyOnboardingPermissionState: Equatable { var transcriptPreview: String var lastCommittedText: String var speechStatusMessage: String + /// 空串 = 自动跟随系统首选语言 + var speechLocaleIdentifier: String + var activeLocaleDescription: String + var availableSpeechLocales: [SpeechLocaleOption] var bluetoothReady: Bool { bluetoothPermissionGranted && bluetoothPoweredOn @@ -50,6 +54,7 @@ struct AhaKeyOnboardingActions { var recheckPermissions: () -> Void var openSystemSettings: () -> Void var toggleTryExperience: () -> Void + var setSpeechLocale: (String) -> Void } enum AhaKeyOnboardingPermissionKind { @@ -326,6 +331,30 @@ struct UnifiedAhaKeyOnboardingView: View { detail: "请蓝牙连接小键盘后,将光标放在这里,按下麦克风键开始说话。" ) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Text("识别语言") + .font(.system(size: 15, weight: .semibold)) + Picker("", selection: Binding( + get: { permissionState.speechLocaleIdentifier }, + set: { actions.setSpeechLocale($0) } + )) { + Text("自动(跟随系统)").tag("") + Divider() + ForEach(permissionState.availableSpeechLocales) { option in + Text(option.name).tag(option.id) + } + } + .labelsHidden() + .frame(maxWidth: 240) + Spacer(minLength: 0) + } + Text("当前生效:\(permissionState.activeLocaleDescription)。说的语言和这里不一致就会转写成乱码,先选对再试。") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 14) { HStack(spacing: 10) { Circle() diff --git a/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AhaKeySocketTests.swift b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AhaKeySocketTests.swift new file mode 100644 index 00000000..c99200ff --- /dev/null +++ b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/AhaKeySocketTests.swift @@ -0,0 +1,34 @@ +import XCTest + +@testable import AhaKeyConfigAgent + +final class AhaKeySocketTests: XCTestCase { + /// sun_path 只有 104 字节,socket 路径含用户主目录,超长必须失败而不是溢出。 + func testRejectsPathLongerThanSunPath() { + let tooLong = "/" + String(repeating: "a", count: 200) + XCTAssertNil(AhaKeySocket.makeAddress(path: tooLong)) + } + + func testAcceptsPathAtCapacityBoundary() { + var probe = sockaddr_un() + let capacity = MemoryLayout.size(ofValue: probe.sun_path) + _ = probe + + XCTAssertNotNil(AhaKeySocket.makeAddress(path: String(repeating: "a", count: capacity - 1))) + XCTAssertNil(AhaKeySocket.makeAddress(path: String(repeating: "a", count: capacity))) + } + + func testWritesTerminatedPath() throws { + let path = "/tmp/ahakey-test.sock" + var addr = try XCTUnwrap(AhaKeySocket.makeAddress(path: path)) + XCTAssertEqual(addr.sun_family, sa_family_t(AF_UNIX)) + let written = withUnsafePointer(to: &addr.sun_path) { + String(cString: UnsafeRawPointer($0).assumingMemoryBound(to: CChar.self)) + } + XCTAssertEqual(written, path) + } + + func testDefaultPathFitsForATypicalUserName() { + XCTAssertNotNil(AhaKeySocket.makeAddress(path: AhaKeySocket.defaultPath)) + } +} diff --git a/ahakeyconfig-mac/Tests/AhaKeyConfigTests/DiagnosticLogRotatorTests.swift b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/DiagnosticLogRotatorTests.swift new file mode 100644 index 00000000..11574b65 --- /dev/null +++ b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/DiagnosticLogRotatorTests.swift @@ -0,0 +1,85 @@ +import XCTest + +@testable import AhaKeyConfig + +final class DiagnosticLogRotatorTests: XCTestCase { + private var directory: URL! + private var logURL: URL! + + override func setUpWithError() throws { + directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("DiagnosticLogRotatorTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + logURL = directory.appendingPathComponent("native-speech.log") + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + private func write(_ contents: String, to url: URL) throws { + try Data(contents.utf8).write(to: url) + } + + private var existingFileNames: Set { + let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) + return Set(names ?? []) + } + + func testDoesNothingBelowLimit() throws { + try write("small", to: logURL) + DiagnosticLogRotator.rotateIfNeeded(at: logURL, maxBytes: 1024) + XCTAssertEqual(existingFileNames, ["native-speech.log"]) + } + + func testDoesNothingWhenFileMissing() { + DiagnosticLogRotator.rotateIfNeeded(at: logURL, maxBytes: 1) + XCTAssertTrue(existingFileNames.isEmpty) + } + + func testRotatesCurrentFileAside() throws { + try write("0123456789", to: logURL) + DiagnosticLogRotator.rotateIfNeeded(at: logURL, maxBytes: 10) + + // 轮转后当前文件不复存在,由调用方新建;内容保留在 .1 + XCTAssertEqual(existingFileNames, ["native-speech.log.1"]) + XCTAssertEqual(try String(contentsOf: DiagnosticLogRotator.rotatedURL(for: logURL, index: 1)), "0123456789") + } + + func testKeepsAtMostFiveFilesAndDropsOldest() throws { + // 连续轮转 8 次,每次的内容可区分,用来确认丢掉的是最老的那份 + for generation in 0 ..< 8 { + try write("generation-\(generation)", to: logURL) + DiagnosticLogRotator.rotateIfNeeded(at: logURL, maxBytes: 1) + } + + // 保留数含当前正在写的一份;此刻当前那份刚被挪走,所以磁盘上是 4 份历史 + XCTAssertEqual(existingFileNames, [ + "native-speech.log.1", + "native-speech.log.2", + "native-speech.log.3", + "native-speech.log.4", + ]) + + // .1 最新、.4 最老,generation-3 及更早的已被丢弃 + for (index, generation) in zip(1 ... 4, [7, 6, 5, 4]) { + let url = DiagnosticLogRotator.rotatedURL(for: logURL, index: index) + XCTAssertEqual(try String(contentsOf: url), "generation-\(generation)") + } + } + + func testHonoursSmallerFileBudget() throws { + for generation in 0 ..< 5 { + try write("generation-\(generation)", to: logURL) + DiagnosticLogRotator.rotateIfNeeded(at: logURL, maxBytes: 1, maxFiles: 2) + } + XCTAssertEqual(existingFileNames, ["native-speech.log.1"]) + XCTAssertEqual(try String(contentsOf: DiagnosticLogRotator.rotatedURL(for: logURL, index: 1)), "generation-4") + } + + func testSingleFileBudgetDiscardsInsteadOfKeepingHistory() throws { + try write("0123456789", to: logURL) + DiagnosticLogRotator.rotateIfNeeded(at: logURL, maxBytes: 1, maxFiles: 1) + XCTAssertTrue(existingFileNames.isEmpty) + } +} diff --git a/ahakeyconfig-mac/Tests/AhaKeyConfigTests/SpeechLocaleResolverTests.swift b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/SpeechLocaleResolverTests.swift new file mode 100644 index 00000000..1cbed140 --- /dev/null +++ b/ahakeyconfig-mac/Tests/AhaKeyConfigTests/SpeechLocaleResolverTests.swift @@ -0,0 +1,104 @@ +import XCTest + +@testable import AhaKeyConfig + +final class SpeechLocaleResolverTests: XCTestCase { + /// 取自真机 `SFSpeechRecognizer.supportedLocales()` 的一个子集,含 en 的多个地区变体。 + private let supported: Set = [ + "zh-CN", "zh-TW", "zh-HK", + "en-US", "en-GB", "en-AU", "en-IN", + "ja-JP", "de-DE", + ].map(Locale.init(identifier:)).reduce(into: Set()) { $0.insert($1) } + + /// 稳定的 stub:不依赖本机语音组件,测试才有确定的期望值。 + private func normalize(_ language: String) -> Locale? { + let defaults = ["en": "en-US", "zh": "zh-CN", "ja": "ja-JP", "de": "de-DE"] + return defaults[language].map(Locale.init(identifier:)) + } + + // MARK: match + + func testMatchesLanguageAndRegionExactly() { + // zh-Hans-CN 带脚本码,仍应落到 zh-CN 而不是 zh-TW / zh-HK + let hit = SpeechLocaleResolver.match("zh-Hans-CN", in: supported, normalize: normalize) + XCTAssertEqual(hit?.identifier, "zh-CN") + } + + func testFallsBackToLanguageWhenRegionHasNoRecognizer() { + // 没有 en-CN 识别器,退到该语言的默认地区 + let hit = SpeechLocaleResolver.match("en-CN", in: supported, normalize: normalize) + XCTAssertEqual(hit?.identifier, "en-US") + } + + func testLanguageOnlyFallbackIsDeterministic() { + // en 在 supported 里有 4 个变体;Set 无序,结果必须始终一致 + let results = (0 ..< 20).map { _ in + SpeechLocaleResolver.match("en-CN", in: supported, normalize: normalize)?.identifier + } + XCTAssertEqual(Set(results), ["en-US"]) + } + + func testReturnsNilForUnparsableTag() { + XCTAssertNil(SpeechLocaleResolver.match("", in: supported, normalize: normalize)) + } + + func testReturnsNilWhenNormalizedLocaleIsUnsupported() { + // 归一化给出的地区不在支持列表里时,不能硬塞给识别器 + let hit = SpeechLocaleResolver.match("fr-FR", in: supported) { _ in Locale(identifier: "fr-FR") } + XCTAssertNil(hit) + } + + // MARK: resolve + + func testExplicitPreferenceWinsOverSystemLanguages() { + let hit = SpeechLocaleResolver.resolve( + preference: "zh-CN", + preferredLanguages: ["en-CN", "zh-Hans-CN"], + supported: supported, + normalize: normalize + ) + XCTAssertEqual(hit?.identifier, "zh-CN") + } + + func testUnsupportedPreferenceIsIgnored() { + // 用户选过的语言在别的机器上可能不受支持,此时应回到自动推断而不是失败 + let hit = SpeechLocaleResolver.resolve( + preference: "ko-KR", + preferredLanguages: ["ja-JP"], + supported: supported, + normalize: normalize + ) + XCTAssertEqual(hit?.identifier, "ja-JP") + } + + func testAutomaticKeepsPreferredLanguageOrder() { + // 首选列表第一项能匹配上就用它,不因为后面有"更精确"的项而改变顺序语义 + let hit = SpeechLocaleResolver.resolve( + preference: "", + preferredLanguages: ["en-CN", "zh-Hans-CN"], + supported: supported, + normalize: normalize + ) + XCTAssertEqual(hit?.identifier, "en-US") + } + + func testAutomaticSkipsUnmatchableEntries() { + let hit = SpeechLocaleResolver.resolve( + preference: "", + preferredLanguages: ["xx-YY", "ja-JP"], + supported: supported, + normalize: normalize + ) + XCTAssertEqual(hit?.identifier, "ja-JP") + } + + func testReturnsNilWhenNothingMatches() { + let hit = SpeechLocaleResolver.resolve( + preference: "", + preferredLanguages: ["xx-YY"], + supported: supported, + normalize: normalize + ) + XCTAssertNil(hit) + } +} diff --git a/ahakeyconfig-mac/scripts/ahakey-state.sh b/ahakeyconfig-mac/scripts/ahakey-state.sh index 82ba8465..8d75e2a8 100755 --- a/ahakeyconfig-mac/scripts/ahakey-state.sh +++ b/ahakeyconfig-mac/scripts/ahakey-state.sh @@ -8,7 +8,7 @@ # PreToolUse=3 SessionStart=4 Stop=5 # TaskCompleted=6 UserPromptSubmit=7 SessionEnd=8 -SOCKET="/tmp/ahakey.sock" +SOCKET="$HOME/Library/Application Support/AhaKeyConfig/agent.sock" STATE="${1:-0}" [ -S "$SOCKET" ] && echo "$STATE" | nc -U "$SOCKET" -w 1 2>/dev/null || true