diff --git a/PRIVACY.md b/PRIVACY.md index ee74ca21..7461069b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -17,6 +17,22 @@ qwen-audio-agent 是本地运行的语音前台,不包含内置遥测、广告 ## 本地数据 +可选的 Web/TUI composer 听写使用独立 Qwen ASR 连接。partial、未提交、取消、失败 +和超时内容只存在于当前进程/会话的临时内存,不进入 conversation、Memory、日志或 +磁盘,也不会新增逐字听写历史。普通文本只有用户成功提交后才沿用既有会话规则; +明确的长期事实纠正属于 Memory-only 控制意图,不进入普通会话或 memory extractor, +只复用现有 Memory 精确替换、敏感过滤与元数据审计。 + +Desktop 的“随处输入”仍复用同一听写连接与临时内存规则。InputMethodKit 只接收 +当前会话的结构化 partial/final/edit 操作和本会话自有 UTF-16 range,不读取或上传 +目标应用的周边文档。IME 与瞬态 Bridge 不持有 provider key、不采音、不联网,也不 +记录操作正文;目标、签名、Secure Event Input、输入源或可见状态无法确认时立即 +拒绝并取消 Gateway 听写。安装状态只保留 bundle 版本/注册/启用布尔值,不新增逐字 +历史。基础输入不申请 Accessibility;可选 Voice Send 仍是单独授权能力。 +用户主动切换到其他输入源时,macOS 可能按系统输入法语义把当前 marked partial +结算到原目标;Qwen 随即拒绝后续操作,不向新目标写入,也不把该文字送入 +conversation 或 Memory。 + 用户档案、长期记忆、任务状态、后台工作目录和配置默认保存在 `~/.config/qwaudio/`。API Key 等凭据不应写入仓库、对话或用户档案。卸载应用不会 自动删除该目录,避免意外丢失用户数据。 diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 16ff659d..28b50065 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -46,6 +46,11 @@ extraResources: mac: category: public.app-category.productivity icon: desktop/build/icon.icns + extraResources: + - from: dist/native-input/QwenInputBridge + to: native-input/QwenInputBridge + - from: dist/native-input/Qwen Input.app + to: native-input/Qwen Input.app hardenedRuntime: true entitlements: desktop/build/entitlements.mac.plist entitlementsInherit: desktop/build/entitlements.mac.inherit.plist diff --git a/desktop/native/QwenInput.xcodeproj/project.pbxproj b/desktop/native/QwenInput.xcodeproj/project.pbxproj new file mode 100644 index 00000000..28c5e296 --- /dev/null +++ b/desktop/native/QwenInput.xcodeproj/project.pbxproj @@ -0,0 +1,1011 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 01A9774F47E97EE161BEE071 /* TextOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4C14E869DB7F863B6188D3 /* TextOperation.swift */; }; + 06455682325167D4C9C56AED /* ControllerRegistryStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB6A421D8ECCD82D397C975 /* ControllerRegistryStateTests.swift */; }; + 0D5F6B51DE79A14DB8F4A387 /* libQwenInputCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8C841D7EA383D4D382E342DA /* libQwenInputCore.a */; }; + 189A275B7EF486FDBFA6B146 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC381746C0EBAAFA3C56F4E3 /* Carbon.framework */; }; + 18C1DAD6C4DD15EEC5601FD9 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97E529D760644859BC504C77 /* Security.framework */; }; + 1986935E02D571BCB9DB3A94 /* TextClientAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E9FAF73BC1C92B1A6214A0 /* TextClientAdapter.swift */; }; + 1D0661AF1E79C88D416EF685 /* SystemInputMethodLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88A81FD5D63F4B15613CC49 /* SystemInputMethodLifecycle.swift */; }; + 25D19EE917B79B6ACF838670 /* libQwenInputCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8C841D7EA383D4D382E342DA /* libQwenInputCore.a */; }; + 2C1163C3C3DA1D9F9F32EB89 /* libQwenInputCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8C841D7EA383D4D382E342DA /* libQwenInputCore.a */; }; + 3617F3A443DEF060839FC708 /* DynamicPeerCodeValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588E045EF60E101B7495AE25 /* DynamicPeerCodeValidator.swift */; }; + 44032CAC838B4E7095F771A3 /* NativeRuntimePeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E37FE60BADFDA91DB2566F8 /* NativeRuntimePeer.swift */; }; + 44FDBA8C71F39045891252FF /* FrameCodecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E83F22A48A735F44D2E5F41B /* FrameCodecTests.swift */; }; + 45E21E00A294E83B949A3673 /* BridgeRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3628751C0DC9361470DFD8F8 /* BridgeRuntime.swift */; }; + 4A725B4EB4088D8F5EF991B2 /* ControllerRegistryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 721ADAF0107F5823CDC23546 /* ControllerRegistryState.swift */; }; + 4D3A832527594504C522AE85 /* NativeInputCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B581168B33486F29A4694F /* NativeInputCore.swift */; }; + 53FFCC20116DB89AF7A2B9C1 /* BridgeConnectionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCDF991A0FDFA79A190157E0 /* BridgeConnectionStateTests.swift */; }; + 5679DE36847E054AA1A09324 /* PeerRequirementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C85A2B7FC49C6B84F6C18218 /* PeerRequirementTests.swift */; }; + 5756C62B733A8524756FC260 /* AuthenticatedUnixSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A7B83D5C9B5BA9CBE04F9D /* AuthenticatedUnixSocket.swift */; }; + 596309270992A8564D45B925 /* InputSourceCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25024CC498402563F292D906 /* InputSourceCoordinatorTests.swift */; }; + 6BA2BDD788A1082DB4C19F08 /* PeerRequirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F61A4734DEEEE1248DE931 /* PeerRequirement.swift */; }; + 77EC0D1027FE098DCC6B423E /* libQwenInputCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8C841D7EA383D4D382E342DA /* libQwenInputCore.a */; }; + 7B70D9062E2A585345F9AC68 /* InputMethodLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C06E5F85A581C0632C52CD1 /* InputMethodLifecycle.swift */; }; + 7D746010C087BF643014EE6F /* BridgeRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3628751C0DC9361470DFD8F8 /* BridgeRuntime.swift */; }; + 80A9FB1F3DDA2669F7F0A974 /* SystemInputMethodLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88A81FD5D63F4B15613CC49 /* SystemInputMethodLifecycle.swift */; }; + 8A3AF1B577451BDD8056B6BB /* TextClientAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF2C108DBD8E6995DE407630 /* TextClientAdapter.swift */; }; + 8C08DA1F77D6033BA25BB419 /* InputController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E4C8AEC4F0097B8FFD827 /* InputController.swift */; }; + 9537E4DFD8781B5CF84C99EE /* SystemInputSourceAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B168E38F4B6CC3264F1B67F9 /* SystemInputSourceAPI.swift */; }; + 9ED034D4FD16241EDB558394 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97E529D760644859BC504C77 /* Security.framework */; }; + 9FEDB5A0B4CB028965E06007 /* SessionLedgerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDC51A8D57B58B899C2E787 /* SessionLedgerTests.swift */; }; + A0D001F40DC9F02D3B69CCDB /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8990507B969791B08FDBCA /* main.swift */; }; + A239BD22E331C6A4B59E0083 /* ProtocolValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA68E39311CBBE9C9C18B8CA /* ProtocolValidator.swift */; }; + A2985C9EFE1F9C230C8C6767 /* IPCProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F787312E23AB5D3B402A713 /* IPCProtocol.swift */; }; + ADE72E606AE4D99839FA2FBB /* SafetyGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577591C2BECAA90E94BD1178 /* SafetyGateTests.swift */; }; + B057FC9C34E050F1E8CA467D /* ProtocolValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5271BD2BFAEC8B7BC35004 /* ProtocolValidatorTests.swift */; }; + B1A59044E0386026509E2EEE /* BridgeRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86311A467F4CE898636FA51A /* BridgeRuntimeTests.swift */; }; + B49D6E60FA83D184C66F7279 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97E529D760644859BC504C77 /* Security.framework */; }; + BE2111D8D04467DE978E632F /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5AE175DEEBBA00594EFDE4 /* NativeSessionState.swift */; }; + C24A42E79CCEDE71BC31847F /* SecureRuntimeDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383D2DA101927793967A242E /* SecureRuntimeDirectoryTests.swift */; }; + C367DF4783999443DD93BE73 /* ControllerRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4A47C0A672E8E12E4F66D27 /* ControllerRegistry.swift */; }; + C3B7DC6EDEBCDDBD78F68845 /* CodeSignatureValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4B8909DA07B94EB7928467 /* CodeSignatureValidator.swift */; }; + C5AD6DA5386CB6DB28C4111B /* InputMethodLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A0B5A207FCE84DE3B57C362 /* InputMethodLifecycleTests.swift */; }; + C92D9B9F6C2A287BA0477009 /* NativeOperationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9803F3A74B81880860FA4537 /* NativeOperationBrokerTests.swift */; }; + C9B818E178D99654569FC357 /* SecureInputGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C972F27EA1F373474C528542 /* SecureInputGate.swift */; }; + C9CB6F55E039E8441EA4A28F /* FrameCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496A4E99E19697EFA205C95D /* FrameCodec.swift */; }; + CB51D96F550CEC6B0893167C /* BridgeConnectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B01EA50B27573BA9ADCDD45A /* BridgeConnectionState.swift */; }; + CB8C3BF85A18E47585A16E1B /* BridgePeerServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE16C0440FB1C43ACA124C /* BridgePeerServer.swift */; }; + CC8469CB45202045943FDF4D /* SecureRuntimeDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEE3FC51E7D6EB9E4E43BFA /* SecureRuntimeDirectory.swift */; }; + CE79A680878822DEE5FDCD30 /* SessionLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 321ED6838BE710B2DE2A3A8B /* SessionLedger.swift */; }; + D353264F02CE93B0069E6D2F /* NativeInputCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD79B4DC4EECEBB14D9CEAC1 /* NativeInputCoreTests.swift */; }; + DDE9CA0DBEC6EB4950C90DA1 /* TextClientAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA841AB10F259EAAC4341A18 /* TextClientAdapterTests.swift */; }; + E066990E0CB96B55C6FB9B46 /* InputSourceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FA45C153157D72549D744F /* InputSourceCoordinator.swift */; }; + E19C7652473F0AA3BD91DF5B /* BridgeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B61ED850B3EC90E170A007 /* BridgeClient.swift */; }; + E9119CD2B46FD3163F6027F0 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC381746C0EBAAFA3C56F4E3 /* Carbon.framework */; }; + EE2A3B7319DDC37F7E124BC9 /* SystemInputSourceAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B168E38F4B6CC3264F1B67F9 /* SystemInputSourceAPI.swift */; }; + F545C667DA4D2773532CEBB3 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97E529D760644859BC504C77 /* Security.framework */; }; + F69FF334CA8EE82FA5C9F5BB /* NativeOperationBroker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B45879E2C6C49AB90291B41 /* NativeOperationBroker.swift */; }; + F6C0696F1D1921E8C9DA569D /* SafetyGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49A7E8BF0C7765F1BF4990A8 /* SafetyGate.swift */; }; + F71918A86E1F924952D10474 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A44832D5271CD80CF763AD7 /* main.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 5DE0A666122C252D942584BF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A8C0873DA47E910D9B11151 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EB0E395C3966E27EE5FA1A12; + remoteInfo = QwenInputCore; + }; + 9C94DAE9E4CF6BD72D25CC29 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A8C0873DA47E910D9B11151 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EB0E395C3966E27EE5FA1A12; + remoteInfo = QwenInputCore; + }; + EC659B4B8CA742E475AC67E0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A8C0873DA47E910D9B11151 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EB0E395C3966E27EE5FA1A12; + remoteInfo = QwenInputCore; + }; + F23E422125EE22B03248E1E1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A8C0873DA47E910D9B11151 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EB0E395C3966E27EE5FA1A12; + remoteInfo = QwenInputCore; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 0AEE3FC51E7D6EB9E4E43BFA /* SecureRuntimeDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureRuntimeDirectory.swift; sourceTree = ""; }; + 0E37FE60BADFDA91DB2566F8 /* NativeRuntimePeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeRuntimePeer.swift; sourceTree = ""; }; + 1A0B5A207FCE84DE3B57C362 /* InputMethodLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMethodLifecycleTests.swift; sourceTree = ""; }; + 25024CC498402563F292D906 /* InputSourceCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourceCoordinatorTests.swift; sourceTree = ""; }; + 296DB8E3E1198D4C21DE6161 /* QwenInputBridge */ = {isa = PBXFileReference; includeInIndex = 0; path = QwenInputBridge; sourceTree = BUILT_PRODUCTS_DIR; }; + 2C06E5F85A581C0632C52CD1 /* InputMethodLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMethodLifecycle.swift; sourceTree = ""; }; + 321ED6838BE710B2DE2A3A8B /* SessionLedger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLedger.swift; sourceTree = ""; }; + 32F61A4734DEEEE1248DE931 /* PeerRequirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerRequirement.swift; sourceTree = ""; }; + 3628751C0DC9361470DFD8F8 /* BridgeRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeRuntime.swift; sourceTree = ""; }; + 383D2DA101927793967A242E /* SecureRuntimeDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureRuntimeDirectoryTests.swift; sourceTree = ""; }; + 3B45879E2C6C49AB90291B41 /* NativeOperationBroker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeOperationBroker.swift; sourceTree = ""; }; + 496A4E99E19697EFA205C95D /* FrameCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameCodec.swift; sourceTree = ""; }; + 49A7E8BF0C7765F1BF4990A8 /* SafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyGate.swift; sourceTree = ""; }; + 577591C2BECAA90E94BD1178 /* SafetyGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyGateTests.swift; sourceTree = ""; }; + 588E045EF60E101B7495AE25 /* DynamicPeerCodeValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicPeerCodeValidator.swift; sourceTree = ""; }; + 645E4C8AEC4F0097B8FFD827 /* InputController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputController.swift; sourceTree = ""; }; + 67B581168B33486F29A4694F /* NativeInputCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeInputCore.swift; sourceTree = ""; }; + 721ADAF0107F5823CDC23546 /* ControllerRegistryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllerRegistryState.swift; sourceTree = ""; }; + 7A44832D5271CD80CF763AD7 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 7B5AE175DEEBBA00594EFDE4 /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = ""; }; + 7F787312E23AB5D3B402A713 /* IPCProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPCProtocol.swift; sourceTree = ""; }; + 85E9FAF73BC1C92B1A6214A0 /* TextClientAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextClientAdapter.swift; sourceTree = ""; }; + 86311A467F4CE898636FA51A /* BridgeRuntimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeRuntimeTests.swift; sourceTree = ""; }; + 89A7B83D5C9B5BA9CBE04F9D /* AuthenticatedUnixSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedUnixSocket.swift; sourceTree = ""; }; + 89D305934785E8CF7A0EE3DB /* QwenInputBridgeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = QwenInputBridgeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8C841D7EA383D4D382E342DA /* libQwenInputCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libQwenInputCore.a; sourceTree = BUILT_PRODUCTS_DIR; }; + 95B61ED850B3EC90E170A007 /* BridgeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeClient.swift; sourceTree = ""; }; + 97E529D760644859BC504C77 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 9803F3A74B81880860FA4537 /* NativeOperationBrokerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeOperationBrokerTests.swift; sourceTree = ""; }; + 9B96A5981D8C078452FF8DBD /* QwenInputCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = QwenInputCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A4A47C0A672E8E12E4F66D27 /* ControllerRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllerRegistry.swift; sourceTree = ""; }; + A6276EBC31F280A4B03E9AFA /* QwenInput.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QwenInput.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AB4B8909DA07B94EB7928467 /* CodeSignatureValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeSignatureValidator.swift; sourceTree = ""; }; + B01EA50B27573BA9ADCDD45A /* BridgeConnectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeConnectionState.swift; sourceTree = ""; }; + B168E38F4B6CC3264F1B67F9 /* SystemInputSourceAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInputSourceAPI.swift; sourceTree = ""; }; + BC381746C0EBAAFA3C56F4E3 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; + C85A2B7FC49C6B84F6C18218 /* PeerRequirementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerRequirementTests.swift; sourceTree = ""; }; + C972F27EA1F373474C528542 /* SecureInputGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputGate.swift; sourceTree = ""; }; + CD79B4DC4EECEBB14D9CEAC1 /* NativeInputCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeInputCoreTests.swift; sourceTree = ""; }; + D5FA45C153157D72549D744F /* InputSourceCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourceCoordinator.swift; sourceTree = ""; }; + DA841AB10F259EAAC4341A18 /* TextClientAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextClientAdapterTests.swift; sourceTree = ""; }; + DBDC51A8D57B58B899C2E787 /* SessionLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLedgerTests.swift; sourceTree = ""; }; + DFB6A421D8ECCD82D397C975 /* ControllerRegistryStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllerRegistryStateTests.swift; sourceTree = ""; }; + E2FE16C0440FB1C43ACA124C /* BridgePeerServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgePeerServer.swift; sourceTree = ""; }; + E83F22A48A735F44D2E5F41B /* FrameCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameCodecTests.swift; sourceTree = ""; }; + EA68E39311CBBE9C9C18B8CA /* ProtocolValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolValidator.swift; sourceTree = ""; }; + ED4C14E869DB7F863B6188D3 /* TextOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextOperation.swift; sourceTree = ""; }; + EF2C108DBD8E6995DE407630 /* TextClientAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextClientAdapter.swift; sourceTree = ""; }; + F88A81FD5D63F4B15613CC49 /* SystemInputMethodLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInputMethodLifecycle.swift; sourceTree = ""; }; + FCDF991A0FDFA79A190157E0 /* BridgeConnectionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeConnectionStateTests.swift; sourceTree = ""; }; + FD5271BD2BFAEC8B7BC35004 /* ProtocolValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProtocolValidatorTests.swift; sourceTree = ""; }; + FD8990507B969791B08FDBCA /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 335E66A276C96E0D1596B215 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2C1163C3C3DA1D9F9F32EB89 /* libQwenInputCore.a in Frameworks */, + 9ED034D4FD16241EDB558394 /* Security.framework in Frameworks */, + E9119CD2B46FD3163F6027F0 /* Carbon.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 39CEA20A85386D02CE0BB8AF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F545C667DA4D2773532CEBB3 /* Security.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5803A601792697D787B98F6B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0D5F6B51DE79A14DB8F4A387 /* libQwenInputCore.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A70056F32D65B4740F8A1E93 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 77EC0D1027FE098DCC6B423E /* libQwenInputCore.a in Frameworks */, + B49D6E60FA83D184C66F7279 /* Security.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BF27FB8149F55FEECDAB2312 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 25D19EE917B79B6ACF838670 /* libQwenInputCore.a in Frameworks */, + 18C1DAD6C4DD15EEC5601FD9 /* Security.framework in Frameworks */, + 189A275B7EF486FDBFA6B146 /* Carbon.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1AD00006B1E7BA3D4437FB73 /* Products */ = { + isa = PBXGroup; + children = ( + 8C841D7EA383D4D382E342DA /* libQwenInputCore.a */, + A6276EBC31F280A4B03E9AFA /* QwenInput.app */, + 296DB8E3E1198D4C21DE6161 /* QwenInputBridge */, + 89D305934785E8CF7A0EE3DB /* QwenInputBridgeTests.xctest */, + 9B96A5981D8C078452FF8DBD /* QwenInputCoreTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 233AEA779F12029941356B0D /* QwenInputCoreTests */ = { + isa = PBXGroup; + children = ( + FCDF991A0FDFA79A190157E0 /* BridgeConnectionStateTests.swift */, + DFB6A421D8ECCD82D397C975 /* ControllerRegistryStateTests.swift */, + E83F22A48A735F44D2E5F41B /* FrameCodecTests.swift */, + 1A0B5A207FCE84DE3B57C362 /* InputMethodLifecycleTests.swift */, + 25024CC498402563F292D906 /* InputSourceCoordinatorTests.swift */, + CD79B4DC4EECEBB14D9CEAC1 /* NativeInputCoreTests.swift */, + 9803F3A74B81880860FA4537 /* NativeOperationBrokerTests.swift */, + C85A2B7FC49C6B84F6C18218 /* PeerRequirementTests.swift */, + FD5271BD2BFAEC8B7BC35004 /* ProtocolValidatorTests.swift */, + 577591C2BECAA90E94BD1178 /* SafetyGateTests.swift */, + 383D2DA101927793967A242E /* SecureRuntimeDirectoryTests.swift */, + DBDC51A8D57B58B899C2E787 /* SessionLedgerTests.swift */, + DA841AB10F259EAAC4341A18 /* TextClientAdapterTests.swift */, + ); + path = QwenInputCoreTests; + sourceTree = ""; + }; + 31A4DAF81B88503195DF40FC /* Sources */ = { + isa = PBXGroup; + children = ( + E8BDE45B42A9FC3C7381C629 /* QwenInput */, + EA58AB78B142CDF8154FA3DF /* QwenInputBridge */, + 8B5757D91B6406EBA8984D6B /* QwenInputCore */, + ); + path = Sources; + sourceTree = ""; + }; + 45A23ACDA19592CD1F8D1374 /* QwenInputBridgeTests */ = { + isa = PBXGroup; + children = ( + 86311A467F4CE898636FA51A /* BridgeRuntimeTests.swift */, + ); + path = QwenInputBridgeTests; + sourceTree = ""; + }; + 587D1AEA10426ECECD095EBA = { + isa = PBXGroup; + children = ( + 31A4DAF81B88503195DF40FC /* Sources */, + F0ECBA30EC41AC171541184B /* Tests */, + 77910CDC53B0E1F22B4DF76F /* Frameworks */, + 1AD00006B1E7BA3D4437FB73 /* Products */, + ); + sourceTree = ""; + }; + 77910CDC53B0E1F22B4DF76F /* Frameworks */ = { + isa = PBXGroup; + children = ( + BC381746C0EBAAFA3C56F4E3 /* Carbon.framework */, + 97E529D760644859BC504C77 /* Security.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 8B5757D91B6406EBA8984D6B /* QwenInputCore */ = { + isa = PBXGroup; + children = ( + 89A7B83D5C9B5BA9CBE04F9D /* AuthenticatedUnixSocket.swift */, + B01EA50B27573BA9ADCDD45A /* BridgeConnectionState.swift */, + AB4B8909DA07B94EB7928467 /* CodeSignatureValidator.swift */, + 721ADAF0107F5823CDC23546 /* ControllerRegistryState.swift */, + 588E045EF60E101B7495AE25 /* DynamicPeerCodeValidator.swift */, + 496A4E99E19697EFA205C95D /* FrameCodec.swift */, + 2C06E5F85A581C0632C52CD1 /* InputMethodLifecycle.swift */, + D5FA45C153157D72549D744F /* InputSourceCoordinator.swift */, + 7F787312E23AB5D3B402A713 /* IPCProtocol.swift */, + 67B581168B33486F29A4694F /* NativeInputCore.swift */, + 3B45879E2C6C49AB90291B41 /* NativeOperationBroker.swift */, + 0E37FE60BADFDA91DB2566F8 /* NativeRuntimePeer.swift */, + 7B5AE175DEEBBA00594EFDE4 /* NativeSessionState.swift */, + 32F61A4734DEEEE1248DE931 /* PeerRequirement.swift */, + EA68E39311CBBE9C9C18B8CA /* ProtocolValidator.swift */, + 49A7E8BF0C7765F1BF4990A8 /* SafetyGate.swift */, + 0AEE3FC51E7D6EB9E4E43BFA /* SecureRuntimeDirectory.swift */, + 321ED6838BE710B2DE2A3A8B /* SessionLedger.swift */, + EF2C108DBD8E6995DE407630 /* TextClientAdapter.swift */, + ED4C14E869DB7F863B6188D3 /* TextOperation.swift */, + ); + path = QwenInputCore; + sourceTree = ""; + }; + E8BDE45B42A9FC3C7381C629 /* QwenInput */ = { + isa = PBXGroup; + children = ( + 95B61ED850B3EC90E170A007 /* BridgeClient.swift */, + A4A47C0A672E8E12E4F66D27 /* ControllerRegistry.swift */, + 645E4C8AEC4F0097B8FFD827 /* InputController.swift */, + 7A44832D5271CD80CF763AD7 /* main.swift */, + C972F27EA1F373474C528542 /* SecureInputGate.swift */, + 85E9FAF73BC1C92B1A6214A0 /* TextClientAdapter.swift */, + ); + path = QwenInput; + sourceTree = ""; + }; + EA58AB78B142CDF8154FA3DF /* QwenInputBridge */ = { + isa = PBXGroup; + children = ( + E2FE16C0440FB1C43ACA124C /* BridgePeerServer.swift */, + 3628751C0DC9361470DFD8F8 /* BridgeRuntime.swift */, + FD8990507B969791B08FDBCA /* main.swift */, + F88A81FD5D63F4B15613CC49 /* SystemInputMethodLifecycle.swift */, + B168E38F4B6CC3264F1B67F9 /* SystemInputSourceAPI.swift */, + ); + path = QwenInputBridge; + sourceTree = ""; + }; + F0ECBA30EC41AC171541184B /* Tests */ = { + isa = PBXGroup; + children = ( + 45A23ACDA19592CD1F8D1374 /* QwenInputBridgeTests */, + 233AEA779F12029941356B0D /* QwenInputCoreTests */, + ); + path = Tests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1C0AD0531C22DCB049394438 /* QwenInput */ = { + isa = PBXNativeTarget; + buildConfigurationList = D62B6156C3370D097C2F6FE7 /* Build configuration list for PBXNativeTarget "QwenInput" */; + buildPhases = ( + 20231296AB1453179BB5C8D5 /* Sources */, + A70056F32D65B4740F8A1E93 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + C460820A66BE3AB3067A213C /* PBXTargetDependency */, + ); + name = QwenInput; + packageProductDependencies = ( + ); + productName = QwenInput; + productReference = A6276EBC31F280A4B03E9AFA /* QwenInput.app */; + productType = "com.apple.product-type.application"; + }; + 96A006D5F512EF7356CD51C6 /* QwenInputCoreTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 64F2F2624BBA4218C1A93EA2 /* Build configuration list for PBXNativeTarget "QwenInputCoreTests" */; + buildPhases = ( + 0ED574C7F3EE14A36B98A2DA /* Sources */, + 5803A601792697D787B98F6B /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 71D73F7238879EEF7E46818B /* PBXTargetDependency */, + ); + name = QwenInputCoreTests; + packageProductDependencies = ( + ); + productName = QwenInputCoreTests; + productReference = 9B96A5981D8C078452FF8DBD /* QwenInputCoreTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + B5383CB908DBB27A6C8ED485 /* QwenInputBridgeTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 944DB030E89DDFF01A7922E0 /* Build configuration list for PBXNativeTarget "QwenInputBridgeTests" */; + buildPhases = ( + 4063FECFDE381E816846CABF /* Sources */, + 335E66A276C96E0D1596B215 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 7A243574E844DA1C737EE28C /* PBXTargetDependency */, + ); + name = QwenInputBridgeTests; + packageProductDependencies = ( + ); + productName = QwenInputBridgeTests; + productReference = 89D305934785E8CF7A0EE3DB /* QwenInputBridgeTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + BF79E8A04E390515CC61D962 /* QwenInputBridge */ = { + isa = PBXNativeTarget; + buildConfigurationList = DCE82AEFB729903880723222 /* Build configuration list for PBXNativeTarget "QwenInputBridge" */; + buildPhases = ( + 8730B3A4F4DAED08DE80F872 /* Sources */, + BF27FB8149F55FEECDAB2312 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 3931B84807D195DF4B692472 /* PBXTargetDependency */, + ); + name = QwenInputBridge; + packageProductDependencies = ( + ); + productName = QwenInputBridge; + productReference = 296DB8E3E1198D4C21DE6161 /* QwenInputBridge */; + productType = "com.apple.product-type.tool"; + }; + EB0E395C3966E27EE5FA1A12 /* QwenInputCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = B1044F718BCBD1CCF625232D /* Build configuration list for PBXNativeTarget "QwenInputCore" */; + buildPhases = ( + 021527C79718D429E3271E9E /* Sources */, + C5E6A3AA658177DEFB219B0E /* Copy Swift Objective-C Interface Header */, + 39CEA20A85386D02CE0BB8AF /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = QwenInputCore; + packageProductDependencies = ( + ); + productName = QwenInputCore; + productReference = 8C841D7EA383D4D382E342DA /* libQwenInputCore.a */; + productType = "com.apple.product-type.library.static"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5A8C0873DA47E910D9B11151 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 1C0AD0531C22DCB049394438 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + 96A006D5F512EF7356CD51C6 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + B5383CB908DBB27A6C8ED485 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + BF79E8A04E390515CC61D962 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + EB0E395C3966E27EE5FA1A12 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = B892B0842940E6FC232D2390 /* Build configuration list for PBXProject "QwenInput" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 587D1AEA10426ECECD095EBA; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 1AD00006B1E7BA3D4437FB73 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1C0AD0531C22DCB049394438 /* QwenInput */, + BF79E8A04E390515CC61D962 /* QwenInputBridge */, + B5383CB908DBB27A6C8ED485 /* QwenInputBridgeTests */, + EB0E395C3966E27EE5FA1A12 /* QwenInputCore */, + 96A006D5F512EF7356CD51C6 /* QwenInputCoreTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXShellScriptBuildPhase section */ + C5E6A3AA658177DEFB219B0E /* Copy Swift Objective-C Interface Header */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(DERIVED_SOURCES_DIR)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)", + ); + name = "Copy Swift Objective-C Interface Header"; + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/include/$(PRODUCT_MODULE_NAME)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "ditto \"${SCRIPT_INPUT_FILE_0}\" \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 021527C79718D429E3271E9E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5756C62B733A8524756FC260 /* AuthenticatedUnixSocket.swift in Sources */, + CB51D96F550CEC6B0893167C /* BridgeConnectionState.swift in Sources */, + C3B7DC6EDEBCDDBD78F68845 /* CodeSignatureValidator.swift in Sources */, + 4A725B4EB4088D8F5EF991B2 /* ControllerRegistryState.swift in Sources */, + 3617F3A443DEF060839FC708 /* DynamicPeerCodeValidator.swift in Sources */, + C9CB6F55E039E8441EA4A28F /* FrameCodec.swift in Sources */, + A2985C9EFE1F9C230C8C6767 /* IPCProtocol.swift in Sources */, + 7B70D9062E2A585345F9AC68 /* InputMethodLifecycle.swift in Sources */, + E066990E0CB96B55C6FB9B46 /* InputSourceCoordinator.swift in Sources */, + 4D3A832527594504C522AE85 /* NativeInputCore.swift in Sources */, + F69FF334CA8EE82FA5C9F5BB /* NativeOperationBroker.swift in Sources */, + 44032CAC838B4E7095F771A3 /* NativeRuntimePeer.swift in Sources */, + BE2111D8D04467DE978E632F /* NativeSessionState.swift in Sources */, + 6BA2BDD788A1082DB4C19F08 /* PeerRequirement.swift in Sources */, + A239BD22E331C6A4B59E0083 /* ProtocolValidator.swift in Sources */, + F6C0696F1D1921E8C9DA569D /* SafetyGate.swift in Sources */, + CC8469CB45202045943FDF4D /* SecureRuntimeDirectory.swift in Sources */, + CE79A680878822DEE5FDCD30 /* SessionLedger.swift in Sources */, + 8A3AF1B577451BDD8056B6BB /* TextClientAdapter.swift in Sources */, + 01A9774F47E97EE161BEE071 /* TextOperation.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0ED574C7F3EE14A36B98A2DA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 53FFCC20116DB89AF7A2B9C1 /* BridgeConnectionStateTests.swift in Sources */, + 06455682325167D4C9C56AED /* ControllerRegistryStateTests.swift in Sources */, + 44FDBA8C71F39045891252FF /* FrameCodecTests.swift in Sources */, + C5AD6DA5386CB6DB28C4111B /* InputMethodLifecycleTests.swift in Sources */, + 596309270992A8564D45B925 /* InputSourceCoordinatorTests.swift in Sources */, + D353264F02CE93B0069E6D2F /* NativeInputCoreTests.swift in Sources */, + C92D9B9F6C2A287BA0477009 /* NativeOperationBrokerTests.swift in Sources */, + 5679DE36847E054AA1A09324 /* PeerRequirementTests.swift in Sources */, + B057FC9C34E050F1E8CA467D /* ProtocolValidatorTests.swift in Sources */, + ADE72E606AE4D99839FA2FBB /* SafetyGateTests.swift in Sources */, + C24A42E79CCEDE71BC31847F /* SecureRuntimeDirectoryTests.swift in Sources */, + 9FEDB5A0B4CB028965E06007 /* SessionLedgerTests.swift in Sources */, + DDE9CA0DBEC6EB4950C90DA1 /* TextClientAdapterTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 20231296AB1453179BB5C8D5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E19C7652473F0AA3BD91DF5B /* BridgeClient.swift in Sources */, + C367DF4783999443DD93BE73 /* ControllerRegistry.swift in Sources */, + 8C08DA1F77D6033BA25BB419 /* InputController.swift in Sources */, + C9B818E178D99654569FC357 /* SecureInputGate.swift in Sources */, + 1986935E02D571BCB9DB3A94 /* TextClientAdapter.swift in Sources */, + F71918A86E1F924952D10474 /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4063FECFDE381E816846CABF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 45E21E00A294E83B949A3673 /* BridgeRuntime.swift in Sources */, + B1A59044E0386026509E2EEE /* BridgeRuntimeTests.swift in Sources */, + 1D0661AF1E79C88D416EF685 /* SystemInputMethodLifecycle.swift in Sources */, + 9537E4DFD8781B5CF84C99EE /* SystemInputSourceAPI.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8730B3A4F4DAED08DE80F872 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CB8C3BF85A18E47585A16E1B /* BridgePeerServer.swift in Sources */, + 7D746010C087BF643014EE6F /* BridgeRuntime.swift in Sources */, + 80A9FB1F3DDA2669F7F0A974 /* SystemInputMethodLifecycle.swift in Sources */, + EE2A3B7319DDC37F7E124BC9 /* SystemInputSourceAPI.swift in Sources */, + A0D001F40DC9F02D3B69CCDB /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 3931B84807D195DF4B692472 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EB0E395C3966E27EE5FA1A12 /* QwenInputCore */; + targetProxy = 9C94DAE9E4CF6BD72D25CC29 /* PBXContainerItemProxy */; + }; + 71D73F7238879EEF7E46818B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EB0E395C3966E27EE5FA1A12 /* QwenInputCore */; + targetProxy = EC659B4B8CA742E475AC67E0 /* PBXContainerItemProxy */; + }; + 7A243574E844DA1C737EE28C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EB0E395C3966E27EE5FA1A12 /* QwenInputCore */; + targetProxy = 5DE0A666122C252D942584BF /* PBXContainerItemProxy */; + }; + C460820A66BE3AB3067A213C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EB0E395C3966E27EE5FA1A12 /* QwenInputCore */; + targetProxy = F23E422125EE22B03248E1E1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2B9A9E15AD15C260A1B7F3BD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.qwenaudio.agent.inputcore; + PRODUCT_MODULE_NAME = QwenInputCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 4B30B77C6891C4158011B568 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = "ai.qwenaudio.agent.inputcore-tests"; + SDKROOT = macosx; + }; + name = Release; + }; + 51213AB9C6D56C28718D3A5D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 5B9AEE0388B4E5367F558859 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.qwenaudio.agent.inputbridge; + PRODUCT_NAME = QwenInputBridge; + SDKROOT = macosx; + }; + name = Debug; + }; + 6ABBDD6F182DB77650754346 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = "ai.qwenaudio.agent.inputbridge-tests"; + SDKROOT = macosx; + }; + name = Debug; + }; + 71A9D4748BE56BC49CC87BDF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.qwenaudio.agent.inputbridge; + PRODUCT_NAME = QwenInputBridge; + SDKROOT = macosx; + }; + name = Release; + }; + 74E3269035B8873F08A5E597 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.qwenaudio.agent.inputcore; + PRODUCT_MODULE_NAME = QwenInputCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + 8B7CF041268338914A8C995E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Resources/QwenInput-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.qwenaudio.agent.inputmethod; + PRODUCT_MODULE_NAME = QwenInput; + PRODUCT_NAME = "Qwen Input"; + REGISTER_WITH_LAUNCH_SERVICES = NO; + SDKROOT = macosx; + }; + name = Release; + }; + BBBA4074D812BEABEDA43BA6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = "ai.qwenaudio.agent.inputbridge-tests"; + SDKROOT = macosx; + }; + name = Release; + }; + C4F198628DD82E4E274C23D3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + DC6AF0A335B5C92A2E620836 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Resources/QwenInput-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.qwenaudio.agent.inputmethod; + PRODUCT_MODULE_NAME = QwenInput; + PRODUCT_NAME = "Qwen Input"; + REGISTER_WITH_LAUNCH_SERVICES = NO; + SDKROOT = macosx; + }; + name = Debug; + }; + E75113650E4C9004C6377C11 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = "ai.qwenaudio.agent.inputcore-tests"; + SDKROOT = macosx; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 64F2F2624BBA4218C1A93EA2 /* Build configuration list for PBXNativeTarget "QwenInputCoreTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E75113650E4C9004C6377C11 /* Debug */, + 4B30B77C6891C4158011B568 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 944DB030E89DDFF01A7922E0 /* Build configuration list for PBXNativeTarget "QwenInputBridgeTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6ABBDD6F182DB77650754346 /* Debug */, + BBBA4074D812BEABEDA43BA6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + B1044F718BCBD1CCF625232D /* Build configuration list for PBXNativeTarget "QwenInputCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2B9A9E15AD15C260A1B7F3BD /* Debug */, + 74E3269035B8873F08A5E597 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + B892B0842940E6FC232D2390 /* Build configuration list for PBXProject "QwenInput" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 51213AB9C6D56C28718D3A5D /* Debug */, + C4F198628DD82E4E274C23D3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D62B6156C3370D097C2F6FE7 /* Build configuration list for PBXNativeTarget "QwenInput" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC6AF0A335B5C92A2E620836 /* Debug */, + 8B7CF041268338914A8C995E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + DCE82AEFB729903880723222 /* Build configuration list for PBXNativeTarget "QwenInputBridge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5B9AEE0388B4E5367F558859 /* Debug */, + 71A9D4748BE56BC49CC87BDF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 5A8C0873DA47E910D9B11151 /* Project object */; +} diff --git a/desktop/native/QwenInput.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/desktop/native/QwenInput.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/desktop/native/QwenInput.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInput.xcscheme b/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInput.xcscheme new file mode 100644 index 00000000..5c382ff8 --- /dev/null +++ b/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInput.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputBridge.xcscheme b/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputBridge.xcscheme new file mode 100644 index 00000000..8cd5e51e --- /dev/null +++ b/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputBridge.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputBridgeTests.xcscheme b/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputBridgeTests.xcscheme new file mode 100644 index 00000000..c6315735 --- /dev/null +++ b/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputBridgeTests.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputCoreTests.xcscheme b/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputCoreTests.xcscheme new file mode 100644 index 00000000..0351d05f --- /dev/null +++ b/desktop/native/QwenInput.xcodeproj/xcshareddata/xcschemes/QwenInputCoreTests.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/native/README.md b/desktop/native/README.md new file mode 100644 index 00000000..8b665254 --- /dev/null +++ b/desktop/native/README.md @@ -0,0 +1,37 @@ +# Desktop native input + +This directory contains the macOS native-input components embedded in Qwen +Audio Agent Desktop: + +- `Qwen Input.app` is a palette-style InputMethodKit input source. +- `QwenInputBridge` is a transient child process owned by Electron main. +- `QwenInputCore` contains protocol, safety, text-ledger, peer-authentication, + and input-source restoration logic shared by both native targets. + +The native components do not capture audio, connect to the Gateway, hold model +credentials, or persist dictated text. Desktop remains the only product, +settings surface, microphone owner, network client, updater, and lifecycle +owner. The Bridge is not a daemon or LaunchAgent. + +## Local build + +```sh +npm run native-input:test +npm run native-input:build +``` + +The default build is current-architecture, ad-hoc signed, and written under +`dist/native-input`. It is for local inspection only. It is not installed, +registered, enabled, or selected by the build or test scripts. A formal release +build uses `native-input:build:release` to create universal artifacts before +Electron Builder signs and notarizes the complete Desktop app. + +## Phase 0 boundary + +Native input is disabled by default. The current automated phase validates the +artifacts, protocol, owned-text ranges, Secure Event Input gate, authenticated +local peer, input-source recovery policy, and Desktop-owned Bridge lifecycle. +It deliberately does not copy anything to `~/Library/Input Methods`, change the +active input source, request microphone or Accessibility permissions, or claim +that cross-application interaction has passed. Those actions belong to the +separately approved manual macOS matrix. diff --git a/desktop/native/Resources/QwenInput-Info.plist b/desktop/native/Resources/QwenInput-Info.plist new file mode 100644 index 00000000..bce5abd0 --- /dev/null +++ b/desktop/native/Resources/QwenInput-Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Qwen Input + CFBundleExecutable + Qwen Input + CFBundleIdentifier + ai.qwenaudio.agent.inputmethod + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Qwen Input + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.11.0 + CFBundleVersion + 1 + InputMethodConnectionName + ai.qwenaudio.agent.inputmethod.connection + InputMethodServerControllerClass + QwenInputController + InputMethodType + Palette + LSBackgroundOnly + + LSMinimumSystemVersion + 13.0 + TISInputSourceID + ai.qwenaudio.agent.inputmethod + TISIntendedLanguage + en + + diff --git a/desktop/native/Sources/QwenInput/BridgeClient.swift b/desktop/native/Sources/QwenInput/BridgeClient.swift new file mode 100644 index 00000000..474c28c1 --- /dev/null +++ b/desktop/native/Sources/QwenInput/BridgeClient.swift @@ -0,0 +1,183 @@ +import Foundation +import QwenInputCore + +final class InputBridgeClient: @unchecked Sendable { + private let queue = DispatchQueue(label: "ai.qwenaudio.agent.input.poll") + private let lock = NSLock() + private var timer: DispatchSourceTimer? + private var target: NativeOperationTarget? + private weak var controller: QwenInputController? + private var sender: AnyObject? + private var polling = false + private var connection = BridgeConnectionState() + + func activate( + controller: QwenInputController, + sender: AnyObject, + target: NativeOperationTarget + ) { + deactivate() + self.controller = controller + self.sender = sender + self.target = target + connection = BridgeConnectionState() + queue.async { [weak self] in + guard let self else { return } + let activation = NativeInputMessage( + type: .imeActivate, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + ) + if self.exchange(activation)?.accepted == true { + self.recordConnectionSuccess() + } else { + self.recordConnectionFailure( + controller: controller, + target: target + ) + } + self.startPolling() + } + } + + func deactivate() { + lock.lock() + let oldTimer = timer + timer = nil + let oldTarget = target + target = nil + controller = nil + sender = nil + polling = false + connection = BridgeConnectionState() + lock.unlock() + oldTimer?.cancel() + if let oldTarget { + queue.async { [weak self] in + _ = self?.exchange(NativeInputMessage( + type: .imeDeactivate, + sessionID: oldTarget.sessionID, + generation: oldTarget.generation, + targetID: oldTarget.targetID + )) + } + } + } + + private func startPolling() { + let source = DispatchSource.makeTimerSource(queue: queue) + source.schedule(deadline: .now(), repeating: .milliseconds(25)) + source.setEventHandler { [weak self] in self?.poll() } + lock.lock() + guard target != nil else { + lock.unlock() + source.cancel() + return + } + timer = source + lock.unlock() + source.resume() + } + + private func poll() { + lock.lock() + guard !polling, let target, let controller, let sender else { + lock.unlock() + return + } + let needsActivation = connection.needsActivation + polling = true + lock.unlock() + defer { + lock.lock() + polling = false + lock.unlock() + } + if needsActivation { + let activation = NativeInputMessage( + type: .imeActivate, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + ) + guard exchange(activation)?.accepted == true else { + recordConnectionFailure(controller: controller, target: target) + return + } + recordConnectionSuccess() + } + let request = NativeInputMessage( + type: .imePoll, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + ) + guard let operation = exchange(request) else { + recordConnectionFailure(controller: controller, target: target) + return + } + recordConnectionSuccess() + guard operation.type != .imeIdle else { + return + } + let accepted = DispatchQueue.main.sync { + controller.applyBridgeOperation(operation, sender: sender) + } + _ = exchange(NativeInputMessage( + type: .imeResult, + reason: accepted ? nil : "operation_rejected", + operationID: operation.operationID, + accepted: accepted, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + )) + } + + private func recordConnectionSuccess() { + lock.lock() + connection.recordSuccess() + lock.unlock() + } + + private func recordConnectionFailure( + controller: QwenInputController, + target: NativeOperationTarget + ) { + lock.lock() + let action = connection.recordFailure() + lock.unlock() + guard action == .failClosed else { return } + DispatchQueue.main.async { [weak self, weak controller] in + guard let self, + let controller, + let sender = self.currentSender(for: target) else { return } + controller.bridgeConnectionLost(sender: sender) + } + } + + private func currentSender( + for expectedTarget: NativeOperationTarget + ) -> AnyObject? { + lock.lock() + defer { lock.unlock() } + guard target == expectedTarget else { return nil } + return sender + } + + private func exchange(_ message: NativeInputMessage) -> NativeInputMessage? { + do { + let response = try AuthenticatedUnixSocketClient.exchange( + FrameCodec.encode(message), + socketURL: NativeRuntimePeer.runtimeDirectory().socketURL(), + peerRequirement: NativeRuntimePeer.requirement( + for: "ai.qwenaudio.agent.inputbridge" + ) + ) + return try FrameCodec.decode(NativeInputMessage.self, from: response) + } catch { + return nil + } + } +} diff --git a/desktop/native/Sources/QwenInput/ControllerRegistry.swift b/desktop/native/Sources/QwenInput/ControllerRegistry.swift new file mode 100644 index 00000000..1883712a --- /dev/null +++ b/desktop/native/Sources/QwenInput/ControllerRegistry.swift @@ -0,0 +1,44 @@ +import Foundation +import QwenInputCore + +final class ControllerRegistry: @unchecked Sendable { + static let shared = ControllerRegistry() + + private let lock = NSLock() + private weak var activeController: QwenInputController? + private var state = ControllerRegistryState() + + private init() {} + + func activate( + _ controller: QwenInputController, + clientIdentifier: String + ) -> ControllerTargetToken { + lock.lock() + defer { lock.unlock() } + activeController = controller + return state.activate(clientIdentifier: clientIdentifier) + } + + func deactivate( + _ controller: QwenInputController, + targetID: UUID + ) { + lock.lock() + defer { lock.unlock() } + guard activeController === controller else { return } + _ = state.deactivate(targetID: targetID) + activeController = nil + } + + func close( + _ controller: QwenInputController, + targetID: UUID + ) { + lock.lock() + defer { lock.unlock() } + guard activeController === controller else { return } + _ = state.close(targetID: targetID) + activeController = nil + } +} diff --git a/desktop/native/Sources/QwenInput/InputController.swift b/desktop/native/Sources/QwenInput/InputController.swift new file mode 100644 index 00000000..b7dadc56 --- /dev/null +++ b/desktop/native/Sources/QwenInput/InputController.swift @@ -0,0 +1,249 @@ +import AppKit +import InputMethodKit +import QwenInputCore + +@objc(QwenInputController) +final class QwenInputController: IMKInputController, @unchecked Sendable { + private var sessionState = NativeSessionState.ready + private var targetToken: ControllerTargetToken? + private var ledger: SessionLedger? + private let clientAdapter = TextClientAdapter() + private let textOperationController = ClientTextOperationController() + private let safetyGate = SystemSecureInputGate.makeSafetyGate() + private let bridgeClient = InputBridgeClient() + + override func activateServer(_ sender: Any!) { + super.activateServer(sender) + guard let inputClient = client(), + let client = IMKTextClientAdapter(inputClient), + let clientIdentifier = client.uniqueIdentifier, + !clientIdentifier.isEmpty else { + sessionState = .blocked + return + } + + let token = ControllerRegistry.shared.activate( + self, + clientIdentifier: clientIdentifier + ) + targetToken = token + let sessionID = UUID() + ledger = SessionLedger( + sessionID: sessionID, + generation: token.generation, + targetID: token.targetID + ) + sessionState = .ready + bridgeClient.activate( + controller: self, + sender: inputClient, + target: NativeOperationTarget( + sessionID: sessionID.uuidString, + generation: token.generation, + targetID: token.targetID.uuidString + ) + ) + } + + override func deactivateServer(_ sender: Any!) { + bridgeClient.deactivate() + removeOwnedPartialIfPossible(from: client()) + if let targetToken { + ControllerRegistry.shared.deactivate( + self, + targetID: targetToken.targetID + ) + } + invalidateSession() + super.deactivateServer(sender) + } + + override func inputControllerWillClose() { + bridgeClient.deactivate() + if let targetToken { + ControllerRegistry.shared.close( + self, + targetID: targetToken.targetID + ) + } + invalidateSession() + super.inputControllerWillClose() + } + + override func handle(_ event: NSEvent!, client sender: Any!) -> Bool { + _ = event + _ = sender + return InputEventPolicy.shouldConsumePhysicalKey(in: sessionState) + } + + func apply(_ effect: ClientTextEffect, sender: Any) -> Bool { + guard let client = IMKTextClientAdapter(sender) else { return false } + let context = SafetyContext( + featureEnabled: true, + desktopConnected: false, + target: targetToken == nil ? .unknown : .ready, + statusVisibility: .unknown, + inputSource: .ready + ) + let decision = safetyGate.evaluate( + state: sessionState, + context: context, + hasOwnedPartial: ledger?.ownedMarkedRange != nil + ) + guard decision == .captureAllowed else { return false } + return (try? clientAdapter.apply(effect, to: client).get()) != nil + } + + func applyBridgeOperation( + _ message: NativeInputMessage, + sender: Any + ) -> Bool { + guard var ledger, + let token = targetToken, + message.operationID != nil, + let client = IMKTextClientAdapter(sender) else { return false } + if let generation = message.generation, + generation != token.generation { return false } + if let targetID = message.targetID, + targetID != token.targetID.uuidString { return false } + if ledger.ownedMarkedRange?.location == NSNotFound, + client.markedRange.location != NSNotFound { + guard case .success = ledger.confirmMarkedRange(client.markedRange) else { + return false + } + self.ledger = ledger + } + + if message.type == .sessionCancel { + guard textOperationController.applyTransaction( + to: &ledger, + client: client, + operation: { candidate in + candidate.cancel( + clientMarkedRange: client.markedRange, + generation: token.generation, + targetID: token.targetID + ) + } + ) else { + return false + } + self.ledger = ledger + sessionState = .cancelled + return true + } + if message.type == .sessionPause { + sessionState = .paused + return true + } + if message.type == .sessionResume { + sessionState = .listening + return true + } + + sessionState = .transcribing + let context = SafetyContext( + featureEnabled: true, + desktopConnected: true, + target: .ready, + statusVisibility: message.statusVisible == true ? .ready : .unavailable, + inputSource: .ready + ) + guard safetyGate.evaluate( + state: sessionState, + context: context, + hasOwnedPartial: ledger.ownedMarkedRange != nil + ) == .captureAllowed else { + removeOwnedPartialIfPossible(from: sender) + sessionState = .blocked + return false + } + + let operation: (inout SessionLedger) -> Result + switch message.type { + case .sessionPartial: + guard let text = message.text else { return false } + operation = { candidate in + candidate.partial( + text: text, + selectedRange: client.selectedRange, + clientMarkedRange: client.markedRange, + generation: token.generation, + targetID: token.targetID + ) + } + case .sessionFinal: + guard let text = message.text else { return false } + operation = { candidate in + candidate.final( + text: text, + selectedRange: client.selectedRange, + clientMarkedRange: client.markedRange, + generation: token.generation, + targetID: token.targetID + ) + } + case .sessionOperation: + guard let target = message.target else { return false } + let edit: OwnedTextEdit = message.operation == "delete" + ? .delete(target: target) + : .replace( + target: target, + replacement: message.replacement ?? "" + ) + operation = { candidate in + candidate.edit( + edit, + generation: token.generation, + targetID: token.targetID + ) + } + default: + return false + } + guard textOperationController.applyTransaction( + to: &ledger, + client: client, + operation: operation + ) else { + sessionState = .blocked + return false + } + self.ledger = ledger + sessionState = message.type == .sessionPartial + ? .transcribing + : .readyToSend + return true + } + + func bridgeConnectionLost(sender: Any) { + removeOwnedPartialIfPossible(from: sender) + sessionState = .error + } + + private func removeOwnedPartialIfPossible(from sender: Any?) { + guard var ledger, + let token = targetToken, + let client = IMKTextClientAdapter(sender) else { + return + } + guard textOperationController.applyTransaction( + to: &ledger, + client: client, + operation: { candidate in + candidate.cancel( + clientMarkedRange: client.markedRange, + generation: token.generation, + targetID: token.targetID + ) + } + ) else { return } + self.ledger = ledger + } + + private func invalidateSession() { + sessionState = .cancelled + targetToken = nil + ledger = nil + } +} diff --git a/desktop/native/Sources/QwenInput/SecureInputGate.swift b/desktop/native/Sources/QwenInput/SecureInputGate.swift new file mode 100644 index 00000000..dedf4c5e --- /dev/null +++ b/desktop/native/Sources/QwenInput/SecureInputGate.swift @@ -0,0 +1,10 @@ +import Carbon.HIToolbox +import QwenInputCore + +enum SystemSecureInputGate { + static func makeSafetyGate() -> SafetyGate { + SafetyGate(secureEventInputEnabled: { + IsSecureEventInputEnabled() + }) + } +} diff --git a/desktop/native/Sources/QwenInput/TextClientAdapter.swift b/desktop/native/Sources/QwenInput/TextClientAdapter.swift new file mode 100644 index 00000000..48524da4 --- /dev/null +++ b/desktop/native/Sources/QwenInput/TextClientAdapter.swift @@ -0,0 +1,40 @@ +import Foundation +import InputMethodKit +import QwenInputCore + +final class IMKTextClientAdapter: NativeTextClient { + private let client: any IMKTextInput + + init?(_ value: Any?) { + guard let client = value as? any IMKTextInput else { return nil } + self.client = client + } + + var selectedRange: NSRange { + client.selectedRange() + } + + var markedRange: NSRange { + client.markedRange() + } + + func setMarkedText( + _ text: NSAttributedString, + selectionRange: NSRange, + replacementRange: NSRange + ) { + client.setMarkedText( + text, + selectionRange: selectionRange, + replacementRange: replacementRange + ) + } + + func insertText(_ text: Any, replacementRange: NSRange) { + client.insertText(text, replacementRange: replacementRange) + } + + var uniqueIdentifier: String? { + client.uniqueClientIdentifierString() + } +} diff --git a/desktop/native/Sources/QwenInput/main.swift b/desktop/native/Sources/QwenInput/main.swift new file mode 100644 index 00000000..fea4be71 --- /dev/null +++ b/desktop/native/Sources/QwenInput/main.swift @@ -0,0 +1,108 @@ +import AppKit +import InputMethodKit +import QwenInputCore + +#if DEBUG +if CommandLine.arguments.count == 3, + CommandLine.arguments[1] == "--operation-probe" { + let socketURL = URL(fileURLWithPath: CommandLine.arguments[2]) + let target = NativeOperationTarget( + sessionID: "probe-session", + generation: 1, + targetID: "probe-target" + ) + func exchange(_ message: NativeInputMessage) throws -> NativeInputMessage { + let response = try AuthenticatedUnixSocketClient.exchange( + FrameCodec.encode(message), + socketURL: socketURL, + peerRequirement: PeerCodeSigningRequirement.debugAdHoc( + bundleID: "ai.qwenaudio.agent.inputbridge" + ) + ) + return try FrameCodec.decode(NativeInputMessage.self, from: response) + } + do { + let activated = try exchange(NativeInputMessage( + type: .imeActivate, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + )) + guard activated.accepted == true else { exit(EXIT_FAILURE) } + try FileHandle.standardOutput.write(contentsOf: Data("probe.ready\n".utf8)) + let deadline = Date(timeIntervalSinceNow: 5) + var operation: NativeInputMessage? + while Date() < deadline, operation == nil { + let response = try exchange(NativeInputMessage( + type: .imePoll, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + )) + if response.type != .imeIdle { operation = response } + if operation == nil { Thread.sleep(forTimeInterval: 0.01) } + } + guard let operation, let operationID = operation.operationID else { + exit(EXIT_FAILURE) + } + let result = try exchange(NativeInputMessage( + type: .imeResult, + operationID: operationID, + accepted: true, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + )) + guard result.accepted == true else { exit(EXIT_FAILURE) } + _ = try exchange(NativeInputMessage( + type: .imeDeactivate, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + )) + exit(EXIT_SUCCESS) + } catch { + exit(EXIT_FAILURE) + } +} +if CommandLine.arguments.count == 3, + CommandLine.arguments[1] == "--peer-probe" { + do { + let requirement = try PeerCodeSigningRequirement.debugAdHoc( + bundleID: "ai.qwenaudio.agent.inputbridge" + ) + let request = try FrameCodec.encode(NativeInputMessage( + type: .sessionArm + )) + let response = try AuthenticatedUnixSocketClient.exchange( + request, + socketURL: URL(fileURLWithPath: CommandLine.arguments[2]), + peerRequirement: requirement + ) + guard response == request else { + exit(EXIT_FAILURE) + } + exit(EXIT_SUCCESS) + } catch { + exit(EXIT_FAILURE) + } +} +#endif + +guard + let connectionName = Bundle.main.object( + forInfoDictionaryKey: "InputMethodConnectionName" + ) as? String, + let bundleIdentifier = Bundle.main.bundleIdentifier +else { + exit(EXIT_FAILURE) +} + +_ = NativeInputCore.protocolVersion +let server = IMKServer( + name: connectionName, + bundleIdentifier: bundleIdentifier +) +withExtendedLifetime(server) { + RunLoop.main.run() +} diff --git a/desktop/native/Sources/QwenInputBridge/BridgePeerServer.swift b/desktop/native/Sources/QwenInputBridge/BridgePeerServer.swift new file mode 100644 index 00000000..ada2e737 --- /dev/null +++ b/desktop/native/Sources/QwenInputBridge/BridgePeerServer.swift @@ -0,0 +1,102 @@ +import Foundation +import QwenInputCore + +final class BridgePeerServer { + private let broker: NativeOperationBroker + private let runtimeDirectory: SecureRuntimeDirectory + private let server: AuthenticatedUnixSocketServer + + init( + broker: NativeOperationBroker, + runtimeDirectory: SecureRuntimeDirectory = NativeRuntimePeer.runtimeDirectory() + ) throws { + self.broker = broker + self.runtimeDirectory = runtimeDirectory + server = AuthenticatedUnixSocketServer( + runtimeDirectory: runtimeDirectory, + peerRequirement: try NativeRuntimePeer.requirement( + for: "ai.qwenaudio.agent.inputmethod" + ), + handler: { [weak broker] request in + guard let broker, + let message = try? FrameCodec.decode( + NativeInputMessage.self, + from: request + ), + let response = Self.handle(message, broker: broker) + else { return nil } + return try? FrameCodec.encode(response) + } + ) + } + + func start() throws { try server.start() } + + func stop() { + server.stop() + try? FileManager.default.removeItem(at: runtimeDirectory.url) + } + + private static func handle( + _ message: NativeInputMessage, + broker: NativeOperationBroker + ) -> NativeInputMessage? { + guard let target = target(from: message) else { return nil } + switch message.type { + case .imeActivate: + broker.activate(target) + return ack(for: message, accepted: true) + case .imeDeactivate: + broker.deactivate(target) + return ack(for: message, accepted: true) + case .imePoll: + return broker.poll(for: target) ?? NativeInputMessage( + type: .imeIdle, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + ) + case .imeResult: + guard let operationID = message.operationID, + let accepted = message.accepted else { return nil } + let completed = broker.complete( + operationID: operationID, + accepted: accepted, + reason: message.reason, + target: target + ) + return ack(for: message, accepted: completed) + default: + return nil + } + } + + private static func target( + from message: NativeInputMessage + ) -> NativeOperationTarget? { + guard let sessionID = message.sessionID, !sessionID.isEmpty, + let generation = message.generation, + let targetID = message.targetID, !targetID.isEmpty else { + return nil + } + return NativeOperationTarget( + sessionID: sessionID, + generation: generation, + targetID: targetID + ) + } + + private static func ack( + for message: NativeInputMessage, + accepted: Bool + ) -> NativeInputMessage { + NativeInputMessage( + type: .imeAck, + operationID: message.operationID, + accepted: accepted, + sessionID: message.sessionID, + generation: message.generation, + targetID: message.targetID + ) + } +} diff --git a/desktop/native/Sources/QwenInputBridge/BridgeRuntime.swift b/desktop/native/Sources/QwenInputBridge/BridgeRuntime.swift new file mode 100644 index 00000000..2d26a664 --- /dev/null +++ b/desktop/native/Sources/QwenInputBridge/BridgeRuntime.swift @@ -0,0 +1,250 @@ +import Foundation +import QwenInputCore + +enum BridgeRuntimeError: Error { + case unsupportedDirection(NativeInputMessageType) +} + +final class BridgeRuntime { + let input: FileHandle + let output: FileHandle + let lifecycle: InputMethodLifecycle? + let broker: NativeOperationBroker + private var inputSourceCoordinator: InputSourceCoordinator + + init( + input: FileHandle = .standardInput, + output: FileHandle = .standardOutput, + lifecycle: InputMethodLifecycle? = try? SystemInputMethodLifecycleFactory.make(), + broker: NativeOperationBroker = NativeOperationBroker(), + inputSourceAPI: InputSourceAPI = SystemInputSourceAPI() + ) { + self.input = input + self.output = output + self.lifecycle = lifecycle + self.broker = broker + inputSourceCoordinator = InputSourceCoordinator( + qwenInputSourceID: "ai.qwenaudio.agent.inputmethod", + api: inputSourceAPI + ) + } + + func run() throws { + defer { emergencyStop(reason: "bridge_exit") } + try write(NativeInputMessage(type: .bridgeReady, state: .ready)) + var decoder = FrameStreamDecoder() + + while true { + let chunk = input.availableData + if chunk.isEmpty { + try decoder.finish() + return + } + do { + for payload in try decoder.append(chunk) { + let request = try FrameCodec.decodePayload( + NativeInputMessage.self, + from: payload + ) + let response = try response(to: request) + try write(response.message) + if response.shouldStop { return } + } + } catch { + try? write(NativeInputMessage( + type: .bridgeError, + state: .error, + reason: String(describing: error) + )) + throw error + } + } + } + + private func response( + to request: NativeInputMessage + ) throws -> (message: NativeInputMessage, shouldStop: Bool) { + if request.operationID != nil, + request.type.rawValue.hasPrefix("session.") { + return (operationResponse(to: request), false) + } + let state: NativeSessionState + let shouldStop: Bool + switch request.type { + case .sessionArm: + state = .ready + shouldStop = false + case .sessionPartial: + state = .transcribing + shouldStop = false + case .sessionFinal: + state = .readyToSend + shouldStop = false + case .sessionCancel: + state = .cancelled + shouldStop = false + case .sessionPause: + state = .paused + shouldStop = false + case .sessionResume: + state = .listening + shouldStop = false + case .sessionOperation: + state = .transcribing + shouldStop = false + case .lifecycleStatus, .lifecycleInstall, .lifecycleRepair, + .lifecycleUninstall: + return (try lifecycleResponse(to: request), false) + case .bridgeStop: + state = .disabled + shouldStop = true + case .bridgeReady, .sessionState, .operationResult, + .lifecycleResult, .imeActivate, .imeDeactivate, .imePoll, + .imeResult, .imeIdle, .imeAck, .bridgeError: + throw BridgeRuntimeError.unsupportedDirection(request.type) + } + return ( + NativeInputMessage(type: .sessionState, state: state), + shouldStop + ) + } + + private func operationResponse( + to request: NativeInputMessage + ) -> NativeInputMessage { + guard let operationID = request.operationID else { + return operationResult(request, accepted: false, reason: "missing_operation_id") + } + if request.type == .sessionArm { + let source = inputSourceCoordinator.begin() + switch source { + case .selected, .alreadySelected: + break + case .selectionRequired: + return operationResult( + request, + accepted: false, + reason: "input_source_selection_required" + ) + default: + return operationResult( + request, + accepted: false, + reason: "input_source_\(String(describing: source))" + ) + } + guard let target = broker.waitForTarget(timeout: 2.0) else { + _ = inputSourceCoordinator.restore() + return operationResult( + request, + accepted: false, + reason: "target_unavailable" + ) + } + let result = broker.arm(statusVisible: request.statusVisible == true) + return NativeInputMessage( + type: .operationResult, + reason: result.reason, + operationID: operationID, + accepted: result.accepted, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + ) + } + + if request.type == .sessionCancel { + let result = broker.submitAndWait(request, timeout: 2.0) + broker.cancel(reason: request.reason ?? "cancelled") + _ = inputSourceCoordinator.restore() + return operationResult( + request, + accepted: result.accepted, + reason: result.reason + ) + } + let result = broker.submitAndWait(request, timeout: 2.0) + if !result.accepted { + broker.cancel(reason: result.reason ?? "operation_failed") + _ = inputSourceCoordinator.restore() + } + return operationResult( + request, + accepted: result.accepted, + reason: result.reason + ) + } + + private func operationResult( + _ request: NativeInputMessage, + accepted: Bool, + reason: String? + ) -> NativeInputMessage { + NativeInputMessage( + type: .operationResult, + reason: reason, + operationID: request.operationID, + accepted: accepted, + sessionID: request.sessionID, + generation: request.generation, + targetID: request.targetID + ) + } + + func emergencyStop(reason: String) { + broker.cancel(reason: reason) + _ = inputSourceCoordinator.restore() + } + + private func lifecycleResponse( + to request: NativeInputMessage + ) throws -> NativeInputMessage { + guard let lifecycle, let requestID = request.requestID else { + throw InputMethodLifecycleError.embeddedBundleMissing + } + let action: String + switch request.type { + case .lifecycleStatus: action = "status" + case .lifecycleInstall: action = "install" + case .lifecycleRepair: action = "repair" + case .lifecycleUninstall: action = "uninstall" + default: throw BridgeRuntimeError.unsupportedDirection(request.type) + } + if request.type != .lifecycleStatus, broker.hasActiveSession() { + return NativeInputMessage( + type: .lifecycleResult, + reason: "session_active", + requestID: requestID, + action: action, + accepted: false + ) + } + let status: InputMethodLifecycleStatus + switch request.type { + case .lifecycleStatus: + status = try lifecycle.status() + case .lifecycleInstall: + status = try lifecycle.install() + case .lifecycleRepair: + status = try lifecycle.repair() + case .lifecycleUninstall: + status = try lifecycle.uninstall() + default: + throw BridgeRuntimeError.unsupportedDirection(request.type) + } + return NativeInputMessage( + type: .lifecycleResult, + requestID: requestID, + action: action, + installed: status.installed, + registered: status.registered, + enabled: status.enabled, + version: status.version, + accepted: true + ) + } + + private func write(_ message: NativeInputMessage) throws { + try output.write(contentsOf: FrameCodec.encode(message)) + } +} diff --git a/desktop/native/Sources/QwenInputBridge/SystemInputMethodLifecycle.swift b/desktop/native/Sources/QwenInputBridge/SystemInputMethodLifecycle.swift new file mode 100644 index 00000000..efdd9ff9 --- /dev/null +++ b/desktop/native/Sources/QwenInputBridge/SystemInputMethodLifecycle.swift @@ -0,0 +1,139 @@ +import Darwin +import Foundation +import QwenInputCore +import Security + +final class SystemInputMethodFileSystem: InputMethodLifecycleFileSystem { + private var backupURLs: [String: URL] = [:] + private let signatureValidator: CodeSignatureValidator + + init(requirement: PeerCodeSigningRequirement) { + signatureValidator = CodeSignatureValidator(requirement: requirement) + } + + func inspect(at url: URL) -> InputMethodArtifactInspection? { + var information = stat() + guard lstat(url.path, &information) == 0 else { return nil } + let symbolicLink = information.st_mode & S_IFMT == S_IFLNK + guard let bundle = Bundle(url: url), + let bundleID = bundle.bundleIdentifier, + let version = bundle.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String else { return nil } + return InputMethodArtifactInspection( + symbolicLink: symbolicLink, + ownerUserID: information.st_uid, + bundleID: bundleID, + version: version, + signatureValid: signatureValidator.isValid(at: url) + ) + } + + func installAtomically(from source: URL, to destination: URL) throws { + let manager = FileManager.default + let parent = destination.deletingLastPathComponent() + try ensureSafeUserDirectory(parent) + let staging = parent.appendingPathComponent( + ".qwen-input-staging-\(UUID().uuidString)", + isDirectory: true + ) + let backup = parent.appendingPathComponent( + ".qwen-input-last-known-good", + isDirectory: true + ) + try? manager.removeItem(at: staging) + try manager.copyItem(at: source, to: staging) + do { + try? manager.removeItem(at: backup) + if manager.fileExists(atPath: destination.path) { + try manager.moveItem(at: destination, to: backup) + backupURLs[destination.path] = backup + } + try manager.moveItem(at: staging, to: destination) + } catch { + try? manager.removeItem(at: staging) + if manager.fileExists(atPath: backup.path), + !manager.fileExists(atPath: destination.path) { + try? manager.moveItem(at: backup, to: destination) + } + throw error + } + } + + func rollbackInstall(at destination: URL) throws { + let manager = FileManager.default + try? manager.removeItem(at: destination) + if let backup = backupURLs.removeValue(forKey: destination.path), + manager.fileExists(atPath: backup.path) { + try manager.moveItem(at: backup, to: destination) + } + } + + func commitInstall(at destination: URL) throws { + let backup = backupURLs.removeValue(forKey: destination.path) + ?? destination.deletingLastPathComponent().appendingPathComponent( + ".qwen-input-last-known-good", + isDirectory: true + ) + if FileManager.default.fileExists(atPath: backup.path) { + try FileManager.default.removeItem(at: backup) + } + } + + func moveToTrash(_ url: URL) throws { + var resultingURL: NSURL? + try FileManager.default.trashItem( + at: url, + resultingItemURL: &resultingURL + ) + } + + private func ensureSafeUserDirectory(_ url: URL) throws { + let manager = FileManager.default + if !manager.fileExists(atPath: url.path) { + try manager.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } + var information = stat() + guard lstat(url.path, &information) == 0, + information.st_mode & S_IFMT == S_IFDIR, + information.st_uid == geteuid() else { + throw CocoaError(.fileWriteNoPermission) + } + } + +} + +enum SystemInputMethodLifecycleFactory { + static func make() throws -> InputMethodLifecycle { + let inputMethodBundleID = "ai.qwenaudio.agent.inputmethod" + let executable = URL(fileURLWithPath: CommandLine.arguments[0]) + .standardizedFileURL + let embedded = executable.deletingLastPathComponent() + .appendingPathComponent("Qwen Input.app", isDirectory: true) + guard let version = Bundle(url: embedded)?.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String else { + throw InputMethodLifecycleError.embeddedBundleMissing + } + let installed = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Input Methods", isDirectory: true) + .appendingPathComponent("Qwen Input.app", isDirectory: true) + return InputMethodLifecycle( + embeddedBundleURL: embedded, + installedBundleURL: installed, + expectedBundleID: inputMethodBundleID, + expectedVersion: version, + currentUserID: geteuid(), + fileSystem: SystemInputMethodFileSystem( + requirement: try NativeRuntimePeer.requirement( + for: inputMethodBundleID + ) + ), + registration: SystemInputSourceAPI() + ) + } +} diff --git a/desktop/native/Sources/QwenInputBridge/SystemInputSourceAPI.swift b/desktop/native/Sources/QwenInputBridge/SystemInputSourceAPI.swift new file mode 100644 index 00000000..ede93bbd --- /dev/null +++ b/desktop/native/Sources/QwenInputBridge/SystemInputSourceAPI.swift @@ -0,0 +1,95 @@ +import Carbon.HIToolbox +import Foundation +import QwenInputCore + +/// Thin production adapter around Text Input Source Services. The coordinator +/// owns all policy; this type only performs exact lookups and requested calls. +/// It is never invoked by the default-off build or automated tests. +final class SystemInputSourceAPI: InputSourceAPI, InputMethodRegistration { + private let qwenInputSourceID: String + + init(qwenInputSourceID: String = "ai.qwenaudio.agent.inputmethod") { + self.qwenInputSourceID = qwenInputSourceID + } + + func containsInputSource() -> Bool { + containsInputSource(id: qwenInputSourceID) + } + + func isInputSourceEnabled() -> Bool { + isInputSourceEnabled(id: qwenInputSourceID) + } + + func registerInputSource(at url: URL) -> Bool { + TISRegisterInputSource(url as CFURL) == noErr + } + + func disableInputSource() -> Bool { + guard let source = inputSource(id: qwenInputSourceID) else { + return true + } + return TISDisableInputSource(source) == noErr + } + func currentKeyboardSourceID() -> String? { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { + return nil + } + return stringProperty(source, key: kTISPropertyInputSourceID) + } + + func containsInputSource(id: String) -> Bool { + inputSource(id: id) != nil + } + + func isInputSourceEnabled(id: String) -> Bool { + guard let source = inputSource(id: id), + let pointer = TISGetInputSourceProperty( + source, + kTISPropertyInputSourceIsEnabled + ) + else { + return false + } + + let value = Unmanaged + .fromOpaque(pointer) + .takeUnretainedValue() + return CFBooleanGetValue(value) + } + + func selectInputSource(id: String) -> Bool { + guard let source = inputSource(id: id) else { + return false + } + return TISSelectInputSource(source) == noErr + } + + private func inputSource(id: String) -> TISInputSource? { + guard let key = kTISPropertyInputSourceID else { + return nil + } + let filter = [key as String: id] as CFDictionary + guard let values = TISCreateInputSourceList(filter, true)?.takeRetainedValue() + else { + return nil + } + + let array = values as NSArray + guard let value = array.firstObject else { + return nil + } + return unsafeBitCast(value as AnyObject, to: TISInputSource.self) + } + + private func stringProperty( + _ source: TISInputSource, + key: CFString? + ) -> String? { + guard let pointer = TISGetInputSourceProperty(source, key) else { + return nil + } + return Unmanaged + .fromOpaque(pointer) + .takeUnretainedValue() as String + } +} diff --git a/desktop/native/Sources/QwenInputBridge/main.swift b/desktop/native/Sources/QwenInputBridge/main.swift new file mode 100644 index 00000000..d1ea39b9 --- /dev/null +++ b/desktop/native/Sources/QwenInputBridge/main.swift @@ -0,0 +1,151 @@ +import Darwin +import Foundation +import QwenInputCore + +private final class BridgeShutdownCoordinator: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + private var runtime: BridgeRuntime? + private var peerServer: AuthenticatedUnixSocketServer? + private var productionPeerServer: BridgePeerServer? + + func attach( + runtime: BridgeRuntime, + peerServer: AuthenticatedUnixSocketServer?, + productionPeerServer: BridgePeerServer? + ) { + lock.lock() + self.runtime = runtime + self.peerServer = peerServer + self.productionPeerServer = productionPeerServer + lock.unlock() + } + + func stop(reason: String) { + lock.lock() + guard !stopped else { lock.unlock(); return } + stopped = true + let runtime = runtime + let peerServer = peerServer + let productionPeerServer = productionPeerServer + lock.unlock() + runtime?.emergencyStop(reason: reason) + peerServer?.stop() + productionPeerServer?.stop() + } +} + +private func signalSource( + _ signalNumber: Int32, + shutdown: BridgeShutdownCoordinator +) -> DispatchSourceSignal { + signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource( + signal: signalNumber, + queue: .global(qos: .userInitiated) + ) + source.setEventHandler { + shutdown.stop(reason: "signal_\(signalNumber)") + exit(EXIT_SUCCESS) + } + source.resume() + return source +} + +private func reportStartupFailure(_ phase: String) { + FileHandle.standardError.write(Data( + "QwenInputBridge startup failed: \(phase)\n".utf8 + )) +} + +var peerServer: AuthenticatedUnixSocketServer? +var productionPeerServer: BridgePeerServer? +let broker = NativeOperationBroker() +var probeMode = false +var operationProbeMode = false +var operationProbeDirectory: SecureRuntimeDirectory? +private let shutdown = BridgeShutdownCoordinator() +#if DEBUG +if CommandLine.arguments.count == 3, + CommandLine.arguments[1] == "--peer-probe-listen" { + probeMode = true + do { + let requirement = try PeerCodeSigningRequirement.debugAdHoc( + bundleID: "ai.qwenaudio.agent.inputmethod" + ) + let server = AuthenticatedUnixSocketServer( + runtimeDirectory: SecureRuntimeDirectory( + url: URL(fileURLWithPath: CommandLine.arguments[2]) + ), + peerRequirement: requirement, + handler: { request in + guard (try? FrameCodec.decode( + NativeInputMessage.self, + from: request + )) != nil else { return nil } + return request + } + ) + try server.start() + peerServer = server + } catch { + reportStartupFailure("peer_probe") + exit(EXIT_FAILURE) + } +} +if CommandLine.arguments.count == 3, + CommandLine.arguments[1] == "--operation-probe-listen" { + operationProbeMode = true + operationProbeDirectory = SecureRuntimeDirectory( + url: URL(fileURLWithPath: CommandLine.arguments[2]) + ) +} +#endif + +do { + if !probeMode { + let server = try BridgePeerServer( + broker: broker, + runtimeDirectory: operationProbeDirectory + ?? NativeRuntimePeer.runtimeDirectory() + ) + try server.start() + productionPeerServer = server + } + let sourceAPI: InputSourceAPI + #if DEBUG + sourceAPI = operationProbeMode + ? ProbeInputSourceAPI() + : SystemInputSourceAPI() + #else + sourceAPI = SystemInputSourceAPI() + #endif + let runtime = BridgeRuntime(broker: broker, inputSourceAPI: sourceAPI) + shutdown.attach( + runtime: runtime, + peerServer: peerServer, + productionPeerServer: productionPeerServer + ) + let terminationSignals = [ + signalSource(SIGTERM, shutdown: shutdown), + signalSource(SIGINT, shutdown: shutdown), + ] + try runtime.run() + withExtendedLifetime(terminationSignals) {} + shutdown.stop(reason: "bridge_exit") +} catch { + shutdown.stop(reason: "bridge_error") + reportStartupFailure("runtime") + exit(EXIT_FAILURE) +} + +#if DEBUG +private final class ProbeInputSourceAPI: InputSourceAPI { + private var current = "ai.qwenaudio.agent.inputmethod" + func currentKeyboardSourceID() -> String? { current } + func containsInputSource(id: String) -> Bool { true } + func isInputSourceEnabled(id: String) -> Bool { true } + func selectInputSource(id: String) -> Bool { current = id; return true } + func registerInputSource(at url: URL) -> Bool { true } +} +#endif diff --git a/desktop/native/Sources/QwenInputCore/AuthenticatedUnixSocket.swift b/desktop/native/Sources/QwenInputCore/AuthenticatedUnixSocket.swift new file mode 100644 index 00000000..43c601de --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/AuthenticatedUnixSocket.swift @@ -0,0 +1,275 @@ +import Darwin +import Foundation + +public enum AuthenticatedUnixSocketError: Error, Equatable, Sendable { + case pathTooLong + case unsafeExistingPath + case createFailed + case bindFailed + case listenFailed + case connectFailed + case credentialLookupFailed + case userMismatch + case signatureMismatch + case truncated + case oversized + case ioFailure + case invalidResponse +} + +public final class AuthenticatedUnixSocketServer: @unchecked Sendable { + public typealias Handler = @Sendable (Data) -> Data? + + private let runtimeDirectory: SecureRuntimeDirectory + private let socketURL: URL + private let peerRequirement: PeerCodeSigningRequirement + private let handler: Handler + private let lock = NSLock() + private var listenerDescriptor: Int32 = -1 + + public init( + runtimeDirectory: SecureRuntimeDirectory, + peerRequirement: PeerCodeSigningRequirement, + handler: @escaping Handler + ) { + self.runtimeDirectory = runtimeDirectory + socketURL = runtimeDirectory.socketURL() + self.peerRequirement = peerRequirement + self.handler = handler + } + + public func start() throws { + try runtimeDirectory.prepare() + try rejectUnsafeExistingSocket() + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw AuthenticatedUnixSocketError.createFailed + } + do { + try configureNoSigPipe(descriptor) + var address = try makeUnixAddress(path: socketURL.path) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard result == 0 else { + throw AuthenticatedUnixSocketError.bindFailed + } + guard chmod(socketURL.path, 0o600) == 0 else { + throw AuthenticatedUnixSocketError.ioFailure + } + guard listen(descriptor, 4) == 0 else { + throw AuthenticatedUnixSocketError.listenFailed + } + } catch { + close(descriptor) + unlink(socketURL.path) + throw error + } + + lock.lock() + listenerDescriptor = descriptor + lock.unlock() + Thread.detachNewThread { [weak self] in + self?.acceptLoop(descriptor: descriptor) + } + } + + public func stop() { + lock.lock() + let descriptor = listenerDescriptor + listenerDescriptor = -1 + lock.unlock() + if descriptor >= 0 { + shutdown(descriptor, SHUT_RDWR) + close(descriptor) + } + unlink(socketURL.path) + } + + private func acceptLoop(descriptor: Int32) { + while true { + let connection = accept(descriptor, nil, nil) + if connection < 0 { return } + configureNoSigPipeIgnoringFailure(connection) + handle(connection: connection) + close(connection) + } + } + + private func handle(connection: Int32) { + guard verifyPeer( + descriptor: connection, + requirement: peerRequirement + ) else { return } + guard let request = try? readFrame(from: connection), + let response = handler(request) else { return } + try? writeAll(response, to: connection) + } + + private func rejectUnsafeExistingSocket() throws { + var information = stat() + guard lstat(socketURL.path, &information) == 0 else { + if errno == ENOENT { return } + throw AuthenticatedUnixSocketError.ioFailure + } + guard information.st_uid == geteuid(), + information.st_mode & S_IFMT == S_IFSOCK else { + throw AuthenticatedUnixSocketError.unsafeExistingPath + } + guard unlink(socketURL.path) == 0 else { + throw AuthenticatedUnixSocketError.ioFailure + } + } +} + +public enum AuthenticatedUnixSocketClient { + public static func exchange( + _ request: Data, + socketURL: URL, + peerRequirement: PeerCodeSigningRequirement + ) throws -> Data { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw AuthenticatedUnixSocketError.createFailed + } + defer { close(descriptor) } + try configureNoSigPipe(descriptor) + var address = try makeUnixAddress(path: socketURL.path) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard result == 0 else { + throw AuthenticatedUnixSocketError.connectFailed + } + guard verifyPeer( + descriptor: descriptor, + requirement: peerRequirement + ) else { + throw AuthenticatedUnixSocketError.signatureMismatch + } + try writeAll(request, to: descriptor) + return try readFrame(from: descriptor) + } +} + +private func makeUnixAddress(path: String) throws -> sockaddr_un { + var address = sockaddr_un() + let bytes = Array(path.utf8CString) + let capacity = MemoryLayout.size(ofValue: address.sun_path) + guard bytes.count <= capacity else { + throw AuthenticatedUnixSocketError.pathTooLong + } + address.sun_len = UInt8(MemoryLayout.size) + address.sun_family = sa_family_t(AF_UNIX) + path.withCString { source in + withUnsafeMutablePointer(to: &address.sun_path.0) { destination in + _ = strlcpy(destination, source, capacity) + } + } + return address +} + +private func verifyPeer( + descriptor: Int32, + requirement: PeerCodeSigningRequirement +) -> Bool { + var peerUserID = uid_t.max + var peerGroupID = gid_t.max + guard getpeereid(descriptor, &peerUserID, &peerGroupID) == 0, + peerUserID == geteuid() else { + return false + } + var peerProcessID = pid_t(0) + var length = socklen_t(MemoryLayout.size) + guard getsockopt( + descriptor, + SOL_LOCAL, + LOCAL_PEERPID, + &peerProcessID, + &length + ) == 0, peerProcessID > 0 else { + return false + } + return DynamicPeerCodeValidator.validate( + processID: peerProcessID, + requirement: requirement + ) +} + +private func configureNoSigPipe(_ descriptor: Int32) throws { + var enabled: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &enabled, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw AuthenticatedUnixSocketError.ioFailure + } +} + +private func configureNoSigPipeIgnoringFailure(_ descriptor: Int32) { + try? configureNoSigPipe(descriptor) +} + +private func readFrame(from descriptor: Int32) throws -> Data { + let header = try readExactly(4, from: descriptor) + let length = header.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + guard length > 0 else { throw AuthenticatedUnixSocketError.truncated } + guard length <= UInt32(FrameCodec.maximumPayloadBytes) else { + throw AuthenticatedUnixSocketError.oversized + } + return header + (try readExactly(Int(length), from: descriptor)) +} + +private func readExactly(_ count: Int, from descriptor: Int32) throws -> Data { + var data = Data(count: count) + var offset = 0 + while offset < count { + let readCount = data.withUnsafeMutableBytes { buffer in + Darwin.read( + descriptor, + buffer.baseAddress!.advanced(by: offset), + count - offset + ) + } + guard readCount > 0 else { + throw AuthenticatedUnixSocketError.truncated + } + offset += readCount + } + return data +} + +private func writeAll(_ data: Data, to descriptor: Int32) throws { + try data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress else { + throw AuthenticatedUnixSocketError.ioFailure + } + var offset = 0 + while offset < buffer.count { + let written = Darwin.write( + descriptor, + base.advanced(by: offset), + buffer.count - offset + ) + guard written > 0 else { + throw AuthenticatedUnixSocketError.ioFailure + } + offset += written + } + } +} diff --git a/desktop/native/Sources/QwenInputCore/BridgeConnectionState.swift b/desktop/native/Sources/QwenInputCore/BridgeConnectionState.swift new file mode 100644 index 00000000..121c0811 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/BridgeConnectionState.swift @@ -0,0 +1,33 @@ +public enum BridgeConnectionAction: Equatable, Sendable { + case none + case failClosed +} + +public struct BridgeConnectionState: Sendable { + public private(set) var needsActivation = true + + private let failureThreshold: Int + private var consecutiveFailures = 0 + private var emittedFailClosed = false + + public init(failureThreshold: Int = 3) { + self.failureThreshold = max(1, failureThreshold) + } + + public mutating func recordSuccess() { + needsActivation = false + consecutiveFailures = 0 + emittedFailClosed = false + } + + public mutating func recordFailure() -> BridgeConnectionAction { + needsActivation = true + consecutiveFailures += 1 + guard consecutiveFailures >= failureThreshold, + !emittedFailClosed else { + return .none + } + emittedFailClosed = true + return .failClosed + } +} diff --git a/desktop/native/Sources/QwenInputCore/CodeSignatureValidator.swift b/desktop/native/Sources/QwenInputCore/CodeSignatureValidator.swift new file mode 100644 index 00000000..f97f8335 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/CodeSignatureValidator.swift @@ -0,0 +1,32 @@ +import Foundation +import Security + +public struct CodeSignatureValidator: Sendable { + private let requirement: PeerCodeSigningRequirement + + public init(requirement: PeerCodeSigningRequirement) { + self.requirement = requirement + } + + public func isValid(at url: URL) -> Bool { + var code: SecStaticCode? + guard SecStaticCodeCreateWithPath( + url as CFURL, + SecCSFlags(), + &code + ) == errSecSuccess, let code else { return false } + var requiredCode: SecRequirement? + guard SecRequirementCreateWithString( + requirement.value as CFString, + SecCSFlags(), + &requiredCode + ) == errSecSuccess, let requiredCode else { return false } + return SecStaticCodeCheckValidity( + code, + SecCSFlags(rawValue: ( + kSecCSCheckAllArchitectures | kSecCSStrictValidate + )), + requiredCode + ) == errSecSuccess + } +} diff --git a/desktop/native/Sources/QwenInputCore/ControllerRegistryState.swift b/desktop/native/Sources/QwenInputCore/ControllerRegistryState.swift new file mode 100644 index 00000000..5e4fb16e --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/ControllerRegistryState.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct ControllerTargetToken: Equatable, Sendable { + public let generation: UInt64 + public let targetID: UUID + public let clientIdentifier: String + + public init( + generation: UInt64, + targetID: UUID, + clientIdentifier: String + ) { + self.generation = generation + self.targetID = targetID + self.clientIdentifier = clientIdentifier + } +} + +public struct ControllerRegistryState: Sendable { + public private(set) var generation: UInt64 = 0 + public private(set) var current: ControllerTargetToken? + + public init() {} + + @discardableResult + public mutating func activate( + clientIdentifier: String, + targetID: UUID = UUID() + ) -> ControllerTargetToken { + generation &+= 1 + let token = ControllerTargetToken( + generation: generation, + targetID: targetID, + clientIdentifier: clientIdentifier + ) + current = token + return token + } + + @discardableResult + public mutating func deactivate(targetID: UUID) -> Bool { + guard current?.targetID == targetID else { return false } + generation &+= 1 + current = nil + return true + } + + @discardableResult + public mutating func close(targetID: UUID) -> Bool { + deactivate(targetID: targetID) + } +} diff --git a/desktop/native/Sources/QwenInputCore/DynamicPeerCodeValidator.swift b/desktop/native/Sources/QwenInputCore/DynamicPeerCodeValidator.swift new file mode 100644 index 00000000..e19bfee7 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/DynamicPeerCodeValidator.swift @@ -0,0 +1,37 @@ +import Foundation +import Security + +public enum DynamicPeerCodeValidator { + public static func validate( + processID: pid_t, + requirement: PeerCodeSigningRequirement + ) -> Bool { + let attributes = NSDictionary( + object: NSNumber(value: processID), + forKey: (kSecGuestAttributePid as String) as NSString + ) + var code: SecCode? + guard SecCodeCopyGuestWithAttributes( + nil, + attributes, + SecCSFlags(), + &code + ) == errSecSuccess, let code else { + return false + } + + var compiledRequirement: SecRequirement? + guard SecRequirementCreateWithString( + requirement.value as CFString, + SecCSFlags(), + &compiledRequirement + ) == errSecSuccess, let compiledRequirement else { + return false + } + return SecCodeCheckValidity( + code, + SecCSFlags(), + compiledRequirement + ) == errSecSuccess + } +} diff --git a/desktop/native/Sources/QwenInputCore/FrameCodec.swift b/desktop/native/Sources/QwenInputCore/FrameCodec.swift new file mode 100644 index 00000000..f8ff8b89 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/FrameCodec.swift @@ -0,0 +1,213 @@ +import Foundation + +public enum NativeInputMessageType: String, Codable, CaseIterable, Sendable { + case bridgeReady = "bridge.ready" + case sessionArm = "session.arm" + case sessionPartial = "session.partial" + case sessionFinal = "session.final" + case sessionOperation = "session.operation" + case sessionCancel = "session.cancel" + case sessionPause = "session.pause" + case sessionResume = "session.resume" + case sessionState = "session.state" + case operationResult = "operation.result" + case lifecycleStatus = "lifecycle.status" + case lifecycleInstall = "lifecycle.install" + case lifecycleRepair = "lifecycle.repair" + case lifecycleUninstall = "lifecycle.uninstall" + case lifecycleResult = "lifecycle.result" + case imeActivate = "ime.activate" + case imeDeactivate = "ime.deactivate" + case imePoll = "ime.poll" + case imeResult = "ime.result" + case imeIdle = "ime.idle" + case imeAck = "ime.ack" + case bridgeStop = "bridge.stop" + case bridgeError = "bridge.error" +} + +public struct NativeInputMessage: Codable, Equatable, Sendable { + enum CodingKeys: String, CodingKey { + case type, text, state, reason, action, installed, registered, enabled + case version, accepted, revision, generation, operation, target + case replacement, statusVisible + case requestID = "requestId" + case operationID = "operationId" + case sequence = "seq" + case sessionID = "sessionId" + case targetID = "targetId" + } + public let type: NativeInputMessageType + public let text: String? + public let state: NativeSessionState? + public let reason: String? + public let requestID: String? + public let action: String? + public let installed: Bool? + public let registered: Bool? + public let enabled: Bool? + public let version: String? + public let operationID: String? + public let accepted: Bool? + public let revision: UInt64? + public let sequence: UInt64? + public let operation: String? + public let target: String? + public let replacement: String? + public let statusVisible: Bool? + public let sessionID: String? + public let generation: UInt64? + public let targetID: String? + + public init( + type: NativeInputMessageType, + text: String? = nil, + state: NativeSessionState? = nil, + reason: String? = nil, + requestID: String? = nil, + action: String? = nil, + installed: Bool? = nil, + registered: Bool? = nil, + enabled: Bool? = nil, + version: String? = nil, + operationID: String? = nil, + accepted: Bool? = nil, + revision: UInt64? = nil, + sequence: UInt64? = nil, + operation: String? = nil, + target: String? = nil, + replacement: String? = nil, + statusVisible: Bool? = nil, + sessionID: String? = nil, + generation: UInt64? = nil, + targetID: String? = nil + ) { + self.type = type + self.text = text + self.state = state + self.reason = reason + self.requestID = requestID + self.action = action + self.installed = installed + self.registered = registered + self.enabled = enabled + self.version = version + self.operationID = operationID + self.accepted = accepted + self.revision = revision + self.sequence = sequence + self.operation = operation + self.target = target + self.replacement = replacement + self.statusVisible = statusVisible + self.sessionID = sessionID + self.generation = generation + self.targetID = targetID + } +} + +public enum FrameCodecError: Error, Equatable, Sendable { + case zeroLength + case oversized + case truncated + case trailingBytes + case invalidUTF8 + case invalidJSON +} + +public enum FrameCodec { + public static let maximumPayloadBytes = 65_536 + private static let headerBytes = 4 + + public static func encode(_ value: T) throws -> Data { + let payload: Data + do { + payload = try JSONEncoder().encode(value) + } catch { + throw FrameCodecError.invalidJSON + } + guard !payload.isEmpty else { throw FrameCodecError.zeroLength } + guard payload.count <= maximumPayloadBytes else { + throw FrameCodecError.oversized + } + + let length = UInt32(payload.count) + var frame = Data(capacity: headerBytes + payload.count) + frame.append(UInt8((length >> 24) & 0xff)) + frame.append(UInt8((length >> 16) & 0xff)) + frame.append(UInt8((length >> 8) & 0xff)) + frame.append(UInt8(length & 0xff)) + frame.append(payload) + return frame + } + + public static func decode( + _ type: T.Type, + from bytes: Bytes + ) throws -> T { + let frame = Data(bytes) + guard frame.count >= headerBytes else { throw FrameCodecError.truncated } + let length = try payloadLength(in: frame) + let expected = headerBytes + length + guard frame.count >= expected else { throw FrameCodecError.truncated } + guard frame.count == expected else { throw FrameCodecError.trailingBytes } + return try decodePayload(type, from: frame.dropFirst(headerBytes)) + } + + public static func decodePayload( + _ type: T.Type, + from bytes: Bytes + ) throws -> T { + let payload = Data(bytes) + guard !payload.isEmpty else { throw FrameCodecError.zeroLength } + guard payload.count <= maximumPayloadBytes else { + throw FrameCodecError.oversized + } + guard String(data: payload, encoding: .utf8) != nil else { + throw FrameCodecError.invalidUTF8 + } + do { + return try JSONDecoder().decode(type, from: payload) + } catch { + throw FrameCodecError.invalidJSON + } + } + + static func payloadLength(in header: Data) throws -> Int { + guard header.count >= headerBytes else { throw FrameCodecError.truncated } + let length = header.prefix(headerBytes).reduce(UInt32(0)) { + ($0 << 8) | UInt32($1) + } + guard length > 0 else { throw FrameCodecError.zeroLength } + guard length <= UInt32(maximumPayloadBytes) else { + throw FrameCodecError.oversized + } + return Int(length) + } +} + +public struct FrameStreamDecoder: Sendable { + private var buffer = Data() + + public init() {} + + public mutating func append( + _ bytes: Bytes + ) throws -> [Data] { + buffer.append(contentsOf: bytes) + var frames: [Data] = [] + + while buffer.count >= 4 { + let length = try FrameCodec.payloadLength(in: buffer) + let frameLength = 4 + length + guard buffer.count >= frameLength else { break } + frames.append(buffer.subdata(in: 4..: Codable, Sendable { + public let protocolVersion: UInt16 + public let sessionID: UUID + public let generation: UInt64 + public let targetID: UUID + public let operationID: UUID + public let sequence: UInt64 + public let payload: Payload + + public init( + protocolVersion: UInt16 = NativeInputCore.protocolVersion, + sessionID: UUID, + generation: UInt64, + targetID: UUID, + operationID: UUID, + sequence: UInt64, + payload: Payload + ) { + self.protocolVersion = protocolVersion + self.sessionID = sessionID + self.generation = generation + self.targetID = targetID + self.operationID = operationID + self.sequence = sequence + self.payload = payload + } + + public var metadata: NativeInputMetadata { + NativeInputMetadata( + protocolVersion: protocolVersion, + sessionID: sessionID, + generation: generation, + targetID: targetID, + operationID: operationID, + sequence: sequence + ) + } +} +public struct NativeInputMetadata: Codable, Equatable, Sendable { + public let protocolVersion: UInt16 + public let sessionID: UUID + public let generation: UInt64 + public let targetID: UUID + public let operationID: UUID + public let sequence: UInt64 + + public init( + protocolVersion: UInt16, + sessionID: UUID, + generation: UInt64, + targetID: UUID, + operationID: UUID, + sequence: UInt64 + ) { + self.protocolVersion = protocolVersion + self.sessionID = sessionID + self.generation = generation + self.targetID = targetID + self.operationID = operationID + self.sequence = sequence + } +} diff --git a/desktop/native/Sources/QwenInputCore/InputMethodLifecycle.swift b/desktop/native/Sources/QwenInputCore/InputMethodLifecycle.swift new file mode 100644 index 00000000..b31137e1 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/InputMethodLifecycle.swift @@ -0,0 +1,163 @@ +import Foundation + +public struct InputMethodArtifactInspection: Equatable, Sendable { + public let symbolicLink: Bool + public let ownerUserID: UInt32 + public let bundleID: String + public let version: String + public let signatureValid: Bool + + public init( + symbolicLink: Bool, + ownerUserID: UInt32, + bundleID: String, + version: String, + signatureValid: Bool + ) { + self.symbolicLink = symbolicLink + self.ownerUserID = ownerUserID + self.bundleID = bundleID + self.version = version + self.signatureValid = signatureValid + } +} + +public struct InputMethodLifecycleStatus: Equatable, Sendable { + public let installed: Bool + public let registered: Bool + public let enabled: Bool + public let version: String + + public init( + installed: Bool, + registered: Bool, + enabled: Bool, + version: String + ) { + self.installed = installed + self.registered = registered + self.enabled = enabled + self.version = version + } +} + +public enum InputMethodLifecycleError: Error, Equatable, Sendable { + case embeddedBundleMissing + case unsafeEmbeddedBundle + case registrationFailed + case disableFailed +} + +public protocol InputMethodLifecycleFileSystem: AnyObject { + func inspect(at url: URL) -> InputMethodArtifactInspection? + func installAtomically(from source: URL, to destination: URL) throws + func commitInstall(at destination: URL) throws + func rollbackInstall(at destination: URL) throws + func moveToTrash(_ url: URL) throws +} + +public protocol InputMethodRegistration: AnyObject { + func containsInputSource() -> Bool + func isInputSourceEnabled() -> Bool + func registerInputSource(at url: URL) -> Bool + func disableInputSource() -> Bool +} + +public final class InputMethodLifecycle { + private let embeddedBundleURL: URL + private let installedBundleURL: URL + private let expectedBundleID: String + private let expectedVersion: String + private let currentUserID: UInt32 + private let fileSystem: InputMethodLifecycleFileSystem + private let registration: InputMethodRegistration + + public init( + embeddedBundleURL: URL, + installedBundleURL: URL, + expectedBundleID: String, + expectedVersion: String, + currentUserID: UInt32, + fileSystem: InputMethodLifecycleFileSystem, + registration: InputMethodRegistration + ) { + self.embeddedBundleURL = embeddedBundleURL + self.installedBundleURL = installedBundleURL + self.expectedBundleID = expectedBundleID + self.expectedVersion = expectedVersion + self.currentUserID = currentUserID + self.fileSystem = fileSystem + self.registration = registration + } + + public func status() throws -> InputMethodLifecycleStatus { + guard let inspection = fileSystem.inspect(at: installedBundleURL), + valid(inspection, installed: true) else { + return InputMethodLifecycleStatus( + installed: false, + registered: false, + enabled: false, + version: "" + ) + } + let registered = registration.containsInputSource() + return InputMethodLifecycleStatus( + installed: true, + registered: registered, + enabled: registered && registration.isInputSourceEnabled(), + version: inspection.version + ) + } + + public func install() throws -> InputMethodLifecycleStatus { + guard let embedded = fileSystem.inspect(at: embeddedBundleURL) else { + throw InputMethodLifecycleError.embeddedBundleMissing + } + guard valid(embedded, installed: false) else { + throw InputMethodLifecycleError.unsafeEmbeddedBundle + } + try fileSystem.installAtomically( + from: embeddedBundleURL, + to: installedBundleURL + ) + guard registration.registerInputSource(at: installedBundleURL) else { + try? fileSystem.rollbackInstall(at: installedBundleURL) + throw InputMethodLifecycleError.registrationFailed + } + try fileSystem.commitInstall(at: installedBundleURL) + return try status() + } + + public func repair() throws -> InputMethodLifecycleStatus { + try install() + } + + public func uninstall() throws -> InputMethodLifecycleStatus { + if registration.containsInputSource(), + !registration.disableInputSource() { + throw InputMethodLifecycleError.disableFailed + } + if fileSystem.inspect(at: installedBundleURL) != nil { + try fileSystem.moveToTrash(installedBundleURL) + } + return InputMethodLifecycleStatus( + installed: false, + registered: false, + enabled: false, + version: "" + ) + } + + private func valid( + _ inspection: InputMethodArtifactInspection, + installed: Bool + ) -> Bool { + guard !inspection.symbolicLink, + inspection.bundleID == expectedBundleID, + inspection.version == expectedVersion, + inspection.signatureValid else { return false } + return installed + ? inspection.ownerUserID == currentUserID + : inspection.ownerUserID == currentUserID || inspection.ownerUserID == 0 + } +} diff --git a/desktop/native/Sources/QwenInputCore/InputSourceCoordinator.swift b/desktop/native/Sources/QwenInputCore/InputSourceCoordinator.swift new file mode 100644 index 00000000..4e48af9d --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/InputSourceCoordinator.swift @@ -0,0 +1,107 @@ +import Foundation + +public protocol InputSourceAPI: AnyObject { + func currentKeyboardSourceID() -> String? + func containsInputSource(id: String) -> Bool + func isInputSourceEnabled(id: String) -> Bool + func selectInputSource(id: String) -> Bool + func registerInputSource(at url: URL) -> Bool +} + +public struct InputSourceRecoveryInstruction: Equatable, Sendable { + public let previousSourceID: String + public let expectedCurrentSourceID: String + + public init(previousSourceID: String, expectedCurrentSourceID: String) { + self.previousSourceID = previousSourceID + self.expectedCurrentSourceID = expectedCurrentSourceID + } +} + +public enum InputSourceBeginResult: Equatable, Sendable { + case selected(previousSourceID: String) + case alreadySelected + case currentSourceUnavailable + case qwenInputUnavailable + case qwenInputDisabled + case selectionRequired + case selectionFailed +} + +public enum InputSourceRestoreResult: Equatable, Sendable { + case restored(sourceID: String) + case alreadyRestored + case currentSourceChanged(actualSourceID: String?) + case previousSourceUnavailable + case restorationFailed +} + +/// Requires Qwen Input to be selected by the user for production sessions. +/// Diagnostic callers may explicitly request selection and compare-and-set +/// restoration; a source selected by the user later is never overwritten. +public struct InputSourceCoordinator { + public private(set) var recoveryInstruction: InputSourceRecoveryInstruction? + + private let qwenInputSourceID: String + private let api: InputSourceAPI + + public init(qwenInputSourceID: String, api: InputSourceAPI) { + self.qwenInputSourceID = qwenInputSourceID + self.api = api + } + + public mutating func begin( + selectIfNeeded: Bool = false + ) -> InputSourceBeginResult { + if recoveryInstruction != nil { + return .alreadySelected + } + + guard let currentSourceID = api.currentKeyboardSourceID() else { + return .currentSourceUnavailable + } + guard api.containsInputSource(id: qwenInputSourceID) else { + return .qwenInputUnavailable + } + guard api.isInputSourceEnabled(id: qwenInputSourceID) else { + return .qwenInputDisabled + } + guard currentSourceID != qwenInputSourceID else { + return .alreadySelected + } + guard selectIfNeeded else { + return .selectionRequired + } + guard api.selectInputSource(id: qwenInputSourceID) else { + return .selectionFailed + } + + recoveryInstruction = InputSourceRecoveryInstruction( + previousSourceID: currentSourceID, + expectedCurrentSourceID: qwenInputSourceID + ) + return .selected(previousSourceID: currentSourceID) + } + + public mutating func restore() -> InputSourceRestoreResult { + guard let recoveryInstruction else { + return .alreadyRestored + } + + let currentSourceID = api.currentKeyboardSourceID() + guard currentSourceID == recoveryInstruction.expectedCurrentSourceID else { + return .currentSourceChanged(actualSourceID: currentSourceID) + } + guard api.containsInputSource(id: recoveryInstruction.previousSourceID), + api.isInputSourceEnabled(id: recoveryInstruction.previousSourceID) + else { + return .previousSourceUnavailable + } + guard api.selectInputSource(id: recoveryInstruction.previousSourceID) else { + return .restorationFailed + } + + self.recoveryInstruction = nil + return .restored(sourceID: recoveryInstruction.previousSourceID) + } +} diff --git a/desktop/native/Sources/QwenInputCore/NativeInputCore.swift b/desktop/native/Sources/QwenInputCore/NativeInputCore.swift new file mode 100644 index 00000000..75e6ced8 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/NativeInputCore.swift @@ -0,0 +1,5 @@ +import Foundation + +public enum NativeInputCore { + public static let protocolVersion: UInt16 = 1 +} diff --git a/desktop/native/Sources/QwenInputCore/NativeOperationBroker.swift b/desktop/native/Sources/QwenInputCore/NativeOperationBroker.swift new file mode 100644 index 00000000..4e31a80a --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/NativeOperationBroker.swift @@ -0,0 +1,208 @@ +import Foundation + +public struct NativeOperationTarget: Equatable, Sendable { + public let sessionID: String + public let generation: UInt64 + public let targetID: String + + public init(sessionID: String, generation: UInt64, targetID: String) { + self.sessionID = sessionID + self.generation = generation + self.targetID = targetID + } +} + +public struct NativeOperationResult: Equatable, Sendable { + public let accepted: Bool + public let reason: String? + + public init(accepted: Bool, reason: String?) { + self.accepted = accepted + self.reason = reason + } +} + +public final class NativeOperationBroker: @unchecked Sendable { + private let condition = NSCondition() + private var target: NativeOperationTarget? + private var armed = false + private var acceptedSequence: UInt64 = 0 + private var operationIDs: Set = [] + private var queued: [NativeInputMessage] = [] + private var inFlight: Set = [] + private var results: [String: NativeOperationResult] = [:] + + public init() {} + + public func activate(_ target: NativeOperationTarget) { + condition.lock() + if self.target != target { rejectOutstanding(reason: "target_lost") } + self.target = target + armed = false + acceptedSequence = 0 + condition.broadcast() + condition.unlock() + } + + public func deactivate(_ target: NativeOperationTarget) { + condition.lock() + if self.target == target { + self.target = nil + armed = false + rejectOutstanding(reason: "target_lost") + condition.broadcast() + } + condition.unlock() + } + + public func currentTarget() -> NativeOperationTarget? { + condition.lock() + defer { condition.unlock() } + return target + } + + public func hasActiveSession() -> Bool { + condition.lock() + defer { condition.unlock() } + return armed + } + + public func waitForTarget(timeout: TimeInterval) -> NativeOperationTarget? { + condition.lock() + defer { condition.unlock() } + let deadline = Date(timeIntervalSinceNow: timeout) + while target == nil && condition.wait(until: deadline) {} + return target + } + + public func arm(statusVisible: Bool) -> NativeOperationResult { + condition.lock() + defer { condition.unlock() } + guard target != nil else { + return NativeOperationResult(accepted: false, reason: "target_unavailable") + } + guard statusVisible else { + return NativeOperationResult(accepted: false, reason: "status_hidden") + } + armed = true + return NativeOperationResult(accepted: true, reason: nil) + } + + public func enqueue(_ operation: NativeInputMessage) -> Bool { + condition.lock() + defer { condition.unlock() } + guard armed, + let target, + let operationID = operation.operationID, + !operationID.isEmpty, + !operationIDs.contains(operationID) else { return false } + if let sequence = operation.sequence { + guard sequence > acceptedSequence else { return false } + acceptedSequence = sequence + } + operationIDs.insert(operationID) + queued.append(NativeInputMessage( + type: operation.type, + text: operation.text, + state: operation.state, + reason: operation.reason, + requestID: operation.requestID, + action: operation.action, + installed: operation.installed, + registered: operation.registered, + enabled: operation.enabled, + version: operation.version, + operationID: operation.operationID, + accepted: operation.accepted, + revision: operation.revision, + sequence: operation.sequence, + operation: operation.operation, + target: operation.target, + replacement: operation.replacement, + statusVisible: operation.statusVisible, + sessionID: target.sessionID, + generation: target.generation, + targetID: target.targetID + )) + condition.broadcast() + return true + } + + public func poll(for target: NativeOperationTarget) -> NativeInputMessage? { + condition.lock() + defer { condition.unlock() } + guard self.target == target, armed, !queued.isEmpty else { return nil } + let operation = queued.removeFirst() + if let operationID = operation.operationID { inFlight.insert(operationID) } + return operation + } + + public func complete( + operationID: String, + accepted: Bool, + reason: String?, + target: NativeOperationTarget + ) -> Bool { + condition.lock() + defer { condition.unlock() } + guard self.target == target, inFlight.remove(operationID) != nil else { + return false + } + results[operationID] = NativeOperationResult( + accepted: accepted, + reason: reason + ) + condition.broadcast() + return true + } + + public func takeResult(operationID: String) -> NativeOperationResult? { + condition.lock() + defer { condition.unlock() } + return results.removeValue(forKey: operationID) + } + + public func submitAndWait( + _ operation: NativeInputMessage, + timeout: TimeInterval + ) -> NativeOperationResult { + guard let operationID = operation.operationID, enqueue(operation) else { + return NativeOperationResult(accepted: false, reason: "operation_rejected") + } + condition.lock() + defer { condition.unlock() } + let deadline = Date(timeIntervalSinceNow: timeout) + while results[operationID] == nil && condition.wait(until: deadline) {} + if let result = results.removeValue(forKey: operationID) { return result } + queued.removeAll { $0.operationID == operationID } + inFlight.remove(operationID) + return NativeOperationResult(accepted: false, reason: "operation_timeout") + } + + public func cancel(reason: String) { + condition.lock() + armed = false + rejectOutstanding(reason: reason) + condition.broadcast() + condition.unlock() + } + + private func rejectOutstanding(reason: String) { + for operation in queued { + if let operationID = operation.operationID { + results[operationID] = NativeOperationResult( + accepted: false, + reason: reason + ) + } + } + for operationID in inFlight { + results[operationID] = NativeOperationResult( + accepted: false, + reason: reason + ) + } + queued.removeAll() + inFlight.removeAll() + } +} diff --git a/desktop/native/Sources/QwenInputCore/NativeRuntimePeer.swift b/desktop/native/Sources/QwenInputCore/NativeRuntimePeer.swift new file mode 100644 index 00000000..2670ebe2 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/NativeRuntimePeer.swift @@ -0,0 +1,59 @@ +import Darwin +import Foundation +import Security + +public enum NativeRuntimePeerError: Error, Sendable { + case missingTeamIdentifier +} + +public enum NativeRuntimePeer { + public static func runtimeDirectory() -> SecureRuntimeDirectory { + runtimeDirectory( + temporaryDirectory: FileManager.default.temporaryDirectory, + userID: geteuid() + ) + } + + public static func runtimeDirectory( + temporaryDirectory: URL, + userID: uid_t + ) -> SecureRuntimeDirectory { + SecureRuntimeDirectory(url: temporaryDirectory.appendingPathComponent( + "qwen-ni-\(userID)", + isDirectory: true + )) + } + + public static func requirement( + for bundleID: String + ) throws -> PeerCodeSigningRequirement { + #if DEBUG + return try .debugAdHoc(bundleID: bundleID) + #else + guard let teamID = currentTeamIdentifier() else { + throw NativeRuntimePeerError.missingTeamIdentifier + } + return try .release(bundleID: bundleID, teamID: teamID) + #endif + } + + private static func currentTeamIdentifier() -> String? { + let executable = Bundle.main.executableURL + ?? URL(fileURLWithPath: CommandLine.arguments[0]) + var code: SecStaticCode? + guard SecStaticCodeCreateWithPath( + executable as CFURL, + SecCSFlags(), + &code + ) == errSecSuccess, let code else { return nil } + var information: CFDictionary? + guard SecCodeCopySigningInformation( + code, + SecCSFlags(), + &information + ) == errSecSuccess, + let values = information as? [CFString: Any] + else { return nil } + return values[kSecCodeInfoTeamIdentifier] as? String + } +} diff --git a/desktop/native/Sources/QwenInputCore/NativeSessionState.swift b/desktop/native/Sources/QwenInputCore/NativeSessionState.swift new file mode 100644 index 00000000..4f07d587 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/NativeSessionState.swift @@ -0,0 +1,22 @@ +public enum NativeSessionState: String, Codable, CaseIterable, Sendable { + case disabled + case ready + case arming + case starting + case listening + case transcribing + case paused + case readyToSend = "ready-to-send" + case blocked + case cancelled + case error + + public var capturesInput: Bool { + switch self { + case .starting, .listening, .transcribing: + true + default: + false + } + } +} diff --git a/desktop/native/Sources/QwenInputCore/PeerRequirement.swift b/desktop/native/Sources/QwenInputCore/PeerRequirement.swift new file mode 100644 index 00000000..4705ce4d --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/PeerRequirement.swift @@ -0,0 +1,129 @@ +import Darwin +import Foundation + +public struct PeerIdentity: Equatable, Sendable { + public let bundleID: String + public let teamID: String? + public let effectiveUserID: uid_t + public let isAdHoc: Bool + public let protocolVersion: UInt16 + + public init( + bundleID: String, + teamID: String?, + effectiveUserID: uid_t, + isAdHoc: Bool, + protocolVersion: UInt16 + ) { + self.bundleID = bundleID + self.teamID = teamID + self.effectiveUserID = effectiveUserID + self.isAdHoc = isAdHoc + self.protocolVersion = protocolVersion + } +} + +public enum PeerRejection: Equatable, Sendable { + case bundleMismatch + case teamMismatch + case userMismatch + case protocolMismatch + case adHocNotAllowed +} + +public enum PeerValidationResult: Equatable, Sendable { + case accepted + case rejected(PeerRejection) +} + +public struct PeerRequirementPolicy: Sendable { + public let expectedBundleID: String + public let expectedTeamID: String? + public let expectedEffectiveUserID: uid_t + public let allowAdHocDebugPeer: Bool + + public init( + expectedBundleID: String, + expectedTeamID: String?, + expectedEffectiveUserID: uid_t, + allowAdHocDebugPeer: Bool + ) { + self.expectedBundleID = expectedBundleID + self.expectedTeamID = expectedTeamID + self.expectedEffectiveUserID = expectedEffectiveUserID + self.allowAdHocDebugPeer = allowAdHocDebugPeer + } + + public func validate(_ peer: PeerIdentity) -> PeerValidationResult { + guard peer.protocolVersion == NativeInputCore.protocolVersion else { + return .rejected(.protocolMismatch) + } + guard peer.effectiveUserID == expectedEffectiveUserID else { + return .rejected(.userMismatch) + } + guard peer.bundleID == expectedBundleID else { + return .rejected(.bundleMismatch) + } + if peer.isAdHoc { + guard allowAdHocDebugPeer else { + return .rejected(.adHocNotAllowed) + } + guard expectedTeamID == nil, peer.teamID == nil else { + return .rejected(.teamMismatch) + } + return .accepted + } + guard peer.teamID == expectedTeamID else { + return .rejected(.teamMismatch) + } + return .accepted + } +} + +public struct PeerCodeSigningRequirement: Equatable, Sendable { + public enum RequirementError: Error, Equatable, Sendable { + case invalidBundleID + case invalidTeamID + } + + public let value: String + + public static func release( + bundleID: String, + teamID: String + ) throws -> PeerCodeSigningRequirement { + guard isSafeBundleID(bundleID) else { + throw RequirementError.invalidBundleID + } + guard isSafeTeamID(teamID) else { + throw RequirementError.invalidTeamID + } + return PeerCodeSigningRequirement( + value: "anchor apple generic and identifier \"\(bundleID)\" " + + "and certificate leaf[subject.OU] = \"\(teamID)\"" + ) + } + + public static func debugAdHoc( + bundleID: String + ) throws -> PeerCodeSigningRequirement { + guard isSafeBundleID(bundleID) else { + throw RequirementError.invalidBundleID + } + return PeerCodeSigningRequirement(value: "identifier \"\(bundleID)\"") + } + + private static func isSafeBundleID(_ value: String) -> Bool { + guard !value.isEmpty else { return false } + return value.unicodeScalars.allSatisfy { + CharacterSet.alphanumerics.contains($0) || $0 == "." || $0 == "-" + } + } + + private static func isSafeTeamID(_ value: String) -> Bool { + guard !value.isEmpty else { return false } + return value.unicodeScalars.allSatisfy { + CharacterSet.alphanumerics.contains($0) + } + } +} diff --git a/desktop/native/Sources/QwenInputCore/ProtocolValidator.swift b/desktop/native/Sources/QwenInputCore/ProtocolValidator.swift new file mode 100644 index 00000000..b6a3fe7e --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/ProtocolValidator.swift @@ -0,0 +1,89 @@ +import Foundation + +public enum ValidationRejection: Equatable, Sendable { + case unsupportedProtocolVersion + case oversizedFrame + case sessionMismatch + case generationMismatch + case targetMismatch + case replayedOperation + case nonMonotonicSequence +} + +public enum ValidationResult: Equatable, Sendable { + case accepted + case rejected(ValidationRejection) +} + +public struct ProtocolValidator: Sendable { + public static let maximumEncodedByteCount = 65_536 + + private var sessionID: UUID + private var generation: UInt64 + private var targetID: UUID + private let replayCapacity: Int + private var acceptedSequence: UInt64 = 0 + private var acceptedOperationIDs: Set = [] + private var operationOrder: [UUID] = [] + + public init( + sessionID: UUID, + generation: UInt64, + targetID: UUID, + replayCapacity: Int = 256 + ) { + self.sessionID = sessionID + self.generation = generation + self.targetID = targetID + self.replayCapacity = max(1, replayCapacity) + } + + public mutating func accept( + metadata: NativeInputMetadata, + encodedByteCount: Int + ) -> ValidationResult { + guard encodedByteCount <= Self.maximumEncodedByteCount else { + return .rejected(.oversizedFrame) + } + guard metadata.protocolVersion == NativeInputCore.protocolVersion else { + return .rejected(.unsupportedProtocolVersion) + } + guard metadata.sessionID == sessionID else { + return .rejected(.sessionMismatch) + } + guard metadata.generation == generation else { + return .rejected(.generationMismatch) + } + guard metadata.targetID == targetID else { + return .rejected(.targetMismatch) + } + guard !acceptedOperationIDs.contains(metadata.operationID) else { + return .rejected(.replayedOperation) + } + guard metadata.sequence > acceptedSequence else { + return .rejected(.nonMonotonicSequence) + } + + acceptedSequence = metadata.sequence + acceptedOperationIDs.insert(metadata.operationID) + operationOrder.append(metadata.operationID) + if operationOrder.count > replayCapacity { + let expired = operationOrder.removeFirst() + acceptedOperationIDs.remove(expired) + } + return .accepted + } + + public mutating func reset( + sessionID: UUID, + generation: UInt64, + targetID: UUID + ) { + self.sessionID = sessionID + self.generation = generation + self.targetID = targetID + acceptedSequence = 0 + acceptedOperationIDs.removeAll(keepingCapacity: true) + operationOrder.removeAll(keepingCapacity: true) + } +} diff --git a/desktop/native/Sources/QwenInputCore/SafetyGate.swift b/desktop/native/Sources/QwenInputCore/SafetyGate.swift new file mode 100644 index 00000000..65b648e1 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/SafetyGate.swift @@ -0,0 +1,121 @@ +public enum GateSignal: Equatable, Sendable { + case ready + case unavailable + case unknown +} +public struct SafetyContext: Equatable, Sendable { + public let featureEnabled: Bool + public let desktopConnected: Bool + public let target: GateSignal + public let statusVisibility: GateSignal + public let inputSource: GateSignal + + public init( + featureEnabled: Bool, + desktopConnected: Bool, + target: GateSignal, + statusVisibility: GateSignal, + inputSource: GateSignal + ) { + self.featureEnabled = featureEnabled + self.desktopConnected = desktopConnected + self.target = target + self.statusVisibility = statusVisibility + self.inputSource = inputSource + } +} + +public enum SafetyBlockReason: Equatable, Sendable { + case featureDisabled + case desktopDisconnected + case targetUnavailable + case targetUnknown + case statusHidden + case statusUnknown + case inputSourceInactive + case inputSourceUnknown + case secureEventInput +} + +public enum SafetyDecision: Equatable, Sendable { + case captureAllowed + case idle + case blocked(reason: SafetyBlockReason, removeOwnedPartial: Bool) +} + +public struct SafetyGate: Sendable { + private let secureEventInputEnabled: @Sendable () -> Bool + + public init(secureEventInputEnabled: @escaping @Sendable () -> Bool) { + self.secureEventInputEnabled = secureEventInputEnabled + } + + public func evaluate( + state: NativeSessionState, + context: SafetyContext, + hasOwnedPartial: Bool + ) -> SafetyDecision { + let removeOwnedPartial = state.capturesInput || hasOwnedPartial + if secureEventInputEnabled() { + return .blocked( + reason: .secureEventInput, + removeOwnedPartial: removeOwnedPartial + ) + } + if !context.featureEnabled { + return .blocked( + reason: .featureDisabled, + removeOwnedPartial: removeOwnedPartial + ) + } + if !context.desktopConnected { + return .blocked( + reason: .desktopDisconnected, + removeOwnedPartial: removeOwnedPartial + ) + } + switch context.target { + case .ready: + break + case .unavailable: + return .blocked( + reason: .targetUnavailable, + removeOwnedPartial: removeOwnedPartial + ) + case .unknown: + return .blocked( + reason: .targetUnknown, + removeOwnedPartial: removeOwnedPartial + ) + } + switch context.statusVisibility { + case .ready: + break + case .unavailable: + return .blocked( + reason: .statusHidden, + removeOwnedPartial: removeOwnedPartial + ) + case .unknown: + return .blocked( + reason: .statusUnknown, + removeOwnedPartial: removeOwnedPartial + ) + } + switch context.inputSource { + case .ready: + break + case .unavailable: + return .blocked( + reason: .inputSourceInactive, + removeOwnedPartial: removeOwnedPartial + ) + case .unknown: + return .blocked( + reason: .inputSourceUnknown, + removeOwnedPartial: removeOwnedPartial + ) + } + return state.capturesInput ? .captureAllowed : .idle + } +} diff --git a/desktop/native/Sources/QwenInputCore/SecureRuntimeDirectory.swift b/desktop/native/Sources/QwenInputCore/SecureRuntimeDirectory.swift new file mode 100644 index 00000000..1802115d --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/SecureRuntimeDirectory.swift @@ -0,0 +1,63 @@ +import Darwin +import Foundation + +public enum SecureRuntimeDirectoryError: Error, Equatable, Sendable { + case symbolicLink + case unsafeOwner + case unsafeMode + case notDirectory + case ioFailure +} + +public struct SecureRuntimeDirectory: Sendable { + public let url: URL + + public init(url: URL) { + self.url = url + } + + public func prepare() throws { + var information = stat() + if lstat(url.path, &information) != 0 { + guard errno == ENOENT else { + throw SecureRuntimeDirectoryError.ioFailure + } + do { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + } catch { + throw SecureRuntimeDirectoryError.ioFailure + } + guard chmod(url.path, 0o700) == 0 else { + throw SecureRuntimeDirectoryError.ioFailure + } + } + try validate() + } + + public func validate() throws { + var information = stat() + guard lstat(url.path, &information) == 0 else { + throw SecureRuntimeDirectoryError.ioFailure + } + guard information.st_mode & S_IFMT != S_IFLNK else { + throw SecureRuntimeDirectoryError.symbolicLink + } + guard information.st_mode & S_IFMT == S_IFDIR else { + throw SecureRuntimeDirectoryError.notDirectory + } + guard information.st_uid == geteuid() else { + throw SecureRuntimeDirectoryError.unsafeOwner + } + guard information.st_mode & 0o777 == 0o700 else { + throw SecureRuntimeDirectoryError.unsafeMode + } + } + + public func socketURL(named name: String = "control.sock") -> URL { + url.appendingPathComponent(name) + } +} diff --git a/desktop/native/Sources/QwenInputCore/SessionLedger.swift b/desktop/native/Sources/QwenInputCore/SessionLedger.swift new file mode 100644 index 00000000..d4f99b36 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/SessionLedger.swift @@ -0,0 +1,231 @@ +import Foundation + +public struct SessionLedger: Sendable { + public let sessionID: UUID + public private(set) var generation: UInt64 + public private(set) var targetID: UUID + public internal(set) var ownedMarkedRange: NSRange? + public private(set) var latestOwnedFinalRange: NSRange? + public private(set) var latestOwnedFinalText: String? + + public init(sessionID: UUID, generation: UInt64, targetID: UUID) { + self.sessionID = sessionID + self.generation = generation + self.targetID = targetID + } + + public mutating func partial( + text: String, + selectedRange: NSRange, + clientMarkedRange: NSRange, + generation: UInt64, + targetID: UUID + ) -> Result { + if let rejection = validate(generation: generation, targetID: targetID) { + return .failure(rejection) + } + + let replacement: NSRange + let location: Int + if let ownedMarkedRange { + guard ownedMarkedRange.location != NSNotFound, + clientMarkedRange.location != NSNotFound else { + return .failure(.unknownClientRange) + } + guard clientMarkedRange == ownedMarkedRange else { + return .failure(.markedRangeMismatch) + } + replacement = ownedMarkedRange + location = ownedMarkedRange.location + } else if clientMarkedRange.location != NSNotFound { + guard clientMarkedRange.length == 0, + selectedRange.location == NSNotFound + || selectedRange.location == clientMarkedRange.location else { + return .failure(.markedRangeMismatch) + } + replacement = clientMarkedRange + location = clientMarkedRange.location + } else { + guard selectedRange.location != NSNotFound else { + return .failure(.unknownClientRange) + } + replacement = NSRange(location: NSNotFound, length: 0) + location = selectedRange.location + } + + let length = utf16Length(text) + ownedMarkedRange = NSRange(location: location, length: length) + return .success(.setMarked( + text: text, + selection: NSRange(location: length, length: 0), + replacement: replacement + )) + } + + public mutating func final( + text: String, + selectedRange: NSRange, + clientMarkedRange: NSRange, + generation: UInt64, + targetID: UUID + ) -> Result { + if let rejection = validate(generation: generation, targetID: targetID) { + return .failure(rejection) + } + + let effect: ClientTextEffect + let location: Int + if let ownedMarkedRange { + guard ownedMarkedRange.location != NSNotFound, + clientMarkedRange.location != NSNotFound else { + return .failure(.unknownClientRange) + } + guard clientMarkedRange == ownedMarkedRange else { + return .failure(.markedRangeMismatch) + } + location = ownedMarkedRange.location + effect = .commitMarked(text: text, replacement: ownedMarkedRange) + } else { + guard selectedRange.location != NSNotFound else { + return .failure(.unknownClientRange) + } + location = selectedRange.location + effect = .commitSelection( + text: text, + expectedSelection: selectedRange + ) + } + + ownedMarkedRange = nil + latestOwnedFinalText = text + latestOwnedFinalRange = NSRange( + location: location, + length: utf16Length(text) + ) + return .success(effect) + } + + public mutating func confirmMarkedRange( + _ clientMarkedRange: NSRange + ) -> Result { + guard let ownedMarkedRange else { + return .failure(.markedRangeMismatch) + } + guard ownedMarkedRange.location != NSNotFound else { + return .failure(.unknownClientRange) + } + guard clientMarkedRange.location != NSNotFound, + ownedMarkedRange == clientMarkedRange else { + return .failure(.markedRangeMismatch) + } + return .success(()) + } + + public mutating func edit( + _ operation: OwnedTextEdit, + generation: UInt64, + targetID: UUID + ) -> Result { + if let rejection = validate(generation: generation, targetID: targetID) { + return .failure(rejection) + } + guard + let latestOwnedFinalText, + let latestOwnedFinalRange, + latestOwnedFinalRange.location != NSNotFound + else { + return .failure(.noOwnedFinalText) + } + + let target: String + let replacement: String + switch operation { + case let .replace(editTarget, editReplacement): + target = editTarget + replacement = editReplacement + case let .delete(editTarget): + target = editTarget + replacement = "" + } + guard !target.isEmpty else { + return .failure(.editTargetNotFound) + } + + let ownedText = latestOwnedFinalText as NSString + let fullRange = NSRange(location: 0, length: ownedText.length) + let first = ownedText.range(of: target, options: [], range: fullRange) + guard first.location != NSNotFound else { + return .failure(.editTargetNotFound) + } + + let nextLocation = first.location + 1 + if nextLocation < ownedText.length { + let remaining = NSRange( + location: nextLocation, + length: ownedText.length - nextLocation + ) + if ownedText.range(of: target, options: [], range: remaining).location + != NSNotFound { + return .failure(.ambiguousEditTarget) + } + } + + let documentRange = NSRange( + location: latestOwnedFinalRange.location + first.location, + length: first.length + ) + let updatedText = ownedText.replacingCharacters( + in: first, + with: replacement + ) + self.latestOwnedFinalText = updatedText + self.latestOwnedFinalRange = NSRange( + location: latestOwnedFinalRange.location, + length: utf16Length(updatedText) + ) + return .success(.insert(text: replacement, replacement: documentRange)) + } + + public mutating func cancel( + clientMarkedRange: NSRange, + generation: UInt64, + targetID: UUID + ) -> Result { + if let rejection = validate(generation: generation, targetID: targetID) { + return .failure(rejection) + } + guard let ownedMarkedRange else { + return .success(.none) + } + guard ownedMarkedRange.location != NSNotFound, + clientMarkedRange.location != NSNotFound else { + return .failure(.unknownClientRange) + } + guard clientMarkedRange == ownedMarkedRange else { + return .failure(.markedRangeMismatch) + } + self.ownedMarkedRange = nil + return .success(.removeMarked(replacement: ownedMarkedRange)) + } + + public mutating func retarget(generation: UInt64, targetID: UUID) { + self.generation = generation + self.targetID = targetID + ownedMarkedRange = nil + latestOwnedFinalRange = nil + latestOwnedFinalText = nil + } + + private func validate( + generation: UInt64, + targetID: UUID + ) -> LedgerError? { + guard generation == self.generation else { return .staleGeneration } + guard targetID == self.targetID else { return .targetMismatch } + return nil + } + + private func utf16Length(_ text: String) -> Int { + (text as NSString).length + } +} diff --git a/desktop/native/Sources/QwenInputCore/TextClientAdapter.swift b/desktop/native/Sources/QwenInputCore/TextClientAdapter.swift new file mode 100644 index 00000000..1d1c5096 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/TextClientAdapter.swift @@ -0,0 +1,167 @@ +import Foundation + +public protocol NativeTextClient: AnyObject { + var selectedRange: NSRange { get } + var markedRange: NSRange { get } + + func setMarkedText( + _ text: NSAttributedString, + selectionRange: NSRange, + replacementRange: NSRange + ) + + func insertText(_ text: Any, replacementRange: NSRange) +} + +public enum TextClientError: Error, Equatable, Sendable { + case unknownSelectedRange + case markedRangeMismatch + case invalidSelectionRange +} + +public struct TextClientAdapter: Sendable { + public init() {} + + @discardableResult + public func apply( + _ effect: ClientTextEffect, + to client: NativeTextClient + ) -> Result { + switch effect { + case let .setMarked(text, selection, replacement): + guard isValidSelection(selection, in: text) else { + return .failure(.invalidSelectionRange) + } + let effectiveReplacement: NSRange + if replacement.location == NSNotFound { + if client.markedRange.location == NSNotFound { + guard client.selectedRange.location != NSNotFound else { + return .failure(.unknownSelectedRange) + } + effectiveReplacement = replacement + } else if client.markedRange.length == 0 { + effectiveReplacement = client.markedRange + } else { + return .failure(.markedRangeMismatch) + } + } else if client.markedRange != replacement { + return .failure(.markedRangeMismatch) + } else { + effectiveReplacement = replacement + } + client.setMarkedText( + NSAttributedString(string: text), + selectionRange: selection, + replacementRange: effectiveReplacement + ) + return .success(true) + + case let .commitMarked(text, replacement): + let currentMarkedRange = client.markedRange + guard replacement.location != NSNotFound, + currentMarkedRange.location != NSNotFound, + currentMarkedRange == replacement else { + return .failure(.markedRangeMismatch) + } + client.insertText(text, replacementRange: replacement) + return .success(true) + + case let .commitSelection(text, expectedSelection): + let currentMarkedRange = client.markedRange + let currentSelection = client.selectedRange + guard currentMarkedRange.location == NSNotFound else { + return .failure(.markedRangeMismatch) + } + guard expectedSelection.location != NSNotFound, + currentSelection.location != NSNotFound, + currentSelection == expectedSelection else { + return .failure(.unknownSelectedRange) + } + client.insertText( + text, + replacementRange: NSRange( + location: NSNotFound, + length: NSNotFound + ) + ) + return .success(true) + + case let .insert(text, replacement): + if replacement.location == NSNotFound, + client.markedRange.location == NSNotFound, + client.selectedRange.location == NSNotFound { + return .failure(.unknownSelectedRange) + } + if replacement.location != NSNotFound, + client.markedRange.location != NSNotFound, + client.markedRange != replacement { + return .failure(.markedRangeMismatch) + } + client.insertText(text, replacementRange: replacement) + return .success(true) + + case let .removeMarked(replacement): + guard replacement.location != NSNotFound, + client.markedRange == replacement else { + return .failure(.markedRangeMismatch) + } + client.setMarkedText( + NSAttributedString(string: ""), + selectionRange: NSRange(location: 0, length: 0), + replacementRange: replacement + ) + client.insertText( + "", + replacementRange: NSRange( + location: NSNotFound, + length: NSNotFound + ) + ) + return .success(true) + + case .none: + return .success(false) + } + } + + private func isValidSelection(_ selection: NSRange, in text: String) -> Bool { + guard selection.location != NSNotFound else { return false } + let length = (text as NSString).length + return selection.location <= length + && selection.length <= length - selection.location + } +} + +public struct ClientTextOperationController: Sendable { + private let adapter = TextClientAdapter() + + public init() {} + + public func apply( + _ result: Result, + to client: NativeTextClient + ) -> Bool { + guard case let .success(effect) = result else { return false } + return (try? adapter.apply(effect, to: client).get()) != nil + } + + public func applyTransaction( + to ledger: inout SessionLedger, + client: NativeTextClient, + operation: (inout SessionLedger) -> Result + ) -> Bool { + var candidate = ledger + guard apply(operation(&candidate), to: client) else { return false } + ledger = candidate + return true + } +} + +public enum InputEventPolicy { + public static func shouldConsumePhysicalKey( + in state: NativeSessionState + ) -> Bool { + _ = state + return false + } +} diff --git a/desktop/native/Sources/QwenInputCore/TextOperation.swift b/desktop/native/Sources/QwenInputCore/TextOperation.swift new file mode 100644 index 00000000..f394cbf5 --- /dev/null +++ b/desktop/native/Sources/QwenInputCore/TextOperation.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum OwnedTextEdit: Equatable, Sendable { + case replace(target: String, replacement: String) + case delete(target: String) +} +public enum ClientTextEffect: Equatable, Sendable { + case setMarked(text: String, selection: NSRange, replacement: NSRange) + case commitMarked(text: String, replacement: NSRange) + case commitSelection(text: String, expectedSelection: NSRange) + case insert(text: String, replacement: NSRange) + case removeMarked(replacement: NSRange) + case none +} + +public enum LedgerError: Error, Equatable, Sendable { + case staleGeneration + case targetMismatch + case unknownClientRange + case markedRangeMismatch + case noOwnedFinalText + case editTargetNotFound + case ambiguousEditTarget +} diff --git a/desktop/native/Tests/QwenInputBridgeTests/BridgeRuntimeTests.swift b/desktop/native/Tests/QwenInputBridgeTests/BridgeRuntimeTests.swift new file mode 100644 index 00000000..aff39aff --- /dev/null +++ b/desktop/native/Tests/QwenInputBridgeTests/BridgeRuntimeTests.swift @@ -0,0 +1,129 @@ +import Foundation +import XCTest +import QwenInputCore + +final class BridgeRuntimeTests: XCTestCase { + func testRunDoesNotChangeTheUserSelectedSourceWhenFrameHandlingThrows() throws { + let input = Pipe() + let output = Pipe() + let source = FakeInputSourceAPI() + let broker = NativeOperationBroker() + broker.activate(NativeOperationTarget( + sessionID: "session-1", + generation: 1, + targetID: "target-1" + )) + let runtime = BridgeRuntime( + input: input.fileHandleForReading, + output: output.fileHandleForWriting, + lifecycle: nil, + broker: broker, + inputSourceAPI: source + ) + + try input.fileHandleForWriting.write(contentsOf: FrameCodec.encode( + NativeInputMessage( + type: .sessionArm, + operationID: "arm-1", + statusVisible: true + ) + )) + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + try? input.fileHandleForWriting.write(contentsOf: Data([0, 0, 0, 0])) + try? input.fileHandleForWriting.close() + } + + XCTAssertThrowsError(try runtime.run()) + XCTAssertEqual(source.current, "ai.qwenaudio.agent.inputmethod") + XCTAssertTrue(source.selections.isEmpty) + } + + func testRepairIsRejectedWithoutMutationWhileSessionIsArmed() throws { + let input = Pipe() + let output = Pipe() + let source = FakeInputSourceAPI() + let broker = NativeOperationBroker() + broker.activate(NativeOperationTarget( + sessionID: "session-1", + generation: 1, + targetID: "target-1" + )) + let fileSystem = FakeLifecycleFileSystem() + let registration = FakeLifecycleRegistration() + let lifecycle = InputMethodLifecycle( + embeddedBundleURL: URL(fileURLWithPath: "/embedded/Qwen Input.app"), + installedBundleURL: URL(fileURLWithPath: "/installed/Qwen Input.app"), + expectedBundleID: "ai.qwenaudio.agent.inputmethod", + expectedVersion: "1.11.0", + currentUserID: 501, + fileSystem: fileSystem, + registration: registration + ) + let runtime = BridgeRuntime( + input: input.fileHandleForReading, + output: output.fileHandleForWriting, + lifecycle: lifecycle, + broker: broker, + inputSourceAPI: source + ) + let messages = [ + NativeInputMessage( + type: .sessionArm, + operationID: "arm-1", + statusVisible: true + ), + NativeInputMessage(type: .lifecycleRepair, requestID: "repair-1"), + NativeInputMessage(type: .bridgeStop), + ] + for message in messages { + try input.fileHandleForWriting.write(contentsOf: FrameCodec.encode(message)) + } + try input.fileHandleForWriting.close() + + try runtime.run() + XCTAssertTrue(fileSystem.installCalls.isEmpty) + XCTAssertEqual(source.current, "ai.qwenaudio.agent.inputmethod") + } +} + +private final class FakeInputSourceAPI: InputSourceAPI, @unchecked Sendable { + var current = "ai.qwenaudio.agent.inputmethod" + var selections: [String] = [] + + func currentKeyboardSourceID() -> String? { current } + func containsInputSource(id: String) -> Bool { true } + func isInputSourceEnabled(id: String) -> Bool { true } + func selectInputSource(id: String) -> Bool { + selections.append(id) + current = id + return true + } + func registerInputSource(at url: URL) -> Bool { true } +} + +private final class FakeLifecycleFileSystem: InputMethodLifecycleFileSystem { + var installCalls: [[String]] = [] + + func inspect(at url: URL) -> InputMethodArtifactInspection? { + InputMethodArtifactInspection( + symbolicLink: false, + ownerUserID: 501, + bundleID: "ai.qwenaudio.agent.inputmethod", + version: "1.11.0", + signatureValid: true + ) + } + func installAtomically(from source: URL, to destination: URL) throws { + installCalls.append([source.path, destination.path]) + } + func commitInstall(at destination: URL) throws {} + func rollbackInstall(at destination: URL) throws {} + func moveToTrash(_ url: URL) throws {} +} + +private final class FakeLifecycleRegistration: InputMethodRegistration { + func containsInputSource() -> Bool { true } + func isInputSourceEnabled() -> Bool { true } + func registerInputSource(at url: URL) -> Bool { true } + func disableInputSource() -> Bool { true } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/BridgeConnectionStateTests.swift b/desktop/native/Tests/QwenInputCoreTests/BridgeConnectionStateTests.swift new file mode 100644 index 00000000..c709769e --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/BridgeConnectionStateTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import QwenInputCore + +final class BridgeConnectionStateTests: XCTestCase { + func testRepeatedFailuresRequestOneFailClosedCleanupAndReconnect() { + var state = BridgeConnectionState(failureThreshold: 3) + + XCTAssertTrue(state.needsActivation) + XCTAssertEqual(state.recordFailure(), .none) + XCTAssertEqual(state.recordFailure(), .none) + XCTAssertEqual(state.recordFailure(), .failClosed) + XCTAssertEqual(state.recordFailure(), .none) + XCTAssertTrue(state.needsActivation) + + state.recordSuccess() + XCTAssertFalse(state.needsActivation) + XCTAssertEqual(state.recordFailure(), .none) + XCTAssertTrue(state.needsActivation) + state.recordSuccess() + XCTAssertFalse(state.needsActivation) + } + + func testARecoveredConnectionCanFailClosedAgain() { + var state = BridgeConnectionState(failureThreshold: 1) + + XCTAssertEqual(state.recordFailure(), .failClosed) + state.recordSuccess() + XCTAssertEqual(state.recordFailure(), .failClosed) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/ControllerRegistryStateTests.swift b/desktop/native/Tests/QwenInputCoreTests/ControllerRegistryStateTests.swift new file mode 100644 index 00000000..8f93a2ac --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/ControllerRegistryStateTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import QwenInputCore + +final class ControllerRegistryStateTests: XCTestCase { + func testActivationAndDeactivationAlwaysAdvanceGeneration() { + var registry = ControllerRegistryState() + + let first = registry.activate(clientIdentifier: "client-a") + XCTAssertEqual(first.generation, 1) + XCTAssertEqual(first.clientIdentifier, "client-a") + XCTAssertEqual(registry.current, first) + + XCTAssertTrue(registry.deactivate(targetID: first.targetID)) + XCTAssertEqual(registry.generation, 2) + XCTAssertNil(registry.current) + + let second = registry.activate(clientIdentifier: "client-b") + XCTAssertEqual(second.generation, 3) + XCTAssertNotEqual(second.targetID, first.targetID) + XCTAssertEqual(registry.current, second) + } + + func testStaleControllerCannotDeactivateTheCurrentTarget() { + var registry = ControllerRegistryState() + let stale = registry.activate(clientIdentifier: "old") + let current = registry.activate(clientIdentifier: "new") + + XCTAssertFalse(registry.deactivate(targetID: stale.targetID)) + XCTAssertEqual(registry.current, current) + XCTAssertEqual(registry.generation, current.generation) + + XCTAssertTrue(registry.close(targetID: current.targetID)) + XCTAssertNil(registry.current) + XCTAssertEqual(registry.generation, current.generation + 1) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/FrameCodecTests.swift b/desktop/native/Tests/QwenInputCoreTests/FrameCodecTests.swift new file mode 100644 index 00000000..7e6b6910 --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/FrameCodecTests.swift @@ -0,0 +1,129 @@ +import Foundation +import XCTest +@testable import QwenInputCore + +final class FrameCodecTests: XCTestCase { + private func message( + _ type: NativeInputMessageType = .sessionPartial, + text: String? = "你好" + ) -> NativeInputMessage { + NativeInputMessage(type: type, text: text) + } + + func testRoundTripUsesFourByteBigEndianLength() throws { + let frame = try FrameCodec.encode(message()) + let declared = frame.prefix(4).reduce(UInt32(0)) { + ($0 << 8) | UInt32($1) + } + + XCTAssertEqual(Int(declared), frame.count - 4) + XCTAssertEqual( + try FrameCodec.decode(NativeInputMessage.self, from: frame), + message() + ) + } + + func testIncrementalDecoderHandlesSplitAndCoalescedFrames() throws { + let first = try FrameCodec.encode(message(.sessionPartial, text: "a")) + let second = try FrameCodec.encode(message(.sessionFinal, text: "b")) + let stream = first + second + var decoder = FrameStreamDecoder() + + XCTAssertEqual(try decoder.append(stream.prefix(3)), []) + let frames = try decoder.append(stream.dropFirst(3)) + + XCTAssertEqual(frames.count, 2) + XCTAssertEqual( + try FrameCodec.decodePayload(NativeInputMessage.self, from: frames[0]), + message(.sessionPartial, text: "a") + ) + XCTAssertEqual( + try FrameCodec.decodePayload(NativeInputMessage.self, from: frames[1]), + message(.sessionFinal, text: "b") + ) + XCTAssertNoThrow(try decoder.finish()) + } + + func testRejectsZeroOversizedTruncatedAndTrailingFrames() throws { + XCTAssertThrowsError(try FrameCodec.decode( + NativeInputMessage.self, + from: Data([0, 0, 0, 0]) + )) { error in + XCTAssertEqual(error as? FrameCodecError, .zeroLength) + } + + let tooLarge = UInt32(FrameCodec.maximumPayloadBytes + 1) + let oversizedHeader = Data([ + UInt8((tooLarge >> 24) & 0xff), + UInt8((tooLarge >> 16) & 0xff), + UInt8((tooLarge >> 8) & 0xff), + UInt8(tooLarge & 0xff), + ]) + XCTAssertThrowsError(try FrameCodec.decode( + NativeInputMessage.self, + from: oversizedHeader + )) { error in + XCTAssertEqual(error as? FrameCodecError, .oversized) + } + + let valid = try FrameCodec.encode(message()) + XCTAssertThrowsError(try FrameCodec.decode( + NativeInputMessage.self, + from: valid.dropLast() + )) { error in + XCTAssertEqual(error as? FrameCodecError, .truncated) + } + XCTAssertThrowsError(try FrameCodec.decode( + NativeInputMessage.self, + from: valid + Data([0]) + )) { error in + XCTAssertEqual(error as? FrameCodecError, .trailingBytes) + } + } + + func testRejectsInvalidUTF8JSONAndUnknownMessageType() { + let invalidUTF8 = framed(Data([0xff])) + XCTAssertThrowsError(try FrameCodec.decode( + NativeInputMessage.self, + from: invalidUTF8 + )) { error in + XCTAssertEqual(error as? FrameCodecError, .invalidUTF8) + } + + let invalidJSON = framed(Data("{".utf8)) + XCTAssertThrowsError(try FrameCodec.decode( + NativeInputMessage.self, + from: invalidJSON + )) { error in + XCTAssertEqual(error as? FrameCodecError, .invalidJSON) + } + + let unknown = framed(Data(#"{"type":"session.unknown"}"#.utf8)) + XCTAssertThrowsError(try FrameCodec.decode( + NativeInputMessage.self, + from: unknown + )) { error in + XCTAssertEqual(error as? FrameCodecError, .invalidJSON) + } + } + + func testFinishRejectsTruncatedBufferedFrame() throws { + let frame = try FrameCodec.encode(message()) + var decoder = FrameStreamDecoder() + _ = try decoder.append(frame.dropLast()) + + XCTAssertThrowsError(try decoder.finish()) { error in + XCTAssertEqual(error as? FrameCodecError, .truncated) + } + } + + private func framed(_ payload: Data) -> Data { + let length = UInt32(payload.count) + return Data([ + UInt8((length >> 24) & 0xff), + UInt8((length >> 16) & 0xff), + UInt8((length >> 8) & 0xff), + UInt8(length & 0xff), + ]) + payload + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/InputMethodLifecycleTests.swift b/desktop/native/Tests/QwenInputCoreTests/InputMethodLifecycleTests.swift new file mode 100644 index 00000000..0d2c944a --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/InputMethodLifecycleTests.swift @@ -0,0 +1,201 @@ +import Foundation +import XCTest +@testable import QwenInputCore + +final class InputMethodLifecycleTests: XCTestCase { + func testInstallValidatesThenAtomicallyInstallsAndRegistersWithoutEnabling() throws { + let fileSystem = FakeInputMethodFileSystem() + let registration = FakeInputMethodRegistration() + let embedded = URL(fileURLWithPath: "/Applications/Qwen.app/Contents/Resources/native-input/Qwen Input.app") + let installed = URL(fileURLWithPath: "/Users/test/Library/Input Methods/Qwen Input.app") + fileSystem.inspections[embedded.path] = .valid(owner: 501) + + let lifecycle = makeLifecycle( + fileSystem: fileSystem, + registration: registration, + embedded: embedded, + installed: installed + ) + let status = try lifecycle.install() + + XCTAssertEqual(fileSystem.installCalls, [[embedded.path, installed.path]]) + XCTAssertEqual(fileSystem.commitCalls, [installed.path]) + XCTAssertEqual(registration.calls, ["register:\(installed.path)"]) + XCTAssertTrue(status.installed) + XCTAssertTrue(status.registered) + XCTAssertFalse(status.enabled) + XCTAssertEqual(status.version, "1.11.0") + } + + func testInstallRejectsSymlinkWrongOwnerSignatureAndVersionBeforeMutation() { + let invalid: [InputMethodArtifactInspection] = [ + .valid(owner: 0, symbolicLink: true), + .valid(owner: 501, signatureValid: false), + .valid(owner: 501, version: "0.9.0"), + .valid(owner: 501, bundleID: "example.wrong.input"), + ] + for inspection in invalid { + let fileSystem = FakeInputMethodFileSystem() + let registration = FakeInputMethodRegistration() + fileSystem.inspections["/embedded/Qwen Input.app"] = inspection + let lifecycle = makeLifecycle( + fileSystem: fileSystem, + registration: registration, + embedded: URL(fileURLWithPath: "/embedded/Qwen Input.app"), + installed: URL(fileURLWithPath: "/installed/Qwen Input.app") + ) + XCTAssertThrowsError(try lifecycle.install()) + XCTAssertTrue(fileSystem.installCalls.isEmpty) + XCTAssertTrue(registration.calls.isEmpty) + } + } + + func testRegistrationFailureRollsBackAtomicReplacement() { + let fileSystem = FakeInputMethodFileSystem() + let registration = FakeInputMethodRegistration() + let embedded = URL(fileURLWithPath: "/embedded/Qwen Input.app") + let installed = URL(fileURLWithPath: "/installed/Qwen Input.app") + fileSystem.inspections[embedded.path] = .valid(owner: 501) + registration.registerResult = false + let lifecycle = makeLifecycle( + fileSystem: fileSystem, + registration: registration, + embedded: embedded, + installed: installed + ) + + XCTAssertThrowsError(try lifecycle.install()) + XCTAssertEqual(fileSystem.rollbackCalls, [installed.path]) + XCTAssertTrue(fileSystem.commitCalls.isEmpty) + } + + func testStatusFailsClosedForUnsafeInstalledBundle() throws { + let fileSystem = FakeInputMethodFileSystem() + let registration = FakeInputMethodRegistration() + let installed = URL(fileURLWithPath: "/installed/Qwen Input.app") + fileSystem.inspections[installed.path] = .valid( + owner: 999, + symbolicLink: true + ) + registration.contains = true + registration.enabled = true + let lifecycle = makeLifecycle( + fileSystem: fileSystem, + registration: registration, + installed: installed + ) + + let status = try lifecycle.status() + XCTAssertFalse(status.installed) + XCTAssertFalse(status.registered) + XCTAssertFalse(status.enabled) + } + + func testUninstallDisablesBeforeMovingBundleToTrash() throws { + let fileSystem = FakeInputMethodFileSystem() + let registration = FakeInputMethodRegistration() + let installed = URL(fileURLWithPath: "/installed/Qwen Input.app") + fileSystem.inspections[installed.path] = .valid(owner: 501) + registration.contains = true + registration.enabled = true + let lifecycle = makeLifecycle( + fileSystem: fileSystem, + registration: registration, + installed: installed + ) + + let status = try lifecycle.uninstall() + XCTAssertEqual(registration.calls, ["disable"]) + XCTAssertEqual(fileSystem.trashCalls, [installed.path]) + XCTAssertFalse(status.installed) + XCTAssertFalse(status.registered) + XCTAssertFalse(status.enabled) + } + + private func makeLifecycle( + fileSystem: FakeInputMethodFileSystem, + registration: FakeInputMethodRegistration, + embedded: URL = URL(fileURLWithPath: "/embedded/Qwen Input.app"), + installed: URL = URL(fileURLWithPath: "/installed/Qwen Input.app") + ) -> InputMethodLifecycle { + InputMethodLifecycle( + embeddedBundleURL: embedded, + installedBundleURL: installed, + expectedBundleID: "ai.qwenaudio.agent.inputmethod", + expectedVersion: "1.11.0", + currentUserID: 501, + fileSystem: fileSystem, + registration: registration + ) + } +} + +private final class FakeInputMethodFileSystem: InputMethodLifecycleFileSystem { + var inspections: [String: InputMethodArtifactInspection] = [:] + var installCalls: [[String]] = [] + var commitCalls: [String] = [] + var rollbackCalls: [String] = [] + var trashCalls: [String] = [] + + func inspect(at url: URL) -> InputMethodArtifactInspection? { + inspections[url.path] + } + + func installAtomically(from source: URL, to destination: URL) throws { + installCalls.append([source.path, destination.path]) + inspections[destination.path] = inspections[source.path] + } + + func rollbackInstall(at destination: URL) throws { + rollbackCalls.append(destination.path) + inspections[destination.path] = nil + } + + func commitInstall(at destination: URL) throws { + commitCalls.append(destination.path) + } + + func moveToTrash(_ url: URL) throws { + trashCalls.append(url.path) + inspections[url.path] = nil + } +} + +private final class FakeInputMethodRegistration: InputMethodRegistration { + var contains = false + var enabled = false + var registerResult = true + var disableResult = true + var calls: [String] = [] + + func containsInputSource() -> Bool { contains } + func isInputSourceEnabled() -> Bool { enabled } + func registerInputSource(at url: URL) -> Bool { + calls.append("register:\(url.path)") + if registerResult { contains = true } + return registerResult + } + func disableInputSource() -> Bool { + calls.append("disable") + if disableResult { enabled = false; contains = false } + return disableResult + } +} + +private extension InputMethodArtifactInspection { + static func valid( + owner: UInt32, + symbolicLink: Bool = false, + signatureValid: Bool = true, + version: String = "1.11.0", + bundleID: String = "ai.qwenaudio.agent.inputmethod" + ) -> InputMethodArtifactInspection { + InputMethodArtifactInspection( + symbolicLink: symbolicLink, + ownerUserID: owner, + bundleID: bundleID, + version: version, + signatureValid: signatureValid + ) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/InputSourceCoordinatorTests.swift b/desktop/native/Tests/QwenInputCoreTests/InputSourceCoordinatorTests.swift new file mode 100644 index 00000000..df5b71e0 --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/InputSourceCoordinatorTests.swift @@ -0,0 +1,177 @@ +import Foundation +import XCTest +@testable import QwenInputCore + +final class InputSourceCoordinatorTests: XCTestCase { + private final class FakeAPI: InputSourceAPI { + var currentID: String? + var available: Set + var enabled: Set + var selectFailures: Set = [] + var calls: [String] = [] + + init(currentID: String?, available: Set, enabled: Set) { + self.currentID = currentID + self.available = available + self.enabled = enabled + } + + func currentKeyboardSourceID() -> String? { + calls.append("current") + return currentID + } + + func containsInputSource(id: String) -> Bool { + calls.append("find:\(id)") + return available.contains(id) + } + + func isInputSourceEnabled(id: String) -> Bool { + calls.append("enabled:\(id)") + return enabled.contains(id) + } + + func selectInputSource(id: String) -> Bool { + calls.append("select:\(id)") + guard available.contains(id), !selectFailures.contains(id) else { + return false + } + currentID = id + return true + } + + func registerInputSource(at url: URL) -> Bool { + calls.append("register:\(url.lastPathComponent)") + return true + } + } + + private let qwenID = "ai.qwenaudio.agent.inputmethod" + private let previousID = "com.apple.keylayout.ABC" + + func testDefaultBeginRequiresExplicitUserSelectionWithoutChangingSource() { + let api = FakeAPI( + currentID: previousID, + available: [previousID, qwenID], + enabled: [previousID, qwenID] + ) + var coordinator = InputSourceCoordinator( + qwenInputSourceID: qwenID, + api: api + ) + + XCTAssertEqual(coordinator.begin(), .selectionRequired) + XCTAssertEqual(api.currentID, previousID) + XCTAssertFalse(api.calls.contains("select:\(qwenID)")) + XCTAssertNil(coordinator.recoveryInstruction) + } + + func testRecordsCurrentSourceBeforeSelectingAndRestoresIt() { + let api = FakeAPI( + currentID: previousID, + available: [previousID, qwenID], + enabled: [previousID, qwenID] + ) + var coordinator = InputSourceCoordinator( + qwenInputSourceID: qwenID, + api: api + ) + + XCTAssertEqual( + coordinator.begin(selectIfNeeded: true), + .selected(previousSourceID: previousID) + ) + XCTAssertEqual( + api.calls, + ["current", "find:\(qwenID)", "enabled:\(qwenID)", "select:\(qwenID)"] + ) + XCTAssertEqual( + coordinator.recoveryInstruction, + InputSourceRecoveryInstruction( + previousSourceID: previousID, + expectedCurrentSourceID: qwenID + ) + ) + + XCTAssertEqual(coordinator.restore(), .restored(sourceID: previousID)) + XCTAssertEqual(api.currentID, previousID) + XCTAssertNil(coordinator.recoveryInstruction) + XCTAssertEqual(coordinator.restore(), .alreadyRestored) + } + + func testDisabledMissingOrFailedQwenSourceNeverCreatesRecoveryState() { + let disabled = FakeAPI( + currentID: previousID, + available: [previousID, qwenID], + enabled: [previousID] + ) + var disabledCoordinator = InputSourceCoordinator( + qwenInputSourceID: qwenID, + api: disabled + ) + XCTAssertEqual(disabledCoordinator.begin(), .qwenInputDisabled) + XCTAssertNil(disabledCoordinator.recoveryInstruction) + + let failing = FakeAPI( + currentID: previousID, + available: [previousID, qwenID], + enabled: [previousID, qwenID] + ) + failing.selectFailures.insert(qwenID) + var failingCoordinator = InputSourceCoordinator( + qwenInputSourceID: qwenID, + api: failing + ) + XCTAssertEqual( + failingCoordinator.begin(selectIfNeeded: true), + .selectionFailed + ) + XCTAssertNil(failingCoordinator.recoveryInstruction) + } + + func testExternalSourceChangeFailsClosedWithoutOverwritingIt() { + let externalID = "com.example.other" + let api = FakeAPI( + currentID: previousID, + available: [previousID, qwenID, externalID], + enabled: [previousID, qwenID, externalID] + ) + var coordinator = InputSourceCoordinator( + qwenInputSourceID: qwenID, + api: api + ) + XCTAssertEqual( + coordinator.begin(selectIfNeeded: true), + .selected(previousSourceID: previousID) + ) + api.currentID = externalID + + XCTAssertEqual( + coordinator.restore(), + .currentSourceChanged(actualSourceID: externalID) + ) + XCTAssertEqual(api.currentID, externalID) + XCTAssertFalse(api.calls.contains("select:\(previousID)")) + } + + func testMissingOrDisabledPreviousSourceIsNeverSelected() { + let api = FakeAPI( + currentID: previousID, + available: [previousID, qwenID], + enabled: [previousID, qwenID] + ) + var coordinator = InputSourceCoordinator( + qwenInputSourceID: qwenID, + api: api + ) + XCTAssertEqual( + coordinator.begin(selectIfNeeded: true), + .selected(previousSourceID: previousID) + ) + api.enabled.remove(previousID) + + XCTAssertEqual(coordinator.restore(), .previousSourceUnavailable) + XCTAssertEqual(api.currentID, qwenID) + XCTAssertFalse(api.calls.contains("select:\(previousID)")) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/NativeInputCoreTests.swift b/desktop/native/Tests/QwenInputCoreTests/NativeInputCoreTests.swift new file mode 100644 index 00000000..920a441b --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/NativeInputCoreTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import QwenInputCore + +final class NativeInputCoreTests: XCTestCase { + func testProtocolStartsAtVersionOne() { + XCTAssertEqual(NativeInputCore.protocolVersion, 1) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/NativeOperationBrokerTests.swift b/desktop/native/Tests/QwenInputCoreTests/NativeOperationBrokerTests.swift new file mode 100644 index 00000000..062d8842 --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/NativeOperationBrokerTests.swift @@ -0,0 +1,102 @@ +import Foundation +import XCTest +@testable import QwenInputCore + +final class NativeOperationBrokerTests: XCTestCase { + func testRequiresVisibleActiveTargetBeforeArmAndRoutesCorrelatedOperation() { + let broker = NativeOperationBroker() + XCTAssertFalse(broker.arm(statusVisible: true).accepted) + + let target = NativeOperationTarget( + sessionID: "session-1", + generation: 3, + targetID: "target-1" + ) + broker.activate(target) + XCTAssertFalse(broker.arm(statusVisible: false).accepted) + XCTAssertTrue(broker.arm(statusVisible: true).accepted) + + let operation = NativeInputMessage( + type: .sessionPartial, + text: "A😀B", + operationID: "operation-1", + revision: 0, + sequence: 1 + ) + XCTAssertTrue(broker.enqueue(operation)) + let routed = broker.poll(for: target) + XCTAssertEqual(routed?.operationID, operation.operationID) + XCTAssertEqual(routed?.text, operation.text) + XCTAssertEqual(routed?.sessionID, target.sessionID) + XCTAssertEqual(routed?.generation, target.generation) + XCTAssertEqual(routed?.targetID, target.targetID) + XCTAssertTrue(broker.complete( + operationID: "operation-1", + accepted: true, + reason: nil, + target: target + )) + XCTAssertEqual(broker.takeResult(operationID: "operation-1"), + NativeOperationResult(accepted: true, reason: nil)) + XCTAssertNil(broker.takeResult(operationID: "operation-1")) + } + + func testTargetLossCancelsQueueAndRejectsLateResults() { + let broker = NativeOperationBroker() + let target = NativeOperationTarget( + sessionID: "session-1", + generation: 1, + targetID: "target-1" + ) + broker.activate(target) + XCTAssertTrue(broker.arm(statusVisible: true).accepted) + XCTAssertTrue(broker.enqueue(NativeInputMessage( + type: .sessionFinal, + text: "secret", + operationID: "operation-1" + ))) + + broker.deactivate(target) + XCTAssertNil(broker.poll(for: target)) + XCTAssertFalse(broker.complete( + operationID: "operation-1", + accepted: true, + reason: nil, + target: target + )) + XCTAssertEqual(broker.takeResult(operationID: "operation-1"), + NativeOperationResult( + accepted: false, + reason: "target_lost" + )) + } + + func testRejectsWrongTargetReplayAndNonMonotonicSequence() { + let broker = NativeOperationBroker() + let target = NativeOperationTarget( + sessionID: "session-1", + generation: 1, + targetID: "target-1" + ) + broker.activate(target) + XCTAssertTrue(broker.arm(statusVisible: true).accepted) + XCTAssertTrue(broker.enqueue(NativeInputMessage( + type: .sessionPartial, + text: "one", + operationID: "operation-1", + sequence: 2 + ))) + XCTAssertFalse(broker.enqueue(NativeInputMessage( + type: .sessionPartial, + text: "replay", + operationID: "operation-1", + sequence: 3 + ))) + XCTAssertFalse(broker.enqueue(NativeInputMessage( + type: .sessionPartial, + text: "stale", + operationID: "operation-2", + sequence: 1 + ))) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/PeerRequirementTests.swift b/desktop/native/Tests/QwenInputCoreTests/PeerRequirementTests.swift new file mode 100644 index 00000000..daccb0a9 --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/PeerRequirementTests.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +@testable import QwenInputCore + +final class PeerRequirementTests: XCTestCase { + private let bridgeBundleID = "ai.qwenaudio.agent.inputbridge" + private let inputBundleID = "ai.qwenaudio.agent.inputmethod" + + func testReleasePolicyAcceptsOnlyExactBundleTeamUserAndProtocol() { + let policy = PeerRequirementPolicy( + expectedBundleID: bridgeBundleID, + expectedTeamID: "ABCDE12345", + expectedEffectiveUserID: 501, + allowAdHocDebugPeer: false + ) + let valid = PeerIdentity( + bundleID: bridgeBundleID, + teamID: "ABCDE12345", + effectiveUserID: 501, + isAdHoc: false, + protocolVersion: NativeInputCore.protocolVersion + ) + + XCTAssertEqual(policy.validate(valid), .accepted) + XCTAssertEqual( + policy.validate(copy(valid, bundleID: inputBundleID)), + .rejected(.bundleMismatch) + ) + XCTAssertEqual( + policy.validate(copy(valid, teamID: "OTHER12345")), + .rejected(.teamMismatch) + ) + XCTAssertEqual( + policy.validate(copy(valid, effectiveUserID: 502)), + .rejected(.userMismatch) + ) + XCTAssertEqual( + policy.validate(copy(valid, protocolVersion: 2)), + .rejected(.protocolMismatch) + ) + XCTAssertEqual( + policy.validate(PeerIdentity( + bundleID: bridgeBundleID, + teamID: nil, + effectiveUserID: 501, + isAdHoc: true, + protocolVersion: NativeInputCore.protocolVersion + )), + .rejected(.adHocNotAllowed) + ) + } + + func testDebugPolicyRequiresExplicitAdHocOptIn() { + let peer = PeerIdentity( + bundleID: inputBundleID, + teamID: nil, + effectiveUserID: 501, + isAdHoc: true, + protocolVersion: NativeInputCore.protocolVersion + ) + let policy = PeerRequirementPolicy( + expectedBundleID: inputBundleID, + expectedTeamID: nil, + expectedEffectiveUserID: 501, + allowAdHocDebugPeer: true + ) + + XCTAssertEqual(policy.validate(peer), .accepted) + } + + func testCodeSigningRequirementIsExactAndRejectsUnsafeIdentifiers() throws { + XCTAssertEqual( + try PeerCodeSigningRequirement.release( + bundleID: bridgeBundleID, + teamID: "ABCDE12345" + ).value, + "anchor apple generic and identifier \"ai.qwenaudio.agent.inputbridge\" " + + "and certificate leaf[subject.OU] = \"ABCDE12345\"" + ) + XCTAssertEqual( + try PeerCodeSigningRequirement.debugAdHoc( + bundleID: inputBundleID + ).value, + "identifier \"ai.qwenaudio.agent.inputmethod\"" + ) + XCTAssertThrowsError(try PeerCodeSigningRequirement.release( + bundleID: "bad\" or true", + teamID: "ABCDE12345" + )) + XCTAssertThrowsError(try PeerCodeSigningRequirement.release( + bundleID: bridgeBundleID, + teamID: "bad team" + )) + } + + func testArtifactValidatorEnforcesTheInjectedSigningRequirement() throws { + let bundle = Bundle(for: Self.self) + let matching = try PeerCodeSigningRequirement.debugAdHoc( + bundleID: bundle.bundleIdentifier! + ) + let wrong = try PeerCodeSigningRequirement.debugAdHoc( + bundleID: "ai.qwenaudio.agent.inputmethod" + ) + + XCTAssertTrue(CodeSignatureValidator(requirement: matching) + .isValid(at: bundle.bundleURL)) + XCTAssertFalse(CodeSignatureValidator(requirement: wrong) + .isValid(at: bundle.bundleURL)) + } + + private func copy( + _ identity: PeerIdentity, + bundleID: String? = nil, + teamID: String? = nil, + effectiveUserID: uid_t? = nil, + isAdHoc: Bool? = nil, + protocolVersion: UInt16? = nil + ) -> PeerIdentity { + PeerIdentity( + bundleID: bundleID ?? identity.bundleID, + teamID: teamID ?? identity.teamID, + effectiveUserID: effectiveUserID ?? identity.effectiveUserID, + isAdHoc: isAdHoc ?? identity.isAdHoc, + protocolVersion: protocolVersion ?? identity.protocolVersion + ) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/ProtocolValidatorTests.swift b/desktop/native/Tests/QwenInputCoreTests/ProtocolValidatorTests.swift new file mode 100644 index 00000000..170fefd3 --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/ProtocolValidatorTests.swift @@ -0,0 +1,245 @@ +import Foundation +import XCTest +@testable import QwenInputCore + +final class ProtocolValidatorTests: XCTestCase { + private let sessionID = UUID(uuidString: "10000000-0000-0000-0000-000000000001")! + private let targetID = UUID(uuidString: "20000000-0000-0000-0000-000000000002")! + + private func operationID(_ suffix: Int) -> UUID { + UUID(uuidString: String( + format: "30000000-0000-0000-0000-%012d", + suffix + ))! + } + + private func metadata( + version: UInt16 = 1, + sessionID: UUID? = nil, + generation: UInt64 = 7, + targetID: UUID? = nil, + operation: Int, + sequence: UInt64 + ) -> NativeInputMetadata { + NativeInputMetadata( + protocolVersion: version, + sessionID: sessionID ?? self.sessionID, + generation: generation, + targetID: targetID ?? self.targetID, + operationID: operationID(operation), + sequence: sequence + ) + } + + private func validator(replayCapacity: Int = 256) -> ProtocolValidator { + ProtocolValidator( + sessionID: sessionID, + generation: 7, + targetID: targetID, + replayCapacity: replayCapacity + ) + } + + func testAcceptsOnlyTheCurrentSessionTargetAndMonotonicSequence() { + var validator = validator() + + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 1), + encodedByteCount: 512 + ), + .accepted + ) + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 2, sequence: 2), + encodedByteCount: 512 + ), + .accepted + ) + } + + func testRejectsWrongVersionWithoutAdvancingTheSequence() { + var validator = validator() + + XCTAssertEqual( + validator.accept( + metadata: metadata( + version: 2, + operation: 1, + sequence: 1 + ), + encodedByteCount: 512 + ), + .rejected(.unsupportedProtocolVersion) + ) + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 1), + encodedByteCount: 512 + ), + .accepted + ) + } + + func testRejectsOversizedFramesWithoutConsumingTheOperation() { + var validator = validator() + + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 1), + encodedByteCount: 65_537 + ), + .rejected(.oversizedFrame) + ) + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 1), + encodedByteCount: 65_536 + ), + .accepted + ) + } + + func testRejectsForeignSessionGenerationAndTarget() { + let foreignSession = UUID(uuidString: "40000000-0000-0000-0000-000000000004")! + let foreignTarget = UUID(uuidString: "50000000-0000-0000-0000-000000000005")! + var validator = validator() + + XCTAssertEqual( + validator.accept( + metadata: metadata( + sessionID: foreignSession, + operation: 1, + sequence: 1 + ), + encodedByteCount: 1 + ), + .rejected(.sessionMismatch) + ) + XCTAssertEqual( + validator.accept( + metadata: metadata( + generation: 6, + operation: 1, + sequence: 1 + ), + encodedByteCount: 1 + ), + .rejected(.generationMismatch) + ) + XCTAssertEqual( + validator.accept( + metadata: metadata( + targetID: foreignTarget, + operation: 1, + sequence: 1 + ), + encodedByteCount: 1 + ), + .rejected(.targetMismatch) + ) + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 1), + encodedByteCount: 1 + ), + .accepted + ) + } + + func testRejectsReplayAndOutOfOrderSequenceWithoutPoisoningNextMessage() { + var validator = validator() + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 1), + encodedByteCount: 1 + ), + .accepted + ) + + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 2), + encodedByteCount: 1 + ), + .rejected(.replayedOperation) + ) + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 2, sequence: 1), + encodedByteCount: 1 + ), + .rejected(.nonMonotonicSequence) + ) + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 2, sequence: 2), + encodedByteCount: 1 + ), + .accepted + ) + } + + func testResetAcceptsAFreshGenerationAndDropsOldIdentity() { + let nextTarget = UUID(uuidString: "60000000-0000-0000-0000-000000000006")! + var validator = validator() + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 1), + encodedByteCount: 1 + ), + .accepted + ) + + validator.reset( + sessionID: sessionID, + generation: 8, + targetID: nextTarget + ) + + XCTAssertEqual( + validator.accept( + metadata: metadata( + generation: 8, + targetID: nextTarget, + operation: 1, + sequence: 1 + ), + encodedByteCount: 1 + ), + .accepted + ) + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 2, sequence: 2), + encodedByteCount: 1 + ), + .rejected(.generationMismatch) + ) + } + + func testReplayWindowIsBounded() { + var validator = validator(replayCapacity: 2) + for operation in 1...3 { + XCTAssertEqual( + validator.accept( + metadata: metadata( + operation: operation, + sequence: UInt64(operation) + ), + encodedByteCount: 1 + ), + .accepted + ) + } + + XCTAssertEqual( + validator.accept( + metadata: metadata(operation: 1, sequence: 4), + encodedByteCount: 1 + ), + .accepted + ) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/SafetyGateTests.swift b/desktop/native/Tests/QwenInputCoreTests/SafetyGateTests.swift new file mode 100644 index 00000000..4b52671a --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/SafetyGateTests.swift @@ -0,0 +1,128 @@ +import XCTest +@testable import QwenInputCore + +final class SafetyGateTests: XCTestCase { + private func context( + featureEnabled: Bool = true, + desktopConnected: Bool = true, + target: GateSignal = .ready, + statusVisibility: GateSignal = .ready, + inputSource: GateSignal = .ready + ) -> SafetyContext { + SafetyContext( + featureEnabled: featureEnabled, + desktopConnected: desktopConnected, + target: target, + statusVisibility: statusVisibility, + inputSource: inputSource + ) + } + + func testOnlyCaptureStatesCanCaptureWhenEveryGateIsReady() { + let gate = SafetyGate(secureEventInputEnabled: { false }) + let states: [(NativeSessionState, SafetyDecision)] = [ + (.disabled, .idle), + (.ready, .idle), + (.arming, .idle), + (.starting, .captureAllowed), + (.listening, .captureAllowed), + (.transcribing, .captureAllowed), + (.paused, .idle), + (.readyToSend, .idle), + (.blocked, .idle), + (.cancelled, .idle), + (.error, .idle), + ] + + for (state, expected) in states { + XCTAssertEqual( + gate.evaluate( + state: state, + context: context(), + hasOwnedPartial: false + ), + expected, + "unexpected decision for \(state)" + ) + } + } + + func testEveryMissingPrerequisiteBlocksCapture() { + let gate = SafetyGate(secureEventInputEnabled: { false }) + let cases: [(SafetyContext, SafetyBlockReason)] = [ + (context(featureEnabled: false), .featureDisabled), + (context(desktopConnected: false), .desktopDisconnected), + (context(target: .unavailable), .targetUnavailable), + (context(target: .unknown), .targetUnknown), + (context(statusVisibility: .unavailable), .statusHidden), + (context(statusVisibility: .unknown), .statusUnknown), + (context(inputSource: .unavailable), .inputSourceInactive), + (context(inputSource: .unknown), .inputSourceUnknown), + ] + + for (context, reason) in cases { + XCTAssertEqual( + gate.evaluate( + state: .listening, + context: context, + hasOwnedPartial: false + ), + .blocked(reason: reason, removeOwnedPartial: true) + ) + } + } + + func testSecureEventInputAlwaysFailsClosed() { + let gate = SafetyGate(secureEventInputEnabled: { true }) + + XCTAssertEqual( + gate.evaluate( + state: .starting, + context: context(), + hasOwnedPartial: false + ), + .blocked( + reason: .secureEventInput, + removeOwnedPartial: true + ) + ) + XCTAssertEqual( + gate.evaluate( + state: .paused, + context: context(), + hasOwnedPartial: true + ), + .blocked( + reason: .secureEventInput, + removeOwnedPartial: true + ) + ) + } + + func testInactiveSessionStillRemovesAnOwnedPartialWhenAGateFails() { + let gate = SafetyGate(secureEventInputEnabled: { false }) + + XCTAssertEqual( + gate.evaluate( + state: .paused, + context: context(desktopConnected: false), + hasOwnedPartial: true + ), + .blocked( + reason: .desktopDisconnected, + removeOwnedPartial: true + ) + ) + XCTAssertEqual( + gate.evaluate( + state: .ready, + context: context(desktopConnected: false), + hasOwnedPartial: false + ), + .blocked( + reason: .desktopDisconnected, + removeOwnedPartial: false + ) + ) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/SecureRuntimeDirectoryTests.swift b/desktop/native/Tests/QwenInputCoreTests/SecureRuntimeDirectoryTests.swift new file mode 100644 index 00000000..03b860f9 --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/SecureRuntimeDirectoryTests.swift @@ -0,0 +1,96 @@ +import Darwin +import Foundation +import XCTest +@testable import QwenInputCore + +final class SecureRuntimeDirectoryTests: XCTestCase { + private var temporaryRoot: URL! + + override func setUpWithError() throws { + temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("qwen-runtime-\(UUID().uuidString)") + } + + override func tearDownWithError() throws { + if let temporaryRoot { + try? FileManager.default.removeItem(at: temporaryRoot) + try? FileManager.default.removeItem( + at: temporaryRoot.appendingPathExtension("real") + ) + } + } + + func testCreatesOnlyA0700RuntimeDirectory() throws { + let directory = SecureRuntimeDirectory(url: temporaryRoot) + + try directory.prepare() + + XCTAssertEqual(try mode(of: temporaryRoot), 0o700) + XCTAssertEqual(try owner(of: temporaryRoot), geteuid()) + XCTAssertEqual( + directory.socketURL().lastPathComponent, + "control.sock" + ) + } + + func testProductionSocketPathFitsDarwinLimitAtMaximumUserID() { + let maximumDarwinSocketPathBytes = 103 + let longestSupportedTemporaryRoot = URL( + fileURLWithPath: "/var/folders/zz/123456789012345678901234567890123456789012345678/T" + ) + let directory = NativeRuntimePeer.runtimeDirectory( + temporaryDirectory: longestSupportedTemporaryRoot, + userID: uid_t.max + ) + + XCTAssertLessThanOrEqual( + directory.socketURL().path.utf8.count, + maximumDarwinSocketPathBytes + ) + XCTAssertEqual( + directory.url.lastPathComponent, + "qwen-ni-\(uid_t.max)" + ) + } + + func testRejectsSymlinkOrWrongModeRuntimeDirectory() throws { + let real = temporaryRoot.appendingPathExtension("real") + try FileManager.default.createDirectory( + at: real, + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + at: temporaryRoot, + withDestinationURL: real + ) + XCTAssertThrowsError(try SecureRuntimeDirectory( + url: temporaryRoot + ).prepare()) { error in + XCTAssertEqual(error as? SecureRuntimeDirectoryError, .symbolicLink) + } + + try FileManager.default.removeItem(at: temporaryRoot) + try FileManager.default.createDirectory( + at: temporaryRoot, + withIntermediateDirectories: true + ) + XCTAssertEqual(chmod(temporaryRoot.path, 0o755), 0) + XCTAssertThrowsError(try SecureRuntimeDirectory( + url: temporaryRoot + ).prepare()) { error in + XCTAssertEqual(error as? SecureRuntimeDirectoryError, .unsafeMode) + } + } + + private func mode(of url: URL) throws -> mode_t { + var value = stat() + guard lstat(url.path, &value) == 0 else { throw POSIXError(.ENOENT) } + return value.st_mode & 0o777 + } + + private func owner(of url: URL) throws -> uid_t { + var value = stat() + guard lstat(url.path, &value) == 0 else { throw POSIXError(.ENOENT) } + return value.st_uid + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/SessionLedgerTests.swift b/desktop/native/Tests/QwenInputCoreTests/SessionLedgerTests.swift new file mode 100644 index 00000000..54dc5c1f --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/SessionLedgerTests.swift @@ -0,0 +1,290 @@ +import Foundation +import XCTest +@testable import QwenInputCore + +final class SessionLedgerTests: XCTestCase { + private let sessionID = UUID(uuidString: "70000000-0000-0000-0000-000000000007")! + private let targetID = UUID(uuidString: "80000000-0000-0000-0000-000000000008")! + + private func ledger() -> SessionLedger { + SessionLedger(sessionID: sessionID, generation: 3, targetID: targetID) + } + + private func range(_ location: Int, _ length: Int) -> NSRange { + NSRange(location: location, length: length) + } + + func testPartialAndFinalUseUTF16Ranges() throws { + var ledger = ledger() + + XCTAssertEqual( + try ledger.partial( + text: "A😀B", + selectedRange: range(4, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: targetID + ).get(), + .setMarked( + text: "A😀B", + selection: range(4, 0), + replacement: range(NSNotFound, 0) + ) + ) + XCTAssertEqual(ledger.ownedMarkedRange, range(4, 4)) + + XCTAssertEqual( + try ledger.final( + text: "A😀B", + selectedRange: range(8, 0), + clientMarkedRange: range(4, 4), + generation: 3, + targetID: targetID + ).get(), + .commitMarked(text: "A😀B", replacement: range(4, 4)) + ) + XCTAssertNil(ledger.ownedMarkedRange) + XCTAssertEqual(ledger.latestOwnedFinalRange, range(4, 4)) + XCTAssertEqual(ledger.latestOwnedFinalText, "A😀B") + } + + func testPartialCanReplaceOnlyTheOwnedMarkedRange() throws { + var ledger = ledger() + _ = try ledger.partial( + text: "hello", + selectedRange: range(2, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: targetID + ).get() + + XCTAssertEqual( + try ledger.partial( + text: "hi", + selectedRange: range(7, 0), + clientMarkedRange: range(2, 5), + generation: 3, + targetID: targetID + ).get(), + .setMarked( + text: "hi", + selection: range(2, 0), + replacement: range(2, 5) + ) + ) + XCTAssertEqual(ledger.ownedMarkedRange, range(2, 2)) + + XCTAssertThrowsError(try ledger.partial( + text: "unsafe", + selectedRange: range(4, 0), + clientMarkedRange: range(0, 2), + generation: 3, + targetID: targetID + ).get()) { error in + XCTAssertEqual(error as? LedgerError, .markedRangeMismatch) + } + XCTAssertEqual(ledger.ownedMarkedRange, range(2, 2)) + } + + func testFirstPartialRejectsWhenSelectionAndMarkedLocationAreOpaque() { + var ledger = ledger() + + XCTAssertEqual( + ledger.partial( + text: "opaque", + selectedRange: range(NSNotFound, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: targetID + ), + .failure(.unknownClientRange) + ) + XCTAssertNil(ledger.ownedMarkedRange) + XCTAssertNil(ledger.latestOwnedFinalRange) + XCTAssertNil(ledger.latestOwnedFinalText) + } + + func testFirstPartialUsesKnownEmptyMarkedLocationWhenSelectionIsOpaque() throws { + var ledger = ledger() + + XCTAssertEqual( + try ledger.partial( + text: "known", + selectedRange: range(NSNotFound, 0), + clientMarkedRange: range(8, 0), + generation: 3, + targetID: targetID + ).get(), + .setMarked( + text: "known", + selection: range(5, 0), + replacement: range(8, 0) + ) + ) + XCTAssertEqual(ledger.ownedMarkedRange, range(8, 5)) + } + + func testOpaqueOwnedFinalAndCancelFailClosedWithoutMutatingLedger() { + let opaque = range(NSNotFound, 6) + var finalLedger = ledger() + finalLedger.ownedMarkedRange = opaque + + XCTAssertEqual( + finalLedger.final( + text: "final", + selectedRange: range(NSNotFound, 0), + clientMarkedRange: opaque, + generation: 3, + targetID: targetID + ), + .failure(.unknownClientRange) + ) + XCTAssertEqual(finalLedger.ownedMarkedRange, opaque) + XCTAssertNil(finalLedger.latestOwnedFinalRange) + XCTAssertNil(finalLedger.latestOwnedFinalText) + + var cancelLedger = ledger() + cancelLedger.ownedMarkedRange = opaque + XCTAssertEqual( + cancelLedger.cancel( + clientMarkedRange: opaque, + generation: 3, + targetID: targetID + ), + .failure(.unknownClientRange) + ) + XCTAssertEqual(cancelLedger.ownedMarkedRange, opaque) + } + + func testReplaceAndDeleteStayInsideOneLatestOwnedFinalMatch() throws { + var ledger = ledger() + _ = try ledger.final( + text: "hello world", + selectedRange: range(10, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: targetID + ).get() + + XCTAssertEqual( + try ledger.edit( + .replace(target: "world", replacement: "earth"), + generation: 3, + targetID: targetID + ).get(), + .insert(text: "earth", replacement: range(16, 5)) + ) + XCTAssertEqual(ledger.latestOwnedFinalText, "hello earth") + XCTAssertEqual(ledger.latestOwnedFinalRange, range(10, 11)) + + XCTAssertEqual( + try ledger.edit( + .delete(target: "hello "), + generation: 3, + targetID: targetID + ).get(), + .insert(text: "", replacement: range(10, 6)) + ) + XCTAssertEqual(ledger.latestOwnedFinalText, "earth") + XCTAssertEqual(ledger.latestOwnedFinalRange, range(10, 5)) + } + + func testAmbiguousAbsentAndUnknownRangesDoNotMutateTheLedger() throws { + var activeLedger = ledger() + _ = try activeLedger.final( + text: "x x", + selectedRange: range(5, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: targetID + ).get() + + XCTAssertThrowsError(try activeLedger.edit( + .replace(target: "x", replacement: "y"), + generation: 3, + targetID: targetID + ).get()) { error in + XCTAssertEqual(error as? LedgerError, .ambiguousEditTarget) + } + XCTAssertThrowsError(try activeLedger.edit( + .delete(target: "z"), + generation: 3, + targetID: targetID + ).get()) { error in + XCTAssertEqual(error as? LedgerError, .editTargetNotFound) + } + XCTAssertEqual(activeLedger.latestOwnedFinalText, "x x") + + var unknown = ledger() + XCTAssertThrowsError(try unknown.final( + text: "never inserted", + selectedRange: range(NSNotFound, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: targetID + ).get()) { error in + XCTAssertEqual(error as? LedgerError, .unknownClientRange) + } + XCTAssertNil(unknown.latestOwnedFinalRange) + } + + func testStaleGenerationOrTargetCannotProduceAnEffect() throws { + let staleTarget = UUID(uuidString: "90000000-0000-0000-0000-000000000009")! + var ledger = ledger() + + XCTAssertThrowsError(try ledger.partial( + text: "stale", + selectedRange: range(0, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 2, + targetID: targetID + ).get()) { error in + XCTAssertEqual(error as? LedgerError, .staleGeneration) + } + XCTAssertThrowsError(try ledger.partial( + text: "foreign", + selectedRange: range(0, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: staleTarget + ).get()) { error in + XCTAssertEqual(error as? LedgerError, .targetMismatch) + } + XCTAssertNil(ledger.ownedMarkedRange) + } + + func testRetargetInvalidatesPendingStateAndCancelNeverRollsBackFinalText() throws { + let nextTarget = UUID(uuidString: "A0000000-0000-0000-0000-00000000000A")! + var ledger = ledger() + _ = try ledger.final( + text: "committed", + selectedRange: range(0, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: targetID + ).get() + _ = try ledger.partial( + text: "temporary", + selectedRange: range(9, 0), + clientMarkedRange: range(NSNotFound, 0), + generation: 3, + targetID: targetID + ).get() + + XCTAssertEqual( + ledger.cancel( + clientMarkedRange: range(9, 9), + generation: 3, + targetID: targetID + ), + .success(.removeMarked(replacement: range(9, 9))) + ) + XCTAssertEqual(ledger.latestOwnedFinalText, "committed") + XCTAssertEqual(ledger.latestOwnedFinalRange, range(0, 9)) + + ledger.retarget(generation: 4, targetID: nextTarget) + XCTAssertNil(ledger.ownedMarkedRange) + XCTAssertNil(ledger.latestOwnedFinalRange) + XCTAssertNil(ledger.latestOwnedFinalText) + } +} diff --git a/desktop/native/Tests/QwenInputCoreTests/TextClientAdapterTests.swift b/desktop/native/Tests/QwenInputCoreTests/TextClientAdapterTests.swift new file mode 100644 index 00000000..b28ddf7a --- /dev/null +++ b/desktop/native/Tests/QwenInputCoreTests/TextClientAdapterTests.swift @@ -0,0 +1,466 @@ +import Foundation +import XCTest +@testable import QwenInputCore + +final class TextClientAdapterTests: XCTestCase { + private final class FakeClient: NativeTextClient { + enum Call: Equatable { + case setMarked(String, selection: NSRange, replacement: NSRange) + case insert(String, replacement: NSRange) + } + + private var selectedRanges: [NSRange] + private var markedRanges: [NSRange] + private(set) var calls: [Call] = [] + + var selectedRange: NSRange { + if selectedRanges.count > 1 { + return selectedRanges.removeFirst() + } + return selectedRanges[0] + } + + var markedRange: NSRange { + if markedRanges.count > 1 { + return markedRanges.removeFirst() + } + return markedRanges[0] + } + + init( + selectedRange: NSRange = NSRange(location: 3, length: 0), + markedRange: NSRange = NSRange(location: NSNotFound, length: 0) + ) { + self.selectedRanges = [selectedRange] + self.markedRanges = [markedRange] + } + + init(selectedRanges: [NSRange], markedRanges: [NSRange]) { + precondition(!selectedRanges.isEmpty) + precondition(!markedRanges.isEmpty) + self.selectedRanges = selectedRanges + self.markedRanges = markedRanges + } + + func setMarkedText( + _ text: NSAttributedString, + selectionRange: NSRange, + replacementRange: NSRange + ) { + calls.append(.setMarked( + text.string, + selection: selectionRange, + replacement: replacementRange + )) + } + + func insertText(_ text: Any, replacementRange: NSRange) { + calls.append(.insert(String(describing: text), replacement: replacementRange)) + } + } + + func testPartialUsesMarkedTextWithExactRanges() throws { + let client = FakeClient() + let effect = ClientTextEffect.setMarked( + text: "A😀B", + selection: NSRange(location: 4, length: 0), + replacement: NSRange(location: NSNotFound, length: 0) + ) + + _ = try TextClientAdapter().apply(effect, to: client).get() + + XCTAssertEqual(client.calls, [ + .setMarked( + "A😀B", + selection: NSRange(location: 4, length: 0), + replacement: NSRange(location: NSNotFound, length: 0) + ), + ]) + } + + func testFinalUsesOwnedRangeAndCancelRemovesCurrentComposition() throws { + let marked = NSRange(location: 8, length: 5) + let client = FakeClient( + selectedRange: NSRange(location: 13, length: 0), + markedRange: marked + ) + let adapter = TextClientAdapter() + + _ = try adapter.apply( + .commitMarked(text: "hello", replacement: marked), + to: client + ).get() + _ = try adapter.apply( + .removeMarked(replacement: marked), + to: client + ).get() + + XCTAssertEqual(client.calls, [ + .insert("hello", replacement: marked), + .setMarked( + "", + selection: NSRange(location: 0, length: 0), + replacement: marked + ), + .insert( + "", + replacement: NSRange(location: NSNotFound, length: NSNotFound) + ), + ]) + } + + func testOpaqueEffectsFailWithoutCallingTheClient() { + let partialClient = FakeClient( + selectedRange: NSRange(location: NSNotFound, length: 0), + markedRange: NSRange(location: NSNotFound, length: 0) + ) + XCTAssertEqual( + TextClientAdapter().apply( + .setMarked( + text: "opaque", + selection: NSRange(location: 6, length: 0), + replacement: NSRange(location: NSNotFound, length: 0) + ), + to: partialClient + ), + .failure(.unknownSelectedRange) + ) + XCTAssertTrue(partialClient.calls.isEmpty) + + let finalClient = FakeClient( + selectedRange: NSRange(location: NSNotFound, length: 0), + markedRange: NSRange(location: NSNotFound, length: 6) + ) + XCTAssertEqual( + TextClientAdapter().apply(.insert( + text: "final", + replacement: NSRange(location: NSNotFound, length: 6) + ), to: finalClient), + .failure(.unknownSelectedRange) + ) + XCTAssertTrue(finalClient.calls.isEmpty) + } + + func testControllerRejectsOpaquePartialFinalAndCancelWithoutCallingClient() { + let sessionID = UUID() + let targetID = UUID() + var partialLedger = SessionLedger( + sessionID: sessionID, + generation: 3, + targetID: targetID + ) + let partialClient = FakeClient( + selectedRange: NSRange(location: NSNotFound, length: 0), + markedRange: NSRange(location: NSNotFound, length: 0) + ) + let partialResult = partialLedger.partial( + text: "opaque", + selectedRange: partialClient.selectedRange, + clientMarkedRange: partialClient.markedRange, + generation: 3, + targetID: targetID + ) + + XCTAssertFalse( + ClientTextOperationController().apply(partialResult, to: partialClient) + ) + XCTAssertTrue(partialClient.calls.isEmpty) + + let opaque = NSRange(location: NSNotFound, length: 6) + var finalLedger = SessionLedger( + sessionID: sessionID, + generation: 3, + targetID: targetID + ) + finalLedger.ownedMarkedRange = opaque + let finalClient = FakeClient( + selectedRange: NSRange(location: NSNotFound, length: 0), + markedRange: opaque + ) + let finalResult = finalLedger.final( + text: "final", + selectedRange: finalClient.selectedRange, + clientMarkedRange: finalClient.markedRange, + generation: 3, + targetID: targetID + ) + + XCTAssertFalse( + ClientTextOperationController().apply(finalResult, to: finalClient) + ) + XCTAssertTrue(finalClient.calls.isEmpty) + + var cancelLedger = SessionLedger( + sessionID: UUID(), + generation: 3, + targetID: targetID + ) + cancelLedger.ownedMarkedRange = opaque + let cancelClient = FakeClient( + selectedRange: NSRange(location: NSNotFound, length: 0), + markedRange: opaque + ) + let cancelResult = cancelLedger.cancel( + clientMarkedRange: cancelClient.markedRange, + generation: 3, + targetID: targetID + ) + + XCTAssertFalse( + ClientTextOperationController().apply(cancelResult, to: cancelClient) + ) + XCTAssertTrue(cancelClient.calls.isEmpty) + } + + func testControllerRejectsOwnedFinalWhenRangeBecomesOpaqueBeforeInsert() throws { + let targetID = UUID() + let ownedRange = NSRange(location: 8, length: 5) + var ledger = SessionLedger( + sessionID: UUID(), + generation: 3, + targetID: targetID + ) + _ = try ledger.partial( + text: "draft", + selectedRange: NSRange(location: 8, length: 0), + clientMarkedRange: NSRange(location: NSNotFound, length: 0), + generation: 3, + targetID: targetID + ).get() + let client = FakeClient( + selectedRanges: [NSRange(location: 13, length: 0)], + markedRanges: [ + ownedRange, + NSRange(location: NSNotFound, length: 0), + ] + ) + + let applied = ClientTextOperationController().applyTransaction( + to: &ledger, + client: client, + operation: { candidate in + candidate.final( + text: "final", + selectedRange: client.selectedRange, + clientMarkedRange: client.markedRange, + generation: 3, + targetID: targetID + ) + } + ) + XCTAssertFalse(applied) + + XCTAssertTrue(client.calls.isEmpty) + XCTAssertEqual(ledger.ownedMarkedRange, ownedRange) + XCTAssertNil(ledger.latestOwnedFinalRange) + XCTAssertNil(ledger.latestOwnedFinalText) + } + + func testControllerKeepsOwnedRangeWhenCleanupBecomesOpaque() throws { + let targetID = UUID() + let ownedRange = NSRange(location: 8, length: 5) + var ledger = SessionLedger( + sessionID: UUID(), + generation: 3, + targetID: targetID + ) + _ = try ledger.partial( + text: "draft", + selectedRange: NSRange(location: 8, length: 0), + clientMarkedRange: NSRange(location: NSNotFound, length: 0), + generation: 3, + targetID: targetID + ).get() + let client = FakeClient( + selectedRanges: [NSRange(location: 13, length: 0)], + markedRanges: [ + ownedRange, + NSRange(location: NSNotFound, length: 0), + ] + ) + + let applied = ClientTextOperationController().applyTransaction( + to: &ledger, + client: client, + operation: { candidate in + candidate.cancel( + clientMarkedRange: client.markedRange, + generation: 3, + targetID: targetID + ) + } + ) + XCTAssertFalse(applied) + + XCTAssertTrue(client.calls.isEmpty) + XCTAssertEqual(ledger.ownedMarkedRange, ownedRange) + } + + func testControllerCommitsStableOwnedFinalAndAllowsOrdinaryEdit() throws { + let targetID = UUID() + let ownedRange = NSRange(location: 8, length: 5) + var ledger = SessionLedger( + sessionID: UUID(), + generation: 3, + targetID: targetID + ) + _ = try ledger.partial( + text: "draft", + selectedRange: NSRange(location: 8, length: 0), + clientMarkedRange: NSRange(location: NSNotFound, length: 0), + generation: 3, + targetID: targetID + ).get() + let finalClient = FakeClient( + selectedRanges: [NSRange(location: 13, length: 0)], + markedRanges: [ownedRange, ownedRange] + ) + + XCTAssertTrue( + ClientTextOperationController().applyTransaction( + to: &ledger, + client: finalClient, + operation: { candidate in + candidate.final( + text: "final", + selectedRange: finalClient.selectedRange, + clientMarkedRange: finalClient.markedRange, + generation: 3, + targetID: targetID + ) + } + ) + ) + XCTAssertEqual(finalClient.calls, [ + .insert("final", replacement: ownedRange), + ]) + XCTAssertNil(ledger.ownedMarkedRange) + XCTAssertEqual(ledger.latestOwnedFinalText, "final") + + let editClient = FakeClient( + selectedRange: NSRange(location: NSNotFound, length: 0), + markedRange: NSRange(location: NSNotFound, length: 0) + ) + XCTAssertTrue( + ClientTextOperationController().applyTransaction( + to: &ledger, + client: editClient, + operation: { candidate in + candidate.edit( + .replace(target: "final", replacement: "edited"), + generation: 3, + targetID: targetID + ) + } + ) + ) + XCTAssertEqual(editClient.calls, [ + .insert("edited", replacement: ownedRange), + ]) + XCTAssertEqual(ledger.latestOwnedFinalText, "edited") + } + + func testControllerCommitsStandaloneFinalAtStableSelection() { + let targetID = UUID() + var ledger = SessionLedger( + sessionID: UUID(), + generation: 3, + targetID: targetID + ) + let client = FakeClient( + selectedRanges: [ + NSRange(location: 10, length: 0), + NSRange(location: 10, length: 0), + ], + markedRanges: [NSRange(location: NSNotFound, length: 0)] + ) + + XCTAssertTrue( + ClientTextOperationController().applyTransaction( + to: &ledger, + client: client, + operation: { candidate in + candidate.final( + text: "standalone", + selectedRange: client.selectedRange, + clientMarkedRange: client.markedRange, + generation: 3, + targetID: targetID + ) + } + ) + ) + XCTAssertEqual(client.calls, [ + .insert( + "standalone", + replacement: NSRange( + location: NSNotFound, + length: NSNotFound + ) + ), + ]) + XCTAssertEqual(ledger.latestOwnedFinalText, "standalone") + XCTAssertEqual( + ledger.latestOwnedFinalRange, + NSRange(location: 10, length: 10) + ) + } + + func testPartialReusesAnEmptyCompositionLeftByTheClient() throws { + let emptyComposition = NSRange(location: 8, length: 0) + let client = FakeClient( + selectedRange: NSRange(location: NSNotFound, length: 0), + markedRange: emptyComposition + ) + + _ = try TextClientAdapter().apply( + .setMarked( + text: "next", + selection: NSRange(location: 4, length: 0), + replacement: NSRange(location: NSNotFound, length: 0) + ), + to: client + ).get() + + XCTAssertEqual(client.calls, [ + .setMarked( + "next", + selection: NSRange(location: 4, length: 0), + replacement: emptyComposition + ), + ]) + } + + func testMismatchedClientRangesFailWithoutCallingClient() { + + let foreignMarked = FakeClient( + selectedRange: NSRange(location: 9, length: 0), + markedRange: NSRange(location: 2, length: 3) + ) + XCTAssertEqual( + TextClientAdapter().apply( + .removeMarked(replacement: NSRange(location: 4, length: 3)), + to: foreignMarked + ), + .failure(.markedRangeMismatch) + ) + XCTAssertTrue(foreignMarked.calls.isEmpty) + } + + func testNoOpDoesNotTouchTheClient() throws { + let client = FakeClient() + + _ = try TextClientAdapter().apply(.none, to: client).get() + + XCTAssertTrue(client.calls.isEmpty) + } + + func testPhysicalKeyboardEventsAreNeverConsumed() { + for state in NativeSessionState.allCases { + XCTAssertFalse( + InputEventPolicy.shouldConsumePhysicalKey(in: state), + "physical key was consumed in \(state)" + ) + } + } +} diff --git a/desktop/native/project.yml b/desktop/native/project.yml new file mode 100644 index 00000000..10eabfd0 --- /dev/null +++ b/desktop/native/project.yml @@ -0,0 +1,141 @@ +name: QwenInput + +options: + minimumXcodeGenVersion: 2.44.0 + deploymentTarget: + macOS: "13.0" + createIntermediateGroups: true + groupSortPosition: top + +settings: + base: + MACOSX_DEPLOYMENT_TARGET: "13.0" + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + CLANG_ENABLE_MODULES: YES + CODE_SIGN_STYLE: Manual + DEVELOPMENT_TEAM: "" + +targets: + QwenInputCore: + type: library.static + platform: macOS + sources: + - path: Sources/QwenInputCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.qwenaudio.agent.inputcore + PRODUCT_MODULE_NAME: QwenInputCore + dependencies: + - sdk: Security.framework + + QwenInputBridge: + type: tool + platform: macOS + sources: + - path: Sources/QwenInputBridge + dependencies: + - target: QwenInputCore + - sdk: Security.framework + - sdk: Carbon.framework + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.qwenaudio.agent.inputbridge + PRODUCT_NAME: QwenInputBridge + CODE_SIGN_IDENTITY: "-" + CREATE_INFOPLIST_SECTION_IN_BINARY: YES + GENERATE_INFOPLIST_FILE: YES + + QwenInput: + type: application + platform: macOS + sources: + - path: Sources/QwenInput + dependencies: + - target: QwenInputCore + - sdk: Security.framework + info: + path: Resources/QwenInput-Info.plist + properties: + CFBundleDisplayName: Qwen Input + CFBundleName: Qwen Input + CFBundleIdentifier: ai.qwenaudio.agent.inputmethod + CFBundleExecutable: Qwen Input + CFBundlePackageType: APPL + CFBundleShortVersionString: "1.11.0" + CFBundleVersion: "1" + LSBackgroundOnly: true + LSMinimumSystemVersion: "13.0" + InputMethodConnectionName: ai.qwenaudio.agent.inputmethod.connection + InputMethodServerControllerClass: QwenInputController + InputMethodType: Palette + TISInputSourceID: ai.qwenaudio.agent.inputmethod + TISIntendedLanguage: en + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.qwenaudio.agent.inputmethod + PRODUCT_MODULE_NAME: QwenInput + PRODUCT_NAME: Qwen Input + CODE_SIGN_IDENTITY: "-" + CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO + GENERATE_INFOPLIST_FILE: NO + REGISTER_WITH_LAUNCH_SERVICES: NO + + QwenInputCoreTests: + type: bundle.unit-test + platform: macOS + sources: + - path: Tests/QwenInputCoreTests + dependencies: + - target: QwenInputCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.qwenaudio.agent.inputcore-tests + CODE_SIGN_IDENTITY: "-" + GENERATE_INFOPLIST_FILE: YES + # Xcode 26's bundled XCTest runtime itself has a macOS 14 floor. The + # production library, Bridge and input method remain macOS 13. + MACOSX_DEPLOYMENT_TARGET: "14.0" + + QwenInputBridgeTests: + type: bundle.unit-test + platform: macOS + sources: + - path: Tests/QwenInputBridgeTests + - path: Sources/QwenInputBridge/BridgeRuntime.swift + - path: Sources/QwenInputBridge/SystemInputMethodLifecycle.swift + - path: Sources/QwenInputBridge/SystemInputSourceAPI.swift + dependencies: + - target: QwenInputCore + - sdk: Security.framework + - sdk: Carbon.framework + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.qwenaudio.agent.inputbridge-tests + CODE_SIGN_IDENTITY: "-" + GENERATE_INFOPLIST_FILE: YES + MACOSX_DEPLOYMENT_TARGET: "14.0" + +schemes: + QwenInputBridge: + build: + targets: + QwenInputBridge: all + QwenInput: + build: + targets: + QwenInput: all + QwenInputCoreTests: + build: + targets: + QwenInputCoreTests: all + test: + targets: + - QwenInputCoreTests + QwenInputBridgeTests: + build: + targets: + QwenInputBridgeTests: all + test: + targets: + - QwenInputBridgeTests diff --git a/desktop/package.json b/desktop/package.json index 6356256a..87a59ed8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "main": "src/main.mjs", "scripts": { "start": "electron .", - "test": "node --test" + "test": "node ../scripts/test-desktop.mjs" }, "devDependencies": { "electron": "^43.2.0" diff --git a/desktop/smoke/native-input-settings-smoke.cjs b/desktop/smoke/native-input-settings-smoke.cjs new file mode 100644 index 00000000..7f12916c --- /dev/null +++ b/desktop/smoke/native-input-settings-smoke.cjs @@ -0,0 +1,134 @@ +const { app, BrowserWindow, ipcMain } = require('electron') +const { resolve } = require('node:path') + +const root = resolve(__dirname, '../..') +const lifecycle = { + installed: true, + registered: true, + enabled: false, + version: '1.11.0', + state: 'needs-enable', +} +const runtime = { + gatewayConnected: false, + backend: null, + realtimeProvider: 'dashscope', + realtimeModel: 'qwen3-omni-flash-realtime', + voiceConfigured: false, +} +let lifecycleStatusCalls = 0 +let openSettingsRequests = 0 + +app.commandLine.appendSwitch('lang', 'en-US') + +function waitFor(probe, timeoutMs = 5_000, describe = () => '') { + const started = Date.now() + return new Promise((resolveWait, rejectWait) => { + const check = async () => { + try { + const value = await probe() + if (value) return resolveWait(value) + } catch { + // The renderer can be between navigation and module initialization. + } + if (Date.now() - started >= timeoutMs) { + Promise.resolve(describe()).then(description => rejectWait(new Error( + `Timed out waiting for native input settings UI: ${description}`, + ))) + return + } + setTimeout(check, 20) + } + void check() + }) +} + +app.whenReady().then(async () => { + const { parseSettings } = await import( + resolve(root, 'desktop/src/settings-config.mjs') + ) + ipcMain.handle('qwen-audio-agent:settings-load', () => ({ + settings: parseSettings('AGENT_PROTOCOL=none\n'), + skins: [{ id: 'fluid', type: 'theme', displayName: 'Fluid' }], + runtime, + wakeShortcutRegistered: true, + })) + ipcMain.handle('qwen-audio-agent:settings-runtime-status', () => runtime) + ipcMain.handle('qwen-audio-agent:settings-detect-backends', () => ({ + backends: [], + })) + ipcMain.handle('qwen-audio-agent:updater-status', () => ({ + phase: 'idle', + currentVersion: '1.11.0', + })) + ipcMain.handle('qwen-audio-agent:native-input-lifecycle', (_event, action) => { + if (action !== 'status') throw new Error('Unexpected lifecycle action') + lifecycleStatusCalls += 1 + return { ...lifecycle } + }) + ipcMain.on('qwen-audio-agent:native-input-open-settings', () => { + openSettingsRequests += 1 + }) + + const window = new BrowserWindow({ + width: 600, + height: 800, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + preload: resolve(root, 'desktop/src/preload.cjs'), + }, + }) + await window.loadFile(resolve(root, 'desktop/src/settings.html')) + const readNativeInputControls = () => window.webContents.executeJavaScript(`({ + language: document.documentElement.lang, + status: document.querySelector('#native-input-status')?.textContent, + buttonText: document.querySelector('#native-input-system-settings')?.textContent, + buttonVisible: !document.querySelector('#native-input-system-settings')?.hidden, + })`) + const initial = await waitFor(async () => { + const value = await readNativeInputControls() + return value.status === 'Installed. Enable Qwen Input in System Settings.' + ? value + : null + }, 5_000, async () => JSON.stringify({ + controls: await readNativeInputControls(), + lifecycleStatusCalls, + })) + + await window.webContents.executeJavaScript( + "document.querySelector('#native-input-system-settings').click()", + ) + await waitFor(() => openSettingsRequests === 1) + + const callsBeforeFocus = lifecycleStatusCalls + Object.assign(lifecycle, { enabled: true, state: 'ready' }) + await window.webContents.executeJavaScript( + "window.dispatchEvent(new Event('focus'))", + ) + const refreshedStatus = await waitFor(async () => { + const text = await window.webContents.executeJavaScript( + "document.querySelector('#native-input-status')?.textContent", + ) + return text === 'Installed and enabled. Select Qwen Input from the input menu.' + ? text + : null + }) + + process.stdout.write(`NATIVE_INPUT_SETTINGS_PROBE:${JSON.stringify({ + ...initial, + openSettingsRequests, + callsBeforeFocus, + callsAfterFocus: lifecycleStatusCalls, + refreshedStatus, + })}\n`) + window.destroy() + app.quit() +}).catch(error => { + process.stderr.write(`${error?.stack || error}\n`) + app.exit(1) +}) + +app.on('window-all-closed', () => {}) diff --git a/desktop/src/i18n.mjs b/desktop/src/i18n.mjs index 52b9a713..6335e42c 100644 --- a/desktop/src/i18n.mjs +++ b/desktop/src/i18n.mjs @@ -57,6 +57,21 @@ const ENGLISH = { '英文': 'English', '日志': 'Logs', '在 Finder 中显示': 'Show in Finder', + '随处输入': 'Input anywhere', + '管理 Qwen Input': 'Manage Qwen Input', + '正在检查输入法…': 'Checking input method…', + '已安装并启用;请从输入菜单选择 Qwen Input': 'Installed and enabled. Select Qwen Input from the input menu.', + '已安装,请在系统设置中启用 Qwen Input': 'Installed. Enable Qwen Input in System Settings.', + '安装不完整,需要修复': 'Installation needs repair', + '尚未安装': 'Not installed', + '输入法状态不可用': 'Input method status unavailable', + '修复': 'Repair', + '卸载': 'Uninstall', + '打开输入源设置': 'Open Input Source Settings', + '输入法操作失败': 'Input method operation failed', + '继续': 'Continue', + '将停用并移除当前用户的 Qwen Input。': 'Qwen Input will be disabled and removed for the current user.', + '将为当前用户安装并注册 Qwen Input;系统不会自动启用或切换输入法。': 'Qwen Input will be installed and registered for the current user. It will not be enabled or selected automatically.', '应用设置': 'Apply settings', '正在安装…': 'Installing…', '当前不可用': 'Unavailable', diff --git a/desktop/src/main.mjs b/desktop/src/main.mjs index 3cf01e7e..84c06787 100644 --- a/desktop/src/main.mjs +++ b/desktop/src/main.mjs @@ -105,6 +105,15 @@ import { } from './updater.mjs' import { createGracefulShutdown } from './graceful-shutdown.mjs' import { DesktopPresence } from './desktop-presence.mjs' +import { + NativeInputHost, + nativeInputBridgePath, +} from './native-input-host.mjs' +import { NativeInputFeature } from './native-input-feature.mjs' +import { + NativeInputLifecycle, + startNativeInputLifecycleHost, +} from './native-input-lifecycle.mjs' // macOS / Linux 图形界面应用的 PATH 只包含系统目录。在启动最早阶段 // 将其扩充为用户登录 shell 的 PATH,让 Gateway 子进程与后台可用性 @@ -228,6 +237,34 @@ const desktopPresence = new DesktopPresence({ globalShortcut, logger, }) +const nativeInputDevelopmentTools = ( + process.defaultApp + && process.env.QWEN_AUDIO_NATIVE_INPUT_DEVTOOLS === 'true' +) +const nativeInputHost = new NativeInputHost({ + resolveArtifact: () => nativeInputBridgePath({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + }), + onEmergencyStop: reason => { + logger.error('native_input.emergency_stop', { reason }) + }, +}) +const nativeInputFeature = new NativeInputFeature({ + enabled: process.platform === 'darwin' && initialSettings.nativeInputEnabled, + accelerator: initialSettings.nativeInputShortcut, + globalShortcut, + host: nativeInputHost, + onSessionRequest: request => { + if (!mainWindow || mainWindow.isDestroyed()) return + desktopPresence.wake() + mainWindow.webContents.send( + 'qwen-audio-agent:native-input-session-request', + request, + ) + }, +}) +const nativeInputLifecycle = new NativeInputLifecycle({ host: nativeInputHost }) const MAX_GATEWAY_CRASH_RESTARTS = 3 @@ -594,6 +631,9 @@ function createWindow() { event.preventDefault() if (isSafeExternalUrl(url)) void shell.openExternal(url) }) + window.webContents.on('render-process-gone', () => { + nativeInputFeature.rendererLost() + }) window.once('ready-to-show', () => window.show()) window.on('blur', () => { orbShell.cancelDrag() @@ -806,6 +846,119 @@ ipcMain.handle('qwen-audio-agent:wake-shortcut-resume', event => { return desktopPresence.resumeShortcut() }) +function assertNativeInputDevelopmentSender(event) { + if ( + !nativeInputDevelopmentTools + || !mainWindow + || event.sender !== mainWindow.webContents + ) { + throw new Error('Native input development controls are unavailable') + } +} + +ipcMain.handle('qwen-audio-agent:native-input-status', event => { + if ( + (!mainWindow || event.sender !== mainWindow.webContents) + && (!settingsWindow || event.sender !== settingsWindow.webContents) + ) throw new Error('Native input status is unavailable') + return { + ...nativeInputFeature.snapshot(), + lifecycle: nativeInputLifecycle.snapshot(), + } +}) + +ipcMain.handle('qwen-audio-agent:native-input-operation', async (event, value) => { + if (!mainWindow || event.sender !== mainWindow.webContents) { + throw new Error('Native input operation is unavailable') + } + const type = String(value?.type || '') + if (![ + 'session.arm', 'session.partial', 'session.final', 'session.operation', + 'session.cancel', 'session.pause', 'session.resume', + ].includes(type)) throw new Error('Native input operation is not allowed') + return nativeInputHost.request({ + type, + operationId: String(value?.operationId || ''), + ...(typeof value?.text === 'string' ? { text: value.text.slice(0, 4096) } : {}), + ...(Number.isInteger(value?.revision) ? { revision: value.revision } : {}), + ...(Number.isInteger(value?.seq) ? { seq: value.seq } : {}), + ...(value?.operation ? { operation: String(value.operation) } : {}), + ...(value?.target ? { target: String(value.target) } : {}), + ...(value?.replacement ? { replacement: String(value.replacement) } : {}), + ...(value?.reason ? { reason: String(value.reason) } : {}), + statusVisible: mainWindow.isVisible(), + }) +}) + +ipcMain.handle('qwen-audio-agent:native-input-lifecycle', async (event, value) => { + if (!settingsWindow || event.sender !== settingsWindow.webContents) { + throw new Error('Native input lifecycle controls are unavailable') + } + const action = String(value || '') + if (!['status', 'install', 'repair', 'uninstall'].includes(action)) { + throw new Error('Native input lifecycle action is not allowed') + } + if (action !== 'status') { + const { response } = await dialog.showMessageBox(settingsWindow, { + type: action === 'uninstall' ? 'warning' : 'question', + buttons: [desktopText('继续'), desktopText('取消')], + defaultId: 1, + cancelId: 1, + title: desktopText('管理 Qwen Input'), + message: action === 'uninstall' + ? desktopText('将停用并移除当前用户的 Qwen Input。') + : desktopText('将为当前用户安装并注册 Qwen Input;系统不会自动启用或切换输入法。'), + }) + if (response !== 0) return { cancelled: true } + } + const startedTemporarily = await startNativeInputLifecycleHost(nativeInputHost) + try { + const status = await nativeInputLifecycle[action]() + nativeInputFeature.applyLifecycleStatus(status) + return status + } finally { + if (startedTemporarily) await nativeInputHost.stop('lifecycle_complete') + } +}) + +ipcMain.on('qwen-audio-agent:native-input-open-settings', event => { + if (!settingsWindow || event.sender !== settingsWindow.webContents) return + void shell.openExternal( + 'x-apple.systempreferences:com.apple.Keyboard-Settings.extension', + ) +}) + +ipcMain.handle('qwen-audio-agent:native-input-start', event => { + assertNativeInputDevelopmentSender(event) + return nativeInputFeature.sendOperation({ + type: 'session.arm', + statusVisible: mainWindow.isVisible(), + }) +}) + +ipcMain.handle('qwen-audio-agent:native-input-stop', event => { + assertNativeInputDevelopmentSender(event) + return nativeInputFeature.sendOperation({ type: 'session.cancel' }) +}) + +for (const [channel, type] of [ + ['qwen-audio-agent:native-input-fake-partial', 'session.partial'], + ['qwen-audio-agent:native-input-fake-final', 'session.final'], +]) { + ipcMain.handle(channel, (event, value) => { + assertNativeInputDevelopmentSender(event) + const text = String(value || '') + if (!text || text.length > 4_096) { + throw new Error('Native input test text must be 1–4096 characters') + } + return nativeInputFeature.sendOperation({ + type, + text, + statusVisible: mainWindow.isVisible(), + }) + }) +} + ipcMain.on('qwen-audio-agent:open-external', async (event, value) => { if (!settingsWindow || event.sender !== settingsWindow.webContents) return let target @@ -1367,6 +1520,15 @@ if (!app.requestSingleInstanceLock()) { accelerator: initialSettings.wakeShortcut, }) } + try { + await nativeInputFeature.initialize() + nativeInputFeature.applyLifecycleStatus( + await nativeInputLifecycle.status(), + ) + } catch (error) { + lastRuntimeError = error?.message || String(error) + logger.error('native_input.start_failed', { error }) + } desktopUpdater = createDesktopUpdater({ currentVersion: app.getVersion(), enabled: app.isPackaged, @@ -1417,6 +1579,7 @@ if (!app.requestSingleInstanceLock()) { app, cleanup: async () => { logger.info('desktop.stopping') + await nativeInputFeature.shutdown() desktopPresence.destroy() tray?.destroy() tray = null diff --git a/desktop/src/native-input-feature.mjs b/desktop/src/native-input-feature.mjs new file mode 100644 index 00000000..aa2d307b --- /dev/null +++ b/desktop/src/native-input-feature.mjs @@ -0,0 +1,120 @@ +export class NativeInputFeature { + constructor({ + enabled = false, + accelerator = 'CommandOrControl+Shift+D', + globalShortcut, + host, + onSessionRequest = () => {}, + } = {}) { + this.enabled = enabled === true + this.accelerator = accelerator + this.globalShortcut = globalShortcut + this.host = host + this.onSessionRequest = onSessionRequest + this.state = this.enabled ? 'idle' : 'disabled' + this.shortcutRegistered = false + this.initializing = null + } + + snapshot() { + return { + enabled: this.enabled, + state: this.state, + shortcutRegistered: this.shortcutRegistered, + } + } + + initialize() { + if (!this.enabled) return Promise.resolve(this.snapshot()) + if (this.state === 'ready') return Promise.resolve(this.snapshot()) + if (this.initializing) return this.initializing + this.initializing = this.startEnabled() + .finally(() => { this.initializing = null }) + return this.initializing + } + + async startEnabled() { + this.state = 'starting' + await this.host.start() + const registered = this.globalShortcut.register( + this.accelerator, + () => this.handleShortcut(), + ) + if (!registered) { + this.state = 'error' + await this.host.stop('shortcut_unavailable') + throw new Error('Native input shortcut is unavailable') + } + this.shortcutRegistered = true + this.state = 'ready' + return this.snapshot() + } + + handleShortcut() { + if (!this.enabled || this.state !== 'ready') return false + try { + this.onSessionRequest({ type: 'toggle' }) + return true + } catch { + this.unregisterShortcut() + this.state = 'error' + this.host.emergencyStop('shortcut_dispatch_failed') + return false + } + } + + sendOperation(message) { + if (!this.enabled || this.state !== 'ready') { + throw new Error('Native input is not ready') + } + return this.host.request(message) + } + + applyLifecycleStatus(status = {}) { + if (!this.enabled) return this.snapshot() + if (status.state !== 'ready') { + this.unregisterShortcut() + this.state = String(status.state || 'error') + return this.snapshot() + } + if (this.host.state !== 'ready') { + this.state = 'error' + return this.snapshot() + } + if (!this.shortcutRegistered) { + const registered = this.globalShortcut.register( + this.accelerator, + () => this.handleShortcut(), + ) + if (!registered) { + this.state = 'error' + return this.snapshot() + } + this.shortcutRegistered = true + } + this.state = 'ready' + return this.snapshot() + } + + rendererLost() { + if (!this.enabled || this.state === 'disabled') return false + this.unregisterShortcut() + this.state = 'error' + this.host.emergencyStop('renderer_lost') + return true + } + + async shutdown() { + this.unregisterShortcut() + this.enabled = false + await this.host.stop('desktop_shutdown') + this.state = 'disabled' + return this.snapshot() + } + + unregisterShortcut() { + if (!this.shortcutRegistered) return + this.globalShortcut.unregister(this.accelerator) + this.shortcutRegistered = false + } +} diff --git a/desktop/src/native-input-host.mjs b/desktop/src/native-input-host.mjs new file mode 100644 index 00000000..27974c77 --- /dev/null +++ b/desktop/src/native-input-host.mjs @@ -0,0 +1,386 @@ +import { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { randomUUID } from 'node:crypto' +import { isAbsolute, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + NativeInputFrameDecoder, + encodeNativeInputFrame, +} from './native-input-protocol.mjs' + +const SAFE_ENVIRONMENT_KEYS = Object.freeze([ + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'PATH', + 'TMPDIR', +]) + +const HOST_REQUEST_TYPES = new Set([ + 'session.arm', + 'session.partial', + 'session.final', + 'session.cancel', + 'session.pause', + 'session.resume', + 'lifecycle.status', + 'lifecycle.install', + 'lifecycle.repair', + 'lifecycle.uninstall', + 'bridge.stop', +]) + +export function nativeInputBridgePath({ + isPackaged = false, + resourcesPath = process.resourcesPath, +} = {}) { + if (isPackaged) { + return resolve(resourcesPath, 'native-input', 'QwenInputBridge') + } + return resolve( + fileURLToPath(new URL('../..', import.meta.url)), + 'dist/native-input/QwenInputBridge', + ) +} + +export function nativeInputChildEnvironment(environment = process.env) { + const result = {} + for (const key of SAFE_ENVIRONMENT_KEYS) { + const value = environment[key] + if (typeof value === 'string' && value) result[key] = value + } + return result +} + +export class NativeInputHost extends EventEmitter { + constructor({ + resolveArtifact = () => nativeInputBridgePath(), + spawnImpl = spawn, + environment = process.env, + startupTimeoutMs = 5_000, + stopTimeoutMs = 1_000, + requestTimeoutMs = 5_000, + onEmergencyStop = () => {}, + } = {}) { + super() + this.resolveArtifact = resolveArtifact + this.spawnImpl = spawnImpl + this.environment = environment + this.startupTimeoutMs = startupTimeoutMs + this.stopTimeoutMs = stopTimeoutMs + this.requestTimeoutMs = requestTimeoutMs + this.onEmergencyStop = onEmergencyStop + this.state = 'idle' + this.child = null + this.decoder = null + this.startPromise = null + this.resolveStart = null + this.rejectStart = null + this.startupTimer = null + this.exitPromise = null + this.resolveExit = null + this.childListeners = null + this.pendingRequests = new Map() + } + + start() { + if (this.state === 'ready') return Promise.resolve({ state: 'ready' }) + if (this.state === 'starting') return this.startPromise + if (this.state !== 'idle') { + return Promise.reject(new Error( + `Native input Bridge cannot start from ${this.state}`, + )) + } + + const executable = this.resolveArtifact() + if (!isAbsolute(executable)) { + return Promise.reject(new Error('Native input Bridge path must be absolute')) + } + + this.state = 'starting' + this.decoder = new NativeInputFrameDecoder() + this.startPromise = new Promise((resolveStart, rejectStart) => { + this.resolveStart = resolveStart + this.rejectStart = rejectStart + }) + + let child + try { + child = this.spawnImpl(executable, [], { + detached: false, + env: nativeInputChildEnvironment(this.environment), + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }) + } catch (error) { + this.fail('spawn_failed', error, { terminate: false }) + return this.startPromise + } + if (!child?.stdin || !child?.stdout || typeof child.kill !== 'function') { + this.fail( + 'spawn_failed', + new Error('Native input Bridge did not expose owned pipes'), + { child, terminate: true }, + ) + return this.startPromise + } + + this.child = child + this.exitPromise = new Promise(resolveExit => { + this.resolveExit = resolveExit + }) + const onData = chunk => this.handleOutput(child, chunk) + const onExit = (code, signal) => this.handleExit(child, code, signal) + const onProcessError = error => this.fail('child_error', error, { child }) + child.stdout.on('data', onData) + child.once('exit', onExit) + child.once('error', onProcessError) + this.childListeners = { child, onData, onExit, onProcessError } + + this.startupTimer = setTimeout(() => { + this.fail( + 'startup_timeout', + new Error('Native input Bridge startup timed out'), + { child }, + ) + }, this.startupTimeoutMs) + return this.startPromise + } + + send(message) { + if (this.state !== 'ready' || !this.child?.stdin?.writable) { + throw new Error('Native input Bridge is not ready') + } + if (!HOST_REQUEST_TYPES.has(message?.type) || message.type === 'bridge.stop') { + throw new Error('Native input message is not an allowed host operation') + } + this.child.stdin.write(encodeNativeInputFrame(message)) + } + + request(message) { + if (this.state !== 'ready' || !this.child?.stdin?.writable) { + return Promise.reject(new Error('Native input Bridge is not ready')) + } + const lifecycle = message?.type?.startsWith('lifecycle.') + const requestId = String( + lifecycle + ? message?.requestId || randomUUID() + : message?.operationId || randomUUID(), + ) + if (!lifecycle && !message?.type?.startsWith('session.')) { + return Promise.reject(new Error('Native input request type is not allowed')) + } + if (this.pendingRequests.has(requestId)) { + return Promise.reject(new Error('Native input requestId is already pending')) + } + const request = lifecycle + ? { ...message, requestId } + : { ...message, operationId: requestId } + const promise = new Promise((resolveRequest, rejectRequest) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(requestId) + rejectRequest(new Error('Native input Bridge request timed out')) + }, this.requestTimeoutMs) + this.pendingRequests.set(requestId, { + resolve: resolveRequest, + reject: rejectRequest, + timer, + }) + }) + try { + this.child.stdin.write(encodeNativeInputFrame(request)) + } catch (error) { + this.rejectRequest(requestId, error) + } + return promise + } + + async stop(reason = 'requested') { + const child = this.child + if (!child) { + this.clearStartup() + this.state = 'idle' + return { state: 'idle' } + } + + this.state = 'stopping' + this.rejectAllRequests(new Error('Native input Bridge stopped')) + this.clearStartup(new Error('Native input Bridge stopped before ready')) + try { + if (child.stdin?.writable) { + child.stdin.write(encodeNativeInputFrame({ + type: 'bridge.stop', + reason, + })) + } + } catch { + // A broken control pipe is equivalent to an already-stopping Bridge. + } + + const exited = await waitForExit(this.exitPromise, this.stopTimeoutMs) + if (!exited && this.child === child) { + child.kill('SIGTERM') + const terminated = await waitForExit(this.exitPromise, this.stopTimeoutMs) + if (!terminated && this.child === child) { + this.state = 'error' + throw new Error('Native input Bridge did not exit after SIGTERM') + } + } + this.detach(child) + if (this.child === child) this.child = null + this.state = 'idle' + return { state: 'idle' } + } + + emergencyStop(reason = 'emergency') { + if (this.state === 'error') return false + const child = this.child + this.state = 'error' + this.clearStartup(new Error(`Native input stopped: ${reason}`)) + this.onEmergencyStop(reason) + this.rejectAllRequests(new Error(`Native input stopped: ${reason}`)) + if (child) { + this.requestOwnedStop(child, reason) + } + return true + } + + handleOutput(child, chunk) { + if (child !== this.child || this.state === 'error') return + let messages + try { + messages = this.decoder.push(chunk) + } catch (error) { + this.fail('malformed_output', error, { child }) + return + } + + for (const message of messages) { + if (this.state === 'starting') { + if (message.type !== 'bridge.ready' || message.state !== 'ready') { + this.fail( + 'malformed_output', + new Error('Native input Bridge did not send bridge.ready first'), + { child }, + ) + return + } + this.state = 'ready' + this.clearStartup() + this.resolveStart?.({ state: 'ready' }) + this.resolveStart = null + this.rejectStart = null + continue + } + if (message.type === 'bridge.error') { + this.fail( + 'bridge_error', + new Error(message.reason || 'Native input Bridge error'), + { child }, + ) + return + } + if ( + message.type === 'lifecycle.result' + || message.type === 'operation.result' + ) { + const correlation = message.type === 'lifecycle.result' + ? message.requestId + : message.operationId + const pending = this.pendingRequests.get(correlation) + if (!pending) continue + clearTimeout(pending.timer) + this.pendingRequests.delete(correlation) + pending.resolve(message) + continue + } + this.emit('message', message) + } + } + + handleExit(child, code, signal) { + if (child !== this.child) return + this.resolveExit?.({ code, signal }) + this.resolveExit = null + this.exitPromise = null + this.detach(child) + this.child = null + if (this.state === 'stopping') return + this.fail( + 'child_exit', + new Error(`Native input Bridge exited (${code ?? signal ?? 'unknown'})`), + { terminate: false }, + ) + } + + fail(reason, error, { child = this.child, terminate = true } = {}) { + if (this.state === 'error') return + this.state = 'error' + this.clearStartup(error) + this.onEmergencyStop(reason) + this.rejectAllRequests(error) + if (child && terminate) { + this.requestOwnedStop(child, reason) + } + this.emit('failed', { reason, error }) + } + + clearStartup(error = null) { + if (this.startupTimer) clearTimeout(this.startupTimer) + this.startupTimer = null + if (error) { + this.rejectStart?.(error) + this.resolveStart = null + this.rejectStart = null + } + } + + detach(child) { + const listeners = this.childListeners + if (!listeners || listeners.child !== child) return + child.stdout.off('data', listeners.onData) + child.off('exit', listeners.onExit) + child.off('error', listeners.onProcessError) + this.childListeners = null + } + + rejectRequest(requestId, error) { + const pending = this.pendingRequests.get(requestId) + if (!pending) return + clearTimeout(pending.timer) + this.pendingRequests.delete(requestId) + pending.reject(error) + } + + rejectAllRequests(error) { + for (const requestId of [...this.pendingRequests.keys()]) { + this.rejectRequest(requestId, error) + } + } + + requestOwnedStop(child, reason) { + try { + if (child.stdin?.writable) { + child.stdin.write(encodeNativeInputFrame({ + type: 'bridge.stop', + reason, + })) + } + } catch { + // The bounded signal below handles a broken control pipe. + } + const timer = setTimeout(() => child.kill('SIGTERM'), this.stopTimeoutMs) + timer.unref?.() + } +} + +function waitForExit(exitPromise, milliseconds) { + return new Promise(resolveWait => { + const timer = setTimeout(() => resolveWait(false), milliseconds) + exitPromise.then(() => { + clearTimeout(timer) + resolveWait(true) + }) + }) +} diff --git a/desktop/src/native-input-lifecycle.mjs b/desktop/src/native-input-lifecycle.mjs new file mode 100644 index 00000000..abcfd8f3 --- /dev/null +++ b/desktop/src/native-input-lifecycle.mjs @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto' + +const ACTIONS = new Set(['status', 'install', 'repair', 'uninstall']) + +export class NativeInputLifecycle { + constructor({ host } = {}) { + this.host = host + this.current = { + installed: false, + registered: false, + enabled: false, + version: '', + state: 'unknown', + } + } + + snapshot() { return { ...this.current } } + status() { return this.run('status') } + install() { return this.run('install') } + repair() { return this.run('repair') } + uninstall() { return this.run('uninstall') } + + async run(action) { + if (!ACTIONS.has(action)) throw new Error('Unknown native input lifecycle action') + const requestId = randomUUID() + try { + const result = await this.host.request({ + type: `lifecycle.${action}`, + requestId, + }) + if ( + result?.type !== 'lifecycle.result' + || result.requestId !== requestId + || result.action !== action + ) { + throw new Error('Native input lifecycle correlation mismatch') + } + if (result.accepted === false) { + throw new Error(result.reason || 'Native input lifecycle action rejected') + } + this.current = normalizeLifecycleResult(result) + return this.snapshot() + } catch (error) { + this.current = { ...this.current, state: 'error' } + throw error + } + } +} + +export async function startNativeInputLifecycleHost(host) { + const startedTemporarily = host.state === 'idle' + if (host.state === 'error') await host.stop('lifecycle_reset') + if (host.state === 'idle' || host.state === 'starting') await host.start() + return startedTemporarily +} + +export function normalizeLifecycleResult(result = {}) { + const installed = result.installed === true + const registered = installed && result.registered === true + const enabled = registered && result.enabled === true + return { + installed, + registered, + enabled, + version: installed ? String(result.version || '') : '', + state: !installed + ? 'not-installed' + : !registered ? 'needs-repair' : !enabled ? 'needs-enable' : 'ready', + } +} diff --git a/desktop/src/native-input-protocol.mjs b/desktop/src/native-input-protocol.mjs new file mode 100644 index 00000000..5edfaf08 --- /dev/null +++ b/desktop/src/native-input-protocol.mjs @@ -0,0 +1,148 @@ +export const MAX_FRAME_PAYLOAD_BYTES = 65_536 + +export const NATIVE_INPUT_MESSAGE_TYPES = Object.freeze([ + 'bridge.ready', + 'session.arm', + 'session.partial', + 'session.final', + 'session.operation', + 'session.cancel', + 'session.pause', + 'session.resume', + 'session.state', + 'operation.result', + 'lifecycle.status', + 'lifecycle.install', + 'lifecycle.repair', + 'lifecycle.uninstall', + 'lifecycle.result', + 'bridge.stop', + 'bridge.error', +]) + +const messageTypes = new Set(NATIVE_INPUT_MESSAGE_TYPES) +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }) + +export class NativeInputProtocolError extends Error { + constructor(code, message) { + super(message) + this.name = 'NativeInputProtocolError' + this.code = code + } +} + +export function encodeNativeInputFrame(message) { + validateMessage(message) + let payload + try { + payload = Buffer.from(JSON.stringify(message), 'utf8') + } catch { + throw protocolError('invalid_json', 'Message is not JSON encodable') + } + if (payload.length === 0) { + throw protocolError('zero_length', 'Frame payload is empty') + } + if (payload.length > MAX_FRAME_PAYLOAD_BYTES) { + throw protocolError('oversized', 'Frame payload exceeds 64 KiB') + } + const header = Buffer.allocUnsafe(4) + header.writeUInt32BE(payload.length) + return Buffer.concat([header, payload]) +} + +export function decodeNativeInputFrame(bytes) { + const frame = Buffer.from(bytes) + if (frame.length < 4) throw protocolError('truncated', 'Missing frame header') + const length = readLength(frame) + const expected = 4 + length + if (frame.length < expected) throw protocolError('truncated', 'Partial frame') + if (frame.length > expected) { + throw protocolError('trailing_bytes', 'Frame contains trailing bytes') + } + return decodePayload(frame.subarray(4)) +} + +export class NativeInputFrameDecoder { + #buffer = Buffer.alloc(0) + + push(bytes) { + const chunk = Buffer.from(bytes) + if (chunk.length > 0) this.#buffer = Buffer.concat([this.#buffer, chunk]) + const messages = [] + while (this.#buffer.length >= 4) { + const length = readLength(this.#buffer) + const frameLength = 4 + length + if (this.#buffer.length < frameLength) break + messages.push(decodePayload(this.#buffer.subarray(4, frameLength))) + this.#buffer = this.#buffer.subarray(frameLength) + } + return messages + } + + finish() { + if (this.#buffer.length !== 0) { + throw protocolError('truncated', 'Stream ended during a frame') + } + } +} + +function readLength(frame) { + const length = frame.readUInt32BE(0) + if (length === 0) throw protocolError('zero_length', 'Frame payload is empty') + if (length > MAX_FRAME_PAYLOAD_BYTES) { + throw protocolError('oversized', 'Frame payload exceeds 64 KiB') + } + return length +} + +function decodePayload(payload) { + let text + try { + text = utf8Decoder.decode(payload) + } catch { + throw protocolError('invalid_utf8', 'Frame is not valid UTF-8') + } + let message + try { + message = JSON.parse(text) + } catch { + throw protocolError('invalid_json', 'Frame is not valid JSON') + } + validateMessage(message) + return message +} + +function validateMessage(message) { + if (!message || typeof message !== 'object' || Array.isArray(message)) { + throw protocolError('invalid_json', 'Message must be an object') + } + if (!messageTypes.has(message.type)) { + throw protocolError('unknown_type', 'Unknown native input message type') + } + if ( + (message.type.startsWith('lifecycle.') && message.type !== 'lifecycle.result') + && !validIdentifier(message.requestId) + ) { + throw protocolError('invalid_correlation', 'Lifecycle requestId is required') + } + if ( + message.type === 'lifecycle.result' + && (!validIdentifier(message.requestId) || typeof message.action !== 'string') + ) { + throw protocolError('invalid_correlation', 'Lifecycle result is not correlated') + } + if ( + message.type === 'operation.result' + && (!validIdentifier(message.operationId) || typeof message.accepted !== 'boolean') + ) { + throw protocolError('invalid_correlation', 'Operation result is not correlated') + } +} + +function validIdentifier(value) { + return typeof value === 'string' && value.length > 0 && value.length <= 128 +} + +function protocolError(code, message) { + return new NativeInputProtocolError(code, message) +} diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 96c58b8c..060de220 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -5,7 +5,7 @@ function sendPoint(channel, x, y) { ipcRenderer.send(channel, { x, y }) } -contextBridge.exposeInMainWorld('qwenAudioAgentDesktop', { +const desktopApi = { dragStart: (x, y) => sendPoint('qwen-audio-agent:drag-start', x, y), dragMove: (x, y) => sendPoint('qwen-audio-agent:drag-move', x, y), dragEnd: () => ipcRenderer.send('qwen-audio-agent:drag-end'), @@ -30,6 +30,29 @@ contextBridge.exposeInMainWorld('qwenAudioAgentDesktop', { ) }, openSettings: () => ipcRenderer.send('qwen-audio-agent:open-settings'), + nativeInputStatus: () => ipcRenderer.invoke( + 'qwen-audio-agent:native-input-status', + ), + nativeInputOperation: operation => ipcRenderer.invoke( + 'qwen-audio-agent:native-input-operation', + operation, + ), + nativeInputLifecycle: action => ipcRenderer.invoke( + 'qwen-audio-agent:native-input-lifecycle', + action, + ), + openNativeInputSettings: () => ipcRenderer.send( + 'qwen-audio-agent:native-input-open-settings', + ), + onNativeInputSessionRequest: callback => { + if (typeof callback !== 'function') return () => {} + const listener = (_event, request) => callback(request) + ipcRenderer.on('qwen-audio-agent:native-input-session-request', listener) + return () => ipcRenderer.removeListener( + 'qwen-audio-agent:native-input-session-request', + listener, + ) + }, loadSurface: () => ipcRenderer.invoke('qwen-audio-agent:surface-load'), setSurface: mode => ipcRenderer.invoke( 'qwen-audio-agent:surface-set', @@ -109,4 +132,26 @@ contextBridge.exposeInMainWorld('qwenAudioAgentDesktop', { ipcRenderer.send('qwen-audio-agent:open-external', url) }, quit: () => ipcRenderer.send('qwen-audio-agent:quit'), -}) +} + +if ( + process.defaultApp + && process.env.QWEN_AUDIO_NATIVE_INPUT_DEVTOOLS === 'true' +) { + desktopApi.nativeInputStart = () => ipcRenderer.invoke( + 'qwen-audio-agent:native-input-start', + ) + desktopApi.nativeInputStop = () => ipcRenderer.invoke( + 'qwen-audio-agent:native-input-stop', + ) + desktopApi.nativeInputFakePartial = text => ipcRenderer.invoke( + 'qwen-audio-agent:native-input-fake-partial', + String(text || ''), + ) + desktopApi.nativeInputFakeFinal = text => ipcRenderer.invoke( + 'qwen-audio-agent:native-input-fake-final', + String(text || ''), + ) +} + +contextBridge.exposeInMainWorld('qwenAudioAgentDesktop', desktopApi) diff --git a/desktop/src/settings-config.mjs b/desktop/src/settings-config.mjs index fc79e0fb..640214e7 100644 --- a/desktop/src/settings-config.mjs +++ b/desktop/src/settings-config.mjs @@ -23,6 +23,8 @@ const DEFAULTS = { orbSkin: 'fluid', autoHideSeconds: 60, wakeShortcut: 'CommandOrControl+Shift+Space', + nativeInputEnabled: false, + nativeInputShortcut: 'CommandOrControl+Shift+D', wakeWordEnabled: false, dashscopeApiKey: '', realtimeBaseUrl: DEFAULT_DASHSCOPE_REALTIME_URL, @@ -47,6 +49,8 @@ const SETTING_KEYS = { orbSkin: 'QWEN_AUDIO_ORB_SKIN', autoHideSeconds: 'QWEN_AUDIO_DESKTOP_AUTO_HIDE_SECONDS', wakeShortcut: 'QWEN_AUDIO_DESKTOP_WAKE_SHORTCUT', + nativeInputEnabled: 'QWEN_AUDIO_NATIVE_INPUT_ENABLED', + nativeInputShortcut: 'QWEN_AUDIO_NATIVE_INPUT_SHORTCUT', wakeWordEnabled: 'QWEN_AUDIO_WAKE_WORD_ENABLED', dashscopeApiKey: 'DASHSCOPE_API_KEY', realtimeBaseUrl: 'QWEN_AUDIO_REALTIME_BASE_URL', @@ -116,11 +120,8 @@ function cleanAutoHideSeconds(value) { return seconds } -function cleanWakeShortcut(value) { - const shortcut = String(value || DEFAULTS.wakeShortcut).trim() - if (shortcut === 'CommandOrControl+Space') { - return DEFAULTS.wakeShortcut - } +function cleanShortcut(value, fallback) { + const shortcut = String(value || fallback).trim() const parts = shortcut.split('+') const key = parts.pop() || '' const modifiers = new Set(parts) @@ -138,7 +139,7 @@ function cleanWakeShortcut(value) { modifiers.has('CommandOrControl') || modifiers.has('Alt') ) if (!validModifiers || !validKey || (!functionKey && !hasCommandModifier)) { - return DEFAULTS.wakeShortcut + return fallback } return [ modifiers.has('CommandOrControl') ? 'CommandOrControl' : '', @@ -148,6 +149,18 @@ function cleanWakeShortcut(value) { ].filter(Boolean).join('+') } +function cleanWakeShortcut(value) { + const shortcut = String(value || DEFAULTS.wakeShortcut).trim() + if (shortcut === 'CommandOrControl+Space') { + return DEFAULTS.wakeShortcut + } + return cleanShortcut(shortcut, DEFAULTS.wakeShortcut) +} + +function cleanNativeInputShortcut(value) { + return cleanShortcut(value, DEFAULTS.nativeInputShortcut) +} + function encoded(value) { const text = String(value ?? '') if (/^[A-Za-z0-9_./:@+-]*$/.test(text)) return text @@ -293,6 +306,17 @@ export function parseSettings(content = '', fallback = {}) { 'QWEN_AUDIO_DESKTOP_WAKE_SHORTCUT', fallback.QWEN_AUDIO_DESKTOP_WAKE_SHORTCUT ?? DEFAULTS.wakeShortcut, )), + nativeInputEnabled: String(configured( + values, + 'QWEN_AUDIO_NATIVE_INPUT_ENABLED', + fallback.QWEN_AUDIO_NATIVE_INPUT_ENABLED || '', + )).toLowerCase() === 'true', + nativeInputShortcut: cleanNativeInputShortcut(configured( + values, + 'QWEN_AUDIO_NATIVE_INPUT_SHORTCUT', + fallback.QWEN_AUDIO_NATIVE_INPUT_SHORTCUT + ?? DEFAULTS.nativeInputShortcut, + )), wakeWordEnabled: String( configured( values, @@ -388,6 +412,10 @@ export function normalizeSettings(settings = {}) { wakeShortcut: cleanWakeShortcut( settings.wakeShortcut ?? DEFAULTS.wakeShortcut, ), + nativeInputEnabled: Boolean(settings.nativeInputEnabled), + nativeInputShortcut: cleanNativeInputShortcut( + settings.nativeInputShortcut ?? DEFAULTS.nativeInputShortcut, + ), wakeWordEnabled: Boolean(settings.wakeWordEnabled), dashscopeApiKey: String( settings.dashscopeApiKey ?? DEFAULTS.dashscopeApiKey, diff --git a/desktop/src/settings.html b/desktop/src/settings.html index 2e5e8fda..39af629f 100644 --- a/desktop/src/settings.html +++ b/desktop/src/settings.html @@ -398,6 +398,23 @@

