From 5770162fcd3d9080f253a6d7e1fa8baafa507277 Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Tue, 2 Jun 2026 14:49:18 +0800 Subject: [PATCH 01/10] Add AhaKeyPluginKit + Plugin demo executable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON-RPC 2.0 over stdio 插件骨架,rebased 到 upstream/main 新结构: * AhaKeyPluginKit (library) - JSONRPC.swift 协议类型 (JSONValue / Request / Response / Error) - PluginClient.swift actor 客户端 (readabilityHandler 事件驱动 stdin/stdout) - PluginHost.swift host/* 方法 + 权限白名单 - PluginLifecycle.swift initialize / initialized / shutdown / exit - PluginManifest.swift plugin.json + ${pluginDir} 占位符 - PluginManager.swift 扫描目录、批量加载、错误隔离 * Plugin (executable demo) - `swift run Plugin` 跑一遍 PluginManager 流程 - 用 AHAKEY_PLUGINS_DIR 指定插件目录 * AhaKeyConfig 主 app - dependencies 加 AhaKeyPluginKit (可直接 import) - exclude AhaKeyPlugin / AhaKeyPluginKit 避免被当成主 app sources 兼容 macOS 12:URL(filePath:) 全部改回 URL(fileURLWithPath:) --- platforms/macos/Package.swift | 15 +- .../macos/Sources/AhaKeyPlugin/Plugin.swift | 47 +++ .../Sources/AhaKeyPluginKit/JSONRPC.swift | 173 +++++++++ .../AhaKeyPluginKit/PluginClient.swift | 356 ++++++++++++++++++ .../Sources/AhaKeyPluginKit/PluginHost.swift | 182 +++++++++ .../AhaKeyPluginKit/PluginLifecycle.swift | 66 ++++ .../AhaKeyPluginKit/PluginManager.swift | 145 +++++++ .../AhaKeyPluginKit/PluginManifest.swift | 132 +++++++ 8 files changed, 1114 insertions(+), 2 deletions(-) create mode 100644 platforms/macos/Sources/AhaKeyPlugin/Plugin.swift create mode 100644 platforms/macos/Sources/AhaKeyPluginKit/JSONRPC.swift create mode 100644 platforms/macos/Sources/AhaKeyPluginKit/PluginClient.swift create mode 100644 platforms/macos/Sources/AhaKeyPluginKit/PluginHost.swift create mode 100644 platforms/macos/Sources/AhaKeyPluginKit/PluginLifecycle.swift create mode 100644 platforms/macos/Sources/AhaKeyPluginKit/PluginManager.swift create mode 100644 platforms/macos/Sources/AhaKeyPluginKit/PluginManifest.swift diff --git a/platforms/macos/Package.swift b/platforms/macos/Package.swift index 8cadb087..b7b1e0f3 100644 --- a/platforms/macos/Package.swift +++ b/platforms/macos/Package.swift @@ -9,13 +9,24 @@ let package = Package( products: [ .executable(name: "AhaKeyConfig", targets: ["AhaKeyConfig"]), .executable(name: "ahakeyconfig-agent", targets: ["AhaKeyConfigAgent"]), + .library(name: "AhaKeyPluginKit", targets: ["AhaKeyPluginKit"]), ], - + targets: [ + .target( + name: "AhaKeyPluginKit", + path: "Sources/AhaKeyPluginKit" + ), + .executableTarget( + name: "Plugin", + dependencies: ["AhaKeyPluginKit"], + path: "Sources/AhaKeyPlugin" + ), .executableTarget( name: "AhaKeyConfig", + dependencies: ["AhaKeyPluginKit"], path: "Sources", - exclude: ["Agent"], + exclude: ["Agent", "AhaKeyPlugin", "AhaKeyPluginKit"], // 与 scripts/build.sh 中 Info.plist 一致。嵌入 __info_plist 段后 TCC 可识别。 // Debug 使用单独 plist:系统在「隐私与安全性」列表中显示为「AhaKey Studio(调试)」,与正式包区分。 linkerSettings: [ diff --git a/platforms/macos/Sources/AhaKeyPlugin/Plugin.swift b/platforms/macos/Sources/AhaKeyPlugin/Plugin.swift new file mode 100644 index 00000000..4095f2fa --- /dev/null +++ b/platforms/macos/Sources/AhaKeyPlugin/Plugin.swift @@ -0,0 +1,47 @@ +import AhaKeyPluginKit +import Foundation + +// AhaKey Plugin demo executable —— 把 `AhaKeyPluginKit` 当 SDK 走一遍 manager 流程: +// 1. 扫描 `~/Library/Application Support/AhaKeyConfig/plugins/` +// (或环境变量 `AHAKEY_PLUGINS_DIR` 指定的目录) +// 2. 按 manifest 拉起每个插件 + 握手 +// 3. 列出加载结果 +// 4. 停一会儿(让插件有机会反向调用 host) +// 5. 全部 shutdown +// +// 真实主 app 直接 `import AhaKeyPluginKit` 使用 `PluginManager`,不依赖这个 executable。 +// 这里只是给 `swift run Plugin` 一个能跑的入口,便于本地验证骨架。 + +@main +struct PluginDemoMain { + static func main() async { + let manager = PluginManager() + let count = await manager.loadAll() + + let plugins = await manager.allLoaded() + if plugins.isEmpty { + FileHandle.standardError.write(Data( + """ + [plugin-demo] no plugins loaded. \ + drop a plugin.json into \(PluginManager.defaultPluginsRoot.path) \ + or set AHAKEY_PLUGINS_DIR= and rerun. + + """.utf8 + )) + } else { + FileHandle.standardError.write(Data( + "[plugin-demo] loaded \(count) plugin(s):\n".utf8 + )) + for p in plugins { + let reported = p.initialize?.name ?? "" + FileHandle.standardError.write(Data( + " - \(p.manifest.id) v\(p.manifest.version) [plugin says: \(reported)]\n".utf8 + )) + } + } + + // 给插件 1 秒做反向 RPC 之类的事,再 shutdown。 + try? await Task.sleep(nanoseconds: 1_000_000_000) + await manager.unloadAll() + } +} diff --git a/platforms/macos/Sources/AhaKeyPluginKit/JSONRPC.swift b/platforms/macos/Sources/AhaKeyPluginKit/JSONRPC.swift new file mode 100644 index 00000000..ca0f1e39 --- /dev/null +++ b/platforms/macos/Sources/AhaKeyPluginKit/JSONRPC.swift @@ -0,0 +1,173 @@ +import Foundation + +// JSON-RPC 2.0 协议类型(https://www.jsonrpc.org/specification)。 +// +// 设计取舍: +// - params / result 是任意 JSON,用本文件里的 `JSONValue` 表达,避免引入第三方 AnyCodable。 +// - id 允许 int / string / null(按规范);本客户端只主动使用 int,但收到 string/null 也能解。 +// - 不区分 batch 调用(暂不需要)。要支持时再加 `[JSONRPCResponse]` 解码分支。 + +public enum JSONRPC { + public static let version = "2.0" +} + +// MARK: - JSONValue + +/// 任意 JSON 值。 +public enum JSONValue: Equatable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) +} + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let b = try? c.decode(Bool.self) { self = .bool(b); return } + // Int 先于 Double:纯整数解出来是 .int。 + if let i = try? c.decode(Int.self) { self = .int(i); return } + if let d = try? c.decode(Double.self) { self = .double(d); return } + if let s = try? c.decode(String.self) { self = .string(s); return } + if let a = try? c.decode([JSONValue].self) { self = .array(a); return } + if let o = try? c.decode([String: JSONValue].self) { self = .object(o); return } + throw DecodingError.dataCorruptedError( + in: c, debugDescription: "Unsupported JSON value" + ) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .null: try c.encodeNil() + case .bool(let b): try c.encode(b) + case .int(let i): try c.encode(i) + case .double(let d): try c.encode(d) + case .string(let s): try c.encode(s) + case .array(let a): try c.encode(a) + case .object(let o): try c.encode(o) + } + } +} + +public extension JSONValue { + /// 把任意 Encodable 转成 JSONValue(走一遍 JSONEncoder/Decoder)。 + static func encode(_ value: T) throws -> JSONValue { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(JSONValue.self, from: data) + } + + /// 把 JSONValue 解成具体类型。 + func decode(_ type: T.Type) throws -> T { + let data = try JSONEncoder().encode(self) + return try JSONDecoder().decode(type, from: data) + } +} + +// MARK: - ID + +/// JSON-RPC id:规范允许 Number / String / Null。 +public enum JSONRPCID: Hashable, Sendable { + case int(Int) + case string(String) + case null +} + +extension JSONRPCID: Codable { + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let i = try? c.decode(Int.self) { self = .int(i); return } + if let s = try? c.decode(String.self) { self = .string(s); return } + throw DecodingError.dataCorruptedError( + in: c, debugDescription: "JSON-RPC id must be number, string, or null" + ) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .int(let i): try c.encode(i) + case .string(let s): try c.encode(s) + case .null: try c.encodeNil() + } + } +} + +// MARK: - Request / Notification + +/// 出站请求;`id == nil` 代表 Notification(按规范连 `id` 字段都不编码)。 +public struct JSONRPCRequest: Encodable, Sendable { + public let jsonrpc: String + public let method: String + public let params: JSONValue? + public let id: JSONRPCID? + + public init(method: String, params: JSONValue?, id: JSONRPCID?) { + self.jsonrpc = JSONRPC.version + self.method = method + self.params = params + self.id = id + } + + private enum CodingKeys: String, CodingKey { + case jsonrpc, method, params, id + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(jsonrpc, forKey: .jsonrpc) + try c.encode(method, forKey: .method) + if let params { try c.encode(params, forKey: .params) } + if let id { try c.encode(id, forKey: .id) } + } +} + +// MARK: - Response + +public struct JSONRPCResponse: Decodable, Sendable { + public let jsonrpc: String + public let id: JSONRPCID? + public let result: JSONValue? + public let error: JSONRPCError? +} + +public struct JSONRPCError: Codable, Sendable, Error { + public let code: Int + public let message: String + public let data: JSONValue? + + public init(code: Int, message: String, data: JSONValue? = nil) { + self.code = code + self.message = message + self.data = data + } +} + +public extension JSONRPCError { + // 规范保留错误码。-32000..-32099 留给实现自定义。 + static let parseError = -32700 + static let invalidRequest = -32600 + static let methodNotFound = -32601 + static let invalidParams = -32602 + static let internalError = -32603 +} + +// MARK: - 客户端侧错误 + +public enum PluginClientError: Error, Sendable { + /// 子进程未启动 / 已退出。 + case notRunning + /// 子进程意外退出,附终止码。 + case processTerminated(Int32) + /// 响应 id 与所有 pending 都对不上。 + case unknownResponseID(JSONRPCID?) + /// 单次 call 等待超时。 + case timeout + /// 解码失败。 + case decodingFailed(String) +} diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginClient.swift b/platforms/macos/Sources/AhaKeyPluginKit/PluginClient.swift new file mode 100644 index 00000000..3038c8f1 --- /dev/null +++ b/platforms/macos/Sources/AhaKeyPluginKit/PluginClient.swift @@ -0,0 +1,356 @@ +import Foundation + +/// 一个 JSON-RPC 2.0 over stdio 客户端。 +/// +/// - 启动子进程(插件 host 端 = 我们;子进程 = 插件本体)。 +/// - 帧格式:**newline-delimited JSON**(每条消息一行 UTF-8 + `\n`)。 +/// 这是 stdio 通讯里最常见的选择;如果未来要换成 LSP 的 `Content-Length` 头, +/// 只需替换 `sendFramed(_:)` 与 reader 里的拆帧逻辑。 +/// - 子进程的 `stderr` 透传给上层(默认打到本进程 stderr,可换 `onStderr`)。 +/// +/// 用法: +/// ```swift +/// let client = PluginClient( +/// executable: URL(fileURLWithPath: "/usr/bin/env"), +/// arguments: ["node", "my-plugin.js"] +/// ) +/// try client.start() +/// let result = try await client.call("add", params: .array([.int(1), .int(2)])) +/// // result == .int(3) +/// client.stop() +/// ``` +public actor PluginClient { + // MARK: - 配置 + + private let executable: URL + private let arguments: [String] + private let environment: [String: String]? + private let workingDirectory: URL? + + /// 单次 `call` 等待响应的默认超时。`notify` 不受此影响。 + public var defaultCallTimeout: TimeInterval = 30 + + /// 子进程 stderr 回调,nil 表示透传到本进程 stderr。 + public var onStderr: (@Sendable (String) -> Void)? + + // MARK: - 进程 / 管道 + + private let process = Process() + private let stdinPipe = Pipe() + private let stdoutPipe = Pipe() + private let stderrPipe = Pipe() + private var started = false + + // MARK: - 协议状态 + + private var nextID = 1 + private var pending: [Int: CheckedContinuation] = [:] + + /// 服务端 → 客户端的 notification 处理器(method → 处理闭包)。 + public typealias NotificationHandler = @Sendable (JSONValue?) -> Void + private var notificationHandlers: [String: NotificationHandler] = [:] + + /// 服务端 → 客户端的 request 处理器(method → 返回 result 或抛 JSONRPCError)。 + /// JSON-RPC 是对等协议,子进程也可以反向调用我们。 + public typealias RequestHandler = @Sendable (JSONValue?) async throws -> JSONValue + private var requestHandlers: [String: RequestHandler] = [:] + + // MARK: - 初始化 + + public init( + executable: URL, + arguments: [String] = [], + environment: [String: String]? = nil, + workingDirectory: URL? = nil + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.workingDirectory = workingDirectory + } + + // MARK: - 启动 / 停止 + + public func start() throws { + guard !started else { return } + process.executableURL = executable + process.arguments = arguments + if let environment { process.environment = environment } + if let workingDirectory { process.currentDirectoryURL = workingDirectory } + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + // 子进程退出时,唤醒所有 pending。 + process.terminationHandler = { [weak self] proc in + Task { await self?.handleProcessTermination(status: proc.terminationStatus) } + } + + try process.run() + started = true + startReader() + startStderrReader() + } + + /// 优雅停止:关闭 stdin,等子进程自然退出;若想强杀用 `terminate()`。 + public func stop() { + guard started else { return } + try? stdinPipe.fileHandleForWriting.close() + } + + public func terminate() { + guard started else { return } + process.terminate() + } + + // MARK: - 注册回调 + + public func setNotificationHandler(_ method: String, _ handler: @escaping NotificationHandler) { + notificationHandlers[method] = handler + } + + public func setRequestHandler(_ method: String, _ handler: @escaping RequestHandler) { + requestHandlers[method] = handler + } + + // MARK: - 发送:call / notify + + /// 发起 JSON-RPC 调用,等待响应。 + @discardableResult + public func call( + _ method: String, + params: JSONValue? = nil, + timeout: TimeInterval? = nil + ) async throws -> JSONValue { + guard started, process.isRunning else { throw PluginClientError.notRunning } + + let id = nextID + nextID += 1 + let request = JSONRPCRequest(method: method, params: params, id: .int(id)) + + // 先注册 continuation,再发数据,避免响应早于注册。 + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + pending[id] = cont + do { + try sendFramed(request) + } catch { + pending.removeValue(forKey: id) + cont.resume(throwing: error) + return + } + armTimeout(id: id, after: timeout ?? defaultCallTimeout) + } + } onCancel: { + Task { await self.failPending(id: id, with: CancellationError()) } + } + } + + /// 发送 Notification(无 id,不等响应)。 + public func notify(_ method: String, params: JSONValue? = nil) throws { + guard started, process.isRunning else { throw PluginClientError.notRunning } + let request = JSONRPCRequest(method: method, params: params, id: nil) + try sendFramed(request) + } + + // MARK: - 帧编码 + + private func sendFramed(_ request: JSONRPCRequest) throws { + var data = try JSONEncoder().encode(request) + data.append(0x0A) // '\n' + if ProcessInfo.processInfo.environment["AHAKEY_PLUGIN_DEBUG"] != nil, + let s = String(data: data, encoding: .utf8) { + FileHandle.standardError.write(Data("[client→plugin] \(s)".utf8)) + } + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } + + // MARK: - Reader(stdout) + + // 之前用 `handle.bytes.lines` 在 daemon-thread 写 stdout 的场景下会延迟数秒才触发, + // 改用 `readabilityHandler` 走 dispatch IO,事件驱动、即时。 + private var stdoutBuffer = Data() + + private func startReader() { + let handle = stdoutPipe.fileHandleForReading + handle.readabilityHandler = { [weak self] h in + let data = h.availableData + guard let self else { return } + if data.isEmpty { + // EOF —— 子进程关了 stdout。 + h.readabilityHandler = nil + Task { await self.handleProcessTermination(status: nil) } + return + } + Task { await self.appendStdout(data) } + } + } + + private func appendStdout(_ data: Data) async { + stdoutBuffer.append(data) + while let nl = stdoutBuffer.firstIndex(of: 0x0A) { + let lineData = stdoutBuffer.subdata(in: stdoutBuffer.startIndex ..< nl) + stdoutBuffer.removeSubrange(stdoutBuffer.startIndex ... nl) + if let line = String(data: lineData, encoding: .utf8) { + await handleIncoming(line: line) + } + } + } + + private func startStderrReader() { + let handle = stderrPipe.fileHandleForReading + let onStderr = self.onStderr + handle.readabilityHandler = { h in + let data = h.availableData + if data.isEmpty { + h.readabilityHandler = nil + return + } + if let onStderr, let s = String(data: data, encoding: .utf8) { + // 按行回调;保留尾部不完整行(罕见)。 + for line in s.split(separator: "\n", omittingEmptySubsequences: false) { + if !line.isEmpty { onStderr(String(line)) } + } + } else { + FileHandle.standardError.write(data) + } + } + } + + // MARK: - 入站派发 + + private func handleIncoming(line: String) async { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return } + if ProcessInfo.processInfo.environment["AHAKEY_PLUGIN_DEBUG"] != nil { + FileHandle.standardError.write(Data("[client←plugin] \(trimmed)\n".utf8)) + } + + // 一条消息可能是:Response(含 id + result/error)、Request(含 id + method)、 + // 或 Notification(含 method 但无 id)。先看有没有 `method` 字段。 + if let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + raw["method"] is String { + await dispatchInbound(data: data, raw: raw) + return + } + + // 当作 Response 解。 + do { + let resp = try JSONDecoder().decode(JSONRPCResponse.self, from: data) + dispatch(response: resp) + } catch { + // 实在解不出来:丢到 stderr 便于排查。 + FileHandle.standardError.write( + Data("[PluginClient] unrecognized frame: \(trimmed)\n".utf8) + ) + } + } + + private func dispatch(response: JSONRPCResponse) { + guard case .int(let i)? = response.id else { + // 我们发出去的 id 全是 int;其他 id 形式直接丢弃。 + return + } + guard let cont = pending.removeValue(forKey: i) else { return } + if let err = response.error { + cont.resume(throwing: err) + } else { + cont.resume(returning: response.result ?? .null) + } + } + + private func dispatchInbound(data: Data, raw: [String: Any]) async { + guard let method = raw["method"] as? String else { return } + let params: JSONValue? = { + guard raw["params"] != nil else { return nil } + // 用 JSONValue 重新解一遍 params 字段。 + struct Wrapper: Decodable { let params: JSONValue? } + return (try? JSONDecoder().decode(Wrapper.self, from: data))?.params + }() + + if raw["id"] == nil { + // Notification + notificationHandlers[method]?(params) + return + } + + // 入站 Request:要回 Response。 + let id: JSONRPCID + do { + struct Wrapper: Decodable { let id: JSONRPCID } + id = try JSONDecoder().decode(Wrapper.self, from: data).id + } catch { + return + } + + if let handler = requestHandlers[method] { + do { + let result = try await handler(params) + try sendResult(id: id, result: result) + } catch let err as JSONRPCError { + try? sendError(id: id, error: err) + } catch { + try? sendError(id: id, error: JSONRPCError( + code: JSONRPCError.internalError, + message: "\(error)", + data: nil + )) + } + } else { + try? sendError(id: id, error: JSONRPCError( + code: JSONRPCError.methodNotFound, + message: "Method not found: \(method)", + data: nil + )) + } + } + + private struct OutboundResponse: Encodable { + let jsonrpc: String = JSONRPC.version + let id: JSONRPCID + let result: JSONValue? + let error: JSONRPCError? + } + + private func sendResult(id: JSONRPCID, result: JSONValue) throws { + let resp = OutboundResponse(id: id, result: result, error: nil) + var data = try JSONEncoder().encode(resp) + data.append(0x0A) + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } + + private func sendError(id: JSONRPCID, error: JSONRPCError) throws { + let resp = OutboundResponse(id: id, result: nil, error: error) + var data = try JSONEncoder().encode(resp) + data.append(0x0A) + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } + + // MARK: - 超时 / 取消 / 进程终止 + + private func armTimeout(id: Int, after seconds: TimeInterval) { + guard seconds > 0 else { return } + Task { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + failPending(id: id, with: PluginClientError.timeout) + } + } + + private func failPending(id: Int, with error: Error) { + guard let cont = pending.removeValue(forKey: id) else { return } + cont.resume(throwing: error) + } + + private func handleProcessTermination(status: Int32?) { + guard started else { return } + started = false + let err: Error = status.map { PluginClientError.processTerminated($0) } + ?? PluginClientError.notRunning + let pendingSnapshot = pending + pending.removeAll() + for (_, cont) in pendingSnapshot { + cont.resume(throwing: err) + } + } +} + diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginHost.swift b/platforms/macos/Sources/AhaKeyPluginKit/PluginHost.swift new file mode 100644 index 00000000..e1669abf --- /dev/null +++ b/platforms/macos/Sources/AhaKeyPluginKit/PluginHost.swift @@ -0,0 +1,182 @@ +import Foundation + +// 在 PluginClient 之上包一层「宿主能力」:注册一组 `host/*` JSON-RPC method, +// 让插件能调用宿主提供的服务。 +// +// 当前最小三件套: +// - host/getInfo → 返回宿主 app 元信息(bundleID / version / build / platform) +// - host/log → 插件把日志打到宿主 stderr +// - host/getSwitchState → 通过 /tmp/ahakey.sock 问 daemon 拨杆状态(agent 没跑则返回 null) +// +// 后续要加的(如 host/showNotification、host/openURL、host/storage/*)按相同方式接到 +// `registerDefaultHandlers` 即可。增加新方法时记得在 manifest 的权限白名单里同步声明。 + +public final class PluginHost: @unchecked Sendable { + public let client: PluginClient + public let appInfo: HostAppInfo + + /// 该插件被允许调用的 `host/*` 方法集合;`nil` 表示不限制(仅用于 demo / 第一方)。 + public let permissions: Set? + + public init( + client: PluginClient, + appInfo: HostAppInfo = .current(), + permissions: Set? = nil + ) { + self.client = client + self.appInfo = appInfo + self.permissions = permissions + } + + /// 宿主当前 expose 的全部 `host/*` 方法名 —— 用于 `plugin/initialize` 时告诉插件。 + public static let availableHostMethods: [String] = [ + "host/getInfo", + "host/log", + "host/getSwitchState", + ] + + /// 注册默认 `host/*` 方法集。请在 `client.start()` 之前调用。 + public func registerDefaultHandlers() async { + let appInfo = self.appInfo + + await register("host/getInfo") { _ in + try JSONValue.encode(appInfo) + } + + await register("host/log") { params in + HostLog.write(params: params) + return .null + } + + await register("host/getSwitchState") { _ in + let state = HostAgentBridge.readSwitchState() + return .object([ + "switchState": state.map { JSONValue.int($0) } ?? .null, + "agentReachable": .bool(state != nil), + ]) + } + } + + /// 包一层权限检查后注册。不在 `permissions` 里的 method 会被 -32601 直接拒掉。 + private func register( + _ method: String, + _ handler: @escaping PluginClient.RequestHandler + ) async { + let permissions = self.permissions + await client.setRequestHandler(method) { params in + if let permissions, !permissions.contains(method) { + throw JSONRPCError( + code: JSONRPCError.methodNotFound, + message: "Method \(method) not in plugin permissions", + data: nil + ) + } + return try await handler(params) + } + } +} + +// MARK: - host/getInfo + +public struct HostAppInfo: Codable, Sendable { + public let bundleID: String + public let version: String + public let build: String + public let platform: String + + public init(bundleID: String, version: String, build: String, platform: String = "macos") { + self.bundleID = bundleID + self.version = version + self.build = build + self.platform = platform + } + + /// 从 `Bundle.main` 读;当宿主不是 app bundle(例如本 Plugin demo executable)时给保底值。 + public static func current() -> HostAppInfo { + let info = Bundle.main.infoDictionary ?? [:] + return .init( + bundleID: Bundle.main.bundleIdentifier ?? "dev.ahakey.unknown", + version: info["CFBundleShortVersionString"] as? String ?? "0.0.0", + build: info["CFBundleVersion"] as? String ?? "0" + ) + } +} + +// MARK: - host/log + +enum HostLog { + /// 兼容两种 params 形态: + /// - `{ "level": "info", "message": "..." }` + /// - `"plain string message"` + static func write(params: JSONValue?) { + var level = "info" + var message = "" + if case .object(let o)? = params { + if case .string(let s)? = o["level"] { level = s } + if case .string(let s)? = o["message"] { message = s } + } else if case .string(let s)? = params { + message = s + } + FileHandle.standardError.write( + Data("[plugin:\(level)] \(message)\n".utf8) + ) + } +} + +// MARK: - host/getSwitchState + +/// 与 `Agent/HookClient.swift` 走同一套 `/tmp/ahakey.sock` 协议 +/// (`{"cmd":"permission","value":1}` → `{"switchState": Int, ...}`)。 +/// agent 没跑或 BLE 没连上时返回 nil。 +/// +/// 没把 socket 协议抽成共用 util,是因为 Agent target 与 AhaKeyPluginKit 暂不互相依赖; +/// 后续若多处都要用,再抽 `AhaKeyAgentBridge` library。 +enum HostAgentBridge { + static let socketPath = "/tmp/ahakey.sock" + static let timeout: Double = 2.0 + + static func readSwitchState() -> Int? { + let req: [String: Any] = ["cmd": "permission", "value": 1] + guard let reply = sendJson(req) else { return nil } + if let i = reply["switchState"] as? Int { return i } + if let n = reply["switchState"] as? NSNumber { return n.intValue } + return nil + } + + private static func sendJson(_ dict: [String: Any]) -> [String: Any]? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var tv = timeval(tv_sec: __darwin_time_t(timeout), 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) { sunPath in + let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self) + _ = strcpy(dst, src) + } + } + let len = socklen_t(MemoryLayout.size) + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, len) } + } + guard connected == 0 else { return nil } + + guard var payload = try? JSONSerialization.data(withJSONObject: dict) else { return nil } + payload.append(0x0A) + let wrote = payload.withUnsafeBytes { p -> Int in + guard let base = p.baseAddress else { return -1 } + return write(fd, base, p.count) + } + guard wrote >= 0 else { return nil } + + var buf = [UInt8](repeating: 0, count: 4096) + let n = read(fd, &buf, buf.count) + guard n > 0 else { return nil } + return (try? JSONSerialization.jsonObject(with: Data(buf[0 ..< Int(n)]))) as? [String: Any] + } +} diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginLifecycle.swift b/platforms/macos/Sources/AhaKeyPluginKit/PluginLifecycle.swift new file mode 100644 index 00000000..2125ac88 --- /dev/null +++ b/platforms/macos/Sources/AhaKeyPluginKit/PluginLifecycle.swift @@ -0,0 +1,66 @@ +import Foundation + +// 插件生命周期握手 —— 借鉴 LSP 的 initialize/initialized/shutdown 流程。 +// +// 时序: +// 1. 宿主 spawn 子进程 → `client.start()` +// 2. 宿主 → 插件: call `plugin/initialize` (params: { host, hostMethods }) +// 3. 插件 → 宿主: result `{ name, version, methods }`(声明自己能处理哪些 method) +// 4. 宿主 → 插件: notify `plugin/initialized`(可选;告诉插件可以开始干活) +// 5. ... 正常 RPC ... +// 6. 宿主 → 插件: call `plugin/shutdown`(等插件清理) +// 7. 宿主 → 插件: notify `plugin/exit` +// 8. 宿主关闭 stdin → 子进程退出 + +public struct PluginInitializeParams: Codable, Sendable { + public let host: HostAppInfo + public let hostMethods: [String] + + public init(host: HostAppInfo, hostMethods: [String]) { + self.host = host + self.hostMethods = hostMethods + } +} + +public struct PluginInitializeResult: Codable, Sendable { + /// 插件自称的 id / name / version;只用来记日志、设置面板展示。 + /// 宿主信任的是 manifest 里的那一份。 + public let name: String? + public let version: String? + + /// 插件声明它能处理的 method 列表。宿主可以据此提前拒绝乱发 method。 + public let methods: [String]? + + public init(name: String? = nil, version: String? = nil, methods: [String]? = nil) { + self.name = name + self.version = version + self.methods = methods + } +} + +public extension PluginClient { + /// 完成 `plugin/initialize` 握手。失败时把错误透出(超时 → `PluginClientError.timeout`)。 + @discardableResult + func initialize( + host: HostAppInfo, + hostMethods: [String], + timeout: TimeInterval = 5 + ) async throws -> PluginInitializeResult { + let params = try JSONValue.encode( + PluginInitializeParams(host: host, hostMethods: hostMethods) + ) + let result = try await call("plugin/initialize", params: params, timeout: timeout) + return try result.decode(PluginInitializeResult.self) + } + + /// 通知插件「初始化完成,可以开干」。失败被吞掉(notification 没有响应)。 + func sendInitialized() throws { + try notify("plugin/initialized") + } + + /// 请求插件清理并准备退出。失败/超时不抛,调用方接着 `stop()` 即可。 + func shutdown(timeout: TimeInterval = 3) async { + _ = try? await call("plugin/shutdown", timeout: timeout) + try? notify("plugin/exit") + } +} diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginManager.swift b/platforms/macos/Sources/AhaKeyPluginKit/PluginManager.swift new file mode 100644 index 00000000..3068a630 --- /dev/null +++ b/platforms/macos/Sources/AhaKeyPluginKit/PluginManager.swift @@ -0,0 +1,145 @@ +import Foundation + +// 扫描插件目录、加载 manifest、拉起所有插件,并管它们的生命周期。 +// +// 默认插件目录: +// ~/Library/Application Support/AhaKeyConfig/plugins//plugin.json +// +// 可通过环境变量 `AHAKEY_PLUGINS_DIR` 临时覆写(调试用)。 +// +// 单个插件加载失败不会影响其他插件 —— 错误写到 stderr,把这个 id 标记为 failed。 + +public actor PluginManager { + public struct LoadedPlugin: Sendable { + public let manifest: PluginManifest + public let host: PluginHost + public let initialize: PluginInitializeResult? + } + + public struct LoadFailure: Sendable { + public let manifestDirectory: URL + public let error: String + } + + private let pluginsRoot: URL + private let appInfo: HostAppInfo + private var loaded: [String: LoadedPlugin] = [:] + private(set) public var failures: [LoadFailure] = [] + + public init( + pluginsRoot: URL = PluginManager.defaultPluginsRoot, + appInfo: HostAppInfo = .current() + ) { + self.pluginsRoot = pluginsRoot + self.appInfo = appInfo + } + + public static var defaultPluginsRoot: URL { + if let override = ProcessInfo.processInfo.environment["AHAKEY_PLUGINS_DIR"], + !override.isEmpty { + return URL(fileURLWithPath: override) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/Application Support/AhaKeyConfig/plugins", + isDirectory: true + ) + } + + // MARK: - Discover + + /// 扫描 `pluginsRoot` 下所有一级子目录,挑出有 `plugin.json` 的。 + /// 不抛错(根目录不存在 → 空数组)。 + public func discover() -> [PluginManifest] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: pluginsRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + var out: [PluginManifest] = [] + for dir in entries { + let isDir = (try? dir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + guard isDir else { continue } + do { + let manifest = try PluginManifest.load(from: dir) + out.append(manifest) + } catch { + failures.append(.init(manifestDirectory: dir, error: "\(error)")) + FileHandle.standardError.write( + Data("[PluginManager] skip \(dir.lastPathComponent): \(error)\n".utf8) + ) + } + } + return out + } + + // MARK: - Load / Unload + + /// 把发现到的插件全部加载。返回成功数;失败的写到 `failures` 与 stderr。 + @discardableResult + public func loadAll() async -> Int { + let manifests = discover() + var ok = 0 + for m in manifests { + do { + try await load(manifest: m) + ok += 1 + } catch { + failures.append(.init(manifestDirectory: m.directory, error: "\(error)")) + FileHandle.standardError.write( + Data("[PluginManager] load \(m.id) failed: \(error)\n".utf8) + ) + } + } + return ok + } + + public func load(manifest: PluginManifest) async throws { + if loaded[manifest.id] != nil { return } // 幂等 + + let ep = manifest.resolvedEntrypoint + let client = PluginClient( + executable: ep.executable, + arguments: ep.arguments, + environment: ep.environment, + workingDirectory: ep.workingDirectory + ) + let host = PluginHost( + client: client, + appInfo: appInfo, + permissions: Set(manifest.permissions) + ) + await host.registerDefaultHandlers() + try await client.start() + + // 握手 + let info = try await client.initialize( + host: appInfo, + hostMethods: PluginHost.availableHostMethods + ) + try await client.sendInitialized() + + loaded[manifest.id] = LoadedPlugin(manifest: manifest, host: host, initialize: info) + } + + public func unloadAll() async { + for (_, p) in loaded { + await p.host.client.shutdown() + await p.host.client.stop() + } + loaded.removeAll() + } + + // MARK: - 查询 + + public func allLoaded() -> [LoadedPlugin] { + Array(loaded.values) + } + + public func plugin(id: String) -> LoadedPlugin? { + loaded[id] + } +} diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginManifest.swift b/platforms/macos/Sources/AhaKeyPluginKit/PluginManifest.swift new file mode 100644 index 00000000..8f68391a --- /dev/null +++ b/platforms/macos/Sources/AhaKeyPluginKit/PluginManifest.swift @@ -0,0 +1,132 @@ +import Foundation + +// 一个插件目录的清单文件(`plugin.json`),决定宿主怎么发现、启动、信任这个插件。 +// +// 目录约定: +// ~/Library/Application Support/AhaKeyConfig/plugins//plugin.json +// +// 最小示例: +// ```json +// { +// "id": "com.example.hello", +// "name": "Hello Plugin", +// "version": "0.1.0", +// "entrypoint": { +// "command": "python3", +// "args": ["${pluginDir}/main.py"] +// }, +// "permissions": ["host/log", "host/getInfo"] +// } +// ``` +// +// 设计取舍: +// - `entrypoint.command` 不要求绝对路径;宿主统一用 `/usr/bin/env ` 拉起, +// 走系统 PATH。要写死可执行用绝对路径,env 也会原样转发。 +// - `${pluginDir}` 是 args / env 里的字符串占位符,解析时替换成 manifest 所在目录的绝对路径。 +// - `permissions` 是白名单:只有在这个列表里的 `host/*` method 能被该插件调用, +// 未声明的会直接回 method-not-found(-32601)。 + +public struct PluginManifest: Codable, Sendable, Equatable { + public let id: String + public let name: String + public let version: String + public let entrypoint: Entrypoint + public let permissions: [String] + + /// manifest 文件所在目录(解析时由 loader 注入;不会被序列化到 JSON)。 + public var directory: URL = URL(fileURLWithPath: "/") + + public struct Entrypoint: Codable, Sendable, Equatable { + public let command: String + public let args: [String] + public let env: [String: String]? + + public init(command: String, args: [String] = [], env: [String: String]? = nil) { + self.command = command + self.args = args + self.env = env + } + } + + public init( + id: String, + name: String, + version: String, + entrypoint: Entrypoint, + permissions: [String] = [], + directory: URL = URL(fileURLWithPath: "/") + ) { + self.id = id + self.name = name + self.version = version + self.entrypoint = entrypoint + self.permissions = permissions + self.directory = directory + } + + private enum CodingKeys: String, CodingKey { + case id, name, version, entrypoint, permissions + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + version = try c.decode(String.self, forKey: .version) + entrypoint = try c.decode(Entrypoint.self, forKey: .entrypoint) + permissions = try c.decodeIfPresent([String].self, forKey: .permissions) ?? [] + directory = URL(fileURLWithPath: "/") + } +} + +// MARK: - 加载 + +public enum PluginManifestError: Error, Sendable { + case fileNotFound(URL) + case decode(URL, String) +} + +public extension PluginManifest { + /// 从目录加载 `plugin.json`;解析失败抛 `PluginManifestError`。 + static func load(from directory: URL) throws -> PluginManifest { + let url = directory.appendingPathComponent("plugin.json") + guard FileManager.default.fileExists(atPath: url.path) else { + throw PluginManifestError.fileNotFound(url) + } + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw PluginManifestError.decode(url, "\(error)") + } + do { + var manifest = try JSONDecoder().decode(PluginManifest.self, from: data) + manifest.directory = directory + return manifest + } catch { + throw PluginManifestError.decode(url, "\(error)") + } + } + + /// 把字符串里所有 `${pluginDir}` 替换成 manifest 所在目录的绝对路径。 + func substitute(_ s: String) -> String { + s.replacingOccurrences(of: "${pluginDir}", with: directory.path) + } + + /// 解析好的、可直接交给 `Process` 的子进程描述。 + var resolvedEntrypoint: ResolvedEntrypoint { + ResolvedEntrypoint( + executable: URL(fileURLWithPath: "/usr/bin/env"), + arguments: [entrypoint.command] + entrypoint.args.map(substitute), + environment: entrypoint.env?.mapValues(substitute), + workingDirectory: directory + ) + } +} + +public struct ResolvedEntrypoint: Sendable { + public let executable: URL + public let arguments: [String] + public let environment: [String: String]? + public let workingDirectory: URL +} From c69dccbe147c5a70eafce11a6facfdc89c5f3aa0 Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Tue, 2 Jun 2026 21:37:01 +0800 Subject: [PATCH 02/10] feat: add swift plugin function --- platforms/macos/Package.swift | 8 +- .../macos/Sources/Agent/AhaKeyAgent.swift | 36 ++- platforms/macos/Sources/Agent/main.swift | 4 +- .../PluginShowcaseApp.swift | 259 ++++++++++++++++++ .../Sources/Utilities/AgentManager.swift | 17 +- 5 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 platforms/macos/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift diff --git a/platforms/macos/Package.swift b/platforms/macos/Package.swift index b7b1e0f3..182b1c80 100644 --- a/platforms/macos/Package.swift +++ b/platforms/macos/Package.swift @@ -9,6 +9,7 @@ let package = Package( products: [ .executable(name: "AhaKeyConfig", targets: ["AhaKeyConfig"]), .executable(name: "ahakeyconfig-agent", targets: ["AhaKeyConfigAgent"]), + .executable(name: "PluginShowcase", targets: ["PluginShowcase"]), .library(name: "AhaKeyPluginKit", targets: ["AhaKeyPluginKit"]), ], @@ -22,11 +23,16 @@ let package = Package( dependencies: ["AhaKeyPluginKit"], path: "Sources/AhaKeyPlugin" ), + .executableTarget( + name: "PluginShowcase", + dependencies: ["AhaKeyPluginKit"], + path: "Sources/AhaKeyPluginShowcase" + ), .executableTarget( name: "AhaKeyConfig", dependencies: ["AhaKeyPluginKit"], path: "Sources", - exclude: ["Agent", "AhaKeyPlugin", "AhaKeyPluginKit"], + exclude: ["Agent", "AhaKeyPlugin", "AhaKeyPluginKit", "AhaKeyPluginShowcase"], // 与 scripts/build.sh 中 Info.plist 一致。嵌入 __info_plist 段后 TCC 可识别。 // Debug 使用单独 plist:系统在「隐私与安全性」列表中显示为「AhaKey Studio(调试)」,与正式包区分。 linkerSettings: [ diff --git a/platforms/macos/Sources/Agent/AhaKeyAgent.swift b/platforms/macos/Sources/Agent/AhaKeyAgent.swift index a42d6d55..d3555977 100644 --- a/platforms/macos/Sources/Agent/AhaKeyAgent.swift +++ b/platforms/macos/Sources/Agent/AhaKeyAgent.swift @@ -99,12 +99,18 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat ) } - func startSocketListener() { - // 清理旧 socket + @discardableResult + func startSocketListener() -> Bool { + if Self.hasLiveSocket(at: socketPath) { + emit("已有 Agent 在监听 Unix socket: \(socketPath)") + return false + } + + // 清理没有监听进程的残留 socket unlink(socketPath) let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { emit("socket() 失败"); return } + guard fd >= 0 else { emit("socket() 失败"); return false } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) @@ -120,7 +126,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat bind(fd, sockPtr, socklen_t(MemoryLayout.size)) } } - guard bindResult == 0 else { emit("bind() 失败: \(errno)"); close(fd); return } + guard bindResult == 0 else { emit("bind() 失败: \(errno)"); close(fd); return false } listen(fd, 5) emit("监听 Unix socket: \(socketPath)") @@ -132,10 +138,32 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat self?.handleClient(clientFd) } } + return true } // MARK: - Socket handling + private static func hasLiveSocket(at path: String) -> Bool { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + 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) + } + } + + return withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(fd, sockPtr, socklen_t(MemoryLayout.size)) == 0 + } + } + } + /// 单个客户端的处理:读一包请求,按 JSON 或旧版纯数字分发。 /// /// 协议: diff --git a/platforms/macos/Sources/Agent/main.swift b/platforms/macos/Sources/Agent/main.swift index 9bb17dc3..b6339cf0 100644 --- a/platforms/macos/Sources/Agent/main.swift +++ b/platforms/macos/Sources/Agent/main.swift @@ -29,7 +29,9 @@ agent.onLog = { msg in let ts = ISO8601DateFormatter().string(from: Date()) print("[\(ts)] \(msg)") } -agent.startSocketListener() +guard agent.startSocketListener() else { + exit(EXIT_FAILURE) +} // 保持运行 RunLoop.main.run() diff --git a/platforms/macos/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift b/platforms/macos/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift new file mode 100644 index 00000000..7163be50 --- /dev/null +++ b/platforms/macos/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift @@ -0,0 +1,259 @@ +import AhaKeyPluginKit +import SwiftUI + +@main +struct PluginShowcaseApp: App { + @StateObject private var model = PluginShowcaseModel() + + var body: some Scene { + WindowGroup("AhaKey Plugin Showcase") { + PluginShowcaseView() + .environmentObject(model) + } + } +} + +@MainActor +final class PluginShowcaseModel: ObservableObject { + struct PluginSummary: Identifiable { + let id: String + let manifestName: String + let version: String + let reportedName: String + let methods: [String] + } + + @Published var plugins: [PluginSummary] = [] + @Published var statusJSON = "Waiting for plugin status..." + @Published var activity: [String] = [] + @Published var isBusy = false + + private let manager = PluginManager() + private var loadedPlugins: [PluginManager.LoadedPlugin] = [] + private var pollingTask: Task? + private var started = false + + func start() { + guard !started else { return } + started = true + + Task { await reload() } + pollingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + await refreshStatus(recordActivity: false) + } + } + } + + func stop() { + pollingTask?.cancel() + pollingTask = nil + started = false + Task { await manager.unloadAll() } + } + + func reload() async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + await manager.unloadAll() + let count = await manager.loadAll() + loadedPlugins = await manager.allLoaded() + plugins = loadedPlugins.map { plugin in + PluginSummary( + id: plugin.manifest.id, + manifestName: plugin.manifest.name, + version: plugin.manifest.version, + reportedName: plugin.initialize?.name ?? "", + methods: plugin.initialize?.methods ?? [] + ) + } + appendActivity("Loaded \(count) plugin(s)") + + let failures = await manager.failures + for failure in failures { + appendActivity("Failed \(failure.manifestDirectory.lastPathComponent): \(failure.error)") + } + + await refreshStatus() + } + + func refreshStatus(recordActivity: Bool = true) async { + guard let plugin = pluginSupporting("demo/getStatus") else { + statusJSON = "No loaded plugin exposes demo/getStatus." + return + } + + do { + let result = try await plugin.host.client.call("demo/getStatus", timeout: 4) + statusJSON = prettyJSON(result) + if recordActivity { + appendActivity("Refreshed status through \(plugin.manifest.id)") + } + } catch { + statusJSON = "Status request failed: \(error)" + if recordActivity { + appendActivity("Status request failed: \(error)") + } + } + } + + func greet() async { + guard let plugin = pluginSupporting("demo/greet") else { + appendActivity("No loaded plugin exposes demo/greet") + return + } + + do { + let result = try await plugin.host.client.call( + "demo/greet", + params: .object(["name": .string("AhaKey Studio")]), + timeout: 4 + ) + appendActivity("demo/greet -> \(compactJSON(result))") + } catch { + appendActivity("demo/greet failed: \(error)") + } + } + + private func pluginSupporting(_ method: String) -> PluginManager.LoadedPlugin? { + loadedPlugins.first { $0.initialize?.methods?.contains(method) == true } + } + + private func appendActivity(_ message: String) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + activity.insert("[\(formatter.string(from: Date()))] \(message)", at: 0) + activity = Array(activity.prefix(20)) + } + + private func prettyJSON(_ value: JSONValue) -> String { + encodeJSON(value, prettyPrinted: true) + } + + private func compactJSON(_ value: JSONValue) -> String { + encodeJSON(value, prettyPrinted: false) + } + + private func encodeJSON(_ value: JSONValue, prettyPrinted: Bool) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = prettyPrinted ? [.prettyPrinted, .sortedKeys] : [.sortedKeys] + guard + let data = try? encoder.encode(value), + let text = String(data: data, encoding: .utf8) + else { + return "" + } + return text + } +} + +struct PluginShowcaseView: View { + @EnvironmentObject private var model: PluginShowcaseModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + pluginList + statusPanel + activityPanel + } + .padding(20) + } + .frame(minWidth: 720, minHeight: 620) + .task { model.start() } + .onDisappear { model.stop() } + } + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text("AhaKey Plugin Showcase") + .font(.title.bold()) + Text("Swift host + TypeScript plugin + AhaKey agent bridge") + .foregroundColor(.secondary) + } + Spacer() + Button("Reload Plugins") { + Task { await model.reload() } + } + .disabled(model.isBusy) + } + } + + private var pluginList: some View { + GroupBox("Loaded Plugins") { + VStack(alignment: .leading, spacing: 10) { + if model.plugins.isEmpty { + Text("No plugins loaded. Build the TypeScript example and set AHAKEY_PLUGINS_DIR.") + .foregroundColor(.secondary) + } else { + ForEach(model.plugins) { plugin in + VStack(alignment: .leading, spacing: 4) { + Text(plugin.manifestName) + .font(.headline) + Text("\(plugin.id) v\(plugin.version)") + .font(.system(.body, design: .monospaced)) + Text("Plugin reports: \(plugin.reportedName)") + .foregroundColor(.secondary) + Text("Methods: \(plugin.methods.joined(separator: ", "))") + .font(.system(.caption, design: .monospaced)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var statusPanel: some View { + GroupBox("App-linked Demo") { + VStack(alignment: .leading, spacing: 10) { + Text("The TypeScript plugin asks the Swift host for app metadata and the physical lever state. The panel refreshes every 2 seconds.") + .foregroundColor(.secondary) + HStack { + Button("Refresh Status") { + Task { await model.refreshStatus() } + } + Button("Call demo/greet") { + Task { await model.greet() } + } + } + Text(model.statusJSON) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08)) + .cornerRadius(8) + } + .padding(8) + } + } + + private var activityPanel: some View { + GroupBox("Activity") { + VStack(alignment: .leading, spacing: 4) { + if model.activity.isEmpty { + Text("No activity yet.") + .foregroundColor(.secondary) + } else { + ForEach(Array(model.activity.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + diff --git a/platforms/macos/Sources/Utilities/AgentManager.swift b/platforms/macos/Sources/Utilities/AgentManager.swift index 73a6db4a..2ca34b76 100644 --- a/platforms/macos/Sources/Utilities/AgentManager.swift +++ b/platforms/macos/Sources/Utilities/AgentManager.swift @@ -72,9 +72,20 @@ final class AgentManager: ObservableObject { } private var agentBinaryPath: String { - // agent 安装到 app bundle 内部(发版须将 ahakeyconfig-agent 与主程序一并复制到 Contents/MacOS/) - let appPath = Bundle.main.bundlePath - return "\(appPath)/Contents/MacOS/ahakeyconfig-agent" + // 发版优先使用 app bundle 内部的 agent。`swift run AhaKeyConfig` 是裸可执行文件, + // 开发时回退到同一 SwiftPM build 目录里的 sibling agent,便于从源码安装 LaunchAgent。 + let bundled = "\(Bundle.main.bundlePath)/Contents/MacOS/ahakeyconfig-agent" + if FileManager.default.isExecutableFile(atPath: bundled) { + return bundled + } + if Bundle.main.bundleURL.pathExtension != "app", + let executableDirectory = Bundle.main.executableURL?.deletingLastPathComponent() { + let development = executableDirectory.appendingPathComponent("ahakeyconfig-agent").path + if FileManager.default.isExecutableFile(atPath: development) { + return development + } + } + return bundled } /// 供界面判断:包内是否带有 agent 可执行文件(发版缺拷贝时 LaunchAgent 无法真正运行)。 From b2ff7841e58ac0618b976f3640a87b6ac9adb8a0 Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Tue, 2 Jun 2026 21:37:20 +0800 Subject: [PATCH 03/10] feat: add typescript sdk --- sdks/README.md | 7 + sdks/typescript/README.md | 140 +++++ .../examples/hello-plugin/plugin.json | 14 + .../examples/hello-plugin/src/main.ts | 72 +++ .../examples/hello-plugin/tsconfig.json | 19 + sdks/typescript/package-lock.json | 50 ++ sdks/typescript/package.json | 38 ++ sdks/typescript/src/index.ts | 514 ++++++++++++++++++ sdks/typescript/test/sdk.test.mjs | 183 +++++++ sdks/typescript/tsconfig.json | 22 + 10 files changed, 1059 insertions(+) create mode 100644 sdks/README.md create mode 100644 sdks/typescript/README.md create mode 100644 sdks/typescript/examples/hello-plugin/plugin.json create mode 100644 sdks/typescript/examples/hello-plugin/src/main.ts create mode 100644 sdks/typescript/examples/hello-plugin/tsconfig.json create mode 100644 sdks/typescript/package-lock.json create mode 100644 sdks/typescript/package.json create mode 100644 sdks/typescript/src/index.ts create mode 100644 sdks/typescript/test/sdk.test.mjs create mode 100644 sdks/typescript/tsconfig.json diff --git a/sdks/README.md b/sdks/README.md new file mode 100644 index 00000000..5198e954 --- /dev/null +++ b/sdks/README.md @@ -0,0 +1,7 @@ +# AhaKey Plugin SDKs + +This directory contains SDKs for building AhaKey desktop plugins. + +- [`typescript`](./typescript): TypeScript SDK for JSON-RPC plugins running as + child processes over stdin/stdout. + diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md new file mode 100644 index 00000000..da7c4449 --- /dev/null +++ b/sdks/typescript/README.md @@ -0,0 +1,140 @@ +# `@ahakey/plugin-sdk` + +TypeScript SDK for AhaKey desktop plugins. + +The AhaKey host launches each plugin as a child process and communicates through +newline-delimited JSON-RPC 2.0 messages over stdin/stdout. This SDK hides the +transport and lifecycle plumbing so a plugin only needs to register hooks and +methods. + +## Requirements + +- Node.js 18 or newer +- npm + +## Build and test + +```bash +cd sdks/typescript +npm install +npm test +``` + +## Minimal plugin + +```ts +import { definePlugin, servePlugin } from "@ahakey/plugin-sdk"; + +servePlugin(definePlugin({ + name: "Hello Plugin", + version: "0.1.0", + async onInitialized({ host }) { + const info = await host.getInfo(); + await host.log(`Connected to ${info.bundleID}`); + }, +})); +``` + +Each plugin directory also needs a `plugin.json` manifest: + +```json +{ + "id": "com.example.hello", + "name": "Hello Plugin", + "version": "0.1.0", + "entrypoint": { + "command": "node", + "args": ["${pluginDir}/dist/main.js"] + }, + "permissions": ["host/getInfo", "host/log"] +} +``` + +The host replaces `${pluginDir}` with the directory containing `plugin.json`. +Only the declared `host/*` permissions can be called by the plugin. + +## Host API + +```ts +host.getInfo() +host.log("message", "info") +host.getSwitchState() +host.supports("host/getSwitchState") +host.call("host/customMethod", params) +host.notify("host/customNotification", params) +``` + +Current built-in host methods: + +| Method | Result | +| --- | --- | +| `host/getInfo` | App bundle ID, version, build and platform | +| `host/log` | Writes a plugin log message to the host | +| `host/getSwitchState` | Returns the keyboard lever state and agent reachability | + +## Plugin methods + +Plugins can expose methods for future host calls: + +```ts +servePlugin(definePlugin({ + name: "Greeter", + version: "0.1.0", + methods: { + "hello/greet": (params) => ({ message: `Hello, ${params.name}!` }), + }, +})); +``` + +The SDK automatically implements: + +- `plugin/initialize` +- `plugin/initialized` +- `plugin/shutdown` +- `plugin/exit` + +## Run the included example + +Launch the visual macOS showcase: + +```bash +cd sdks/typescript +npm install +npm run demo +``` + +To read the physical lever state during source development, start the Agent in a +second terminal before launching the showcase. This does not install a +LaunchAgent or modify IDE hooks: + +```bash +cd sdks/typescript +npm run demo:agent +``` + +Then run `npm run demo` in the first terminal. The Agent owns the BLE connection +while it is running; stop it with `Ctrl-C` before opening the full AhaKey Studio +app for keyboard configuration. + +The `PluginShowcase` Swift target opens a small window that: + +- discovers and launches the TypeScript example plugin; +- shows the plugin metadata and declared RPC methods; +- calls `demo/getStatus` to read host app metadata and the physical lever state; +- calls `demo/greet` to demonstrate a host-to-plugin request; +- refreshes the lever state every 2 seconds through the AhaKey agent bridge. + +If the AhaKey agent is not running or does not own the BLE connection, the lever +state is displayed as offline. That is expected: the rest of the demo still +works. + +When running the full `AhaKeyConfig` target from source, the existing +`安装并启用` button can also install the development Agent binary from the local +SwiftPM build directory. Packaged `.app` builds continue to use the bundled +`Contents/MacOS/ahakeyconfig-agent`. + +For a terminal-only lifecycle smoke test, run: + +```bash +npm run demo:cli +``` diff --git a/sdks/typescript/examples/hello-plugin/plugin.json b/sdks/typescript/examples/hello-plugin/plugin.json new file mode 100644 index 00000000..aef2fc43 --- /dev/null +++ b/sdks/typescript/examples/hello-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "id": "dev.ahakey.example.typescript", + "name": "AhaKey TypeScript Showcase", + "version": "0.1.0", + "entrypoint": { + "command": "node", + "args": ["${pluginDir}/dist/main.js"] + }, + "permissions": [ + "host/getInfo", + "host/log", + "host/getSwitchState" + ] +} diff --git a/sdks/typescript/examples/hello-plugin/src/main.ts b/sdks/typescript/examples/hello-plugin/src/main.ts new file mode 100644 index 00000000..8955441a --- /dev/null +++ b/sdks/typescript/examples/hello-plugin/src/main.ts @@ -0,0 +1,72 @@ +import { + definePlugin, + servePlugin, + type AhaKeyHost, + type SwitchStateResult, +} from "@ahakey/plugin-sdk"; + +let host: AhaKeyHost | undefined; + +function describeSwitchState(state: SwitchStateResult): string { + if (!state.agentReachable) { + return "offline: start the AhaKey agent and let it own the BLE connection"; + } + return state.switchState === 0 + ? "automatic approval" + : `manual approval (switchState=${state.switchState ?? "unknown"})`; +} + +async function getShowcaseStatus() { + if (host === undefined) { + throw new Error("Plugin has not completed initialization"); + } + + const [info, state] = await Promise.all([ + host.getInfo(), + host.getSwitchState(), + ]); + return { + plugin: "AhaKey TypeScript Showcase", + host: info, + lever: { + ...state, + description: describeSwitchState(state), + }, + }; +} + +servePlugin(definePlugin({ + name: "AhaKey TypeScript Showcase", + version: "0.1.0", + methods: { + "demo/greet": (params) => { + const name = typeof params === "object" + && params !== null + && "name" in params + && typeof params.name === "string" + ? params.name + : "world"; + return { message: `Hello, ${name}!` }; + }, + "demo/getStatus": getShowcaseStatus, + }, + onInitialize(_params, connectedHost) { + host = connectedHost; + }, + async onInitialized({ host: connectedHost }) { + const status = await getShowcaseStatus(); + const description = status.lever.description; + await connectedHost.log( + `TypeScript showcase connected to ${status.host.bundleID}; lever=${description}`, + ); + }, + async onShutdown(context) { + const connectedHost = context?.host ?? host; + if (connectedHost === undefined) { + return; + } + await connectedHost.log( + "TypeScript showcase received plugin/shutdown", + ); + }, +})); diff --git a/sdks/typescript/examples/hello-plugin/tsconfig.json b/sdks/typescript/examples/hello-plugin/tsconfig.json new file mode 100644 index 00000000..731b8aeb --- /dev/null +++ b/sdks/typescript/examples/hello-plugin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "types": [ + "node" + ], + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json new file mode 100644 index 00000000..f6d858a7 --- /dev/null +++ b/sdks/typescript/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "@ahakey/plugin-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ahakey/plugin-sdk", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^25.9.1", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json new file mode 100644 index 00000000..ec1cc8e2 --- /dev/null +++ b/sdks/typescript/package.json @@ -0,0 +1,38 @@ +{ + "name": "@ahakey/plugin-sdk", + "version": "0.1.0", + "description": "TypeScript SDK for AhaKey desktop plugins", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json && tsc -p examples/hello-plugin/tsconfig.json", + "build:sdk": "tsc -p tsconfig.json", + "clean": "rm -rf dist examples/hello-plugin/dist", + "demo": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../platforms/macos PluginShowcase", + "demo:agent": "swift run --package-path ../../platforms/macos ahakeyconfig-agent", + "demo:cli": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../platforms/macos Plugin", + "test": "npm run build && node --test test/*.test.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p examples/hello-plugin/tsconfig.json --noEmit" + }, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "typescript": "^6.0.3" + } +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts new file mode 100644 index 00000000..7fbb83c2 --- /dev/null +++ b/sdks/typescript/src/index.ts @@ -0,0 +1,514 @@ +import { createInterface, type Interface } from "node:readline"; +import type { Readable, Writable } from "node:stream"; + +export type JsonPrimitive = boolean | number | string | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +export type JsonRpcId = number | string | null; +export type MaybePromise = T | Promise; + +export const JSON_RPC_VERSION = "2.0" as const; + +export const JSON_RPC_ERROR = { + parseError: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internalError: -32603, +} as const; + +export class JsonRpcError extends Error { + readonly code: number; + readonly data?: unknown; + + constructor(code: number, message: string, data?: unknown) { + super(message); + this.name = "JsonRpcError"; + this.code = code; + this.data = data; + } + + toJSON(): { code: number; message: string; data?: unknown } { + return this.data === undefined + ? { code: this.code, message: this.message } + : { code: this.code, message: this.message, data: this.data }; + } +} + +export interface HostAppInfo { + bundleID: string; + version: string; + build: string; + platform: string; +} + +export interface PluginInitializeParams { + host: HostAppInfo; + hostMethods: string[]; +} + +export interface PluginInitializeResult { + name?: string; + version?: string; + methods?: string[]; +} + +export interface SwitchStateResult { + switchState: number | null; + agentReachable: boolean; +} + +export type HostLogLevel = "debug" | "info" | "warn" | "error" | (string & {}); + +export type RpcMethod = ( + params: TParams, +) => MaybePromise; + +export type RpcNotification = ( + params: TParams, +) => MaybePromise; + +export interface PluginContext { + host: AhaKeyHost; + initializeParams: PluginInitializeParams; +} + +export interface PluginDefinition { + name: string; + version: string; + methods?: Record; + notifications?: Record; + onInitialize?: ( + params: PluginInitializeParams, + host: AhaKeyHost, + ) => MaybePromise>; + onInitialized?: (context: PluginContext) => MaybePromise; + onShutdown?: (context: PluginContext | undefined) => MaybePromise; + onExit?: (context: PluginContext | undefined) => MaybePromise; +} + +export interface PluginServerOptions { + input?: Readable; + output?: Writable; + errorOutput?: Writable; + defaultCallTimeoutMs?: number; +} + +interface JsonRpcRequest { + jsonrpc: typeof JSON_RPC_VERSION; + method: string; + params?: unknown; + id?: JsonRpcId; +} + +interface JsonRpcResponse { + jsonrpc: typeof JSON_RPC_VERSION; + id: JsonRpcId; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +interface PendingCall { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType | undefined; +} + +function hasOwn(value: object, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asRpcId(value: unknown): JsonRpcId | undefined { + if (value === null || typeof value === "string" || typeof value === "number") { + return value; + } + return undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +class JsonRpcPeer { + private readonly input: Readable; + private readonly output: Writable; + private readonly errorOutput: Writable; + private readonly defaultCallTimeoutMs: number; + private readonly methods = new Map(); + private readonly notifications = new Map(); + private readonly pending = new Map(); + private reader: Interface | undefined; + private nextId = 1; + private started = false; + private closed = false; + + constructor(options: PluginServerOptions = {}) { + this.input = options.input ?? process.stdin; + this.output = options.output ?? process.stdout; + this.errorOutput = options.errorOutput ?? process.stderr; + this.defaultCallTimeoutMs = options.defaultCallTimeoutMs ?? 30_000; + } + + start(): void { + if (this.started) { + return; + } + if (this.closed) { + throw new Error("Cannot restart a closed JSON-RPC peer"); + } + + this.started = true; + this.reader = createInterface({ + input: this.input, + crlfDelay: Infinity, + terminal: false, + }); + this.reader.on("line", (line) => { + void this.handleLine(line); + }); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.reader?.close(); + this.input.pause(); + + for (const [id, pending] of this.pending) { + if (pending.timeout !== undefined) { + clearTimeout(pending.timeout); + } + pending.reject(new Error(`JSON-RPC peer closed while waiting for response ${id}`)); + } + this.pending.clear(); + } + + registerMethod(method: string, handler: RpcMethod): void { + this.methods.set(method, handler); + } + + registerNotification(method: string, handler: RpcNotification): void { + this.notifications.set(method, handler); + } + + call( + method: string, + params?: TParams, + timeoutMs = this.defaultCallTimeoutMs, + ): Promise { + if (!this.started || this.closed) { + return Promise.reject(new Error("JSON-RPC peer is not running")); + } + + const id = this.nextId++; + const request: JsonRpcRequest = { jsonrpc: JSON_RPC_VERSION, method, id }; + if (params !== undefined) { + request.params = params; + } + + return new Promise((resolve, reject) => { + const timeout = timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new Error(`JSON-RPC call timed out: ${method}`)); + }, timeoutMs) + : undefined; + timeout?.unref(); + + this.pending.set(id, { + resolve: (result) => resolve(result as TResult), + reject, + timeout, + }); + + try { + this.send(request); + } catch (error) { + this.pending.delete(id); + if (timeout !== undefined) { + clearTimeout(timeout); + } + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + notify(method: string, params?: TParams): void { + if (!this.started || this.closed) { + throw new Error("JSON-RPC peer is not running"); + } + + const request: JsonRpcRequest = { jsonrpc: JSON_RPC_VERSION, method }; + if (params !== undefined) { + request.params = params; + } + this.send(request); + } + + logError(message: string): void { + this.errorOutput.write(`[ahakey-plugin-sdk] ${message}\n`); + } + + private send(message: JsonRpcRequest | JsonRpcResponse): void { + this.output.write(`${JSON.stringify(message)}\n`); + } + + private async handleLine(line: string): Promise { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return; + } + + let message: unknown; + try { + message = JSON.parse(trimmed); + } catch (error) { + this.sendError(null, new JsonRpcError( + JSON_RPC_ERROR.parseError, + `Parse error: ${errorMessage(error)}`, + )); + return; + } + + if (!isRecord(message) || message.jsonrpc !== JSON_RPC_VERSION) { + this.sendError(null, new JsonRpcError( + JSON_RPC_ERROR.invalidRequest, + "Invalid JSON-RPC request", + )); + return; + } + + if (typeof message.method === "string") { + await this.handleRequest(message); + return; + } + + this.handleResponse(message); + } + + private async handleRequest(message: Record): Promise { + const method = message.method as string; + const params = message.params; + const isNotification = !hasOwn(message, "id"); + + if (isNotification) { + const handler = this.notifications.get(method); + if (handler !== undefined) { + try { + await handler(params); + } catch (error) { + this.logError(`notification ${method} failed: ${errorMessage(error)}`); + } + } + return; + } + + const id = asRpcId(message.id); + if (id === undefined) { + this.sendError(null, new JsonRpcError( + JSON_RPC_ERROR.invalidRequest, + "JSON-RPC request id must be a number, string, or null", + )); + return; + } + + const handler = this.methods.get(method); + if (handler === undefined) { + this.sendError(id, new JsonRpcError( + JSON_RPC_ERROR.methodNotFound, + `Method not found: ${method}`, + )); + return; + } + + try { + const result = await handler(params); + this.send({ jsonrpc: JSON_RPC_VERSION, id, result: result ?? null }); + } catch (error) { + this.sendError( + id, + error instanceof JsonRpcError + ? error + : new JsonRpcError(JSON_RPC_ERROR.internalError, errorMessage(error)), + ); + } + } + + private handleResponse(message: Record): void { + const id = asRpcId(message.id); + if (typeof id !== "number") { + this.logError("ignoring response with an unknown id"); + return; + } + + const pending = this.pending.get(id); + if (pending === undefined) { + this.logError(`ignoring response for unknown id ${id}`); + return; + } + this.pending.delete(id); + if (pending.timeout !== undefined) { + clearTimeout(pending.timeout); + } + + if (isRecord(message.error) + && typeof message.error.code === "number" + && typeof message.error.message === "string") { + pending.reject(new JsonRpcError( + message.error.code, + message.error.message, + message.error.data, + )); + return; + } + pending.resolve(message.result ?? null); + } + + private sendError(id: JsonRpcId, error: JsonRpcError): void { + this.send({ + jsonrpc: JSON_RPC_VERSION, + id, + error: error.toJSON(), + }); + } +} + +export class AhaKeyHost { + private initializeParams: PluginInitializeParams | undefined; + + constructor(private readonly peer: JsonRpcPeer) {} + + setInitializeParams(params: PluginInitializeParams): void { + this.initializeParams = params; + } + + supports(method: string): boolean { + return this.initializeParams?.hostMethods.includes(method) ?? false; + } + + call( + method: string, + params?: TParams, + timeoutMs?: number, + ): Promise { + return this.peer.call(method, params, timeoutMs); + } + + notify(method: string, params?: TParams): void { + this.peer.notify(method, params); + } + + getInfo(): Promise { + return this.call("host/getInfo"); + } + + async log(message: string, level: HostLogLevel = "info"): Promise { + await this.call("host/log", { level, message }); + } + + getSwitchState(): Promise { + return this.call("host/getSwitchState"); + } +} + +export class AhaKeyPluginServer { + readonly host: AhaKeyHost; + + private readonly peer: JsonRpcPeer; + private contextValue: PluginContext | undefined; + + constructor( + private readonly definition: PluginDefinition, + options: PluginServerOptions = {}, + ) { + this.peer = new JsonRpcPeer(options); + this.host = new AhaKeyHost(this.peer); + this.registerLifecycle(); + + for (const [method, handler] of Object.entries(definition.methods ?? {})) { + this.peer.registerMethod(method, handler); + } + for (const [method, handler] of Object.entries(definition.notifications ?? {})) { + this.peer.registerNotification(method, handler); + } + } + + get context(): PluginContext | undefined { + return this.contextValue; + } + + start(): this { + this.peer.start(); + return this; + } + + close(): void { + this.peer.close(); + } + + private registerLifecycle(): void { + this.peer.registerMethod("plugin/initialize", async (params) => { + if (!isRecord(params) + || !isRecord(params.host) + || !Array.isArray(params.hostMethods) + || !params.hostMethods.every((method) => typeof method === "string")) { + throw new JsonRpcError( + JSON_RPC_ERROR.invalidParams, + "plugin/initialize expects { host, hostMethods }", + ); + } + + const initializeParams = params as unknown as PluginInitializeParams; + this.host.setInitializeParams(initializeParams); + this.contextValue = { host: this.host, initializeParams }; + const overrides = await this.definition.onInitialize?.(initializeParams, this.host); + + return { + name: this.definition.name, + version: this.definition.version, + methods: Object.keys(this.definition.methods ?? {}), + ...overrides, + } satisfies PluginInitializeResult; + }); + + this.peer.registerNotification("plugin/initialized", async () => { + if (this.contextValue !== undefined) { + await this.definition.onInitialized?.(this.contextValue); + } + }); + + this.peer.registerMethod("plugin/shutdown", async () => { + await this.definition.onShutdown?.(this.contextValue); + return null; + }); + + this.peer.registerNotification("plugin/exit", async () => { + try { + await this.definition.onExit?.(this.contextValue); + } finally { + this.close(); + } + }); + } +} + +export function definePlugin(definition: PluginDefinition): PluginDefinition { + return definition; +} + +export function servePlugin( + definition: PluginDefinition, + options?: PluginServerOptions, +): AhaKeyPluginServer { + return new AhaKeyPluginServer(definition, options).start(); +} + diff --git a/sdks/typescript/test/sdk.test.mjs b/sdks/typescript/test/sdk.test.mjs new file mode 100644 index 00000000..95af94c9 --- /dev/null +++ b/sdks/typescript/test/sdk.test.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { + AhaKeyPluginServer, + JSON_RPC_ERROR, + JsonRpcError, +} from "../dist/index.js"; + +function createHarness(definition) { + const input = new PassThrough(); + const output = new PassThrough(); + const errorOutput = new PassThrough(); + const messages = []; + const waiters = []; + let buffer = ""; + + output.setEncoding("utf8"); + output.on("data", (chunk) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline === -1) { + break; + } + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.length === 0) { + continue; + } + const message = JSON.parse(line); + const waiter = waiters.shift(); + if (waiter === undefined) { + messages.push(message); + } else { + waiter(message); + } + } + }); + + const server = new AhaKeyPluginServer(definition, { + input, + output, + errorOutput, + defaultCallTimeoutMs: 500, + }).start(); + + return { + server, + send(message) { + input.write(`${JSON.stringify(message)}\n`); + }, + nextMessage() { + const message = messages.shift(); + return message === undefined + ? new Promise((resolve) => waiters.push(resolve)) + : Promise.resolve(message); + }, + close() { + server.close(); + input.end(); + output.end(); + errorOutput.end(); + }, + }; +} + +const initializeParams = { + host: { + bundleID: "dev.ahakey.test", + version: "1.2.3", + build: "42", + platform: "macos", + }, + hostMethods: ["host/getInfo", "host/log", "host/getSwitchState"], +}; + +test("serves lifecycle and custom plugin methods", async (t) => { + let shutdownCalled = false; + let exitCalled = false; + const harness = createHarness({ + name: "Test Plugin", + version: "0.1.0", + methods: { + "hello/greet": (params) => ({ message: `Hello, ${params.name}!` }), + }, + onShutdown() { + shutdownCalled = true; + }, + onExit() { + exitCalled = true; + }, + }); + t.after(() => harness.close()); + + harness.send({ + jsonrpc: "2.0", + id: 1, + method: "plugin/initialize", + params: initializeParams, + }); + assert.deepEqual(await harness.nextMessage(), { + jsonrpc: "2.0", + id: 1, + result: { + name: "Test Plugin", + version: "0.1.0", + methods: ["hello/greet"], + }, + }); + + assert.equal(harness.server.host.supports("host/log"), true); + assert.equal(harness.server.host.supports("host/openURL"), false); + + harness.send({ + jsonrpc: "2.0", + id: "greet-1", + method: "hello/greet", + params: { name: "AhaKey" }, + }); + assert.deepEqual(await harness.nextMessage(), { + jsonrpc: "2.0", + id: "greet-1", + result: { message: "Hello, AhaKey!" }, + }); + + harness.send({ jsonrpc: "2.0", id: 2, method: "plugin/shutdown" }); + assert.deepEqual(await harness.nextMessage(), { + jsonrpc: "2.0", + id: 2, + result: null, + }); + assert.equal(shutdownCalled, true); + + harness.send({ jsonrpc: "2.0", method: "plugin/exit" }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(exitCalled, true); +}); + +test("calls host methods and exposes JSON-RPC errors", async (t) => { + const harness = createHarness({ name: "Test Plugin", version: "0.1.0" }); + t.after(() => harness.close()); + + harness.send({ + jsonrpc: "2.0", + id: 1, + method: "plugin/initialize", + params: initializeParams, + }); + await harness.nextMessage(); + + const infoPromise = harness.server.host.getInfo(); + const request = await harness.nextMessage(); + assert.deepEqual(request, { + jsonrpc: "2.0", + id: 1, + method: "host/getInfo", + }); + harness.send({ + jsonrpc: "2.0", + id: request.id, + result: initializeParams.host, + }); + assert.deepEqual(await infoPromise, initializeParams.host); + + const rejected = harness.server.host.call("host/blocked"); + const blockedRequest = await harness.nextMessage(); + harness.send({ + jsonrpc: "2.0", + id: blockedRequest.id, + error: { + code: JSON_RPC_ERROR.methodNotFound, + message: "Method host/blocked not in plugin permissions", + }, + }); + await assert.rejects(rejected, (error) => { + assert.ok(error instanceof JsonRpcError); + assert.equal(error.code, JSON_RPC_ERROR.methodNotFound); + return true; + }); +}); + diff --git a/sdks/typescript/tsconfig.json b/sdks/typescript/tsconfig.json new file mode 100644 index 00000000..4a070a40 --- /dev/null +++ b/sdks/typescript/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "types": [ + "node" + ], + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts" + ] +} From 02249f0b6adead800b3a4b2818b1ae5f5f20f9c7 Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Thu, 18 Jun 2026 20:07:37 +0800 Subject: [PATCH 04/10] feat(sdk): add lever-counter example plugin A small background plugin that polls the physical switch state once a second, counts auto/manual flips, tracks per-mode dwell time, and exposes demo/flowStats. Falls back to ~/.ahakey-fake-lever when no agent/keyboard is reachable, so it can be demoed without hardware. Wired into the SDK build / typecheck / clean scripts. Co-Authored-By: Claude Opus 4.8 --- .../examples/lever-counter/plugin.json | 14 ++ .../examples/lever-counter/src/main.ts | 152 ++++++++++++++++++ .../examples/lever-counter/tsconfig.json | 19 +++ sdks/typescript/package.json | 6 +- 4 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 sdks/typescript/examples/lever-counter/plugin.json create mode 100644 sdks/typescript/examples/lever-counter/src/main.ts create mode 100644 sdks/typescript/examples/lever-counter/tsconfig.json diff --git a/sdks/typescript/examples/lever-counter/plugin.json b/sdks/typescript/examples/lever-counter/plugin.json new file mode 100644 index 00000000..05066fc2 --- /dev/null +++ b/sdks/typescript/examples/lever-counter/plugin.json @@ -0,0 +1,14 @@ +{ + "id": "dev.ahakey.example.lever-counter", + "name": "AhaKey Lever Counter", + "version": "0.1.0", + "entrypoint": { + "command": "node", + "args": ["${pluginDir}/dist/main.js"] + }, + "permissions": [ + "host/getInfo", + "host/log", + "host/getSwitchState" + ] +} diff --git a/sdks/typescript/examples/lever-counter/src/main.ts b/sdks/typescript/examples/lever-counter/src/main.ts new file mode 100644 index 00000000..25f83c13 --- /dev/null +++ b/sdks/typescript/examples/lever-counter/src/main.ts @@ -0,0 +1,152 @@ +import { servePlugin, definePlugin, type AhaKeyHost } from "@ahakey/plugin-sdk"; +import { readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// AhaKey Lever Counter —— 一个最小的插件 demo: +// 后台每秒读一次物理拨杆,统计你在「自动 / 手动」两档之间翻了多少次、各待了多久, +// 每次翻档写一条 host 日志,并把快照落到 ~/.ahakey-flow-stats.json(随时 cat 查看)。 +// 暴露 RPC 方法 `demo/flowStats` 供宿主主动查询。 +// +// 无硬件演示:agent / 键盘读不到拨杆时,回退读模拟文件 —— +// echo 0 > ~/.ahakey-fake-lever # 自动档(full-send) +// echo 1 > ~/.ahakey-fake-lever # 手动档(human in the loop) + +/** 模拟拨杆文件:真实拨杆读不到时的兜底输入。 */ +const FAKE_LEVER = join(homedir(), ".ahakey-fake-lever"); +/** 每次更新都把统计快照写到这里,方便直接 `cat` 查看。 */ +const STATS_FILE = join(homedir(), ".ahakey-flow-stats.json"); +const POLL_MS = 1000; + +type Mode = "auto" | "manual"; + +interface Stats { + startedAt: number; + flips: number; + enter: Record; + dwellMs: Record; + current: Mode | null; + currentSince: number; +} + +const stats: Stats = { + startedAt: Date.now(), + flips: 0, + enter: { auto: 0, manual: 0 }, + dwellMs: { auto: 0, manual: 0 }, + current: null, + currentSince: Date.now(), +}; + +let host: AhaKeyHost | undefined; +let timer: ReturnType | undefined; + +/** switchState 语义:0 = 自动档,非 0 = 手动档。 */ +function modeOf(switchState: number | null): Mode | null { + if (switchState === null) return null; + return switchState === 0 ? "auto" : "manual"; +} + +/** 真实拨杆优先;读不到(offline)时回退到模拟文件。 */ +async function readMode(): Promise { + if (host !== undefined) { + try { + const r = await host.getSwitchState(); + if (r.agentReachable && r.switchState !== null) return modeOf(r.switchState); + } catch { + // 落到模拟文件 + } + } + try { + const n = Number(readFileSync(FAKE_LEVER, "utf8").trim()); + return Number.isFinite(n) ? modeOf(n) : null; + } catch { + return null; + } +} + +function fmt(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + return `${Math.floor(total / 60)}m${String(total % 60).padStart(2, "0")}s`; +} + +/** 只读快照:把当前档位「尚未结算」的时长也算进去。 */ +function snapshot() { + const now = Date.now(); + const dwell: Record = { ...stats.dwellMs }; + const cur = stats.current; + if (cur !== null) dwell[cur] += now - stats.currentSince; + return { + current: cur ?? "unknown", + currentDwell: cur === null ? "0m00s" : fmt(now - stats.currentSince), + flips: stats.flips, + auto: { entered: stats.enter.auto, total: fmt(dwell.auto) }, + manual: { entered: stats.enter.manual, total: fmt(dwell.manual) }, + uptime: fmt(now - stats.startedAt), + }; +} + +function persist(): void { + try { + writeFileSync(STATS_FILE, `${JSON.stringify(snapshot(), null, 2)}\n`); + } catch { + // 写不了就算了,不影响计数 + } +} + +async function tick(): Promise { + const mode = await readMode(); + if (mode === null) return; // 读不到拨杆,不计 + + const now = Date.now(); + const cur = stats.current; + + // 首次确定档位 + if (cur === null) { + stats.current = mode; + stats.currentSince = now; + stats.enter[mode] += 1; + persist(); + return; + } + + // 翻档:结算上一档时长 + 计数 + if (mode !== cur) { + stats.dwellMs[cur] += now - stats.currentSince; + stats.flips += 1; + stats.enter[mode] += 1; + stats.current = mode; + stats.currentSince = now; + persist(); + const snap = snapshot(); + await host?.log( + `lever ${cur.toUpperCase()}→${mode.toUpperCase()} · flips=${snap.flips} · auto ${snap.auto.total} / manual ${snap.manual.total}`, + ); + } +} + +servePlugin(definePlugin({ + name: "AhaKey Lever Counter", + version: "0.1.0", + methods: { + "demo/flowStats": () => snapshot(), + }, + onInitialize(_params, connectedHost) { + host = connectedHost; + }, + async onInitialized() { + await host?.log("lever-counter online · polling switch state every 1s"); + timer = setInterval(() => { + void tick(); + }, POLL_MS); + timer.unref(); + void tick(); + }, + onShutdown() { + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + persist(); + }, +})); diff --git a/sdks/typescript/examples/lever-counter/tsconfig.json b/sdks/typescript/examples/lever-counter/tsconfig.json new file mode 100644 index 00000000..731b8aeb --- /dev/null +++ b/sdks/typescript/examples/lever-counter/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "types": [ + "node" + ], + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index ec1cc8e2..f10be990 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -16,14 +16,14 @@ "README.md" ], "scripts": { - "build": "tsc -p tsconfig.json && tsc -p examples/hello-plugin/tsconfig.json", + "build": "tsc -p tsconfig.json && tsc -p examples/hello-plugin/tsconfig.json && tsc -p examples/lever-counter/tsconfig.json", "build:sdk": "tsc -p tsconfig.json", - "clean": "rm -rf dist examples/hello-plugin/dist", + "clean": "rm -rf dist examples/hello-plugin/dist examples/lever-counter/dist", "demo": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../platforms/macos PluginShowcase", "demo:agent": "swift run --package-path ../../platforms/macos ahakeyconfig-agent", "demo:cli": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../platforms/macos Plugin", "test": "npm run build && node --test test/*.test.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p examples/hello-plugin/tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p examples/hello-plugin/tsconfig.json --noEmit && tsc -p examples/lever-counter/tsconfig.json --noEmit" }, "engines": { "node": ">=18" From 462ddf72c5e87f26d3c6da2ebc2274a42e763a6f Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Thu, 18 Jun 2026 20:07:37 +0800 Subject: [PATCH 05/10] ci: add test+package workflow and macOS packaging script ci.yml runs on push/PR: TypeScript SDK typecheck + tests, compile-check all Swift targets, then package the macOS app and upload it as an artifact. package_app.sh assembles AhaKeyConfig into an ad-hoc signed "AhaKey Studio.app", shared by local builds and CI. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 69 ++++++++++++++++++++++++++ platforms/macos/scripts/package_app.sh | 61 +++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100755 platforms/macos/scripts/package_app.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b121c2d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +# 同一分支后推的提交取消正在跑的旧 run,省 runner 时间。 +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + typescript-sdk: + name: TypeScript SDK · test + build + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdks/typescript + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: sdks/typescript/package-lock.json + + - name: Install dependencies + run: npm install + + - name: Typecheck (SDK + examples) + run: npm run typecheck + + - name: Test (build + node --test) + run: npm test + + macos-app: + name: macOS · build + package + runs-on: macos-15 + defaults: + run: + working-directory: platforms/macos + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # package_app.sh 用 git rev-list --count 算 build 号 + + - name: Toolchain + run: swift --version + + - name: Compile-check all targets (debug) + run: swift build + + - name: Package "AhaKey Studio.app" (release, ad-hoc signed) + run: | + chmod +x scripts/package_app.sh + scripts/package_app.sh + + - name: Zip app bundle + working-directory: platforms/macos/dist + run: ditto -c -k --sequesterRsrc --keepParent "AhaKey Studio.app" "AhaKey-Studio-macOS.zip" + + - name: Upload app artifact + uses: actions/upload-artifact@v4 + with: + name: AhaKey-Studio-macOS + path: platforms/macos/dist/AhaKey-Studio-macOS.zip + if-no-files-found: error diff --git a/platforms/macos/scripts/package_app.sh b/platforms/macos/scripts/package_app.sh new file mode 100755 index 00000000..29095ad8 --- /dev/null +++ b/platforms/macos/scripts/package_app.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# 把 AhaKeyConfig 组装成 ad-hoc 签名的 "AhaKey Studio.app"。 +# 本地与 CI 共用;产物落在 platforms/macos/dist/。 +# +# APP_VERSION=1.2.3 APP_BUILD=42 scripts/package_app.sh +# +# 版本号可用环境变量覆盖,缺省时 version=0.0.0、build=git 提交数。 +set -euo pipefail + +cd "$(dirname "$0")/.." # -> platforms/macos + +CONFIG="release" +APP_NAME="AhaKey Studio" +EXEC="AhaKeyConfig" +DIST="dist" +APP="${DIST}/${APP_NAME}.app" + +echo "==> swift build -c ${CONFIG} --product ${EXEC}" +swift build -c "${CONFIG}" --product "${EXEC}" +BIN_PATH="$(swift build -c "${CONFIG}" --show-bin-path)" + +echo "==> assembling ${APP}" +rm -rf "${APP}" +mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources" +cp "${BIN_PATH}/${EXEC}" "${APP}/Contents/MacOS/${EXEC}" + +VERSION="${APP_VERSION:-0.0.0}" +BUILD="${APP_BUILD:-$(git rev-list --count HEAD 2>/dev/null || echo 1)}" + +# EmbeddedInfo.plist 只含 TCC 权限键(已嵌进二进制的 __info_plist 段); +# bundle 的 Contents/Info.plist 还需要 CFBundleExecutable 等键,这里补全。 +cat > "${APP}/Contents/Info.plist" < + + + + CFBundleName${APP_NAME} + CFBundleDisplayName${APP_NAME} + CFBundleIdentifierlab.jawa.ahakeyconfig + CFBundleExecutable${EXEC} + CFBundlePackageTypeAPPL + CFBundleShortVersionString${VERSION} + CFBundleVersion${BUILD} + LSMinimumSystemVersion12.0 + NSHighResolutionCapable + NSBluetoothAlwaysUsageDescriptionAhaKey 配置需要蓝牙连接你的 AhaKey 键盘。 + NSMicrophoneUsageDescriptionAhaKey Studio 需要访问麦克风,才能使用苹果原生语音转写。 + NSSpeechRecognitionUsageDescriptionAhaKey Studio 需要语音识别权限,才能把语音键转换成苹果原生转写。 + + +PLIST + +# 默认资源(OLED 动图等),存在才拷。 +if [ -d "Resources" ]; then + cp -R "Resources/." "${APP}/Contents/Resources/" 2>/dev/null || true +fi + +# ad-hoc 签名,让产物在本地/CI 检查时可被 Gatekeeper 识别为已签名(非公证)。 +codesign --force --deep --sign - "${APP}" 2>/dev/null || echo "warn: codesign skipped (no codesign available)" + +echo "==> done: ${APP} (version ${VERSION}, build ${BUILD})" From d4bd6fe337e927a562c579ebc2f9a5e8bcca9158 Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Thu, 18 Jun 2026 20:15:55 +0800 Subject: [PATCH 06/10] fix(ci): build SDK before typechecking examples Examples resolve @ahakey/plugin-sdk via package self-reference to dist/index.d.ts, but dist/ is gitignored and absent on a clean CI checkout, so `npm run typecheck` failed with "Cannot find module '@ahakey/plugin-sdk'". Make typecheck self-contained by running build:sdk first, the same way the test script already builds. Co-Authored-By: Claude Opus 4.8 --- sdks/typescript/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index f10be990..258d0b89 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -23,7 +23,7 @@ "demo:agent": "swift run --package-path ../../platforms/macos ahakeyconfig-agent", "demo:cli": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../platforms/macos Plugin", "test": "npm run build && node --test test/*.test.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p examples/hello-plugin/tsconfig.json --noEmit && tsc -p examples/lever-counter/tsconfig.json --noEmit" + "typecheck": "npm run build:sdk && tsc -p examples/hello-plugin/tsconfig.json --noEmit && tsc -p examples/lever-counter/tsconfig.json --noEmit" }, "engines": { "node": ">=18" From feb5201c592c7b7a38899620904a1903d76246df Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Sun, 21 Jun 2026 15:28:45 +0800 Subject: [PATCH 07/10] refactor: rename platforms/macos to ahakeyconfig-mac to align with upstream layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream/main 把多平台目录从 platforms// 拍扁成 ahakeyconfig-/。 这一 commit 只做目录重命名,路径里引用 platforms/macos 的脚本/CI/docs 留到下一个 commit 处理。 --- {platforms/macos => ahakeyconfig-mac}/Makefile | 0 {platforms/macos => ahakeyconfig-mac}/Package.swift | 0 .../Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist | 0 .../Packaging/AhaKeyConfig-EmbeddedInfo.plist | 0 .../Resources/DefaultOLED/claude_0.gif | Bin .../Resources/DefaultOLED/cursor_0.gif | Bin .../Sources/Agent/AhaKeyAgent.swift | 0 .../Sources/Agent/CursorCliLeverSync.swift | 0 .../Agent/CursorPermissionsJsonLeverSync.swift | 0 .../Sources/Agent/HookClient.swift | 0 .../Sources/Agent/main.swift | 0 .../Sources/AhaKeyConfigApp.swift | 0 .../Sources/AhaKeyPlugin/Plugin.swift | 0 .../Sources/AhaKeyPluginKit/JSONRPC.swift | 0 .../Sources/AhaKeyPluginKit/PluginClient.swift | 0 .../Sources/AhaKeyPluginKit/PluginHost.swift | 0 .../Sources/AhaKeyPluginKit/PluginLifecycle.swift | 0 .../Sources/AhaKeyPluginKit/PluginManager.swift | 0 .../Sources/AhaKeyPluginKit/PluginManifest.swift | 0 .../AhaKeyPluginShowcase/PluginShowcaseApp.swift | 0 .../Sources/BLE/AhaKeyBLEManager.swift | 0 .../Sources/BLE/AhaKeyProtocol.swift | 0 .../Sources/Models/AhaKeyStudioModels.swift | 0 .../Sources/Utilities/AgentManager.swift | 0 .../Sources/Utilities/AhaTypeTextOptimizer.swift | 0 .../Sources/Utilities/CloudAccountManager.swift | 0 .../Sources/Utilities/DebugSigningFixer.swift | 0 .../Sources/Utilities/DefaultOLEDAssets.swift | 0 .../NativeSpeechTranscriptionService.swift | 0 .../Sources/Utilities/OLEDFrameEncoder.swift | 0 .../Sources/Utilities/VoiceRelayService.swift | 0 .../Utilities/VoiceStatusHUDController.swift | 0 .../Sources/Views/AhaKeyStudioView.swift | 0 .../Sources/Views/CompatLabeledContent.swift | 0 .../Sources/Views/ContentView.swift | 0 .../Sources/Views/DeviceInfoView.swift | 0 .../Sources/Views/KeyMappingView.swift | 0 .../Sources/Views/OLEDManagerView.swift | 0 .../macos => ahakeyconfig-mac}/VibeCodeKeyboard.ico | Bin .../scripts/build.local.env | 0 .../scripts/build.local.env.example | 0 .../scripts/generate_dmg_background.swift | 0 .../scripts/generate_icons.swift | 0 .../scripts/package_app.sh | 0 44 files changed, 0 insertions(+), 0 deletions(-) rename {platforms/macos => ahakeyconfig-mac}/Makefile (100%) rename {platforms/macos => ahakeyconfig-mac}/Package.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist (100%) rename {platforms/macos => ahakeyconfig-mac}/Packaging/AhaKeyConfig-EmbeddedInfo.plist (100%) rename {platforms/macos => ahakeyconfig-mac}/Resources/DefaultOLED/claude_0.gif (100%) rename {platforms/macos => ahakeyconfig-mac}/Resources/DefaultOLED/cursor_0.gif (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Agent/AhaKeyAgent.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Agent/CursorCliLeverSync.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Agent/CursorPermissionsJsonLeverSync.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Agent/HookClient.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Agent/main.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyConfigApp.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyPlugin/Plugin.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyPluginKit/JSONRPC.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyPluginKit/PluginClient.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyPluginKit/PluginHost.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyPluginKit/PluginLifecycle.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyPluginKit/PluginManager.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyPluginKit/PluginManifest.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/BLE/AhaKeyBLEManager.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/BLE/AhaKeyProtocol.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Models/AhaKeyStudioModels.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/AgentManager.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/AhaTypeTextOptimizer.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/CloudAccountManager.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/DebugSigningFixer.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/DefaultOLEDAssets.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/NativeSpeechTranscriptionService.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/OLEDFrameEncoder.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/VoiceRelayService.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Utilities/VoiceStatusHUDController.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Views/AhaKeyStudioView.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Views/CompatLabeledContent.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Views/ContentView.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Views/DeviceInfoView.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Views/KeyMappingView.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/Sources/Views/OLEDManagerView.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/VibeCodeKeyboard.ico (100%) rename {platforms/macos => ahakeyconfig-mac}/scripts/build.local.env (100%) rename {platforms/macos => ahakeyconfig-mac}/scripts/build.local.env.example (100%) rename {platforms/macos => ahakeyconfig-mac}/scripts/generate_dmg_background.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/scripts/generate_icons.swift (100%) rename {platforms/macos => ahakeyconfig-mac}/scripts/package_app.sh (100%) diff --git a/platforms/macos/Makefile b/ahakeyconfig-mac/Makefile similarity index 100% rename from platforms/macos/Makefile rename to ahakeyconfig-mac/Makefile diff --git a/platforms/macos/Package.swift b/ahakeyconfig-mac/Package.swift similarity index 100% rename from platforms/macos/Package.swift rename to ahakeyconfig-mac/Package.swift diff --git a/platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist similarity index 100% rename from platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist rename to ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist diff --git a/platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo.plist b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist similarity index 100% rename from platforms/macos/Packaging/AhaKeyConfig-EmbeddedInfo.plist rename to ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist diff --git a/platforms/macos/Resources/DefaultOLED/claude_0.gif b/ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif similarity index 100% rename from platforms/macos/Resources/DefaultOLED/claude_0.gif rename to ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif diff --git a/platforms/macos/Resources/DefaultOLED/cursor_0.gif b/ahakeyconfig-mac/Resources/DefaultOLED/cursor_0.gif similarity index 100% rename from platforms/macos/Resources/DefaultOLED/cursor_0.gif rename to ahakeyconfig-mac/Resources/DefaultOLED/cursor_0.gif diff --git a/platforms/macos/Sources/Agent/AhaKeyAgent.swift b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift similarity index 100% rename from platforms/macos/Sources/Agent/AhaKeyAgent.swift rename to ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift diff --git a/platforms/macos/Sources/Agent/CursorCliLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift similarity index 100% rename from platforms/macos/Sources/Agent/CursorCliLeverSync.swift rename to ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift diff --git a/platforms/macos/Sources/Agent/CursorPermissionsJsonLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift similarity index 100% rename from platforms/macos/Sources/Agent/CursorPermissionsJsonLeverSync.swift rename to ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift diff --git a/platforms/macos/Sources/Agent/HookClient.swift b/ahakeyconfig-mac/Sources/Agent/HookClient.swift similarity index 100% rename from platforms/macos/Sources/Agent/HookClient.swift rename to ahakeyconfig-mac/Sources/Agent/HookClient.swift diff --git a/platforms/macos/Sources/Agent/main.swift b/ahakeyconfig-mac/Sources/Agent/main.swift similarity index 100% rename from platforms/macos/Sources/Agent/main.swift rename to ahakeyconfig-mac/Sources/Agent/main.swift diff --git a/platforms/macos/Sources/AhaKeyConfigApp.swift b/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyConfigApp.swift rename to ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift diff --git a/platforms/macos/Sources/AhaKeyPlugin/Plugin.swift b/ahakeyconfig-mac/Sources/AhaKeyPlugin/Plugin.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyPlugin/Plugin.swift rename to ahakeyconfig-mac/Sources/AhaKeyPlugin/Plugin.swift diff --git a/platforms/macos/Sources/AhaKeyPluginKit/JSONRPC.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/JSONRPC.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyPluginKit/JSONRPC.swift rename to ahakeyconfig-mac/Sources/AhaKeyPluginKit/JSONRPC.swift diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginClient.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginClient.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyPluginKit/PluginClient.swift rename to ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginClient.swift diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginHost.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyPluginKit/PluginHost.swift rename to ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginLifecycle.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginLifecycle.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyPluginKit/PluginLifecycle.swift rename to ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginLifecycle.swift diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginManager.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManager.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyPluginKit/PluginManager.swift rename to ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManager.swift diff --git a/platforms/macos/Sources/AhaKeyPluginKit/PluginManifest.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManifest.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyPluginKit/PluginManifest.swift rename to ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManifest.swift diff --git a/platforms/macos/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift similarity index 100% rename from platforms/macos/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift rename to ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift diff --git a/platforms/macos/Sources/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift similarity index 100% rename from platforms/macos/Sources/BLE/AhaKeyBLEManager.swift rename to ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift diff --git a/platforms/macos/Sources/BLE/AhaKeyProtocol.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift similarity index 100% rename from platforms/macos/Sources/BLE/AhaKeyProtocol.swift rename to ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift diff --git a/platforms/macos/Sources/Models/AhaKeyStudioModels.swift b/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift similarity index 100% rename from platforms/macos/Sources/Models/AhaKeyStudioModels.swift rename to ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift diff --git a/platforms/macos/Sources/Utilities/AgentManager.swift b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift similarity index 100% rename from platforms/macos/Sources/Utilities/AgentManager.swift rename to ahakeyconfig-mac/Sources/Utilities/AgentManager.swift diff --git a/platforms/macos/Sources/Utilities/AhaTypeTextOptimizer.swift b/ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift similarity index 100% rename from platforms/macos/Sources/Utilities/AhaTypeTextOptimizer.swift rename to ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift diff --git a/platforms/macos/Sources/Utilities/CloudAccountManager.swift b/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift similarity index 100% rename from platforms/macos/Sources/Utilities/CloudAccountManager.swift rename to ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift diff --git a/platforms/macos/Sources/Utilities/DebugSigningFixer.swift b/ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift similarity index 100% rename from platforms/macos/Sources/Utilities/DebugSigningFixer.swift rename to ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift diff --git a/platforms/macos/Sources/Utilities/DefaultOLEDAssets.swift b/ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift similarity index 100% rename from platforms/macos/Sources/Utilities/DefaultOLEDAssets.swift rename to ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift diff --git a/platforms/macos/Sources/Utilities/NativeSpeechTranscriptionService.swift b/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift similarity index 100% rename from platforms/macos/Sources/Utilities/NativeSpeechTranscriptionService.swift rename to ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift diff --git a/platforms/macos/Sources/Utilities/OLEDFrameEncoder.swift b/ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift similarity index 100% rename from platforms/macos/Sources/Utilities/OLEDFrameEncoder.swift rename to ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift diff --git a/platforms/macos/Sources/Utilities/VoiceRelayService.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift similarity index 100% rename from platforms/macos/Sources/Utilities/VoiceRelayService.swift rename to ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift diff --git a/platforms/macos/Sources/Utilities/VoiceStatusHUDController.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift similarity index 100% rename from platforms/macos/Sources/Utilities/VoiceStatusHUDController.swift rename to ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift diff --git a/platforms/macos/Sources/Views/AhaKeyStudioView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift similarity index 100% rename from platforms/macos/Sources/Views/AhaKeyStudioView.swift rename to ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift diff --git a/platforms/macos/Sources/Views/CompatLabeledContent.swift b/ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift similarity index 100% rename from platforms/macos/Sources/Views/CompatLabeledContent.swift rename to ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift diff --git a/platforms/macos/Sources/Views/ContentView.swift b/ahakeyconfig-mac/Sources/Views/ContentView.swift similarity index 100% rename from platforms/macos/Sources/Views/ContentView.swift rename to ahakeyconfig-mac/Sources/Views/ContentView.swift diff --git a/platforms/macos/Sources/Views/DeviceInfoView.swift b/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift similarity index 100% rename from platforms/macos/Sources/Views/DeviceInfoView.swift rename to ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift diff --git a/platforms/macos/Sources/Views/KeyMappingView.swift b/ahakeyconfig-mac/Sources/Views/KeyMappingView.swift similarity index 100% rename from platforms/macos/Sources/Views/KeyMappingView.swift rename to ahakeyconfig-mac/Sources/Views/KeyMappingView.swift diff --git a/platforms/macos/Sources/Views/OLEDManagerView.swift b/ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift similarity index 100% rename from platforms/macos/Sources/Views/OLEDManagerView.swift rename to ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift diff --git a/platforms/macos/VibeCodeKeyboard.ico b/ahakeyconfig-mac/VibeCodeKeyboard.ico similarity index 100% rename from platforms/macos/VibeCodeKeyboard.ico rename to ahakeyconfig-mac/VibeCodeKeyboard.ico diff --git a/platforms/macos/scripts/build.local.env b/ahakeyconfig-mac/scripts/build.local.env similarity index 100% rename from platforms/macos/scripts/build.local.env rename to ahakeyconfig-mac/scripts/build.local.env diff --git a/platforms/macos/scripts/build.local.env.example b/ahakeyconfig-mac/scripts/build.local.env.example similarity index 100% rename from platforms/macos/scripts/build.local.env.example rename to ahakeyconfig-mac/scripts/build.local.env.example diff --git a/platforms/macos/scripts/generate_dmg_background.swift b/ahakeyconfig-mac/scripts/generate_dmg_background.swift similarity index 100% rename from platforms/macos/scripts/generate_dmg_background.swift rename to ahakeyconfig-mac/scripts/generate_dmg_background.swift diff --git a/platforms/macos/scripts/generate_icons.swift b/ahakeyconfig-mac/scripts/generate_icons.swift similarity index 100% rename from platforms/macos/scripts/generate_icons.swift rename to ahakeyconfig-mac/scripts/generate_icons.swift diff --git a/platforms/macos/scripts/package_app.sh b/ahakeyconfig-mac/scripts/package_app.sh similarity index 100% rename from platforms/macos/scripts/package_app.sh rename to ahakeyconfig-mac/scripts/package_app.sh From 63f548ff56513591cf7bbcc78f6595b9aac6b71d Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Sun, 21 Jun 2026 15:30:03 +0800 Subject: [PATCH 08/10] refactor: update path references after macOS dir rename Update platforms/macos -> ahakeyconfig-mac in path references that belonged to feat/plugin's own commits (TS SDK npm scripts, CI workflow working-directory, package_app.sh comments). README/CHANGELOG/ docs path references are left for the upcoming merge with upstream/main to overwrite, since upstream already rewrote them. --- .github/workflows/ci.yml | 6 +++--- ahakeyconfig-mac/scripts/package_app.sh | 4 ++-- sdks/typescript/package.json | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b121c2d1..9de7aa33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: runs-on: macos-15 defaults: run: - working-directory: platforms/macos + working-directory: ahakeyconfig-mac steps: - uses: actions/checkout@v4 with: @@ -58,12 +58,12 @@ jobs: scripts/package_app.sh - name: Zip app bundle - working-directory: platforms/macos/dist + working-directory: ahakeyconfig-mac/dist run: ditto -c -k --sequesterRsrc --keepParent "AhaKey Studio.app" "AhaKey-Studio-macOS.zip" - name: Upload app artifact uses: actions/upload-artifact@v4 with: name: AhaKey-Studio-macOS - path: platforms/macos/dist/AhaKey-Studio-macOS.zip + path: ahakeyconfig-mac/dist/AhaKey-Studio-macOS.zip if-no-files-found: error diff --git a/ahakeyconfig-mac/scripts/package_app.sh b/ahakeyconfig-mac/scripts/package_app.sh index 29095ad8..de625323 100755 --- a/ahakeyconfig-mac/scripts/package_app.sh +++ b/ahakeyconfig-mac/scripts/package_app.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # 把 AhaKeyConfig 组装成 ad-hoc 签名的 "AhaKey Studio.app"。 -# 本地与 CI 共用;产物落在 platforms/macos/dist/。 +# 本地与 CI 共用;产物落在 ahakeyconfig-mac/dist/。 # # APP_VERSION=1.2.3 APP_BUILD=42 scripts/package_app.sh # # 版本号可用环境变量覆盖,缺省时 version=0.0.0、build=git 提交数。 set -euo pipefail -cd "$(dirname "$0")/.." # -> platforms/macos +cd "$(dirname "$0")/.." # -> ahakeyconfig-mac CONFIG="release" APP_NAME="AhaKey Studio" diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 258d0b89..ceaf7f4e 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -19,9 +19,9 @@ "build": "tsc -p tsconfig.json && tsc -p examples/hello-plugin/tsconfig.json && tsc -p examples/lever-counter/tsconfig.json", "build:sdk": "tsc -p tsconfig.json", "clean": "rm -rf dist examples/hello-plugin/dist examples/lever-counter/dist", - "demo": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../platforms/macos PluginShowcase", - "demo:agent": "swift run --package-path ../../platforms/macos ahakeyconfig-agent", - "demo:cli": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../platforms/macos Plugin", + "demo": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../ahakeyconfig-mac PluginShowcase", + "demo:agent": "swift run --package-path ../../ahakeyconfig-mac ahakeyconfig-agent", + "demo:cli": "npm run build && AHAKEY_PLUGINS_DIR=\"$PWD/examples\" swift run --package-path ../../ahakeyconfig-mac Plugin", "test": "npm run build && node --test test/*.test.mjs", "typecheck": "npm run build:sdk && tsc -p examples/hello-plugin/tsconfig.json --noEmit && tsc -p examples/lever-counter/tsconfig.json --noEmit" }, From 52a3e08613a8c42b885ec76850a340c3e2a8a64a Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Sun, 21 Jun 2026 15:55:51 +0800 Subject: [PATCH 09/10] fix(ci): update release workflow paths after macOS dir rename release.yml still referenced platforms/macos/client, which was renamed to ahakeyconfig-mac (feb5201/63f548f). ci.yml was updated but release.yml was missed, so any v* tag would fail the release job. - working-directory: platforms/macos/client -> ahakeyconfig-mac - upload path: platforms/macos/client/dist -> ahakeyconfig-mac/dist - pin DMG_BASENAME=AhaKey-Studio-macOS so the artifact filename matches the upload path (package_dmg.sh otherwise appends a timestamp) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ddbef10..ef41c88d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,9 @@ jobs: fetch-depth: 0 # git rev-list --count 需要完整历史 - name: Build & Package DMG (ad-hoc signed) - working-directory: platforms/macos/client + working-directory: ahakeyconfig-mac + env: + DMG_BASENAME: AhaKey-Studio-macOS # 固定产物名,与下方上传路径一致(否则默认带时间戳后缀) run: | chmod +x scripts/build.sh scripts/package_dmg.sh scripts/package_dmg.sh @@ -34,4 +36,4 @@ jobs: prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} generate_release_notes: true files: | - platforms/macos/client/dist/AhaKey-Studio-macOS.dmg + ahakeyconfig-mac/dist/AhaKey-Studio-macOS.dmg From 3bc26a0957e90994b369105ae24a16ffe5efead5 Mon Sep 17 00:00:00 2001 From: sakruhnab1 Date: Mon, 22 Jun 2026 10:11:13 +0800 Subject: [PATCH 10/10] fix(release): bake the real version into the app so users can tell builds apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.sh hard-coded CFBundleShortVersionString to 0.1.0, and release.yml only used the tag version for the GitHub Release title — so every published DMG reported 0.1.0 and users couldn't tell whether they'd installed a fix. - build.sh: read APP_VERSION (default 0.1.0) into CFBundleShortVersionString. - release.yml: resolve version from the tag before building and pass it as APP_VERSION, so tag vX.Y.Z -> app version X.Y.Z. - build.sh: fix BUILD_NUMBER git path ($APP_ROOT/../.. pointed above the repo after the dir rename, so CFBundleVersion was stuck at 1); use $APP_ROOT. Verified locally: APP_VERSION=1.2.3 -> CFBundleShortVersionString 1.2.3, CFBundleVersion 68 (commit count). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 11 ++++++----- ahakeyconfig-mac/scripts/build.sh | 7 +++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef41c88d..25e2804e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,18 +16,19 @@ jobs: with: fetch-depth: 0 # git rev-list --count 需要完整历史 + - name: Resolve version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + - name: Build & Package DMG (ad-hoc signed) working-directory: ahakeyconfig-mac env: - DMG_BASENAME: AhaKey-Studio-macOS # 固定产物名,与下方上传路径一致(否则默认带时间戳后缀) + DMG_BASENAME: AhaKey-Studio-macOS # 固定产物名,与下方上传路径一致(否则默认带时间戳后缀) + APP_VERSION: ${{ steps.version.outputs.version }} # tag vX.Y.Z → 写进 app 的 CFBundleShortVersionString run: | chmod +x scripts/build.sh scripts/package_dmg.sh scripts/package_dmg.sh - - name: Get version from tag - id: version - run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/ahakeyconfig-mac/scripts/build.sh b/ahakeyconfig-mac/scripts/build.sh index 6c2bbf3f..c560ff15 100755 --- a/ahakeyconfig-mac/scripts/build.sh +++ b/ahakeyconfig-mac/scripts/build.sh @@ -86,7 +86,10 @@ if [[ -d "$APP_ROOT/Resources/DefaultOLED" ]]; then ditto "$APP_ROOT/Resources/DefaultOLED" "$APP_BUNDLE/Contents/Resources/DefaultOLED" fi -BUILD_NUMBER="$(git -C "$APP_ROOT/../.." rev-list --count HEAD 2>/dev/null || echo 1)" +BUILD_NUMBER="$(git -C "$APP_ROOT" rev-list --count HEAD 2>/dev/null || echo 1)" +# 版本号:缺省 0.1.0(本地开发),release.yml 打 tag(vX.Y.Z) 时经 APP_VERSION 注入真实版本, +# 否则所有 Release 都会是同一个写死的版本号,用户无法区分是否已更新。 +APP_VERSION_STRING="${APP_VERSION:-0.1.0}" cat > "$INFO_PLIST" < @@ -110,7 +113,7 @@ cat > "$INFO_PLIST" <CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + ${APP_VERSION_STRING} CFBundleVersion ${BUILD_NUMBER} LSMinimumSystemVersion