运行状态

+
+ +
+ + 正在检查输入法… +
+
+ + + + +
+
+
日志 + + {t(dictationStateLabel(dictationView.state))} + {dictationView.notice ? ` · ${dictationView.notice}` : ''} + + {dictationView.partial && + {dictationView.partial} + } +
} {attachments.length > 0 &&
{attachments.map((item, index) => {inputPartLabel(item.part, index)} @@ -116,7 +198,11 @@ export default function MultimodalComposer({ placeholder={compact ? t('输入文字或图片') : t('输入文字,或粘贴、拖入图片和文件')} - onChange={event => setText(event.target.value)} + onChange={event => { + if (dictationClient.current?.active) { + dictationClient.current.keyboard(event.target.value) + } else setText(event.target.value) + }} onPaste={event => { const files = event.clipboardData?.files if (!files?.length) return @@ -130,5 +216,8 @@ export default function MultimodalComposer({
{error && {error}} + {dictationView.error && + {dictationView.error} + } } diff --git a/web/src/composer-dictation.js b/web/src/composer-dictation.js new file mode 100644 index 00000000..990aea5f --- /dev/null +++ b/web/src/composer-dictation.js @@ -0,0 +1,199 @@ +import { + CommitReceipts, + ComposerDictation, + textFingerprint, +} from '../../shared/dictation-contract.mjs' + +export function enqueueDictationEvent(queue, item, limit = 64) { + return [...(Array.isArray(queue) ? queue : []), item].slice(-limit) +} + +export class ComposerDictationClient { + constructor({ + enabled = false, + canStart = () => false, + send = () => false, + submit = () => false, + onView = () => {}, + setCapture = () => {}, + } = {}) { + this.enabled = enabled + this.canStart = canStart + this.send = send + this.submit = submit + this.onView = onView + this.setCapture = setCapture + this.model = new ComposerDictation() + this.receipts = new CommitReceipts() + this.state = 'idle' + this.error = '' + this.notice = '' + this.active = false + this.continuous = true + } + + view() { + return { + ...this.model.snapshot(), + state: this.state, + error: this.error, + notice: this.notice, + } + } + + publish() { this.onView(this.view()) } + + start(text = '', { continuous = true } = {}) { + if (!this.enabled || this.active || !this.canStart()) return false + this.model = new ComposerDictation(text) + this.continuous = continuous !== false + this.active = true + this.error = '' + this.notice = '' + this.state = 'starting' + this.setCapture(true) + const sent = this.send({ + type: 'dictation.start', text: this.model.text, + revision: this.model.revision, continuous: this.continuous, + }) + if (!sent) { + this.active = false + this.setCapture(false, { restore: true }) + this.state = 'error' + this.error = 'Gateway 尚未连接' + } + this.publish() + return sent + } + + stop(type = 'dictation.stop') { + if (!this.active) return false + this.send({ type }) + this.active = false + this.setCapture(false, { restore: true }) + this.state = type === 'dictation.cancel' ? 'cancelled' : 'stopped' + this.notice = '' + this.publish() + return true + } + + shortcut(event, text = this.model.text) { + if (!this.enabled || event?.key?.toLowerCase() !== 'd') return false + if (!(event.ctrlKey || event.metaKey) || !event.shiftKey) return false + event.preventDefault?.() + return this.active ? this.stop() : this.start(text) + } + + keyboard(text) { + if (!this.active) { + this.model = new ComposerDictation(text) + this.publish() + return true + } + const expectedRevision = this.model.revision + const previousText = this.model.text + const pending = this.model.pending + const editedText = String(text || '') + const mergedText = pending && editedText.startsWith(previousText) + ? `${previousText}${pending}${editedText.slice(previousText.length)}` + : `${editedText}${pending}` + this.model.keyboardEdit(() => mergedText) + const snapshot = this.model.snapshot() + this.send({ + type: 'dictation.context', + expectedRevision, + revision: this.model.revision, + text: this.model.text, + range: snapshot.range, + }) + this.publish() + return true + } + + manualCommitted() { + if (!this.active) return false + const expectedRevision = this.model.revision + this.model.reset('') + if (this.continuous) { + this.send({ + type: 'dictation.reset', expectedRevision, + revision: this.model.revision, + }) + this.state = 'listening' + } else { + this.send({ type: 'dictation.stop' }) + this.active = false + this.setCapture(false, { restore: true }) + this.state = 'stopped' + } + this.publish() + return true + } + + handle(event = {}) { + if (!this.enabled) return false + if (event.type === 'input.suspend') { + this.active = false + this.setCapture(false, { restore: true }) + this.state = 'stopped' + this.error = '麦克风已由外部输入占用;恢复后请重新开始听写' + this.notice = '' + this.publish() + return true + } + if (event.type === 'dictation.state') { + const wasActive = this.active + this.state = String(event.state || 'idle') + this.error = this.state === 'error' ? String(event.message || '') : '' + this.notice = String(event.notice || '') + if (['cancelled', 'error', 'stopped'].includes(this.state)) { + this.active = false + if (wasActive) this.setCapture(false, { + restore: true, + }) + } + this.publish() + return true + } + if (!this.active) return false + if (event.type === 'dictation.partial') { + const accepted = this.model.partial(event) + if (accepted) this.publish() + return accepted + } + if (event.type === 'dictation.final') { + const accepted = this.model.final(event) + if (accepted) this.publish() + return accepted + } + if (event.type === 'dictation.operation') { + const accepted = event.operation === 'replace' + && this.model.replaceRecent(event) + if (accepted) this.publish() + return accepted + } + if (event.type === 'dictation.commit.request') { + if ( + Number(event.revision) !== this.model.revision + || event.fingerprint !== textFingerprint(this.model.text) + || !this.receipts.accept(event.commitId) + ) return false + const memoryOnly = event.intent === 'memory-correction' + const submitted = memoryOnly ? false : this.submit(this.model.text) === true + const accepted = memoryOnly || submitted + this.send({ + type: 'dictation.commit.ack', + commitId: event.commitId, + revision: event.revision, + fingerprint: event.fingerprint, + submitted, + ...(memoryOnly ? { accepted: true, intent: event.intent } : {}), + }) + if (accepted) this.model.reset('') + else this.error = '内容未提交,请检查 Gateway 连接' + this.publish() + return accepted + } + return false + } +} diff --git a/web/src/i18n.js b/web/src/i18n.js index 87e9fb59..53da81cf 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -1,5 +1,14 @@ const translations = { '待命': 'Standby', + '启动中': 'Starting', + '监听中': 'Listening', + '转写中': 'Transcribing', + '编辑中': 'Editing', + '待发送': 'Ready to send', + '已暂停': 'Paused', + '已停止': 'Stopped', + '失败': 'Failed', + '未知状态': 'Unknown state', '正在听': 'Listening', '正在说': 'Speaking', '正在处理任务': 'Working on a task', @@ -84,6 +93,11 @@ const translations = { '添加图片或文件': 'Add images or files', '移除附件': 'Remove attachment', '发送': 'Send', + '切换听写': 'Toggle dictation', + '听写(Ctrl/⌘+Shift+D)': 'Dictation (Ctrl/Command+Shift+D)', + '请从 macOS 输入菜单选择 Qwen Input': 'Select Qwen Input from the macOS input menu', + '当前输入目标已改变,请重新开始听写': 'The input target changed. Start dictation again.', + '当前输入目标不可用': 'The input target is unavailable', 'Gateway 尚未连接': 'Gateway is not connected yet', '无法读取文件': 'Unable to read file', '文件 {name} 超过 8 MB 限制': 'File {name} exceeds the 8 MB limit', diff --git a/web/src/native-input-dictation.js b/web/src/native-input-dictation.js new file mode 100644 index 00000000..c9dc490b --- /dev/null +++ b/web/src/native-input-dictation.js @@ -0,0 +1,199 @@ +import { t } from './i18n.js' + +export class NativeInputDictationClient { + constructor({ + enabled = false, + canStart = () => false, + sendGateway = () => false, + sendNative = () => Promise.reject(new Error('Native input unavailable')), + setCapture = () => {}, + onView = () => {}, + } = {}) { + this.enabled = enabled === true + this.canStart = canStart + this.sendGateway = sendGateway + this.sendNative = sendNative + this.setCapture = setCapture + this.onView = onView + this.active = false + this.state = this.enabled ? 'idle' : 'disabled' + this.error = '' + this.generation = 0 + this.pending = Promise.resolve() + } + + view() { + return { enabled: this.enabled, active: this.active, state: this.state, error: this.error } + } + + publish() { this.onView(this.view()) } + settled() { return this.pending } + + async start({ continuous = true } = {}) { + if (!this.enabled || this.active || !this.canStart()) return false + const generation = ++this.generation + this.state = 'arming' + this.error = '' + this.publish() + let armed + try { + const operationId = crypto.randomUUID() + const result = await this.sendNative({ type: 'session.arm', operationId }) + armed = result?.type === 'operation.result' + && result.operationId === operationId + && result.accepted === true + if (!armed && typeof result?.reason === 'string') { + this.error = nativeInputArmError(result.reason) + } + } catch (error) { + this.error = nativeInputArmError(error?.message) + armed = false + } + if (!armed || generation !== this.generation) { + this.state = 'error' + if (!this.error) this.error = nativeInputArmError() + this.publish() + return false + } + const sent = this.sendGateway({ + type: 'dictation.start', text: '', revision: 0, + continuous: continuous !== false, + }) === true + if (!sent) { + this.state = 'error' + this.error = 'Gateway is unavailable' + await this.safeNativeCancel('gateway_unavailable') + this.publish() + return false + } + this.active = true + this.state = 'starting' + this.setCapture(true) + this.publish() + return true + } + + cancel(reason = 'cancelled') { + if (!this.active && !['arming', 'starting'].includes(this.state)) return false + this.generation += 1 + this.active = false + this.sendGateway({ type: 'dictation.cancel' }) + this.setCapture(false, { restore: true }) + this.state = reason === 'user_cancelled' ? 'cancelled' : 'error' + this.error = this.state === 'error' ? String(reason) : '' + this.pending = this.pending.then(() => this.safeNativeCancel(reason)) + this.publish() + return true + } + + handle(event = {}) { + if (!this.enabled) return false + if (event.type === 'input.suspend') return this.cancel('input_suspended') + if (event.type === 'dictation.state') { + const state = String(event.state || '') + if (['cancelled', 'error', 'stopped'].includes(state)) { + if (this.active) { + this.generation += 1 + this.active = false + this.setCapture(false, { restore: true }) + this.pending = this.pending.then(() => this.safeNativeCancel(state)) + } + this.state = state + this.error = state === 'error' ? String(event.message || 'Dictation failed') : '' + this.publish() + return true + } + if (this.active) { + this.state = state + this.publish() + return true + } + return false + } + if (!this.active) return false + if (!['dictation.partial', 'dictation.final', 'dictation.operation'].includes(event.type)) { + return false + } + const generation = this.generation + const operation = nativeOperation(event) + this.pending = this.pending + .then(() => this.sendNative(operation)) + .then(result => { + if (generation !== this.generation || !this.active) return false + if ( + result?.type !== 'operation.result' + || result.operationId !== operation.operationId + || result.accepted !== true + ) { + this.failNative(result?.reason || 'native_operation_failed') + return false + } + return true + }) + .catch(error => { + if (generation === this.generation && this.active) { + this.failNative(error?.message || String(error)) + } + return false + }) + return true + } + + failNative(reason) { + this.generation += 1 + this.active = false + this.state = 'error' + this.error = String(reason || 'Native input failed') + this.sendGateway({ type: 'dictation.cancel' }) + this.setCapture(false, { restore: true }) + this.publish() + } + + async safeNativeCancel(reason) { + try { + await this.sendNative({ + type: 'session.cancel', + operationId: crypto.randomUUID(), + reason, + }) + } catch { + // Terminal cleanup is best-effort after capture and Gateway stop locally. + } + } +} + +function nativeInputArmError(reason) { + if (reason === 'input_source_selection_required') { + return t('请从 macOS 输入菜单选择 Qwen Input') + } + if (reason === 'target_changed') { + return t('当前输入目标已改变,请重新开始听写') + } + return t('当前输入目标不可用') +} + +export function consumeNativeInputEvents(events, afterId, handle) { + let cursor = Number.isInteger(afterId) ? afterId : 0 + for (const item of events || []) { + if (!Number.isInteger(item?.id) || item.id <= cursor) continue + cursor = item.id + handle(item.event) + } + return cursor +} + +function nativeOperation(event) { + const type = event.type === 'dictation.operation' + ? 'session.operation' + : event.type.replace('dictation.', 'session.') + return { + type, + operationId: crypto.randomUUID(), + ...(typeof event.text === 'string' ? { text: event.text } : {}), + ...(Number.isInteger(event.revision) ? { revision: event.revision } : {}), + ...(Number.isInteger(event.seq) ? { seq: event.seq } : {}), + ...(event.operation ? { operation: event.operation } : {}), + ...(event.target ? { target: event.target } : {}), + ...(event.replacement ? { replacement: event.replacement } : {}), + } +} diff --git a/web/src/styles.css b/web/src/styles.css index 04eb2fc0..bfe5681f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -22,6 +22,31 @@ gap: 8px; } +.composer-dictation { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + padding: 0 4px 7px; + color: rgba(255, 255, 255, 0.72); + font-size: 12px; +} + +.composer-dictation button { + border: 0; + color: inherit; + background: transparent; + cursor: pointer; +} + +.composer-dictation-partial { + overflow: hidden; + text-decoration: underline; + text-decoration-style: dotted; + text-overflow: ellipsis; + white-space: nowrap; +} + .composer-row textarea { flex: 1; min-height: 42px; @@ -352,6 +377,24 @@ header .voice.waiting { color: #ded5ff; border-color: #927cf480; } place-items: center; } +.desktop-native-input-status { + position: fixed; + z-index: 22; + right: 16px; + bottom: 16px; + max-width: 240px; + padding: 6px 10px; + border-radius: 999px; + background: rgb(20 24 33 / 88%); + color: #dce8ff; + font-size: 12px; + pointer-events: none; +} + +.desktop-native-input-status.error { + color: #ffd6d6; +} + .desktop-task-stack { width: 100%; min-height: 0; diff --git a/web/src/useRealtimeVoice.js b/web/src/useRealtimeVoice.js index 0e34e71f..e615ac72 100644 --- a/web/src/useRealtimeVoice.js +++ b/web/src/useRealtimeVoice.js @@ -182,6 +182,27 @@ export function microphoneControlEvent({ : { type: GatewayClientEvent.MUTE } } +export function shouldCaptureMicrophone({ + enabled, + dictationCapture, + suspended, + hostInputSuspended, +} = {}) { + return (enabled === true || dictationCapture === true) + && suspended !== true + && hostInputSuspended !== true +} + +export function canStartComposerDictation({ + enabled, + ownership, + hostInputSuspended, +} = {}) { + return enabled === true + && ownership?.state === 'active' + && hostInputSuspended !== true +} + export function microphoneSamplesDuringManualInput(samples, pending = false) { if (pending !== true) return samples // Keep advancing provider-side VAD with silence so an already-open speech @@ -222,6 +243,8 @@ export default function useRealtimeVoice({ state: 'available', holder: null, }) + const [dictationCapture, setDictationCaptureState] = useState(false) + const [hostInputSuspended, setHostInputSuspended] = useState(false) const eventRef = useRef(onEvent) const inputErrorRef = useRef(onInputError) const wakeWordOnlyRef = useRef(wakeWordOnly) @@ -239,6 +262,7 @@ export default function useRealtimeVoice({ )].sort().join(',') const enabledRef = useRef(enabled) const inputReadyRef = useRef(false) + const dictationCaptureRef = useRef(false) const outputMutedRef = useRef(outputMuted) const mutedPlaybackResponses = useRef(new Set()) const manualInputPendingRef = useRef(false) @@ -612,6 +636,18 @@ export default function useRealtimeVoice({ if (event.type === GatewayServerEvent.PLAYBACK_CLEAR) { stopPlayback(event.reason || '') } + if (event.type === GatewayServerEvent.INPUT_SUSPEND) { + setHostInputSuspended(true) + setDictationCaptureState(false) + dictationCaptureRef.current = false + sendSocketEvent({ + type: GatewayClientEvent.INPUT_SUSPEND_ACK, + owner: event.owner, + }) + } + if (event.type === GatewayServerEvent.INPUT_RESUME) { + setHostInputSuspended(false) + } if (event.type === GatewayServerEvent.AUDIO_DELTA) { if (outputMutedRef.current || !audioRef.current) { consumeMutedAudio(event.responseId) @@ -673,6 +709,7 @@ export default function useRealtimeVoice({ markAudioDone, play, realtimeProvider, + sendSocketEvent, releaseManualInputGuard, flushPendingManualInputs, sessionId, @@ -691,7 +728,12 @@ export default function useRealtimeVoice({ }, [sessionId]) useEffect(() => { - if (!enabled || suspended) { + if (!shouldCaptureMicrophone({ + enabled, + dictationCapture, + suspended, + hostInputSuspended, + })) { inputReadyRef.current = false setInputReady(false) sendSocketEvent(microphoneControlEvent({ @@ -755,7 +797,9 @@ export default function useRealtimeVoice({ inputSampleRate.current, ) socket.send(JSON.stringify({ - type: GatewayClientEvent.AUDIO_APPEND, + type: dictationCaptureRef.current + ? GatewayClientEvent.DICTATION_AUDIO_APPEND + : GatewayClientEvent.AUDIO_APPEND, audio: pcmBase64(audio), })) } @@ -763,12 +807,14 @@ export default function useRealtimeVoice({ processor.connect(context.destination) inputReadyRef.current = true setInputReady(true) - sendSocketEvent(microphoneControlEvent({ - enabled: true, - inputOnlyMute, - wakeWordOnly, - takeover, - })) + if (enabledRef.current && !dictationCaptureRef.current) { + sendSocketEvent(microphoneControlEvent({ + enabled: true, + inputOnlyMute, + wakeWordOnly, + takeover, + })) + } } catch (reason) { if (!disposed) failInput(reason) } @@ -785,7 +831,9 @@ export default function useRealtimeVoice({ } }, [ activateAudio, + dictationCapture, enabled, + hostInputSuspended, inputOnlyMute, wakeWordOnly, sendSocketEvent, @@ -836,6 +884,13 @@ export default function useRealtimeVoice({ return false }, [holdManualInputGuard, releaseManualInputGuard, sendSocketEvent]) + const setDictationCapture = useCallback(active => { + const next = Boolean(active) + dictationCaptureRef.current = next + setDictationCaptureState(next) + if (next) activateAudio() + }, [activateAudio]) + return { state, visualState: visualVoiceState(state), @@ -845,9 +900,12 @@ export default function useRealtimeVoice({ connectionState, wakeWordActive, ownership, + hostInputSuspended, activateAudio, interrupt, wake, sendInput, + sendGatewayEvent: sendSocketEvent, + setDictationCapture, } } diff --git a/web/test/composer-dictation.test.mjs b/web/test/composer-dictation.test.mjs new file mode 100644 index 00000000..676d2502 --- /dev/null +++ b/web/test/composer-dictation.test.mjs @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + ComposerDictationClient, + enqueueDictationEvent, +} from '../src/composer-dictation.js' +import { + dictationStateLabel, + textFingerprint, +} from '../../shared/dictation-contract.mjs' + +function harness({ enabled = true, canStart = () => true, submit = () => true } = {}) { + const sent = [] + const views = [] + const routes = [] + const client = new ComposerDictationClient({ + enabled, + canStart, + send: event => { sent.push(event); return true }, + submit, + onView: view => views.push(view), + setCapture: (active, options) => routes.push({ active, options }), + }) + return { client, sent, views, routes } +} + +test('disabled client does not register or send anything', () => { + const h = harness({ enabled: false }) + assert.equal(h.client.start('draft'), false) + assert.equal(h.client.shortcut({ ctrlKey: true, shiftKey: true, key: 'd' }), false) + assert.deepEqual(h.sent, []) + assert.deepEqual(h.routes, []) +}) + +test('Web event queue preserves every event in one React batch', () => { + const first = { id: 1, event: { type: 'dictation.commit.request' } } + const second = { id: 2, event: { type: 'dictation.state', state: 'stopped' } } + const queued = enqueueDictationEvent(enqueueDictationEvent([], first), second) + assert.deepEqual(queued, [first, second]) +}) + +test('dictation state labels are localized instead of exposing protocol tokens', () => { + assert.equal(dictationStateLabel('ready-to-send'), '待发送') + assert.equal(dictationStateLabel('transcribing'), '转写中') +}) + +test('mute, ownership, or suspension gate prevents Web dictation start', () => { + const h = harness({ canStart: () => false }) + assert.equal(h.client.start('draft'), false) + assert.deepEqual(h.sent, []) + assert.deepEqual(h.routes, []) +}) + +test('shortcut starts capture and renders partial separately before final locks it', () => { + const h = harness() + assert.equal(h.client.shortcut({ ctrlKey: true, shiftKey: true, key: 'd' }, 'draft'), true) + assert.equal(h.sent[0].type, 'dictation.start') + assert.deepEqual(h.routes, [{ active: true, options: undefined }]) + h.client.handle({ type: 'dictation.partial', text: ' one', revision: 0, seq: 1 }) + assert.equal(h.client.view().text, 'draft') + assert.equal(h.client.view().partial, ' one') + h.client.handle({ type: 'dictation.final', text: ' one', revision: 0, seq: 2 }) + assert.equal(h.client.view().text, 'draft one') + assert.equal(h.client.view().partial, '') +}) + +test('keyboard editing settles partial and publishes the new revision', () => { + const h = harness() + h.client.start('a') + h.client.handle({ type: 'dictation.partial', text: 'b', revision: 0, seq: 1 }) + h.client.keyboard('a!') + assert.equal(h.client.view().text, 'ab!') + assert.equal(h.sent.at(-1).type, 'dictation.context') + assert.deepEqual(h.sent.at(-1).range, { start: 1, end: 2 }) +}) + +function editedDictation({ initial = 'typed ', spoken = 'spoken', edited }) { + const h = harness() + h.client.start(initial) + h.client.handle({ + type: 'dictation.final', text: spoken, revision: 0, seq: 1, + }) + h.client.keyboard(edited) + assert.equal(h.client.view().text, edited) + return h.sent.at(-1).range +} + +test('keyboard insertion before dictation shifts the recent range', () => { + assert.deepEqual(editedDictation({ + initial: 'typed prefix ', + edited: 'typed inserted prefix spoken', + }), { start: 22, end: 28 }) +}) + +test('keyboard deletion before dictation shifts the recent range', () => { + assert.deepEqual(editedDictation({ + initial: 'remove prefix ', + edited: 'prefix spoken', + }), { start: 7, end: 13 }) +}) + +test('keyboard insertion inside dictation clears the unreliable range', () => { + assert.equal(editedDictation({ edited: 'typed spXoken' }), null) +}) + +test('keyboard clearing the composer clears the recent range', () => { + assert.equal(editedDictation({ edited: '' }), null) +}) + +test('Memory-only correction acknowledges without ordinary composer submission', () => { + const submissions = [] + const h = harness({ submit: text => { submissions.push(text); return true } }) + h.client.start('纠正长期事实:上海改为杭州。') + const request = { + type: 'dictation.commit.request', + intent: 'memory-correction', + commitId: 'memory-1', + revision: 0, + fingerprint: textFingerprint('纠正长期事实:上海改为杭州。'), + } + assert.equal(h.client.handle(request), true) + assert.deepEqual(submissions, []) + assert.deepEqual(h.sent.at(-1), { + type: 'dictation.commit.ack', + commitId: 'memory-1', + revision: 0, + fingerprint: request.fingerprint, + submitted: false, + accepted: true, + intent: 'memory-correction', + }) +}) + +test('edit miss is a non-blocking notice instead of an error', () => { + const h = harness() + h.client.start('draft') + h.client.handle({ + type: 'dictation.state', state: 'listening', + notice: '编辑目标不存在;已保留为普通草稿', + }) + assert.equal(h.client.view().error, '') + assert.match(h.client.view().notice, /已保留/) +}) + +test('matching commit calls the existing submit once and a retry is rejected', () => { + const submissions = [] + const h = harness({ submit: text => { submissions.push(text); return true } }) + h.client.start('hello') + const request = { + type: 'dictation.commit.request', commitId: 'commit-1', revision: 0, + fingerprint: textFingerprint('hello'), + } + assert.equal(h.client.handle(request), true) + assert.equal(h.client.handle(request), false) + assert.deepEqual(submissions, ['hello']) + assert.equal(h.sent.filter(event => event.type === 'dictation.commit.ack').length, 1) +}) + +test('stale commit, suspend, cancel, and error never submit or restore capture', () => { + for (const event of [ + { type: 'dictation.commit.request', commitId: 'x', revision: 9, fingerprint: 'bad' }, + { type: 'input.suspend', owner: 'host' }, + { type: 'dictation.state', state: 'cancelled' }, + { type: 'dictation.state', state: 'error', message: 'failed' }, + ]) { + let submits = 0 + const h = harness({ submit: () => { submits += 1; return true } }) + h.client.start('draft') + h.client.handle(event) + assert.equal(submits, 0) + if (event.type !== 'dictation.commit.request') { + assert.equal(h.routes.at(-1).active, false) + } + } +}) + +test('manual and voice commits both restart only in continuous mode', () => { + const h = harness() + h.client.start('draft', { continuous: true }) + h.client.manualCommitted() + assert.equal(h.sent.at(-1).type, 'dictation.reset') + assert.equal(h.client.active, true) + assert.equal(h.routes.length, 1) + + const stopped = harness() + stopped.client.start('draft', { continuous: false }) + stopped.client.manualCommitted() + assert.equal(stopped.sent.at(-1).type, 'dictation.stop') + assert.deepEqual(stopped.routes, [ + { active: true, options: undefined }, + { active: false, options: { restore: true } }, + ]) +}) + +test('cancel and provider error release dictation capture without latching main audio off', () => { + for (const event of [ + { type: 'dictation.state', state: 'cancelled' }, + { type: 'dictation.state', state: 'error', message: 'failed' }, + ]) { + const h = harness() + h.client.start('draft') + h.client.handle(event) + assert.deepEqual(h.routes.at(-1), { + active: false, + options: { restore: true }, + }) + } +}) diff --git a/web/test/native-input-dictation.test.mjs b/web/test/native-input-dictation.test.mjs new file mode 100644 index 00000000..1506e9a3 --- /dev/null +++ b/web/test/native-input-dictation.test.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import * as nativeDictation from '../src/native-input-dictation.js' + +const { NativeInputDictationClient } = nativeDictation + +async function withLang(lang, run) { + const previous = globalThis.localStorage + globalThis.localStorage = { + getItem: key => (key === 'qwen-audio-lang' ? lang : null), + } + try { + return await run() + } finally { + globalThis.localStorage = previous + } +} + +function harness(overrides = {}) { + const gateway = [] + const native = [] + const capture = [] + const views = [] + const client = new NativeInputDictationClient({ + enabled: true, + canStart: () => true, + sendGateway: event => { + gateway.push(event) + return true + }, + sendNative: operation => { + native.push(operation) + return Promise.resolve({ + type: 'operation.result', + operationId: operation.operationId, + accepted: true, + }) + }, + setCapture: (active, options) => capture.push({ active, options }), + onView: view => views.push(view), + ...overrides, + }) + return { capture, client, gateway, native, views } +} + +test('starts an empty native draft only after ownership/suspend gates pass', async () => { + const blocked = harness({ canStart: () => false }) + assert.equal(await blocked.client.start(), false) + assert.deepEqual(blocked.gateway, []) + assert.deepEqual(blocked.native, []) + assert.deepEqual(blocked.capture, []) + + const ready = harness() + assert.equal(await ready.client.start({ continuous: false }), true) + assert.deepEqual(ready.native.map(item => item.type), ['session.arm']) + assert.deepEqual(ready.gateway, [{ + type: 'dictation.start', + text: '', + revision: 0, + continuous: false, + }]) + assert.deepEqual(ready.capture, [{ active: true, options: undefined }]) +}) + +test('fails closed when native arm does not return an accepted correlated result', async () => { + for (const result of [undefined, {}, { accepted: true }]) { + const blocked = harness({ sendNative: () => Promise.resolve(result) }) + assert.equal(await blocked.client.start(), false) + assert.deepEqual(blocked.gateway, []) + assert.deepEqual(blocked.capture, []) + assert.equal(blocked.client.view().state, 'error') + } + + await withLang('zh-CN', async () => { + const sourceNotSelected = harness({ + sendNative: operation => Promise.resolve({ + type: 'operation.result', + operationId: operation.operationId, + accepted: false, + reason: 'input_source_selection_required', + }), + }) + assert.equal(await sourceNotSelected.client.start(), false) + assert.equal( + sourceNotSelected.client.view().error, + '请从 macOS 输入菜单选择 Qwen Input', + ) + assert.deepEqual(sourceNotSelected.gateway, []) + }) + + for (const [lang, expected] of [ + ['zh-CN', '当前输入目标不可用'], + ['en-US', 'The input target is unavailable'], + ]) { + await withLang(lang, async () => { + const targetUnavailable = harness({ + sendNative: operation => Promise.resolve({ + type: 'operation.result', + operationId: operation.operationId, + accepted: false, + reason: 'target_unavailable', + }), + }) + assert.equal(await targetUnavailable.client.start(), false) + assert.equal(targetUnavailable.client.view().error, expected) + }) + } +}) + +test('routes partial/final to correlated native operations and never submits conversation', async () => { + const { client, gateway, native } = harness() + await client.start() + assert.equal(client.handle({ + type: 'dictation.partial', + text: '你好', + revision: 0, + seq: 1, + }), true) + assert.equal(client.handle({ + type: 'dictation.final', + text: '你好世界', + revision: 1, + seq: 2, + }), true) + await client.settled() + + assert.deepEqual(native.slice(1).map(item => ({ + type: item.type, + text: item.text, + revision: item.revision, + seq: item.seq, + operationId: typeof item.operationId, + })), [ + { + type: 'session.partial', text: '你好', revision: 0, seq: 1, + operationId: 'string', + }, + { + type: 'session.final', text: '你好世界', revision: 1, seq: 2, + operationId: 'string', + }, + ]) + assert.deepEqual(gateway.map(item => item.type), ['dictation.start']) +}) + +test('cancel and native failure stop capture, cancel Gateway, and reject late results', async () => { + let resolvePartial + const pendingPartial = new Promise(resolve => { resolvePartial = resolve }) + const { capture, client, gateway } = harness({ + sendNative: operation => operation.type === 'session.partial' + ? pendingPartial + : Promise.resolve({ + type: 'operation.result', + operationId: operation.operationId, + accepted: true, + }), + }) + await client.start() + client.handle({ + type: 'dictation.partial', text: 'secret', revision: 0, seq: 1, + }) + assert.equal(client.cancel('user_cancelled'), true) + resolvePartial({ + type: 'operation.result', + operationId: 'late-result-does-not-match', + accepted: true, + }) + await client.settled() + + assert.deepEqual(gateway.map(item => item.type), [ + 'dictation.start', 'dictation.cancel', + ]) + assert.deepEqual(capture.at(-1), { + active: false, + options: { restore: true }, + }) + assert.equal(client.view().state, 'cancelled') + + const failed = harness({ + sendNative: operation => Promise.resolve({ + type: 'operation.result', + operationId: operation.operationId, + accepted: operation.type === 'session.arm', + ...(operation.type === 'session.arm' ? {} : { reason: 'target_changed' }), + }), + }) + await failed.client.start() + failed.client.handle({ + type: 'dictation.final', text: 'must not land', revision: 1, seq: 1, + }) + await failed.client.settled() + assert.equal(failed.client.view().state, 'error') + assert.deepEqual(failed.gateway.map(item => item.type), [ + 'dictation.start', 'dictation.cancel', + ]) + assert.equal(failed.capture.at(-1).active, false) +}) + +test('input.suspend and terminal Gateway states fail closed before late operations', async () => { + const { client, gateway, native } = harness() + await client.start() + assert.equal(client.handle({ type: 'input.suspend' }), true) + assert.equal(client.handle({ + type: 'dictation.final', text: 'late', revision: 1, seq: 1, + }), false) + await client.settled() + assert.deepEqual(native.map(item => item.type), ['session.arm', 'session.cancel']) + assert.deepEqual(gateway.map(item => item.type), [ + 'dictation.start', 'dictation.cancel', + ]) + assert.equal(client.view().state, 'error') +}) + +test('ordered native event consumption handles every unseen event exactly once', () => { + assert.equal(typeof nativeDictation.consumeNativeInputEvents, 'function') + const handled = [] + const events = [ + { id: 1, event: { type: 'dictation.partial' } }, + { id: 2, event: { type: 'dictation.final' } }, + { id: 3, event: { type: 'dictation.state', state: 'cancelled' } }, + ] + let cursor = nativeDictation.consumeNativeInputEvents( + events, + 0, + event => handled.push(event.type), + ) + cursor = nativeDictation.consumeNativeInputEvents( + events, + cursor, + event => handled.push(event.type), + ) + assert.equal(cursor, 3) + assert.deepEqual(handled, [ + 'dictation.partial', 'dictation.final', 'dictation.state', + ]) +}) diff --git a/web/test/voice-state.test.mjs b/web/test/voice-state.test.mjs index 0b4a065b..84b80a0c 100644 --- a/web/test/voice-state.test.mjs +++ b/web/test/voice-state.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { acceptsVoiceState, + canStartComposerDictation, microphoneSamplesDuringManualInput, microphoneControlEvent, releasesManualInputGuard, @@ -9,9 +10,33 @@ import { retainedRealtimeProvider, shouldAdvertiseVoice, shouldClaimReleasedVoice, + shouldCaptureMicrophone, visualVoiceState, } from '../src/useRealtimeVoice.js' +test('ending dictation cannot permanently latch an enabled microphone off', () => { + assert.equal(shouldCaptureMicrophone({ + enabled: true, + dictationCapture: false, + suspended: false, + hostInputSuspended: false, + dictationCaptureBlocked: true, + }), true) +}) + +test('Web dictation requires unmuted active ownership without host suspension', () => { + assert.equal(canStartComposerDictation({ + enabled: true, + ownership: { state: 'active' }, + hostInputSuspended: false, + }), true) + for (const blocked of [ + { enabled: false, ownership: { state: 'active' }, hostInputSuspended: false }, + { enabled: true, ownership: { state: 'busy' }, hostInputSuspended: false }, + { enabled: true, ownership: { state: 'active' }, hostInputSuspended: true }, + ]) assert.equal(canStartComposerDictation(blocked), false) +}) + test('keeps a persisted front end only while the server still offers it', () => { const providers = [{ key: 'dashscope' }, { key: 'speech-to-speech' }]