diff --git a/CHANGELOG.md b/CHANGELOG.md index b20995db..0fca9069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### ✨ Features - **iOS (MoriRemote)**: Rebuilt the remote companion around a secure, pane-native tmux control runtime. It supports saved workspaces, password or OpenSSH private-key authentication, explicit host-key trust, local terminal history/selection, agent status, and adaptive iPhone/iPad presentation without taking over another attached tmux client's selection or size. +- **iOS (MoriRemote)**: Reworked terminal navigation and input chrome: a slim four-icon keyboard accessory replaces the oversized capsule dock; Navigator has an explicit host selector and switches terminals only after choosing a session; modal forms suspend the terminal responder; Chinese and other IMEs now commit marked text exactly once. Keypad also adds photo/clipboard image input through the current authenticated SSH root: MoriRemote previews and atomically uploads the image, then inserts a shell-escaped remote path without pressing Enter. +- **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session instead of manually creating workspace records. The terminal’s Sessions button refreshes and lists every tmux session on the current host, including sessions not yet connected on the phone. ### 🐛 Bug Fixes +- **iOS (MoriRemote)**: Make workspace runtime status, topology, and agent metadata directly observable, so an open Navigator and library badges update instead of writing to an unused revision counter. +- **iOS (MoriRemote)**: Refresh local scrollback geometry after Ghostty publishes each completed renderer frame, so output arriving after the pre-render terminal-change callback no longer leaves a stale hard stop above the true bottom. +- **iOS (MoriRemote)**: Prevented a usable terminal from remaining labeled “Connecting…” when a delayed syncing callback arrives after live topology. - **iOS (MoriRemote)**: Fixed SSH tmux connections remaining on “Waiting for the active tmux pane” even though the remote control client had attached. - **iOS (MoriRemote)**: Hardened credentials to device-bound, unlocked-only Keychain storage; fenced Ghostty shutdown behind terminal-surface teardown; defer reconnects while backgrounded; and release dormant runtimes first under memory pressure. diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index a5f737b3..25ea7ee0 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -10,9 +10,14 @@ ### ✨ 新功能 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 +- **iOS(MoriRemote)**:重做终端导航与输入栏:纤薄的四图标键盘附件取代笨重的胶囊 Dock;Navigator 提供明确的主机选择器,只有选择会话后才切换终端;模态表单会暂停终端响应器;中文等输入法的组合文本只会提交一次。Keypad 还新增照片与剪贴板图片输入:图片经当前已认证 SSH 根连接预览并原子上传,随后仅插入经过 shell 转义的远程路径,不会自动按回车。 +- **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 ### 🐛 问题修复 +- **iOS(MoriRemote)**:让工作区运行状态、拓扑和 agent 元数据可被直接观察,避免 Navigator 与资源库徽章只写入无人读取的 revision 而不更新。 +- **iOS(MoriRemote)**:在 Ghostty 发布每个完整渲染帧后刷新本地回滚区几何,避免预渲染终端变更回调读到旧状态,导致滚动在真实底部之前被硬性截断。 +- **iOS(MoriRemote)**:修复终端已可用后,延迟到达的同步回调仍会让标题一直显示“正在连接”的问题。 - **iOS(MoriRemote)**:修复远端 tmux 控制客户端已经连接,但界面仍一直停在“正在等待活动的 tmux pane”的问题。 - **iOS(MoriRemote)**:凭证改为仅限本设备、仅在解锁时可用的 Keychain 存储;Ghostty 必须在终端 surface 拆除后才释放;后台期间延后重连;低内存时优先释放非活动运行时。 diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index d60fadd4..8011ab8e 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -7,46 +7,125 @@ objects = { /* Begin PBXBuildFile section */ + 04BF2F5618DC52CA420109BF /* GhosttyTerminalResponderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 678146824749C1C540C8D179 /* GhosttyTerminalResponderViewTests.swift */; }; + 0A3E5C8D6D19481EDBA77835 /* GhosttyKitControlSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */; }; + 0CD0FDE83F290E70D2B82BAB /* SSHImageUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AE8D2B54D6CB377400488B /* SSHImageUpload.swift */; }; + 0E1ABB2DB77400492C766DB7 /* GhosttyKeypadSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13347B90704FC1FF2D28C63A /* GhosttyKeypadSheet.swift */; }; 109E4551800EDCA43A760F80 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9A1E86AC034B64B37F68A846 /* Localizable.strings */; }; + 12041C9D8ADD6871BE824AD5 /* GhosttyKitControlSurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 991EF1D262BD8AA86A113A21 /* GhosttyKitControlSurfaceTests.swift */; }; 14EC21ABAE7D514B7F68225A /* Stores.swift in Sources */ = {isa = PBXBuildFile; fileRef = B05E4FE02E3C3962771154D7 /* Stores.swift */; }; 16DF0B72EA0BA73D616F61D5 /* CitadelSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77CF33F94CEBE45B61807FB /* CitadelSSHTransport.swift */; }; - 29E5C06C8FB1718904533EEE /* TmuxControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */; }; + 1B4ABC9EE1AAD05752C0DDDE /* GhosttySurfaceMouseEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF6A6CC81A7E40BF7BB825E /* GhosttySurfaceMouseEvent.swift */; }; + 2302A7B4A772047379C73067 /* GhosttyTerminalCompositionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD23FE8E78A324650AA77CC /* GhosttyTerminalCompositionState.swift */; }; + 25683F5BC20807C3F9ADE249 /* GhosttyKeyboardChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */; }; + 26B09ED95683BE1B0C6484A6 /* GhosttyImageAttachmentSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7ADE8147E90699397F2846 /* GhosttyImageAttachmentSheet.swift */; }; + 29F224E2CCC19FD837908D80 /* GhosttyTmuxPrefixInputBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0544BADF4E27E64B50E2BBC9 /* GhosttyTmuxPrefixInputBufferTests.swift */; }; + 2E5B1E954FE1011C8C057D50 /* GhosttyTerminalPresentationProjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D76C58B99537D89078D58B /* GhosttyTerminalPresentationProjector.swift */; }; 2EA225F941CDB8FE36CA7A8F /* SavedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210BF0AD6F094B3D96D0A36D /* SavedModels.swift */; }; 30694D81E01EF77122136322 /* MoriRemoteDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBBE7E60EEF6A3363F73F0A /* MoriRemoteDependencies.swift */; }; + 31097F77B38B348F9E7E0BB3 /* Haptic.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7595020B1AEF0AE384FF639 /* Haptic.swift */; }; 3207EDDDF0FDBE441D981A8B /* SSHTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B402FCCB191BE4C43EE332CF /* SSHTransportTests.swift */; }; + 3465552A378696C80FABA606 /* TmuxSessionLinkWriteFailureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F848DC8CEFD90068DE779866 /* TmuxSessionLinkWriteFailureTests.swift */; }; 376EEC1B30EE8565C4B085D1 /* MoriRemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F3182F8D251430A10B298E /* MoriRemoteApp.swift */; }; - 3D5ABC739FD3170547500C7E /* GhosttyKitABIProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.swift */; }; + 3A1491877954A342656AEBF9 /* GhosttyTerminalViewportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9659C2FCA72254982C81D686 /* GhosttyTerminalViewportCoordinator.swift */; }; + 3A2DB1541BA69E76A0E29F66 /* GhosttyKeyboardChromeModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2FE3FEDF880BE280656A2D /* GhosttyKeyboardChromeModeTests.swift */; }; + 3CC26EB85FA319E85F2D36BC /* GhosttyManagedSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D69DE7D59B2C5BA8D15339 /* GhosttyManagedSurface.swift */; }; + 3D1C909CBEFA2B7BEC0D33B6 /* TmuxSessionLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076F9259ECBB8E9DE2737FB2 /* TmuxSessionLink.swift */; }; + 3DF786F56C47B1E2B6EC0348 /* GhosttySurfaceScrollGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8CC6B54296A1389D419EFDB /* GhosttySurfaceScrollGestureTests.swift */; }; + 3E7D9500BA676CB7BF115D4E /* GhosttyTerminalResponderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24733F909F325E7D558F8E31 /* GhosttyTerminalResponderView.swift */; }; 3FAC41CC848F16DDB123A9F6 /* Phase4ShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B2C6DECBA8C1C75B1A429CD /* Phase4ShellTests.swift */; }; - 40234B3C274CDF5CBDF2746B /* GhosttyTerminalProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.swift */; }; + 40753F5ADF7AF6E9BDB498D2 /* GhosttyImageAttachmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CE71956CBE6B24690CB0E7 /* GhosttyImageAttachmentTests.swift */; }; + 4990AE0712D61323469D3EF4 /* GhosttyTerminalSurfaceInteractionOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = B29878C376F0AD7940205B86 /* GhosttyTerminalSurfaceInteractionOutcome.swift */; }; 4B2ADF9D0C7011A644E1104E /* TmuxShellCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D631082FBB8A05D4BA1F801F /* TmuxShellCommand.swift */; }; - 4E34F7CDB1296EF5B949969A /* TmuxSessionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C4381824F6C5E7604AAF7A /* TmuxSessionController.swift */; }; 51CDD881F49D4124117A3D7D /* HostTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4A9E8D51098FB9FE41A085 /* HostTrust.swift */; }; + 52063DA59155A7D5A809FE35 /* SSHTmuxSessionDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D53B10B6CBF18D7DDAF1B27 /* SSHTmuxSessionDiscovery.swift */; }; + 52DDD20FA3AEF27F1DFAE907 /* GhosttyTerminalCoreViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20230C5AB4EBA556DBF90A42 /* GhosttyTerminalCoreViewTests.swift */; }; + 545CF67F733324F87DE9CBEB /* MoriRemoteTerminalFacadeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAD61A087944EDE21D81BDA /* MoriRemoteTerminalFacadeTests.swift */; }; 54F38E40C11EB8E48022762A /* AgentMetadataProjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */; }; 54F48944A9E87F22A490B8D4 /* LegacyMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */; }; 553D7FA2654275C5B609DB67 /* SSHRootPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5CE2DEDF2FDD48461599ED /* SSHRootPool.swift */; }; 57A0B148D1B8D23CA120481C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 495654252F6CE455BE0201B3 /* Assets.xcassets */; }; + 57EF884059F194983540CBCB /* GhosttyManagedSurfaceLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EBA5074382AC1C70BEB15E /* GhosttyManagedSurfaceLookup.swift */; }; 5D0A706E5CC15CA815D2205C /* NIOPosix in Frameworks */ = {isa = PBXBuildFile; productRef = 82712771B627666368A3F09C /* NIOPosix */; }; - 61910C0D99CE3C03CDCAA824 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AC557BE4951B52EE25ED7B /* GhosttyKit.xcframework */; }; + 5E5DC6D129A41CB33E44B40B /* SSHRootAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49212AC593537B7DD8346F22 /* SSHRootAccess.swift */; }; + 62D15BB204ED62613E6FE241 /* GhosttyTerminalPrefixFlushLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9429B7B3608382ECA97B080 /* GhosttyTerminalPrefixFlushLifecycleTests.swift */; }; 64772A470A3EB7EE2CD92BA8 /* SSHTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CE86B5FC370573653D3245 /* SSHTmuxControlTransport.swift */; }; + 648919CAF60386D84ABC45D8 /* TmuxTerminalSessionShutdownDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F95080D57FCEAFA52CA791 /* TmuxTerminalSessionShutdownDrainTests.swift */; }; + 656BC1E3C7AC26BC7C6FC1C6 /* GhosttyKeyboardChromeActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */; }; 68C87C51C7B1430199E64AAC /* SSHPrivateKeyInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */; }; 6C61BF2005608B1366F0CE31 /* RemoteRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA4083A9512502482A6CECA /* RemoteRootView.swift */; }; + 6D55ADE98CE693CA6802197D /* GhosttyTerminalCoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC055273524821D3351EF36A /* GhosttyTerminalCoreView.swift */; }; 6E8368DBBA57DB44BDC8E1E5 /* LegacyMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88BDAAB702E98FDD084041C /* LegacyMigration.swift */; }; - 7847C4893AE9B6481BD63CEB /* Phase3RuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */; }; + 7608ABD730F2113B6100141F /* TmuxSessionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6FCBEFA092CA08F879265D1 /* TmuxSessionController.swift */; }; 78FE94F2F5065228E0F8B259 /* Phase2TransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */; }; + 7A6776485CC915CC8E0444AC /* GhosttyPublishedFrameObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4572DCA39CA3C26E446A98D /* GhosttyPublishedFrameObserverTests.swift */; }; + 7ACD00781983EB3E3052D10F /* TmuxIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2B40FFD54FA5E83ED47253 /* TmuxIdentity.swift */; }; + 7B1B93EEB1DE05CA1966D556 /* TmuxPaneSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F69592AA4A0788015A2A51 /* TmuxPaneSurface.swift */; }; + 7D9CB9C5D0700BA450BD9954 /* MoriTmuxNativeStartupIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD098C91CB7C0CA340592E1 /* MoriTmuxNativeStartupIsolationTests.swift */; }; 7EB9D2C181E73D43B40C9485 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E788C063B75F299CBC1D0165 /* PrivacyInfo.xcprivacy */; }; 85ADFC9BAA17017ECA067C5B /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = F391794B759D1B5CD2C36000 /* Citadel */; }; + 8811DFD8D63BC3F6263EFCF1 /* GhosttyRendererLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF190110E6756726243B4B5 /* GhosttyRendererLayer.swift */; }; 8A1DC8FFA1F8B93734D4E0E3 /* SSHAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D3CED1154E19B734CC9AA8 /* SSHAuth.swift */; }; + 8A46F1EDB03E40047674D287 /* MoriRemoteTerminal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CDAD6C595773E27C2302A0E2 /* MoriRemoteTerminal.framework */; }; + 914D5C34DBB457D970A90C25 /* GhosttySurfaceKeyEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */; }; + 9213AD0FB635C3806A8C0010 /* MoriRemoteTerminalProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80AB026D4A388B7B0DCEBD4 /* MoriRemoteTerminalProbe.swift */; }; 950C33367217BF06FA564D2B /* NIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6712048F2C2EC6961F582380 /* NIO */; }; - A64AA54831409250D2D7AC3A /* GhosttyPaneSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */; }; - BE15F82CE37D35B536E662ED /* GhosttyTmuxRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */; }; + 954E0187B8822478AC53A4A6 /* MoriRemoteTerminal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CDAD6C595773E27C2302A0E2 /* MoriRemoteTerminal.framework */; }; + 968FC5B380206254527E3718 /* TmuxTerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DC599E9268D13F97F75BC /* TmuxTerminalSession.swift */; }; + 977DBF133BDD92FAFED6FAAB /* TmuxTerminalScreenAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E68FBB36ECEC4F7C77BA31B4 /* TmuxTerminalScreenAdapter.swift */; }; + 97F8C17610EA20C2D9B93496 /* GhosttyModifierStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87D3C5A05813B59CE76559C /* GhosttyModifierStateTests.swift */; }; + 99D35E837708840A52D7175B /* Phase6TerminalOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34AD49A6DC5B4A977C92A64A /* Phase6TerminalOwnershipTests.swift */; }; + 9F04137A8480985A7635FE52 /* ImageInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF2A6B22DB0665EE8DE530E /* ImageInputTests.swift */; }; + 9F941D728EA6D2D9DDD8E2DC /* GhosttyKeyboardVisibilityProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */; }; + A17B820D2070516F1E059C96 /* GhosttyKeyboardVisibilityProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */; }; + A82ADEB60A40355F4B307D93 /* GhosttyKitRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F28E32D6C407ED02A977516 /* GhosttyKitRuntime.swift */; }; + A8638E4BBE4B7CF1DD78F7A1 /* TmuxTerminalScreenAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F127EC360B82F2E804AF82D3 /* TmuxTerminalScreenAdapterTests.swift */; }; + AABCC196B2F7F17A6BA540BE /* GhosttySingleViewportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */; }; + AAF3070C7F3F444471211195 /* GhosttyTerminalResponderTextInputShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B49983C9FB3783CBF224C23 /* GhosttyTerminalResponderTextInputShim.swift */; }; + ACE22CEBB527F6CDA6C44470 /* TerminalSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A42139069AA3C15AAA45B5F1 /* TerminalSettings.swift */; }; + AE2B7491776C8FC853899464 /* TmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6CD869EC2CF2DD3481DE8E9 /* TmuxControlTransport.swift */; }; + AFE0787AFAB5605F12537763 /* GhosttyRuntimeTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF39528D1FAB67FE7906605F /* GhosttyRuntimeTrace.swift */; }; + B191529CCEFA8B8A6161B20E /* GhosttyViewportSizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 676FDCFD6BAB360A6FDE7151 /* GhosttyViewportSizing.swift */; }; + C07777BEE0CE5B8012A02C74 /* GhosttyTerminalResponderFocusPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62BCDBA618379FE5456A1C57 /* GhosttyTerminalResponderFocusPolicyTests.swift */; }; + C15730869E5A9CF770E3D2CC /* GhosttyTmuxPrefixInputBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */; }; + C34E3D3F89FF5F790D22D0C0 /* TmuxControlViewport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */; }; + C3F4EADB4D52F73F75D6E750 /* MoriRemoteTerminalFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE453EA5ED8C3C00E2EFF00 /* MoriRemoteTerminalFacade.swift */; }; + C598300440F9F8D28664A7A2 /* MoriTmuxIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6973C2936B36B0BFE904B022 /* MoriTmuxIsolationTests.swift */; }; + C5CA42EC2B7413DB3A326D73 /* GhosttyScrollPhysicsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C290B472A5F07F07F8B1DA7 /* GhosttyScrollPhysicsView.swift */; }; C7B874D9B7973D33D6D7E5D6 /* NIOSSH in Frameworks */ = {isa = PBXBuildFile; productRef = 2B047F037703450AD7431F98 /* NIOSSH */; }; - DE5D4372E774A67A9B411667 /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */; }; + D33F9D8A01C19333113BE7B9 /* GhosttyTerminalCompositionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B36247C24B0EFA6F0FAF9F3 /* GhosttyTerminalCompositionStateTests.swift */; }; + D59928A947468A35FBE0FA53 /* GhosttyPaneScrollContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04577A2C3BBC8458C5A60A76 /* GhosttyPaneScrollContainerView.swift */; }; + DACDAB6BF863B2DE4F81F8A9 /* GhosttyTerminalViewportCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8CA8956F700B0020F44AED /* GhosttyTerminalViewportCoordinatorTests.swift */; }; + E4C3B69390643F74AAE51D48 /* GhosttyPublishedFrameObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A49304A7038A93C9A55D7F2C /* GhosttyPublishedFrameObserver.swift */; }; + E7370773B910676A973FD85C /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443669BFE66D88CAC8DCCEE2 /* DeterministicTmuxControlTransport.swift */; }; + E75088D081F5457454778A3D /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E9E52C78F643675B58F0BA /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */; }; + E84DA64184225212B72BE0EA /* GhosttyScrollDeltaBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523D89AB51C587C880ABC74F /* GhosttyScrollDeltaBudgetTests.swift */; }; + EBF5A90730794909C6C63A91 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AC557BE4951B52EE25ED7B /* GhosttyKit.xcframework */; }; + EDAC14212B0E6E2F7CD8B3CA /* GhosttyModifierState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB587FE0CD381E9401F1040A /* GhosttyModifierState.swift */; }; EDD8C4E66770445F478F5BA5 /* RemoteRootModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1E077AE28FE61640CCBFBC /* RemoteRootModel.swift */; }; - EF60EB78984BBF1E9A3FFC35 /* GhosttyKitRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */; }; + F04B667AE1CBE572A7553EEE /* GhosttyTerminalResponderFocusPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E137716C1A4146436A28685D /* GhosttyTerminalResponderFocusPolicy.swift */; }; + F1D09D202AF835D9ED07031C /* GhosttyKeyboardCursorTrackpad.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A15305D227B6DD4A6C8CBE /* GhosttyKeyboardCursorTrackpad.swift */; }; + F4684D11FED84F66904D7C0D /* GhosttyKeyboardCursorTrackpadHUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1839CAA9C61F3104CB8231C6 /* GhosttyKeyboardCursorTrackpadHUD.swift */; }; F80DC80D0F8E3E17AD450B98 /* Phase5AgentMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0323091B8EC347B211B0AF /* Phase5AgentMetadataTests.swift */; }; + FA8F3FA0C8EE6BB056279187 /* GhosttyTerminalInputCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A597D49CF7A1A57D33B9A7E2 /* GhosttyTerminalInputCoordinator.swift */; }; + FBCC61A7E5D38B8184FF864E /* GhosttySurfaceScrollGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A50387A301B14D8E30A167 /* GhosttySurfaceScrollGesture.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 5BF2A3B9CF68B3969A12FD35 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 04D4F671C0A85A6697E3F07E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9C93F42E4A058106E7F7B7D9; + remoteInfo = MoriRemoteTerminal; + }; + C44F5AB5EF476577CC2B93BD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 04D4F671C0A85A6697E3F07E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9C93F42E4A058106E7F7B7D9; + remoteInfo = MoriRemoteTerminal; + }; F94EA73662EAB769336ACE5F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 04D4F671C0A85A6697E3F07E /* Project object */; @@ -57,55 +136,136 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 01AE8D2B54D6CB377400488B /* SSHImageUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHImageUpload.swift; sourceTree = ""; }; 02AC557BE4951B52EE25ED7B /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GhosttyKit.xcframework; path = ../Frameworks/GhosttyKit.xcframework; sourceTree = ""; }; + 04577A2C3BBC8458C5A60A76 /* GhosttyPaneScrollContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPaneScrollContainerView.swift; sourceTree = ""; }; + 0544BADF4E27E64B50E2BBC9 /* GhosttyTmuxPrefixInputBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxPrefixInputBufferTests.swift; sourceTree = ""; }; + 076F9259ECBB8E9DE2737FB2 /* TmuxSessionLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionLink.swift; sourceTree = ""; }; + 07F95080D57FCEAFA52CA791 /* TmuxTerminalSessionShutdownDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalSessionShutdownDrainTests.swift; sourceTree = ""; }; 0A0323091B8EC347B211B0AF /* Phase5AgentMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Phase5AgentMetadataTests.swift; sourceTree = ""; }; 0B2C6DECBA8C1C75B1A429CD /* Phase4ShellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Phase4ShellTests.swift; sourceTree = ""; }; + 0B36247C24B0EFA6F0FAF9F3 /* GhosttyTerminalCompositionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalCompositionStateTests.swift; sourceTree = ""; }; 0EBBE7E60EEF6A3363F73F0A /* MoriRemoteDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteDependencies.swift; sourceTree = ""; }; - 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalProbe.swift; sourceTree = ""; }; + 0F28E32D6C407ED02A977516 /* GhosttyKitRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitRuntime.swift; sourceTree = ""; }; + 11D7B7A9198618BF7CCA853B /* MoriRemoteTerminalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MoriRemoteTerminalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 13347B90704FC1FF2D28C63A /* GhosttyKeypadSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeypadSheet.swift; sourceTree = ""; }; + 1839CAA9C61F3104CB8231C6 /* GhosttyKeyboardCursorTrackpadHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardCursorTrackpadHUD.swift; sourceTree = ""; }; + 1B49983C9FB3783CBF224C23 /* GhosttyTerminalResponderTextInputShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderTextInputShim.swift; sourceTree = ""; }; + 20230C5AB4EBA556DBF90A42 /* GhosttyTerminalCoreViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalCoreViewTests.swift; sourceTree = ""; }; + 20D69DE7D59B2C5BA8D15339 /* GhosttyManagedSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyManagedSurface.swift; sourceTree = ""; }; 210BF0AD6F094B3D96D0A36D /* SavedModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedModels.swift; sourceTree = ""; }; 21CE86B5FC370573653D3245 /* SSHTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHTmuxControlTransport.swift; sourceTree = ""; }; + 22E9E52C78F643675B58F0BA /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalSurfaceInteractionOutcomeTests.swift; sourceTree = ""; }; + 24733F909F325E7D558F8E31 /* GhosttyTerminalResponderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderView.swift; sourceTree = ""; }; 2E4A9E8D51098FB9FE41A085 /* HostTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTrust.swift; sourceTree = ""; }; + 34AD49A6DC5B4A977C92A64A /* Phase6TerminalOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Phase6TerminalOwnershipTests.swift; sourceTree = ""; }; + 3C290B472A5F07F07F8B1DA7 /* GhosttyScrollPhysicsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollPhysicsView.swift; sourceTree = ""; }; + 3DF6A6CC81A7E40BF7BB825E /* GhosttySurfaceMouseEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceMouseEvent.swift; sourceTree = ""; }; 405397F8D3FACA71D62B7717 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + 443669BFE66D88CAC8DCCEE2 /* DeterministicTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterministicTmuxControlTransport.swift; sourceTree = ""; }; + 44A50387A301B14D8E30A167 /* GhosttySurfaceScrollGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceScrollGesture.swift; sourceTree = ""; }; 46D3CED1154E19B734CC9AA8 /* SSHAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAuth.swift; sourceTree = ""; }; 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMigrationTests.swift; sourceTree = ""; }; 46F3182F8D251430A10B298E /* MoriRemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteApp.swift; sourceTree = ""; }; - 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterministicTmuxControlTransport.swift; sourceTree = ""; }; + 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChrome.swift; sourceTree = ""; }; + 49212AC593537B7DD8346F22 /* SSHRootAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHRootAccess.swift; sourceTree = ""; }; 495654252F6CE455BE0201B3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 4E8CA8956F700B0020F44AED /* GhosttyTerminalViewportCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewportCoordinatorTests.swift; sourceTree = ""; }; + 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitControlSurface.swift; sourceTree = ""; }; + 523D89AB51C587C880ABC74F /* GhosttyScrollDeltaBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollDeltaBudgetTests.swift; sourceTree = ""; }; + 5EF2A6B22DB0665EE8DE530E /* ImageInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageInputTests.swift; sourceTree = ""; }; + 62BCDBA618379FE5456A1C57 /* GhosttyTerminalResponderFocusPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderFocusPolicyTests.swift; sourceTree = ""; }; 64BCCA48638A9FE1582D7514 /* MoriRemoteTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MoriRemoteTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControl.swift; sourceTree = ""; }; + 676FDCFD6BAB360A6FDE7151 /* GhosttyViewportSizing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyViewportSizing.swift; sourceTree = ""; }; + 678146824749C1C540C8D179 /* GhosttyTerminalResponderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderViewTests.swift; sourceTree = ""; }; + 6973C2936B36B0BFE904B022 /* MoriTmuxIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriTmuxIsolationTests.swift; sourceTree = ""; }; 6C511C1314958A8D89FC53C8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; - 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitRuntime.swift; sourceTree = ""; }; + 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySingleViewportView.swift; sourceTree = ""; }; + 7D53B10B6CBF18D7DDAF1B27 /* SSHTmuxSessionDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHTmuxSessionDiscovery.swift; sourceTree = ""; }; 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Phase2TransportTests.swift; sourceTree = ""; }; 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMetadataProjector.swift; sourceTree = ""; }; - 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitABIProbe.swift; sourceTree = ""; }; - 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPaneSurface.swift; sourceTree = ""; }; + 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChromeActionsTests.swift; sourceTree = ""; }; + 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControlViewport.swift; sourceTree = ""; }; + 8C2B40FFD54FA5E83ED47253 /* TmuxIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxIdentity.swift; sourceTree = ""; }; + 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxPrefixInputBuffer.swift; sourceTree = ""; }; + 9659C2FCA72254982C81D686 /* GhosttyTerminalViewportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewportCoordinator.swift; sourceTree = ""; }; + 991EF1D262BD8AA86A113A21 /* GhosttyKitControlSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitControlSurfaceTests.swift; sourceTree = ""; }; 9B1E077AE28FE61640CCBFBC /* RemoteRootModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRootModel.swift; sourceTree = ""; }; + 9BD23FE8E78A324650AA77CC /* GhosttyTerminalCompositionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalCompositionState.swift; sourceTree = ""; }; + 9DAD61A087944EDE21D81BDA /* MoriRemoteTerminalFacadeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteTerminalFacadeTests.swift; sourceTree = ""; }; + 9F7ADE8147E90699397F2846 /* GhosttyImageAttachmentSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyImageAttachmentSheet.swift; sourceTree = ""; }; + A42139069AA3C15AAA45B5F1 /* TerminalSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettings.swift; sourceTree = ""; }; + A49304A7038A93C9A55D7F2C /* GhosttyPublishedFrameObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPublishedFrameObserver.swift; sourceTree = ""; }; + A597D49CF7A1A57D33B9A7E2 /* GhosttyTerminalInputCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalInputCoordinator.swift; sourceTree = ""; }; + A7D76C58B99537D89078D58B /* GhosttyTerminalPresentationProjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalPresentationProjector.swift; sourceTree = ""; }; + A7F69592AA4A0788015A2A51 /* TmuxPaneSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxPaneSurface.swift; sourceTree = ""; }; B05E4FE02E3C3962771154D7 /* Stores.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stores.swift; sourceTree = ""; }; + B29878C376F0AD7940205B86 /* GhosttyTerminalSurfaceInteractionOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalSurfaceInteractionOutcome.swift; sourceTree = ""; }; + B2CE71956CBE6B24690CB0E7 /* GhosttyImageAttachmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyImageAttachmentTests.swift; sourceTree = ""; }; + B32DC599E9268D13F97F75BC /* TmuxTerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalSession.swift; sourceTree = ""; }; + B3EBA5074382AC1C70BEB15E /* GhosttyManagedSurfaceLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyManagedSurfaceLookup.swift; sourceTree = ""; }; B402FCCB191BE4C43EE332CF /* SSHTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHTransportTests.swift; sourceTree = ""; }; B88BDAAB702E98FDD084041C /* LegacyMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMigration.swift; sourceTree = ""; }; - BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxRuntime.swift; sourceTree = ""; }; + BDD098C91CB7C0CA340592E1 /* MoriTmuxNativeStartupIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriTmuxNativeStartupIsolationTests.swift; sourceTree = ""; }; + BE2FE3FEDF880BE280656A2D /* GhosttyKeyboardChromeModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChromeModeTests.swift; sourceTree = ""; }; BEC31B48C129D1E076933F45 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - C1C4381824F6C5E7604AAF7A /* TmuxSessionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionController.swift; sourceTree = ""; }; - C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Phase3RuntimeTests.swift; sourceTree = ""; }; + BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceKeyEvent.swift; sourceTree = ""; }; + C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardVisibilityProjection.swift; sourceTree = ""; }; + C6CD869EC2CF2DD3481DE8E9 /* TmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControlTransport.swift; sourceTree = ""; }; + C87D3C5A05813B59CE76559C /* GhosttyModifierStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyModifierStateTests.swift; sourceTree = ""; }; + C8CC6B54296A1389D419EFDB /* GhosttySurfaceScrollGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceScrollGestureTests.swift; sourceTree = ""; }; + CDAD6C595773E27C2302A0E2 /* MoriRemoteTerminal.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MoriRemoteTerminal.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CF39528D1FAB67FE7906605F /* GhosttyRuntimeTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRuntimeTrace.swift; sourceTree = ""; }; + D5A15305D227B6DD4A6C8CBE /* GhosttyKeyboardCursorTrackpad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardCursorTrackpad.swift; sourceTree = ""; }; D631082FBB8A05D4BA1F801F /* TmuxShellCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxShellCommand.swift; sourceTree = ""; }; D77CF33F94CEBE45B61807FB /* CitadelSSHTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CitadelSSHTransport.swift; sourceTree = ""; }; + D80AB026D4A388B7B0DCEBD4 /* MoriRemoteTerminalProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteTerminalProbe.swift; sourceTree = ""; }; + DB587FE0CD381E9401F1040A /* GhosttyModifierState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyModifierState.swift; sourceTree = ""; }; DC5CE2DEDF2FDD48461599ED /* SSHRootPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHRootPool.swift; sourceTree = ""; }; DDA4083A9512502482A6CECA /* RemoteRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRootView.swift; sourceTree = ""; }; + E137716C1A4146436A28685D /* GhosttyTerminalResponderFocusPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalResponderFocusPolicy.swift; sourceTree = ""; }; E6317A1B56EED0B86D2F4D5D /* MoriRemote.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = MoriRemote.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E68FBB36ECEC4F7C77BA31B4 /* TmuxTerminalScreenAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalScreenAdapter.swift; sourceTree = ""; }; E788C063B75F299CBC1D0165 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardVisibilityProjectionTests.swift; sourceTree = ""; }; + EC055273524821D3351EF36A /* GhosttyTerminalCoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalCoreView.swift; sourceTree = ""; }; + F127EC360B82F2E804AF82D3 /* TmuxTerminalScreenAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalScreenAdapterTests.swift; sourceTree = ""; }; + F4572DCA39CA3C26E446A98D /* GhosttyPublishedFrameObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPublishedFrameObserverTests.swift; sourceTree = ""; }; + F6FCBEFA092CA08F879265D1 /* TmuxSessionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionController.swift; sourceTree = ""; }; + F7595020B1AEF0AE384FF639 /* Haptic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptic.swift; sourceTree = ""; }; + F848DC8CEFD90068DE779866 /* TmuxSessionLinkWriteFailureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionLinkWriteFailureTests.swift; sourceTree = ""; }; + F9429B7B3608382ECA97B080 /* GhosttyTerminalPrefixFlushLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalPrefixFlushLifecycleTests.swift; sourceTree = ""; }; + FCE453EA5ED8C3C00E2EFF00 /* MoriRemoteTerminalFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteTerminalFacade.swift; sourceTree = ""; }; + FFF190110E6756726243B4B5 /* GhosttyRendererLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRendererLayer.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 524C2EA8B7CC0664F728458E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8A46F1EDB03E40047674D287 /* MoriRemoteTerminal.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6EE2939CF32C554B39C68D99 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EBF5A90730794909C6C63A91 /* GhosttyKit.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; F7510CFC46875E2AE7CF2B7A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 954E0187B8822478AC53A4A6 /* MoriRemoteTerminal.framework in Frameworks */, 85ADFC9BAA17017ECA067C5B /* Citadel in Frameworks */, 950C33367217BF06FA564D2B /* NIO in Frameworks */, 5D0A706E5CC15CA815D2205C /* NIOPosix in Frameworks */, C7B874D9B7973D33D6D7E5D6 /* NIOSSH in Frameworks */, - 61910C0D99CE3C03CDCAA824 /* GhosttyKit.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -126,25 +286,41 @@ D77CF33F94CEBE45B61807FB /* CitadelSSHTransport.swift */, 2E4A9E8D51098FB9FE41A085 /* HostTrust.swift */, 46D3CED1154E19B734CC9AA8 /* SSHAuth.swift */, + 01AE8D2B54D6CB377400488B /* SSHImageUpload.swift */, 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */, + 49212AC593537B7DD8346F22 /* SSHRootAccess.swift */, DC5CE2DEDF2FDD48461599ED /* SSHRootPool.swift */, ); path = SSH; sourceTree = ""; }; + 30DDCCC0FF189CEABC730FB5 /* Tmux */ = { + isa = PBXGroup; + children = ( + CF39528D1FAB67FE7906605F /* GhosttyRuntimeTrace.swift */, + C6CD869EC2CF2DD3481DE8E9 /* TmuxControlTransport.swift */, + 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */, + 8C2B40FFD54FA5E83ED47253 /* TmuxIdentity.swift */, + A7F69592AA4A0788015A2A51 /* TmuxPaneSurface.swift */, + F6FCBEFA092CA08F879265D1 /* TmuxSessionController.swift */, + 076F9259ECBB8E9DE2737FB2 /* TmuxSessionLink.swift */, + E68FBB36ECEC4F7C77BA31B4 /* TmuxTerminalScreenAdapter.swift */, + B32DC599E9268D13F97F75BC /* TmuxTerminalSession.swift */, + ); + path = Tmux; + sourceTree = ""; + }; 3AA0698F729DC6B09848C13A /* MoriRemote */ = { isa = PBXGroup; children = ( 63BE1FDACD0B6E72C3E5E7E5 /* App */, B063AA8B3750F1CA282E879F /* Domain */, - 4951FA280BE48E9B2C0D8D06 /* Ghostty */, BB548A1B5870D127A31579E5 /* Persistence */, 2A2C3E328CF35E08821257D9 /* Resources */, 30D66DA26907E44CBF500E44 /* SSH */, 85F3C6650D4ABEA622250660 /* Tmux */, B723E13FED944C47F0F5BB37 /* Views */, 495654252F6CE455BE0201B3 /* Assets.xcassets */, - 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.swift */, 6C511C1314958A8D89FC53C8 /* Info.plist */, 46F3182F8D251430A10B298E /* MoriRemoteApp.swift */, E788C063B75F299CBC1D0165 /* PrivacyInfo.xcprivacy */, @@ -152,15 +328,35 @@ path = MoriRemote; sourceTree = ""; }; - 4951FA280BE48E9B2C0D8D06 /* Ghostty */ = { + 5C295A7834704597EC4619C0 /* MoriRemoteTerminalTests */ = { isa = PBXGroup; children = ( - 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */, - 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */, - 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.swift */, - BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */, + 443669BFE66D88CAC8DCCEE2 /* DeterministicTmuxControlTransport.swift */, + B2CE71956CBE6B24690CB0E7 /* GhosttyImageAttachmentTests.swift */, + 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */, + BE2FE3FEDF880BE280656A2D /* GhosttyKeyboardChromeModeTests.swift */, + EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */, + 991EF1D262BD8AA86A113A21 /* GhosttyKitControlSurfaceTests.swift */, + C87D3C5A05813B59CE76559C /* GhosttyModifierStateTests.swift */, + F4572DCA39CA3C26E446A98D /* GhosttyPublishedFrameObserverTests.swift */, + 523D89AB51C587C880ABC74F /* GhosttyScrollDeltaBudgetTests.swift */, + C8CC6B54296A1389D419EFDB /* GhosttySurfaceScrollGestureTests.swift */, + 0B36247C24B0EFA6F0FAF9F3 /* GhosttyTerminalCompositionStateTests.swift */, + 20230C5AB4EBA556DBF90A42 /* GhosttyTerminalCoreViewTests.swift */, + F9429B7B3608382ECA97B080 /* GhosttyTerminalPrefixFlushLifecycleTests.swift */, + 62BCDBA618379FE5456A1C57 /* GhosttyTerminalResponderFocusPolicyTests.swift */, + 678146824749C1C540C8D179 /* GhosttyTerminalResponderViewTests.swift */, + 22E9E52C78F643675B58F0BA /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */, + 4E8CA8956F700B0020F44AED /* GhosttyTerminalViewportCoordinatorTests.swift */, + 0544BADF4E27E64B50E2BBC9 /* GhosttyTmuxPrefixInputBufferTests.swift */, + 9DAD61A087944EDE21D81BDA /* MoriRemoteTerminalFacadeTests.swift */, + 6973C2936B36B0BFE904B022 /* MoriTmuxIsolationTests.swift */, + BDD098C91CB7C0CA340592E1 /* MoriTmuxNativeStartupIsolationTests.swift */, + F848DC8CEFD90068DE779866 /* TmuxSessionLinkWriteFailureTests.swift */, + F127EC360B82F2E804AF82D3 /* TmuxTerminalScreenAdapterTests.swift */, + 07F95080D57FCEAFA52CA791 /* TmuxTerminalSessionShutdownDrainTests.swift */, ); - path = Ghostty; + path = MoriRemoteTerminalTests; sourceTree = ""; }; 63BE1FDACD0B6E72C3E5E7E5 /* App */ = { @@ -176,15 +372,24 @@ isa = PBXGroup; children = ( 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */, - 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */, 21CE86B5FC370573653D3245 /* SSHTmuxControlTransport.swift */, - 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */, - C1C4381824F6C5E7604AAF7A /* TmuxSessionController.swift */, + 7D53B10B6CBF18D7DDAF1B27 /* SSHTmuxSessionDiscovery.swift */, D631082FBB8A05D4BA1F801F /* TmuxShellCommand.swift */, ); path = Tmux; sourceTree = ""; }; + 93B99C852B502FC6650738F1 /* MoriRemoteTerminal */ = { + isa = PBXGroup; + children = ( + FEF282EBB6D60331E0FEA772 /* App */, + E9BDACDAE156BE4F317E876B /* Domain */, + D08B8BC473350934D03F8E02 /* Ghostty */, + 30DDCCC0FF189CEABC730FB5 /* Tmux */, + ); + path = MoriRemoteTerminal; + sourceTree = ""; + }; B063AA8B3750F1CA282E879F /* Domain */ = { isa = PBXGroup; children = ( @@ -214,6 +419,8 @@ isa = PBXGroup; children = ( 3AA0698F729DC6B09848C13A /* MoriRemote */, + 93B99C852B502FC6650738F1 /* MoriRemoteTerminal */, + 5C295A7834704597EC4619C0 /* MoriRemoteTerminalTests */, EC1D22246639767E9C4475A3 /* MoriRemoteTests */, E6A9207E06A0AA3FF84EEAD4 /* Frameworks */, CF688A13389F8A9D4F04A9A0 /* Products */, @@ -224,11 +431,50 @@ isa = PBXGroup; children = ( E6317A1B56EED0B86D2F4D5D /* MoriRemote.app */, + CDAD6C595773E27C2302A0E2 /* MoriRemoteTerminal.framework */, + 11D7B7A9198618BF7CCA853B /* MoriRemoteTerminalTests.xctest */, 64BCCA48638A9FE1582D7514 /* MoriRemoteTests.xctest */, ); name = Products; sourceTree = ""; }; + D08B8BC473350934D03F8E02 /* Ghostty */ = { + isa = PBXGroup; + children = ( + 9F7ADE8147E90699397F2846 /* GhosttyImageAttachmentSheet.swift */, + 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */, + D5A15305D227B6DD4A6C8CBE /* GhosttyKeyboardCursorTrackpad.swift */, + 1839CAA9C61F3104CB8231C6 /* GhosttyKeyboardCursorTrackpadHUD.swift */, + C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */, + 13347B90704FC1FF2D28C63A /* GhosttyKeypadSheet.swift */, + 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */, + 0F28E32D6C407ED02A977516 /* GhosttyKitRuntime.swift */, + 20D69DE7D59B2C5BA8D15339 /* GhosttyManagedSurface.swift */, + B3EBA5074382AC1C70BEB15E /* GhosttyManagedSurfaceLookup.swift */, + DB587FE0CD381E9401F1040A /* GhosttyModifierState.swift */, + 04577A2C3BBC8458C5A60A76 /* GhosttyPaneScrollContainerView.swift */, + A49304A7038A93C9A55D7F2C /* GhosttyPublishedFrameObserver.swift */, + FFF190110E6756726243B4B5 /* GhosttyRendererLayer.swift */, + 3C290B472A5F07F07F8B1DA7 /* GhosttyScrollPhysicsView.swift */, + 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */, + BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */, + 3DF6A6CC81A7E40BF7BB825E /* GhosttySurfaceMouseEvent.swift */, + 44A50387A301B14D8E30A167 /* GhosttySurfaceScrollGesture.swift */, + 9BD23FE8E78A324650AA77CC /* GhosttyTerminalCompositionState.swift */, + EC055273524821D3351EF36A /* GhosttyTerminalCoreView.swift */, + A597D49CF7A1A57D33B9A7E2 /* GhosttyTerminalInputCoordinator.swift */, + A7D76C58B99537D89078D58B /* GhosttyTerminalPresentationProjector.swift */, + E137716C1A4146436A28685D /* GhosttyTerminalResponderFocusPolicy.swift */, + 1B49983C9FB3783CBF224C23 /* GhosttyTerminalResponderTextInputShim.swift */, + 24733F909F325E7D558F8E31 /* GhosttyTerminalResponderView.swift */, + B29878C376F0AD7940205B86 /* GhosttyTerminalSurfaceInteractionOutcome.swift */, + 9659C2FCA72254982C81D686 /* GhosttyTerminalViewportCoordinator.swift */, + 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */, + 676FDCFD6BAB360A6FDE7151 /* GhosttyViewportSizing.swift */, + ); + path = Ghostty; + sourceTree = ""; + }; E6A9207E06A0AA3FF84EEAD4 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -237,19 +483,38 @@ name = Frameworks; sourceTree = ""; }; + E9BDACDAE156BE4F317E876B /* Domain */ = { + isa = PBXGroup; + children = ( + A42139069AA3C15AAA45B5F1 /* TerminalSettings.swift */, + ); + path = Domain; + sourceTree = ""; + }; EC1D22246639767E9C4475A3 /* MoriRemoteTests */ = { isa = PBXGroup; children = ( + 5EF2A6B22DB0665EE8DE530E /* ImageInputTests.swift */, 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */, 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */, - C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */, 0B2C6DECBA8C1C75B1A429CD /* Phase4ShellTests.swift */, 0A0323091B8EC347B211B0AF /* Phase5AgentMetadataTests.swift */, + 34AD49A6DC5B4A977C92A64A /* Phase6TerminalOwnershipTests.swift */, B402FCCB191BE4C43EE332CF /* SSHTransportTests.swift */, ); path = MoriRemoteTests; sourceTree = ""; }; + FEF282EBB6D60331E0FEA772 /* App */ = { + isa = PBXGroup; + children = ( + F7595020B1AEF0AE384FF639 /* Haptic.swift */, + FCE453EA5ED8C3C00E2EFF00 /* MoriRemoteTerminalFacade.swift */, + D80AB026D4A388B7B0DCEBD4 /* MoriRemoteTerminalProbe.swift */, + ); + path = App; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -265,6 +530,7 @@ buildRules = ( ); dependencies = ( + B1A880AA7F84442B2FBEBFCD /* PBXTargetDependency */, ); name = MoriRemote; packageProductDependencies = ( @@ -277,6 +543,24 @@ productReference = E6317A1B56EED0B86D2F4D5D /* MoriRemote.app */; productType = "com.apple.product-type.application"; }; + 9C93F42E4A058106E7F7B7D9 /* MoriRemoteTerminal */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9538F7C2BB5F510B72A5DBD1 /* Build configuration list for PBXNativeTarget "MoriRemoteTerminal" */; + buildPhases = ( + 60D1997B92E100918C22BD89 /* Sources */, + 6EE2939CF32C554B39C68D99 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MoriRemoteTerminal; + packageProductDependencies = ( + ); + productName = MoriRemoteTerminal; + productReference = CDAD6C595773E27C2302A0E2 /* MoriRemoteTerminal.framework */; + productType = "com.apple.product-type.framework"; + }; B6AEABE71E27F93510CA50A8 /* MoriRemoteTests */ = { isa = PBXNativeTarget; buildConfigurationList = AC164D6FBF79B9F2E0C42756 /* Build configuration list for PBXNativeTarget "MoriRemoteTests" */; @@ -295,6 +579,25 @@ productReference = 64BCCA48638A9FE1582D7514 /* MoriRemoteTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + CC5FEE20D68E195A5B4E8935 /* MoriRemoteTerminalTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 77EC10B938866EFDF2FBFA07 /* Build configuration list for PBXNativeTarget "MoriRemoteTerminalTests" */; + buildPhases = ( + D8FB46F5FAA72D03EC61EB9D /* Sources */, + 524C2EA8B7CC0664F728458E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + F0CE8679B7360DD5B9C75B49 /* PBXTargetDependency */, + ); + name = MoriRemoteTerminalTests; + packageProductDependencies = ( + ); + productName = MoriRemoteTerminalTests; + productReference = 11D7B7A9198618BF7CCA853B /* MoriRemoteTerminalTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -330,6 +633,8 @@ projectRoot = ""; targets = ( 219FEED5B577C565EBA81436 /* MoriRemote */, + 9C93F42E4A058106E7F7B7D9 /* MoriRemoteTerminal */, + CC5FEE20D68E195A5B4E8935 /* MoriRemoteTerminalTests */, B6AEABE71E27F93510CA50A8 /* MoriRemoteTests */, ); }; @@ -371,15 +676,66 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 60D1997B92E100918C22BD89 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 26B09ED95683BE1B0C6484A6 /* GhosttyImageAttachmentSheet.swift in Sources */, + 25683F5BC20807C3F9ADE249 /* GhosttyKeyboardChrome.swift in Sources */, + F1D09D202AF835D9ED07031C /* GhosttyKeyboardCursorTrackpad.swift in Sources */, + F4684D11FED84F66904D7C0D /* GhosttyKeyboardCursorTrackpadHUD.swift in Sources */, + 9F941D728EA6D2D9DDD8E2DC /* GhosttyKeyboardVisibilityProjection.swift in Sources */, + 0E1ABB2DB77400492C766DB7 /* GhosttyKeypadSheet.swift in Sources */, + 0A3E5C8D6D19481EDBA77835 /* GhosttyKitControlSurface.swift in Sources */, + A82ADEB60A40355F4B307D93 /* GhosttyKitRuntime.swift in Sources */, + 3CC26EB85FA319E85F2D36BC /* GhosttyManagedSurface.swift in Sources */, + 57EF884059F194983540CBCB /* GhosttyManagedSurfaceLookup.swift in Sources */, + EDAC14212B0E6E2F7CD8B3CA /* GhosttyModifierState.swift in Sources */, + D59928A947468A35FBE0FA53 /* GhosttyPaneScrollContainerView.swift in Sources */, + E4C3B69390643F74AAE51D48 /* GhosttyPublishedFrameObserver.swift in Sources */, + 8811DFD8D63BC3F6263EFCF1 /* GhosttyRendererLayer.swift in Sources */, + AFE0787AFAB5605F12537763 /* GhosttyRuntimeTrace.swift in Sources */, + C5CA42EC2B7413DB3A326D73 /* GhosttyScrollPhysicsView.swift in Sources */, + AABCC196B2F7F17A6BA540BE /* GhosttySingleViewportView.swift in Sources */, + 914D5C34DBB457D970A90C25 /* GhosttySurfaceKeyEvent.swift in Sources */, + 1B4ABC9EE1AAD05752C0DDDE /* GhosttySurfaceMouseEvent.swift in Sources */, + FBCC61A7E5D38B8184FF864E /* GhosttySurfaceScrollGesture.swift in Sources */, + 2302A7B4A772047379C73067 /* GhosttyTerminalCompositionState.swift in Sources */, + 6D55ADE98CE693CA6802197D /* GhosttyTerminalCoreView.swift in Sources */, + FA8F3FA0C8EE6BB056279187 /* GhosttyTerminalInputCoordinator.swift in Sources */, + 2E5B1E954FE1011C8C057D50 /* GhosttyTerminalPresentationProjector.swift in Sources */, + F04B667AE1CBE572A7553EEE /* GhosttyTerminalResponderFocusPolicy.swift in Sources */, + AAF3070C7F3F444471211195 /* GhosttyTerminalResponderTextInputShim.swift in Sources */, + 3E7D9500BA676CB7BF115D4E /* GhosttyTerminalResponderView.swift in Sources */, + 4990AE0712D61323469D3EF4 /* GhosttyTerminalSurfaceInteractionOutcome.swift in Sources */, + 3A1491877954A342656AEBF9 /* GhosttyTerminalViewportCoordinator.swift in Sources */, + C15730869E5A9CF770E3D2CC /* GhosttyTmuxPrefixInputBuffer.swift in Sources */, + B191529CCEFA8B8A6161B20E /* GhosttyViewportSizing.swift in Sources */, + 31097F77B38B348F9E7E0BB3 /* Haptic.swift in Sources */, + C3F4EADB4D52F73F75D6E750 /* MoriRemoteTerminalFacade.swift in Sources */, + 9213AD0FB635C3806A8C0010 /* MoriRemoteTerminalProbe.swift in Sources */, + ACE22CEBB527F6CDA6C44470 /* TerminalSettings.swift in Sources */, + AE2B7491776C8FC853899464 /* TmuxControlTransport.swift in Sources */, + C34E3D3F89FF5F790D22D0C0 /* TmuxControlViewport.swift in Sources */, + 7ACD00781983EB3E3052D10F /* TmuxIdentity.swift in Sources */, + 7B1B93EEB1DE05CA1966D556 /* TmuxPaneSurface.swift in Sources */, + 7608ABD730F2113B6100141F /* TmuxSessionController.swift in Sources */, + 3D1C909CBEFA2B7BEC0D33B6 /* TmuxSessionLink.swift in Sources */, + 977DBF133BDD92FAFED6FAAB /* TmuxTerminalScreenAdapter.swift in Sources */, + 968FC5B380206254527E3718 /* TmuxTerminalSession.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 76A5C3E337C059514C36B29A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9F04137A8480985A7635FE52 /* ImageInputTests.swift in Sources */, 54F48944A9E87F22A490B8D4 /* LegacyMigrationTests.swift in Sources */, 78FE94F2F5065228E0F8B259 /* Phase2TransportTests.swift in Sources */, - 7847C4893AE9B6481BD63CEB /* Phase3RuntimeTests.swift in Sources */, 3FAC41CC848F16DDB123A9F6 /* Phase4ShellTests.swift in Sources */, F80DC80D0F8E3E17AD450B98 /* Phase5AgentMetadataTests.swift in Sources */, + 99D35E837708840A52D7175B /* Phase6TerminalOwnershipTests.swift in Sources */, 3207EDDDF0FDBE441D981A8B /* SSHTransportTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -390,12 +746,6 @@ files = ( 54F38E40C11EB8E48022762A /* AgentMetadataProjector.swift in Sources */, 16DF0B72EA0BA73D616F61D5 /* CitadelSSHTransport.swift in Sources */, - DE5D4372E774A67A9B411667 /* DeterministicTmuxControlTransport.swift in Sources */, - 3D5ABC739FD3170547500C7E /* GhosttyKitABIProbe.swift in Sources */, - EF60EB78984BBF1E9A3FFC35 /* GhosttyKitRuntime.swift in Sources */, - A64AA54831409250D2D7AC3A /* GhosttyPaneSurface.swift in Sources */, - 40234B3C274CDF5CBDF2746B /* GhosttyTerminalProbe.swift in Sources */, - BE15F82CE37D35B536E662ED /* GhosttyTmuxRuntime.swift in Sources */, 51CDD881F49D4124117A3D7D /* HostTrust.swift in Sources */, 6E8368DBBA57DB44BDC8E1E5 /* LegacyMigration.swift in Sources */, 376EEC1B30EE8565C4B085D1 /* MoriRemoteApp.swift in Sources */, @@ -403,17 +753,49 @@ EDD8C4E66770445F478F5BA5 /* RemoteRootModel.swift in Sources */, 6C61BF2005608B1366F0CE31 /* RemoteRootView.swift in Sources */, 8A1DC8FFA1F8B93734D4E0E3 /* SSHAuth.swift in Sources */, + 0CD0FDE83F290E70D2B82BAB /* SSHImageUpload.swift in Sources */, 68C87C51C7B1430199E64AAC /* SSHPrivateKeyInspector.swift in Sources */, + 5E5DC6D129A41CB33E44B40B /* SSHRootAccess.swift in Sources */, 553D7FA2654275C5B609DB67 /* SSHRootPool.swift in Sources */, 64772A470A3EB7EE2CD92BA8 /* SSHTmuxControlTransport.swift in Sources */, + 52063DA59155A7D5A809FE35 /* SSHTmuxSessionDiscovery.swift in Sources */, 2EA225F941CDB8FE36CA7A8F /* SavedModels.swift in Sources */, 14EC21ABAE7D514B7F68225A /* Stores.swift in Sources */, - 29E5C06C8FB1718904533EEE /* TmuxControl.swift in Sources */, - 4E34F7CDB1296EF5B949969A /* TmuxSessionController.swift in Sources */, 4B2ADF9D0C7011A644E1104E /* TmuxShellCommand.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + D8FB46F5FAA72D03EC61EB9D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E7370773B910676A973FD85C /* DeterministicTmuxControlTransport.swift in Sources */, + 40753F5ADF7AF6E9BDB498D2 /* GhosttyImageAttachmentTests.swift in Sources */, + 656BC1E3C7AC26BC7C6FC1C6 /* GhosttyKeyboardChromeActionsTests.swift in Sources */, + 3A2DB1541BA69E76A0E29F66 /* GhosttyKeyboardChromeModeTests.swift in Sources */, + A17B820D2070516F1E059C96 /* GhosttyKeyboardVisibilityProjectionTests.swift in Sources */, + 12041C9D8ADD6871BE824AD5 /* GhosttyKitControlSurfaceTests.swift in Sources */, + 97F8C17610EA20C2D9B93496 /* GhosttyModifierStateTests.swift in Sources */, + 7A6776485CC915CC8E0444AC /* GhosttyPublishedFrameObserverTests.swift in Sources */, + E84DA64184225212B72BE0EA /* GhosttyScrollDeltaBudgetTests.swift in Sources */, + 3DF786F56C47B1E2B6EC0348 /* GhosttySurfaceScrollGestureTests.swift in Sources */, + D33F9D8A01C19333113BE7B9 /* GhosttyTerminalCompositionStateTests.swift in Sources */, + 52DDD20FA3AEF27F1DFAE907 /* GhosttyTerminalCoreViewTests.swift in Sources */, + 62D15BB204ED62613E6FE241 /* GhosttyTerminalPrefixFlushLifecycleTests.swift in Sources */, + C07777BEE0CE5B8012A02C74 /* GhosttyTerminalResponderFocusPolicyTests.swift in Sources */, + 04BF2F5618DC52CA420109BF /* GhosttyTerminalResponderViewTests.swift in Sources */, + E75088D081F5457454778A3D /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift in Sources */, + DACDAB6BF863B2DE4F81F8A9 /* GhosttyTerminalViewportCoordinatorTests.swift in Sources */, + 29F224E2CCC19FD837908D80 /* GhosttyTmuxPrefixInputBufferTests.swift in Sources */, + 545CF67F733324F87DE9CBEB /* MoriRemoteTerminalFacadeTests.swift in Sources */, + C598300440F9F8D28664A7A2 /* MoriTmuxIsolationTests.swift in Sources */, + 7D9CB9C5D0700BA450BD9954 /* MoriTmuxNativeStartupIsolationTests.swift in Sources */, + 3465552A378696C80FABA606 /* TmuxSessionLinkWriteFailureTests.swift in Sources */, + A8638E4BBE4B7CF1DD78F7A1 /* TmuxTerminalScreenAdapterTests.swift in Sources */, + 648919CAF60386D84ABC45D8 /* TmuxTerminalSessionShutdownDrainTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -422,6 +804,16 @@ target = 219FEED5B577C565EBA81436 /* MoriRemote */; targetProxy = F94EA73662EAB769336ACE5F /* PBXContainerItemProxy */; }; + B1A880AA7F84442B2FBEBFCD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9C93F42E4A058106E7F7B7D9 /* MoriRemoteTerminal */; + targetProxy = C44F5AB5EF476577CC2B93BD /* PBXContainerItemProxy */; + }; + F0CE8679B7360DD5B9C75B49 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9C93F42E4A058106E7F7B7D9 /* MoriRemoteTerminal */; + targetProxy = 5BF2A3B9CF68B3969A12FD35 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -456,6 +848,28 @@ }; name = Release; }; + 5C1C2292B5AD7DC936EEE0AB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + ); + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 69426152C9AAF1A96AC6E225 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -468,10 +882,6 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = "${DEVELOPMENT_TEAM}"; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "\"../Frameworks\"", - ); GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MoriRemote/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -483,7 +893,6 @@ OTHER_LDFLAGS = ( "$(inherited)", "-lc++", - "-Wl,-u,_ghostty_tmux_client_config_new", ); PRODUCT_BUNDLE_IDENTIFIER = "com.vaayne.mori-remote"; PRODUCT_NAME = MoriRemote; @@ -551,6 +960,100 @@ }; name = Release; }; + 7FD338A88E751945A03874FD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../Frameworks\"", + ); + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MACH_O_TYPE = staticlib; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + ); + PRODUCT_NAME = MoriRemoteTerminal; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + A341BE51FA3B793C16878D7E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../Frameworks\"", + ); + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MACH_O_TYPE = staticlib; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + ); + PRODUCT_NAME = MoriRemoteTerminal; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + B78CFEEADB3165EA69448716 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + ); + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; B92CA5DD46DFAC00DB97415E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -645,10 +1148,6 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = "${DEVELOPMENT_TEAM}"; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "\"../Frameworks\"", - ); GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MoriRemote/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -660,7 +1159,6 @@ OTHER_LDFLAGS = ( "$(inherited)", "-lc++", - "-Wl,-u,_ghostty_tmux_client_config_new", ); PRODUCT_BUNDLE_IDENTIFIER = "com.vaayne.mori-remote"; PRODUCT_NAME = MoriRemote; @@ -683,6 +1181,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 77EC10B938866EFDF2FBFA07 /* Build configuration list for PBXNativeTarget "MoriRemoteTerminalTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5C1C2292B5AD7DC936EEE0AB /* Debug */, + B78CFEEADB3165EA69448716 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 9336E4FEC94E0847B39758F5 /* Build configuration list for PBXProject "MoriRemote" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -692,6 +1199,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 9538F7C2BB5F510B72A5DBD1 /* Build configuration list for PBXNativeTarget "MoriRemoteTerminal" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7FD338A88E751945A03874FD /* Debug */, + A341BE51FA3B793C16878D7E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; AC164D6FBF79B9F2E0C42756 /* Build configuration list for PBXNativeTarget "MoriRemoteTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/MoriRemote/MoriRemote.xcodeproj/xcshareddata/xcschemes/MoriRemote.xcscheme b/MoriRemote/MoriRemote.xcodeproj/xcshareddata/xcschemes/MoriRemote.xcscheme index 2009d7d9..3e023037 100644 --- a/MoriRemote/MoriRemote.xcodeproj/xcshareddata/xcschemes/MoriRemote.xcscheme +++ b/MoriRemote/MoriRemote.xcodeproj/xcshareddata/xcschemes/MoriRemote.xcscheme @@ -50,6 +50,17 @@ ReferencedContainer = "container:MoriRemote.xcodeproj"> + + + + diff --git a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift index bfef0879..dcb60c08 100644 --- a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift +++ b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift @@ -6,14 +6,17 @@ import Foundation @MainActor final class MoriRemoteDependencies { let library: RemoteLibrary - let trustedHosts: TrustedHostStore - let roots = SSHRootPool() - private var ghosttyRuntime: GhosttyKitRuntime? + let sshRoots: SSHRootAccess init(storage: MoriRemoteStorage, legacyServersURL: URL) { - trustedHosts = storage.trustedHosts let migrator = LegacyServerMigrator(storage: storage, legacyServersURL: legacyServersURL) - library = RemoteLibrary(storage: storage, migrator: migrator) + let library = RemoteLibrary(storage: storage, migrator: migrator) + self.library = library + sshRoots = SSHRootAccess( + library: library, + pool: SSHRootPool(), + trustedHosts: storage.trustedHosts + ) } static func live() -> MoriRemoteDependencies { @@ -26,12 +29,6 @@ final class MoriRemoteDependencies { } } - func terminalRuntime() throws -> GhosttyKitRuntime { - if let ghosttyRuntime { return ghosttyRuntime } - let runtime = try GhosttyKitRuntime() - ghosttyRuntime = runtime - return runtime - } } struct RemoteLibrarySnapshot: Sendable { @@ -74,24 +71,16 @@ actor RemoteLibrary { func reload() throws -> RemoteLibrarySnapshot { try snapshot(migration: nil) } - func save(server: SavedServer, workspace: SavedWorkspace?, identity: SSHIdentity, credential: ProfileCredential?) throws -> RemoteLibrarySnapshot { + func save(server: SavedServer, identity: SSHIdentity, credential: ProfileCredential?) throws -> RemoteLibrarySnapshot { var server = try server.validated() - var workspace = try workspace?.validated() _ = try identity.validated() - guard (workspace == nil || workspace?.serverID == server.id), identity.serverID == server.id, identity.id == server.identityID else { + guard identity.serverID == server.id, identity.id == server.identityID else { throw PersistenceError.corruptStore("profile references") } let existingServers = try storage.servers.all() - let existingWorkspaces = try storage.workspaces.all() // Drafts never own recency. Preserve it through profile edits so an edit - // cannot reorder a server/workspace or revive a different workspace. + // cannot reorder a server or its discovered sessions. if let existing = existingServers.first(where: { $0.id == server.id }) { server.lastConnectedAt = existing.lastConnectedAt } - if let id = workspace?.id, let existing = existingWorkspaces.first(where: { $0.id == id }) { - // A profile edit may update only its own selected workspace; it may - // never repurpose another server's workspace record. - guard existing.serverID == server.id else { throw PersistenceError.corruptStore("workspace ownership") } - workspace?.lastConnectedAt = existing.lastConnectedAt - } let existingIdentity = try storage.identities.all().first { $0.id == identity.id } if let existingIdentity, existingIdentity.kind != identity.kind, credential == nil { // A changed identity type must never silently reinterpret a secret. @@ -111,13 +100,6 @@ actor RemoteLibrary { } else { _ = try storage.servers.insertIfAbsent(server) } - if let workspace { - if existingWorkspaces.contains(where: { $0.id == workspace.id }) { - try storage.workspaces.replace(workspace) - } else { - _ = try storage.workspaces.insertIfAbsent(workspace) - } - } if try storage.identities.all().contains(where: { $0.id == identity.id }) { try storage.identities.replace(identity) } else { @@ -151,28 +133,6 @@ actor RemoteLibrary { return try snapshot(migration: nil) } - func save(workspace: SavedWorkspace) throws -> RemoteLibrarySnapshot { - var workspace = try workspace.validated() - guard try storage.servers.all().contains(where: { $0.id == workspace.serverID }) else { - throw PersistenceError.notFound(workspace.serverID) - } - let existingWorkspaces = try storage.workspaces.all() - if let existing = existingWorkspaces.first(where: { $0.id == workspace.id }) { - guard existing.serverID == workspace.serverID else { throw PersistenceError.corruptStore("workspace ownership") } - // User edits name/session, never their recency ordering. - workspace.lastConnectedAt = existing.lastConnectedAt - try storage.workspaces.replace(workspace) - } else { - _ = try storage.workspaces.insertIfAbsent(workspace) - } - return try snapshot(migration: nil) - } - - func delete(workspaceID: UUID) throws -> RemoteLibrarySnapshot { - try storage.workspaces.remove(workspaceID) - return try snapshot(migration: nil) - } - func delete(serverID: UUID) throws -> RemoteLibrarySnapshot { let workspaces = try storage.workspaces.all().filter { $0.serverID == serverID } let identities = try storage.identities.all().filter { $0.serverID == serverID } @@ -196,6 +156,29 @@ actor RemoteLibrary { try SSHHostTrustResolver(store: storage.trustedHosts).explicitlyTrust(challenge, replaceChanged: replaceChanged) } + func synchronizeDiscoveredSessions(serverID: UUID, names: [String]) throws -> RemoteLibrarySnapshot { + guard try storage.servers.all().contains(where: { $0.id == serverID }) else { + throw PersistenceError.notFound(serverID) + } + var existingNames = Set(try storage.workspaces.all().filter { $0.serverID == serverID }.map(\.tmuxSession)) + for name in names where !existingNames.contains(name) { + let workspace = try SavedWorkspace(serverID: serverID, name: name, tmuxSession: name).validated() + _ = try storage.workspaces.insertIfAbsent(workspace) + existingNames.insert(name) + } + return try snapshot(migration: nil) + } + + func discoveryMaterial(for serverID: UUID) throws -> (SavedServer, SSHIdentity, RemoteSettings) { + guard let server = try storage.servers.all().first(where: { $0.id == serverID }) else { + throw PersistenceError.notFound(serverID) + } + guard let identity = try storage.identities.all().first(where: { $0.id == server.identityID }) else { + throw SSHAuthResolverError.missingIdentity(server.identityID) + } + return (server, identity, try storage.settings.load(or: .default)) + } + func connectionMaterial(for workspaceID: UUID) throws -> (SavedWorkspace, SavedServer, SSHIdentity, RemoteSettings) { let workspaces = try storage.workspaces.all() guard let workspace = workspaces.first(where: { $0.id == workspaceID }) else { throw PersistenceError.notFound(workspaceID) } diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index 26444d78..b69e6561 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -1,65 +1,37 @@ import Foundation +import MoriRemoteTerminal import Observation import SwiftUI -struct WorkspaceDraft: Identifiable, Sendable { +struct ServerProfileDraft: Identifiable, Sendable { let id: UUID - let serverID: UUID - var name: String - var tmuxSession: String - - init(serverID: UUID, workspace: SavedWorkspace? = nil) { - id = workspace?.id ?? UUID() - self.serverID = serverID - name = workspace?.name ?? "main" - tmuxSession = workspace?.tmuxSession ?? "main" - } - - func record() throws -> SavedWorkspace { - try SavedWorkspace(id: id, serverID: serverID, name: name, tmuxSession: tmuxSession).validated() - } -} - -struct ServerWorkspaceDraft: Identifiable, Sendable { - let id: UUID - /// Nil means an existing server edit: profile edits must not invent or - /// overwrite an arbitrary workspace belonging to that server. - let workspaceID: UUID? let serverLastConnectedAt: Date? - let workspaceLastConnectedAt: Date? var serverName: String var host: String var port: String var username: String - var workspaceName: String - var tmuxSession: String var identityKind: SSHIdentityKind var password: String var privateKey: String var passphrase: String - init(server: SavedServer? = nil, workspace: SavedWorkspace? = nil, identity: SSHIdentity? = nil) { + init(server: SavedServer? = nil, identity: SSHIdentity? = nil) { id = server?.id ?? UUID() - workspaceID = workspace?.id ?? (server == nil ? UUID() : nil) serverLastConnectedAt = server?.lastConnectedAt - workspaceLastConnectedAt = workspace?.lastConnectedAt serverName = server?.name ?? "" host = server?.host ?? "" port = String(server?.port ?? 22) username = server?.username ?? "" - workspaceName = workspace?.name ?? "main" - tmuxSession = workspace?.tmuxSession ?? "main" identityKind = identity?.kind ?? .password password = "" privateKey = "" passphrase = "" } - func records(existingIdentityID: UUID? = nil) throws -> (SavedServer, SavedWorkspace?, SSHIdentity, ProfileCredential?) { + func records(existingIdentityID: UUID? = nil) throws -> (SavedServer, SSHIdentity, ProfileCredential?) { guard let port = Int(port) else { throw SavedModelValidationError.invalidPort } let identityID = existingIdentityID ?? id let server = SavedServer(id: id, name: serverName, host: host, port: port, username: username, identityID: identityID, lastConnectedAt: serverLastConnectedAt) - let workspace = try workspaceID.map { try SavedWorkspace(id: $0, serverID: id, name: workspaceName, tmuxSession: tmuxSession, lastConnectedAt: workspaceLastConnectedAt).validated() } let identity = SSHIdentity(id: identityID, serverID: id, kind: identityKind, label: identityKind == .password ? "password" : "private key") let credential: ProfileCredential? switch identityKind { @@ -67,21 +39,52 @@ struct ServerWorkspaceDraft: Identifiable, Sendable { case .privateKey: credential = privateKey.isEmpty ? nil : .privateKey(.init(privateKeyPEM: privateKey, passphrase: passphrase.isEmpty ? nil : passphrase)) } - return (try server.validated(), workspace, try identity.validated(), credential) + return (try server.validated(), try identity.validated(), credential) + } +} + +enum RemoteNavigatorProjection { + static func sessions(_ values: [SavedWorkspace], matching query: String) -> [SavedWorkspace] { + values.filter { query.isEmpty || $0.tmuxSession.localizedCaseInsensitiveContains(query) } + } + + static func windows(_ values: [MoriRemoteTerminalWindow], matching query: String) -> [MoriRemoteTerminalWindow] { + values.filter { query.isEmpty || $0.title.localizedCaseInsensitiveContains(query) || String($0.id).contains(query) } + } + + static func panes( + _ values: [MoriRemoteTerminalPane], + windows: [MoriRemoteTerminalWindow], + matching query: String + ) -> [MoriRemoteTerminalPane] { + values.filter { pane in + let windowTitle = windows.first(where: { $0.id == pane.windowID })?.title ?? "" + return query.isEmpty || String(pane.id).contains(query) || windowTitle.localizedCaseInsensitiveContains(query) + } } } +enum ServerSessionDiscoveryStatus: Equatable, Sendable { + case idle + case loading + case loaded + case failed(String) +} + +enum PendingSSHTrustAction: Equatable, Sendable { + case connect(UUID) + case discover(UUID) +} + enum WorkspaceRuntimeStatus: Equatable { case connecting case ready - case reconnecting case disconnected(String) var title: String { switch self { case .connecting: String(localized: "Connecting…") case .ready: String(localized: "Connected") - case .reconnecting: String(localized: "Reconnecting…") case .disconnected: String(localized: "Disconnected") } } @@ -106,6 +109,8 @@ struct WorkspaceMemoryPressurePolicy: Sendable { } } +/// Adaptive chrome may change around a workspace, but never the retained +/// terminal-session identity. A new runtime is the only valid replacement. /// Main-actor admission fence for asynchronous connection attempts. A token is /// claimed before the first await, then invalidated by disconnect/delete/replacement. enum SSHTrustPresentation: Equatable { @@ -135,88 +140,88 @@ struct WorkspaceConnectionAttemptLedger: Sendable { mutating func cancel(workspaceID: UUID) { tokens[workspaceID] = nil } } -@MainActor +@MainActor @Observable final class ActiveWorkspaceRuntime { let workspace: SavedWorkspace let instanceID: UUID - private let runtime: GhosttyTmuxRuntime + let session: MoriRemoteTerminalSession private let metadataProjector: AgentMetadataProjector - private let initialScrollbackLines: Int - private(set) var topology: TmuxSessionController.Topology? - var agentMetadata: [TmuxPaneID: AgentMetadata] { metadataProjector.metadata } - private(set) var focusedPaneID: TmuxPaneID? + private var metadataRevision: UInt64 = 0 + private(set) var topology: MoriRemoteTerminalTopology? + var agentMetadata: [UInt64: AgentMetadata] { + _ = metadataRevision + return metadataProjector.metadata + } + var focusedPaneID: UInt64? { + guard let activeWindowID = topology?.activeWindowID else { return nil } + return topology?.windows.first(where: { $0.id == activeWindowID })?.activePaneID + } private(set) var status: WorkspaceRuntimeStatus = .connecting var onTransportLoss: (@MainActor (UUID) -> Void)? - var onChange: (@MainActor () -> Void)? - init(workspace: SavedWorkspace, settings: RemoteSettings, app: GhosttyKitRuntime, transport: any TmuxControlTransport, instanceID: UUID = UUID()) { + init(workspace: SavedWorkspace, settings: RemoteSettings, transport: MoriRemoteTerminalTransport, instanceID: UUID = UUID()) throws { self.workspace = workspace self.instanceID = instanceID - initialScrollbackLines = settings.effectiveInitialScrollbackLines - let runtime = GhosttyTmuxRuntime(app: app.appHandle, transport: transport, instanceID: instanceID) - self.runtime = runtime - metadataProjector = AgentMetadataProjector(instanceID: instanceID) { completion in - runtime.queryAgentMetadata(completion: completion) + session = try MoriRemoteTerminalSession( + transport: transport, + initialScrollbackLines: settings.effectiveInitialScrollbackLines, + instanceID: instanceID + ) + metadataProjector = AgentMetadataProjector(instanceID: instanceID) { [session] in + let result = await session.queryAgentMetadata() + return .init(succeeded: result.status == .success, body: result.body) } - metadataProjector.onChange = { [weak self] in self?.onChange?() } - runtime.onTopology = { [weak self] topology in + metadataProjector.onChange = { [weak self] in self?.metadataRevision &+= 1 } + session.onTopologyChange = { [weak self] topology in guard let self else { return } self.topology = topology - if let focused = self.focusedPaneID, topology.panes.contains(where: { $0.id == focused }) { - // Keep client-local focus stable through topology updates. - } else { - self.focusedPaneID = topology.activePaneID - } self.status = .ready - self.metadataProjector.topologyDidChange(topology) - self.onChange?() + self.metadataProjector.topologyDidChange(paneIDs: topology.panes.map(\.id)) } - runtime.onState = { [weak self] state in - guard let self else { return } - switch state { - case .ready: - self.status = .ready - // Topology normally follows, but a ready signal without it must - // still dismiss the connecting presentation deterministically. - if self.topology == nil { self.onChange?() } - case .detached: - self.status = .disconnected(String(localized: "Connection lost.")) - self.onChange?() - self.onTransportLoss?(self.instanceID) - case .closed: self.onChange?() - case .attaching: self.status = .connecting; self.onChange?() - } - } - // Surface registration completes asynchronously after topology. Wake - // SwiftUI when the real renderer arrives instead of leaving a quiet pane - // on the placeholder until another tmux event happens. - runtime.onSurface = { [weak self] _ in self?.onChange?() } - runtime.onInputFailed = { [weak self] _ in self?.onChange?() } + session.onConnectionStateChange = { [weak self] state in self?.receive(state) } + session.setPresentationActive(false) } - func start() async throws { try await runtime.start(columns: 120, rows: 40, historyLineLimit: initialScrollbackLines) } - func stop() async { metadataProjector.stop(); await runtime.stop() } - func setMetadataRefreshVisible(_ visible: Bool) { metadataProjector.setVisible(visible) } + func start() async throws { try await session.start() } + func stop() async { metadataProjector.stop(); await session.stop() } + func setVisible(_ visible: Bool) { + metadataProjector.setVisible(visible) + session.setPresentationActive(visible) + } func foregrounded() { metadataProjector.foregrounded() } func confirmTransportAfterForeground() async { - guard !(await runtime.isActive()) else { - foregrounded() + guard await session.isControlChannelActive() else { + status = .disconnected(String(localized: "Connection lost.")) + onTransportLoss?(instanceID) return } - status = .disconnected(String(localized: "Connection lost.")) - onChange?() - onTransportLoss?(instanceID) + foregrounded() } - func surface() -> TmuxPaneSurface? { focusedPaneID.flatMap(runtime.surface(for:)) } - func metadata(for paneID: TmuxPaneID) -> AgentMetadata { agentMetadata[paneID] ?? .unknown } + func metadata(for paneID: UInt64) -> AgentMetadata { agentMetadata[paneID] ?? .unknown } var agentSummary: AgentMetadata { agentMetadata.values.max { lhs, rhs in lhs.state.priority < rhs.state.priority } ?? .unknown } - func selectWindow(_ id: TmuxWindowID) { runtime.selectWindow(id) } - func selectPane(_ id: TmuxPaneID) { focusedPaneID = id; runtime.selectPane(id); onChange?() } - func split(horizontal: Bool) { runtime.mutateSharedWorkspace(horizontal ? .splitHorizontal : .splitVertical) } - func newWindow() { runtime.mutateSharedWorkspace(.newWindow) } - func closePane() { runtime.mutateSharedWorkspace(.closePane) } + func selectWindow(_ id: UInt64) { session.selectWindow(id) } + func selectPane(_ id: UInt64) { session.selectPane(id) } + func performSharedMutation(_ mutation: MoriRemoteTerminalSharedMutation) { session.performSharedMutation(mutation) } + + private func receive(_ state: MoriRemoteTerminalConnectionState) { + switch state { + case .connecting: + // A late syncing notification may race the topology callback. Once + // topology exists, the rendered terminal is authoritative and ready. + if topology == nil { status = .connecting } + case .ready: + status = .ready + case .disconnected: + let wasDisconnected: Bool + if case .disconnected = status { wasDisconnected = true } else { wasDisconnected = false } + status = .disconnected(String(localized: "Connection lost.")) + // A thrown start error is already surfaced by connect(); only a + // post-start transport transition earns the bounded reconnect. + if !wasDisconnected, session.lastError == nil { onTransportLoss?(instanceID) } + } + } } @MainActor @Observable @@ -236,12 +241,13 @@ final class RemoteRootModel { private(set) var identities: [SSHIdentity] = [] private(set) var settings = RemoteSettings.default private(set) var runtimes: [UUID: ActiveWorkspaceRuntime] = [:] + private(set) var sessionDiscovery: [UUID: ServerSessionDiscoveryStatus] = [:] + private var discoveredSessionNames: [UUID: Set] = [:] var activeWorkspaceID: UUID? var pendingTrust: SSHHostTrustChallenge? var errorMessage: String? var migrationReport: LegacyMigrationReport? - var runtimeRevision = 0 - private var pendingTrustWorkspaceID: UUID? + private var pendingTrustAction: PendingSSHTrustAction? private(set) var isLoaded = false var libraryLoadError: String? { bootstrapFailure } var isBootstrapping: Bool { loadingTask != nil } @@ -250,8 +256,26 @@ final class RemoteRootModel { var activeRuntime: ActiveWorkspaceRuntime? { activeWorkspaceID.flatMap { runtimes[$0] } } var activeWorkspaces: [SavedWorkspace] { workspaces.filter { runtimes[$0.id] != nil } } + func visibleWorkspaces(for serverID: UUID) -> [SavedWorkspace] { + let discovered = discoveredSessionNames[serverID] + let candidates = workspaces.filter { + $0.serverID == serverID && (discovered == nil || discovered?.contains($0.tmuxSession) == true) + } + return Dictionary(grouping: candidates, by: \.tmuxSession) + .values + .compactMap { duplicates in + duplicates.max { lhs, rhs in + let lhsActive = runtimes[lhs.id] != nil + let rhsActive = runtimes[rhs.id] != nil + if lhsActive != rhsActive { return !lhsActive && rhsActive } + return (lhs.lastConnectedAt ?? .distantPast, lhs.id.uuidString) + < (rhs.lastConnectedAt ?? .distantPast, rhs.id.uuidString) + } + } + .sorted { $0.tmuxSession.localizedStandardCompare($1.tmuxSession) == .orderedAscending } + } func agentSummary(for workspaceID: UUID) -> AgentMetadata { runtimes[workspaceID]?.agentSummary ?? .unknown } - func metadata(for workspaceID: UUID, paneID: TmuxPaneID) -> AgentMetadata { runtimes[workspaceID]?.metadata(for: paneID) ?? .unknown } + func metadata(for workspaceID: UUID, paneID: UInt64) -> AgentMetadata { runtimes[workspaceID]?.metadata(for: paneID) ?? .unknown } func bootstrap() { guard !isLoaded, loadingTask == nil else { return } @@ -268,34 +292,43 @@ final class RemoteRootModel { } } - func save(_ draft: ServerWorkspaceDraft, existingServer: SavedServer? = nil) { + func save(_ draft: ServerProfileDraft, existingServer: SavedServer? = nil) { Task { do { let currentIdentity = existingServer.flatMap { server in identities.first { $0.id == server.identityID } } let records = try draft.records(existingIdentityID: currentIdentity?.id) - let snapshot = try await dependencies.library.save(server: records.0, workspace: records.1, identity: records.2, credential: records.3) - apply(snapshot) - } catch { errorMessage = error.localizedDescription } - } - } - - func save(_ draft: WorkspaceDraft) { - Task { - do { - let snapshot = try await dependencies.library.save(workspace: draft.record()) + let snapshot = try await dependencies.library.save(server: records.0, identity: records.1, credential: records.2) apply(snapshot) + discoverSessions(serverID: records.0.id) } catch { errorMessage = error.localizedDescription } } } - func delete(workspace: SavedWorkspace) { - connectionAttempts.cancel(workspaceID: workspace.id) - Task { + func discoverSessions(serverID: UUID) { + guard sessionDiscovery[serverID] != .loading else { return } + sessionDiscovery[serverID] = .loading + Task { [weak self] in + guard let self else { return } do { - await disconnect(workspaceID: workspace.id) - let snapshot = try await dependencies.library.delete(workspaceID: workspace.id) - apply(snapshot) - } catch { errorMessage = error.localizedDescription } + let root = try await self.dependencies.sshRoots.server(serverID) + let names = try await SSHTmuxSessionDiscovery(rootSource: root).load() + let snapshot = try await self.dependencies.library.synchronizeDiscoveredSessions(serverID: serverID, names: names) + self.apply(snapshot) + self.discoveredSessionNames[serverID] = Set(names) + self.sessionDiscovery[serverID] = .loaded + } catch let error as SSHHostTrustError { + switch SSHTrustPresentation.resolve(error) { + case let .challenge(challenge): + self.errorMessage = nil + self.pendingTrust = challenge + self.pendingTrustAction = .discover(serverID) + self.sessionDiscovery[serverID] = .idle + case let .error(message): + self.sessionDiscovery[serverID] = .failed(message) + } + } catch { + self.sessionDiscovery[serverID] = .failed(error.localizedDescription) + } } } @@ -342,28 +375,23 @@ final class RemoteRootModel { guard let self else { return } var runtime: ActiveWorkspaceRuntime? do { - let material = try await self.dependencies.library.connectionMaterial(for: workspaceID) + let access = try await self.dependencies.sshRoots.workspace(workspaceID) guard self.attemptIsCurrent(attempt, workspaceID: workspaceID) else { return } - let auth = try await self.dependencies.library.resolveAuth(server: material.1, identity: material.2, settings: material.3) - guard self.attemptIsCurrent(attempt, workspaceID: workspaceID) else { return } - let endpoint = try CanonicalEndpoint(host: material.1.host, port: material.1.port) - let key = SSHRootPool.Key(serverID: material.1.id, endpoint: endpoint, username: material.1.username, authenticationFingerprint: auth.rootPoolFingerprint) let instanceID = UUID() let transport = SSHTmuxControlTransport( - connector: CitadelSSHRootConnector(server: material.1, auth: auth, trust: SSHHostTrustResolver(store: self.dependencies.trustedHosts)), - pool: self.dependencies.roots, - poolKey: key, - sourceSession: material.0.tmuxSession, + rootSource: access.root, + sourceSession: access.workspace.tmuxSession, runtimeID: instanceID ) - let created = ActiveWorkspaceRuntime(workspace: material.0, settings: material.3, app: try self.dependencies.terminalRuntime(), transport: transport, instanceID: instanceID) + let created = try ActiveWorkspaceRuntime( + workspace: access.workspace, + settings: access.settings, + transport: transport.asTerminalTransport(), + instanceID: instanceID + ) runtime = created guard self.attemptIsCurrent(attempt, workspaceID: workspaceID) else { await created.stop(); return } created.onTransportLoss = { [weak self] id in self?.lost(workspaceID: workspaceID, instanceID: id) } - created.onChange = { [weak self, weak created] in - guard let self, self.runtimes[workspaceID] === created else { return } - self.runtimeRevision &+= 1 - } self.runtimes[workspaceID] = created // An automatic reconnect must not steal focus from another // healthy workspace. If the lost workspace was focused, @@ -386,10 +414,10 @@ final class RemoteRootModel { case let .challenge(challenge): self.errorMessage = nil self.pendingTrust = challenge - self.pendingTrustWorkspaceID = workspaceID + self.pendingTrustAction = .connect(workspaceID) case let .error(message): self.pendingTrust = nil - self.pendingTrustWorkspaceID = nil + self.pendingTrustAction = nil self.errorMessage = message } } catch { @@ -403,18 +431,22 @@ final class RemoteRootModel { func dismissTrust() { pendingTrust = nil - pendingTrustWorkspaceID = nil + pendingTrustAction = nil } func confirmTrust(_ challenge: SSHHostTrustChallenge, replaceChanged: Bool) { Task { do { try await dependencies.library.trust(challenge, replaceChanged: replaceChanged) - let workspaceID = pendingTrustWorkspaceID + let action = pendingTrustAction pendingTrust = nil - pendingTrustWorkspaceID = nil + pendingTrustAction = nil errorMessage = nil - if let workspaceID { connect(workspaceID: workspaceID) } + switch action { + case let .connect(workspaceID): connect(workspaceID: workspaceID) + case let .discover(serverID): discoverSessions(serverID: serverID) + case nil: break + } } catch { errorMessage = error.localizedDescription } } } @@ -424,22 +456,26 @@ final class RemoteRootModel { deferredReconnects.remove(workspaceID) guard let runtime = runtimes.removeValue(forKey: workspaceID) else { return } if activeWorkspaceID == workspaceID { activeWorkspaceID = nil } - runtime.setMetadataRefreshVisible(false) + runtime.setVisible(false) await runtime.stop() } func disconnectActive() { if let activeWorkspaceID { Task { await disconnect(workspaceID: activeWorkspaceID) } } } - func selectWindow(_ id: TmuxWindowID) { activeRuntime?.selectWindow(id) } - func selectPane(_ id: TmuxPaneID) { activeRuntime?.selectPane(id) } - func split(horizontal: Bool) { activeRuntime?.split(horizontal: horizontal) } - func newWindow() { activeRuntime?.newWindow() } - func closePane() { activeRuntime?.closePane() } + func selectWindow(_ id: UInt64) { activeRuntime?.selectWindow(id) } + func selectPane(_ id: UInt64) { activeRuntime?.selectPane(id) } + func performSharedMutation(_ mutation: MoriRemoteTerminalSharedMutation) { + activeRuntime?.performSharedMutation(mutation) + } + + func imageUploader(for workspaceID: UUID) -> MoriRemoteTerminalImageUploader { + SSHImageUploadService(sshRoots: dependencies.sshRoots).uploader(for: workspaceID) + } /// Scene activation is intentionally metadata-only: reconnect remains /// reserved for a real control-transport loss. Backgrounding stops the /// visible-runtime poll; foregrounding starts one immediate refresh. func scenePhaseChanged(_ phase: ScenePhase) { sceneIsActive = phase == .active - activeRuntime?.setMetadataRefreshVisible(sceneIsActive) + activeRuntime?.setVisible(sceneIsActive) guard sceneIsActive else { return } // A loss can occur in any live workspace while iOS suspends this scene. // Drain all deferred attempts before probing the focused one; otherwise @@ -472,12 +508,12 @@ final class RemoteRootModel { private func activate(workspaceID: UUID) { guard activeWorkspaceID != workspaceID else { - runtimes[workspaceID]?.setMetadataRefreshVisible(sceneIsActive) + runtimes[workspaceID]?.setVisible(sceneIsActive) return } - if let activeWorkspaceID { runtimes[activeWorkspaceID]?.setMetadataRefreshVisible(false) } + if let activeWorkspaceID { runtimes[activeWorkspaceID]?.setVisible(false) } activeWorkspaceID = workspaceID - runtimes[workspaceID]?.setMetadataRefreshVisible(sceneIsActive) + runtimes[workspaceID]?.setVisible(sceneIsActive) } private func attemptIsCurrent(_ attempt: UUID, workspaceID: UUID) -> Bool { @@ -489,7 +525,7 @@ final class RemoteRootModel { runtimes[workspaceID] = nil if activeWorkspaceID == workspaceID { activeWorkspaceID = nil } } - runtime.setMetadataRefreshVisible(false) + runtime.setVisible(false) await runtime.stop() } diff --git a/MoriRemote/MoriRemote/Ghostty/GhosttyKitRuntime.swift b/MoriRemote/MoriRemote/Ghostty/GhosttyKitRuntime.swift deleted file mode 100644 index 80308a11..00000000 --- a/MoriRemote/MoriRemote/Ghostty/GhosttyKitRuntime.swift +++ /dev/null @@ -1,155 +0,0 @@ -import Darwin -import Foundation -import GhosttyKit -import UIKit - -enum GhosttyKitRuntimeError: Error, Equatable, LocalizedError { - case initializationFailed(Int32) - case processDirectoryConfigurationFailed(String) - case environmentConfigurationFailed(String) - case configurationFileFailed(String) - case configCreationFailed - case appCreationFailed - - var errorDescription: String? { - switch self { - case .initializationFailed(let result): "Ghostty initialization failed (\(result))." - case .processDirectoryConfigurationFailed(let path): "Ghostty could not prepare \(path)." - case .environmentConfigurationFailed(let name): "Ghostty could not configure \(name)." - case .configurationFileFailed(let path): "Ghostty could not write its terminal configuration at \(path)." - case .configCreationFailed: "Ghostty could not create its terminal configuration." - case .appCreationFailed: "Ghostty could not create its rendering runtime." - } - } -} - -/// Process-wide Ghostty owner. iOS has no useful process HOME by default in the -/// simulator; configure the XDG roots before ghostty_init so font/config lookup -/// has the same prerequisites as the upstream renderer. -@MainActor -final class GhosttyKitRuntime { - private static var didInitialize = false - private let state: State - private let callbacks: Callbacks - - private final class State { - let app: ghostty_app_t - let config: ghostty_config_t - private var released = false - init(app: ghostty_app_t, config: ghostty_config_t) { self.app = app; self.config = config } - func release() { - guard !released else { return } - released = true - ghostty_app_free(app) - ghostty_config_free(config) - } - } - - init() throws { - try Self.initializeBackend() - guard let config = ghostty_config_new() else { throw GhosttyKitRuntimeError.configCreationFailed } - do { - try Self.loadMinimumTerminalConfiguration(into: config) - } catch { - ghostty_config_free(config) - throw error - } - ghostty_config_finalize(config) - let callbacks = Callbacks() - var runtimeConfig = ghostty_runtime_config_s( - userdata: callbacks.userdata, - supports_selection_clipboard: true, - wakeup_cb: Callbacks.wakeup, - action_cb: Callbacks.action, - read_clipboard_cb: nil, - confirm_read_clipboard_cb: nil, - write_clipboard_cb: nil, - close_surface_cb: nil - ) - guard let app = ghostty_app_new(&runtimeConfig, config) else { - ghostty_config_free(config) - throw GhosttyKitRuntimeError.appCreationFailed - } - state = State(app: app, config: config) - self.callbacks = callbacks - callbacks.app = app - } - - /// Release only from the main actor after every terminal/surface fence has - /// completed. `deinit` is not a safe native lifecycle boundary: a queued - /// wakeup may otherwise tick an app whose C storage was just freed. - func shutdown() { - callbacks.app = nil - state.release() - } - - var appHandle: ghostty_app_t { state.app } - func surfaceConfig() -> ghostty_terminal_surface_config_s { ghostty_terminal_surface_config_new() } - - private static func initializeBackend() throws { - guard !didInitialize else { return } - try configureProcessDirectories() - let result = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) - guard result == GHOSTTY_SUCCESS else { throw GhosttyKitRuntimeError.initializationFailed(result) } - didInitialize = true - } - - private static func configureProcessDirectories() throws { - let home = NSHomeDirectory() - let support = "\(home)/Library/Application Support" - let caches = "\(home)/Library/Caches" - for path in [support, caches] { - do { try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) } - catch { throw GhosttyKitRuntimeError.processDirectoryConfigurationFailed(path) } - } - try setEnvironment("HOME", home) - try setEnvironment("XDG_CONFIG_HOME", support) - try setEnvironment("XDG_CACHE_HOME", caches) - try setEnvironment("XDG_STATE_HOME", support) - } - - private static func setEnvironment(_ name: String, _ value: String) throws { - guard getenv(name) == nil else { return } - let result = name.withCString { name in value.withCString { value in setenv(name, value, 1) } } - guard result == 0 else { throw GhosttyKitRuntimeError.environmentConfigurationFailed(name) } - } - - private static func loadMinimumTerminalConfiguration(into config: ghostty_config_t) throws { - // Keep a concrete iOS-safe font size: default config can resolve to no - // usable font when HOME/XDG are absent in Simulator. - let contents = "font-size = 14\nfont-family = Menlo\nbackground = #20242c\nforeground = #e6eaf0\n" - let url = FileManager.default.temporaryDirectory.appendingPathComponent("mori-ghostty-\(UUID().uuidString).conf") - do { try contents.write(to: url, atomically: true, encoding: .utf8) } - catch { throw GhosttyKitRuntimeError.configurationFileFailed(url.path) } - defer { try? FileManager.default.removeItem(at: url) } - url.path.withCString { ghostty_config_load_file(config, $0) } - } - - private final class Callbacks: @unchecked Sendable { - // This reference is read only by a Task dispatched to MainActor and is - // cleared by `shutdown()` on that same actor before native free. - var app: ghostty_app_t? - var userdata: UnsafeMutableRawPointer { Unmanaged.passUnretained(self).toOpaque() } - static let wakeup: ghostty_runtime_wakeup_cb = { userdata in - guard let userdata else { return } - let callbacks = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - Task { @MainActor in if let app = callbacks.app { ghostty_app_tick(app) } } - } - static let action: ghostty_runtime_action_cb = { _, _, _ in true } - } -} - -final class GhosttySurfaceView: UIView { - var drawSurface: (() -> Void)? - var rendererHealthy = true - override class var layerClass: AnyClass { CAMetalLayer.self } - override func draw(_ rect: CGRect) { super.draw(rect); drawSurface?() } - override func didMoveToWindow() { super.didMoveToWindow(); alignGhosttyRendererSublayers(); setNeedsDisplay() } - override func layoutSubviews() { super.layoutSubviews(); alignGhosttyRendererSublayers() } - func alignGhosttyRendererSublayers() { - let scale = max(window?.screen.scale ?? contentScaleFactor, 1) - contentScaleFactor = scale - layer.contentsScale = scale - for sublayer in layer.sublayers ?? [] { sublayer.frame = bounds; sublayer.contentsScale = scale } - } -} diff --git a/MoriRemote/MoriRemote/Ghostty/GhosttyPaneSurface.swift b/MoriRemote/MoriRemote/Ghostty/GhosttyPaneSurface.swift deleted file mode 100644 index e9cfb648..00000000 --- a/MoriRemote/MoriRemote/Ghostty/GhosttyPaneSurface.swift +++ /dev/null @@ -1,526 +0,0 @@ -import Foundation -import GhosttyKit -import QuartzCore -import SwiftUI -import UIKit - -/// Native key event: Ghostty expects Darwin virtual key codes, not its public -/// GHOSTTY_KEY enum. Text from the software keyboard uses input(), never key(). -struct GhosttySurfaceKeyEvent: Equatable { - struct Mods: OptionSet, Equatable { let rawValue: UInt32 - static let shift = Self(rawValue: GHOSTTY_MODS_SHIFT.rawValue) - static let ctrl = Self(rawValue: GHOSTTY_MODS_CTRL.rawValue) - static let alt = Self(rawValue: GHOSTTY_MODS_ALT.rawValue) - static let `super` = Self(rawValue: GHOSTTY_MODS_SUPER.rawValue) - } - let keyCode: UInt32 - let mods: Mods - init(_ keyCode: UInt32, mods: Mods = []) { self.keyCode = keyCode; self.mods = mods } - static let backspace = Self(0x33) - static let enter = Self(0x24) - static let up = Self(0x7E) - static let down = Self(0x7D) - static let left = Self(0x7B) - static let right = Self(0x7C) - static let tab = Self(0x30) - static let escape = Self(0x35) - static let forwardDelete = Self(0x75) - static let home = Self(0x73) - static let end = Self(0x77) - static let pageUp = Self(0x74) - static let pageDown = Self(0x79) - - func withCValue(_ body: (ghostty_input_key_s) -> T) -> T { - var value = ghostty_input_key_s() - value.action = GHOSTTY_ACTION_PRESS - value.keycode = keyCode - value.mods = ghostty_input_mods_e(mods.rawValue) - return body(value) - } -} - -/// Small, deterministic cap used by the local scroll view. It protects the -/// renderer from a UIKit deceleration dumping an unbounded history jump. -/// Hardware control combinations become terminal bytes rather than UIKit menu -/// shortcuts. Physical navigation keys remain native Ghostty key events. -@MainActor enum GhosttyTerminalHardwareCommandMapping { - enum Command: Equatable { case key(GhosttySurfaceKeyEvent), text(String) } - static func command(characters: String, keyCode: UIKeyboardHIDUsage, modifiers: UIKeyModifierFlags) -> Command? { - let mods = GhosttyTerminalResponderView.modifiers(modifiers) - let keys: [UIKeyboardHIDUsage: GhosttySurfaceKeyEvent] = [.keyboardDeleteOrBackspace: .backspace, .keyboardReturnOrEnter: .enter, .keyboardTab: .tab, .keyboardEscape: .escape, .keyboardDeleteForward: .forwardDelete, .keyboardHome: .home, .keyboardEnd: .end, .keyboardPageUp: .pageUp, .keyboardPageDown: .pageDown, .keyboardUpArrow: .up, .keyboardDownArrow: .down, .keyboardLeftArrow: .left, .keyboardRightArrow: .right] - if let key = keys[keyCode] { return .key(.init(key.keyCode, mods: mods)) } - guard !characters.isEmpty, !modifiers.contains(.command) else { return nil } - if modifiers.contains(.control), characters.unicodeScalars.count == 1, let scalar = characters.unicodeScalars.first { - // UIKit may already translate Ctrl+C (and friends) to a control - // byte. Preserve it; translating a second time corrupts NUL/ETX. - if scalar.value < 0x20 { return .text(characters) } - if scalar.value == 0x20 { return .text("\0") } // Ctrl+Space - if scalar.value <= 0x7F { return .text(String(UnicodeScalar(scalar.value & 0x1F)!)) } - } - guard !modifiers.contains(.control) else { return nil } - return .text(characters) - } -} - -/// Pure state prevents marked CJK composition from being sent twice: updates -/// replace marked text, and only the final commit is emitted. -struct GhosttyMarkedTextComposition: Equatable { - private(set) var marked = "" - var isActive: Bool { !marked.isEmpty } - mutating func update(_ text: String?) { marked = text ?? "" } - mutating func commit(_ text: String) -> String? { - let output = text.isEmpty ? marked : text - marked = "" - return output.isEmpty ? nil : output - } -} - -struct GhosttyScrollProjection: Equatable { - func synchronize(currentOffset: CGFloat, contentHeight: CGFloat, viewportHeight: CGFloat, followsBottom: Bool) -> CGFloat { - followsBottom ? max(0, contentHeight - viewportHeight) : min(currentOffset, max(0, contentHeight - viewportHeight)) - } -} - -struct GhosttyScrollDeltaBudget { - private(set) var available: Double - private var last: TimeInterval? - let unitsPerSecond: Double - let burstSeconds: Double - - init(unitsPerSecond: Double = 120, burstSeconds: Double = 0.08) { - self.unitsPerSecond = unitsPerSecond - self.burstSeconds = burstSeconds - available = unitsPerSecond * burstSeconds - } - - mutating func clamp(_ delta: Double, now: TimeInterval) -> Double { - if let last { available = min(unitsPerSecond * burstSeconds, available + max(0, now - last) * unitsPerSecond) } - last = now - let amount = min(abs(delta), available) - available -= amount - return delta < 0 ? -amount : amount - } -} - -/// Models the interval in which UIKit ownership must survive an asynchronous -/// unregister before the corresponding native surface is freed. -struct GhosttySurfaceCloseFence: Equatable { - enum State: Equatable { case open, awaitingNativeFree, released } - private(set) var state: State = .open - - mutating func beginClose() -> Bool { - guard state == .open else { return false } - state = .awaitingNativeFree - return true - } - - mutating func finishNativeFree() { - precondition(state == .awaitingNativeFree) - state = .released - } -} - -@MainActor -final class GhosttyManagedSurfaceRegistry { - private var surfaces: [TmuxPaneID: TmuxPaneSurface] = [:] - func register(_ surface: TmuxPaneSurface) { surfaces[surface.paneID] = surface } - func unregister(_ surface: TmuxPaneSurface) { if surfaces[surface.paneID] === surface { surfaces.removeValue(forKey: surface.paneID) } } - func surface(for paneID: TmuxPaneID) -> TmuxPaneSurface? { surfaces[paneID] } -} - -/// Main-actor owner of one real CAMetal Ghostty renderer. The controller's -/// unregister completion is the ownership fence: native memory is never freed -/// before it has stopped publishing terminal_changed callbacks. -@MainActor -final class TmuxPaneSurface { - let paneID: TmuxPaneID - let view: GhosttySurfaceView - private let app: ghostty_app_t - private let controller: TmuxSessionController - private let terminal: TmuxSessionController.RetainedTerminal - private let callbackBox: CallbackBox - private var surface: ghostty_terminal_surface_t? - private var closed = false - private var closeFence = GhosttySurfaceCloseFence() - private var closeCompletions: [@MainActor () -> Void] = [] - private var visible = false - private var focused = false - private var displayLink: CADisplayLink? - private var lastMetrics: (UInt32, UInt32, CGFloat)? - private(set) var drawCount = 0 - private(set) var lastRendererResult: ghostty_terminal_surface_result_e = GHOSTTY_TERMINAL_SURFACE_RESULT_OK - var onTerminalActivity: (@MainActor () -> Void)? - - private final class CallbackBox: @unchecked Sendable { - weak var controller: TmuxSessionController? - weak var owner: TmuxPaneSurface? - let paneID: TmuxPaneID - init(controller: TmuxSessionController, paneID: TmuxPaneID) { self.controller = controller; self.paneID = paneID } - static let write: ghostty_terminal_surface_write_cb = { userdata, bytes, count in - guard let userdata, let bytes, count > 0 else { return false } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - // This is the only outbound path. input/key/paste each cause one - // callback; sending again from the responder would duplicate bytes. - box.controller?.sendInput(Data(bytes: bytes, count: count), to: box.paneID) - return box.controller != nil - } - static let health: ghostty_terminal_surface_renderer_health_cb = { userdata, health in - guard health == GHOSTTY_RENDERER_HEALTH_UNHEALTHY, let userdata else { return } - let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - DispatchQueue.main.async { box.owner?.rendererFailed() } - } - } - - static func create( - app: ghostty_app_t, - controller: TmuxSessionController, - terminal: TmuxSessionController.RetainedTerminal, - config base: ghostty_terminal_surface_config_s, - size: CGSize, - completion: @escaping @MainActor (TmuxPaneSurface?) -> Void - ) { - let view = GhosttySurfaceView(frame: CGRect(origin: .zero, size: size)) - let box = CallbackBox(controller: controller, paneID: terminal.paneID) - let scale = max(UIScreen.main.scale, 1) - var config = base - config.platform_tag = GHOSTTY_PLATFORM_IOS - config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(uiview: Unmanaged.passUnretained(view).toOpaque())) - config.userdata = Unmanaged.passUnretained(box).toOpaque() - config.write_cb = CallbackBox.write - config.renderer_health_cb = CallbackBox.health - config.scale_factor = Double(scale) - config.font_size = 14 - config.width_px = UInt32(max(1, (size.width * scale).rounded())) - config.height_px = UInt32(max(1, (size.height * scale).rounded())) - config.visible = false - config.focused = false - var handle: ghostty_terminal_surface_t? - guard ghostty_terminal_surface_new(app, terminal.handle, &config, &handle) == GHOSTTY_TERMINAL_SURFACE_RESULT_OK, let handle else { completion(nil); return } - let pane = TmuxPaneSurface(app: app, controller: controller, terminal: terminal, view: view, callbackBox: box, surface: handle) - box.owner = pane - view.drawSurface = { [weak pane] in pane?.draw() } - controller.registerSurface(paneID: terminal.paneID, surface: handle) { result in - guard case .success = result else { pane.freeUnregistered(); completion(nil); return } - pane.startDrawLoop() - pane.update(size: size) - completion(pane) - } - } - - private init(app: ghostty_app_t, controller: TmuxSessionController, terminal: TmuxSessionController.RetainedTerminal, view: GhosttySurfaceView, callbackBox: CallbackBox, surface: ghostty_terminal_surface_t) { - self.app = app; self.controller = controller; self.terminal = terminal; self.view = view; self.callbackBox = callbackBox; self.surface = surface; paneID = terminal.paneID - } - - func setVisible(_ next: Bool) { - guard !closed, visible != next, let surface else { return } - visible = next - _ = ghostty_terminal_surface_set_visible(surface, next) - displayLink?.isPaused = !next - if next { setNeedsDraw() } - } - - func setFocused(_ next: Bool) { - guard !closed, focused != next, let surface else { return } - focused = next - _ = ghostty_terminal_surface_set_focused(surface, next) - } - - func update(size: CGSize) { - guard let surface, !closed else { return } - let scale = max(view.window?.screen.scale ?? view.contentScaleFactor, 1) - let width = UInt32(max(1, (size.width * scale).rounded())) - let height = UInt32(max(1, (size.height * scale).rounded())) - guard lastMetrics?.0 != width || lastMetrics?.1 != height || lastMetrics?.2 != scale else { return } - lastMetrics = (width, height, scale) - view.frame = CGRect(origin: .zero, size: size) - view.contentScaleFactor = scale - view.alignGhosttyRendererSublayers() - _ = ghostty_terminal_surface_set_size(surface, width, height) - setNeedsDraw() - } - - @discardableResult func input(_ text: String) -> Bool { withBytes(text) { ghostty_terminal_surface_input($0, $1, $2) } } - @discardableResult func paste(_ text: String) -> Bool { withBytes(text) { ghostty_terminal_surface_paste($0, $1, $2) } } - @discardableResult func key(_ event: GhosttySurfaceKeyEvent) -> Bool { - guard let surface, !closed else { return false } - controller.prepareForInput() - return event.withCValue { accepted(ghostty_terminal_surface_key(surface, $0)) } - } - - func selectWord(at point: CGPoint) { guard let surface, !closed else { return }; var snapshot = ghostty_terminal_surface_selection_snapshot_s(); _ = ghostty_terminal_surface_select_word(surface, point.x * view.contentScaleFactor, point.y * view.contentScaleFactor, &snapshot); setNeedsDraw() } - func clearSelection() { guard let surface, !closed else { return }; var snapshot = ghostty_terminal_surface_selection_snapshot_s(); _ = ghostty_terminal_surface_clear_selection(surface, &snapshot); setNeedsDraw() } - func copySelection() -> String? { - guard let surface, !closed else { return nil }; var text = ghostty_text_s() - guard ghostty_terminal_surface_read_selection(surface, &text) == GHOSTTY_TERMINAL_SURFACE_INPUT_SENT else { return nil } - defer { _ = ghostty_terminal_surface_free_text(surface, &text) } - guard let pointer = text.text else { return nil } - return String(decoding: UnsafeRawBufferPointer(start: pointer, count: Int(text.text_len)), as: UTF8.self) - } - - func interactionState() -> ghostty_terminal_surface_interaction_state_s { guard let surface, !closed else { return .init() }; var state = ghostty_terminal_surface_interaction_state_s(); _ = ghostty_terminal_surface_interaction_state(surface, &state); return state } - func scroll(to row: UInt64, offset: Double) { guard let surface, !closed else { return }; var state = ghostty_terminal_surface_interaction_state_s(); _ = ghostty_terminal_surface_scroll_to_position(surface, row, offset, &state); setNeedsDraw() } - - func terminalChanged() { - guard let surface, !closed else { return } - lastRendererResult = ghostty_terminal_surface_terminal_changed(surface) - setNeedsDraw(); onTerminalActivity?() - } - - func rendererDiagnostics() -> String { - "visible=\(visible) focused=\(focused) draws=\(drawCount) health=\(view.rendererHealthy) last_result=\(lastRendererResult.rawValue) view=\(Int(view.bounds.width))x\(Int(view.bounds.height)) scale=\(view.contentScaleFactor)" - } - - private func withBytes(_ text: String, _ operation: (ghostty_terminal_surface_t, UnsafePointer?, Int) -> ghostty_terminal_surface_input_result_e) -> Bool { - guard let surface, !closed, !text.isEmpty else { return false } - // Do not inspect or alter copy mode while navigating, selecting, or - // copying. A real keystroke/paste is the sole intentional exit point. - controller.prepareForInput() - let result: ghostty_terminal_surface_input_result_e = text.utf8.withContiguousStorageIfAvailable { operation(surface, $0.baseAddress, $0.count) } ?? Array(text.utf8).withUnsafeBufferPointer { operation(surface, $0.baseAddress, $0.count) } - return accepted(result) - } - private func accepted(_ result: ghostty_terminal_surface_input_result_e) -> Bool { result == GHOSTTY_TERMINAL_SURFACE_INPUT_SENT || result == GHOSTTY_TERMINAL_SURFACE_INPUT_CONSUMED_NO_OUTPUT } - private func setNeedsDraw() { view.setNeedsDisplay() } - private func draw() { - guard visible, let surface, !closed else { return } - ghostty_app_tick(app) - lastRendererResult = ghostty_terminal_surface_draw(surface) - if lastRendererResult == GHOSTTY_TERMINAL_SURFACE_RESULT_OK { drawCount += 1; onTerminalActivity?() } - } - private func startDrawLoop() { let link = CADisplayLink(target: self, selector: #selector(tick)); link.add(to: .main, forMode: .common); link.isPaused = true; displayLink = link } - @objc private func tick() { draw() } - private func rendererFailed() { guard !closed else { return }; view.rendererHealthy = false; setVisible(false) } - private func freeUnregistered() { displayLink?.invalidate(); displayLink = nil; callbackBox.owner = nil; if let surface { ghostty_terminal_surface_free(surface) }; surface = nil; closed = true } - - func close(_ completion: @escaping @MainActor () -> Void = {}) { - guard closeFence.beginClose() else { - if closeFence.state == .awaitingNativeFree { - closeCompletions.append(completion) - } else { - completion() - } - return - } - closed = true; displayLink?.invalidate(); displayLink = nil; callbackBox.owner = nil - guard let surface else { - closeFence.finishNativeFree() - completion() - return - } - closeCompletions.append(completion) - controller.unregisterSurface(paneID: paneID, surface: surface) { [self] in - // Keep the owner (and therefore CallbackBox/UIKit view) alive until - // unregister fences every queued terminal_changed before native free. - ghostty_terminal_surface_free(surface) - self.surface = nil - self.closeFence.finishNativeFree() - let completions = self.closeCompletions - self.closeCompletions.removeAll() - completions.forEach { $0() } - } - } -} - -@MainActor -final class GhosttyTerminalResponderView: UIView, UIKeyInput, UITextInputTraits { - weak var pane: TmuxPaneSurface? - private var composition = GhosttyMarkedTextComposition() - lazy var floatingCursorTokenizer: UITextInputTokenizer = UITextInputStringTokenizer(textInput: self) - weak var inputDelegate: UITextInputDelegate? - var hasMarkedText: Bool { composition.isActive } - override var canBecomeFirstResponder: Bool { pane != nil } - var hasText: Bool { true } - var autocorrectionType: UITextAutocorrectionType = .no - var autocapitalizationType: UITextAutocapitalizationType = .none - var spellCheckingType: UITextSpellCheckingType = .no - var smartQuotesType: UITextSmartQuotesType = .no - var smartDashesType: UITextSmartDashesType = .no - func insertText(_ text: String) { submitCommittedText(text) } - /// Called by the UITextInput shim when UIKit updates a CJK marked range. - func updateMarkedText(_ text: String?) { composition.update(text) } - func commitMarkedText() { if let committed = composition.commit("") { _ = pane?.input(committed.replacingOccurrences(of: "\n", with: "\r")) } } - private func submitCommittedText(_ text: String) { if let committed = composition.commit(text) { _ = pane?.input(committed.replacingOccurrences(of: "\n", with: "\r")) } } - func deleteBackward() { _ = pane?.key(.backspace) } - override func paste(_ sender: Any?) { if let text = UIPasteboard.general.string { _ = pane?.paste(text) } } - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - var unhandled = Set() - for press in presses { - guard let key = press.key, let command = Self.command(for: key) else { unhandled.insert(press); continue } - switch command { case .key(let event): _ = pane?.key(event); case .text(let text): _ = pane?.input(text) } - } - if !unhandled.isEmpty { super.pressesBegan(unhandled, with: event) } - } - enum Command { case key(GhosttySurfaceKeyEvent), text(String) } - static func command(for key: UIKey) -> Command? { GhosttyTerminalHardwareCommandMapping.command(characters: key.characters, keyCode: key.keyCode, modifiers: key.modifierFlags).map { switch $0 { case .key(let key): .key(key); case .text(let text): .text(text) } } } - static func modifiers(_ input: UIKeyModifierFlags) -> GhosttySurfaceKeyEvent.Mods { var result: GhosttySurfaceKeyEvent.Mods = []; if input.contains(.shift) { result.insert(.shift) }; if input.contains(.control) { result.insert(.ctrl) }; if input.contains(.alternate) { result.insert(.alt) }; if input.contains(.command) { result.insert(.super) }; return result } -} - -/// UIKit requires a UITextInput document to drive IME/floating-cursor paths. -/// This virtual one-character document never represents terminal contents; -/// marked text stays local until UIKit commits it through unmark/replace/insert. -final class GhosttyVirtualTextPosition: UITextPosition { - let offset: Int - init(_ offset: Int) { self.offset = offset; super.init() } -} - -final class GhosttyVirtualTextRange: UITextRange { - let from: GhosttyVirtualTextPosition - let to: GhosttyVirtualTextPosition - init(_ from: GhosttyVirtualTextPosition, _ to: GhosttyVirtualTextPosition) { self.from = from; self.to = to; super.init() } - override var start: UITextPosition { from } - override var end: UITextPosition { to } - override var isEmpty: Bool { from.offset == to.offset } -} - -extension GhosttyTerminalResponderView: UITextInput { - var selectedTextRange: UITextRange? { - get { GhosttyVirtualTextRange(GhosttyVirtualTextPosition(1), GhosttyVirtualTextPosition(1)) } - set { _ = newValue } - } - var markedTextRange: UITextRange? { - guard hasMarkedText else { return nil } - return GhosttyVirtualTextRange(GhosttyVirtualTextPosition(0), GhosttyVirtualTextPosition(1)) - } - var markedTextStyle: [NSAttributedString.Key: Any]? { get { nil } set { _ = newValue } } - var beginningOfDocument: UITextPosition { GhosttyVirtualTextPosition(0) } - var endOfDocument: UITextPosition { GhosttyVirtualTextPosition(1) } - var tokenizer: UITextInputTokenizer { floatingCursorTokenizer } - var selectionAffinity: UITextStorageDirection { get { .forward } set { _ = newValue } } - - func text(in range: UITextRange) -> String? { - guard let range = range as? GhosttyVirtualTextRange, range.from.offset >= 0, range.to.offset <= 1 else { return nil } - return range.isEmpty ? "" : " " - } - func replace(_ range: UITextRange, withText text: String) { _ = range; submitCommittedText(text) } - func setMarkedText(_ markedText: String?, selectedRange: NSRange) { _ = selectedRange; updateMarkedText(markedText) } - func unmarkText() { commitMarkedText() } - func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { - guard let from = fromPosition as? GhosttyVirtualTextPosition, let to = toPosition as? GhosttyVirtualTextPosition else { return nil } - return GhosttyVirtualTextRange(from, to) - } - func position(from position: UITextPosition, offset: Int) -> UITextPosition? { - guard let position = position as? GhosttyVirtualTextPosition else { return nil } - return GhosttyVirtualTextPosition(max(0, min(1, position.offset + offset))) - } - func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { self.position(from: position, offset: offset) } - func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { - guard let lhs = position as? GhosttyVirtualTextPosition, let rhs = other as? GhosttyVirtualTextPosition else { return .orderedSame } - return lhs.offset == rhs.offset ? .orderedSame : lhs.offset < rhs.offset ? .orderedAscending : .orderedDescending - } - func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { guard let lhs = from as? GhosttyVirtualTextPosition, let rhs = toPosition as? GhosttyVirtualTextPosition else { return 0 }; return rhs.offset - lhs.offset } - func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { _ = direction; return range.end } - func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { _ = direction; guard let position = position as? GhosttyVirtualTextPosition else { return nil }; return GhosttyVirtualTextRange(position, position) } - func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> NSWritingDirection { _ = (position, direction); return .natural } - func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) { _ = (writingDirection, range) } - func firstRect(for range: UITextRange) -> CGRect { _ = range; return .zero } - func caretRect(for position: UITextPosition) -> CGRect { _ = position; return .zero } - func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { _ = range; return [] } - func closestPosition(to point: CGPoint) -> UITextPosition? { _ = point; return GhosttyVirtualTextPosition(0) } - func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { _ = (point, range); return GhosttyVirtualTextPosition(0) } - func characterRange(at point: CGPoint) -> UITextRange? { _ = point; let zero = GhosttyVirtualTextPosition(0); return GhosttyVirtualTextRange(zero, zero) } -} - -/// Identity-only seam for replacement tests; native surface handles stay private. -struct GhosttyTerminalHostAttachmentPolicy { - static func needsReplacement(current: ObjectIdentifier?, next: ObjectIdentifier) -> Bool { current != next } - /// A reused SwiftUI host may outlive adoption of its pane view by another - /// host. Only the current superview owner may touch that shared surface. - static func ownsPaneView(superviewIsHostScroll: Bool) -> Bool { superviewIsHostScroll } -} - -@MainActor -final class GhosttyTerminalHostView: UIView, UIScrollViewDelegate { - private let scroll = UIScrollView() - private let responder = GhosttyTerminalResponderView() - private weak var pane: TmuxPaneSurface? - private var budget = GhosttyScrollDeltaBudget() - private var lastOffset: CGFloat = 0 - private let projection = GhosttyScrollProjection() - private var isSynchronizingFromTerminal = false - override init(frame: CGRect) { super.init(frame: frame); scroll.delegate = self; scroll.alwaysBounceVertical = true; scroll.showsVerticalScrollIndicator = true; addSubview(scroll); addSubview(responder); let tap = UITapGestureRecognizer(target: self, action: #selector(focus)); addGestureRecognizer(tap); let long = UILongPressGestureRecognizer(target: self, action: #selector(handleSelection(_:))); addGestureRecognizer(long) } - required init?(coder: NSCoder) { fatalError() } - func install(_ pane: TmuxPaneSurface) { - guard GhosttyTerminalHostAttachmentPolicy.needsReplacement(current: self.pane.map(ObjectIdentifier.init), next: ObjectIdentifier(pane)) else { - synchronizePresentationActivity() - return - } - // SwiftUI may reuse this host while focusedPaneID changes. The old - // surface must be fully detached before the new view is ordered in, - // otherwise it can keep a display link and input callback alive here. - teardownCurrentPane() - self.pane = pane - responder.pane = pane - pane.onTerminalActivity = { [weak self] in self?.synchronizeScrollFromTerminal() } - pane.view.removeFromSuperview() - scroll.addSubview(pane.view) - budget = .init() - lastOffset = 0 - isSynchronizingFromTerminal = false - synchronizePresentationActivity() - setNeedsLayout() - } - override func didMoveToWindow() { - super.didMoveToWindow() - // SwiftUI can call updateUIView before this host is attached. This is - // the authoritative visibility transition, not install(). - synchronizePresentationActivity() - setNeedsLayout() - } - private func synchronizePresentationActivity() { - pane?.setVisible(window != nil) - pane?.setFocused(window != nil && responder.isFirstResponder) - pane?.view.alignGhosttyRendererSublayers() - } - func detach() { teardownCurrentPane() } - private func teardownCurrentPane() { - responder.resignFirstResponder() - responder.pane = nil - guard let pane else { return } - guard GhosttyTerminalHostAttachmentPolicy.ownsPaneView(superviewIsHostScroll: pane.view.superview === scroll) else { - // A newer host has adopted this view. Clearing the callback or - // visibility here would blank that live host's terminal. - self.pane = nil - return - } - pane.onTerminalActivity = nil - pane.setFocused(false) - pane.setVisible(false) - pane.view.removeFromSuperview() - self.pane = nil - } - override func layoutSubviews() { super.layoutSubviews(); scroll.frame = bounds; responder.frame = bounds; guard let pane else { return }; pane.view.frame = CGRect(origin: CGPoint(x: 0, y: scroll.contentOffset.y), size: bounds.size); pane.update(size: bounds.size); let state = pane.interactionState().scrollbar; let cellHeight = max(bounds.height / CGFloat(max(state.len, 1)), 1); scroll.contentSize = CGSize(width: bounds.width, height: max(bounds.height, CGFloat(state.total) * cellHeight)) } - @objc private func focus() { _ = responder.becomeFirstResponder(); synchronizePresentationActivity() } - @objc private func handleSelection(_ recognizer: UILongPressGestureRecognizer) { guard recognizer.state == .began, let pane else { return }; pane.selectWord(at: recognizer.location(in: pane.view)); if let text = pane.copySelection(), !text.isEmpty { UIPasteboard.general.string = text } } - func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard let pane else { return } - let state = pane.interactionState().scrollbar - let cellHeight = max(bounds.height / CGFloat(max(state.len, 1)), 1) - pane.view.frame.origin.y = scrollView.contentOffset.y - guard !isSynchronizingFromTerminal else { return } - let delta = budget.clamp(Double(scrollView.contentOffset.y - lastOffset), now: CACurrentMediaTime()) - lastOffset = scrollView.contentOffset.y - guard delta != 0 else { return } - let maximumRow = Double(state.total > state.len ? state.total - state.len : 0) - let row = UInt64(max(0, min(maximumRow, floor(Double(scrollView.contentOffset.y / cellHeight))))) - pane.scroll(to: row, offset: 0) - } - private func synchronizeScrollFromTerminal() { - guard let pane else { return } - let state = pane.interactionState().scrollbar; let cellHeight = max(bounds.height / CGFloat(max(state.len, 1)), 1) - let height = max(bounds.height, CGFloat(state.total) * cellHeight) - let followsBottom = scroll.contentOffset.y >= max(0, scroll.contentSize.height - bounds.height - 1) - scroll.contentSize = CGSize(width: bounds.width, height: height) - let offset = projection.synchronize(currentOffset: scroll.contentOffset.y, contentHeight: height, viewportHeight: bounds.height, followsBottom: followsBottom) - isSynchronizingFromTerminal = true - defer { isSynchronizingFromTerminal = false } - scroll.setContentOffset(CGPoint(x: 0, y: offset), animated: false) - pane.view.frame.origin.y = offset - lastOffset = offset - } -} - -struct TmuxPaneSurfaceView: UIViewRepresentable { - let surface: TmuxPaneSurface? - func makeUIView(context: Context) -> GhosttyTerminalHostView { GhosttyTerminalHostView() } - func updateUIView(_ host: GhosttyTerminalHostView, context: Context) { if let surface { host.install(surface) } } - static func dismantleUIView(_ uiView: GhosttyTerminalHostView, coordinator: ()) { uiView.detach(); uiView.removeFromSuperview() } -} diff --git a/MoriRemote/MoriRemote/Ghostty/GhosttyTerminalProbe.swift b/MoriRemote/MoriRemote/Ghostty/GhosttyTerminalProbe.swift deleted file mode 100644 index f3e7c3c5..00000000 --- a/MoriRemote/MoriRemote/Ghostty/GhosttyTerminalProbe.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SwiftUI -import Observation -import OSLog - -#if DEBUG -/// Credential-free integration route. The terminal pixels below are the native -/// Ghostty UIView fed by the same tmux controller/link used in production. -struct GhosttyTerminalProbe: View { - @State private var model = ProbeModel() - var body: some View { - VStack(spacing: 8) { - if let surface = model.surface { - TmuxPaneSurfaceView(surface: surface) - } else if model.didTimeOut { - ContentUnavailableView("Ghostty renderer failed", systemImage: "exclamationmark.triangle", description: Text(model.status)) - } else { - ProgressView(String(localized: "Ghostty terminal probe loading")) - } - Text(model.status).font(.caption).foregroundStyle(model.didTimeOut ? .red : .secondary) - } - .padding() - .accessibilityIdentifier("ghostty-terminal-probe") - .task { await model.start() } - .onDisappear { Task { await model.stop() } } - } -} - -@MainActor -@Observable private final class ProbeModel { - var surface: TmuxPaneSurface? - var status = String(localized: "Starting Ghostty tmux transcript…") - var didTimeOut = false - private var ghostty: GhosttyKitRuntime? - private var timeoutTask: Task? - private var runtime: GhosttyTmuxRuntime? - private var didRecordResult = false - private let logger = Logger(subsystem: "com.vaayne.mori-remote", category: "ghostty-probe") - - private func recordResult(success: Bool, detail: String) { - guard !didRecordResult else { return } - didRecordResult = true - if success { - logger.notice("MORI_GHOSTTY_PROBE_RESULT success=true detail=\(detail, privacy: .public)") - } else { - logger.error("MORI_GHOSTTY_PROBE_RESULT success=false detail=\(detail, privacy: .public)") - } - } - - func start() async { - guard runtime == nil else { return } - do { - let ghostty = try GhosttyKitRuntime() - let pane = "%0;83;44;0;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;;;0;0;43;8,16\n" - let window = "$42 @0 1 %0 83 44 b7dd,83x44,0,0,0 b7dd,83x44,0,0,0 probe\n" - // This startup transcript is the upstream deterministic fixture. - let transcript = "%begin 1 1 0\n%end 1 1 0\n%session-changed $42 main\n" + "%begin 2 2 1\n3.1\n%end 2 2 1\n" + "%begin 3 3 1\n%end 3 3 1\n" + "%begin 4 4 1\n\(window)%end 4 4 1\n" + "%begin 5 5 1\n\(pane)%end 5 5 1\n" + (6...9).map { "%begin \($0) \($0) 1\n%end \($0) \($0) 1\n" }.joined() - let runtime = GhosttyTmuxRuntime(app: ghostty.appHandle, transport: DeterministicTmuxControlTransport(transcript: [transcript])) - runtime.onSurface = { [weak self, weak runtime] surface in - self?.timeoutTask?.cancel() - self?.surface = surface - runtime?.feedDeterministicOutput("%output %0 MoriRemote Ghostty transcript\\015\\012$ \n") - self?.status = String(localized: "Ghostty transcript fed; waiting for native draw…") - Task { @MainActor [weak self, weak surface] in - guard let surface else { return } - for _ in 0..<20 { - if surface.drawCount >= 3 { - self?.status = String(localized: "Ghostty rendered deterministic tmux transcript") - self?.recordResult(success: true, detail: "draw-threshold") - return - } - try? await Task.sleep(for: .milliseconds(100)) - } - self?.didTimeOut = true - self?.status = String(format: String(localized: "Ghostty renderer did not draw transcript: %@"), surface.rendererDiagnostics()) - self?.recordResult(success: false, detail: "draw-threshold-timeout") - } - } - runtime.onState = { [weak self] state in self?.status = String(format: String(localized: "Ghostty tmux: %@"), String(describing: state)) } - self.ghostty = ghostty; self.runtime = runtime - try await runtime.start(columns: 83, rows: 44) - timeoutTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled, let self else { return } - guard let surface = self.surface else { - self.didTimeOut = true - self.status = String(localized: "No live Ghostty terminal surface arrived within 5 seconds.") - self.recordResult(success: false, detail: "surface-timeout") - return - } - guard surface.drawCount < 3 else { return } - self.didTimeOut = true - self.status = String(format: String(localized: "Ghostty renderer timed out: %@"), surface.rendererDiagnostics()) - self.recordResult(success: false, detail: "renderer-timeout") - } - } catch { - didTimeOut = true - status = String(format: String(localized: "Ghostty probe failed: %@"), String(describing: error)) - recordResult(success: false, detail: "startup-error") - } - } - func stop() async { - timeoutTask?.cancel() - timeoutTask = nil - await runtime?.stop() - runtime = nil - ghostty?.shutdown() - ghostty = nil - surface = nil - } -} -#endif diff --git a/MoriRemote/MoriRemote/Ghostty/GhosttyTmuxRuntime.swift b/MoriRemote/MoriRemote/Ghostty/GhosttyTmuxRuntime.swift deleted file mode 100644 index 52bfc8c8..00000000 --- a/MoriRemote/MoriRemote/Ghostty/GhosttyTmuxRuntime.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation -import GhosttyKit -import UIKit - -/// Pure callback identity fence; native surfaces are deliberately not fabricated -/// in tests. Runtime callbacks may publish only while their original instance is live. -struct GhosttyRuntimeCallbackGate: Sendable { - let instanceID: UUID - private(set) var stopped = false - mutating func stop() { stopped = true } - func accepts(_ id: UUID) -> Bool { !stopped && id == instanceID } -} - -/// One-shot composition. Native parsing stays on the controller queue; UIKit -/// owns renderers and waits for unregister before releasing their handles. -@MainActor -final class GhosttyTmuxRuntime { - let instanceID: UUID - private let app: ghostty_app_t - private let controller: TmuxSessionController - private let link: TmuxSessionLink - private var surfaces: [TmuxPaneID: TmuxPaneSurface] = [:] - private var gate: GhosttyRuntimeCallbackGate - private var stopped: Bool { gate.stopped } - private var viewport = CGSize(width: 390, height: 600) - private var creatingPaneIDs = Set() - private var creationWaiters: [CheckedContinuation] = [] - - var onTopology: (@MainActor (TmuxSessionController.Topology) -> Void)? - var onSurface: (@MainActor (TmuxPaneSurface?) -> Void)? - var onState: (@MainActor (TmuxSessionController.State) -> Void)? - /// Phase 4 presents this server-side pane-input rejection; Phase 3 keeps - /// it observable instead of silently dropping the controller callback. - var onInputFailed: (@MainActor (String) -> Void)? - - init(app: ghostty_app_t, transport: any TmuxControlTransport, instanceID: UUID = UUID()) { - self.app = app - self.instanceID = instanceID - gate = GhosttyRuntimeCallbackGate(instanceID: instanceID) - let relay = Relay() - controller = TmuxSessionController(callbacks: .init( - state: { state in Task { @MainActor in relay.owner?.receive(state, from: relay.id) } }, - topology: { topology in Task { @MainActor in relay.owner?.receive(topology, from: relay.id) } }, - terminal: { terminal in Task { @MainActor in relay.owner?.receive(terminal, from: relay.id) } }, - paneRemoved: { paneID in Task { @MainActor in relay.owner?.remove(paneID, from: relay.id) } }, - inputFailed: { message in Task { @MainActor in relay.owner?.receiveInputFailure(message, from: relay.id) } } - )) - link = TmuxSessionLink(transport: transport, receive: { relay.controller?.pump($0) }, disconnected: { relay.controller?.transportClosed() }) - relay.controller = controller; relay.owner = self; relay.id = instanceID - controller.setOutboundSink { [link] bytes in link.enqueue(bytes) } - } - - func start(columns: UInt16, rows: UInt16, historyLineLimit: Int = TmuxSessionController.initialHistoryLineLimit) async throws { - let controller = controller - try await link.start(beforeReceive: { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in controller.start(columns: columns, rows: rows, historyLineLimit: historyLineLimit) { result in continuation.resume(with: result) } } }) - } - - func updateViewport(_ size: CGSize) { - viewport = size - surfaces.values.forEach { $0.update(size: size) } - } - - func surface(for paneID: TmuxPaneID) -> TmuxPaneSurface? { surfaces[paneID] } - func isActive() async -> Bool { await link.isActive() } - func selectWindow(_ id: TmuxWindowID) { controller.selectWindow(id) } - func selectPane(_ id: TmuxPaneID) { controller.selectPane(id) } - func mutateSharedWorkspace(_ mutation: TmuxClientCommandPolicy.SharedMutation) { - controller.mutateSharedWorkspace(mutation) - } - - /// DEBUG probe feeds output only after the surface registration fence. - func feedDeterministicOutput(_ output: String) { - controller.pump(Data(output.utf8)) - // The parser queue publishes output before it signals the renderer; - // schedule after that serial feed has completed for the probe fixture. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.surfaces.values.forEach { $0.terminalChanged() } - } - } - func sendInput(_ text: String, to pane: TmuxPaneID) { controller.sendInput(Data(text.utf8), to: pane, tracked: true) { _ in } } - func queryAgentMetadata(completion: @escaping @Sendable (TmuxSessionController.CommandResult) -> Void) { - controller.queryAgentMetadata(completion: completion) - } - - func stop() async { - guard !stopped else { return }; gate.stop() - controller.setOutboundSink(nil) - await link.stop() - while !creatingPaneIDs.isEmpty { await withCheckedContinuation { creationWaiters.append($0) } } - let panes = Array(surfaces.values); surfaces.removeAll() - for pane in panes { await withCheckedContinuation { (continuation: CheckedContinuation) in pane.close { continuation.resume() } } } - await withCheckedContinuation { (continuation: CheckedContinuation) in controller.shutdown { continuation.resume() } } - } - - private func receive(_ state: TmuxSessionController.State, from id: UUID) { guard gate.accepts(id) else { return }; onState?(state) } - private func receive(_ topology: TmuxSessionController.Topology, from id: UUID) { guard gate.accepts(id) else { return }; onTopology?(topology) } - private func receiveInputFailure(_ message: String, from id: UUID) { guard gate.accepts(id) else { return }; onInputFailed?(message) } - private func receive(_ terminal: TmuxSessionController.RetainedTerminal, from id: UUID) { - guard gate.accepts(id), surfaces[terminal.paneID] == nil, creatingPaneIDs.insert(terminal.paneID).inserted else { return } - TmuxPaneSurface.create(app: app, controller: controller, terminal: terminal, config: ghostty_terminal_surface_config_new(), size: viewport) { [weak self] pane in - guard let self else { pane?.close(); return } - self.creatingPaneIDs.remove(terminal.paneID) - if self.creatingPaneIDs.isEmpty { let waiters = self.creationWaiters; self.creationWaiters.removeAll(); waiters.forEach { $0.resume() } } - guard !self.stopped, id == self.instanceID else { pane?.close(); return } - guard let pane else { return } - self.surfaces[pane.paneID] = pane - pane.terminalChanged() - self.onSurface?(pane) - } - } - private func remove(_ paneID: TmuxPaneID, from id: UUID) { - guard gate.accepts(id), let pane = surfaces.removeValue(forKey: paneID) else { return } - pane.close() - } - private final class Relay: @unchecked Sendable { weak var owner: GhosttyTmuxRuntime?; weak var controller: TmuxSessionController?; var id = UUID() } -} diff --git a/MoriRemote/MoriRemote/GhosttyKitABIProbe.swift b/MoriRemote/MoriRemote/GhosttyKitABIProbe.swift deleted file mode 100644 index 9dfae993..00000000 --- a/MoriRemote/MoriRemote/GhosttyKitABIProbe.swift +++ /dev/null @@ -1,9 +0,0 @@ -import GhosttyKit - -/// Compile-time contract for the sans-I/O tmux ABI required by the remux rewrite. -/// This is intentionally unused: Phase 0 must not alter the existing terminal flow. -@MainActor -enum GhosttyKitABIProbe { - static let tmuxClientConfigConstructor: () -> ghostty_tmux_client_config_s = - ghostty_tmux_client_config_new -} diff --git a/MoriRemote/MoriRemote/MoriRemoteApp.swift b/MoriRemote/MoriRemote/MoriRemoteApp.swift index 72b2219d..6f6898c3 100644 --- a/MoriRemote/MoriRemote/MoriRemoteApp.swift +++ b/MoriRemote/MoriRemote/MoriRemoteApp.swift @@ -1,3 +1,4 @@ +import MoriRemoteTerminal import SwiftUI import UIKit @@ -10,7 +11,7 @@ struct MoriRemoteApp: App { Group { #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ghostty-terminal-probe") { - GhosttyTerminalProbe() + MoriRemoteTerminalProbe() } else { RemoteRootView(root: root) } diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index 2ee28cc7..14caa030 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -50,6 +50,10 @@ "No Servers" = "No Servers"; "No matches for “%@”" = "No matches for “%@”"; "No tmux sessions" = "No tmux sessions"; +"Load sessions" = "Load Sessions"; +"Loading sessions…" = "Loading Sessions…"; +"Refresh sessions" = "Refresh Sessions"; +"Session discovery failed" = "Couldn’t Load Sessions"; "Not Connected" = "Not Connected"; "Offline" = "Offline"; "Opening shell…" = "Opening shell…"; @@ -111,6 +115,71 @@ "Function Keys" = "Function Keys"; "Home key" = "Home key"; "Modifiers" = "Modifiers"; +"Shortcuts" = "Shortcuts"; +"Keypad" = "Keypad"; +"Navigator" = "Navigator"; +"Current host" = "Current host"; +"Manage servers" = "Manage servers"; +"Choose a session to switch terminals" = "Choose a session to switch terminals"; +"Add image" = "Add image"; +"Choose photo" = "Choose photo"; +"Paste image" = "Paste image"; +"Uploading image…" = "Uploading image…"; +"The clipboard does not contain an image." = "The clipboard does not contain an image."; +"The selected image could not be loaded." = "The selected image could not be loaded."; +"The uploaded image path is invalid." = "The uploaded image path is invalid."; +"The terminal could not insert the image path." = "The terminal could not insert the image path."; +"Image" = "Image"; +"Insert path" = "Insert path"; +"Loading image…" = "Loading image…"; +"Choose an image" = "Choose an image"; +"The image is uploaded through the current SSH server, then its remote path is inserted into the terminal." = "The image is uploaded through the current SSH server, then its remote path is inserted into the terminal."; +"This SSH connection cannot upload files." = "This SSH connection cannot upload files."; +"The image filename is invalid." = "The image filename is invalid."; +"The local image is no longer available." = "The local image is no longer available."; +"The image upload timed out." = "The image upload timed out."; +"The image upload failed." = "The image upload failed."; +"Essential" = "Essential"; +"Process" = "Process"; +"One shot" = "One Shot"; +"One shot armed" = "One Shot Armed"; +"Interrupt" = "Interrupt"; +"EOF" = "EOF"; +"Suspend" = "Suspend"; +"Clear" = "Clear"; +"History" = "History"; +"Line start" = "Line Start"; +"Line end" = "Line End"; +"Delete left" = "Delete Left"; +"Delete right" = "Delete Right"; +"Delete word" = "Delete Word"; +"Previous word" = "Previous Word"; +"Next word" = "Next Word"; +"Filter sessions, windows, and panes" = "Filter Sessions, Windows, and Panes"; +"Nothing here" = "Nothing Here"; +"No matching results" = "No Matching Results"; +"Common shortcuts" = "Common Shortcuts"; +"Line editing" = "Line Editing"; +"Ctrl-C · Interrupt" = "Ctrl-C · Interrupt"; +"Ctrl-D · End input" = "Ctrl-D · End Input"; +"Ctrl-Z · Suspend" = "Ctrl-Z · Suspend"; +"Ctrl-L · Clear" = "Ctrl-L · Clear"; +"Ctrl-R · History search" = "Ctrl-R · History Search"; +"Ctrl-A · Line start" = "Ctrl-A · Line Start"; +"Ctrl-E · Line end" = "Ctrl-E · Line End"; +"Ctrl-U · Delete to start" = "Ctrl-U · Delete to Start"; +"Ctrl-K · Delete to end" = "Ctrl-K · Delete to End"; +"Ctrl-W · Delete word" = "Ctrl-W · Delete Word"; +"Alt-B · Previous word" = "Alt-B · Previous Word"; +"Alt-F · Next word" = "Alt-F · Next Word"; +"Terminal keys" = "Terminal Keys"; +"tmux actions" = "tmux Actions"; +"New window" = "New Window"; +"Split right" = "Split Right"; +"Split down" = "Split Down"; +"Close window" = "Close Window"; +"Page Up" = "Page Up"; +"Page Down" = "Page Down"; "Navigation" = "Navigation"; "Next Pane" = "Next Pane"; "Next pane (⌘])" = "Next pane (⌘])"; @@ -170,10 +239,12 @@ "Choose a saved workspace to open its terminal." = "Choose a saved workspace to open its terminal."; "No saved servers" = "No Saved Servers"; "No matching workspaces" = "No Matching Workspaces"; +"No matching sessions" = "No Matching Sessions"; "Add a server and workspace to begin." = "Add a server and workspace to begin."; "Edit server" = "Edit Server"; "Delete server" = "Delete Server"; "Filter servers and workspaces" = "Filter servers and workspaces"; +"Filter servers and sessions" = "Filter servers and sessions"; "Settings" = "Settings"; "Add server" = "Add Server"; "Connection lost." = "Connection lost."; @@ -185,7 +256,11 @@ "Split down (shared)" = "Split Down (shared)"; "New window (shared)" = "New Window (shared)"; "Close pane (shared)" = "Close Pane (shared)"; +"Close window (shared)" = "Close Window (shared)"; "Close pane" = "Close Pane"; + +"Confirm shared workspace change" = "Confirm Shared Workspace Change"; +"This change affects every tmux client." = "This change affects every tmux client."; "Close shared pane?" = "Close Shared Pane?"; "Closing this shared pane affects every tmux client." = "Closing this shared pane affects every tmux client."; "Confirm destructive action" = "Confirm Destructive Action"; @@ -196,6 +271,8 @@ "Waiting for the active tmux pane." = "Waiting for the active tmux pane."; "Windows" = "Windows"; "Panes" = "Panes"; +"Active workspaces" = "Active Workspaces"; +"Sessions on %@" = "Sessions on %@"; "Workspace controls" = "Workspace Controls"; "Server" = "Server"; "Name" = "Name"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index 32c41a6e..08c2a2fd 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -50,6 +50,10 @@ "Filter sessions" = "筛选会话"; "No matches for “%@”" = "没有匹配“%@”的结果"; "No tmux sessions" = "没有 tmux 会话"; +"Load sessions" = "加载会话"; +"Loading sessions…" = "正在加载会话…"; +"Refresh sessions" = "刷新会话"; +"Session discovery failed" = "无法加载会话"; "Not Connected" = "未连接"; "Offline" = "离线"; "Opening shell…" = "正在打开 shell…"; @@ -111,6 +115,71 @@ "Function Keys" = "功能键"; "Home key" = "Home 键"; "Modifiers" = "修饰键"; +"Shortcuts" = "快捷键"; +"Keypad" = "按键面板"; +"Navigator" = "导航"; +"Current host" = "当前主机"; +"Manage servers" = "管理服务器"; +"Choose a session to switch terminals" = "选择会话后才会切换终端"; +"Add image" = "添加图片"; +"Choose photo" = "选择照片"; +"Paste image" = "粘贴图片"; +"Uploading image…" = "正在上传图片…"; +"The clipboard does not contain an image." = "剪贴板中没有图片。"; +"The selected image could not be loaded." = "无法读取所选图片。"; +"The uploaded image path is invalid." = "上传后的图片路径无效。"; +"The terminal could not insert the image path." = "终端无法插入图片路径。"; +"Image" = "图片"; +"Insert path" = "插入路径"; +"Loading image…" = "正在读取图片…"; +"Choose an image" = "选择图片"; +"The image is uploaded through the current SSH server, then its remote path is inserted into the terminal." = "图片会通过当前 SSH 服务器上传,然后将远程路径插入终端。"; +"This SSH connection cannot upload files." = "此 SSH 连接无法上传文件。"; +"The image filename is invalid." = "图片文件名无效。"; +"The local image is no longer available." = "本地图片已不可用。"; +"The image upload timed out." = "图片上传超时。"; +"The image upload failed." = "图片上传失败。"; +"Essential" = "基础按键"; +"Process" = "进程控制"; +"One shot" = "一次性"; +"One shot armed" = "一次性已启用"; +"Interrupt" = "中断"; +"EOF" = "结束输入"; +"Suspend" = "挂起"; +"Clear" = "清屏"; +"History" = "历史搜索"; +"Line start" = "行首"; +"Line end" = "行尾"; +"Delete left" = "删除左侧"; +"Delete right" = "删除右侧"; +"Delete word" = "删除单词"; +"Previous word" = "上一个单词"; +"Next word" = "下一个单词"; +"Filter sessions, windows, and panes" = "筛选会话、窗口和面板"; +"Nothing here" = "这里还没有内容"; +"No matching results" = "没有匹配结果"; +"Common shortcuts" = "常用快捷键"; +"Line editing" = "行编辑"; +"Ctrl-C · Interrupt" = "Ctrl-C · 中断"; +"Ctrl-D · End input" = "Ctrl-D · 结束输入"; +"Ctrl-Z · Suspend" = "Ctrl-Z · 挂起"; +"Ctrl-L · Clear" = "Ctrl-L · 清屏"; +"Ctrl-R · History search" = "Ctrl-R · 搜索历史"; +"Ctrl-A · Line start" = "Ctrl-A · 跳到行首"; +"Ctrl-E · Line end" = "Ctrl-E · 跳到行尾"; +"Ctrl-U · Delete to start" = "Ctrl-U · 删除至行首"; +"Ctrl-K · Delete to end" = "Ctrl-K · 删除至行尾"; +"Ctrl-W · Delete word" = "Ctrl-W · 删除单词"; +"Alt-B · Previous word" = "Alt-B · 上一个单词"; +"Alt-F · Next word" = "Alt-F · 下一个单词"; +"Terminal keys" = "终端按键"; +"tmux actions" = "tmux 操作"; +"New window" = "新建窗口"; +"Split right" = "向右分屏"; +"Split down" = "向下分屏"; +"Close window" = "关闭窗口"; +"Page Up" = "向上翻页"; +"Page Down" = "向下翻页"; "Navigation" = "导航"; "Next Pane" = "下一个面板"; "Next pane (⌘])" = "下一个面板(⌘])"; @@ -170,10 +239,12 @@ "Choose a saved workspace to open its terminal." = "选择一个已保存的工作区以打开终端。"; "No saved servers" = "没有已保存的服务器"; "No matching workspaces" = "没有匹配的工作区"; +"No matching sessions" = "没有匹配的会话"; "Add a server and workspace to begin." = "添加服务器和工作区后即可开始。"; "Edit server" = "编辑服务器"; "Delete server" = "删除服务器"; "Filter servers and workspaces" = "筛选服务器和工作区"; +"Filter servers and sessions" = "筛选服务器和会话"; "Settings" = "设置"; "Add server" = "添加服务器"; "Connection lost." = "连接已中断。"; @@ -185,7 +256,11 @@ "Split down (shared)" = "向下分屏(共享)"; "New window (shared)" = "新建窗口(共享)"; "Close pane (shared)" = "关闭面板(共享)"; +"Close window (shared)" = "关闭窗口(共享)"; "Close pane" = "关闭面板"; + +"Confirm shared workspace change" = "确认共享工作区更改"; +"This change affects every tmux client." = "此更改会影响每个 tmux 客户端。"; "Close shared pane?" = "关闭共享面板?"; "Closing this shared pane affects every tmux client." = "关闭此共享面板会影响所有 tmux 客户端。"; "Confirm destructive action" = "确认破坏性操作"; @@ -196,6 +271,8 @@ "Waiting for the active tmux pane." = "正在等待活动的 tmux 面板。"; "Windows" = "窗口"; "Panes" = "面板"; +"Active workspaces" = "活动工作区"; +"Sessions on %@" = "%@ 上的会话"; "Workspace controls" = "工作区控制"; "Server" = "服务器"; "Name" = "名称"; diff --git a/MoriRemote/MoriRemote/SSH/CitadelSSHTransport.swift b/MoriRemote/MoriRemote/SSH/CitadelSSHTransport.swift index 802d39c3..fc21caa1 100644 --- a/MoriRemote/MoriRemote/SSH/CitadelSSHTransport.swift +++ b/MoriRemote/MoriRemote/SSH/CitadelSSHTransport.swift @@ -187,11 +187,160 @@ final class CitadelSSHRootConnection: SSHRootConnection, @unchecked Sendable { return CitadelControlChannel(child) } + func openFileUploadSession() async throws -> any SSHFileUploadSession { + let sftp = try await SSHUploadTimeout.run( + timeout: .seconds(15), + operation: { [channel] in + try await SFTPClient.open(overAuthenticatedSSHChannel: channel) + }, + cleanupLateSuccess: { sftp in try? await sftp.close() } + ) + return CitadelSFTPFileUploadSession(sftp: sftp) + } + func close() async { try? await channel.close() } } +final class CitadelSFTPFileUploadSession: SSHFileUploadSession, @unchecked Sendable { + private static let chunkSize = 4 * 1024 * 1024 + private static let maxInFlightWrites = 64 + private enum State: Equatable { case open, closing, timedOut, closed } + private let sftp: SFTPClient + private let closeLock = NSLock() + private var state = State.open + + init(sftp: SFTPClient) { + self.sftp = sftp + } + + func ensureDirectoryExists(atPath path: String) async throws { + do { + _ = try await operation { try await self.sftp.getAttributes(at: path) } + } catch where isNoSuchFile(error) { + do { + try await operation { try await self.sftp.createDirectory(atPath: path) } + } catch { + do { + _ = try await operation { try await self.sftp.getAttributes(at: path) } + } catch { + throw error + } + } + } + } + + func uploadFile( + from localURL: URL, + to remotePath: String, + progress: @escaping SSHFileUploadProgressHandler + ) async throws { + let localFile = try FileHandle(forReadingFrom: localURL) + defer { try? localFile.close() } + let remoteFile = try await operation { + try await self.sftp.openFile(filePath: remotePath, flags: [.write, .create, .truncate]) + } + do { + var offset: UInt64 = 0 + while true { + try Task.checkCancellation() + let data = try localFile.read(upToCount: Self.chunkSize) ?? Data() + guard !data.isEmpty else { break } + var buffer = ByteBufferAllocator().buffer(capacity: data.count) + buffer.writeBytes(data) + let writeBuffer = buffer + let writeOffset = offset + try await operation { + try await remoteFile.writePipelined( + writeBuffer, + at: writeOffset, + maxInFlight: Self.maxInFlightWrites + ) + } + offset += UInt64(data.count) + await progress(Int64(min(offset, UInt64(Int64.max)))) + } + try await operation { try await remoteFile.close() } + } catch { + try? await remoteFile.close() + throw error + } + } + + func renameFile(from temporaryPath: String, to finalPath: String) async throws { + try await operation { try await self.sftp.rename(at: temporaryPath, to: finalPath) } + } + + func removeFileIfExists(atPath path: String) async throws { + do { + try await operation { try await self.sftp.remove(at: path) } + } catch where isNoSuchFile(error) { + return + } + } + + func close() async throws { + let action = closeLock.withLock { () -> State in + let previous = state + if previous == .open { state = .closing } + return previous + } + switch action { + case .closed: + return + case .timedOut, .closing: + throw SSHFileUploadError.operationTimedOut + case .open: + do { + try await SSHUploadTimeout.run( + timeout: .seconds(15), + operation: { try await self.sftp.close() }, + onTimeout: { self.invalidateAfterTimeout() } + ) + closeLock.withLock { state = .closed } + } catch { + invalidateAfterTimeout() + throw error + } + } + } + + private func operation( + _ body: @escaping @Sendable () async throws -> Value + ) async throws -> Value { + guard closeLock.withLock({ state == .open }) else { + throw SSHFileUploadError.operationTimedOut + } + do { + return try await SSHUploadTimeout.run( + timeout: .seconds(15), + operation: body, + onTimeout: { self.invalidateAfterTimeout() } + ) + } catch is CancellationError { + invalidateAfterTimeout() + throw CancellationError() + } + } + + private func invalidateAfterTimeout() { + let shouldClose = closeLock.withLock { () -> Bool in + guard state == .open || state == .closing else { return false } + state = .timedOut + return true + } + guard shouldClose else { return } + let sftp = self.sftp + Task { try? await sftp.close() } + } + + private func isNoSuchFile(_ error: Error) -> Bool { + guard let status = error as? SFTPMessage.Status else { return false } + return status.errorCode == .noSuchFile + } +} + final class CitadelControlChannel: SSHChildChannel, @unchecked Sendable { nonisolated let receivedBytes: AsyncThrowingStream private let channel: Channel diff --git a/MoriRemote/MoriRemote/SSH/SSHImageUpload.swift b/MoriRemote/MoriRemote/SSH/SSHImageUpload.swift new file mode 100644 index 00000000..9db22c8f --- /dev/null +++ b/MoriRemote/MoriRemote/SSH/SSHImageUpload.swift @@ -0,0 +1,255 @@ +import Foundation +import MoriRemoteTerminal +import NIOCore + +extension SSHFileUploadError: LocalizedError { + var errorDescription: String? { + switch self { + case .unsupported: String(localized: "This SSH connection cannot upload files.") + case .invalidFilename: String(localized: "The image filename is invalid.") + case .localFileUnavailable: String(localized: "The local image is no longer available.") + case .operationTimedOut: String(localized: "The image upload timed out.") + case .uploadFailed: String(localized: "The image upload failed.") + } + } +} + +private final class SSHUploadTimeoutGate: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var pendingResult: Result? + private var tasks: [Task] = [] + private var finished = false + + func install(_ continuation: CheckedContinuation) { + let pending = lock.withLock { () -> Result? in + if finished { return pendingResult } + self.continuation = continuation + return nil + } + if let pending { continuation.resume(with: pending) } + } + + func setTasks(_ tasks: [Task]) { + let cancel = lock.withLock { () -> [Task] in + if finished { return tasks } + self.tasks = tasks + return [] + } + cancel.forEach { $0.cancel() } + } + + func succeed(_ value: Value) -> Bool { finish(.success(value)) } + func fail(_ error: Error) -> Bool { finish(.failure(error)) } + func cancel() { _ = fail(CancellationError()) } + + func beginTimeout() -> Bool { + let cancel = lock.withLock { () -> [Task]? in + guard !finished else { return nil } + finished = true + let tasks = self.tasks + self.tasks.removeAll() + return tasks + } + guard let cancel else { return false } + cancel.forEach { $0.cancel() } + return true + } + + func finishTimeout() { + let result = Result.failure(SSHFileUploadError.operationTimedOut) + let continuation = lock.withLock { () -> CheckedContinuation? in + pendingResult = result + let continuation = self.continuation + self.continuation = nil + return continuation + } + continuation?.resume(with: result) + } + + private func finish(_ result: Result) -> Bool { + let completion = lock.withLock { () -> (CheckedContinuation?, [Task])? in + guard !finished else { return nil } + finished = true + pendingResult = result + let continuation = self.continuation + self.continuation = nil + let tasks = self.tasks + self.tasks.removeAll() + return (continuation, tasks) + } + guard let completion else { return false } + completion.1.forEach { $0.cancel() } + completion.0?.resume(with: result) + return true + } +} + +enum SSHUploadTimeout { + static func run( + timeout: TimeAmount, + operation: @escaping @Sendable () async throws -> Value, + onTimeout: @escaping @Sendable () async -> Void = {}, + cleanupLateSuccess: @escaping @Sendable (Value) async -> Void = { _ in } + ) async throws -> Value { + let gate = SSHUploadTimeoutGate() + let nanoseconds = UInt64(clamping: timeout.nanoseconds) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + gate.install(continuation) + let operationTask = Task { + do { + let value = try await operation() + if !gate.succeed(value) { await cleanupLateSuccess(value) } + } catch { + _ = gate.fail(error) + } + } + let timeoutTask = Task { + do { + try await Task.sleep(nanoseconds: nanoseconds) + if gate.beginTimeout() { + await onTimeout() + gate.finishTimeout() + } + } catch is CancellationError { + return + } catch { + _ = gate.fail(error) + } + } + gate.setTasks([operationTask, timeoutTask]) + } + } onCancel: { + gate.cancel() + } + } +} + +struct SSHImageUploadPaths: Equatable, Sendable { + let directory: String + let temporary: String + let final: String + let terminal: String +} + +struct SSHImageUploadPathBuilder: Sendable { + static let remoteRoot = ".cache/mori/attachments" + static let terminalRoot = "~/.cache/mori/attachments" + + func paths(workspaceID: UUID, transferID: UUID, filename: String) throws -> SSHImageUploadPaths { + let sanitized = sanitize(filename) + guard !sanitized.isEmpty else { throw SSHFileUploadError.invalidFilename } + let directory = "\(Self.remoteRoot)/\(workspaceID.uuidString.lowercased())/\(transferID.uuidString.lowercased())" + let terminalDirectory = "\(Self.terminalRoot)/\(workspaceID.uuidString.lowercased())/\(transferID.uuidString.lowercased())" + return SSHImageUploadPaths( + directory: directory, + temporary: "\(directory)/.\(sanitized).part", + final: "\(directory)/\(sanitized)", + terminal: "\(terminalDirectory)/\(sanitized)" + ) + } + + func directoryPrefixes(_ path: String) -> [String] { + let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + return components.indices.map { components[...$0].joined(separator: "/") } + } + + private func sanitize(_ value: String) -> String { + let cleaned = value.unicodeScalars.map { scalar -> Character in + scalar.value < 0x20 || scalar == "/" || scalar == "\\" || scalar == "\0" + ? "_" : Character(scalar) + } + let result = String(cleaned) + .trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: "._"))) + return result.isEmpty ? "image" : String(result.prefix(180)) + } +} + +enum SSHImageUploadTransfer { + static func run( + session: any SSHFileUploadSession, + localURL: URL, + paths: SSHImageUploadPaths, + totalBytes: Int64, + progress: @escaping MoriRemoteTerminalImageUploader.ProgressHandler + ) async throws { + let builder = SSHImageUploadPathBuilder() + for directory in builder.directoryPrefixes(paths.directory) { + try await session.ensureDirectoryExists(atPath: directory) + } + try? await session.removeFileIfExists(atPath: paths.temporary) + try await session.uploadFile(from: localURL, to: paths.temporary) { uploaded in + await progress(uploaded, totalBytes) + } + try await session.renameFile(from: paths.temporary, to: paths.final) + } +} + +struct SSHImageUploadService: Sendable { + let sshRoots: SSHRootAccess + + func uploader(for workspaceID: UUID) -> MoriRemoteTerminalImageUploader { + MoriRemoteTerminalImageUploader { localURL, filename, progress in + try await upload( + workspaceID: workspaceID, + localURL: localURL, + filename: filename, + progress: progress + ) + } + } + + private func upload( + workspaceID: UUID, + localURL: URL, + filename: String, + progress: @escaping MoriRemoteTerminalImageUploader.ProgressHandler + ) async throws -> String { + try Task.checkCancellation() + guard FileManager.default.fileExists(atPath: localURL.path) else { + throw SSHFileUploadError.localFileUnavailable + } + let totalBytes = (try FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? NSNumber)?.int64Value ?? 0 + let access = try await sshRoots.workspace(workspaceID) + let lease = try await access.root.lease() + let session: any SSHFileUploadSession + do { + session = try await lease.root.openFileUploadSession() + } catch { + await lease.release(.reusable) + throw error + } + + let paths = try SSHImageUploadPathBuilder().paths( + workspaceID: workspaceID, + transferID: UUID(), + filename: filename + ) + do { + try await SSHImageUploadTransfer.run( + session: session, + localURL: localURL, + paths: paths, + totalBytes: totalBytes, + progress: progress + ) + try await session.close() + await lease.release(.reusable) + await progress(totalBytes, totalBytes) + return paths.terminal + } catch { + try? await session.removeFileIfExists(atPath: paths.temporary) + let closeSucceeded: Bool + do { + try await session.close() + closeSucceeded = true + } catch { + closeSucceeded = false + } + await lease.release(closeSucceeded ? .reusable : .invalidated) + throw error + } + } + +} diff --git a/MoriRemote/MoriRemote/SSH/SSHRootAccess.swift b/MoriRemote/MoriRemote/SSH/SSHRootAccess.swift new file mode 100644 index 00000000..f8b2c1fe --- /dev/null +++ b/MoriRemote/MoriRemote/SSH/SSHRootAccess.swift @@ -0,0 +1,110 @@ +import Foundation + +/// A lazy, authenticated route into one pooled SSH root. Authentication and +/// endpoint identity are fixed when the route is prepared; TOFU still occurs +/// only when the owning discovery, terminal, or upload operation requests a lease. +struct AuthenticatedSSHRootSource: Sendable { + private let pool: SSHRootPool + private let key: SSHRootPool.Key + private let connector: any SSHRootConnecting + + init( + pool: SSHRootPool, + key: SSHRootPool.Key, + connector: any SSHRootConnecting + ) { + self.pool = pool + self.key = key + self.connector = connector + } + + func lease() async throws -> SSHRootLease { + try await pool.lease(for: key, connector: connector) + } +} + +struct SSHWorkspaceAccess: Sendable { + let workspace: SavedWorkspace + let settings: RemoteSettings + let root: AuthenticatedSSHRootSource +} + +/// Resolves durable server material into the single authenticated-root recipe +/// shared by discovery, terminal control, and SFTP uploads. +struct SSHRootAccess: Sendable { + typealias ConnectorFactory = @Sendable ( + SavedServer, + ResolvedSSHAuth, + TrustedHostStore + ) -> any SSHRootConnecting + + private let library: RemoteLibrary + private let pool: SSHRootPool + private let trustedHosts: TrustedHostStore + private let makeConnector: ConnectorFactory + + init( + library: RemoteLibrary, + pool: SSHRootPool, + trustedHosts: TrustedHostStore, + makeConnector: @escaping ConnectorFactory = { server, auth, trustedHosts in + CitadelSSHRootConnector( + server: server, + auth: auth, + trust: SSHHostTrustResolver(store: trustedHosts) + ) + } + ) { + self.library = library + self.pool = pool + self.trustedHosts = trustedHosts + self.makeConnector = makeConnector + } + + func workspace(_ workspaceID: UUID) async throws -> SSHWorkspaceAccess { + let material = try await library.connectionMaterial(for: workspaceID) + let root = try await source( + server: material.1, + identity: material.2, + settings: material.3 + ) + return SSHWorkspaceAccess( + workspace: material.0, + settings: material.3, + root: root + ) + } + + func server(_ serverID: UUID) async throws -> AuthenticatedSSHRootSource { + let material = try await library.discoveryMaterial(for: serverID) + return try await source( + server: material.0, + identity: material.1, + settings: material.2 + ) + } + + private func source( + server: SavedServer, + identity: SSHIdentity, + settings: RemoteSettings + ) async throws -> AuthenticatedSSHRootSource { + let auth = try await library.resolveAuth( + server: server, + identity: identity, + settings: settings + ) + let endpoint = try CanonicalEndpoint(host: server.host, port: server.port) + let key = SSHRootPool.Key( + serverID: server.id, + endpoint: endpoint, + username: server.username, + authenticationFingerprint: auth.rootPoolFingerprint + ) + return AuthenticatedSSHRootSource( + pool: pool, + key: key, + connector: makeConnector(server, auth, trustedHosts) + ) + } +} diff --git a/MoriRemote/MoriRemote/SSH/SSHRootPool.swift b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift index 1560967d..f2f88c4c 100644 --- a/MoriRemote/MoriRemote/SSH/SSHRootPool.swift +++ b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift @@ -8,11 +8,40 @@ protocol SSHChildChannel: AnyObject, Sendable { func close() async throws } +typealias SSHFileUploadProgressHandler = @Sendable (Int64) async -> Void + +protocol SSHFileUploadSession: Sendable { + func ensureDirectoryExists(atPath path: String) async throws + func uploadFile( + from localURL: URL, + to remotePath: String, + progress: @escaping SSHFileUploadProgressHandler + ) async throws + func renameFile(from temporaryPath: String, to finalPath: String) async throws + func removeFileIfExists(atPath path: String) async throws + func close() async throws +} + protocol SSHRootConnection: Sendable { func openSessionChannel() async throws -> any SSHChildChannel + func openFileUploadSession() async throws -> any SSHFileUploadSession func close() async } +extension SSHRootConnection { + func openFileUploadSession() async throws -> any SSHFileUploadSession { + throw SSHFileUploadError.unsupported + } +} + +enum SSHFileUploadError: Error, Equatable, Sendable { + case unsupported + case invalidFilename + case localFileUnavailable + case operationTimedOut + case uploadFailed +} + protocol SSHRootConnecting: Sendable { func connect() async throws -> any SSHRootConnection } @@ -21,6 +50,11 @@ enum SSHRootPoolError: Error, Equatable, Sendable { case staleLease } +enum SSHRootLeaseDisposition: Sendable { + case reusable + case invalidated +} + /// Shares authenticated SSH roots while preserving lease ownership. A generation token /// prevents an old failed connect or idle timer from deleting a newer replacement. actor SSHRootPool { @@ -91,7 +125,7 @@ actor SSHRootPool { } } - fileprivate func release(_ lease: SSHRootLease, disposition: TmuxControlTransportCloseDisposition) async { + fileprivate func release(_ lease: SSHRootLease, disposition: SSHRootLeaseDisposition) async { guard let key = lease.key, let token = lease.token else { await lease.root.close() return @@ -196,7 +230,7 @@ struct SSHRootLease: Sendable { self.token = token } - func release(_ disposition: TmuxControlTransportCloseDisposition) async { + func release(_ disposition: SSHRootLeaseDisposition) async { let shouldRelease = releaseState.claim() guard shouldRelease else { return } await pool.release(self, disposition: disposition) diff --git a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift index 5ea74e02..df918e57 100644 --- a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift +++ b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift @@ -26,6 +26,13 @@ struct AgentMetadata: Equatable, Sendable { static let unknown = Self(state: .unknown, name: nil) } +/// App-local projection of the facade's fixed query result. It intentionally +/// carries no tmux-controller detail across the terminal boundary. +struct AgentMetadataQueryResult: Sendable { + let succeeded: Bool + let body: String +} + /// Parses the one bounded, fixed-format tmux response. Pane options are /// untrusted remote text: state is exact-match only, and labels cannot smuggle /// a row/delimiter into the navigation projection. @@ -34,14 +41,14 @@ struct AgentMetadataResponseParser: Sendable { static let maximumRecords = 512 static let maximumNameLength = 64 - func parse(_ body: String) -> [TmuxPaneID: AgentMetadata] { + func parse(_ body: String) -> [UInt64: AgentMetadata] { guard body.utf8.count <= Self.maximumResponseBytes else { return [:] } let records = body.split(separator: "\n", omittingEmptySubsequences: true) // Never prefix-truncate: an injected valid row can otherwise be hidden // after the cap, and a duplicate must invalidate the whole response. guard records.count <= Self.maximumRecords else { return [:] } - var result: [TmuxPaneID: AgentMetadata] = [:] - var seenPaneIDs = Set() + var result: [UInt64: AgentMetadata] = [:] + var seenPaneIDs = Set() for line in records { let fields = line.split(separator: "\t", omittingEmptySubsequences: false) guard let first = fields.first, let paneID = parsePaneID(first) else { continue } @@ -52,11 +59,9 @@ struct AgentMetadataResponseParser: Sendable { return result } - private func parsePaneID(_ field: Substring) -> TmuxPaneID? { - guard field.first == "%", field.dropFirst().allSatisfy(\.isNumber), - let rawValue = UInt64(field.dropFirst()) - else { return nil } - return .init(rawValue) + private func parsePaneID(_ field: Substring) -> UInt64? { + guard field.first == "%", field.dropFirst().allSatisfy(\.isNumber) else { return nil } + return UInt64(field.dropFirst()) } private func normalizeState(_ field: Substring) -> MoriAgentState { @@ -72,43 +77,32 @@ struct AgentMetadataResponseParser: Sendable { } } -/// Projects response records only onto panes the active Ghostty topology owns. +/// Projects response records only onto panes the active facade topology owns. /// A successful response is authoritative, so missing or cleared options erase /// old metadata; a failed response intentionally yields unknown instead. struct AgentMetadataProjection: Sendable { - static func merge(_ records: [TmuxPaneID: AgentMetadata], into topology: TmuxSessionController.Topology?) -> [TmuxPaneID: AgentMetadata] { - guard let topology else { return [:] } + static func merge(_ records: [UInt64: AgentMetadata], paneIDs: [UInt64]) -> [UInt64: AgentMetadata] { // Corrupt/native snapshots must not crash the UI. First occurrence wins, // matching the topology order used everywhere else in the projection. - var projection: [TmuxPaneID: AgentMetadata] = [:] - for pane in topology.panes where projection[pane.id] == nil { - projection[pane.id] = records[pane.id] ?? .unknown + var projection: [UInt64: AgentMetadata] = [:] + for paneID in paneIDs where projection[paneID] == nil { + projection[paneID] = records[paneID] ?? .unknown } return projection } } -/// The size class selects surrounding chrome, never a terminal identity. Keeping -/// this policy explicit makes rotation/split-view regressions deterministic. -enum RemoteTerminalPresentation: Sendable { - enum Mode: Sendable { case compact, regular } - static func identity(for runtimeInstanceID: UUID, mode: Mode) -> UUID { - _ = mode - return runtimeInstanceID - } -} - /// A visible runtime owns one projector. It has no tmux parser or transport: -/// the supplied query closure is the existing Ghostty-correlated controller -/// boundary. Cancellation and the immutable instance ID reject late replies. +/// the supplied query is the facade's fixed correlated metadata result. +/// Cancellation and the immutable instance ID reject late replies. @MainActor final class AgentMetadataProjector { static let refreshInterval: Duration = .seconds(5) private let instanceID: UUID - private let query: (@escaping @Sendable (TmuxSessionController.CommandResult) -> Void) -> Void + private let query: @MainActor () async -> AgentMetadataQueryResult private let parser = AgentMetadataResponseParser() - private var topology: TmuxSessionController.Topology? + private var paneIDs: [UInt64] = [] private var refreshTask: Task? private var visible = false private var stopped = false @@ -118,19 +112,19 @@ final class AgentMetadataProjector { /// old completion that arrives after presentation changed. private var queryGeneration: UInt64 = 0 - private(set) var metadata: [TmuxPaneID: AgentMetadata] = [:] + private(set) var metadata: [UInt64: AgentMetadata] = [:] private(set) var lastFailure: String? var onChange: (@MainActor () -> Void)? - init(instanceID: UUID, query: @escaping (@escaping @Sendable (TmuxSessionController.CommandResult) -> Void) -> Void) { + init(instanceID: UUID, query: @escaping @MainActor () async -> AgentMetadataQueryResult) { self.instanceID = instanceID self.query = query } - func topologyDidChange(_ topology: TmuxSessionController.Topology) { + func topologyDidChange(paneIDs: [UInt64]) { guard !stopped else { return } - self.topology = topology - metadata = AgentMetadataProjection.merge(metadata, into: topology) + self.paneIDs = paneIDs + metadata = AgentMetadataProjection.merge(metadata, paneIDs: paneIDs) onChange?() refreshImmediately() } @@ -172,25 +166,26 @@ final class AgentMetadataProjector { } private func refreshImmediately() { - guard visible, !stopped, topology != nil, !queryInFlight else { return } + guard visible, !stopped, !paneIDs.isEmpty, !queryInFlight else { return } queryInFlight = true let responseInstanceID = instanceID let responseGeneration = queryGeneration - query { [weak self] result in - Task { @MainActor in self?.receive(result, from: responseInstanceID, generation: responseGeneration) } + Task { [weak self] in + guard let self else { return } + let result = await self.query() + self.receive(result, from: responseInstanceID, generation: responseGeneration) } } - private func receive(_ result: TmuxSessionController.CommandResult, from responseInstanceID: UUID, generation: UInt64) { + private func receive(_ result: AgentMetadataQueryResult, from responseInstanceID: UUID, generation: UInt64) { guard !stopped, responseInstanceID == instanceID, generation == queryGeneration, visible else { return } queryInFlight = false - switch result.status { - case .success: + if result.succeeded { lastFailure = nil - metadata = AgentMetadataProjection.merge(parser.parse(result.body), into: topology) - case .skipped, .error: + metadata = AgentMetadataProjection.merge(parser.parse(result.body), paneIDs: paneIDs) + } else { lastFailure = result.body - metadata = AgentMetadataProjection.merge([:], into: topology) + metadata = AgentMetadataProjection.merge([:], paneIDs: paneIDs) } onChange?() } diff --git a/MoriRemote/MoriRemote/Tmux/DeterministicTmuxControlTransport.swift b/MoriRemote/MoriRemote/Tmux/DeterministicTmuxControlTransport.swift deleted file mode 100644 index a5ec9373..00000000 --- a/MoriRemote/MoriRemote/Tmux/DeterministicTmuxControlTransport.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -/// Scripted transport for tests and the DEBUG renderer probe. Chunks may be -/// delayed or fail; all accepted writes and terminal errors remain observable. -actor DeterministicTmuxControlTransport: TmuxControlTransport { - struct Event: Sendable { let delayNanoseconds: UInt64; let chunk: Data?; let error: Error? - static func chunk(_ string: String, after delayNanoseconds: UInt64 = 0) -> Self { .init(delayNanoseconds: delayNanoseconds, chunk: Data(string.utf8), error: nil) } - static func failure(_ error: Error, after delayNanoseconds: UInt64 = 0) -> Self { .init(delayNanoseconds: delayNanoseconds, chunk: nil, error: error) } - } - nonisolated let receivedBytes: AsyncThrowingStream - private let continuation: AsyncThrowingStream.Continuation - private let events: [Event] - private var started = false - private var writes: [Data] = [] - private var writeError: Error? - private let holdOpen: Bool - - init(transcript: [String], writeError: Error? = nil, holdOpen: Bool = false) { self.init(events: transcript.map { Event.chunk($0) }, writeError: writeError, holdOpen: holdOpen) } - init(events: [Event], writeError: Error? = nil, holdOpen: Bool = false) { - self.events = events; self.writeError = writeError; self.holdOpen = holdOpen - var captured: AsyncThrowingStream.Continuation! - receivedBytes = AsyncThrowingStream { captured = $0 } - continuation = captured - } - func start() async throws { - guard !started else { return }; started = true - for event in events { - if event.delayNanoseconds > 0 { try? await Task.sleep(nanoseconds: event.delayNanoseconds) } - if let chunk = event.chunk { continuation.yield(chunk) } - if let error = event.error { continuation.finish(throwing: error); return } - } - if !holdOpen && !events.contains(where: { $0.error != nil }) { continuation.finish() } - } - func send(_ data: Data) async throws { writes.append(data); if let writeError { throw writeError } } - func setWriteError(_ error: Error?) { writeError = error } - func isActive() async -> Bool { started } - func close(disposition: TmuxControlTransportCloseDisposition) async { _ = disposition; continuation.finish() } - func sentWrites() -> [Data] { writes } -} diff --git a/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift b/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift index dfed9aca..873f95af 100644 --- a/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift +++ b/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift @@ -1,16 +1,15 @@ import Foundation +import MoriRemoteTerminal /// The Phase 2 vertical transport. Startup mutations deliberately happen on separate /// no-PTY exec children: version probe, grouped shadow creation, then the long-lived /// `tmux -C` child. A rejected/old probe therefore cannot create a tmux session. -actor SSHTmuxControlTransport: TmuxControlTransport { +actor SSHTmuxControlTransport { nonisolated let receivedBytes: AsyncThrowingStream private enum Lifecycle: Equatable { case idle, starting, started, closing, closed } - private let connector: any SSHRootConnecting - private let pool: SSHRootPool - private let poolKey: SSHRootPool.Key + private let rootSource: AuthenticatedSSHRootSource private let tmuxExecutable: String private let sourceSession: String private let runtimeID: UUID @@ -26,16 +25,12 @@ actor SSHTmuxControlTransport: TmuxControlTransport { private var lifecycle: Lifecycle = .idle init( - connector: any SSHRootConnecting, - pool: SSHRootPool, - poolKey: SSHRootPool.Key, + rootSource: AuthenticatedSSHRootSource, tmuxExecutable: String = "tmux", sourceSession: String, runtimeID: UUID = UUID() ) { - self.connector = connector - self.pool = pool - self.poolKey = poolKey + self.rootSource = rootSource self.tmuxExecutable = tmuxExecutable self.sourceSession = sourceSession self.runtimeID = runtimeID @@ -44,13 +39,25 @@ actor SSHTmuxControlTransport: TmuxControlTransport { self.continuation = continuation } + nonisolated func asTerminalTransport() -> MoriRemoteTerminalTransport { + MoriRemoteTerminalTransport( + receivedBytes: receivedBytes, + start: { try await self.start() }, + send: { try await self.send($0) }, + close: { disposition in + await self.close(disposition: disposition) + }, + isActive: { await self.isActive() } + ) + } + func start() async throws { guard lifecycle != .closed && lifecycle != .closing else { throw SSHTmuxControlTransportError.closed } guard lifecycle == .idle else { throw SSHTmuxControlTransportError.alreadyStarted } lifecycle = .starting do { - let lease = try await pool.lease(for: poolKey, connector: connector) + let lease = try await rootSource.lease() guard lifecycle == .starting else { // close won before this healthy shared root was installed here; // return its lease to the pool instead of tearing down peers. @@ -143,7 +150,7 @@ actor SSHTmuxControlTransport: TmuxControlTransport { return await control.isActive() } - func close(disposition: TmuxControlTransportCloseDisposition) async { + func close(disposition: MoriRemoteTerminalCloseDisposition) async { guard lifecycle != .closed && lifecycle != .closing else { return } await terminate(disposition: disposition, error: nil) } @@ -186,7 +193,7 @@ actor SSHTmuxControlTransport: TmuxControlTransport { /// A mismatch is intentionally non-destructive. Root loss merely leaves an owned /// disposable shadow behind; it never risks killing the source workspace. - private func cleanupShadowIfPossible(using lease: SSHRootLease) async -> TmuxControlTransportCloseDisposition { + private func cleanupShadowIfPossible(using lease: SSHRootLease) async -> MoriRemoteTerminalCloseDisposition { guard shadowCreated else { return .reusable } do { let plan = try TmuxCommandBuilder.cleanupPlan( @@ -206,7 +213,7 @@ actor SSHTmuxControlTransport: TmuxControlTransport { /// Claim closing synchronously before the first await. Every caller then sees a /// closed transport while this method releases channels, shadow, lease, and stream. - private func terminate(disposition: TmuxControlTransportCloseDisposition, error: Error?) async { + private func terminate(disposition: MoriRemoteTerminalCloseDisposition, error: Error?) async { guard lifecycle != .closed && lifecycle != .closing else { return } lifecycle = .closing let control = self.control @@ -225,7 +232,7 @@ actor SSHTmuxControlTransport: TmuxControlTransport { if let lease { let cleanup = await cleanupShadowIfPossible(using: lease) if cleanup == .invalidated { finalDisposition = .invalidated } - await lease.release(finalDisposition) + await lease.release(finalDisposition == .reusable ? .reusable : .invalidated) } lifecycle = .closed continuation.finish(throwing: error) diff --git a/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift b/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift new file mode 100644 index 00000000..1546dbd2 --- /dev/null +++ b/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Lists source sessions through the same authenticated root pool as terminal +/// runtimes. Discovery never creates, attaches, resizes, or switches a tmux client. +struct SSHTmuxSessionDiscovery: Sendable { + let rootSource: AuthenticatedSSHRootSource + var tmuxExecutable = "tmux" + + func load() async throws -> [String] { + let lease = try await rootSource.lease() + do { + let version = try await run( + command: TmuxCommandBuilder.preflight(executable: tmuxExecutable), + root: lease.root + ) + try TmuxCommandBuilder.requireSupportedVersion(version) + let output = try await run( + command: TmuxCommandBuilder.listSessions(executable: tmuxExecutable), + root: lease.root + ) + await lease.release(.reusable) + return TmuxSessionList.parse(output).names + } catch { + await lease.release(.invalidated) + throw error + } + } + + private func run(command: String, root: any SSHRootConnection) async throws -> String { + let child = try await root.openSessionChannel() + do { + try await child.execute(command) + var output = Data() + for try await bytes in child.receivedBytes { output.append(bytes) } + try? await child.close() + return String(decoding: output, as: UTF8.self) + } catch { + try? await child.close() + throw error + } + } +} diff --git a/MoriRemote/MoriRemote/Tmux/TmuxControl.swift b/MoriRemote/MoriRemote/Tmux/TmuxControl.swift deleted file mode 100644 index a54b328e..00000000 --- a/MoriRemote/MoriRemote/Tmux/TmuxControl.swift +++ /dev/null @@ -1,206 +0,0 @@ -import Foundation - -struct TmuxVersion: Comparable, Equatable, Sendable { - let major: Int - let minor: Int - - static func < (lhs: Self, rhs: Self) -> Bool { (lhs.major, lhs.minor) < (rhs.major, rhs.minor) } - - static func parse(_ output: String) -> TmuxVersion? { - let fields = output.trimmingCharacters(in: .whitespacesAndNewlines).split(whereSeparator: \.isWhitespace) - guard fields.count == 2, fields[0] == "tmux" else { return nil } - let pieces = fields[1].split(separator: ".", maxSplits: 1) - guard pieces.count == 2, let major = Int(pieces[0]) else { return nil } - let digits = pieces[1].prefix { $0.isNumber } - guard !digits.isEmpty, let minor = Int(digits) else { return nil } - return .init(major: major, minor: minor) - } -} - -enum TmuxCommandError: Error, Equatable, Sendable, LocalizedError { - case invalidExecutable - case unsafeArgument - case malformedVersion - case unsupportedVersion - case ownershipMismatch - case groupMismatch - - var errorDescription: String? { - switch self { - case .invalidExecutable: - String(localized: "The tmux executable must be an absolute path or tmux.") - case .unsafeArgument: - String(localized: "The tmux command contains an unsupported control character.") - case .malformedVersion: - String(localized: "The tmux version response is invalid.") - case .unsupportedVersion: - String(localized: "tmux 3.2 or later is required.") - case .ownershipMismatch: - String(localized: "The temporary tmux session could not be verified safely.") - case .groupMismatch: - String(localized: "The temporary tmux session is not grouped with the requested workspace.") - } - } -} - -/// Builds a bare non-login POSIX shell command. Every dynamic token is single-quoted; -/// the only shell expansion is the fixed PATH setup and `command -v` resolution. -enum TmuxCommandBuilder { - static func validateExecutable(_ path: String) throws { - guard path == "tmux" || path.hasPrefix("/") else { throw TmuxCommandError.invalidExecutable } - try validate(path) - } - - static func command(executable: String, arguments: [String]) throws -> String { - try validateExecutable(executable) - try arguments.forEach(validate) - return TmuxShellCommand.command(executable: executable, arguments: arguments) - } - - static func preflight(executable: String) throws -> String { try command(executable: executable, arguments: ["-V"]) } - - static func requireSupportedVersion(_ output: String) throws { - guard let version = TmuxVersion.parse(output) else { throw TmuxCommandError.malformedVersion } - guard version >= .init(major: 3, minor: 2) else { throw TmuxCommandError.unsupportedVersion } - } - - static func shadowName(source: String, runtimeID: UUID) throws -> String { - try validate(source) - return "\(source)--mori-remote-\(runtimeID.uuidString.lowercased())" - } - - static func createShadow(executable: String, source: String, runtimeID: UUID) throws -> String { - let shadow = try shadowName(source: source, runtimeID: runtimeID) - return try command(executable: executable, arguments: ["new-session", "-d", "-t", source, "-s", shadow]) - } - - /// `-f` applies flags to the newly attached control client, before it can receive navigation. - static func attachShadow(executable: String, shadow: String) throws -> String { - try command(executable: executable, arguments: ["-C", "attach-session", "-t", shadow, "-f", "active-pane,ignore-size"]) - } - - struct ShadowCleanupPlan: Equatable, Sendable { - let source: String - let shadow: String - let runtimeID: UUID - let verifyCommand: String - let killCommand: String - } - - static func cleanupPlan(executable: String, source: String, shadow: String, runtimeID: UUID) throws -> ShadowCleanupPlan { - let expected = try shadowName(source: source, runtimeID: runtimeID) - guard shadow == expected else { throw TmuxCommandError.ownershipMismatch } - // Caller must parse this exact pair before issuing kill; source is never inferred from user input. - let verify = try command(executable: executable, arguments: ["display-message", "-p", "-t", shadow, "#{session_name}\t#{session_group}"]) - let kill = try command(executable: executable, arguments: ["kill-session", "-t", shadow]) - return .init(source: source, shadow: shadow, runtimeID: runtimeID, verifyCommand: verify, killCommand: kill) - } - - static func verifyCleanup(_ output: String, plan: ShadowCleanupPlan) throws { - let fields = output.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "\t", omittingEmptySubsequences: false) - guard fields.count == 2, fields[0] == plan.shadow else { throw TmuxCommandError.ownershipMismatch } - // tmux reports the group leader name. A source session is its own leader; a grouped shadow reports source. - guard fields[1] == plan.source else { throw TmuxCommandError.groupMismatch } - } - - private static func validate(_ value: String) throws { - guard !value.contains(where: { $0 == "\n" || $0 == "\r" || $0 == "\0" }) else { throw TmuxCommandError.unsafeArgument } - } -} - -protocol TmuxControlTransport: Sendable { - var receivedBytes: AsyncThrowingStream { get } - func start() async throws - func send(_ data: Data) async throws - func isActive() async -> Bool - func close(disposition: TmuxControlTransportCloseDisposition) async -} - -enum TmuxControlTransportCloseDisposition: Equatable, Sendable { case reusable, invalidated } - -/// The continuation is made once at init. `enqueue` is synchronous and -/// thread-safe, so serial controller drains retain their exact admission order. -actor TmuxSessionLink { - private let transport: any TmuxControlTransport - private let receive: @Sendable (Data) -> Void - private let disconnected: @Sendable () -> Void - private let outbound: AsyncStream - nonisolated private let outboundContinuation: AsyncStream.Continuation - private var writer: Task? - private var reader: Task? - private var closed = false - - init( - transport: any TmuxControlTransport, - receive: @escaping @Sendable (Data) -> Void, - disconnected: @escaping @Sendable () -> Void - ) { - self.transport = transport - self.receive = receive - self.disconnected = disconnected - - var continuation: AsyncStream.Continuation! - outbound = AsyncStream { continuation = $0 } - outboundContinuation = continuation - } - - nonisolated func enqueue(_ bytes: Data) { - outboundContinuation.yield(bytes) - } - - /// Compatibility for async transport callers; writer-queue users call `enqueue`. - func send(_ bytes: Data) { - enqueue(bytes) - } - - func start(beforeReceive: @escaping @Sendable () async throws -> Void = {}) async throws { - guard writer == nil else { return } - writer = Task { [transport, outbound] in - for await bytes in outbound { - do { - try await transport.send(bytes) - } catch { - await self.fail() - return - } - } - } - try await transport.start() - do { - try await beforeReceive() - } catch { - await fail() - throw error - } - reader = Task { [transport] in - do { - for try await bytes in transport.receivedBytes { - self.receive(bytes) - } - } catch {} - if !Task.isCancelled { await self.fail() } - } - } - - func isActive() async -> Bool { - guard !closed else { return false } - return await transport.isActive() - } - - func stop() async { - guard !closed else { return } - closed = true - outboundContinuation.finish() - writer?.cancel() - reader?.cancel() - await transport.close(disposition: .reusable) - } - - private func fail() async { - guard !closed else { return } - closed = true - outboundContinuation.finish() - await transport.close(disposition: .invalidated) - disconnected() - } -} diff --git a/MoriRemote/MoriRemote/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemote/Tmux/TmuxSessionController.swift deleted file mode 100644 index 7386eee9..00000000 --- a/MoriRemote/MoriRemote/Tmux/TmuxSessionController.swift +++ /dev/null @@ -1,319 +0,0 @@ -import Foundation -import GhosttyKit - -struct TmuxWindowID: Hashable, Comparable, Sendable { let rawValue: UInt64; init(_ rawValue: UInt64) { self.rawValue = rawValue }; static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } } -struct TmuxPaneID: Hashable, Comparable, Sendable { let rawValue: UInt64; init(_ rawValue: UInt64) { self.rawValue = rawValue }; static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } } - -/// Queue-independent admission contract. Native surface pointers remain private to -/// `TmuxSessionController`'s writer queue; tests exercise this value model rather -/// than manufacture a fake native surface. -struct TmuxSurfaceRegistrationLedger: Sendable { - enum Result: Equatable, Sendable { case registered, unavailable, unknownPane, duplicate, removed, ignored } - private var registered: [TmuxPaneID: UInt] = [:] - mutating func register(paneID: TmuxPaneID, identity: UInt, clientAvailable: Bool, retained: Set) -> Result { - guard clientAvailable else { return .unavailable } - guard retained.contains(paneID) else { return .unknownPane } - guard registered[paneID] == nil else { return .duplicate } - registered[paneID] = identity - return .registered - } - mutating func unregister(paneID: TmuxPaneID, identity: UInt) -> Result { - guard registered[paneID] == identity else { return .ignored } - registered.removeValue(forKey: paneID) - return .removed - } - var isEmpty: Bool { registered.isEmpty } -} - -/// Client-local navigation only. Keeping this pure makes the command safety -/// contract testable without inventing a native tmux client. -enum TmuxClientCommandPolicy { - enum SharedMutation: Sendable { case splitHorizontal, splitVertical, newWindow, closePane } - - static func selectWindow(_ id: TmuxWindowID) -> String { "select-window -t @\(id.rawValue)" } - static func selectPane(_ id: TmuxPaneID) -> String { "select-pane -t %\(id.rawValue)" } - /// This static conditional runs only immediately before terminal input. It - /// never enters copy mode for browsing; it just releases a stale shared mode - /// so the arriving keystroke remains typeable. - static let cancelStaleInputMode = "if-shell -F '#{pane_in_mode}' 'send-keys -X cancel' ''" - /// Fixed command only: agent metadata must stay in Ghostty's correlated - /// control stream, never a polling SSH channel or a second parser. - static let agentMetadataQuery = "list-panes -a -F '#{pane_id}\t#{@mori-agent-state}\t#{@mori-agent-name}'" - static func shared(_ mutation: SharedMutation) -> String { - switch mutation { - case .splitHorizontal: "split-window -h" - case .splitVertical: "split-window -v" - case .newWindow: "new-window" - case .closePane: "kill-pane" - } - } - static func isAllowed(_ command: String) -> Bool { - command.hasPrefix("select-window -t @") || command.hasPrefix("select-pane -t %") || - command == cancelStaleInputMode || command == agentMetadataQuery || - ["split-window -h", "split-window -v", "new-window", "kill-pane"].contains(command) - } -} - -/// The sole tmux control parser and command admission point. Every client call, -/// parser pump, outbound consume, and surface notification is serialized on -/// `queue`; terminal handles are retained objects with explicit ownership. -final class TmuxSessionController: @unchecked Sendable { - static let initialHistoryLineLimit = 2_000 - /// Local scrollback is byte-addressed by Ghostty: 10,000 lines × a conservative - /// 256 bytes/line. Revisit when real transcript telemetry exceeds this budget. - static let maximumScrollbackBytes = 2_560_000 - - struct Window: Equatable, Sendable { let id: TmuxWindowID; let name: String; let active: Bool; let activePaneID: TmuxPaneID } - struct Pane: Equatable, Sendable { enum Phase: Equatable, Sendable { case hydrating, live }; let id: TmuxPaneID; let windowID: TmuxWindowID; let width: UInt32; let height: UInt32; let phase: Phase } - struct Topology: Equatable, Sendable { - let revision: UInt64; let sessionName: String; let windows: [Window]; let panes: [Pane]; let activeWindowID: TmuxWindowID? - var activePaneID: TmuxPaneID? { guard let activeWindowID else { return nil }; return windows.first(where: { $0.id == activeWindowID })?.activePaneID } - } - enum State: Equatable, Sendable { case detached, attaching, ready, closed } - enum Request: Equatable, Sendable { case selectWindow, selectPane, input } - enum CommandStatus: Equatable, Sendable { case success, skipped, error } - struct CommandResult: Equatable, Sendable { let status: CommandStatus; let body: String; let causeToken: UInt64 } - enum StartError: Swift.Error { case invalidGrid, native(ghostty_tmux_result_e), closed } - enum SurfaceError: Swift.Error { case unavailable, unknownPane, duplicate } - - /// Ownership is transferred exactly once from the native client to this - /// object. It may outlive a removed pane and the client, then releases once. - final class RetainedTerminal: @unchecked Sendable { - let paneID: TmuxPaneID - private let native: ghostty_terminal_t - var handle: ghostty_terminal_t { native } - init(paneID: TmuxPaneID, handle: ghostty_terminal_t) { self.paneID = paneID; native = handle } - deinit { ghostty_terminal_release(native) } - } - struct Callbacks: Sendable { - var state: @Sendable (State) -> Void = { _ in } - var topology: @Sendable (Topology) -> Void = { _ in } - var terminal: @Sendable (RetainedTerminal) -> Void = { _ in } - var paneRemoved: @Sendable (TmuxPaneID) -> Void = { _ in } - var inputFailed: @Sendable (String) -> Void = { _ in } - var completion: @Sendable (Request, CommandResult) -> Void = { _, _ in } - } - - let queue: DispatchQueue - private let callbacks: Callbacks - private var client: ghostty_tmux_client_t? - private var sink: (@Sendable (Data) -> Void)? - private var currentTopology: Topology? - private var topologyRevision: UInt64 = 0 - private var retainedPaneIDs = Set() - /// Opaque identities only. This queue is their only dereferencer. - private struct NativeSurface: @unchecked Sendable, Equatable { let handle: ghostty_terminal_surface_t; var identity: UInt { UInt(bitPattern: handle) } } - private var surfaces: [TmuxPaneID: NativeSurface] = [:] - private var surfaceLedger = TmuxSurfaceRegistrationLedger() - private var completions: [UInt64: Request] = [:] - private var trackedInputCompletions: [UInt64: @Sendable (CommandResult) -> Void] = [:] - private var queryCompletions: [UInt64: @Sendable (CommandResult) -> Void] = [:] - private var shuttingDown = false - - init(callbacks: Callbacks, queue: DispatchQueue = .init(label: "mori.remote.tmux.writer")) { self.callbacks = callbacks; self.queue = queue } - deinit { assert(client == nil, "shutdown must free tmux client") } - - func setOutboundSink(_ sink: (@Sendable (Data) -> Void)?) { queue.async { [self] in preconditionWriter(); self.sink = sink } } - func start(columns: UInt16, rows: UInt16, historyLineLimit: Int = TmuxSessionController.initialHistoryLineLimit, completion: @escaping @Sendable (Result) -> Void) { - queue.async { [self] in - preconditionWriter() - guard !shuttingDown, client == nil else { completion(.failure(.closed)); return } - guard columns > 0, rows > 0 else { completion(.failure(.invalidGrid)); return } - var config = ghostty_tmux_client_config_new() - config.userdata = Unmanaged.passUnretained(self).toOpaque() - config.action_cb = Self.actionCallback - config.history_line_limit_is_set = true - config.history_line_limit = min(max(historyLineLimit, Self.initialHistoryLineLimit), RemoteSettings.maximumScrollbackLines) - config.max_scrollback = Self.maximumScrollbackBytes - config.initial_columns = columns; config.initial_rows = rows - var created: ghostty_tmux_client_t? - let result = ghostty_tmux_client_new(&config, &created) - guard result == GHOSTTY_TMUX_RESULT_OK, let created else { completion(.failure(.native(result))); return } - client = created - publish(.attaching) - drainOutbound() - completion(.success(())) - } - } - - func transportClosed() { queue.async { [self] in preconditionWriter(); guard !shuttingDown else { return }; failPending(); publish(.detached) } } - func pump(_ bytes: Data) { queue.async { [self, bytes] in - preconditionWriter(); guard let client, !shuttingDown else { return } - let result = bytes.withUnsafeBytes { ghostty_tmux_client_feed(client, $0.bindMemory(to: UInt8.self).baseAddress, $0.count) } - guard result == GHOSTTY_TMUX_RESULT_OK else { failPending(); publish(.detached); return } - drainOutbound() - } } - - /// Shutdown is valid only after each surface's unregister completion. That - /// fence ensures no queued terminal_changed call can reach freed memory. - func shutdown(completion: @escaping @Sendable () -> Void = {}) { queue.async { [self] in - preconditionWriter(); guard !shuttingDown else { DispatchQueue.main.async(execute: completion); return } - shuttingDown = true; sink = nil; failPending() - assert(surfaces.isEmpty && surfaceLedger.isEmpty, "unregister terminal surfaces before controller shutdown") - currentTopology = nil; retainedPaneIDs.removeAll() - if let client { let result = ghostty_tmux_client_free(client); assert(result == GHOSTTY_TMUX_RESULT_OK, "tmux client free failed") } - client = nil; publish(.closed) - DispatchQueue.main.async(execute: completion) - } } - - func registerSurface(paneID: TmuxPaneID, surface: ghostty_terminal_surface_t, completion: @escaping @MainActor @Sendable (Result) -> Void) { - let native = NativeSurface(handle: surface) - queue.async { [self, native] in - preconditionWriter() - let admitted = surfaceLedger.register(paneID: paneID, identity: native.identity, clientAvailable: client != nil && !shuttingDown, retained: retainedPaneIDs) - let result: Result - switch admitted { case .registered: surfaces[paneID] = native; result = .success(()); case .unavailable: result = .failure(.unavailable); case .unknownPane: result = .failure(.unknownPane); default: result = .failure(.duplicate) } - DispatchQueue.main.async { completion(result) } - } - } - func unregisterSurface(paneID: TmuxPaneID, surface: ghostty_terminal_surface_t, completion: @escaping @MainActor @Sendable () -> Void) { - let native = NativeSurface(handle: surface) - queue.async { [self, native] in - preconditionWriter(); _ = surfaceLedger.unregister(paneID: paneID, identity: native.identity) - if surfaces[paneID] == native { surfaces.removeValue(forKey: paneID) } - DispatchQueue.main.async { completion() } - } - } - - /// These are client-local navigation commands only; no refresh-client, - /// resize-pane, zoom, or server copy-mode command is admitted here. - func selectWindow(_ id: TmuxWindowID) { enqueue(TmuxClientCommandPolicy.selectWindow(id), request: .selectWindow) } - func selectPane(_ id: TmuxPaneID) { enqueue(TmuxClientCommandPolicy.selectPane(id), request: .selectPane) } - /// Selection is non-mutating. This is called only from an actual input path, - /// before Ghostty emits the pane bytes, and the writer queue preserves order. - func prepareForInput() { enqueue(TmuxClientCommandPolicy.cancelStaleInputMode, request: .input) } - /// The only query result API. The fixed command is correlated by Ghostty's - /// command token, so callers cannot observe or parse raw control bytes. - func queryAgentMetadata(completion: @escaping @Sendable (CommandResult) -> Void) { - enqueueQuery(TmuxClientCommandPolicy.agentMetadataQuery, completion: completion) - } - func mutateSharedWorkspace(_ mutation: TmuxClientCommandPolicy.SharedMutation) { - enqueue(TmuxClientCommandPolicy.shared(mutation), request: .input) - } - func sendInput(_ data: Data, to pane: TmuxPaneID, tracked: Bool = false, completion: @escaping @Sendable (CommandResult) -> Void = { _ in }) { - queue.async { [self, data] in - preconditionWriter() - guard let client, !shuttingDown else { completion(.init(status: .error, body: "session unavailable", causeToken: 0)); return } - if data.isEmpty { completion(.init(status: .success, body: "", causeToken: 0)); return } - var token: UInt64 = 0 - let result = data.withUnsafeBytes { buffer in tracked ? ghostty_tmux_client_send_pane_input_tracked(client, pane.rawValue, buffer.bindMemory(to: UInt8.self).baseAddress, buffer.count, &token) : ghostty_tmux_client_send_pane_input(client, pane.rawValue, buffer.bindMemory(to: UInt8.self).baseAddress, buffer.count) } - guard result == GHOSTTY_TMUX_RESULT_OK else { completion(.init(status: .error, body: "\(result)", causeToken: 0)); return } - if tracked { trackedInputCompletions[token] = completion } else { completion(.init(status: .success, body: "", causeToken: 0)) } - drainOutbound() - } - } - - private func enqueue(_ command: String, request: Request) { queue.async { [self] in - preconditionWriter(); guard let client, !shuttingDown else { callbacks.completion(request, .init(status: .error, body: "session unavailable", causeToken: 0)); return } - var token: UInt64 = 0 - let result = command.utf8.withContiguousStorageIfAvailable { ghostty_tmux_client_enqueue_command(client, .init(ptr: $0.baseAddress, len: $0.count), &token) } ?? Array(command.utf8).withUnsafeBufferPointer { ghostty_tmux_client_enqueue_command(client, .init(ptr: $0.baseAddress, len: $0.count), &token) } - guard result == GHOSTTY_TMUX_RESULT_OK else { callbacks.completion(request, .init(status: .error, body: "\(result)", causeToken: 0)); return } - completions[token] = request; drainOutbound() - } } - - private func enqueueQuery(_ command: String, completion: @escaping @Sendable (CommandResult) -> Void) { - queue.async { [self] in - preconditionWriter() - guard let client, !shuttingDown else { - completion(.init(status: .error, body: "session unavailable", causeToken: 0)) - return - } - var token: UInt64 = 0 - let result = command.utf8.withContiguousStorageIfAvailable { - ghostty_tmux_client_enqueue_command(client, .init(ptr: $0.baseAddress, len: $0.count), &token) - } ?? Array(command.utf8).withUnsafeBufferPointer { - ghostty_tmux_client_enqueue_command(client, .init(ptr: $0.baseAddress, len: $0.count), &token) - } - guard result == GHOSTTY_TMUX_RESULT_OK else { - completion(.init(status: .error, body: "\(result)", causeToken: 0)) - return - } - queryCompletions[token] = completion - drainOutbound() - } - } - - private func drainOutbound() { - preconditionWriter(); guard let client else { return } - var bytes = ghostty_tmux_bytes_s() - guard ghostty_tmux_client_outbound(client, &bytes) == GHOSTTY_TMUX_RESULT_OK else { failPending(); publish(.detached); return } - guard bytes.len > 0, let pointer = bytes.ptr else { return } - let owned = Data(bytes: pointer, count: bytes.len) - guard ghostty_tmux_client_consume(client, bytes.len) == GHOSTTY_TMUX_RESULT_OK else { failPending(); publish(.detached); return } - sink?(owned) - } - - private static let actionCallback: ghostty_tmux_action_cb = { userdata, action in - guard let userdata, let action else { return } - Unmanaged.fromOpaque(userdata).takeUnretainedValue().handle(action.pointee) - } - private func handle(_ action: ghostty_tmux_action_s) { - preconditionWriter() - switch action.tag { - case GHOSTTY_TMUX_ACTION_TOPOLOGY: handleTopology(action.value.topology) - case GHOSTTY_TMUX_ACTION_PANE_CHANGED: handlePaneChanged(TmuxPaneID(action.value.pane_id)) - case GHOSTTY_TMUX_ACTION_COMMAND_COMPLETE: handleCommand(action.value.command) - case GHOSTTY_TMUX_ACTION_INPUT_FAILED: callbacks.inputFailed(decode(action.value.input_failure)) - case GHOSTTY_TMUX_ACTION_EXIT: failPending(); publish(.detached) - default: break // Forward-compatible ABI tags must not tear down a healthy attachment. - } - } - private func handleTopology(_ action: ghostty_tmux_topology_action_s) { - preconditionWriter(); var accumulator = TopologyAccumulator() - let result = withUnsafeMutablePointer(to: &accumulator) { ghostty_tmux_topology_visit(action.view, UnsafeMutableRawPointer($0), { raw, record in guard let raw, let record else { return }; raw.assumingMemoryBound(to: TopologyAccumulator.self).pointee.append(record.pointee) }) } - guard result == GHOSTTY_TMUX_RESULT_OK else { failPending(); publish(.detached); return } - topologyRevision &+= 1 - let snapshot = Topology(revision: topologyRevision, sessionName: decode(action.session_name), windows: accumulator.windows, panes: accumulator.panes, activeWindowID: accumulator.windows.first(where: \.active)?.id) - let removed = Set(currentTopology?.panes.map(\.id) ?? []).subtracting(snapshot.panes.map(\.id)) - currentTopology = snapshot - // Retained references may outlive panes; dropping our ID ownership lets - // the presentation owner release them after its unregister fence. - removed.forEach { retainedPaneIDs.remove($0) } - let callbacks = callbacks - DispatchQueue.main.async { removed.sorted().forEach(callbacks.paneRemoved) } - // A live pane can be materialized immediately. Hydrating panes wait for - // their authoritative PANE_CHANGED completion. - for pane in snapshot.panes where pane.phase == .live { retainTerminal(pane.id) } - publish(.ready) - DispatchQueue.main.async { callbacks.topology(snapshot) } - } - private func handlePaneChanged(_ paneID: TmuxPaneID) { - preconditionWriter(); retainTerminal(paneID) - // Registration precedes notification; a new surface receives an initial - // explicit terminalChanged in its MainActor owner after registration. - if let surface = surfaces[paneID] { _ = ghostty_terminal_surface_terminal_changed(surface.handle) } - } - private func retainTerminal(_ paneID: TmuxPaneID) { - preconditionWriter(); guard retainedPaneIDs.insert(paneID).inserted, let client else { return } - var terminal: ghostty_terminal_t? - guard ghostty_tmux_client_retain_pane_terminal(client, paneID.rawValue, &terminal) == GHOSTTY_TMUX_RESULT_OK, let terminal else { retainedPaneIDs.remove(paneID); return } - let handoff = RetainedTerminal(paneID: paneID, handle: terminal); let callbacks = callbacks - DispatchQueue.main.async { callbacks.terminal(handoff) } - } - private func handleCommand(_ command: ghostty_tmux_command_completion_s) { - preconditionWriter() - let status: CommandStatus = command.status == GHOSTTY_TMUX_COMMAND_SUCCESS ? .success : command.status == GHOSTTY_TMUX_COMMAND_SKIPPED ? .skipped : .error - let result = CommandResult(status: status, body: decode(command.body), causeToken: command.cause_token) - if let callback = trackedInputCompletions.removeValue(forKey: command.token) { callback(result) } - if let callback = queryCompletions.removeValue(forKey: command.token) { callback(result) } - if let request = completions.removeValue(forKey: command.token) { callbacks.completion(request, result) } - } - private func failPending() { - let result = CommandResult(status: .error, body: "transport closed", causeToken: 0) - let commands = completions.values; completions.removeAll(); commands.forEach { callbacks.completion($0, result) } - let inputs = trackedInputCompletions.values; trackedInputCompletions.removeAll(); inputs.forEach { $0(result) } - let queries = queryCompletions.values; queryCompletions.removeAll(); queries.forEach { $0(result) } - } - private func publish(_ state: State) { let callbacks = callbacks; DispatchQueue.main.async { callbacks.state(state) } } - private func preconditionWriter() { dispatchPrecondition(condition: .onQueue(queue)) } -} - -private func decode(_ bytes: ghostty_tmux_bytes_s) -> String { guard let pointer = bytes.ptr, bytes.len > 0 else { return "" }; return String(decoding: UnsafeBufferPointer(start: pointer, count: bytes.len), as: UTF8.self) } -private struct TopologyAccumulator { - var windows: [TmuxSessionController.Window] = []; var panes: [TmuxSessionController.Pane] = [] - mutating func append(_ record: ghostty_tmux_topology_record_s) { switch record.tag { - case GHOSTTY_TMUX_TOPOLOGY_WINDOW: let value = record.value.window; windows.append(.init(id: .init(value.id), name: decode(value.name), active: value.active, activePaneID: .init(value.active_pane_id))) - case GHOSTTY_TMUX_TOPOLOGY_PANE: let value = record.value.pane; panes.append(.init(id: .init(value.id), windowID: .init(value.window_id), width: UInt32(clamping: value.width), height: UInt32(clamping: value.height), phase: value.phase == GHOSTTY_TMUX_PANE_LIVE ? .live : .hydrating)) - default: break - } } -} diff --git a/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift b/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift index 68566557..4c3f3ebb 100644 --- a/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift +++ b/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift @@ -1,7 +1,7 @@ import Foundation -/// Foundation-only POSIX command assembly. It is kept free of app types so the host -/// contract test can compile and execute the exact production implementation. +/// Foundation-only POSIX command assembly. It is kept free of terminal-native +/// types so the SSH transport owns only its grouped-shadow lifecycle. enum TmuxShellCommand { static let fallbackPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" @@ -17,3 +17,130 @@ enum TmuxShellCommand { "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } } + +struct TmuxVersion: Comparable, Equatable, Sendable { + let major: Int + let minor: Int + + static func < (lhs: Self, rhs: Self) -> Bool { (lhs.major, lhs.minor) < (rhs.major, rhs.minor) } + + static func parse(_ output: String) -> TmuxVersion? { + let fields = output.trimmingCharacters(in: .whitespacesAndNewlines).split(whereSeparator: \.isWhitespace) + guard fields.count == 2, fields[0] == "tmux" else { return nil } + let pieces = fields[1].split(separator: ".", maxSplits: 1) + guard pieces.count == 2, let major = Int(pieces[0]) else { return nil } + let digits = pieces[1].prefix { $0.isNumber } + guard !digits.isEmpty, let minor = Int(digits) else { return nil } + return .init(major: major, minor: minor) + } +} + +enum TmuxCommandError: Error, Equatable, Sendable, LocalizedError { + case invalidExecutable + case unsafeArgument + case malformedVersion + case unsupportedVersion + case ownershipMismatch + case groupMismatch + + var errorDescription: String? { + switch self { + case .invalidExecutable: + String(localized: "The tmux executable must be an absolute path or tmux.") + case .unsafeArgument: + String(localized: "The tmux command contains an unsupported control character.") + case .malformedVersion: + String(localized: "The tmux version response is invalid.") + case .unsupportedVersion: + String(localized: "tmux 3.2 or later is required.") + case .ownershipMismatch: + String(localized: "The temporary tmux session could not be verified safely.") + case .groupMismatch: + String(localized: "The temporary tmux session is not grouped with the requested workspace.") + } + } +} + +/// Builds a bare non-login POSIX shell command. Every dynamic token is +/// single-quoted; the only shell expansion is the fixed PATH setup and command +/// resolution. Grouped-shadow cleanup is verified before it can kill anything. +enum TmuxCommandBuilder { + static func validateExecutable(_ path: String) throws { + guard path == "tmux" || path.hasPrefix("/") else { throw TmuxCommandError.invalidExecutable } + try validate(path) + } + + static func command(executable: String, arguments: [String]) throws -> String { + try validateExecutable(executable) + try arguments.forEach(validate) + return TmuxShellCommand.command(executable: executable, arguments: arguments) + } + + static func preflight(executable: String) throws -> String { try command(executable: executable, arguments: ["-V"]) } + + static func listSessions(executable: String) throws -> String { + try command(executable: executable, arguments: ["list-sessions", "-F", "#{session_name}"]) + } + + static func requireSupportedVersion(_ output: String) throws { + guard let version = TmuxVersion.parse(output) else { throw TmuxCommandError.malformedVersion } + guard version >= .init(major: 3, minor: 2) else { throw TmuxCommandError.unsupportedVersion } + } + + static func shadowName(source: String, runtimeID: UUID) throws -> String { + try validate(source) + return "\(source)--mori-remote-\(runtimeID.uuidString.lowercased())" + } + + static func createShadow(executable: String, source: String, runtimeID: UUID) throws -> String { + let shadow = try shadowName(source: source, runtimeID: runtimeID) + return try command(executable: executable, arguments: ["new-session", "-d", "-t", source, "-s", shadow]) + } + + /// `-f` applies flags to the newly attached control client, before it can receive navigation. + static func attachShadow(executable: String, shadow: String) throws -> String { + try command(executable: executable, arguments: ["-C", "attach-session", "-t", shadow, "-f", "active-pane,ignore-size"]) + } + + struct ShadowCleanupPlan: Equatable, Sendable { + let source: String + let shadow: String + let runtimeID: UUID + let verifyCommand: String + let killCommand: String + } + + static func cleanupPlan(executable: String, source: String, shadow: String, runtimeID: UUID) throws -> ShadowCleanupPlan { + let expected = try shadowName(source: source, runtimeID: runtimeID) + guard shadow == expected else { throw TmuxCommandError.ownershipMismatch } + let verify = try command(executable: executable, arguments: ["display-message", "-p", "-t", shadow, "#{session_name}\t#{session_group}"]) + let kill = try command(executable: executable, arguments: ["kill-session", "-t", shadow]) + return .init(source: source, shadow: shadow, runtimeID: runtimeID, verifyCommand: verify, killCommand: kill) + } + + static func verifyCleanup(_ output: String, plan: ShadowCleanupPlan) throws { + let fields = output.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "\t", omittingEmptySubsequences: false) + guard fields.count == 2, fields[0] == plan.shadow else { throw TmuxCommandError.ownershipMismatch } + guard fields[1] == plan.source else { throw TmuxCommandError.groupMismatch } + } + + private static func validate(_ value: String) throws { + guard !value.contains(where: { $0 == "\n" || $0 == "\r" || $0 == "\0" }) else { throw TmuxCommandError.unsafeArgument } + } +} + +struct TmuxSessionList: Equatable, Sendable { + let names: [String] + + static func parse(_ output: String) -> TmuxSessionList { + let names = Set(output.split(whereSeparator: \.isNewline).map(String.init)) + .filter { !$0.isEmpty && !isMoriRemoteShadow($0) } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + return TmuxSessionList(names: names) + } + + private static func isMoriRemoteShadow(_ name: String) -> Bool { + guard let marker = name.range(of: "--mori-remote-", options: .backwards) else { return false } + return UUID(uuidString: String(name[marker.upperBound...])) != nil + } +} diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 0376864f..30784326 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import MoriRemoteTerminal @MainActor struct RemoteRootView: View { @@ -45,11 +46,6 @@ struct RemoteRootView: View { } case .settings: RemoteSettingsView(settings: root.settings, onSave: root.save(settings:)) - case let .workspace(serverID, workspaceID): - WorkspaceEditorView( - draft: .init(serverID: serverID, workspace: workspaceID.flatMap { id in root.workspaces.first { $0.id == id } }), - onSave: root.save - ) case .library: NavigationStack { library } } @@ -59,8 +55,6 @@ struct RemoteRootView: View { switch action { case let .server(server, _): Button(String(localized: "Delete"), role: .destructive) { root.delete(server); pendingConfirmation = nil } - case let .workspace(workspace): - Button(String(localized: "Delete"), role: .destructive) { root.delete(workspace: workspace); pendingConfirmation = nil } } } } message: { @@ -97,7 +91,12 @@ struct RemoteRootView: View { @ViewBuilder private var terminalDetail: some View { if let runtime = root.activeRuntime { - RemoteTerminalView(root: root, runtime: runtime, compact: sizeClass == .compact, showLibrary: { sheet = .library }) + RemoteTerminalDetailView( + root: root, + runtime: runtime, + isInputSuspended: sheet != nil, + showLibrary: { sheet = .library } + ) } else if sizeClass == .compact { NavigationStack { library } } else { @@ -112,7 +111,8 @@ struct RemoteRootView: View { private var library: some View { RemoteLibraryView( servers: root.servers, - workspaces: root.workspaces, + workspacesByServer: Dictionary(uniqueKeysWithValues: root.servers.map { ($0.id, root.visibleWorkspaces(for: $0.id)) }), + sessionDiscovery: root.sessionDiscovery, activeWorkspaceIDs: Set(root.activeWorkspaces.map(\.id)), agentSummaries: Dictionary(uniqueKeysWithValues: root.activeWorkspaces.map { ($0.id, root.agentSummary(for: $0.id)) }), migrationReport: root.migrationReport, @@ -123,9 +123,7 @@ struct RemoteRootView: View { onAdd: { sheet = .add }, onEdit: { sheet = .edit($0.id) }, onDelete: { server in pendingConfirmation = .server(server, workspaceCount: root.workspaces.filter { $0.serverID == server.id }.count) }, - onAddWorkspace: { sheet = .workspace(serverID: $0, workspaceID: nil) }, - onEditWorkspace: { sheet = .workspace(serverID: $0.serverID, workspaceID: $0.id) }, - onDeleteWorkspace: { pendingConfirmation = .workspace($0) }, + onRefreshSessions: { root.discoverSessions(serverID: $0) }, onSettings: { sheet = .settings } ) } @@ -147,33 +145,26 @@ struct RemoteRootView: View { private enum RemoteDestructiveAction: Identifiable { case server(SavedServer, workspaceCount: Int) - case workspace(SavedWorkspace) var id: UUID { - switch self { - case let .server(server, _): server.id - case let .workspace(workspace): workspace.id - } + switch self { case let .server(server, _): server.id } } var message: String { switch self { case let .server(_, workspaceCount): String(format: String(localized: "Deleting this server also deletes %lld workspaces and their saved credentials."), workspaceCount) - case .workspace: - String(localized: "Deleting this workspace disconnects it and cannot be undone.") } } } private enum RemoteSheet: Identifiable { - case add, edit(UUID), settings, workspace(serverID: UUID, workspaceID: UUID?), library + case add, edit(UUID), settings, library var id: String { switch self { case .add: "add" case let .edit(id): "edit-\(id)" case .settings: "settings" - case let .workspace(serverID, workspaceID): "workspace-\(serverID)-\(workspaceID?.uuidString ?? "new")" case .library: "library" } } @@ -181,7 +172,8 @@ private enum RemoteSheet: Identifiable { private struct RemoteLibraryView: View { let servers: [SavedServer] - let workspaces: [SavedWorkspace] + let workspacesByServer: [UUID: [SavedWorkspace]] + let sessionDiscovery: [UUID: ServerSessionDiscoveryStatus] let activeWorkspaceIDs: Set let agentSummaries: [UUID: AgentMetadata] let migrationReport: LegacyMigrationReport? @@ -189,9 +181,7 @@ private struct RemoteLibraryView: View { let onAdd: () -> Void let onEdit: (SavedServer) -> Void let onDelete: (SavedServer) -> Void - let onAddWorkspace: (UUID) -> Void - let onEditWorkspace: (SavedWorkspace) -> Void - let onDeleteWorkspace: (SavedWorkspace) -> Void + let onRefreshSessions: (UUID) -> Void let onSettings: () -> Void @State private var filter = "" @@ -205,15 +195,15 @@ private struct RemoteLibraryView: View { } if filteredServers.isEmpty { ContentUnavailableView( - filter.isEmpty ? String(localized: "No saved servers") : String(localized: "No matching workspaces"), + filter.isEmpty ? String(localized: "No saved servers") : String(localized: "No matching sessions"), systemImage: "server.rack", - description: Text(String(localized: "Add a server and workspace to begin.")) + description: Text(String(localized: "Add a server to get started.")) ) .listRowBackground(Color.clear) } ForEach(filteredServers) { server in Section { - ForEach(workspaces.filter { $0.serverID == server.id && matches($0) }) { workspace in + ForEach((workspacesByServer[server.id] ?? []).filter(matches)) { workspace in Button { onConnect(workspace.id) } label: { HStack { Image(systemName: activeWorkspaceIDs.contains(workspace.id) ? "terminal.fill" : "terminal") @@ -232,17 +222,18 @@ private struct RemoteLibraryView: View { } } } - .contextMenu { - Button(String(localized: "Edit workspace"), action: { onEditWorkspace(workspace) }) - Button(String(localized: "Delete workspace"), role: .destructive, action: { onDeleteWorkspace(workspace) }) - } } - Button(String(localized: "Add workspace"), systemImage: "plus") { onAddWorkspace(server.id) } + discoveryRow(for: server) } header: { HStack { Text(verbatim: server.name) Spacer() Text(verbatim: "\(server.username)@\(server.host)") + Button { onRefreshSessions(server.id) } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Refresh sessions")) } } .contextMenu { @@ -251,18 +242,45 @@ private struct RemoteLibraryView: View { } } } - .searchable(text: $filter, prompt: String(localized: "Filter servers and workspaces")) + .searchable(text: $filter, prompt: String(localized: "Filter servers and sessions")) .toolbar { ToolbarItem(placement: .topBarLeading) { Button(String(localized: "Settings"), systemImage: "gear", action: onSettings) } ToolbarItem(placement: .topBarTrailing) { Button(String(localized: "Add server"), systemImage: "plus", action: onAdd) } } } + @ViewBuilder private func discoveryRow(for server: SavedServer) -> some View { + switch sessionDiscovery[server.id] ?? .idle { + case .idle: + Button(String(localized: "Load sessions"), systemImage: "arrow.clockwise") { onRefreshSessions(server.id) } + case .loading: + HStack { ProgressView(); Text(String(localized: "Loading sessions…")) } + .foregroundStyle(.secondary) + case .loaded: + if (workspacesByServer[server.id] ?? []).isEmpty { + Label(String(localized: "No tmux sessions"), systemImage: "terminal") + .foregroundStyle(.secondary) + } + case let .failed(message): + Button { onRefreshSessions(server.id) } label: { + VStack(alignment: .leading) { + Label(String(localized: "Session discovery failed"), systemImage: "exclamationmark.triangle") + Text(verbatim: message).font(.caption).foregroundStyle(.secondary) + } + } + } + } + private var filteredServers: [SavedServer] { - servers.filter { server in workspaces.contains { $0.serverID == server.id && matches($0) } } + servers.filter { server in + filter.isEmpty + || server.name.localizedCaseInsensitiveContains(filter) + || server.host.localizedCaseInsensitiveContains(filter) + || (workspacesByServer[server.id] ?? []).contains(where: matches) + } } private func matches(_ workspace: SavedWorkspace) -> Bool { - filter.isEmpty || workspace.name.localizedCaseInsensitiveContains(filter) || workspace.tmuxSession.localizedCaseInsensitiveContains(filter) || servers.first(where: { $0.id == workspace.serverID })?.name.localizedCaseInsensitiveContains(filter) == true + filter.isEmpty || workspace.name.localizedCaseInsensitiveContains(filter) || workspace.tmuxSession.localizedCaseInsensitiveContains(filter) } } @@ -308,132 +326,362 @@ private struct AgentMetadataBadge: View { } @MainActor -private struct RemoteTerminalView: View { +private struct RemoteTerminalDetailView: View { let root: RemoteRootModel let runtime: ActiveWorkspaceRuntime - let compact: Bool + let isInputSuspended: Bool let showLibrary: () -> Void - @State private var showsPanes = false - @State private var confirmsClosePane = false + @State private var showsNavigator = false + @State private var pendingSharedMutation: RemoteSharedMutation? var body: some View { VStack(spacing: 0) { - header - if let surface = runtime.surface() { - TmuxPaneSurfaceView(surface: surface) - .id(RemoteTerminalPresentation.identity(for: runtime.instanceID, mode: compact ? .compact : .regular)) - .background(Color.black) - } else { - ContentUnavailableView(runtime.status.title, systemImage: "terminal", description: Text(String(localized: "Waiting for the active tmux pane."))) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black) - } + MoriRemoteTerminalView( + session: runtime.session, + isInputSuspended: isInputSuspended || showsNavigator, + imageUploader: root.imageUploader(for: runtime.workspace.id), + onShowNavigator: { + root.discoverSessions(serverID: runtime.workspace.serverID) + showsNavigator = true + }, + onSharedMutationRequest: { pendingSharedMutation = RemoteSharedMutation($0) } + ) + .id(runtime.instanceID) + .background(Color.black) } .background(Color.black.ignoresSafeArea()) - .sheet(isPresented: $showsPanes) { panePicker } - .onChange(of: root.runtimeRevision) { _, _ in } - .confirmationDialog(String(localized: "Close shared pane?"), isPresented: $confirmsClosePane, titleVisibility: .visible) { - Button(String(localized: "Close pane"), role: .destructive) { root.closePane() } + .sheet(isPresented: $showsNavigator) { + RemoteNavigatorView(root: root, runtime: runtime) { + showsNavigator = false + Task { @MainActor in + await Task.yield() + showLibrary() + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .confirmationDialog( + String(localized: "Confirm shared workspace change"), + isPresented: sharedMutationConfirmationBinding, + titleVisibility: .visible + ) { + if let mutation = pendingSharedMutation { + Button(mutation.title, role: mutation.isDestructive ? .destructive : nil) { + root.performSharedMutation(mutation.value) + pendingSharedMutation = nil + } + } } message: { - Text(String(localized: "Closing this shared pane affects every tmux client.")) + Text(String(localized: "This change affects every tmux client.")) + } + } + + private var sharedMutationConfirmationBinding: Binding { + .init(get: { pendingSharedMutation != nil }, set: { if !$0 { pendingSharedMutation = nil } }) + } + +} + +@MainActor +private struct RemoteNavigatorView: View { + enum Scope: String, CaseIterable, Identifiable { + case sessions, windows, panes + var id: Self { self } + var title: String { + switch self { + case .sessions: String(localized: "Sessions") + case .windows: String(localized: "Windows") + case .panes: String(localized: "Panes") + } } } - private var header: some View { - HStack(spacing: 12) { - if compact { - Button(action: dismissKeyboard) { Image(systemName: "keyboard.chevron.compact.down") } - .accessibilityLabel(String(localized: "Dismiss keyboard")) - Button(action: showLibrary) { Image(systemName: "sidebar.left") } - .accessibilityLabel(String(localized: "Show library")) + @Environment(\.dismiss) private var dismiss + let root: RemoteRootModel + let runtime: ActiveWorkspaceRuntime + let showLibrary: () -> Void + @State private var selectedServerID: UUID + @State private var scope = Scope.sessions + @State private var filter = "" + + init(root: RemoteRootModel, runtime: ActiveWorkspaceRuntime, showLibrary: @escaping () -> Void) { + self.root = root + self.runtime = runtime + self.showLibrary = showLibrary + _selectedServerID = State(initialValue: runtime.workspace.serverID) + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + hostSelector + + Picker(String(localized: "Navigator"), selection: $scope) { + ForEach(Scope.allCases) { item in + Text(item.title) + .tag(item) + .disabled(item != .sessions && !isBrowsingActiveHost) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.bottom, 10) + + List { content } + .listStyle(.plain) + .overlay { emptyState } + } + .navigationTitle(String(localized: "Navigator")) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $filter, prompt: String(localized: "Filter sessions, windows, and panes")) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: showLibrary) { Image(systemName: "server.rack") } + .accessibilityLabel(String(localized: "Manage servers")) + } + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + root.discoverSessions(serverID: selectedServerID) + } label: { Image(systemName: "arrow.clockwise") } + .accessibilityLabel(String(localized: "Refresh sessions")) + Button(String(localized: "Done")) { dismiss() } + } + } + } + .onChange(of: scope) { _, newScope in + if newScope != .sessions, !isBrowsingActiveHost { + scope = .sessions } - Button { showsPanes = true } label: { - VStack(alignment: .leading, spacing: 1) { - Text(verbatim: runtime.topology?.sessionName ?? runtime.workspace.name) - .lineLimit(1) - HStack(spacing: 6) { - Text(runtime.status.title).font(.caption).foregroundStyle(.secondary) - AgentMetadataBadge(metadata: runtime.metadata(for: runtime.focusedPaneID ?? TmuxPaneID(0))) + filter = "" + } + .onChange(of: selectedServerID) { _, serverID in + scope = .sessions + filter = "" + root.discoverSessions(serverID: serverID) + } + } + + private var hostSelector: some View { + Menu { + ForEach(root.servers) { server in + Button { + selectedServerID = server.id + } label: { + Label { + Text(verbatim: "\(server.name) · \(server.host)") + } icon: { + Image(systemName: hostMenuSymbol(for: server.id)) } } } - Spacer() - Menu { - Button(String(localized: "Split right (shared)")) { root.split(horizontal: true) } - Button(String(localized: "Split down (shared)")) { root.split(horizontal: false) } - Button(String(localized: "New window (shared)")) { root.newWindow() } - Button(String(localized: "Close pane (shared)"), role: .destructive) { confirmsClosePane = true } - } label: { Image(systemName: "rectangle.3.group") } - Button(action: copySelection) { Image(systemName: "doc.on.doc") } - .accessibilityLabel(String(localized: "Copy selection")) - Button(action: root.disconnectActive) { Image(systemName: "power") } - .accessibilityLabel(String(localized: "Disconnect")) - } - .padding(.horizontal, 12) - .frame(height: 48) - .foregroundStyle(.white) - .background(Color(white: 0.12)) - } - - private var panePicker: some View { - NavigationStack { - List { - Section(String(localized: "Windows")) { - ForEach(runtime.topology?.windows ?? [], id: \.id) { window in - Button { root.selectWindow(window.id) } label: { - HStack { - Label { - Text(verbatim: window.name) - } icon: { - Image(systemName: window.active ? "rectangle.inset.filled" : "rectangle") - } - Spacer() - AgentMetadataBadge(metadata: windowMetadata(window)) - } + } label: { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.mint) + .frame(width: 32, height: 32) + .background(Color.mint.opacity(0.12), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: selectedServer?.name ?? String(localized: "Server")) + .font(.subheadline.weight(.semibold)) + if let server = selectedServer { + Text(verbatim: "\(server.username)@\(server.host)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + if !isBrowsingActiveHost { + Text(String(localized: "Choose a session to switch terminals")) + .font(.caption2) + .foregroundStyle(.secondary) } } } - Section(String(localized: "Panes")) { - ForEach(runtime.topology?.panes ?? [], id: \.id) { pane in - Button { - root.selectPane(pane.id) - showsPanes = false - } label: { - HStack { - Text(verbatim: "%\(pane.id.rawValue)") - .font(.body.monospaced()) - AgentMetadataBadge(metadata: runtime.metadata(for: pane.id)) - Spacer() - Text(verbatim: "\(pane.width)×\(pane.height)") - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } + Spacer() + if isBrowsingActiveHost { + Text(String(localized: "Current host")) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func hostMenuSymbol(for serverID: UUID) -> String { + if serverID == selectedServerID { return "checkmark.circle.fill" } + if serverID == runtime.workspace.serverID { return "terminal.fill" } + return "server.rack" + } + + @ViewBuilder private var content: some View { + switch scope { + case .sessions: + ForEach(filteredSessions) { workspace in + let activeRuntime = root.runtimes[workspace.id] + Button { + root.connect(workspaceID: workspace.id) + dismiss() + } label: { + HStack { + Label { + Text(verbatim: workspace.tmuxSession) + } icon: { + Image(systemName: workspace.id == root.activeWorkspaceID ? "terminal.fill" : "terminal") } + Spacer() + Text(activeRuntime?.status.title ?? String(localized: "Disconnected")) + .font(.caption).foregroundStyle(.secondary) + if workspace.id == root.activeWorkspaceID { Image(systemName: "checkmark") } + } + } + .swipeActions { + if activeRuntime != nil { + Button(role: .destructive) { + Task { await root.disconnect(workspaceID: workspace.id) } + } label: { Label(String(localized: "Disconnect"), systemImage: "bolt.slash") } + } + } + } + case .windows: + ForEach(filteredWindows) { window in + Button { + root.selectWindow(window.id) + dismiss() + } label: { + HStack { + Label { + Text(verbatim: window.title) + } icon: { + Image(systemName: window.active ? "rectangle.inset.filled" : "rectangle") + } + Spacer() + AgentMetadataBadge(metadata: windowMetadata(window)) + if window.active { Image(systemName: "checkmark") } } } } - .navigationTitle(String(localized: "Workspace controls")) - .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(String(localized: "Done")) { showsPanes = false } } } + case .panes: + ForEach(filteredPanes) { pane in + Button { + root.selectPane(pane.id) + dismiss() + } label: { + HStack { + Label { + Text(verbatim: "%\(pane.id)").font(.body.monospaced()) + } icon: { + Image(systemName: "square.split.2x1") + } + VStack(alignment: .leading) { + Text(verbatim: windowTitle(for: pane)) + Text(verbatim: "\(pane.columns)×\(pane.rows)") + .font(.caption.monospaced()).foregroundStyle(.secondary) + } + Spacer() + AgentMetadataBadge(metadata: runtime.metadata(for: pane.id)) + if pane.id == runtime.focusedPaneID { Image(systemName: "checkmark") } + } + } + } + } + } + + @ViewBuilder private var emptyState: some View { + if scope == .sessions, root.sessionDiscovery[selectedServerID] == .loading, filteredSessions.isEmpty { + ProgressView(String(localized: "Loading sessions…")) + } else if visibleItemCount == 0 { + ContentUnavailableView(emptyTitle, systemImage: filter.isEmpty ? "rectangle.stack.badge.minus" : "magnifyingglass") } } - private func windowMetadata(_ window: TmuxSessionController.Window) -> AgentMetadata { + private var emptyTitle: String { + filter.isEmpty ? String(localized: "Nothing here") : String(localized: "No matching results") + } + private var selectedServer: SavedServer? { + root.servers.first { $0.id == selectedServerID } + } + private var isBrowsingActiveHost: Bool { + selectedServerID == runtime.workspace.serverID + } + private var filteredSessions: [SavedWorkspace] { + RemoteNavigatorProjection.sessions(root.visibleWorkspaces(for: selectedServerID), matching: filter) + } + private var filteredWindows: [MoriRemoteTerminalWindow] { + guard isBrowsingActiveHost else { return [] } + return RemoteNavigatorProjection.windows(runtime.topology?.windows ?? [], matching: filter) + } + private var filteredPanes: [MoriRemoteTerminalPane] { + guard isBrowsingActiveHost else { return [] } + return RemoteNavigatorProjection.panes( + runtime.topology?.panes ?? [], + windows: runtime.topology?.windows ?? [], + matching: filter + ) + } + private var visibleItemCount: Int { + switch scope { case .sessions: filteredSessions.count; case .windows: filteredWindows.count; case .panes: filteredPanes.count } + } + private func windowTitle(for pane: MoriRemoteTerminalPane) -> String { + runtime.topology?.windows.first(where: { $0.id == pane.windowID })?.title ?? String(localized: "Window") + } + private func windowMetadata(_ window: MoriRemoteTerminalWindow) -> AgentMetadata { runtime.topology?.panes .filter { $0.windowID == window.id } .map { runtime.metadata(for: $0.id) } .max { $0.state.priority < $1.state.priority } ?? .unknown } - private func dismissKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } - private func copySelection() { if let text = runtime.surface()?.copySelection(), !text.isEmpty { UIPasteboard.general.string = text } } +} + +private enum RemoteSharedMutation: Identifiable, Equatable { + case newWindow, splitHorizontal, splitVertical, closePane, closeWindow + + init(_ mutation: MoriRemoteTerminalSharedMutation) { + self = switch mutation { + case .newWindow: .newWindow + case .splitHorizontal: .splitHorizontal + case .splitVertical: .splitVertical + case .closePane: .closePane + case .closeWindow: .closeWindow + } + } + + var id: Self { self } + var value: MoriRemoteTerminalSharedMutation { + switch self { + case .newWindow: .newWindow + case .splitHorizontal: .splitHorizontal + case .splitVertical: .splitVertical + case .closePane: .closePane + case .closeWindow: .closeWindow + } + } + var title: String { + switch self { + case .newWindow: String(localized: "New window (shared)") + case .splitHorizontal: String(localized: "Split right (shared)") + case .splitVertical: String(localized: "Split down (shared)") + case .closePane: String(localized: "Close pane (shared)") + case .closeWindow: String(localized: "Close window (shared)") + } + } + var isDestructive: Bool { self == .closePane || self == .closeWindow } } private struct ProfileEditorView: View { @Environment(\.dismiss) private var dismiss - @State private var draft: ServerWorkspaceDraft + @State private var draft: ServerProfileDraft let existingServer: SavedServer? - let onSave: (ServerWorkspaceDraft) -> Void + let onSave: (ServerProfileDraft) -> Void - init(draft: ServerWorkspaceDraft, existingServer: SavedServer? = nil, onSave: @escaping (ServerWorkspaceDraft) -> Void) { + init(draft: ServerProfileDraft, existingServer: SavedServer? = nil, onSave: @escaping (ServerProfileDraft) -> Void) { _draft = State(initialValue: draft) self.existingServer = existingServer self.onSave = onSave @@ -450,12 +698,6 @@ private struct ProfileEditorView: View { TextField(String(localized: "Username"), text: $draft.username) .textInputAutocapitalization(.never).autocorrectionDisabled() } - if draft.workspaceID != nil { - Section(String(localized: "Workspace")) { - TextField(String(localized: "Workspace name"), text: $draft.workspaceName) - TextField(String(localized: "tmux session"), text: $draft.tmuxSession) - } - } Section(String(localized: "Authentication")) { Picker(String(localized: "Identity"), selection: $draft.identityKind) { Text(String(localized: "Password")).tag(SSHIdentityKind.password) @@ -480,31 +722,6 @@ private struct ProfileEditorView: View { } } -private struct WorkspaceEditorView: View { - @Environment(\.dismiss) private var dismiss - @State private var draft: WorkspaceDraft - let onSave: (WorkspaceDraft) -> Void - - init(draft: WorkspaceDraft, onSave: @escaping (WorkspaceDraft) -> Void) { - _draft = State(initialValue: draft) - self.onSave = onSave - } - - var body: some View { - NavigationStack { - Form { - TextField(String(localized: "Workspace name"), text: $draft.name) - TextField(String(localized: "tmux session"), text: $draft.tmuxSession) - } - .navigationTitle(String(localized: "Workspace")) - .toolbar { - ToolbarItem(placement: .cancellationAction) { Button(String(localized: "Cancel"), action: dismiss.callAsFunction) } - ToolbarItem(placement: .confirmationAction) { Button(String(localized: "Save")) { onSave(draft); dismiss() } } - } - } - } -} - private struct RemoteSettingsView: View { @Environment(\.dismiss) private var dismiss @State private var settings: RemoteSettings diff --git a/MoriRemote/MoriRemoteTerminal/App/Haptic.swift b/MoriRemote/MoriRemoteTerminal/App/Haptic.swift new file mode 100644 index 00000000..55cf8c63 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/App/Haptic.swift @@ -0,0 +1,72 @@ +import AudioToolbox +import UIKit + +@MainActor +enum Haptic { + static func selection() { + UISelectionFeedbackGenerator().selectionChanged() + } + + static func tap(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .light) { + UIImpactFeedbackGenerator(style: style).impactOccurred() + } + + static func warning() { + UINotificationFeedbackGenerator().notificationOccurred(.warning) + } + + static func success() { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + + static func error() { + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + + // MARK: - Chrome press feedback (long-lived prepared generators) + + private static var keyboardImpact: UIImpactFeedbackGenerator? + private static var chromeSelection: UISelectionFeedbackGenerator? + + /// Warm both keyboard-chrome generators so the first key/dock press has no + /// cold-start latency. Idempotent. + static func prewarmChromeFeedback() { + if keyboardImpact == nil { + keyboardImpact = UIImpactFeedbackGenerator(style: .light) + } + if chromeSelection == nil { + chromeSelection = UISelectionFeedbackGenerator() + } + keyboardImpact?.prepare() + chromeSelection?.prepare() + } + + /// Touch-down feedback for an accessory key (ctrl / esc / tab). + /// Audio defaults to OFF: a normal-app view cannot honor iOS Settings > + /// Keyboard Feedback > Sound, so we ship visual + haptic only and leave + /// the audio click as an explicit caller opt-in. + static func keyboardPress(playsAudio: Bool = false) { + if keyboardImpact == nil { + keyboardImpact = UIImpactFeedbackGenerator(style: .light) + } + keyboardImpact?.impactOccurred() + keyboardImpact?.prepare() + + if playsAudio { + // Public-API approximation of the iOS keyboard click; not exact + // parity. Plays via the ringer/silent path. + AudioServicesPlaySystemSound(1104) + } + } + + /// Touch-down feedback for chrome navigation/toggle controls (home, + /// windows, panes, keyboard toggle). Selection tic - canonical for + /// nav/toggle controls and materially quieter than the key impact. + static func chromeControlPress() { + if chromeSelection == nil { + chromeSelection = UISelectionFeedbackGenerator() + } + chromeSelection?.selectionChanged() + chromeSelection?.prepare() + } +} diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift new file mode 100644 index 00000000..77a02696 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -0,0 +1,260 @@ +import Foundation +import SwiftUI + +/// App-owned SSH is reduced to this sans-I/O boundary. It deliberately has no +/// Citadel, persistence, account, or arbitrary tmux-command vocabulary. +public enum MoriRemoteTerminalCloseDisposition: Sendable { case reusable, invalidated } + +public struct MoriRemoteTerminalTransport: Sendable { + public let receivedBytes: AsyncThrowingStream + public let start: @Sendable () async throws -> Void + public let send: @Sendable (Data) async throws -> Void + public let close: @Sendable (MoriRemoteTerminalCloseDisposition) async -> Void + public let isActive: @Sendable () async -> Bool + + public init( + receivedBytes: AsyncThrowingStream, + start: @escaping @Sendable () async throws -> Void, + send: @escaping @Sendable (Data) async throws -> Void, + close: @escaping @Sendable (MoriRemoteTerminalCloseDisposition) async -> Void, + isActive: @escaping @Sendable () async -> Bool + ) { + self.receivedBytes = receivedBytes + self.start = start + self.send = send + self.close = close + self.isActive = isActive + } +} + +private struct ClosureTmuxControlTransport: TmuxControlTransport, TmuxControlTransportLivenessChecking { + let base: MoriRemoteTerminalTransport + var receivedBytes: AsyncThrowingStream { base.receivedBytes } + func start(initialViewport: TmuxControlViewport?) async throws { _ = initialViewport; try await base.start() } + func send(_ data: Data) async throws { try await base.send(data) } + func close(disposition: TmuxControlTransportCloseDisposition) async { + await base.close(disposition == .reusable ? .reusable : .invalidated) + } + func isControlChannelActive() async -> Bool { await base.isActive() } +} + +public enum MoriRemoteTerminalConnectionState: Equatable, Sendable { + case connecting, ready, disconnected +} + +enum MoriRemoteTerminalConnectionProjection { + static func applying(_ incoming: MoriRemoteTerminalConnectionState, hasTopology: Bool) -> MoriRemoteTerminalConnectionState { + incoming == .connecting && hasTopology ? .ready : incoming + } +} + +public struct MoriRemoteTerminalPane: Identifiable, Equatable, Sendable { + public let id: UInt64 + public let windowID: UInt64 + public let columns: UInt32 + public let rows: UInt32 + public init(id: UInt64, windowID: UInt64, columns: UInt32, rows: UInt32) { + self.id = id; self.windowID = windowID; self.columns = columns; self.rows = rows + } +} + +public struct MoriRemoteTerminalWindow: Identifiable, Equatable, Sendable { + public let id: UInt64 + public let title: String + public let active: Bool + public let activePaneID: UInt64? + public init(id: UInt64, title: String, active: Bool, activePaneID: UInt64?) { + self.id = id; self.title = title; self.active = active; self.activePaneID = activePaneID + } +} + +public struct MoriRemoteTerminalTopology: Equatable, Sendable { + public let windows: [MoriRemoteTerminalWindow] + public let panes: [MoriRemoteTerminalPane] + public let activeWindowID: UInt64? + public init(windows: [MoriRemoteTerminalWindow], panes: [MoriRemoteTerminalPane], activeWindowID: UInt64?) { + self.windows = windows; self.panes = panes; self.activeWindowID = activeWindowID + } +} + +public struct MoriRemoteTerminalAgentMetadataResult: Equatable, Sendable { + public enum Status: Equatable, Sendable { case success, skipped, failed } + public let status: Status + public let body: String +} + +public enum MoriRemoteTerminalSharedMutation: Sendable { + case newWindow, splitHorizontal, splitVertical, closePane, closeWindow +} + +/// App-owned SSH uploads an image and returns the shell-visible remote path. +/// The terminal module owns picker/staging UI but never credentials or roots. +public struct MoriRemoteTerminalImageUploader: Sendable { + public typealias ProgressHandler = @Sendable (Int64, Int64) async -> Void + private let uploadHandler: @Sendable (URL, String, @escaping ProgressHandler) async throws -> String + + public init( + upload: @escaping @Sendable (URL, String, @escaping ProgressHandler) async throws -> String + ) { + uploadHandler = upload + } + + public func upload( + localURL: URL, + filename: String, + progress: @escaping ProgressHandler + ) async throws -> String { + try await uploadHandler(localURL, filename, progress) + } +} + +/// The sole public native-terminal owner. It retains GhosttyKitRuntime before +/// constructing the tmux client, so callers cannot repeat an uninitialized +/// native harness or leak Ghostty handles into the application target. +@MainActor +public final class MoriRemoteTerminalSession { + public let instanceID: UUID + public private(set) var connectionState: MoriRemoteTerminalConnectionState = .connecting + public private(set) var topology: MoriRemoteTerminalTopology? + public private(set) var lastError: String? + + public var onConnectionStateChange: (@MainActor (MoriRemoteTerminalConnectionState) -> Void)? + public var onTopologyChange: (@MainActor (MoriRemoteTerminalTopology) -> Void)? + + private let runtime: GhosttyKitRuntime + private let terminalSession: TmuxTerminalSession + fileprivate let screenAdapter: TmuxTerminalScreenAdapter + + public init( + transport: MoriRemoteTerminalTransport, + initialScrollbackLines: Int = 2_000, + instanceID: UUID = UUID() + ) throws { + self.instanceID = instanceID + let runtime = try GhosttyKitRuntime() + self.runtime = runtime + let terminalSession = TmuxTerminalSession( + app: runtime.appHandle, + transport: ClosureTmuxControlTransport(base: transport), + historyLineLimit: initialScrollbackLines, + baseSurfaceConfig: { runtime.makeTmuxBaseSurfaceConfig() }, + paneViewTheme: { .ghosttyDefault } + ) + let screenAdapter = TmuxTerminalScreenAdapter() + self.terminalSession = terminalSession + self.screenAdapter = screenAdapter + screenAdapter.activate( + session: terminalSession, + initialViewportHandler: { [weak terminalSession] size, scale in + terminalSession?.updateViewportMetrics(size: size, scale: scale) + } + ) + terminalSession.onStateChange = { [weak self] state in self?.receive(state) } + terminalSession.onTopologyChange = { [weak self] snapshot in self?.receive(snapshot) } + } + + public func start() async throws { + do { + try await terminalSession.connect() + } catch { + lastError = error.localizedDescription + publishConnectionState(.disconnected) + throw error + } + } + + public func stop() async { + screenAdapter.invalidate() + await terminalSession.shutdown() + publishConnectionState(.disconnected) + } + + public func setPresentationActive(_ active: Bool) { + terminalSession.setAppActive(active) + } + + public func isControlChannelActive() async -> Bool { await terminalSession.controlChannelIsActive() } + + public func selectWindow(_ id: UInt64) { terminalSession.controller.requestSelectWindow(windowID: .init(id)) } + public func selectPane(_ id: UInt64) { terminalSession.controller.requestSelectPane(paneID: .init(id)) } + public func queryAgentMetadata() async -> MoriRemoteTerminalAgentMetadataResult { + await withCheckedContinuation { continuation in + terminalSession.controller.queryAgentMetadata { result in + let status: MoriRemoteTerminalAgentMetadataResult.Status = switch result.status { + case .success: .success; case .skipped: .skipped; case .failed: .failed + } + continuation.resume(returning: .init(status: status, body: result.body)) + } + } + } + + public func performSharedMutation(_ mutation: MoriRemoteTerminalSharedMutation) { + let value: TmuxSessionController.SharedMutation = switch mutation { + case .newWindow: .newWindow; case .splitHorizontal: .splitHorizontal + case .splitVertical: .splitVertical; case .closePane: .closePane; case .closeWindow: .closeWindow + } + terminalSession.controller.requestSharedMutation(value) + } + + private func receive(_ state: TmuxSessionController.SessionState) { + switch state { + case .ready: + lastError = nil + publishConnectionState(.ready) + case .attaching, .syncing: + // Topology proves the control client is usable. A delayed syncing + // callback must not regress an already rendered terminal to Connecting. + publishConnectionState(MoriRemoteTerminalConnectionProjection.applying(.connecting, hasTopology: topology != nil)) + case .detached, .closed: + publishConnectionState(.disconnected) + } + } + + private func receive(_ snapshot: TmuxSessionController.TopologySnapshot) { + let topology = MoriRemoteTerminalTopology( + windows: snapshot.windows.map { .init(id: $0.id.rawValue, title: $0.name, active: $0.active, activePaneID: $0.activePaneID?.rawValue) }, + panes: snapshot.panes.map { .init(id: $0.id.rawValue, windowID: $0.windowID.rawValue, columns: $0.width, rows: $0.height) }, + activeWindowID: snapshot.activeWindowID?.rawValue + ) + self.topology = topology + onTopologyChange?(topology) + } + + private func publishConnectionState(_ state: MoriRemoteTerminalConnectionState) { + guard connectionState != state else { return } + connectionState = state + onConnectionStateChange?(state) + } +} + +public struct MoriRemoteTerminalView: View { + private let session: MoriRemoteTerminalSession + private let isInputSuspended: Bool + private let imageUploader: MoriRemoteTerminalImageUploader? + private let onShowNavigator: () -> Void + private let onSharedMutationRequest: (MoriRemoteTerminalSharedMutation) -> Void + + public init( + session: MoriRemoteTerminalSession, + isInputSuspended: Bool = false, + imageUploader: MoriRemoteTerminalImageUploader? = nil, + onShowNavigator: @escaping () -> Void = {}, + onSharedMutationRequest: @escaping (MoriRemoteTerminalSharedMutation) -> Void = { _ in } + ) { + self.session = session + self.isInputSuspended = isInputSuspended + self.imageUploader = imageUploader + self.onShowNavigator = onShowNavigator + self.onSharedMutationRequest = onSharedMutationRequest + } + + public var body: some View { + GhosttyTerminalCoreView( + screen: session.screenAdapter, + isInputSuspended: isInputSuspended, + imageUploader: imageUploader, + onShowNavigator: onShowNavigator, + onSharedMutationRequest: onSharedMutationRequest + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift new file mode 100644 index 00000000..0c53ccfa --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift @@ -0,0 +1,126 @@ +import Foundation +import Observation +import OSLog +import SwiftUI + +#if DEBUG +/// Credential-free simulator smoke route. It deliberately exercises the same +/// facade and view that production uses; the app target never constructs a +/// Ghostty runtime or native surface for this check. +public struct MoriRemoteTerminalProbe: View { + @State private var model = ProbeModel() + + public init() {} + + public var body: some View { + VStack(spacing: 8) { + if let session = model.session { + MoriRemoteTerminalView(session: session) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.didTimeOut { + ContentUnavailableView("Ghostty renderer failed", systemImage: "exclamationmark.triangle", description: Text(model.status)) + } else { + ProgressView("Ghostty terminal probe loading") + } + Text(model.status).font(.caption).foregroundStyle(model.didTimeOut ? .red : .secondary) + } + .padding() + .accessibilityIdentifier("ghostty-terminal-probe") + .task { await model.start() } + .onDisappear { Task { await model.stop() } } + } +} + +@MainActor +@Observable private final class ProbeModel { + var session: MoriRemoteTerminalSession? + var status = "Starting Ghostty tmux transcript…" + var didTimeOut = false + private var timeoutTask: Task? + private var didRecordResult = false + private let logger = Logger(subsystem: "com.vaayne.mori-remote", category: "ghostty-probe") + + func recordSuccess() { + guard !didRecordResult else { return } + didRecordResult = true + status = "Ghostty initialized deterministic tmux transcript" + logger.notice("MORI_GHOSTTY_PROBE_RESULT success=true detail=facade-topology") + } + + private func recordFailure(_ detail: String) { + guard !didRecordResult else { return } + didRecordResult = true + logger.error("MORI_GHOSTTY_PROBE_RESULT success=false detail=\(detail, privacy: .public)") + } + + func start() async { + guard session == nil else { return } + do { + let transport = ProbeTransport() + let session = try MoriRemoteTerminalSession(transport: transport.transport()) + session.onTopologyChange = { [weak self] _ in self?.recordSuccess() } + self.session = session + try await session.start() + timeoutTask = Task { [weak self, weak session] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, let self, !self.didRecordResult, session != nil else { return } + self.didTimeOut = true + self.status = "No live Ghostty terminal surface arrived within 5 seconds." + self.recordFailure("presentation-timeout") + } + } catch { + didTimeOut = true + status = "Ghostty probe failed: \(error.localizedDescription)" + recordFailure("startup-error") + } + } + + func stop() async { + timeoutTask?.cancel() + timeoutTask = nil + await session?.stop() + session = nil + } +} + +private actor ProbeTransport { + nonisolated let receivedBytes: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private var started = false + + init() { + var continuation: AsyncThrowingStream.Continuation! + receivedBytes = AsyncThrowingStream { continuation = $0 } + self.continuation = continuation + } + + nonisolated func transport() -> MoriRemoteTerminalTransport { + .init( + receivedBytes: receivedBytes, + start: { try await self.start() }, + send: { _ in }, + close: { _ in await self.close() }, + isActive: { await self.started } + ) + } + + private func start() throws { + guard !started else { return } + started = true + let pane = "%0;83;44;0;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;;;0;0;43;8,16\n" + let window = "$42 @0 1 %0 83 44 b7dd,83x44,0,0,0 b7dd,83x44,0,0,0 probe\n" + let transcript = "%begin 1 1 0\n%end 1 1 0\n%session-changed $42 main\n" + + "%begin 2 2 1\n3.1\n%end 2 2 1\n" + + "%begin 3 3 1\n\(window)%end 3 3 1\n" + + "%begin 4 4 1\n\(pane)%end 4 4 1\n" + + (5...8).map { "%begin \($0) \($0) 1\n%end \($0) \($0) 1\n" }.joined() + continuation.yield(Data(transcript.utf8)) + continuation.yield(Data("%output %0 MoriRemote Ghostty transcript\\015\\012$ \n".utf8)) + } + + private func close() { + started = false + continuation.finish() + } +} +#endif diff --git a/MoriRemote/MoriRemoteTerminal/Domain/TerminalSettings.swift b/MoriRemote/MoriRemoteTerminal/Domain/TerminalSettings.swift new file mode 100644 index 00000000..50805f3d --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Domain/TerminalSettings.swift @@ -0,0 +1,189 @@ +import Foundation +import UIKit + +enum TerminalTheme: String, CaseIterable, Codable, Identifiable, Sendable { + case ghosttyDefault + case remuxDark + case remuxLight + + var id: String { rawValue } + + var displayName: String { + switch self { + case .ghosttyDefault: + "Ghostty Default" + case .remuxDark: + "Catppuccin Mocha" + case .remuxLight: + "Catppuccin Latte" + } + } + + var pickerTitle: String { + switch self { + case .ghosttyDefault: + "Default" + case .remuxDark: + "Mocha" + case .remuxLight: + "Latte" + } + } + + var ghosttyConfigLines: [String] { + switch self { + case .ghosttyDefault: + [] + case .remuxDark: + [ + // Catppuccin Mocha is the dark member of the popular + // Catppuccin family. Keep the full palette inline so iOS + // config updates don't depend on theme resource lookup in the + // embedded runtime. + "palette = 0=#45475a", + "palette = 1=#f38ba8", + "palette = 2=#a6e3a1", + "palette = 3=#f9e2af", + "palette = 4=#89b4fa", + "palette = 5=#f5c2e7", + "palette = 6=#94e2d5", + "palette = 7=#a6adc8", + "palette = 8=#585b70", + "palette = 9=#f38ba8", + "palette = 10=#a6e3a1", + "palette = 11=#f9e2af", + "palette = 12=#89b4fa", + "palette = 13=#f5c2e7", + "palette = 14=#94e2d5", + "palette = 15=#bac2de", + "background = #1e1e2e", + "foreground = #cdd6f4", + "cursor-color = #f5e0dc", + "cursor-text = #11111b", + "selection-background = #353749", + "selection-foreground = #cdd6f4", + "split-divider-color = #313244", + ] + case .remuxLight: + [ + // Catppuccin Latte is the light member of the popular + // Catppuccin family. + "palette = 0=#5c5f77", + "palette = 1=#d20f39", + "palette = 2=#40a02b", + "palette = 3=#df8e1d", + "palette = 4=#1e66f5", + "palette = 5=#ea76cb", + "palette = 6=#179299", + "palette = 7=#acb0be", + "palette = 8=#6c6f85", + "palette = 9=#d20f39", + "palette = 10=#40a02b", + "palette = 11=#df8e1d", + "palette = 12=#1e66f5", + "palette = 13=#ea76cb", + "palette = 14=#179299", + "palette = 15=#bcc0cc", + "background = #eff1f5", + "foreground = #4c4f69", + "cursor-color = #dc8a78", + "cursor-text = #eff1f5", + "selection-background = #d8dae1", + "selection-foreground = #4c4f69", + "split-divider-color = #ccd0da", + ] + } + } + + /// Background color the terminal renders against, mirrored by Ghostty UI + /// chrome so surfaces can blend into the terminal area during keyboard + /// transitions. Values must stay in sync with `ghosttyConfigLines` and with + /// Ghostty's own default for `.ghosttyDefault`, sourced from + /// `src/config/Config.zig`. + var terminalChromeStyle: GhosttyTerminalChromeStyle { .ghosttyDefault } + var terminalKeyboardAppearance: UIKeyboardAppearance { self == .remuxLight ? .light : .dark } + + var terminalBackgroundHex: UInt32 { + switch self { + case .ghosttyDefault: + 0x282C34 + case .remuxDark: + 0x1E1E2E + case .remuxLight: + 0xEFF1F5 + } + } +} + +struct TerminalSettings: Equatable, Codable, Sendable { + static let minimumFontSize: Float32 = 8 + static let maximumFontSize: Float32 = 24 + static let defaultExplicitFontSize: Float32 = 10 + static let `default` = TerminalSettings(fontSize: nil, theme: .ghosttyDefault) + + var fontSize: Float32? + var theme: TerminalTheme + + /// Opt-in to the legacy `ssh-rsa` host-key algorithm, which uses SHA-1 + /// signatures. + /// Defaults to `false` when absent from persisted settings. + var allowInsecureRSAHostKeys: Bool + + init( + fontSize: Float32?, + theme: TerminalTheme, + allowInsecureRSAHostKeys: Bool = false + ) { + self.fontSize = Self.normalizedFontSize(fontSize) + self.theme = theme + self.allowInsecureRSAHostKeys = allowInsecureRSAHostKeys + } + + private enum CodingKeys: String, CodingKey { + case fontSize + case theme + case allowInsecureRSAHostKeys + } + + // Custom decoding keeps older persisted settings (written before these keys + // existed) loadable rather than failing the whole store on a missing key. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + fontSize: try container.decodeIfPresent(Float32.self, forKey: .fontSize), + theme: try container.decodeIfPresent(TerminalTheme.self, forKey: .theme) ?? .ghosttyDefault, + allowInsecureRSAHostKeys: try container.decodeIfPresent(Bool.self, forKey: .allowInsecureRSAHostKeys) ?? false + ) + } + + func hasSameTerminalAppearance(as other: TerminalSettings) -> Bool { + fontSize == other.fontSize && theme == other.theme + } + + var ghosttyConfigContents: String? { + ghosttyConfigContents(effectiveFontSize: nil) + } + + func ghosttyConfigContents(effectiveFontSize: Float32?) -> String? { + var lines = theme.ghosttyConfigLines + if let effectiveFontSize = effectiveFontSize ?? fontSize { + lines.append("font-size = \(Self.configString(for: effectiveFontSize))") + } + + guard !lines.isEmpty else { return nil } + return lines.joined(separator: "\n") + "\n" + } + + private static func normalizedFontSize(_ value: Float32?) -> Float32? { + guard let value, value.isFinite else { return nil } + return min(max(value, minimumFontSize), maximumFontSize) + } + + private static func configString(for value: Float32) -> String { + if value.rounded() == value { + return String(Int(value)) + } + + return String(format: "%.2f", value) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyImageAttachmentSheet.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyImageAttachmentSheet.swift new file mode 100644 index 00000000..1d9f899e --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyImageAttachmentSheet.swift @@ -0,0 +1,293 @@ +import CoreTransferable +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers +import UIKit + +private struct GhosttyStagedImage: Transferable, Sendable { + let url: URL + let filename: String + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(importedContentType: .image) { received in + let source = received.file + let filename = sanitizedFilename(source.lastPathComponent) + let destination = try stagingURL(filename: filename) + try FileManager.default.copyItem(at: source, to: destination) + return GhosttyStagedImage(url: destination, filename: filename) + } + } + + static func fromPasteboard() throws -> GhosttyStagedImage { + guard let image = UIPasteboard.general.image, + let data = image.pngData() + else { throw GhosttyImageAttachmentError.noPasteboardImage } + let filename = "pasted-image.png" + let destination = try stagingURL(filename: filename) + try data.write(to: destination, options: .atomic) + return GhosttyStagedImage(url: destination, filename: filename) + } + + private static func stagingURL(filename: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("MoriRemoteImages", isDirectory: true) + .appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent(filename, isDirectory: false) + } + + private static func sanitizedFilename(_ value: String) -> String { + let cleaned = value.unicodeScalars.map { scalar -> Character in + scalar.value < 0x20 || scalar == "/" || scalar == "\\" || scalar == "\0" + ? "_" : Character(scalar) + } + let filename = String(cleaned).trimmingCharacters(in: .whitespacesAndNewlines) + return filename.isEmpty ? "image" : String(filename.prefix(180)) + } +} + +enum GhosttyImageTerminalPathFormatter { + static func insertionText(for path: String) -> String? { + guard !path.isEmpty, + !path.unicodeScalars.contains(where: { $0 == "\0" || $0 == "\n" || $0 == "\r" }) + else { return nil } + if path.unicodeScalars.allSatisfy({ shellSafe.contains($0) }) { return path } + if path.hasPrefix("~/") { + return "~/" + singleQuote(String(path.dropFirst(2))) + } + return singleQuote(path) + } + + private static func singleQuote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } + + private static let shellSafe = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./~-" + ) +} + +private enum GhosttyImageAttachmentError: LocalizedError { + case noPasteboardImage + case photoUnavailable + case invalidRemotePath + case terminalRejected + + var errorDescription: String? { + switch self { + case .noPasteboardImage: String(localized: "The clipboard does not contain an image.") + case .photoUnavailable: String(localized: "The selected image could not be loaded.") + case .invalidRemotePath: String(localized: "The uploaded image path is invalid.") + case .terminalRejected: String(localized: "The terminal could not insert the image path.") + } + } +} + +struct GhosttyImageAttachmentSheet: View { + @Environment(\.dismiss) private var dismiss + let uploader: MoriRemoteTerminalImageUploader + let insertPath: (String) -> Bool + + @State private var photoSelection: PhotosPickerItem? + @State private var stagedImage: GhosttyStagedImage? + @State private var previewImage: UIImage? + @State private var isPreparing = false + @State private var isUploading = false + @State private var uploadedBytes: Int64 = 0 + @State private var totalBytes: Int64 = 0 + @State private var errorMessage: String? + @State private var preparationTask: Task? + @State private var uploadTask: Task? + + var body: some View { + NavigationStack { + VStack(spacing: 18) { + preview + + HStack(spacing: 10) { + PhotosPicker(selection: $photoSelection, matching: .images) { + Label(String(localized: "Choose photo"), systemImage: "photo.on.rectangle") + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.bordered) + .disabled(isUploading) + + Button(action: pasteImage) { + Label(String(localized: "Paste image"), systemImage: "doc.on.clipboard") + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.bordered) + .disabled(isUploading) + } + + if isUploading { + ProgressView(value: uploadFraction) { + Text(String(localized: "Uploading image…")) + } + } + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer(minLength: 0) + } + .padding(16) + .navigationTitle(String(localized: "Image")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "Cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "Insert path"), action: uploadAndInsert) + .disabled(stagedImage == nil || isPreparing || isUploading) + } + } + } + .onChange(of: photoSelection) { _, selection in + guard let selection else { return } + prepare(selection) + } + .onDisappear { + preparationTask?.cancel() + uploadTask?.cancel() + if !isUploading { cleanup(stagedImage) } + } + } + + @ViewBuilder private var preview: some View { + if let previewImage { + Image(uiImage: previewImage) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: 260) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.12)) + } + } else if isPreparing { + ProgressView(String(localized: "Loading image…")) + .frame(maxWidth: .infinity, minHeight: 180) + } else { + ContentUnavailableView( + String(localized: "Choose an image"), + systemImage: "photo", + description: Text(String(localized: "The image is uploaded through the current SSH server, then its remote path is inserted into the terminal.")) + ) + .frame(maxWidth: .infinity, minHeight: 180) + } + } + + private var uploadFraction: Double { + guard totalBytes > 0 else { return 0 } + return min(1, max(0, Double(uploadedBytes) / Double(totalBytes))) + } + + private func prepare(_ selection: PhotosPickerItem) { + preparationTask?.cancel() + isPreparing = true + errorMessage = nil + preparationTask = Task { + do { + guard let image = try await selection.loadTransferable(type: GhosttyStagedImage.self) else { + throw GhosttyImageAttachmentError.photoUnavailable + } + do { + try Task.checkCancellation() + let preview = await Task.detached(priority: .utility) { UIImage(contentsOfFile: image.url.path) }.value + try Task.checkCancellation() + await MainActor.run { + cleanup(stagedImage) + stagedImage = image + previewImage = preview + photoSelection = nil + isPreparing = false + } + } catch { + cleanup(image) + throw error + } + } catch is CancellationError { + await MainActor.run { + photoSelection = nil + isPreparing = false + } + } catch { + await MainActor.run { + photoSelection = nil + isPreparing = false + errorMessage = error.localizedDescription + } + } + } + } + + private func pasteImage() { + preparationTask?.cancel() + isPreparing = false + do { + let image = try GhosttyStagedImage.fromPasteboard() + cleanup(stagedImage) + stagedImage = image + previewImage = UIImage(contentsOfFile: image.url.path) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func uploadAndInsert() { + guard let stagedImage else { return } + isUploading = true + errorMessage = nil + uploadedBytes = 0 + totalBytes = (try? FileManager.default.attributesOfItem(atPath: stagedImage.url.path)[.size] as? NSNumber)?.int64Value ?? 0 + let expectedBytes = totalBytes + uploadTask = Task { + do { + let remotePath = try await uploader.upload( + localURL: stagedImage.url, + filename: stagedImage.filename, + progress: { uploaded, total in + await MainActor.run { + uploadedBytes = uploaded + totalBytes = total > 0 ? total : expectedBytes + } + } + ) + try Task.checkCancellation() + guard let insertion = GhosttyImageTerminalPathFormatter.insertionText(for: remotePath) else { + throw GhosttyImageAttachmentError.invalidRemotePath + } + guard insertPath(insertion) else { throw GhosttyImageAttachmentError.terminalRejected } + await MainActor.run { + cleanup(stagedImage) + isUploading = false + self.stagedImage = nil + dismiss() + } + } catch is CancellationError { + await MainActor.run { + cleanup(stagedImage) + isUploading = false + self.stagedImage = nil + } + } catch { + await MainActor.run { + isUploading = false + errorMessage = error.localizedDescription + } + } + } + } + + private func cleanup(_ image: GhosttyStagedImage?) { + guard let image else { return } + try? FileManager.default.removeItem(at: image.url.deletingLastPathComponent()) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift new file mode 100644 index 00000000..37ce22c8 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -0,0 +1,239 @@ +import SwiftUI + +enum GhosttyKeyboardChromeMode: Equatable { + case hidden + case system + + var enablesSystemKeyboard: Bool { self == .system } + func toggledKeyboard() -> Self { self == .hidden ? .system : .hidden } + func applyingSystemKeyboardVisibility(_ isVisible: Bool) -> Self { isVisible ? .system : .hidden } +} + +enum GhosttyKeyboardChromeSizing { + static let dockButtonHeight: CGFloat = 38 + static let dockButtonWidth: CGFloat = 38 + static let compactDockButtonWidth: CGFloat = 35 + static let dockButtonCornerRadius: CGFloat = 17 + static let controlGroupVerticalPadding: CGFloat = 4 + static let baselineHeight = dockButtonHeight + (controlGroupVerticalPadding * 2) + + static func keyboardReplacementHeight(keyboardOverlapHeight: CGFloat, bottomSafeAreaHeight: CGFloat) -> CGFloat { + guard keyboardOverlapHeight.isFinite, keyboardOverlapHeight > 0 else { return 0 } + return ceil(max(0, keyboardOverlapHeight - max(0, bottomSafeAreaHeight))) + } +} + +struct GhosttyTerminalChromeStyle { + let accent: Color + let accentForeground: Color + var selectedStroke: Color { accent.opacity(0.76) } + static let ghosttyDefault = Self(accent: .accentColor, accentForeground: .white) +} + +private struct GhosttyTerminalChromeStyleKey: EnvironmentKey { + static let defaultValue = GhosttyTerminalChromeStyle.ghosttyDefault +} + +extension EnvironmentValues { + var ghosttyTerminalChromeStyle: GhosttyTerminalChromeStyle { + get { self[GhosttyTerminalChromeStyleKey.self] } + set { self[GhosttyTerminalChromeStyleKey.self] = newValue } + } +} + +enum GhosttyPhoneChromePalette { static let dock = Color.black } + +/// Testable action boundary behind the remux-style menu chrome. Mori adds +/// categories and shared-mutation requests without importing account, +/// shortcut-store, or composer dependencies into the terminal module. +struct GhosttyKeyboardChromeActions { + let showNavigator: () -> Void + let toggleKeyboard: () -> Void + let toggleControl: () -> Void + let toggleAlt: () -> Void + let requestSharedMutation: (MoriRemoteTerminalSharedMutation) -> Void + let sendShortcut: (String) -> Bool + let sendKey: (GhosttySurfaceKeyEvent) -> Bool + + func perform(_ action: Action) -> Bool { + switch action { + case .navigator: showNavigator(); return true + case .keyboard: toggleKeyboard(); return true + case .control: toggleControl(); return true + case .alt: toggleAlt(); return true + case .newWindow: requestSharedMutation(.newWindow); return true + case .splitHorizontal: requestSharedMutation(.splitHorizontal); return true + case .splitVertical: requestSharedMutation(.splitVertical); return true + case .closePane: requestSharedMutation(.closePane); return true + case .closeWindow: requestSharedMutation(.closeWindow); return true + case .ctrlC: return sendShortcut("\u{03}") + case .ctrlD: return sendShortcut("\u{04}") + case .ctrlZ: return sendShortcut("\u{1A}") + case .ctrlL: return sendShortcut("\u{0C}") + case .ctrlA: return sendShortcut("\u{01}") + case .ctrlE: return sendShortcut("\u{05}") + case .ctrlR: return sendShortcut("\u{12}") + case .ctrlU: return sendShortcut("\u{15}") + case .ctrlK: return sendShortcut("\u{0B}") + case .ctrlW: return sendShortcut("\u{17}") + case .altB: return sendShortcut("\u{1B}b") + case .altF: return sendShortcut("\u{1B}f") + case .escape: return sendKey(.init(keyCode: .escape)) + case .tab: return sendKey(.init(keyCode: .tab)) + case .shiftTab: return sendKey(.init(keyCode: .tab, mods: .shift)) + case .arrowLeft: return sendKey(.init(keyCode: .arrowLeft)) + case .arrowUp: return sendKey(.init(keyCode: .arrowUp)) + case .arrowDown: return sendKey(.init(keyCode: .arrowDown)) + case .arrowRight: return sendKey(.init(keyCode: .arrowRight)) + case .home: return sendKey(.init(keyCode: .home)) + case .end: return sendKey(.init(keyCode: .end)) + case .pageUp: return sendKey(.init(keyCode: .pageUp)) + case .pageDown: return sendKey(.init(keyCode: .pageDown)) + case .questionMark: + return sendKey(.init(keyCode: .slash, text: "?", mods: .shift, consumedMods: .shift, unshiftedCodepoint: 0x2F)) + case .slash: + return sendKey(.init(keyCode: .slash, text: "/", unshiftedCodepoint: 0x2F)) + } + } + + enum Action { + case navigator, keyboard, control, alt + case escape, tab, shiftTab, arrowLeft, arrowUp, arrowDown, arrowRight + case home, end, pageUp, pageDown, questionMark, slash + case ctrlC, ctrlD, ctrlZ, ctrlL, ctrlA, ctrlE, ctrlR, ctrlU, ctrlK, ctrlW, altB, altF + case newWindow, splitHorizontal, splitVertical, closePane, closeWindow + } +} + +/// A single input accessory strip: four stable targets, no localized label +/// can distort the terminal viewport or move the keyboard control. +struct GhosttyKeyboardChrome: View { + private enum PresentedSheet: String, Identifiable { + case keypad, image + var id: Self { self } + } + + @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + @State private var presentedSheet: PresentedSheet? + + let keyboardMode: GhosttyKeyboardChromeMode + let isEnabled: Bool + let isCompact: Bool + let isControlArmed: Bool + let isAltArmed: Bool + let imageUploader: MoriRemoteTerminalImageUploader? + let insertImagePath: (String) -> Bool + let onImagePresentationChange: (Bool) -> Void + let actions: GhosttyKeyboardChromeActions + + var body: some View { + HStack(spacing: 0) { + toolbarButton( + "keyboard.badge.ellipsis", + id: "terminal.keypad", + label: String(localized: "Keypad"), + active: isControlArmed || isAltArmed + ) { presentedSheet = .keypad } + + Menu { + Section { + Button("New window") { _ = actions.perform(.newWindow) } + Button("Split right") { _ = actions.perform(.splitHorizontal) } + Button("Split down") { _ = actions.perform(.splitVertical) } + } + Section { + Button("Close pane", role: .destructive) { _ = actions.perform(.closePane) } + Button("Close window", role: .destructive) { _ = actions.perform(.closeWindow) } + } + } label: { + toolbarIcon("terminal", active: false) + } + .frame(maxWidth: .infinity) + .accessibilityLabel(String(localized: "tmux actions")) + .accessibilityIdentifier("terminal.tmux-actions") + .disabled(!isEnabled) + + toolbarButton( + "rectangle.stack", + id: "terminal.navigator", + label: String(localized: "Navigator"), + enabled: true + ) { _ = actions.perform(.navigator) } + + toolbarButton( + "keyboard", + id: "terminal.keyboard", + label: keyboardMode == .hidden ? String(localized: "Show keyboard") : String(localized: "Hide keyboard"), + active: keyboardMode == .system + ) { _ = actions.perform(.keyboard) } + } + .frame(height: 46) + .padding(.horizontal, isCompact ? 8 : 16) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { Divider().opacity(0.7) } + .preferredColorScheme(.dark) + .accessibilityElement(children: .contain) + .onChange(of: presentedSheet) { _, sheet in + onImagePresentationChange(sheet == .image) + } + .onDisappear { onImagePresentationChange(false) } + .sheet(item: $presentedSheet) { sheet in + switch sheet { + case .keypad: + GhosttyKeypadSheet( + isControlArmed: isControlArmed, + isAltArmed: isAltArmed, + onAddImage: imageUploader == nil ? nil : { presentedSheet = .image }, + actions: actions + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + case .image: + if let imageUploader { + GhosttyImageAttachmentSheet( + uploader: imageUploader, + insertPath: insertImagePath + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } + } + } + + private func toolbarButton( + _ systemName: String, + id: String, + label: String, + enabled: Bool? = nil, + active: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + toolbarIcon(systemName, active: active) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .accessibilityLabel(label) + .accessibilityIdentifier(id) + .disabled(!(enabled ?? isEnabled)) + } + + private func toolbarIcon(_ systemName: String, active: Bool) -> some View { + Image(systemName: systemName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(active ? chromeStyle.accent : Color.primary) + .frame(width: 44, height: 38) + .background( + active ? chromeStyle.accent.opacity(0.15) : Color.clear, + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + .contentShape(Rectangle()) + } +} + +extension View { + func ghosttyTerminalChromePresentation(_ colorScheme: ColorScheme, chromeStyle: GhosttyTerminalChromeStyle) -> some View { + preferredColorScheme(colorScheme).environment(\.ghosttyTerminalChromeStyle, chromeStyle) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardCursorTrackpad.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardCursorTrackpad.swift new file mode 100644 index 00000000..a03c22fd --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardCursorTrackpad.swift @@ -0,0 +1,458 @@ +import CoreGraphics +import Foundation +import QuartzCore + +/// Maps radial travel from a fixed long-press origin to one cardinal direction +/// and three repeat-rate tiers. Each committed tier has a stable plateau before +/// travel begins arming the next tier. +struct GhosttyKeyboardCursorTrackpad { + struct Configuration { + var neutralRadius: CGFloat + var armingDistance: CGFloat + var tierPlateauDistance: CGFloat + var oneXRepeatInterval: TimeInterval + var twoXRepeatInterval: TimeInterval + var threeXRepeatInterval: TimeInterval + var directionSwitchRatio: CGFloat + var tierReleaseHysteresis: CGFloat + + static let `default` = Configuration( + neutralRadius: 8, + armingDistance: 28, + tierPlateauDistance: 14, + oneXRepeatInterval: 0.45, + twoXRepeatInterval: 0.225, + threeXRepeatInterval: 0.12, + directionSwitchRatio: 1.25, + tierReleaseHysteresis: 4 + ) + } + + enum Direction: Equatable { + case left + case right + case up + case down + + var isHorizontal: Bool { + self == .left || self == .right + } + } + + enum SpeedTier: Int, Equatable { + case neutral = 0 + case one = 1 + case two = 2 + case three = 3 + } + + struct FeedbackState: Equatable { + var isVisible: Bool + var direction: Direction? + var committedTier: SpeedTier + var armingTier: SpeedTier? + var armingProgress: CGFloat + + static let hidden = FeedbackState( + isVisible: false, + direction: nil, + committedTier: .neutral, + armingTier: nil, + armingProgress: 0 + ) + + static let active = FeedbackState( + isVisible: true, + direction: nil, + committedTier: .neutral, + armingTier: nil, + armingProgress: 0 + ) + } + + let configuration: Configuration + + private var origin: CGPoint? + private var direction: Direction? + private var committedTier: SpeedTier = .neutral + + init(configuration: Configuration = .default) { + precondition(configuration.neutralRadius >= 0, "neutralRadius must be non-negative") + precondition(configuration.armingDistance > 0, "armingDistance must be positive") + precondition(configuration.tierPlateauDistance >= 0, "tierPlateauDistance must be non-negative") + precondition(configuration.oneXRepeatInterval > 0, "oneXRepeatInterval must be positive") + precondition(configuration.twoXRepeatInterval > 0, "twoXRepeatInterval must be positive") + precondition(configuration.threeXRepeatInterval > 0, "threeXRepeatInterval must be positive") + precondition(configuration.directionSwitchRatio >= 1, "directionSwitchRatio must be at least 1") + precondition(configuration.tierReleaseHysteresis >= 0, "tierReleaseHysteresis must be non-negative") + precondition( + configuration.tierReleaseHysteresis < configuration.armingDistance, + "tierReleaseHysteresis must be shorter than armingDistance" + ) + self.configuration = configuration + } + + mutating func begin(at point: CGPoint) -> FeedbackState { + origin = point + direction = nil + committedTier = .neutral + return .active + } + + mutating func update(at point: CGPoint) -> FeedbackState { + guard let origin else { + return begin(at: point) + } + + let displacement = CGPoint( + x: point.x - origin.x, + y: point.y - origin.y + ) + let radius = sqrt( + displacement.x * displacement.x + + displacement.y * displacement.y + ) + guard radius > 0, radius >= configuration.neutralRadius else { + direction = nil + committedTier = .neutral + return .active + } + + direction = resolveDirection(for: displacement) + guard let direction else { return .active } + + let speed = speedState(at: radius) + + return FeedbackState( + isVisible: true, + direction: direction, + committedTier: speed.committedTier, + armingTier: speed.armingTier, + armingProgress: speed.armingProgress + ) + } + + func repeatInterval(for tier: SpeedTier) -> TimeInterval? { + switch tier { + case .neutral: nil + case .one: configuration.oneXRepeatInterval + case .two: configuration.twoXRepeatInterval + case .three: configuration.threeXRepeatInterval + } + } + + private mutating func speedState( + at radius: CGFloat + ) -> (committedTier: SpeedTier, armingTier: SpeedTier?, armingProgress: CGFloat) { + let rawState = rawSpeedState(at: radius) + + if rawState.committedTier.rawValue > committedTier.rawValue { + committedTier = rawState.committedTier + return rawState + } + + if rawState.committedTier.rawValue < committedTier.rawValue { + let releaseRadius = activationRadius(for: committedTier) + - configuration.tierReleaseHysteresis + guard radius < releaseRadius else { + return (committedTier, nil, 0) + } + committedTier = rawState.committedTier + } + + return rawState + } + + private func rawSpeedState( + at radius: CGFloat + ) -> (committedTier: SpeedTier, armingTier: SpeedTier?, armingProgress: CGFloat) { + var remaining = radius - configuration.neutralRadius + guard remaining >= 0 else { return (.neutral, nil, 0) } + + if remaining < configuration.armingDistance { + return (.neutral, .one, remaining / configuration.armingDistance) + } + remaining -= configuration.armingDistance + + if remaining < configuration.tierPlateauDistance { + return (.one, nil, 0) + } + remaining -= configuration.tierPlateauDistance + + if remaining < configuration.armingDistance { + return (.one, .two, remaining / configuration.armingDistance) + } + remaining -= configuration.armingDistance + + if remaining < configuration.tierPlateauDistance { + return (.two, nil, 0) + } + remaining -= configuration.tierPlateauDistance + + if remaining < configuration.armingDistance { + return (.two, .three, remaining / configuration.armingDistance) + } + return (.three, nil, 0) + } + + private func activationRadius(for tier: SpeedTier) -> CGFloat { + guard tier != .neutral else { return configuration.neutralRadius } + return configuration.neutralRadius + + CGFloat(tier.rawValue) * configuration.armingDistance + + CGFloat(tier.rawValue - 1) * configuration.tierPlateauDistance + } + + private mutating func resolveDirection(for displacement: CGPoint) -> Direction { + let proposed: Direction + if abs(displacement.x) >= abs(displacement.y) { + proposed = displacement.x >= 0 ? .right : .left + } else { + proposed = displacement.y >= 0 ? .down : .up + } + + guard let direction else { return proposed } + guard direction.isHorizontal != proposed.isHorizontal else { return proposed } + + let currentAxisDistance = direction.isHorizontal + ? abs(displacement.x) + : abs(displacement.y) + let proposedAxisDistance = proposed.isHorizontal + ? abs(displacement.x) + : abs(displacement.y) + guard proposedAxisDistance >= currentAxisDistance * configuration.directionSwitchRatio else { + if direction.isHorizontal { + return displacement.x >= 0 ? .right : .left + } + return displacement.y >= 0 ? .down : .up + } + return proposed + } +} + +/// Owns one anchored cursor-steering gesture and its single repeat scheduler. +@MainActor +final class GhosttyKeyboardCursorTrackpadDriver { + enum HapticCue: Equatable { + case tierChanged + case neutralEntered + } + + private struct RepeatState { + var direction: GhosttyKeyboardCursorTrackpad.Direction + var tier: GhosttyKeyboardCursorTrackpad.SpeedTier + var nextFireAt: TimeInterval + } + + private let configuration: GhosttyKeyboardCursorTrackpad.Configuration + private let playHaptic: (HapticCue) -> Void + private weak var owner: AnyObject? + private var trackpad: GhosttyKeyboardCursorTrackpad? + private var sendKeyEvent: ((GhosttySurfaceKeyEvent) -> Bool)? + private var publishFeedback: ((GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void)? + private var repeatState: RepeatState? + private var repeatTimer: Timer? + private var didSteer = false + + init( + configuration: GhosttyKeyboardCursorTrackpad.Configuration = .default, + playHaptic: @escaping (HapticCue) -> Void = { cue in + switch cue { + case .tierChanged: + Haptic.selection() + case .neutralEntered: + Haptic.tap(.soft) + } + } + ) { + self.configuration = configuration + self.playHaptic = playHaptic + } + + func begin( + owner: AnyObject, + at point: CGPoint, + sendKeyEvent: @escaping (GhosttySurfaceKeyEvent) -> Bool, + onFeedbackChange: @escaping (GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void + ) { + cancelCurrentGesture() + + var trackpad = GhosttyKeyboardCursorTrackpad(configuration: configuration) + let feedback = trackpad.begin(at: point) + self.owner = owner + self.trackpad = trackpad + self.sendKeyEvent = sendKeyEvent + publishFeedback = onFeedbackChange + didSteer = false + onFeedbackChange(feedback) + Haptic.tap(.soft) + } + + @discardableResult + func update( + owner: AnyObject, + at point: CGPoint, + now: TimeInterval = CACurrentMediaTime() + ) -> GhosttyKeyboardCursorTrackpad.FeedbackState? { + guard self.owner === owner, var trackpad else { return nil } + + let feedback = trackpad.update(at: point) + self.trackpad = trackpad + guard apply(feedback: feedback, now: now) else { return feedback } + publishFeedback?(feedback) + return feedback + } + + /// Deterministic time injection for repeat behavior tests. Delayed ticks + /// emit at most one key and never catch up a backlog. + func repeatTick(at now: TimeInterval) { + guard let state = repeatState, now >= state.nextFireAt else { return } + repeatTimer?.invalidate() + repeatTimer = nil + + guard emit(direction: state.direction) else { + cancelCurrentGesture() + return + } + + guard let interval = trackpad?.repeatInterval(for: state.tier) else { + stopRepeating() + return + } + repeatState?.nextFireAt = now + interval + scheduleRepeat(after: interval) + } + + /// Returns whether this gesture sent or attempted any steering key. A + /// non-owner gets `nil`, allowing a stationary terminal long press to + /// continue into text selection. + @discardableResult + func end(owner: AnyObject) -> Bool? { + guard self.owner === owner else { return nil } + let result = didSteer + cancelCurrentGesture() + return result + } + + @discardableResult + func cancel(owner: AnyObject) -> Bool { + guard self.owner === owner else { return false } + cancelCurrentGesture() + return true + } + + private func apply( + feedback: GhosttyKeyboardCursorTrackpad.FeedbackState, + now: TimeInterval + ) -> Bool { + guard let direction = feedback.direction, + feedback.committedTier != .neutral, + let interval = trackpad?.repeatInterval(for: feedback.committedTier) + else { + let enteredNeutral = repeatState != nil + stopRepeating() + if enteredNeutral { + playHaptic(.neutralEntered) + } + return true + } + + guard let current = repeatState else { + guard emit(direction: direction) else { + cancelCurrentGesture() + return false + } + playHaptic(.tierChanged) + repeatState = RepeatState( + direction: direction, + tier: feedback.committedTier, + nextFireAt: now + interval + ) + scheduleRepeat(after: interval) + return true + } + + guard current.direction == direction else { + stopRepeating() + guard emit(direction: direction) else { + cancelCurrentGesture() + return false + } + playHaptic(.tierChanged) + repeatState = RepeatState( + direction: direction, + tier: feedback.committedTier, + nextFireAt: now + interval + ) + scheduleRepeat(after: interval) + return true + } + + guard current.tier != feedback.committedTier else { return true } + if feedback.committedTier.rawValue > current.tier.rawValue { + guard emit(direction: direction) else { + cancelCurrentGesture() + return false + } + } + playHaptic(.tierChanged) + repeatState = RepeatState( + direction: direction, + tier: feedback.committedTier, + nextFireAt: now + interval + ) + scheduleRepeat(after: interval) + return true + } + + private func emit(direction: GhosttyKeyboardCursorTrackpad.Direction) -> Bool { + didSteer = true + return sendKeyEvent?(GhosttySurfaceKeyEvent(keyCode: direction.keyCode)) == true + } + + private func scheduleRepeat(after delay: TimeInterval) { + repeatTimer?.invalidate() + let timer = Timer( + timeInterval: max(delay, 0.001), + target: self, + selector: #selector(repeatTimerFired), + userInfo: nil, + repeats: false + ) + RunLoop.main.add(timer, forMode: .common) + repeatTimer = timer + } + + private func stopRepeating() { + repeatState = nil + repeatTimer?.invalidate() + repeatTimer = nil + } + + private func cancelCurrentGesture() { + let hadOwner = owner != nil + trackpad = nil + stopRepeating() + if hadOwner { + publishFeedback?(.hidden) + } + owner = nil + sendKeyEvent = nil + publishFeedback = nil + didSteer = false + } + + @objc private func repeatTimerFired() { + repeatTick(at: CACurrentMediaTime()) + } +} + +private extension GhosttyKeyboardCursorTrackpad.Direction { + var keyCode: GhosttySurfaceKeyEvent.KeyCode { + switch self { + case .up: .arrowUp + case .down: .arrowDown + case .left: .arrowLeft + case .right: .arrowRight + } + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardCursorTrackpadHUD.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardCursorTrackpadHUD.swift new file mode 100644 index 00000000..743108ce --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardCursorTrackpadHUD.swift @@ -0,0 +1,209 @@ +import SwiftUI + +/// Displays either the stable committed rate or the complete shape currently +/// being armed. The repeat scheduler remains driven only by `committedTier`. +struct GhosttyKeyboardCursorTrackpadHUD: View { + @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + + let state: GhosttyKeyboardCursorTrackpad.FeedbackState + + private let cornerRadius: CGFloat = 14 + private let dimensions: CGFloat = 64 + private let maximumArmingFill: CGFloat = 0.82 + + var body: some View { + ZStack { + ForEach(DirectionPlacement.allCases, id: \.self) { placement in + Group { + if state.direction == placement.direction { + tierIndicator(for: placement.direction) + } else { + baseArrow(for: placement.direction) + } + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: placement.alignment + ) + } + } + .padding(8) + .frame(width: dimensions, height: dimensions) + .background(GhosttyPhoneChromePalette.dock.opacity(0.78)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.28), radius: 6, y: 2) + .opacity(state.isVisible ? 1 : 0) + .scaleEffect(state.isVisible ? 1 : 0.94) + .animation(.spring(response: 0.22, dampingFraction: 0.78), value: state.isVisible) + .accessibilityHidden(true) + .allowsHitTesting(false) + } + + private func baseArrow(for direction: GhosttyKeyboardCursorTrackpad.Direction) -> some View { + Image(systemName: arrowSymbol(for: direction)) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color.white.opacity(0.42)) + } + + @ViewBuilder + private func tierIndicator( + for direction: GhosttyKeyboardCursorTrackpad.Direction + ) -> some View { + let displayedTier = state.armingTier ?? state.committedTier + let progress: CGFloat = state.armingTier == nil + ? 1 + : state.armingProgress * maximumArmingFill + + switch displayedTier { + case .neutral: + baseArrow(for: direction) + case .one: + progressivelyFilledSymbol( + arrowSymbol(for: direction), + direction: direction, + progress: progress, + size: 13 + ) + case .two, .three: + chevronGroup( + direction: direction, + count: displayedTier.rawValue, + progress: progress + ) + } + } + + @ViewBuilder + private func chevronGroup( + direction: GhosttyKeyboardCursorTrackpad.Direction, + count: Int, + progress: CGFloat + ) -> some View { + if direction.isHorizontal { + HStack(spacing: -2) { + chevrons(direction: direction, count: count, progress: progress) + } + } else { + VStack(spacing: -2) { + chevrons(direction: direction, count: count, progress: progress) + } + } + } + + @ViewBuilder + private func chevrons( + direction: GhosttyKeyboardCursorTrackpad.Direction, + count: Int, + progress: CGFloat + ) -> some View { + ForEach(0.. Bool { + direction == .left || direction == .up + } + + private func progressivelyFilledSymbol( + _ symbol: String, + direction: GhosttyKeyboardCursorTrackpad.Direction, + progress: CGFloat, + size: CGFloat + ) -> some View { + ZStack { + Image(systemName: symbol) + .foregroundStyle(Color.white.opacity(0.42)) + Image(systemName: symbol) + .foregroundStyle(chromeStyle.accent) + .mask { + GeometryReader { proxy in + Rectangle() + .frame( + width: direction.isHorizontal + ? proxy.size.width * progress + : proxy.size.width, + height: direction.isHorizontal + ? proxy.size.height + : proxy.size.height * progress + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: fillAlignment(for: direction) + ) + } + } + } + .font(.system(size: size, weight: .bold)) + } + + private func fillAlignment( + for direction: GhosttyKeyboardCursorTrackpad.Direction + ) -> Alignment { + switch direction { + case .right: .leading + case .left: .trailing + case .down: .top + case .up: .bottom + } + } + + private func arrowSymbol(for direction: GhosttyKeyboardCursorTrackpad.Direction) -> String { + switch direction { + case .up: "arrow.up" + case .down: "arrow.down" + case .left: "arrow.left" + case .right: "arrow.right" + } + } + + private func chevronSymbol(for direction: GhosttyKeyboardCursorTrackpad.Direction) -> String { + switch direction { + case .up: "chevron.up" + case .down: "chevron.down" + case .left: "chevron.left" + case .right: "chevron.right" + } + } + + private enum DirectionPlacement: CaseIterable, Hashable { + case up + case down + case left + case right + + var direction: GhosttyKeyboardCursorTrackpad.Direction { + switch self { + case .up: .up + case .down: .down + case .left: .left + case .right: .right + } + } + + var alignment: Alignment { + switch self { + case .up: .top + case .down: .bottom + case .left: .leading + case .right: .trailing + } + } + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardVisibilityProjection.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardVisibilityProjection.swift new file mode 100644 index 00000000..386b6275 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardVisibilityProjection.swift @@ -0,0 +1,411 @@ +import CoreGraphics +import Foundation + +enum GhosttyKeyboardViewportTransitionTarget: Equatable { + case shown + case hidden + + var traceLabel: String { + switch self { + case .shown: + return "shown" + case .hidden: + return "hidden" + } + } +} + +extension Optional where Wrapped == GhosttyKeyboardViewportTransitionTarget { + var traceLabel: String { + switch self { + case .some(let target): + return target.traceLabel + case .none: + return "nil" + } + } +} + +struct GhosttyKeyboardViewportTransitionRequest: Equatable { + let target: GhosttyKeyboardViewportTransitionTarget? + let allowsTargetOverride: Bool + let fallbackDelay: TimeInterval + + init( + target: GhosttyKeyboardViewportTransitionTarget?, + allowsTargetOverride: Bool = false, + fallbackDelay: TimeInterval = GhosttyKeyboardViewportTransitionTiming.defaultFallbackDelay + ) { + self.target = target + self.allowsTargetOverride = allowsTargetOverride + self.fallbackDelay = fallbackDelay + } +} + +struct GhosttyKeyboardViewportTransitionBeginResult: Equatable { + let didStart: Bool + let fallbackToken: UInt64 + let fallbackDelay: TimeInterval +} + +struct GhosttyKeyboardViewportTransitionCompletionResult: Equatable { + let target: GhosttyKeyboardViewportTransitionTarget? +} + +struct GhosttyKeyboardVisibilityProjection: Equatable { + let frameEnd: CGRect + let screenBounds: CGRect + let isVisible: Bool + let overlapHeight: CGFloat + let animationDuration: TimeInterval + let transitionTarget: GhosttyKeyboardViewportTransitionTarget + let fallbackDelay: TimeInterval + let shouldBeginViewportTransition: Bool + + init( + frameEnd: CGRect, + screenBounds: CGRect, + animationDuration: TimeInterval?, + keyboardMode: GhosttyKeyboardChromeMode, + isDismissSystemKeyboardRequested: Bool + ) { + self.frameEnd = frameEnd + self.screenBounds = screenBounds + + let visibleOverlapHeight = GhosttyViewportSizing.normalizedHeight( + GhosttySoftwareKeyboardVisibility.visibleOverlapHeight( + frameEnd: frameEnd, + screenBounds: screenBounds + ) + ) + let resolvedAnimationDuration = + animationDuration ?? GhosttyKeyboardViewportTransitionTiming.defaultAnimationDuration + let target: GhosttyKeyboardViewportTransitionTarget = + visibleOverlapHeight > 0 ? .shown : .hidden + + self.isVisible = visibleOverlapHeight > 0 + self.overlapHeight = visibleOverlapHeight + self.animationDuration = resolvedAnimationDuration + self.transitionTarget = target + self.fallbackDelay = Self.fallbackDelay(animationDuration: resolvedAnimationDuration) + self.shouldBeginViewportTransition = GhosttyKeyboardViewportTransitionPolicy.shouldBeginVisibilityTransition( + notificationTarget: target, + keyboardMode: keyboardMode, + isDismissSystemKeyboardRequested: isDismissSystemKeyboardRequested + ) + } + + var transitionRequest: GhosttyKeyboardViewportTransitionRequest? { + guard shouldBeginViewportTransition else { return nil } + return GhosttyKeyboardViewportTransitionRequest( + target: transitionTarget, + fallbackDelay: fallbackDelay + ) + } + + static func fallbackDelay(animationDuration: TimeInterval) -> TimeInterval { + min( + max( + animationDuration + GhosttyKeyboardViewportTransitionTiming.fallbackGraceInterval, + GhosttyKeyboardViewportTransitionTiming.minimumFallbackDelay + ), + GhosttyKeyboardViewportTransitionTiming.maximumFallbackDelay + ) + } +} + +struct GhosttyKeyboardToggleProjection: Equatable { + let previousMode: GhosttyKeyboardChromeMode + let expectedMode: GhosttyKeyboardChromeMode + let isInputAvailable: Bool + let startsSystemKeyboardTransition: Bool + let transitionTarget: GhosttyKeyboardViewportTransitionTarget? + let fallbackDelay: TimeInterval? + let shouldAwaitSystemKeyboardPresentation: Bool + + init( + keyboardMode: GhosttyKeyboardChromeMode, + isInputAvailable: Bool + ) { + let expectedMode = keyboardMode.toggledKeyboard() + let startsSystemKeyboardTransition = Self.isSystemKeyboardTransition( + from: keyboardMode, + to: expectedMode + ) && isInputAvailable + + self.previousMode = keyboardMode + self.expectedMode = expectedMode + self.isInputAvailable = isInputAvailable + self.startsSystemKeyboardTransition = startsSystemKeyboardTransition + self.transitionTarget = startsSystemKeyboardTransition + ? Self.transitionTarget(for: expectedMode) + : nil + self.fallbackDelay = startsSystemKeyboardTransition + ? Self.fallbackDelay(for: expectedMode) + : nil + self.shouldAwaitSystemKeyboardPresentation = + startsSystemKeyboardTransition && expectedMode == .system + } + + var transitionRequest: GhosttyKeyboardViewportTransitionRequest? { + guard startsSystemKeyboardTransition, + let transitionTarget, + let fallbackDelay + else { + return nil + } + + return GhosttyKeyboardViewportTransitionRequest( + target: transitionTarget, + allowsTargetOverride: true, + fallbackDelay: fallbackDelay + ) + } + + private static func isSystemKeyboardTransition( + from previousMode: GhosttyKeyboardChromeMode, + to nextMode: GhosttyKeyboardChromeMode + ) -> Bool { + (previousMode == .hidden && nextMode == .system) + || (previousMode == .system && nextMode == .hidden) + } + + private static func transitionTarget( + for keyboardMode: GhosttyKeyboardChromeMode + ) -> GhosttyKeyboardViewportTransitionTarget { + switch keyboardMode { + case .system: + return .shown + case .hidden: + return .hidden + } + } + + private static func fallbackDelay( + for keyboardMode: GhosttyKeyboardChromeMode + ) -> TimeInterval { + switch keyboardMode { + case .system: + return GhosttyKeyboardViewportTransitionTiming.systemPresentationFallbackDelay + case .hidden: + return GhosttyKeyboardViewportTransitionTiming.defaultFallbackDelay + } + } +} + +struct GhosttyKeyboardViewportTransitionCoordinator: Equatable { + private(set) var isAwaitingSystemKeyboardPresentation = false + private var fallbackGate = GhosttyKeyboardViewportFallbackTokenGate() + + mutating func transitionRequest( + forToggle projection: GhosttyKeyboardToggleProjection + ) -> GhosttyKeyboardViewportTransitionRequest? { + guard let request = projection.transitionRequest else { return nil } + isAwaitingSystemKeyboardPresentation = projection.shouldAwaitSystemKeyboardPresentation + return request + } + + mutating func performKeyboardToggleTransition( + projection: GhosttyKeyboardToggleProjection, + beginTransition: (GhosttyKeyboardViewportTransitionRequest) -> Void, + applyKeyboardToggle: () -> GhosttyKeyboardChromeMode, + completeTransition: () -> Void + ) { + if let request = transitionRequest(forToggle: projection) { + beginTransition(request) + } + + let resultingMode = applyKeyboardToggle() + if projection.startsSystemKeyboardTransition, + resultingMode != projection.expectedMode { + completeTransition() + } + } + + mutating func observeKeyboardVisibility(isVisible: Bool) { + guard isVisible else { return } + isAwaitingSystemKeyboardPresentation = false + } + + mutating func clearAwaitingSystemKeyboardPresentation() { + isAwaitingSystemKeyboardPresentation = false + } + + mutating func prepareUnexpectedHideRecovery() -> GhosttyKeyboardViewportTransitionRequest { + isAwaitingSystemKeyboardPresentation = true + return GhosttyKeyboardViewportTransitionRequest( + target: .shown, + allowsTargetOverride: true, + fallbackDelay: GhosttyKeyboardViewportTransitionTiming.systemPresentationFallbackDelay + ) + } + + mutating func beginTransition( + _ request: GhosttyKeyboardViewportTransitionRequest, + viewportCoordinator: inout GhosttyTerminalViewportCoordinator, + liveSize: CGSize + ) -> GhosttyKeyboardViewportTransitionBeginResult { + let didStart = viewportCoordinator.beginKeyboardTransition( + target: request.target, + allowsTargetOverride: request.allowsTargetOverride, + liveSize: liveSize + ) + return GhosttyKeyboardViewportTransitionBeginResult( + didStart: didStart, + fallbackToken: fallbackGate.issueToken(), + fallbackDelay: request.fallbackDelay + ) + } + + mutating func completeTransition( + viewportCoordinator: inout GhosttyTerminalViewportCoordinator, + liveSize: CGSize + ) -> GhosttyKeyboardViewportTransitionCompletionResult? { + guard viewportCoordinator.isKeyboardTransitionActive else { return nil } + + fallbackGate.invalidate() + isAwaitingSystemKeyboardPresentation = false + let target = viewportCoordinator.keyboardTransitionTarget + viewportCoordinator.completeKeyboardTransition(liveSize: liveSize) + return GhosttyKeyboardViewportTransitionCompletionResult(target: target) + } + + mutating func completeTransitionFromFallback( + token: UInt64, + viewportCoordinator: inout GhosttyTerminalViewportCoordinator, + liveSize: CGSize + ) -> GhosttyKeyboardViewportTransitionCompletionResult? { + guard fallbackGate.accepts(token) else { return nil } + guard viewportCoordinator.isKeyboardTransitionActive else { return nil } + return completeTransition( + viewportCoordinator: &viewportCoordinator, + liveSize: liveSize + ) + } +} + +struct GhosttyKeyboardViewportFallbackTokenGate: Equatable { + private var currentToken: UInt64 = 0 + + mutating func issueToken() -> UInt64 { + currentToken += 1 + return currentToken + } + + mutating func invalidate() { + currentToken += 1 + } + + func accepts(_ token: UInt64) -> Bool { + currentToken == token + } +} + +enum GhosttyKeyboardViewportCompletionAction: Equatable { + case complete + case ignoreTargetMismatch + case ignorePolicy + case recoverUnexpectedHide +} + +struct GhosttyKeyboardViewportCompletionProjection: Equatable { + let eventTarget: GhosttyKeyboardViewportTransitionTarget + let activeTransitionTarget: GhosttyKeyboardViewportTransitionTarget? + let action: GhosttyKeyboardViewportCompletionAction + + init( + eventTarget: GhosttyKeyboardViewportTransitionTarget, + activeTransitionTarget: GhosttyKeyboardViewportTransitionTarget?, + keyboardMode: GhosttyKeyboardChromeMode, + isDismissSystemKeyboardRequested: Bool, + isInputAvailable: Bool, + isSelectionSheetPresented: Bool, + isTransientInputOwnerPresented: Bool = false, + isAwaitingSystemKeyboardPresentation: Bool, + isSceneActive: Bool + ) { + self.eventTarget = eventTarget + self.activeTransitionTarget = activeTransitionTarget + + if eventTarget == .hidden, + !GhosttyKeyboardViewportTransitionPolicy.shouldBeginVisibilityTransition( + notificationTarget: .hidden, + keyboardMode: keyboardMode, + isDismissSystemKeyboardRequested: isDismissSystemKeyboardRequested + ) { + self.action = GhosttyKeyboardViewportTransitionPolicy + .shouldRecoverSystemKeyboardAfterIgnoredHide( + keyboardMode: keyboardMode, + isDismissSystemKeyboardRequested: isDismissSystemKeyboardRequested, + isInputAvailable: isInputAvailable, + isSelectionSheetPresented: isSelectionSheetPresented, + isTransientInputOwnerPresented: isTransientInputOwnerPresented, + isAwaitingSystemKeyboardPresentation: isAwaitingSystemKeyboardPresentation, + isSceneActive: isSceneActive + ) + ? .recoverUnexpectedHide + : .ignorePolicy + return + } + + self.action = Self.matches( + activeTransitionTarget, + eventTarget: eventTarget + ) + ? .complete + : .ignoreTargetMismatch + } + + private static func matches( + _ activeTransitionTarget: GhosttyKeyboardViewportTransitionTarget?, + eventTarget: GhosttyKeyboardViewportTransitionTarget + ) -> Bool { + activeTransitionTarget == nil || activeTransitionTarget == eventTarget + } +} + +enum GhosttyKeyboardViewportTransitionPolicy { + static func shouldBeginVisibilityTransition( + notificationTarget: GhosttyKeyboardViewportTransitionTarget, + keyboardMode: GhosttyKeyboardChromeMode, + isDismissSystemKeyboardRequested: Bool + ) -> Bool { + switch notificationTarget { + case .shown: + return keyboardMode == .system + + case .hidden: + guard !(keyboardMode == .system && !isDismissSystemKeyboardRequested) else { + return false + } + return true + } + } + + static func shouldRecoverSystemKeyboardAfterIgnoredHide( + keyboardMode: GhosttyKeyboardChromeMode, + isDismissSystemKeyboardRequested: Bool, + isInputAvailable: Bool, + isSelectionSheetPresented: Bool, + isTransientInputOwnerPresented: Bool = false, + isAwaitingSystemKeyboardPresentation: Bool, + isSceneActive: Bool + ) -> Bool { + keyboardMode == .system + && !isDismissSystemKeyboardRequested + && isInputAvailable + && !isSelectionSheetPresented + && !isTransientInputOwnerPresented + && !isAwaitingSystemKeyboardPresentation + && isSceneActive + } +} + +enum GhosttyKeyboardViewportTransitionTiming { + static let defaultAnimationDuration: TimeInterval = 0.35 + static let fallbackGraceInterval: TimeInterval = 0.02 + static let minimumFallbackDelay: TimeInterval = 0.25 + static let maximumFallbackDelay: TimeInterval = 1.0 + static let defaultFallbackDelay: TimeInterval = 1.0 + static let systemPresentationFallbackDelay: TimeInterval = 2.0 +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift new file mode 100644 index 00000000..ed3111d1 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift @@ -0,0 +1,170 @@ +import SwiftUI + +struct GhosttyKeypadSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + + let isControlArmed: Bool + let isAltArmed: Bool + let onAddImage: (() -> Void)? + let actions: GhosttyKeyboardChromeActions + + private let compactColumns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 4) + private let editColumns = [GridItem(.adaptive(minimum: 96), spacing: 8)] + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + modifierRow + if let onAddImage { + Button(action: onAddImage) { + Label(String(localized: "Add image"), systemImage: "photo.on.rectangle") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(KeypadButtonStyle(active: false, accent: chromeStyle.accent)) + .accessibilityIdentifier("terminal.keypad.add-image") + } + keySection(String(localized: "Essential"), items: essentialKeys, columns: compactColumns) + keySection(String(localized: "Process"), items: processKeys, columns: compactColumns) + keySection(String(localized: "Edit line"), items: editingKeys, columns: editColumns) + keySection(String(localized: "Symbols"), items: symbolKeys, columns: compactColumns) + } + .padding(16) + } + .navigationTitle(String(localized: "Keypad")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "Done")) { dismiss() } + } + } + } + } + + private var modifierRow: some View { + HStack(spacing: 10) { + modifierButton("Ctrl", active: isControlArmed, action: .control) + modifierButton("Alt", active: isAltArmed, action: .alt) + } + } + + private func modifierButton(_ title: String, active: Bool, action: GhosttyKeyboardChromeActions.Action) -> some View { + Button { _ = actions.perform(action) } label: { + VStack(spacing: 2) { + Text(title).font(.headline) + Text(active ? String(localized: "One shot armed") : String(localized: "One shot")) + .font(.caption2) + .foregroundStyle(active ? chromeStyle.accent : .secondary) + } + .frame(maxWidth: .infinity, minHeight: 48) + } + .buttonStyle(KeypadButtonStyle(active: active, accent: chromeStyle.accent)) + } + + private func keySection( + _ title: String, + items: [KeypadItem], + columns: [GridItem] + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.caption.weight(.semibold)).foregroundStyle(.secondary) + LazyVGrid(columns: columns, spacing: 8) { + ForEach(items) { item in + Button { _ = actions.perform(item.action) } label: { + VStack(spacing: 2) { + Text(item.title).font(.subheadline.weight(.semibold)).lineLimit(1) + if let detail = item.detail { + Text(detail).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + } + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(KeypadButtonStyle(active: false, accent: chromeStyle.accent)) + .accessibilityLabel(item.accessibilityLabel) + } + } + } + } + + private var essentialKeys: [KeypadItem] { + [ + .init("Esc", .escape), .init("Tab", .tab), .init("Shift-Tab", .shiftTab), + .init("←", .arrowLeft, label: String(localized: "Left arrow")), + .init("↑", .arrowUp, label: String(localized: "Up arrow")), + .init("↓", .arrowDown, label: String(localized: "Down arrow")), + .init("→", .arrowRight, label: String(localized: "Right arrow")), + .init("Home", .home), .init("End", .end), + .init("PgUp", .pageUp, label: String(localized: "Page Up")), + .init("PgDn", .pageDown, label: String(localized: "Page Down")), + ] + } + + private var processKeys: [KeypadItem] { + [ + .init("Ctrl-C", .ctrlC, detail: String(localized: "Interrupt")), + .init("Ctrl-D", .ctrlD, detail: String(localized: "EOF")), + .init("Ctrl-Z", .ctrlZ, detail: String(localized: "Suspend")), + .init("Ctrl-L", .ctrlL, detail: String(localized: "Clear")), + .init("Ctrl-R", .ctrlR, detail: String(localized: "History")), + ] + } + + private var editingKeys: [KeypadItem] { + [ + .init("Ctrl-A", .ctrlA, detail: String(localized: "Line start")), + .init("Ctrl-E", .ctrlE, detail: String(localized: "Line end")), + .init("Ctrl-U", .ctrlU, detail: String(localized: "Delete left")), + .init("Ctrl-K", .ctrlK, detail: String(localized: "Delete right")), + .init("Ctrl-W", .ctrlW, detail: String(localized: "Delete word")), + .init("Alt-B", .altB, detail: String(localized: "Previous word")), + .init("Alt-F", .altF, detail: String(localized: "Next word")), + ] + } + + private var symbolKeys: [KeypadItem] { + [.init("?", .questionMark), .init("/", .slash)] + } +} + +private struct KeypadItem: Identifiable { + let id: String + let title: String + let detail: String? + let action: GhosttyKeyboardChromeActions.Action + let accessibilityLabel: String + + init( + _ title: String, + _ action: GhosttyKeyboardChromeActions.Action, + detail: String? = nil, + label: String? = nil + ) { + id = title + self.title = title + self.detail = detail + self.action = action + accessibilityLabel = label ?? [title, detail].compactMap { $0 }.joined(separator: ", ") + } +} + +private struct KeypadButtonStyle: ButtonStyle { + let active: Bool + let accent: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(active ? accent : Color.primary) + .padding(.horizontal, 6) + .background( + active ? accent.opacity(0.16) : Color.primary.opacity(configuration.isPressed ? 0.11 : 0.065), + in: RoundedRectangle(cornerRadius: 11, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(active ? accent.opacity(0.8) : Color.primary.opacity(0.12), lineWidth: active ? 1.25 : 0.75) + } + .scaleEffect(configuration.isPressed ? 0.97 : 1) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKitControlSurface.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKitControlSurface.swift new file mode 100644 index 00000000..22a793d8 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKitControlSurface.swift @@ -0,0 +1,518 @@ +import CoreGraphics +import Foundation +import GhosttyKit + +struct GhosttySurfaceDisplayMetrics: Equatable { + let contentScale: Double + let pixelWidth: UInt32 + let pixelHeight: UInt32 + + init(contentScale: Double, pixelWidth: UInt32, pixelHeight: UInt32) { + self.contentScale = contentScale + self.pixelWidth = pixelWidth + self.pixelHeight = pixelHeight + } + + init(size: CGSize, scale: CGFloat) { + let rawScale = Double(scale) + let safeScale = rawScale.isFinite && rawScale > 0 ? rawScale : 1 + contentScale = safeScale + pixelWidth = Self.pixelDimension(points: size.width, scale: safeScale) + pixelHeight = Self.pixelDimension(points: size.height, scale: safeScale) + } + + private static func pixelDimension(points: CGFloat, scale: Double) -> UInt32 { + let points = Double(points) + guard points.isFinite, points > 0 else { return 1 } + let pixels = (points * scale).rounded(.toNearestOrAwayFromZero) + guard pixels.isFinite, pixels > 0 else { return 1 } + return UInt32(min(pixels, Double(UInt32.max))) + } +} + +struct GhosttySurfaceDisplayUpdateTracker { + private var lastMetrics: GhosttySurfaceDisplayMetrics? + + mutating func nextMetrics(size: CGSize, scale: CGFloat) -> GhosttySurfaceDisplayMetrics? { + let metrics = GhosttySurfaceDisplayMetrics(size: size, scale: scale) + guard metrics != lastMetrics else { return nil } + lastMetrics = metrics + return metrics + } + + mutating func reset() { + lastMetrics = nil + } +} + +struct GhosttySurfaceScrollState: Equatable { + static let empty = GhosttySurfaceScrollState(total: 0, offset: 0, len: 0, cellOffset: 0) + + let total: UInt64 + let offset: UInt64 + let len: UInt64 + let cellOffset: Double + + init(total: UInt64, offset: UInt64, len: UInt64, cellOffset: Double) { + self.total = total + self.offset = offset + self.len = len + self.cellOffset = cellOffset + } + + init(cValue: ghostty_terminal_surface_scrollbar_s) { + self.init( + total: cValue.total, + offset: cValue.offset, + len: cValue.len, + cellOffset: cValue.cell_offset + ) + } + + var maxRow: UInt64 { + total > len ? total - len : 0 + } +} + +enum GhosttySurfaceScrollRoute: Equatable { + case viewport + case altScreenCursor + case mouseReport + + init(cValue: ghostty_terminal_surface_scroll_route_e) { + switch cValue { + case GHOSTTY_TERMINAL_SURFACE_SCROLL_ROUTE_ALTERNATE_SCREEN_CURSOR: + self = .altScreenCursor + case GHOSTTY_TERMINAL_SURFACE_SCROLL_ROUTE_REMOTE_MOUSE: + self = .mouseReport + default: + self = .viewport + } + } +} + +struct GhosttySurfaceInteractionState: Equatable { + static let empty = GhosttySurfaceInteractionState( + scrollState: .empty, + scrollRoute: .viewport, + mouseCaptured: false + ) + + let scrollState: GhosttySurfaceScrollState + let scrollRoute: GhosttySurfaceScrollRoute + let mouseCaptured: Bool + + init(cValue: ghostty_terminal_surface_interaction_state_s) { + scrollState = GhosttySurfaceScrollState(cValue: cValue.scrollbar) + scrollRoute = GhosttySurfaceScrollRoute(cValue: cValue.route) + mouseCaptured = cValue.mouse_captured + } + + private init( + scrollState: GhosttySurfaceScrollState, + scrollRoute: GhosttySurfaceScrollRoute, + mouseCaptured: Bool + ) { + self.scrollState = scrollState + self.scrollRoute = scrollRoute + self.mouseCaptured = mouseCaptured + } +} + +struct GhosttyLocalSelectionSnapshot: Equatable { + static let inactive = GhosttyLocalSelectionSnapshot( + cValue: ghostty_terminal_surface_selection_snapshot_s(), + scaleFactor: 1 + ) + + let start: CGRect? + let end: CGRect? + let isActive: Bool + + init(cValue: ghostty_terminal_surface_selection_snapshot_s, scaleFactor: Double) { + start = Self.rect(cValue.start, scaleFactor: scaleFactor) + end = Self.rect(cValue.end, scaleFactor: scaleFactor) + isActive = cValue.active + } + + private static func rect( + _ value: ghostty_terminal_surface_selection_rect_s, + scaleFactor: Double + ) -> CGRect? { + guard value.visible else { return nil } + return CGRect( + x: CGFloat(value.x_px / scaleFactor), + y: CGFloat(value.y_px / scaleFactor), + width: CGFloat(Double(value.width_px) / scaleFactor), + height: CGFloat(Double(value.height_px) / scaleFactor) + ) + } +} + +enum GhosttyLocalSelectionOutcome: Equatable { + case snapshot(GhosttyLocalSelectionSnapshot) + case unavailable +} + +enum GhosttyLocalLinkSelectionOutcome: Equatable { + case match( + snapshot: GhosttyLocalSelectionSnapshot, + explicitTarget: String? + ) + case noMatch(snapshot: GhosttyLocalSelectionSnapshot) + case unavailable +} + +extension TmuxControlViewport { + init?(ghosttySurfaceSize size: ghostty_surface_size_s) { + guard size.columns > 0, size.rows > 0 else { return nil } + self.init( + columns: size.columns, + rows: size.rows, + pixelWidth: size.width_px, + pixelHeight: size.height_px + ) + } +} + +/// Lean MainActor wrapper over one externally owned terminal surface. +/// Native lifetime fencing is owned by TmuxSessionController/TmuxPaneSurface; +/// this wrapper adds no lock, ownership mode, or alternate backend. +@MainActor +final class GhosttyKitControlSurface { + enum Failure: Equatable { + case native(ghostty_terminal_surface_result_e) + case scaleChanged + } + + let handle: ghostty_terminal_surface_t + + private let scaleFactor: Double + private let onFailure: (Failure) -> Void + private var invalidated = false + private var failureReported = false + + init( + surface: ghostty_terminal_surface_t, + scaleFactor: Double, + onFailure: @escaping (Failure) -> Void + ) { + handle = surface + self.scaleFactor = scaleFactor + self.onFailure = onFailure + } + + var isInvalidated: Bool { invalidated } + + func invalidate() { + invalidated = true + } + + @discardableResult + func sendInput(_ text: String) -> Bool { + guard !invalidated else { return false } + guard !text.isEmpty else { return true } + return withUTF8(text) { pointer, count in + Self.accepted(ghostty_terminal_surface_input(handle, pointer, count)) + } + } + + @discardableResult + func sendPaste(_ text: String) -> Bool { + guard !invalidated else { return false } + guard !text.isEmpty else { return true } + return withUTF8(text) { pointer, count in + Self.accepted(ghostty_terminal_surface_paste(handle, pointer, count)) + } + } + + @discardableResult + func sendKeyEvent(_ event: GhosttySurfaceKeyEvent) -> Bool { + guard !invalidated else { return false } + return event.withCValue { + Self.accepted(ghostty_terminal_surface_key(handle, $0)) + } + } + + func keyTranslationMods(_ mods: GhosttySurfaceKeyEvent.Mods) -> GhosttySurfaceKeyEvent.Mods { + guard !invalidated else { return mods } + let filtered = ghostty_terminal_surface_key_translation_mods( + handle, + ghostty_input_mods_e(mods.rawValue) + ) + return GhosttySurfaceKeyEvent.Mods(rawValue: UInt32(filtered.rawValue)) + } + + @discardableResult + func sendMouseButton(_ event: GhosttySurfaceMouseButtonEvent) -> Bool { + guard !invalidated else { return false } + return event.withCValues { + Self.accepted(ghostty_terminal_surface_mouse_button(handle, $0, $1, $2)) + } + } + + @discardableResult + func sendMousePosition( + _ position: CGPoint, + mods: GhosttySurfaceKeyEvent.Mods = [] + ) -> Bool { + guard !invalidated else { return false } + return Self.accepted(ghostty_terminal_surface_mouse_pos( + handle, + Double(position.x) * scaleFactor, + Double(position.y) * scaleFactor, + ghostty_input_mods_e(mods.rawValue) + )) + } + + @discardableResult + func sendMouseScroll(_ event: GhosttySurfaceMouseScrollEvent) -> Bool { + guard !invalidated else { return false } + return Self.accepted(ghostty_terminal_surface_mouse_scroll( + handle, + event.deltaX, + event.deltaY, + ghostty_input_scroll_mods_t(event.mods.rawValue) + )) + } + + func interactionState() -> GhosttySurfaceInteractionState { + guard !invalidated else { return .empty } + var state = ghostty_terminal_surface_interaction_state_s() + let result = ghostty_terminal_surface_interaction_state(handle, &state) + guard report(result) else { return .empty } + return GhosttySurfaceInteractionState(cValue: state) + } + + func scrollToPosition(row: UInt64, cellOffset: Double) -> GhosttySurfaceScrollState { + guard !invalidated else { return .empty } + var state = ghostty_terminal_surface_interaction_state_s() + let result = ghostty_terminal_surface_scroll_to_position( + handle, + row, + cellOffset, + &state + ) + _ = report(result) + return GhosttySurfaceScrollState(cValue: state.scrollbar) + } + + func isMouseCaptured() -> Bool { + interactionState().mouseCaptured + } + + func selectionSnapshot() -> GhosttyLocalSelectionOutcome { + guard !invalidated else { return .unavailable } + var snapshot = ghostty_terminal_surface_selection_snapshot_s() + let result = ghostty_terminal_surface_selection_snapshot(handle, &snapshot) + return selectionOutcome(result, snapshot: snapshot, retryCommittedWake: false) + } + + func selectWord(at point: CGPoint) -> GhosttyLocalSelectionOutcome { + mutateSelection { snapshot in + ghostty_terminal_surface_select_word( + handle, + Double(point.x) * scaleFactor, + Double(point.y) * scaleFactor, + snapshot + ) + } + } + + func selectLink(at point: CGPoint) -> GhosttyLocalLinkSelectionOutcome { + guard !invalidated else { return .unavailable } + + var snapshotValue = ghostty_terminal_surface_selection_snapshot_s() + var matched = false + var target = ghostty_text_s() + let result = ghostty_terminal_surface_select_link( + handle, + Double(point.x) * scaleFactor, + Double(point.y) * scaleFactor, + &snapshotValue, + &matched, + &target + ) + defer { + if target.text != nil { + let freeResult = ghostty_terminal_surface_free_text(handle, &target) + assert(freeResult == GHOSTTY_TERMINAL_SURFACE_INPUT_CONSUMED_NO_OUTPUT) + } + } + + let snapshot = GhosttyLocalSelectionSnapshot( + cValue: snapshotValue, + scaleFactor: scaleFactor + ) + switch result { + case GHOSTTY_TERMINAL_SURFACE_RESULT_OK where matched: + let explicitTarget = target.text == nil + ? nil + : Self.decodeGhosttyText(target) + return .match(snapshot: snapshot, explicitTarget: explicitTarget) + case GHOSTTY_TERMINAL_SURFACE_RESULT_OK: + return .noMatch(snapshot: snapshot) + case GHOSTTY_TERMINAL_SURFACE_RESULT_INVALID_INPUT, + GHOSTTY_TERMINAL_SURFACE_RESULT_OUT_OF_MEMORY: + return .unavailable + case GHOSTTY_TERMINAL_SURFACE_RESULT_FAILED: + let retry = ghostty_terminal_surface_terminal_changed(handle) + guard retry == GHOSTTY_TERMINAL_SURFACE_RESULT_OK else { + fail(.native(retry)) + return .unavailable + } + return .unavailable + default: + fail(.native(result)) + return .unavailable + } + } + + func setSelectionEndpoint( + _ endpoint: ghostty_terminal_surface_selection_endpoint_e, + at point: CGPoint + ) -> GhosttyLocalSelectionOutcome { + mutateSelection { snapshot in + ghostty_terminal_surface_set_selection_endpoint( + handle, + endpoint, + Double(point.x) * scaleFactor, + Double(point.y) * scaleFactor, + snapshot + ) + } + } + + func clearSelection() -> GhosttyLocalSelectionOutcome { + mutateSelection { snapshot in + ghostty_terminal_surface_clear_selection(handle, snapshot) + } + } + + func readSelection() -> String? { + guard !invalidated else { return nil } + var text = ghostty_text_s() + let result = ghostty_terminal_surface_read_selection(handle, &text) + guard result == GHOSTTY_TERMINAL_SURFACE_INPUT_SENT else { return nil } + defer { + let freeResult = ghostty_terminal_surface_free_text(handle, &text) + assert(freeResult == GHOSTTY_TERMINAL_SURFACE_INPUT_CONSUMED_NO_OUTPUT) + } + return Self.decodeGhosttyText(text) + } + + @discardableResult + func updateDisplay(metrics: GhosttySurfaceDisplayMetrics) -> Bool { + guard !invalidated else { return false } + guard metrics.contentScale == scaleFactor else { + fail(.scaleChanged) + return false + } + return report(ghostty_terminal_surface_set_size( + handle, + metrics.pixelWidth, + metrics.pixelHeight + )) + } + + @discardableResult + func setFocused(_ focused: Bool) -> Bool { + guard !invalidated else { return false } + return report(ghostty_terminal_surface_set_focused(handle, focused)) + } + + @discardableResult + func setVisible(_ visible: Bool) -> Bool { + guard !invalidated else { return false } + return report(ghostty_terminal_surface_set_visible(handle, visible)) + } + + func currentSize() -> ghostty_surface_size_s { + guard !invalidated else { return ghostty_surface_size_s() } + var size = ghostty_surface_size_s() + let result = ghostty_terminal_surface_size(handle, &size) + guard report(result) else { return ghostty_surface_size_s() } + return size + } + + static func decodeGhosttyText(_ text: ghostty_text_s) -> String { + guard let pointer = text.text, text.text_len > 0 else { return "" } + return String( + decoding: UnsafeRawBufferPointer(start: pointer, count: Int(text.text_len)), + as: UTF8.self + ) + } + + private func mutateSelection( + _ operation: (UnsafeMutablePointer) + -> ghostty_terminal_surface_result_e + ) -> GhosttyLocalSelectionOutcome { + guard !invalidated else { return .unavailable } + var snapshot = ghostty_terminal_surface_selection_snapshot_s() + let result = operation(&snapshot) + return selectionOutcome(result, snapshot: snapshot, retryCommittedWake: true) + } + + private func selectionOutcome( + _ result: ghostty_terminal_surface_result_e, + snapshot value: ghostty_terminal_surface_selection_snapshot_s, + retryCommittedWake: Bool + ) -> GhosttyLocalSelectionOutcome { + let snapshot = GhosttyLocalSelectionSnapshot( + cValue: value, + scaleFactor: scaleFactor + ) + switch result { + case GHOSTTY_TERMINAL_SURFACE_RESULT_OK, + GHOSTTY_TERMINAL_SURFACE_RESULT_INVALID_INPUT: + return .snapshot(snapshot) + case GHOSTTY_TERMINAL_SURFACE_RESULT_OUT_OF_MEMORY: + return .unavailable + case GHOSTTY_TERMINAL_SURFACE_RESULT_FAILED where retryCommittedWake: + let retry = ghostty_terminal_surface_terminal_changed(handle) + guard retry == GHOSTTY_TERMINAL_SURFACE_RESULT_OK else { + fail(.native(retry)) + return .unavailable + } + return .snapshot(snapshot) + default: + fail(.native(result)) + return .unavailable + } + } + + private func withUTF8( + _ text: String, + body: (UnsafePointer?, Int) -> Result + ) -> Result { + if let result = text.utf8.withContiguousStorageIfAvailable({ buffer in + body(buffer.baseAddress, buffer.count) + }) { + return result + } + return Array(text.utf8).withUnsafeBufferPointer { + body($0.baseAddress, $0.count) + } + } + + @discardableResult + private func report(_ result: ghostty_terminal_surface_result_e) -> Bool { + guard result == GHOSTTY_TERMINAL_SURFACE_RESULT_OK else { + fail(.native(result)) + return false + } + return true + } + + private func fail(_ failure: Failure) { + guard !failureReported else { return } + failureReported = true + invalidated = true + onFailure(failure) + } + + private static func accepted(_ result: ghostty_terminal_surface_input_result_e) -> Bool { + result == GHOSTTY_TERMINAL_SURFACE_INPUT_SENT + || result == GHOSTTY_TERMINAL_SURFACE_INPUT_CONSUMED_NO_OUTPUT + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKitRuntime.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKitRuntime.swift new file mode 100644 index 00000000..75f7021e --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKitRuntime.swift @@ -0,0 +1,370 @@ +import Darwin +import Foundation +import GhosttyKit +import QuartzCore +import UIKit + +enum GhosttyKitRuntimeError: Error, Equatable { + case initializationFailed(Int32) + case processDirectoryConfigurationFailed(String) + case environmentConfigurationFailed(String) + case runtimeConfigurationFileFailed(String) + case configCreationFailed + case appCreationFailed + case surfaceMeasurementFailed(ghostty_terminal_surface_result_e) +} + +enum GhosttyTerminalDeviceClass { + case phone + case pad +} + +enum GhosttyTerminalAppearancePolicy { + static let phoneMinimumFontSize: Float32 = 10 + static let phoneDefaultFontSize: Float32 = 10 + + static func effectiveFontSize( + for settings: TerminalSettings, + deviceClass: GhosttyTerminalDeviceClass, + contentSizeCategory: UIContentSizeCategory = .large + ) -> Float32? { + if let fontSize = settings.fontSize { + return fontSize + } + return effectiveFontSize(for: deviceClass, contentSizeCategory: contentSizeCategory) + } + + static func effectiveFontSize( + for deviceClass: GhosttyTerminalDeviceClass, + contentSizeCategory: UIContentSizeCategory = .large + ) -> Float32? { + switch deviceClass { + case .phone: + return phoneFontSize(contentSizeCategory: contentSizeCategory) + case .pad: + return nil + } + } + + @MainActor + static func currentDeviceFontSize( + settings: TerminalSettings = .default + ) -> Float32? { + let category = UIApplication.shared.preferredContentSizeCategory + switch UIDevice.current.userInterfaceIdiom { + case .phone: + return effectiveFontSize( + for: settings, + deviceClass: .phone, + contentSizeCategory: category + ) + case .pad: + return effectiveFontSize( + for: settings, + deviceClass: .pad, + contentSizeCategory: category + ) + default: + return settings.fontSize + } + } + + private static func phoneFontSize(contentSizeCategory: UIContentSizeCategory) -> Float32 { + let traits = UITraitCollection(preferredContentSizeCategory: contentSizeCategory) + let scaled = UIFontMetrics(forTextStyle: .body).scaledValue( + for: CGFloat(phoneDefaultFontSize), + compatibleWith: traits + ) + return Float32(max(scaled, CGFloat(phoneMinimumFontSize))) + } +} + +private struct GhosttyTerminalRendererWarmupKey: Hashable { + let theme: String + let fontSize: Float32? + let screenScale: Int + let contentSizeCategory: String + + init( + terminalSettings: TerminalSettings, + screenScale: CGFloat, + contentSizeCategory: UIContentSizeCategory + ) { + theme = terminalSettings.theme.rawValue + fontSize = terminalSettings.fontSize + self.screenScale = Int((screenScale * 1000).rounded()) + self.contentSizeCategory = contentSizeCategory.rawValue + } +} + +final class GhosttyKitSurfaceView: UIView { + override init(frame: CGRect) { + super.init(frame: frame.isEmpty ? CGRect(x: 0, y: 0, width: 1, height: 1) : frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + override class var layerClass: AnyClass { CAMetalLayer.self } + + override func layoutSubviews() { + super.layoutSubviews() + alignGhosttyRendererSublayers() + } + + func alignGhosttyRendererSublayers() { + let scale = max(window?.screen.scale ?? contentScaleFactor, 1) + layer.contentsScale = scale + for sublayer in layer.sublayers ?? [] { + sublayer.frame = bounds + sublayer.contentsScale = scale + } + } + + func applyTerminalTheme(_ theme: TerminalTheme) { + backgroundColor = theme.terminalBackgroundUIColor + } + + private func configure() { + applyTerminalTheme(.ghosttyDefault) + clipsToBounds = true + isOpaque = true + contentScaleFactor = max(UIScreen.main.scale, 1) + } +} + +extension TerminalTheme { + var terminalBackgroundUIColor: UIColor { + let hex = terminalBackgroundHex + return UIColor( + red: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1 + ) + } +} + +@MainActor +final class GhosttyKitRuntime { + private static var initialized = false + private static var terminalRendererWarmupKeys = Set() + + private let state: GhosttyKitRuntimeState + + init(terminalSettings: TerminalSettings = .default) throws { + try Self.initializeBackend() + state = try GhosttyKitRuntimeState(terminalSettings: terminalSettings) + } + + var appHandle: ghostty_app_t { state.app } + + func makeTmuxBaseSurfaceConfig() -> ghostty_terminal_surface_config_s { + ghostty_terminal_surface_config_new() + } + + static func prewarmTerminalRenderer(terminalSettings: TerminalSettings) { + let key = GhosttyTerminalRendererWarmupKey( + terminalSettings: terminalSettings, + screenScale: UIScreen.main.scale, + contentSizeCategory: UIApplication.shared.preferredContentSizeCategory + ) + guard terminalRendererWarmupKeys.insert(key).inserted else { return } + + do { + let runtime = try GhosttyKitRuntime(terminalSettings: terminalSettings) + _ = try runtime.measureTmuxViewport( + size: CGSize(width: 96, height: 96), + scale: UIScreen.main.scale + ) + } catch { + terminalRendererWarmupKeys.remove(key) + GhosttyRuntimeTrace.diagnostics( + "runtime.prewarmTerminalRenderer failed error=\(String(describing: error))" + ) + } + } + + func measureTmuxViewport(size: CGSize, scale: CGFloat) throws -> TmuxControlViewport? { + let size = GhosttyTerminalViewportCoordinator.normalized(size) + guard size.width > 1, size.height > 1 else { return nil } + + let metrics = GhosttySurfaceDisplayMetrics(size: size, scale: scale) + var config = makeTmuxBaseSurfaceConfig() + config.scale_factor = metrics.contentScale + config.width_px = metrics.pixelWidth + config.height_px = metrics.pixelHeight + var measured = ghostty_surface_size_s() + let result = ghostty_terminal_surface_measure(state.app, &config, &measured) + guard result == GHOSTTY_TERMINAL_SURFACE_RESULT_OK else { + throw GhosttyKitRuntimeError.surfaceMeasurementFailed(result) + } + return TmuxControlViewport(ghosttySurfaceSize: measured) + } + + #if DEBUG + var appHandleForTesting: ghostty_app_t { state.app } + #endif + + func applyTerminalSettings(_ settings: TerminalSettings) throws { + try state.applyTerminalSettings(settings) + } + + private static func initializeBackend() throws { + guard !initialized else { return } + try configureProcessDirectories() + let result = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) + guard result == GHOSTTY_SUCCESS else { + throw GhosttyKitRuntimeError.initializationFailed(result) + } + initialized = true + } + + private static func configureProcessDirectories() throws { + let home = NSHomeDirectory() + let applicationSupport = "\(home)/Library/Application Support" + let caches = "\(home)/Library/Caches" + try createDirectoryIfNeeded(at: applicationSupport) + try createDirectoryIfNeeded(at: caches) + try setEnvironment("HOME", to: home) + try setEnvironment("XDG_CONFIG_HOME", to: applicationSupport) + try setEnvironment("XDG_CACHE_HOME", to: caches) + try setEnvironment("XDG_STATE_HOME", to: applicationSupport) + } + + private static func createDirectoryIfNeeded(at path: String) throws { + do { + try FileManager.default.createDirectory( + atPath: path, + withIntermediateDirectories: true + ) + } catch { + throw GhosttyKitRuntimeError.processDirectoryConfigurationFailed(path) + } + } + + private static func setEnvironment(_ name: String, to value: String) throws { + guard getenv(name) == nil else { return } + let result = name.withCString { namePointer in + value.withCString { valuePointer in + setenv(namePointer, valuePointer, 1) + } + } + guard result == 0 else { + throw GhosttyKitRuntimeError.environmentConfigurationFailed(name) + } + } +} + +private final class GhosttyKitRuntimeState { + let app: ghostty_app_t + private(set) var terminalSettings: TerminalSettings + + private let config: ghostty_config_t + private let callbacks: GhosttyKitRuntimeCallbacks + + @MainActor + init(terminalSettings: TerminalSettings) throws { + guard let config = ghostty_config_new() else { + throw GhosttyKitRuntimeError.configCreationFailed + } + try Self.loadSettings( + terminalSettings, + into: config, + effectiveFontSize: GhosttyTerminalAppearancePolicy + .currentDeviceFontSize(settings: terminalSettings) + ) + ghostty_config_finalize(config) + + let callbacks = GhosttyKitRuntimeCallbacks() + var runtimeConfig = ghostty_runtime_config_s( + userdata: callbacks.userdata, + supports_selection_clipboard: false, + wakeup_cb: GhosttyKitRuntimeCallbacks.wakeupCallback, + action_cb: GhosttyKitRuntimeCallbacks.actionCallback, + read_clipboard_cb: nil, + confirm_read_clipboard_cb: nil, + write_clipboard_cb: nil, + close_surface_cb: nil + ) + guard let app = ghostty_app_new(&runtimeConfig, config) else { + ghostty_config_free(config) + throw GhosttyKitRuntimeError.appCreationFailed + } + + self.app = app + self.config = config + self.callbacks = callbacks + self.terminalSettings = terminalSettings + callbacks.app = app + } + + deinit { + callbacks.app = nil + ghostty_app_free(app) + ghostty_config_free(config) + _ = callbacks + } + + @MainActor + func applyTerminalSettings(_ settings: TerminalSettings) throws { + guard settings != terminalSettings else { return } + guard let replacement = ghostty_config_new() else { + throw GhosttyKitRuntimeError.configCreationFailed + } + defer { ghostty_config_free(replacement) } + + try Self.loadSettings( + settings, + into: replacement, + effectiveFontSize: GhosttyTerminalAppearancePolicy + .currentDeviceFontSize(settings: settings) + ) + ghostty_config_finalize(replacement) + ghostty_app_update_config(app, replacement) + terminalSettings = settings + } + + private static func loadSettings( + _ settings: TerminalSettings, + into config: ghostty_config_t, + effectiveFontSize: Float32? + ) throws { + guard let contents = settings.ghosttyConfigContents( + effectiveFontSize: effectiveFontSize + ) else { return } + + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("remux-ghostty-\(UUID().uuidString).conf") + do { + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + throw GhosttyKitRuntimeError.runtimeConfigurationFileFailed(fileURL.path) + } + defer { try? FileManager.default.removeItem(at: fileURL) } + fileURL.path.withCString { ghostty_config_load_file(config, $0) } + } +} + +private final class GhosttyKitRuntimeCallbacks: @unchecked Sendable { + var app: ghostty_app_t? + + var userdata: UnsafeMutableRawPointer { + Unmanaged.passUnretained(self).toOpaque() + } + + static let wakeupCallback: ghostty_runtime_wakeup_cb = { userdata in + guard let userdata else { return } + let callbacks = Unmanaged + .fromOpaque(userdata).takeUnretainedValue() + Task { @MainActor in + guard let app = callbacks.app else { return } + ghostty_app_tick(app) + } + } + + static let actionCallback: ghostty_runtime_action_cb = { _, _, _ in true } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift new file mode 100644 index 00000000..d25a10a3 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift @@ -0,0 +1,191 @@ +import Foundation +import GhosttyKit +import UIKit + +enum GhosttyRuntimeSelectionDirection { + case previous + case next + + func advancedIndex(from index: Int, count: Int) -> Int { + precondition(count > 0) + return switch self { + case .previous: (index - 1 + count) % count + case .next: (index + 1) % count + } + } +} + +func ghosttyDiagnosticShortID(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(8)) +} + +func ghosttyDiagnosticPointer(_ pointer: UnsafeMutableRawPointer?) -> String { + guard let pointer else { return "nil" } + return String(format: "0x%llx", UInt64(UInt(bitPattern: pointer))) +} + +func ghosttyDiagnosticRect(_ rect: CGRect) -> String { + String( + format: "%.1fx%.1f@%.1f,%.1f", + rect.width, + rect.height, + rect.minX, + rect.minY + ) +} + +func ghosttyDiagnosticSurfaceSize(_ size: ghostty_surface_size_s) -> String { + "\(size.columns)x\(size.rows) cells \(size.width_px)x\(size.height_px)px cell=\(size.cell_width_px)x\(size.cell_height_px)" +} + +/// Stable screen-facing projection of one concrete tmux pane surface. +/// The pane surface owns native lifetime and display updates; this type contains +/// only UIKit presentation state and direct terminal interaction forwarding. +@MainActor +final class GhosttyManagedSurface { + let id: UUID + let view: GhosttyKitSurfaceView + private(set) var controlSurface: GhosttyKitControlSurface + + private weak var paneOwner: TmuxPaneSurface? + private(set) var isFocused = false + private(set) var isVisible = false + private(set) var scrollState: GhosttySurfaceScrollState + private(set) var scrollRoute: GhosttySurfaceScrollRoute + var onScrollStateChange: (() -> Void)? + var onDisplayUpdate: ((GhosttyManagedSurface, CGSize, CGFloat) -> Void)? + var onLocalSelectionGeometryChange: (() -> Void)? + + private var displayUpdateTracker = GhosttySurfaceDisplayUpdateTracker() + + init( + id: UUID, + view: GhosttyKitSurfaceView, + controlSurface: GhosttyKitControlSurface, + paneOwner: TmuxPaneSurface, + interactionState: GhosttySurfaceInteractionState + ) { + self.id = id + self.view = view + self.controlSurface = controlSurface + self.paneOwner = paneOwner + scrollState = interactionState.scrollState + scrollRoute = interactionState.scrollRoute + } + + func applyTerminalTheme(_ theme: TerminalTheme) { + view.applyTerminalTheme(theme) + } + + @discardableResult + func sendInput(_ text: String) -> FocusedTerminalInputSubmissionResult { + guard !text.isEmpty else { return .empty } + guard controlSurface.sendInput(text) else { return .surfaceRejected } + onLocalSelectionGeometryChange?() + return .accepted + } + + @discardableResult + func sendPaste(_ text: String) -> FocusedTerminalInputSubmissionResult { + guard !text.isEmpty else { return .empty } + return controlSurface.sendPaste(text) ? .accepted : .surfaceRejected + } + + @discardableResult + func sendKeyEvent(_ event: GhosttySurfaceKeyEvent) -> FocusedTerminalInputSubmissionResult { + guard controlSurface.sendKeyEvent(event) else { return .surfaceRejected } + onLocalSelectionGeometryChange?() + return .accepted + } + + func setVisible(_ visible: Bool) { + guard visible != isVisible else { return } + isVisible = visible + _ = controlSurface.setVisible(visible) + } + + func setFocused(_ focused: Bool) { + guard focused != isFocused else { return } + isFocused = focused + _ = controlSurface.setFocused(focused) + } + + func prepareForPermanentRemoval() { + onDisplayUpdate = nil + onScrollStateChange = nil + setFocused(false) + setVisible(false) + view.isHidden = true + view.removeFromSuperview() + onLocalSelectionGeometryChange?() + onLocalSelectionGeometryChange = nil + } + + func replaceControlSurface(_ replacement: GhosttyKitControlSurface) { + controlSurface = replacement + displayUpdateTracker.reset() + refreshInteractionState() + isFocused = false + isVisible = false + } + + @discardableResult + func updateDisplay(size: CGSize, scale: CGFloat) -> Bool { + guard let metrics = displayUpdateTracker.nextMetrics(size: size, scale: scale) else { + return false + } + guard paneOwner?.updateDisplay(metrics: metrics) == true else { return false } + onDisplayUpdate?(self, size, scale) + onLocalSelectionGeometryChange?() + return true + } + + func refreshInteractionState() { + let state = controlSurface.interactionState() + let changed = state.scrollState != scrollState || state.scrollRoute != scrollRoute + scrollState = state.scrollState + scrollRoute = state.scrollRoute + if changed { onScrollStateChange?() } + onLocalSelectionGeometryChange?() + } + + @discardableResult + func sendMouseButton(_ event: GhosttySurfaceMouseButtonEvent) -> Bool { + controlSurface.sendMouseButton(event) + } + + func sendMousePosition( + _ position: CGPoint, + mods: GhosttySurfaceKeyEvent.Mods = [] + ) { + _ = controlSurface.sendMousePosition(position, mods: mods) + } + + func sendMouseScroll(_ event: GhosttySurfaceMouseScrollEvent) { + _ = controlSurface.sendMouseScroll(event) + refreshInteractionState() + } + + @discardableResult + func scrollToPosition(row: UInt64, cellOffset: Double) -> GhosttySurfaceScrollState { + let next = controlSurface.scrollToPosition(row: row, cellOffset: cellOffset) + guard next != scrollState else { return scrollState } + scrollState = next + onScrollStateChange?() + onLocalSelectionGeometryChange?() + return scrollState + } + + func notifyLocalSelectionGeometryChanged() { + onLocalSelectionGeometryChange?() + } + + func isMouseCaptured() -> Bool { + controlSurface.isMouseCaptured() + } + + func diagnosticSummary() -> String { + "surface=\(ghosttyDiagnosticShortID(id)) handle=\(String(describing: controlSurface.handle)) visible=\(isVisible) focused=\(isFocused) view=\(ghosttyDiagnosticRect(view.frame)) bounds=\(ghosttyDiagnosticRect(view.bounds)) size=\(ghosttyDiagnosticSurfaceSize(controlSurface.currentSize())) scroll=total:\(scrollState.total) offset:\(scrollState.offset) len:\(scrollState.len) route:\(scrollRoute)" + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurfaceLookup.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurfaceLookup.swift new file mode 100644 index 00000000..be6c4131 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurfaceLookup.swift @@ -0,0 +1,16 @@ +import Foundation + +@MainActor +struct GhosttyManagedSurfaceLookup { + static let empty = GhosttyManagedSurfaceLookup { _ in nil } + + private let lookup: (UUID) -> GhosttyManagedSurface? + + init(_ lookup: @escaping (UUID) -> GhosttyManagedSurface?) { + self.lookup = lookup + } + + func managedSurface(for id: UUID) -> GhosttyManagedSurface? { + lookup(id) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift new file mode 100644 index 00000000..7d2cfabd --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift @@ -0,0 +1,82 @@ +import Foundation + +struct GhosttyModifierState: Equatable { + private(set) var controlArmed = false + private(set) var altArmed = false + + var isControlArmed: Bool { controlArmed } + var isAltArmed: Bool { altArmed } + + mutating func toggleControl() { controlArmed.toggle() } + mutating func toggleAlt() { altArmed.toggle() } + mutating func clearControl() { controlArmed = false } + mutating func clearAlt() { altArmed = false } + + mutating func apply(to text: String) -> String { + guard controlArmed || altArmed else { return text } + defer { + controlArmed = false + altArmed = false + } + let controlled = controlArmed ? (Self.controlText(for: text) ?? text) : text + // Terminal Alt/Meta text is conventionally encoded as an ESC prefix. + // Key events use the native Alt modifier below instead. + return altArmed ? "\u{1B}" + controlled : controlled + } + + mutating func apply(to event: GhosttySurfaceKeyEvent) -> GhosttySurfaceKeyEvent { + guard controlArmed || altArmed else { return event } + defer { + controlArmed = false + altArmed = false + } + var mods = event.mods + if controlArmed { mods.insert(.ctrl) } + if altArmed { mods.insert(.alt) } + + return GhosttySurfaceKeyEvent( + action: event.action, + keyCode: event.keyCode, + text: event.text, + composing: event.composing, + mods: mods, + consumedMods: event.consumedMods, + unshiftedCodepoint: event.unshiftedCodepoint + ) + } + + static func controlText(for text: String) -> String? { + guard + text.count == 1, + let scalar = text.unicodeScalars.first, + let translated = controlScalar(for: scalar) + else { + return nil + } + + return String(translated) + } + + static func controlScalar(for scalar: UnicodeScalar) -> UnicodeScalar? { + switch scalar.value { + case 0x41 ... 0x5A: + return UnicodeScalar(scalar.value - 0x40) + case 0x61 ... 0x7A: + return UnicodeScalar(scalar.value - 0x60) + case 0x20, 0x40: + return UnicodeScalar(0x00) + case 0x5B: + return UnicodeScalar(0x1B) + case 0x5C: + return UnicodeScalar(0x1C) + case 0x5D: + return UnicodeScalar(0x1D) + case 0x5E, 0x36: + return UnicodeScalar(0x1E) + case 0x5F, 0x2D: + return UnicodeScalar(0x1F) + default: + return nil + } + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPaneScrollContainerView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPaneScrollContainerView.swift new file mode 100644 index 00000000..6e891f3a --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPaneScrollContainerView.swift @@ -0,0 +1,704 @@ +import CoreGraphics +import QuartzCore +import UIKit + +struct GhosttyPaneScrollPosition: Equatable { + let row: UInt64 + let cellOffset: Double + + func approximatelyEquals(_ other: GhosttyPaneScrollPosition?) -> Bool { + guard let other else { return false } + return row == other.row && abs(cellOffset - other.cellOffset) < 0.000_001 + } +} + +enum GhosttyPaneScrollGeometry { + static func position(for state: GhosttySurfaceScrollState) -> GhosttyPaneScrollPosition { + let row = min(state.offset, state.maxRow) + let cellOffset = row == state.maxRow + ? 0 + : min(max(state.cellOffset, 0), 0.999_999_999) + return GhosttyPaneScrollPosition(row: row, cellOffset: cellOffset) + } + + static func displayViewportSize(for bounds: CGRect) -> CGSize? { + guard bounds.width.isFinite, bounds.height.isFinite else { return nil } + guard bounds.width > 0, bounds.height > 0 else { return nil } + return CGSize(width: bounds.width, height: bounds.height) + } + + static func documentHeight( + viewportHeight: CGFloat, + cellHeight: CGFloat, + state: GhosttySurfaceScrollState + ) -> CGFloat { + guard viewportHeight.isFinite, viewportHeight > 0 else { return 1 } + guard cellHeight.isFinite, cellHeight > 0 else { return viewportHeight } + + let gridHeight = CGFloat(state.total) * cellHeight + let viewportGridHeight = CGFloat(state.len) * cellHeight + let padding = max(0, viewportHeight - viewportGridHeight) + return max(viewportHeight, gridHeight + padding) + } + + static func contentOffsetY( + for state: GhosttySurfaceScrollState, + cellHeight: CGFloat, + maxContentOffsetY: CGFloat + ) -> CGFloat { + guard cellHeight.isFinite, cellHeight > 0 else { return 0 } + + let row = min(state.offset, state.maxRow) + if row == state.maxRow { + return max(maxContentOffsetY, 0) + } + let cellOffset = row == state.maxRow + ? 0 + : min(max(state.cellOffset, 0), 0.999_999_999) + let rawOffset = (CGFloat(row) + CGFloat(cellOffset)) * cellHeight + return min(max(rawOffset, 0), max(maxContentOffsetY, 0)) + } + + static func position( + forContentOffsetY contentOffsetY: CGFloat, + cellHeight: CGFloat, + state: GhosttySurfaceScrollState, + maxContentOffsetY: CGFloat + ) -> GhosttyPaneScrollPosition? { + guard cellHeight.isFinite, cellHeight > 0 else { return nil } + + let clampedOffset = min(max(contentOffsetY, 0), max(maxContentOffsetY, 0)) + let rowDouble = floor(clampedOffset / cellHeight) + let maxRow = state.maxRow + guard rowDouble < CGFloat(maxRow) else { + return GhosttyPaneScrollPosition(row: maxRow, cellOffset: 0) + } + + let row = min(UInt64(max(rowDouble, 0)), maxRow) + let fractional = Double((clampedOffset / cellHeight) - rowDouble) + return GhosttyPaneScrollPosition( + row: row, + cellOffset: min(max(fractional, 0), 0.999_999_999) + ) + } +} + +@MainActor +final class GhosttyPaneScrollContainerView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate { + private let scrollView = UIScrollView() + private let contentView = UIView() + + /// The viewport borrows the one retained pane surface it currently shows. + /// `TmuxTerminalSession` owns that surface independently of UIKit attachment. + private var surface: GhosttyManagedSurface? + private var displayScale: CGFloat = max(UIScreen.main.scale, 1) + private var displayLink: CADisplayLink? + private var pendingContentOffset: CGPoint? + private var isApplyingProgrammaticUpdate = false + private var lastAppliedScrollRoute: GhosttySurfaceScrollRoute? + private var isUserViewportScrolling = false + private var lastSentViewportScrollPosition: GhosttyPaneScrollPosition? + private var submitRouteForwardedMouseScroll: ((UUID, GhosttySurfaceMouseScrollEvent) -> GhosttyMouseInputSubmissionOutcome)? + private var submitRouteForwardedMousePosition: ((UUID, CGPoint, GhosttySurfaceKeyEvent.Mods) -> GhosttyMouseInputSubmissionOutcome)? + + /// UIKit-native scroll physics for the forwarded scroll routes: + /// pan and deceleration of this hidden scroll view produce the + /// offset deltas forwarded as precise scroll events, so flicks + /// coast on Apple's deceleration curve exactly like a trackpad. + /// On the mouse-report route the events become wheel reports; on + /// the alt-screen-cursor route the engine converts them to cursor + /// keys (the same wheel-with-inertia semantics desktop terminals + /// give alternate-scroll apps). The route is revalidated on every + /// delta, so leftover momentum never becomes input for whatever + /// mode comes next. + private let physicsScrollView = GhosttyScrollPhysicsView() + private var physicsForwardingGesture = GhosttyRouteForwardingScrollGesture( + preciseScale: GhosttyScrollTuning.routeForwardedGain + ) + private var physicsDeltaBudget = GhosttyScrollDeltaBudget(unitsPerSecond: 0) + private var physicsTailCutoff = GhosttyScrollTailCutoff() + private var physicsVelocityGain = GhosttyScrollVelocityGain() + private var physicsPeakMultiplier: Double = 1 + private var physicsReportedOffsetY: CGFloat? + private var isPhysicsGestureActive = false + private var isRecenteringPhysicsScroll = false + + /// Set when a touch catches a live deceleration. The tap that + /// lands from that touch is the catch itself, not an intentional + /// click — native scroll views swallow it. The tree view's tap + /// handler consumes this before forwarding mouse actions. + private var lastMomentumCatchAt: TimeInterval? + private static let momentumCatchTapWindow: TimeInterval = 0.4 + + /// Caps on reports one gesture may produce per second, per route. + /// On the mouse-report route each tick costs the remote TUI a + /// full repaint (opencode: ~6KB/frame), so headroom over the + /// proven-comfortable ~45/s is modest. On the alt-screen-cursor + /// route a tick is one cursor key and pagers repaint ~one line + /// (~100B), so the ceiling is set by feel, not cost — and one + /// tick is a single line, so fast flicks need the rate. + private static let maxMouseReportTicksPerSecond: Double = 60 + private static let maxAltScreenTicksPerSecond: Double = 150 + + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + MainActor.assumeIsolated { + displayLink?.invalidate() + } + } + + @discardableResult + func update( + surface: GhosttyManagedSurface, + displayScale: CGFloat, + submitRouteForwardedMouseScroll: ((UUID, GhosttySurfaceMouseScrollEvent) -> GhosttyMouseInputSubmissionOutcome)?, + submitRouteForwardedMousePosition: ((UUID, CGPoint, GhosttySurfaceKeyEvent.Mods) -> GhosttyMouseInputSubmissionOutcome)? + ) -> Bool { + let normalizedDisplayScale = max(displayScale, 1) + let didChangeScale = self.displayScale != normalizedDisplayScale + self.displayScale = normalizedDisplayScale + self.submitRouteForwardedMouseScroll = submitRouteForwardedMouseScroll + self.submitRouteForwardedMousePosition = submitRouteForwardedMousePosition + + var needsLayout = didChangeScale + if self.surface !== surface { + GhosttyRuntimeTrace.diagnostics( + "scroll.update attach old=\(ghosttyDiagnosticShortID(self.surface?.id)) new=\(ghosttyDiagnosticShortID(surface.id)) bounds=\(ghosttyDiagnosticRect(bounds)) surface={\(surface.diagnosticSummary())}" + ) + haltPhysicsScroll() + self.surface?.onScrollStateChange = nil + resetViewportScrollInteractionState() + self.surface = surface + + if surface.view.superview !== contentView { + surface.view.removeFromSuperview() + contentView.addSubview(surface.view) + } + + surface.onScrollStateChange = { [weak self, weak surface] in + guard let self, self.surface === surface else { return } + self.synchronizeFromSurface() + } + needsLayout = true + } + surface.view.isHidden = false + surface.view.alpha = 1 + surface.view.layer.opacity = 1 + + needsLayout = synchronizeRoute() || needsLayout + if needsLayout { + setNeedsLayout() + } + return needsLayout + } + + override func layoutSubviews() { + super.layoutSubviews() + scrollView.frame = bounds + physicsScrollView.frame = bounds + physicsScrollView.synchronizeVirtualContent() + recenterPhysicsScrollIfIdle() + synchronizeFromSurface() + synchronizeSurfaceFrame() + } + + func detachSurfaceIfNeeded(_ surface: GhosttyManagedSurface) { + guard self.surface === surface else { return } + haltPhysicsScroll() + surface.onScrollStateChange = nil + self.surface = nil + submitRouteForwardedMouseScroll = nil + submitRouteForwardedMousePosition = nil + lastAppliedScrollRoute = nil + resetViewportScrollInteractionState() + surface.view.isHidden = true + surface.view.removeFromSuperview() + } + + func detachCurrentSurfaceForRemoval() { + guard let surface else { return } + detachSurfaceIfNeeded(surface) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView === physicsScrollView { + forwardPhysicsScrollDelta() + return + } + + pinSurfaceToVisibleBounds() + guard !isApplyingProgrammaticUpdate else { return } + guard surface?.scrollRoute == .viewport else { return } + + pendingContentOffset = scrollView.contentOffset + ensureDisplayLink() + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + if scrollView === physicsScrollView { + if physicsScrollView.isDecelerating { + lastMomentumCatchAt = CACurrentMediaTime() + } + beginPhysicsGesture() + return + } + + guard surface?.scrollRoute == .viewport else { return } + isUserViewportScrolling = true + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if scrollView === physicsScrollView { + if !decelerate { + endPhysicsGesture(phase: .ended) + } + return + } + + guard surface?.scrollRoute == .viewport else { return } + guard !decelerate else { return } + finishUserViewportScroll() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + if scrollView === physicsScrollView { + // Deceleration ending while a touch is tracking means that + // touch caught the fling (a natural end has no touch). + if physicsScrollView.isTracking { + lastMomentumCatchAt = CACurrentMediaTime() + } + endPhysicsGesture(phase: .ended) + return + } + + guard surface?.scrollRoute == .viewport else { return } + finishUserViewportScroll() + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === physicsScrollView.panGestureRecognizer else { return true } + guard surface?.scrollRoute != .viewport else { return false } + + let pan = physicsScrollView.panGestureRecognizer + return GhosttySurfacePanGesture.routeForwardingScrollShouldBegin( + forVelocity: pan.velocity(in: self), + translation: pan.translation(in: self) + ) + } + + + private func configure() { + clipsToBounds = true + backgroundColor = .clear + + scrollView.delegate = self + scrollView.backgroundColor = .clear + scrollView.clipsToBounds = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.isDirectionalLockEnabled = true + scrollView.showsVerticalScrollIndicator = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceVertical = false + scrollView.alwaysBounceHorizontal = false + scrollView.bounces = true + scrollView.delaysContentTouches = false + scrollView.canCancelContentTouches = true + addSubview(scrollView) + + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + + physicsScrollView.delegate = self + physicsScrollView.backgroundColor = .clear + physicsScrollView.contentInsetAdjustmentBehavior = .never + physicsScrollView.isDirectionalLockEnabled = true + physicsScrollView.showsVerticalScrollIndicator = false + physicsScrollView.showsHorizontalScrollIndicator = false + physicsScrollView.alwaysBounceVertical = false + physicsScrollView.alwaysBounceHorizontal = false + addSubview(physicsScrollView) + // The physics view is hit-test transparent; its pan tracks + // touches on the container while driving the scroll view's + // native deceleration. + addGestureRecognizer(physicsScrollView.panGestureRecognizer) + } + + @discardableResult + private func synchronizeRoute() -> Bool { + let route = surface?.scrollRoute ?? .viewport + let didChangeRoute = route != lastAppliedScrollRoute + lastAppliedScrollRoute = route + let usesNativeViewportScroll = route == .viewport + scrollView.isScrollEnabled = usesNativeViewportScroll + scrollView.showsVerticalScrollIndicator = usesNativeViewportScroll + physicsScrollView.panGestureRecognizer.isEnabled = !usesNativeViewportScroll + if usesNativeViewportScroll { + haltPhysicsScroll() + } else if didChangeRoute { + // Route changed between forwarded modes (e.g. the app + // toggled mouse reporting): momentum for the old mode must + // not become input for the new one. + haltPhysicsScroll() + resetViewportScrollInteractionState() + } + return didChangeRoute + } + + private func synchronizeFromSurface() { + guard let surface else { return } + synchronizeRoute() + + let viewportHeight = max(bounds.height, 1) + let cellHeight = cellHeight(for: surface) + let contentHeight = GhosttyPaneScrollGeometry.documentHeight( + viewportHeight: viewportHeight, + cellHeight: cellHeight, + state: surface.scrollState + ) + let contentSize = CGSize(width: max(bounds.width, 1), height: contentHeight) + let maxOffsetY = max(0, contentHeight - viewportHeight) + + if isActiveViewportScrollInteraction { + synchronizeContentSize(contentSize) + pinSurfaceToVisibleBounds() + return + } + + withProgrammaticScrollSynchronization { + synchronizeContentSize(contentSize) + let offsetY = GhosttyPaneScrollGeometry.contentOffsetY( + for: surface.scrollState, + cellHeight: cellHeight, + maxContentOffsetY: maxOffsetY + ) + applyProgrammaticContentOffset(CGPoint(x: 0, y: offsetY)) + lastSentViewportScrollPosition = GhosttyPaneScrollGeometry.position(for: surface.scrollState) + pinSurfaceToVisibleBounds() + } + } + + private func synchronizeSurfaceFrame() { + guard let surface else { return } + guard let viewportSize = GhosttyPaneScrollGeometry.displayViewportSize(for: bounds) else { + GhosttyRuntimeTrace.tmuxViewport( + "scroll.surfaceFrame skipped_unsized surface=\(ghosttyDiagnosticShortID(surface.id)) bounds=\(ghosttyDiagnosticRect(bounds)) before=\(ghosttyDiagnosticSurfaceSize(surface.controlSurface.currentSize()))" + ) + return + } + + pinSurfaceToVisibleBounds(surface: surface, viewportSize: viewportSize) + GhosttyRuntimeTrace.tmuxViewport( + "scroll.surfaceFrame begin surface=\(ghosttyDiagnosticShortID(surface.id)) viewport=\(Int(viewportSize.width))x\(Int(viewportSize.height)) offset=\(scrollView.contentOffset.x),\(scrollView.contentOffset.y) scale=\(displayScale) before=\(ghosttyDiagnosticSurfaceSize(surface.controlSurface.currentSize()))" + ) + GhosttyRuntimeTrace.diagnostics( + "scroll.surfaceFrame surface=\(ghosttyDiagnosticShortID(surface.id)) viewport=\(viewportSize.width)x\(viewportSize.height) offset=\(scrollView.contentOffset.x),\(scrollView.contentOffset.y) scale=\(displayScale) before={\(surface.diagnosticSummary())}" + ) + let didUpdateDisplay = surface.updateDisplay(size: viewportSize, scale: displayScale) + if didUpdateDisplay { + surface.view.alignGhosttyRendererSublayers() + } + GhosttyRuntimeTrace.tmuxViewport( + "scroll.surfaceFrame end surface=\(ghosttyDiagnosticShortID(surface.id)) didUpdateDisplay=\(didUpdateDisplay) after=\(ghosttyDiagnosticSurfaceSize(surface.controlSurface.currentSize()))" + ) + GhosttyRuntimeTrace.diagnostics( + "scroll.surfaceFrame applied surface={\(surface.diagnosticSummary())}" + ) + } + + private func pinSurfaceToVisibleBounds() { + guard let surface else { return } + guard let viewportSize = GhosttyPaneScrollGeometry.displayViewportSize(for: bounds) else { return } + + pinSurfaceToVisibleBounds(surface: surface, viewportSize: viewportSize) + } + + private func pinSurfaceToVisibleBounds( + surface: GhosttyManagedSurface, + viewportSize: CGSize + ) { + let origin = CGPoint(x: scrollView.contentOffset.x, y: scrollView.contentOffset.y) + let frame = CGRect(origin: origin, size: viewportSize) + guard surface.view.frame != frame else { return } + surface.view.frame = frame + } + + private func applyProgrammaticContentOffset(_ offset: CGPoint) { + guard scrollView.contentOffset != offset else { return } + scrollView.setContentOffset(offset, animated: false) + } + + private func synchronizeContentSize(_ contentSize: CGSize) { + guard scrollView.contentSize != contentSize else { return } + scrollView.contentSize = contentSize + contentView.frame = CGRect(origin: .zero, size: contentSize) + } + + private func withProgrammaticScrollSynchronization(_ body: () -> Void) { + pendingContentOffset = nil + invalidateDisplayLink() + isApplyingProgrammaticUpdate = true + body() + isApplyingProgrammaticUpdate = false + pendingContentOffset = nil + invalidateDisplayLink() + } + + private func cellHeight(for surface: GhosttyManagedSurface) -> CGFloat { + let size = surface.controlSurface.currentSize() + let scale = max(displayScale, 1) + if size.cell_height_px > 0 { + return CGFloat(size.cell_height_px) / scale + } + if surface.scrollState.len > 0 { + return max(bounds.height, 1) / CGFloat(surface.scrollState.len) + } + return 0 + } + + private func ensureDisplayLink() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(displayLinkTick)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + private func invalidateDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } + + private func resetViewportScrollInteractionState() { + isUserViewportScrolling = false + lastSentViewportScrollPosition = nil + pendingContentOffset = nil + invalidateDisplayLink() + } + + private var isActiveViewportScrollInteraction: Bool { + guard surface?.scrollRoute == .viewport else { return false } + return isUserViewportScrolling || + scrollView.isTracking || + scrollView.isDragging || + scrollView.isDecelerating + } + + private func finishUserViewportScroll() { + flushPendingViewportScrollOffset() + isUserViewportScrolling = false + synchronizeFromSurface() + } + + @objc + private func displayLinkTick() { + guard flushPendingViewportScrollOffset() else { + invalidateDisplayLink() + return + } + } + + @discardableResult + private func flushPendingViewportScrollOffset() -> Bool { + guard let offset = pendingContentOffset else { return false } + pendingContentOffset = nil + + guard let surface, surface.scrollRoute == .viewport else { return false } + let viewportHeight = max(bounds.height, 1) + let maxOffsetY = max(0, scrollView.contentSize.height - viewportHeight) + guard let position = GhosttyPaneScrollGeometry.position( + forContentOffsetY: offset.y, + cellHeight: cellHeight(for: surface), + state: surface.scrollState, + maxContentOffsetY: maxOffsetY + ) else { + return false + } + + guard !position.approximatelyEquals(lastSentViewportScrollPosition) else { return false } + lastSentViewportScrollPosition = position + + surface.scrollToPosition( + row: position.row, + cellOffset: position.cellOffset + ) + return true + } + + // MARK: Mouse-report scroll physics + + /// A new drag, including catching a live deceleration. Closing any + /// previous stream first keeps the event phases well-formed. + private func beginPhysicsGesture() { + if isPhysicsGestureActive { + endPhysicsGesture(phase: .cancelled) + } + + guard let surface, surface.scrollRoute != .viewport, + submitRouteForwardedMouseScroll != nil + else { + haltPhysicsScroll() + return + } + + // Wheel reports encode at the pointer position, and touch UIs + // never report one: anchor the pointer at the gesture's touch + // point so the encoder does not drop the events + // (mouse_encode.zig out-of-viewport rule). + let location = physicsScrollView.panGestureRecognizer.location(in: surface.view) + GhosttyRuntimeTrace.diagnostics( + "scroll.physics begin surface=\(ghosttyDiagnosticShortID(surface.id)) location=\(location.x),\(location.y) offset=\(physicsScrollView.contentOffset.y)" + ) + _ = submitRouteForwardedMousePosition?(surface.id, location, []) + + let cellHeightPixels = max(cellHeight(for: surface), 1) * displayScale + let ticksPerSecond = surface.scrollRoute == .altScreenCursor + ? Self.maxAltScreenTicksPerSecond + : Self.maxMouseReportTicksPerSecond + let budgetUnitsPerSecond = ticksPerSecond + * cellHeightPixels + / physicsForwardingGesture.preciseScale + GhosttyRuntimeTrace.diagnostics( + "scroll.physics budget cellPts=\(cellHeight(for: surface)) scale=\(displayScale) cellPx=\(cellHeightPixels) unitsPerSec=\(budgetUnitsPerSecond)" + ) + physicsDeltaBudget.rearm(unitsPerSecond: budgetUnitsPerSecond) + physicsTailCutoff.reset() + physicsVelocityGain.reset() + physicsPeakMultiplier = 1 + physicsReportedOffsetY = physicsScrollView.contentOffset.y + isPhysicsGestureActive = true + } + + /// Convert the offset delta since the last callback into the same + /// precise scroll events the contact pan produces. The route is + /// revalidated on every delta: the remote app can change terminal + /// modes mid-deceleration, and leftover momentum must not turn + /// into input for whatever mode comes next. + private func forwardPhysicsScrollDelta() { + guard !isRecenteringPhysicsScroll, isPhysicsGestureActive else { return } + + guard let surface, surface.scrollRoute != .viewport, + let submitRouteForwardedMouseScroll + else { + haltPhysicsScroll() + return + } + + let offsetY = physicsScrollView.contentOffset.y + let reported = physicsReportedOffsetY ?? offsetY + physicsReportedOffsetY = offsetY + + // Finger moving down drags the virtual offset down; positive + // translation means scroll up, matching the contact pan. + let delta = Double(reported - offsetY) + let now = CACurrentMediaTime() + + // While decelerating (finger up), stop once the coast falls + // below the cell-quantization floor: the remaining tail would + // emit isolated whole-cell ticks hundreds of milliseconds + // apart — jitter, not motion. Never cuts off an active drag. + if physicsScrollView.isDecelerating, + physicsTailCutoff.shouldStop( + delta: delta, + at: now, + cellHeightPoints: Double(max(cellHeight(for: surface), 1)) + ) { + GhosttyRuntimeTrace.diagnostics( + "scroll.physics tailCutoff surface=\(ghosttyDiagnosticShortID(surface.id))" + ) + haltPhysicsScroll() + return + } + if !physicsScrollView.isDecelerating { + physicsTailCutoff.reset() + } + + // Velocity acceleration before the budget, so the per-route + // tick cap bounds the effective output. + let multiplier = physicsVelocityGain.multiplier(delta: delta, at: now) + physicsPeakMultiplier = max(physicsPeakMultiplier, multiplier) + let accelerated = delta * multiplier + let budgeted = physicsDeltaBudget.clamp(accelerated, at: now) + guard budgeted != 0 else { return } + + let events = physicsForwardingGesture.events( + forTranslation: CGPoint(x: 0, y: budgeted), + phase: .changed + ) + for event in events { + _ = submitRouteForwardedMouseScroll(surface.id, event) + } + } + + private func endPhysicsGesture(phase: GhosttySurfacePanGesture.Phase) { + guard isPhysicsGestureActive else { return } + isPhysicsGestureActive = false + physicsReportedOffsetY = nil + + if let surface, let submitRouteForwardedMouseScroll { + let events = physicsForwardingGesture.events( + forTranslation: .zero, + phase: phase + ) + for event in events { + _ = submitRouteForwardedMouseScroll(surface.id, event) + } + } else { + physicsForwardingGesture.reset() + } + + GhosttyRuntimeTrace.diagnostics( + "scroll.physics end surface=\(ghosttyDiagnosticShortID(surface?.id)) phase=\(phase) peakGain=\(String(format: "%.2f", physicsPeakMultiplier))" + ) + recenterPhysicsScrollIfIdle() + } + + /// Stop any tracking or deceleration immediately (route flips, + /// surface detach, teardown) and close the local event stream. + private func haltPhysicsScroll() { + let wasMoving = physicsScrollView.isTracking || + physicsScrollView.isDragging || + physicsScrollView.isDecelerating + if wasMoving { + isRecenteringPhysicsScroll = true + physicsScrollView.setContentOffset(physicsScrollView.contentOffset, animated: false) + isRecenteringPhysicsScroll = false + } + if isPhysicsGestureActive { + endPhysicsGesture(phase: .cancelled) + } else if wasMoving { + recenterPhysicsScrollIfIdle() + } + } + + /// True exactly once for the tap produced by a fling-catch touch; + /// consuming keeps an immediate deliberate follow-up tap clickable. + func consumeMomentumCatchTap(at now: TimeInterval = CACurrentMediaTime()) -> Bool { + guard let caughtAt = lastMomentumCatchAt else { return false } + lastMomentumCatchAt = nil + return now - caughtAt < Self.momentumCatchTapWindow + } + + private func recenterPhysicsScrollIfIdle() { + guard !physicsScrollView.isTracking, + !physicsScrollView.isDragging, + !physicsScrollView.isDecelerating + else { return } + + let centered = CGPoint(x: 0, y: physicsScrollView.centeredContentOffsetY) + guard physicsScrollView.contentOffset != centered else { return } + isRecenteringPhysicsScroll = true + physicsScrollView.setContentOffset(centered, animated: false) + isRecenteringPhysicsScroll = false + physicsReportedOffsetY = nil + } + +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPublishedFrameObserver.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPublishedFrameObserver.swift new file mode 100644 index 00000000..edb06c9f --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPublishedFrameObserver.swift @@ -0,0 +1,39 @@ +import QuartzCore + +@MainActor +protocol GhosttyInteractionStateRefreshing: AnyObject { + func refreshInteractionState() +} + +extension GhosttyManagedSurface: GhosttyInteractionStateRefreshing {} + +/// Ghostty publishes renderer-complete frames by replacing the renderer +/// sublayer's contents. Scrollbar state is authoritative only after that +/// publication; polling immediately after terminalChanged can observe the +/// previous frame and permanently under-size the local scroll document. +@MainActor +final class GhosttyPublishedFrameObserver { + private final class Target: @unchecked Sendable { + weak var value: (any GhosttyInteractionStateRefreshing)? + init(_ value: any GhosttyInteractionStateRefreshing) { self.value = value } + } + + private var observation: NSKeyValueObservation? + + func observe(_ layer: CALayer, target: any GhosttyInteractionStateRefreshing) { + invalidate() + let target = Target(target) + observation = layer.observe(\.contents, options: [.new]) { _, _ in + DispatchQueue.main.async { target.value?.refreshInteractionState() } + } + } + + func invalidate() { + observation?.invalidate() + observation = nil + } + + deinit { + observation?.invalidate() + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRendererLayer.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRendererLayer.swift new file mode 100644 index 00000000..e26c1678 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRendererLayer.swift @@ -0,0 +1,28 @@ +import IOSurface +import QuartzCore + +/// Locates Ghostty's renderer layer and reads only publication geometry. +/// Pixel capture belonged to the removed pane-preview feature. +enum GhosttyRendererLayer { + static func find(in viewLayer: CALayer) -> CALayer? { + let layers = viewLayer.sublayers ?? [] + if let published = layers.first(where: { iosurface(from: $0) != nil }) { + return published + } + // Ghostty installs exactly one direct renderer sublayer before its + // first IOSurface publication. + return layers.count == 1 ? layers[0] : nil + } + + static func dimensions(in layer: CALayer) -> (width: Int, height: Int)? { + guard let surface = iosurface(from: layer) else { return nil } + return (IOSurfaceGetWidth(surface), IOSurfaceGetHeight(surface)) + } + + private static func iosurface(from layer: CALayer) -> IOSurface? { + guard let contents = layer.contents else { return nil } + let value = contents as CFTypeRef + guard CFGetTypeID(value) == IOSurfaceGetTypeID() else { return nil } + return unsafeDowncast(contents as AnyObject, to: IOSurface.self) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyScrollPhysicsView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyScrollPhysicsView.swift new file mode 100644 index 00000000..cb819b62 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyScrollPhysicsView.swift @@ -0,0 +1,218 @@ +import QuartzCore +import UIKit + +/// Tuning for route-forwarded scrolling, resolved once at startup. +enum GhosttyScrollTuning { + /// Device-tuned gain for both forwarded scroll routes + /// (2026-06-12 A/B on iPhone 14 Pro Max): at the legacy 2.0, slow + /// controlled drags jump ahead of the finger; 1.0 and 1.5 felt + /// equally calm, and 1.5 preserves more momentum reach per flick. + /// The alt-screen-cursor route was folded onto the same physics + /// path afterwards and validated at this gain (man/less on + /// device), so it shares the value deliberately. + static let routeForwardedDefaultGain: CGFloat = 1.5 + + /// Gain from finger travel to precise scroll units on the + /// mouse-report route. `REMUX_SCROLL_PRECISE_GAIN` overrides for + /// on-device feel experiments (clamped; same read-once pattern as + /// the REMUX_TRACE_* flags). + static let routeForwardedGain: CGFloat = { + let fallback = routeForwardedDefaultGain + guard + let raw = ProcessInfo.processInfo.environment["REMUX_SCROLL_PRECISE_GAIN"], + let value = Double(raw), value.isFinite + else { + return fallback + } + let clamped = CGFloat(min(max(value, 0.5), 4)) + NSLog("Remux scrollTuning preciseGain=%.2f (env override)", clamped) + return clamped + }() +} + +/// Physics engine for route-forwarded (mouse-report) scrolling. +/// +/// A hit-test transparent `UIScrollView` with a large virtual content +/// area: its pan gesture and native deceleration produce contentOffset +/// changes that the scroll container converts into the same precise +/// scroll events a trackpad would produce, including the momentum tail +/// after the finger lifts. The view renders nothing and never receives +/// touches itself — the container attaches this view's pan gesture +/// recognizer to itself, so the scroll view acts purely as UIKit's +/// scroll physics driver (the Blink Shell pattern). +final class GhosttyScrollPhysicsView: UIScrollView { + /// Virtual content height as a multiple of the viewport. Large + /// enough that a single fling cannot reach an edge before the + /// container recenters between gestures. + private static let virtualSpanMultiplier: CGFloat = 9 + + /// Touches pass through to the surface view; this view only exists + /// so UIKit scroll physics run. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + nil + } + + var centeredContentOffsetY: CGFloat { + max(0, (contentSize.height - bounds.height) / 2) + } + + /// Keep the virtual content proportional to the viewport so the + /// deceleration runway scales with the screen. + func synchronizeVirtualContent() { + let size = CGSize( + width: max(bounds.width, 1), + height: max(bounds.height, 1) * Self.virtualSpanMultiplier + ) + guard contentSize != size else { return } + contentSize = size + } +} + +/// Velocity-mapped gain for forwarded scrolling — the standard input +/// acceleration shape (macOS scroll/pointer acceleration works the +/// same way): a pure, monotonic function of smoothed instantaneous +/// velocity. Below `slowVelocity` the multiplier is exactly 1 so the +/// device-tuned slow-drag feel is untouched; above it the multiplier +/// ramps linearly to `maxMultiplier` at `fastVelocity`, giving hard +/// flicks real reach on routes where one tick is a single line. +/// Deterministic: the same gesture at the same speed always scrolls +/// the same amount (the EMA only suppresses single-frame spikes). +struct GhosttyScrollVelocityGain { + /// Below this speed (points/s) the multiplier is 1: slow, + /// controlled drags keep the validated 1:1-ish mapping. + static let slowVelocity: Double = 900 + /// Speed at which the ramp reaches `maxMultiplier`. + static let fastVelocity: Double = 3_500 + static let maxMultiplier: Double = 3.5 + + private let smoothing: Double + private var smoothedSpeed: Double? + private var lastSampleTime: TimeInterval? + + init(smoothing: Double = 0.3) { + self.smoothing = min(max(smoothing, 0.01), 1) + } + + /// Multiplier for one offset delta (points) observed at `now`. + mutating func multiplier(delta: Double, at now: TimeInterval) -> Double { + defer { lastSampleTime = now } + guard let lastSampleTime else { return 1 } + let dt = now - lastSampleTime + guard dt > 0 else { return Self.multiplier(forVelocity: smoothedSpeed ?? 0) } + + let speed = abs(delta) / dt + let smoothed = (smoothedSpeed ?? speed) * (1 - smoothing) + speed * smoothing + smoothedSpeed = smoothed + return Self.multiplier(forVelocity: smoothed) + } + + /// The pure mapping: 1 below the slow knee, linear ramp to the + /// cap at the fast knee. + static func multiplier(forVelocity velocity: Double) -> Double { + guard velocity > slowVelocity else { return 1 } + guard velocity < fastVelocity else { return maxMultiplier } + let t = (velocity - slowVelocity) / (fastVelocity - slowVelocity) + return 1 + t * (maxMultiplier - 1) + } + + mutating func reset() { + smoothedSpeed = nil + lastSampleTime = nil + } +} + +/// Detects when a deceleration has slowed below the cell-quantization +/// floor. Native scrolling coasts to sub-pixel speeds, but forwarded +/// scrolling emits whole cells: below a couple of cells per second the +/// tail degenerates into isolated ticks hundreds of milliseconds apart +/// — perceived as jitter, not motion. Velocity is smoothed with an +/// exponential moving average so a single short frame cannot trigger +/// a premature stop. +struct GhosttyScrollTailCutoff { + /// Smoothed speed below which the coast is pure noise, in cells + /// per second. Discrete whole-cell steps read as motion at ~6+ + /// ticks/s; slower than that the tail is stutter, not glide. + static let minimumCellsPerSecond: Double = 6 + + private let smoothing: Double + private var smoothedSpeed: Double? + private var lastSampleTime: TimeInterval? + + init(smoothing: Double = 0.3) { + self.smoothing = min(max(smoothing, 0.01), 1) + } + + /// Feed one offset delta (points) observed at `now`; returns true + /// when the smoothed speed has fallen below the floor. + mutating func shouldStop( + delta: Double, + at now: TimeInterval, + cellHeightPoints: Double + ) -> Bool { + defer { lastSampleTime = now } + guard let lastSampleTime else { return false } + let dt = now - lastSampleTime + guard dt > 0, cellHeightPoints > 0 else { return false } + + let speed = abs(delta) / dt + let smoothed = (smoothedSpeed ?? speed) * (1 - smoothing) + speed * smoothing + smoothedSpeed = smoothed + return smoothed < Self.minimumCellsPerSecond * cellHeightPoints + } + + mutating func reset() { + smoothedSpeed = nil + lastSampleTime = nil + } +} + +/// Token-bucket cap on route-forwarded scroll throughput, in gesture +/// translation units (points). A violent fling decelerates from very +/// high velocity; without a cap one gesture could enqueue an unbounded +/// stream of wheel reports, each of which costs the remote TUI a full +/// repaint. Excess delta is dropped, not deferred: flicks saturate at +/// the cap instead of stretching the scroll out in time. +struct GhosttyScrollDeltaBudget { + /// Default burst window. Device traces (2026-06-12) showed 0.25s + /// lets a violent flick dump ~37 one-line ticks instantly — a + /// near-full-screen teleport before steady-rate pacing kicks in. + /// ~0.08s bounds the initial dump to a few lines while leaving + /// gesture-start latency imperceptible. + static let defaultBurstSeconds: Double = 0.08 + + private(set) var unitsPerSecond: Double + private let burstSeconds: Double + private var available: Double + private var lastRefill: TimeInterval? + + init(unitsPerSecond: Double, burstSeconds: Double = Self.defaultBurstSeconds) { + self.unitsPerSecond = max(unitsPerSecond, 0) + self.burstSeconds = max(burstSeconds, 0) + self.available = self.unitsPerSecond * self.burstSeconds + self.lastRefill = nil + } + + /// Clamp a signed delta against the remaining budget, refilled by + /// the time elapsed since the previous call. + mutating func clamp(_ delta: Double, at now: TimeInterval) -> Double { + let capacity = unitsPerSecond * burstSeconds + if let lastRefill { + let elapsed = max(0, now - lastRefill) + available = min(available + elapsed * unitsPerSecond, capacity) + } + lastRefill = now + + guard delta != 0, available > 0 else { return 0 } + let magnitude = min(abs(delta), available) + available -= magnitude + return delta < 0 ? -magnitude : magnitude + } + + /// Re-arm for a new gesture with a freshly computed rate (the cap + /// depends on the surface's cell height, which can change). + mutating func rearm(unitsPerSecond: Double) { + self.unitsPerSecond = max(unitsPerSecond, 0) + self.available = self.unitsPerSecond * burstSeconds + self.lastRefill = nil + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift new file mode 100644 index 00000000..a5deedec --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift @@ -0,0 +1,843 @@ +import GhosttyKit +import SwiftUI +import UIKit + +/// Hosts the single terminal surface presented by MoriRemote. Pane/window +/// topology belongs to the app-owned Navigator, never viewport layout. +struct GhosttySingleViewportView: View { + let surfaceLookup: GhosttyManagedSurfaceLookup + let projection: GhosttyTerminalViewportPresentationProjection + let terminalTheme: TerminalTheme + let trackpadDriver: GhosttyKeyboardCursorTrackpadDriver + let onSurfaceTap: ((UUID) -> Void)? + let onWindowSwipe: ((GhosttyRuntimeSelectionDirection) -> Void)? + let sendKeyEvent: (GhosttySurfaceKeyEvent) -> Bool + let onTrackpadFeedbackChange: (GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void + let isMouseCaptured: (UUID) -> Bool + let submitMouseButton: ((UUID, GhosttySurfaceMouseButtonEvent) -> GhosttyMouseInputSubmissionOutcome)? + let submitMousePosition: ((UUID, CGPoint, GhosttySurfaceKeyEvent.Mods) -> GhosttyMouseInputSubmissionOutcome)? + let submitMouseScroll: ((UUID, GhosttySurfaceMouseScrollEvent) -> GhosttyMouseInputSubmissionOutcome)? + + var body: some View { + GhosttySingleViewportRepresentable( + surfaceLookup: surfaceLookup, + projection: projection, + terminalTheme: terminalTheme, + trackpadDriver: trackpadDriver, + onSurfaceTap: onSurfaceTap, + onWindowSwipe: onWindowSwipe, + sendKeyEvent: sendKeyEvent, + onTrackpadFeedbackChange: onTrackpadFeedbackChange, + isMouseCaptured: isMouseCaptured, + submitMouseButton: submitMouseButton, + submitMousePosition: submitMousePosition, + submitMouseScroll: submitMouseScroll + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct GhosttySingleViewportRepresentable: UIViewRepresentable { + let surfaceLookup: GhosttyManagedSurfaceLookup + let projection: GhosttyTerminalViewportPresentationProjection + let terminalTheme: TerminalTheme + let trackpadDriver: GhosttyKeyboardCursorTrackpadDriver + let onSurfaceTap: ((UUID) -> Void)? + let onWindowSwipe: ((GhosttyRuntimeSelectionDirection) -> Void)? + let sendKeyEvent: (GhosttySurfaceKeyEvent) -> Bool + let onTrackpadFeedbackChange: (GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void + let isMouseCaptured: (UUID) -> Bool + let submitMouseButton: ((UUID, GhosttySurfaceMouseButtonEvent) -> GhosttyMouseInputSubmissionOutcome)? + let submitMousePosition: ((UUID, CGPoint, GhosttySurfaceKeyEvent.Mods) -> GhosttyMouseInputSubmissionOutcome)? + let submitMouseScroll: ((UUID, GhosttySurfaceMouseScrollEvent) -> GhosttyMouseInputSubmissionOutcome)? + + func makeUIView(context: Context) -> GhosttySingleViewportContainerView { + let view = GhosttySingleViewportContainerView() + view.backgroundColor = terminalTheme.terminalBackgroundUIColor + view.updateSelectionHandleStyle(terminalTheme.terminalChromeStyle) + return view + } + + func updateUIView(_ view: GhosttySingleViewportContainerView, context: Context) { + view.backgroundColor = terminalTheme.terminalBackgroundUIColor + view.updateSelectionHandleStyle(terminalTheme.terminalChromeStyle) + view.update( + projection: projection, + surfaceLookup: surfaceLookup, + trackpadDriver: trackpadDriver, + onSurfaceTap: onSurfaceTap, + onWindowSwipe: onWindowSwipe, + sendKeyEvent: sendKeyEvent, + onTrackpadFeedbackChange: onTrackpadFeedbackChange, + isMouseCaptured: isMouseCaptured, + submitMouseButton: submitMouseButton, + submitMousePosition: submitMousePosition, + submitMouseScroll: submitMouseScroll + ) + } + + static func dismantleUIView( + _ view: GhosttySingleViewportContainerView, + coordinator: () + ) { + view.dismantle() + } +} + +private final class GhosttySingleViewportContainerView: UIView, + UIGestureRecognizerDelegate, + @preconcurrency UIEditMenuInteractionDelegate +{ + private var surfaceLookup = GhosttyManagedSurfaceLookup.empty + private var projection = GhosttyTerminalViewportPresentationProjection.empty + private var activeContainer: GhosttyPaneScrollContainerView? + private var activeSurfaceID: UUID? + private var trackpadDriver: GhosttyKeyboardCursorTrackpadDriver? + + private var onSurfaceTap: ((UUID) -> Void)? + private var onWindowSwipe: ((GhosttyRuntimeSelectionDirection) -> Void)? + private var sendKeyEvent: ((GhosttySurfaceKeyEvent) -> Bool)? + private var onTrackpadFeedbackChange: ((GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void)? + private var isMouseCaptured: (UUID) -> Bool = { _ in false } + private var submitMouseButton: ((UUID, GhosttySurfaceMouseButtonEvent) -> GhosttyMouseInputSubmissionOutcome)? + private var submitMousePosition: ((UUID, CGPoint, GhosttySurfaceKeyEvent.Mods) -> GhosttyMouseInputSubmissionOutcome)? + private var submitMouseScroll: ((UUID, GhosttySurfaceMouseScrollEvent) -> GhosttyMouseInputSubmissionOutcome)? + + private var activePanAxis: GhosttySurfacePanGesture.Axis? + private var didNavigateForActivePan = false + private weak var localSelectionSurface: GhosttyManagedSurface? + private weak var localSelectionControlSurface: GhosttyKitControlSurface? + private var longPressOriginalPoint: CGPoint? + private var selectionSnapshot = GhosttyLocalSelectionSnapshot.inactive + + private lazy var startSelectionHandle = makeSelectionHandle( + endpoint: GHOSTTY_TERMINAL_SURFACE_SELECTION_ENDPOINT_START + ) + private lazy var endSelectionHandle = makeSelectionHandle( + endpoint: GHOSTTY_TERMINAL_SURFACE_SELECTION_ENDPOINT_END + ) + + private lazy var panRecognizer: UIPanGestureRecognizer = { + let recognizer = UIPanGestureRecognizer( + target: self, + action: #selector(handleSurfacePan(_:)) + ) + recognizer.maximumNumberOfTouches = 1 + recognizer.cancelsTouchesInView = false + return recognizer + }() + + private lazy var surfaceTapRecognizer: UITapGestureRecognizer = { + let recognizer = UITapGestureRecognizer( + target: self, + action: #selector(handleSurfaceTap(_:)) + ) + recognizer.cancelsTouchesInView = false + recognizer.delegate = self + return recognizer + }() + + private lazy var selectionLongPressRecognizer: UILongPressGestureRecognizer = { + let recognizer = UILongPressGestureRecognizer( + target: self, + action: #selector(handleSelectionLongPress(_:)) + ) + recognizer.minimumPressDuration = 0.45 + recognizer.cancelsTouchesInView = false + recognizer.delegate = self + return recognizer + }() + + private lazy var selectionEditMenuInteraction = UIEditMenuInteraction(delegate: self) + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + panRecognizer.delegate = self + surfaceTapRecognizer.require(toFail: selectionLongPressRecognizer) + addGestureRecognizer(panRecognizer) + addGestureRecognizer(selectionLongPressRecognizer) + addGestureRecognizer(surfaceTapRecognizer) + addInteraction(selectionEditMenuInteraction) + addSubview(startSelectionHandle) + addSubview(endSelectionHandle) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let inset = (GhosttySelectionHandleView.hitTargetSize - startSelectionHandle.bounds.width) / 2 + guard !startSelectionHandle.isHidden, + !endSelectionHandle.isHidden, + startSelectionHandle.frame.insetBy(dx: -inset, dy: -inset).contains(point), + endSelectionHandle.frame.insetBy(dx: -inset, dy: -inset).contains(point) + else { return super.hitTest(point, with: event) } + + let startDistance = point.squaredDistance(to: startSelectionHandle.center) + let endDistance = point.squaredDistance(to: endSelectionHandle.center) + let handle = startDistance <= endDistance + ? startSelectionHandle + : endSelectionHandle + return handle.hitTest(convert(point, to: handle), with: event) + } + + func updateSelectionHandleStyle(_ style: GhosttyTerminalChromeStyle) { + let fillColor = UIColor(style.accent) + let borderColor = UIColor(style.accentForeground).cgColor + startSelectionHandle.backgroundColor = fillColor + endSelectionHandle.backgroundColor = fillColor + startSelectionHandle.layer.borderColor = borderColor + endSelectionHandle.layer.borderColor = borderColor + } + + func update( + projection: GhosttyTerminalViewportPresentationProjection, + surfaceLookup: GhosttyManagedSurfaceLookup, + trackpadDriver: GhosttyKeyboardCursorTrackpadDriver, + onSurfaceTap: ((UUID) -> Void)?, + onWindowSwipe: ((GhosttyRuntimeSelectionDirection) -> Void)?, + sendKeyEvent: @escaping (GhosttySurfaceKeyEvent) -> Bool, + onTrackpadFeedbackChange: @escaping (GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void, + isMouseCaptured: @escaping (UUID) -> Bool, + submitMouseButton: ((UUID, GhosttySurfaceMouseButtonEvent) -> GhosttyMouseInputSubmissionOutcome)?, + submitMousePosition: ((UUID, CGPoint, GhosttySurfaceKeyEvent.Mods) -> GhosttyMouseInputSubmissionOutcome)?, + submitMouseScroll: ((UUID, GhosttySurfaceMouseScrollEvent) -> GhosttyMouseInputSubmissionOutcome)? + ) { + self.projection = projection + self.surfaceLookup = surfaceLookup + self.trackpadDriver = trackpadDriver + self.onSurfaceTap = onSurfaceTap + self.onWindowSwipe = onWindowSwipe + self.sendKeyEvent = sendKeyEvent + self.onTrackpadFeedbackChange = onTrackpadFeedbackChange + self.isMouseCaptured = isMouseCaptured + self.submitMouseButton = submitMouseButton + self.submitMousePosition = submitMousePosition + self.submitMouseScroll = submitMouseScroll + + if activePanAxis == .horizontal, !projection.canNavigateWindows { + resetActivePanState() + } + let previousSurfaceID = activeSurfaceID + syncActiveSurface() + var shouldRestoreSelection = previousSurfaceID != activeSurfaceID + if localSelectionSurface != nil { + if exactLocalSelectionSurface() == nil { + cancelLocalSelectionInteraction() + shouldRestoreSelection = true + } else { + layoutSelectionHandles() + } + } + if shouldRestoreSelection { + restoreLocalSelectionIfPresent() + } + flushLayoutIfPossible() + } + + func dismantle() { + disableInteractions() + resetActivePanState() + if let container = activeContainer { + retire(container: container) + } + activeContainer = nil + activeSurfaceID = nil + surfaceLookup = .empty + projection = .empty + + } + + private func disableInteractions() { + cancelLocalSelectionInteraction() + onSurfaceTap = nil + onWindowSwipe = nil + sendKeyEvent = nil + onTrackpadFeedbackChange = nil + isMouseCaptured = { _ in false } + submitMouseButton = nil + submitMousePosition = nil + submitMouseScroll = nil + trackpadDriver = nil + } + + override func layoutSubviews() { + super.layoutSubviews() + layoutActiveSurface() + layoutSelectionHandles() + } + + private func syncActiveSurface() { + let startedAt = GhosttyRuntimeTrace.perfEnabled + ? GhosttyRuntimeTrace.nowNanos() + : nil + defer { + if let startedAt { + GhosttyRuntimeTrace.perf( + "viewport.sync surface=\(ghosttyDiagnosticShortID(projection.surfaceID)) attached=\(activeContainer == nil ? 0 : 1) elapsed_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: startedAt))" + ) + } + } + + guard let desiredID = projection.surfaceID else { + cancelLocalSelectionInteraction() + retireActiveContainer() + return + } + + if activeSurfaceID != desiredID { + cancelLocalSelectionInteraction() + retireActiveContainer() + } + guard let surface = surfaceLookup.managedSurface(for: desiredID) else { + retireActiveContainer() + return + } + + let container = activeContainer ?? GhosttyPaneScrollContainerView() + activeContainer = container + activeSurfaceID = desiredID + container.backgroundColor = .clear + let changed = container.update( + surface: surface, + displayScale: effectiveScale, + submitRouteForwardedMouseScroll: submitMouseScroll, + submitRouteForwardedMousePosition: submitMousePosition + ) + if container.superview !== self { + container.removeFromSuperview() + addSubview(container) + bringSubviewToFront(startSelectionHandle) + bringSubviewToFront(endSelectionHandle) + } + if changed { + container.layoutIfNeeded() + } + } + + private func retireActiveContainer() { + guard let container = activeContainer else { + activeContainer?.removeFromSuperview() + activeContainer = nil + activeSurfaceID = nil + return + } + retire(container: container) + activeContainer = nil + activeSurfaceID = nil + } + + private func retire(container: GhosttyPaneScrollContainerView) { + container.detachCurrentSurfaceForRemoval() + container.removeFromSuperview() + } + + private func layoutActiveSurface() { + guard let surfaceID = activeSurfaceID, + let container = activeContainer, + let surface = surfaceLookup.managedSurface(for: surfaceID) + else { return } + + let startedAt = GhosttyRuntimeTrace.perfEnabled + ? GhosttyRuntimeTrace.nowNanos() + : nil + let targetFrame = bounds.integral + let changedFrame = container.frame != targetFrame + if changedFrame { + container.frame = targetFrame + } + let changedContainer = container.update( + surface: surface, + displayScale: effectiveScale, + submitRouteForwardedMouseScroll: submitMouseScroll, + submitRouteForwardedMousePosition: submitMousePosition + ) + if changedFrame || changedContainer { + container.layoutIfNeeded() + } + + if let startedAt { + GhosttyRuntimeTrace.perf( + "viewport.layout bounds=\(ghosttyDiagnosticRect(bounds)) changed=\(changedFrame || changedContainer) elapsed_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: startedAt))" + ) + } + } + + private func flushLayoutIfPossible() { + guard bounds.width > 1, bounds.height > 1 else { + setNeedsLayout() + return + } + layoutActiveSurface() + } + + @objc + private func handleSelectionLongPress(_ recognizer: UILongPressGestureRecognizer) { + switch recognizer.state { + case .began: + beginTerminalLongPress(recognizer) + case .changed: + updateTerminalLongPress(recognizer) + case .ended: + endTerminalLongPress() + case .cancelled, .failed: + reconcileLocalSelectionAfterLongPress() + case .possible: + break + @unknown default: + cancelLocalSelectionInteraction() + } + } + + private func beginTerminalLongPress(_ recognizer: UILongPressGestureRecognizer) { + guard let driver = trackpadDriver, + let surfaceID = projection.surfaceID, + let surface = surfaceLookup.managedSurface(for: surfaceID), + surface.view.isDescendant(of: self) + else { return } + + cancelLocalSelectionInteraction() + localSelectionSurface = surface + localSelectionControlSurface = surface.controlSurface + longPressOriginalPoint = recognizer.location(in: surface.view) + surface.onLocalSelectionGeometryChange = { [weak self] in + self?.refreshLocalSelectionGeometry() + } + driver.begin( + owner: self, + at: recognizer.location(in: surface.view), + sendKeyEvent: { [weak self] event in + guard self?.exactLocalSelectionSurface() != nil else { return false } + return self?.sendKeyEvent?(event) == true + }, + onFeedbackChange: { [weak self] state in + self?.onTrackpadFeedbackChange?(state) + } + ) + } + + private func updateTerminalLongPress(_ recognizer: UILongPressGestureRecognizer) { + guard let driver = trackpadDriver, + let (surface, _) = exactLocalSelectionSurface() + else { + cancelLocalSelectionInteraction() + return + } + _ = driver.update(owner: self, at: recognizer.location(in: surface.view)) + } + + private func endTerminalLongPress() { + guard let driver = trackpadDriver else { + cancelLocalSelectionInteraction() + return + } + let didSteer = driver.end(owner: self) + guard didSteer == false else { + reconcileLocalSelectionAfterLongPress() + return + } + + guard let point = longPressOriginalPoint, + let (_, control) = exactLocalSelectionSurface() + else { + cancelLocalSelectionInteraction() + return + } + + switch control.selectLink(at: point) { + case .match(let snapshot, _): + longPressOriginalPoint = nil + applySelectionOutcome(.snapshot(snapshot), presentMenu: true) + case .noMatch: + longPressOriginalPoint = nil + applySelectionOutcome(control.selectWord(at: point), presentMenu: true) + case .unavailable: + reconcileLocalSelectionAfterLongPress() + } + } + + private func presentSelectionCopyMenu() { + guard selectionSnapshot.isActive, + exactLocalSelectionSurface() != nil, + let anchor = selectionMenuAnchorHandle + else { return } + selectionEditMenuInteraction.presentEditMenu( + with: UIEditMenuConfiguration( + identifier: nil, + sourcePoint: anchor.center + ) + ) + } + + private var selectionMenuAnchorHandle: GhosttySelectionHandleView? { + if !endSelectionHandle.isHidden { return endSelectionHandle } + if !startSelectionHandle.isHidden { return startSelectionHandle } + return nil + } + + @objc + private func handleSurfaceTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended, + let surfaceID = projection.surfaceID, + let surface = surfaceLookup.managedSurface(for: surfaceID) + else { return } + + if selectionSnapshot.isActive, + let (_, control) = exactLocalSelectionSurface() { + _ = control.clearSelection() + cancelLocalSelectionInteraction() + return + } + + let mouseCaptured = isMouseCaptured(surfaceID) + if activeContainer?.consumeMomentumCatchTap() == true { + return + } + for action in GhosttySurfaceTapGesture.actions( + forLocalPoint: recognizer.location(in: surface.view), + mouseCaptured: mouseCaptured + ) { + switch action { + case .activateInput: + onSurfaceTap?(surfaceID) + case .mousePosition(let position): + _ = submitMousePosition?(surfaceID, position, []) + case .mouseButton(let event): + _ = submitMouseButton?(surfaceID, event) + } + } + } + + @objc + private func handleSurfacePan(_ recognizer: UIPanGestureRecognizer) { + guard let phase = GhosttySurfacePanGesture.Phase(recognizer.state) else { return } + + if phase == .began { + resetActivePanState() + } + defer { resetActivePanStateIfEnded(phase) } + + guard let surfaceID = projection.surfaceID, + surfaceLookup.managedSurface(for: surfaceID) != nil + else { return } + + if longPressOriginalPoint != nil { + return + } + + let translation = recognizer.translation(in: self) + activePanAxis = GhosttySurfacePanGesture.axis( + forTranslation: translation, + currentAxis: activePanAxis + ) + if activePanAxis == .horizontal { + routeHorizontalNavigation( + translation: translation, + velocity: recognizer.velocity(in: self) + ) + } + } + + private func routeHorizontalNavigation(translation: CGPoint, velocity: CGPoint) { + guard projection.canNavigateWindows, + let direction = GhosttySurfacePanGesture.windowNavigationDirection( + forTranslation: translation, + velocity: velocity, + axis: .horizontal, + didNavigate: didNavigateForActivePan + ) + else { return } + + didNavigateForActivePan = true + onWindowSwipe?(direction.runtimeSelectionDirection) + } + + private func resetActivePanStateIfEnded(_ phase: GhosttySurfacePanGesture.Phase) { + if phase == .ended || phase == .cancelled { + resetActivePanState() + } + } + + private func resetActivePanState() { + activePanAxis = nil + didNavigateForActivePan = false + } + + private func exactLocalSelectionSurface() + -> (GhosttyManagedSurface, GhosttyKitControlSurface)? { + guard let recordedSurface = projectedLocalSelectionSurface(), + let recordedControl = localSelectionControlSurface, + recordedSurface.controlSurface === recordedControl + else { return nil } + return (recordedSurface, recordedControl) + } + + private func projectedLocalSelectionSurface() -> GhosttyManagedSurface? { + guard let recordedSurface = localSelectionSurface, + projection.surfaceID == recordedSurface.id, + activeSurfaceID == recordedSurface.id, + surfaceLookup.managedSurface(for: recordedSurface.id) === recordedSurface, + recordedSurface.view.isDescendant(of: self) + else { return nil } + return recordedSurface + } + + private func refreshLocalSelectionGeometry() { + guard let surface = projectedLocalSelectionSurface() else { + cancelLocalSelectionInteraction() + return + } + let control = surface.controlSurface + localSelectionControlSurface = control + // Output during neutral/steering must not resurrect an older selection. + guard longPressOriginalPoint == nil, selectionSnapshot.isActive else { return } + applySelectionOutcome(control.selectionSnapshot(), presentMenu: false) + } + + private func reconcileLocalSelectionAfterLongPress() { + _ = trackpadDriver?.cancel(owner: self) + longPressOriginalPoint = nil + guard let surface = projectedLocalSelectionSurface() else { + cancelLocalSelectionInteraction() + return + } + localSelectionControlSurface = surface.controlSurface + applySelectionOutcome( + surface.controlSurface.selectionSnapshot(), + presentMenu: false + ) + } + + private func restoreLocalSelectionIfPresent() { + guard localSelectionSurface == nil, + let surfaceID = activeSurfaceID, + let surface = surfaceLookup.managedSurface(for: surfaceID), + surface.view.isDescendant(of: self), + case .snapshot(let snapshot) = surface.controlSurface.selectionSnapshot(), + snapshot.isActive + else { return } + + localSelectionSurface = surface + localSelectionControlSurface = surface.controlSurface + surface.onLocalSelectionGeometryChange = { [weak self] in + self?.refreshLocalSelectionGeometry() + } + selectionSnapshot = snapshot + layoutSelectionHandles() + } + + private func applySelectionOutcome( + _ outcome: GhosttyLocalSelectionOutcome, + presentMenu: Bool + ) { + guard case .snapshot(let snapshot) = outcome else { + cancelLocalSelectionInteraction() + return + } + selectionSnapshot = snapshot + guard selectionSnapshot.isActive else { + cancelLocalSelectionInteraction() + return + } + layoutSelectionHandles() + if presentMenu { presentSelectionCopyMenu() } + } + + private func cancelLocalSelectionInteraction() { + _ = trackpadDriver?.cancel(owner: self) + localSelectionSurface?.onLocalSelectionGeometryChange = nil + selectionEditMenuInteraction.dismissMenu() + localSelectionSurface = nil + localSelectionControlSurface = nil + longPressOriginalPoint = nil + selectionSnapshot = .inactive + startSelectionHandle.isHidden = true + endSelectionHandle.isHidden = true + } + + private func makeSelectionHandle( + endpoint: ghostty_terminal_surface_selection_endpoint_e + ) -> GhosttySelectionHandleView { + let handle = GhosttySelectionHandleView(endpoint: endpoint) + handle.isHidden = true + handle.addGestureRecognizer(UIPanGestureRecognizer( + target: self, + action: #selector(handleSelectionEndpointPan(_:)) + )) + return handle + } + + @objc + private func handleSelectionEndpointPan(_ recognizer: UIPanGestureRecognizer) { + guard let handle = recognizer.view as? GhosttySelectionHandleView, + let (surface, control) = exactLocalSelectionSurface() + else { + cancelLocalSelectionInteraction() + return + } + if recognizer.state == .began { + selectionEditMenuInteraction.dismissMenu() + } + if recognizer.state == .changed { + applySelectionOutcome( + control.setSelectionEndpoint( + handle.endpoint, + at: recognizer.location(in: surface.view) + ), + presentMenu: false + ) + } else if recognizer.state == .ended || recognizer.state == .cancelled { + presentSelectionCopyMenu() + } + } + + private func layoutSelectionHandles() { + guard selectionSnapshot.isActive, + let (surface, _) = exactLocalSelectionSurface() + else { + startSelectionHandle.isHidden = true + endSelectionHandle.isHidden = true + return + } + position(startSelectionHandle, surface: surface) + position(endSelectionHandle, surface: surface) + } + + private func position( + _ handle: GhosttySelectionHandleView, + surface: GhosttyManagedSurface + ) { + let isStart = handle.endpoint == GHOSTTY_TERMINAL_SURFACE_SELECTION_ENDPOINT_START + let rect = isStart ? selectionSnapshot.start : selectionSnapshot.end + guard let rect else { + handle.isHidden = true + return + } + let anchor = isStart + ? CGPoint(x: rect.minX, y: rect.minY) + : CGPoint(x: rect.maxX, y: rect.maxY) + let rawCenter = surface.view.convert(anchor, to: self) + let inset = handle.bounds.width / 2 + handle.center = CGPoint( + x: min(max(rawCenter.x, bounds.minX + inset), bounds.maxX - inset), + y: min(max(rawCenter.y, bounds.minY + inset), bounds.maxY - inset) + ) + handle.isHidden = false + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + guard gestureRecognizer === panRecognizer || otherGestureRecognizer === panRecognizer else { + return false + } + return gestureRecognizer is UIPanGestureRecognizer && + otherGestureRecognizer is UIPanGestureRecognizer + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + _ = gestureRecognizer + guard let touchedView = touch.view else { return true } + return !touchedView.isDescendant(of: startSelectionHandle) + && !touchedView.isDescendant(of: endSelectionHandle) + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === panRecognizer else { return true } + return GhosttySurfacePanGesture.surfaceContainerPanShouldBegin( + topLevelCount: projection.windowCount, + velocity: panRecognizer.velocity(in: self) + ) + } + + private var effectiveScale: CGFloat { + max(window?.screen.scale ?? UIScreen.main.scale, 1) + } + + func editMenuInteraction( + _ interaction: UIEditMenuInteraction, + menuFor configuration: UIEditMenuConfiguration, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + _ = interaction + _ = configuration + _ = suggestedActions + guard selectionSnapshot.isActive, + exactLocalSelectionSurface() != nil, + selectionMenuAnchorHandle != nil + else { return nil } + + guard let (_, control) = exactLocalSelectionSurface(), + let selectedText = control.readSelection(), + !selectedText.isEmpty + else { return nil } + + var actions: [UIMenuElement] = [] + actions.append( + UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in + guard let (_, control) = self?.exactLocalSelectionSurface(), + let text = control.readSelection(), + !text.isEmpty + else { return } + UIPasteboard.general.string = text + } + ) + return UIMenu(children: actions) + } + + func editMenuInteraction( + _ interaction: UIEditMenuInteraction, + targetRectFor configuration: UIEditMenuConfiguration + ) -> CGRect { + _ = interaction + _ = configuration + return selectionMenuAnchorHandle?.frame ?? .zero + } +} + +private final class GhosttySelectionHandleView: UIView { + static let hitTargetSize: CGFloat = 44 + let endpoint: ghostty_terminal_surface_selection_endpoint_e + + init(endpoint: ghostty_terminal_surface_selection_endpoint_e) { + self.endpoint = endpoint + super.init(frame: CGRect(x: 0, y: 0, width: 18, height: 18)) + layer.borderWidth = 1 + layer.cornerRadius = 9 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + _ = event + let inset = (Self.hitTargetSize - bounds.width) / 2 + return bounds.insetBy(dx: -inset, dy: -inset).contains(point) + } +} + +private extension CGPoint { + func squaredDistance(to other: CGPoint) -> CGFloat { + let dx = x - other.x + let dy = y - other.y + return dx * dx + dy * dy + } +} + +private extension GhosttySurfacePanGesture.WindowNavigationDirection { + var runtimeSelectionDirection: GhosttyRuntimeSelectionDirection { + switch self { + case .previous: .previous + case .next: .next + } + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift new file mode 100644 index 00000000..ba3e20a5 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift @@ -0,0 +1,118 @@ +import Foundation +import GhosttyKit + +struct GhosttySurfaceKeyEvent: Equatable { + enum Action: Equatable { + case press + case release + case `repeat` + + var rawValue: UInt32 { + cValue.rawValue + } + + fileprivate var cValue: ghostty_input_action_e { + switch self { + case .press: + GHOSTTY_ACTION_PRESS + case .release: + GHOSTTY_ACTION_RELEASE + case .repeat: + GHOSTTY_ACTION_REPEAT + } + } + } + + struct Mods: OptionSet, Equatable { + let rawValue: UInt32 + + static let none = Mods([]) + static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue) + static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue) + static let alt = Mods(rawValue: GHOSTTY_MODS_ALT.rawValue) + static let `super` = Mods(rawValue: GHOSTTY_MODS_SUPER.rawValue) + static let caps = Mods(rawValue: GHOSTTY_MODS_CAPS.rawValue) + static let shiftRight = Mods(rawValue: GHOSTTY_MODS_SHIFT_RIGHT.rawValue) + static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue) + static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue) + static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue) + + fileprivate var cValue: ghostty_input_mods_e { + ghostty_input_mods_e(rawValue) + } + } + + struct KeyCode: RawRepresentable, Equatable, Hashable { + let rawValue: UInt32 + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + /// `ghostty_surface_key` takes platform-native keycodes, not the + /// public `GHOSTTY_KEY_*` enum values. Ghostty's iOS build shares the + /// Darwin/macOS keycode table in `src/input/keycodes.zig`. + static let enter = Self(rawValue: 0x24) + static let tab = Self(rawValue: 0x30) + static let escape = Self(rawValue: 0x35) + static let backspace = Self(rawValue: 0x33) + static let delete = Self(rawValue: 0x75) + static let arrowUp = Self(rawValue: 0x7E) + static let arrowDown = Self(rawValue: 0x7D) + static let arrowLeft = Self(rawValue: 0x7B) + static let arrowRight = Self(rawValue: 0x7C) + static let home = Self(rawValue: 0x73) + static let end = Self(rawValue: 0x77) + static let pageUp = Self(rawValue: 0x74) + static let pageDown = Self(rawValue: 0x79) + static let space = Self(rawValue: 0x31) + static let slash = Self(rawValue: 0x2C) + } + + let action: Action + let keyCode: KeyCode + let text: String? + let composing: Bool + let mods: Mods + let consumedMods: Mods + let unshiftedCodepoint: UInt32 + + init( + action: Action = .press, + keyCode: KeyCode, + text: String? = nil, + composing: Bool = false, + mods: Mods = [], + consumedMods: Mods = [], + unshiftedCodepoint: UInt32 = 0 + ) { + self.action = action + self.keyCode = keyCode + self.text = text + self.composing = composing + self.mods = mods + self.consumedMods = consumedMods + self.unshiftedCodepoint = unshiftedCodepoint + } + + @discardableResult + func withCValue(_ body: (ghostty_input_key_s) -> T) -> T { + var event = ghostty_input_key_s() + event.action = action.cValue + event.keycode = keyCode.rawValue + event.composing = composing + event.mods = mods.cValue + event.consumed_mods = consumedMods.cValue + event.unshifted_codepoint = unshiftedCodepoint + + if let text { + return text.withCString { cString in + event.text = cString + return body(event) + } + } + + event.text = nil + return body(event) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceMouseEvent.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceMouseEvent.swift new file mode 100644 index 00000000..8d99af58 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceMouseEvent.swift @@ -0,0 +1,166 @@ +import CoreGraphics +import Foundation +import GhosttyKit + +struct GhosttySurfaceMouseButtonEvent: Equatable { + enum State: Equatable { + case press + case release + + fileprivate var cValue: ghostty_input_mouse_state_e { + switch self { + case .press: + GHOSTTY_MOUSE_PRESS + case .release: + GHOSTTY_MOUSE_RELEASE + } + } + } + + enum Button: Equatable { + case unknown + case left + case right + case middle + case four + case five + case six + case seven + case eight + case nine + case ten + case eleven + + fileprivate var cValue: ghostty_input_mouse_button_e { + switch self { + case .unknown: + GHOSTTY_MOUSE_UNKNOWN + case .left: + GHOSTTY_MOUSE_LEFT + case .right: + GHOSTTY_MOUSE_RIGHT + case .middle: + GHOSTTY_MOUSE_MIDDLE + case .four: + GHOSTTY_MOUSE_FOUR + case .five: + GHOSTTY_MOUSE_FIVE + case .six: + GHOSTTY_MOUSE_SIX + case .seven: + GHOSTTY_MOUSE_SEVEN + case .eight: + GHOSTTY_MOUSE_EIGHT + case .nine: + GHOSTTY_MOUSE_NINE + case .ten: + GHOSTTY_MOUSE_TEN + case .eleven: + GHOSTTY_MOUSE_ELEVEN + } + } + } + + let state: State + let button: Button + let mods: GhosttySurfaceKeyEvent.Mods + + init( + state: State, + button: Button, + mods: GhosttySurfaceKeyEvent.Mods = [] + ) { + self.state = state + self.button = button + self.mods = mods + } + + @discardableResult + func withCValues( + _ body: (ghostty_input_mouse_state_e, ghostty_input_mouse_button_e, ghostty_input_mods_e) -> T + ) -> T { + body(state.cValue, button.cValue, ghostty_input_mods_e(mods.rawValue)) + } +} + +struct GhosttySurfaceMouseScrollMods: Equatable { + enum Momentum: UInt8, Equatable { + case none = 0 + case began = 1 + case stationary = 2 + case changed = 3 + case ended = 4 + case cancelled = 5 + case mayBegin = 6 + } + + let rawValue: Int32 + + var precision: Bool { + rawValue & 0b0000_0001 != 0 + } + + var momentum: Momentum { + let momentumBits = (rawValue >> 1) & 0b0000_0111 + return Momentum(rawValue: UInt8(momentumBits)) ?? .none + } + + init( + precision: Bool = false, + momentum: Momentum = .none + ) { + var rawValue: Int32 = 0 + if precision { + rawValue |= 0b0000_0001 + } + rawValue |= Int32(momentum.rawValue) << 1 + self.rawValue = rawValue + } + + init(rawValue: Int32) { + self.rawValue = rawValue + } + + fileprivate var cValue: ghostty_input_scroll_mods_t { + rawValue + } +} + +struct GhosttySurfaceMouseScrollEvent: Equatable { + let deltaX: Double + let deltaY: Double + let mods: GhosttySurfaceMouseScrollMods + + init( + deltaX: Double, + deltaY: Double, + mods: GhosttySurfaceMouseScrollMods = .init() + ) { + self.deltaX = deltaX + self.deltaY = deltaY + self.mods = mods + } +} + +struct GhosttySurfaceTapGesture { + enum Action: Equatable { + case activateInput + case mousePosition(CGPoint) + case mouseButton(GhosttySurfaceMouseButtonEvent) + } + + static func actions( + forLocalPoint point: CGPoint, + mouseCaptured: Bool + ) -> [Action] { + var actions: [Action] = [.activateInput] + guard mouseCaptured else { return actions } + + actions.append(contentsOf: [ + .mousePosition(point), + .mouseButton(.init(state: .press, button: .left)), + .mouseButton(.init(state: .release, button: .left)), + ]) + return actions + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceScrollGesture.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceScrollGesture.swift new file mode 100644 index 00000000..3728c882 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceScrollGesture.swift @@ -0,0 +1,241 @@ +import CoreGraphics +import UIKit + +struct GhosttySurfacePanGesture { + enum Axis: Equatable { + case horizontal + case vertical + } + + enum WindowNavigationDirection: Equatable { + case previous + case next + } + + enum Phase: Equatable { + case began + case changed + case ended + case cancelled + + var momentum: GhosttySurfaceMouseScrollMods.Momentum { + switch self { + case .began: + .began + case .changed: + .changed + case .ended: + .ended + case .cancelled: + .cancelled + } + } + } + + private static let dominantAxisTolerance: CGFloat = 1.5 + private static let activationThreshold: CGFloat = 6 + private static let windowNavigationTranslationThreshold: CGFloat = 56 + private static let windowNavigationVelocityThreshold: CGFloat = 480 + + static func horizontalNavigationShouldBegin(forVelocity velocity: CGPoint) -> Bool { + dominantAxis(forVelocity: velocity) == .horizontal + } + + static func surfaceContainerPanShouldBegin( + topLevelCount: Int, + velocity: CGPoint + ) -> Bool { + guard topLevelCount > 1 else { return false } + return dominantAxis(forVelocity: velocity) != .vertical + } + + static func verticalScrollShouldBegin(forVelocity velocity: CGPoint) -> Bool { + dominantAxis(forVelocity: velocity) == .vertical + } + + static func routeForwardingScrollShouldBegin( + forVelocity velocity: CGPoint, + translation: CGPoint + ) -> Bool { + if let translationAxis = axis(forTranslation: translation) { + return translationAxis == .vertical + } + + return verticalScrollShouldBegin(forVelocity: velocity) + } + + static func axis( + forTranslation translation: CGPoint, + currentAxis: Axis? = nil + ) -> Axis? { + if let currentAxis { + return currentAxis + } + + let absX = abs(translation.x) + let absY = abs(translation.y) + + guard absX >= activationThreshold || absY >= activationThreshold else { + return nil + } + + if absY >= absX * dominantAxisTolerance { + return .vertical + } + + if absX >= absY * dominantAxisTolerance { + return .horizontal + } + + return nil + } + + static func windowNavigationDirection( + forTranslation translation: CGPoint, + velocity: CGPoint, + axis: Axis, + didNavigate: Bool + ) -> WindowNavigationDirection? { + guard axis == .horizontal, !didNavigate else { return nil } + + let absX = abs(translation.x) + let absY = abs(translation.y) + let absVelocityX = abs(velocity.x) + let absVelocityY = abs(velocity.y) + let hasHorizontalTranslation = + absX >= windowNavigationTranslationThreshold && + absX >= absY * dominantAxisTolerance + let hasHorizontalVelocity = + absVelocityX >= windowNavigationVelocityThreshold && + absVelocityX >= absVelocityY * dominantAxisTolerance + + guard hasHorizontalTranslation || hasHorizontalVelocity else { + return nil + } + + let directionValue = absX >= activationThreshold ? translation.x : velocity.x + guard directionValue != 0 else { return nil } + + return directionValue < 0 ? .next : .previous + } + + private static func dominantAxis(forVelocity velocity: CGPoint) -> Axis? { + let absX = abs(velocity.x) + let absY = abs(velocity.y) + + guard absX >= activationThreshold || absY >= activationThreshold else { + return nil + } + + if absY >= absX * dominantAxisTolerance { + return .vertical + } + + if absX >= absY * dominantAxisTolerance { + return .horizontal + } + + return nil + } +} + +struct GhosttyRouteForwardingScrollGesture { + /// Default multiplier from gesture translation points to precise + /// scroll units. + static let defaultPreciseScale: CGFloat = 2 + private static let minimumPreciseDelta: Double = 1 + + /// Multiplier from gesture translation points to precise scroll + /// units. Fixed for the gesture's lifetime: a gain that varies + /// mid-gesture would make the same drag distance scroll different + /// amounts depending on speed history. + let preciseScale: CGFloat + + private var pendingTranslation = CGPoint.zero + private var hasBegun = false + + init(preciseScale: CGFloat = Self.defaultPreciseScale) { + self.preciseScale = preciseScale + } + + mutating func events( + forTranslation translation: CGPoint, + phase: GhosttySurfacePanGesture.Phase = .changed + ) -> [GhosttySurfaceMouseScrollEvent] { + pendingTranslation.x += translation.x + pendingTranslation.y += translation.y + + let deltaY = Double(pendingTranslation.y * preciseScale) + let isTerminalPhase = phase == .ended || phase == .cancelled + let hasDispatchableDelta = abs(deltaY) >= Self.minimumPreciseDelta + + if !hasBegun { + guard hasDispatchableDelta else { + if isTerminalPhase { + reset() + } + return [] + } + + hasBegun = true + pendingTranslation = .zero + + var events = [ + Self.event(deltaY: deltaY, phase: .began), + ] + if isTerminalPhase { + events.append(Self.event(deltaY: 0, phase: phase)) + reset() + } + return events + } + + if isTerminalPhase { + let event = Self.event(deltaY: deltaY, phase: phase) + reset() + return [event] + } + + guard hasDispatchableDelta else { + return [] + } + + pendingTranslation = .zero + return [Self.event(deltaY: deltaY, phase: .changed)] + } + + mutating func reset() { + pendingTranslation = .zero + hasBegun = false + } + + private static func event( + deltaY: Double, + phase: GhosttySurfacePanGesture.Phase + ) -> GhosttySurfaceMouseScrollEvent { + GhosttySurfaceMouseScrollEvent( + deltaX: 0, + deltaY: deltaY, + mods: .init(precision: true, momentum: phase.momentum) + ) + } +} + +extension GhosttySurfacePanGesture.Phase { + init?(_ state: UIGestureRecognizer.State) { + switch state { + case .began: + self = .began + case .changed: + self = .changed + case .ended: + self = .ended + case .cancelled, .failed: + self = .cancelled + case .possible: + return nil + @unknown default: + return nil + } + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCompositionState.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCompositionState.swift new file mode 100644 index 00000000..29179dd5 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCompositionState.swift @@ -0,0 +1,61 @@ +import CoreGraphics +import Foundation + +/// Stateful terminal-screen composition used by `GhosttyTerminalCoreView`. +/// It joins the upstream keyboard visibility projection to the viewport hold +/// coordinator; keeping the pair together prevents a keyboard notification +/// from changing chrome intent without preserving renderer geometry. +struct GhosttyTerminalCompositionState: Equatable { + var inputCoordinator = GhosttyTerminalInputCoordinator() + var viewportCoordinator = GhosttyTerminalViewportCoordinator() + var keyboardTransitionCoordinator = GhosttyKeyboardViewportTransitionCoordinator() + private(set) var keyboardOverlapHeight: CGFloat = 0 + + mutating func reconcileViewport(_ size: CGSize) -> GhosttyTerminalViewportLiveSizeObservation { + viewportCoordinator.reconcileLiveSize(size) + } + + mutating func applyKeyboardVisibility( + frameEnd: CGRect, + screenBounds: CGRect, + animationDuration: TimeInterval? + ) -> GhosttyKeyboardViewportTransitionRequest? { + let projection = GhosttyKeyboardVisibilityProjection( + frameEnd: frameEnd, + screenBounds: screenBounds, + animationDuration: animationDuration, + keyboardMode: inputCoordinator.keyboardMode, + isDismissSystemKeyboardRequested: inputCoordinator.isDismissSystemKeyboardRequested + ) + keyboardOverlapHeight = projection.overlapHeight + inputCoordinator.updateSoftwareKeyboardVisibility(projection.isVisible) + keyboardTransitionCoordinator.observeKeyboardVisibility(isVisible: projection.isVisible) + return projection.transitionRequest + } + + mutating func beginKeyboardTransition( + _ request: GhosttyKeyboardViewportTransitionRequest + ) -> GhosttyKeyboardViewportTransitionBeginResult { + keyboardTransitionCoordinator.beginTransition( + request, + viewportCoordinator: &viewportCoordinator, + liveSize: viewportCoordinator.latestLiveSize + ) + } + + mutating func completeKeyboardTransition( + token: UInt64? = nil + ) -> GhosttyKeyboardViewportTransitionCompletionResult? { + if let token { + return keyboardTransitionCoordinator.completeTransitionFromFallback( + token: token, + viewportCoordinator: &viewportCoordinator, + liveSize: viewportCoordinator.latestLiveSize + ) + } + return keyboardTransitionCoordinator.completeTransition( + viewportCoordinator: &viewportCoordinator, + liveSize: viewportCoordinator.latestLiveSize + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift new file mode 100644 index 00000000..6695673d --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -0,0 +1,299 @@ +import GhosttyKit +import SwiftUI +import UIKit + +/// Minimal composition root derived from remux's `GhosttySurfaceScreen`. +/// +/// It binds one `TmuxTerminalScreenAdapter` to the active viewport, text +/// responder, input coordinator, cursor-trackpad HUD, and terminal keyboard +/// chrome. Its only construction input is the +/// adapter, so deterministic tests never need Mori SSH or persistence. +struct GhosttyTerminalCoreView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @ObservedObject private var screen: TmuxTerminalScreenAdapter + private let onShowNavigator: () -> Void + private let onSharedMutationRequest: (MoriRemoteTerminalSharedMutation) -> Void + private let isInputSuspended: Bool + private let imageUploader: MoriRemoteTerminalImageUploader? + @State private var terminalInputController = GhosttyTerminalInputController() + @State private var responderHandoff = GhosttyKeyboardResponderHandoff() + @State private var trackpadDriver = GhosttyKeyboardCursorTrackpadDriver() + @State private var trackpadFeedback = GhosttyKeyboardCursorTrackpad.FeedbackState.hidden + @State private var compositionState = GhosttyTerminalCompositionState() + @State private var prefixFlushTask: Task? + @State private var sessionGeneration: UInt64 = 0 + @State private var isImageAttachmentPresented = false + + init( + screen: TmuxTerminalScreenAdapter, + isInputSuspended: Bool = false, + imageUploader: MoriRemoteTerminalImageUploader? = nil, + onShowNavigator: @escaping () -> Void = {}, + onSharedMutationRequest: @escaping (MoriRemoteTerminalSharedMutation) -> Void = { _ in } + ) { + self.screen = screen + self.isInputSuspended = isInputSuspended + self.imageUploader = imageUploader + self.onShowNavigator = onShowNavigator + self.onSharedMutationRequest = onSharedMutationRequest + } + + var body: some View { + let viewportPresentation = screen.terminalViewportPresentationProjection + let isInputAvailable = GhosttyTerminalInputAvailabilityProjection( + isTerminalReady: screen.isInputAvailable, + isSuspended: isInputSuspended || isImageAttachmentPresented + ).isInputAvailable + ZStack(alignment: .bottom) { + Color.black.ignoresSafeArea() + GeometryReader { geometry in + let liveSize = GhosttyTerminalViewportCoordinator.normalized(geometry.size) + let effectiveSize = compositionState.viewportCoordinator.effectiveSize(liveSize: liveSize) + GhosttySingleViewportView( + surfaceLookup: screen.terminalManagedSurfaceLookup, + projection: viewportPresentation, + terminalTheme: .ghosttyDefault, + trackpadDriver: trackpadDriver, + onSurfaceTap: { _ in activateTerminalInput() }, + onWindowSwipe: { guard isInputAvailable else { return }; screen.focusAdjacentTmuxTopLevel($0) }, + sendKeyEvent: sendTerminalKey, + onTrackpadFeedbackChange: { trackpadFeedback = $0 }, + isMouseCaptured: { isInputAvailable && screen.isMouseCaptured(for: $0) }, + submitMouseButton: { isInputAvailable ? screen.sendMouseButton(to: $0, $1) : .surfaceRejected }, + submitMousePosition: { isInputAvailable ? screen.sendMousePosition(to: $0, $1, mods: $2) : .surfaceRejected }, + submitMouseScroll: { isInputAvailable ? screen.sendMouseScroll(to: $0, $1) : .surfaceRejected } + ) + .frame(width: effectiveSize.width, height: effectiveSize.height, alignment: .topLeading) + .onAppear { reconcileViewport(liveSize) } + .onChange(of: liveSize) { _, size in reconcileViewport(size) } + .overlay(alignment: .center) { GhosttyKeyboardCursorTrackpadHUD(state: trackpadFeedback) } + } + + GhosttyTerminalResponderRepresentable( + isEnabled: isInputAvailable, + wantsFirstResponder: isInputAvailable && compositionState.inputCoordinator.keyboardMode == .system, + activationToken: compositionState.inputCoordinator.terminalActivationToken, + responderHandoff: responderHandoff, + trackpadDriver: trackpadDriver, + keyboardAppearance: TerminalTheme.ghosttyDefault.terminalKeyboardAppearance, + sendText: sendTerminalText, + sendPaste: sendTerminalPaste, + sendKeyEvent: sendTerminalKey, + onTrackpadFeedbackChange: { trackpadFeedback = $0 }, + onFirstResponderChange: { isFirstResponder in + if !isFirstResponder, compositionState.inputCoordinator.keyboardMode == .system, + !compositionState.inputCoordinator.isDismissSystemKeyboardRequested { + compositionState.inputCoordinator.refocusSystemKeyboardIfActive(isInputAvailable: isInputAvailable) + } + } + ) + .frame(width: 1, height: 1) + .accessibilityHidden(true) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + GhosttyKeyboardChrome( + keyboardMode: compositionState.inputCoordinator.keyboardMode, + isEnabled: isInputAvailable, + isCompact: horizontalSizeClass == .compact, + isControlArmed: terminalInputController.isControlArmed, + isAltArmed: terminalInputController.isAltArmed, + imageUploader: imageUploader, + insertImagePath: insertUploadedImagePath, + onImagePresentationChange: { isImageAttachmentPresented = $0 }, + actions: .init( + showNavigator: onShowNavigator, + toggleKeyboard: toggleKeyboard, + toggleControl: { terminalInputController.toggleControl() }, + toggleAlt: { terminalInputController.toggleAlt() }, + requestSharedMutation: onSharedMutationRequest, + sendShortcut: sendTerminalShortcut, + sendKey: sendTerminalKey + ) + ) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { + guard !isTerminalInputSuspended else { return } + updateKeyboardVisibility(with: $0) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + guard !isTerminalInputSuspended else { return } + completeKeyboardTransition(for: .shown) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)) { _ in + guard !isTerminalInputSuspended else { return } + completeKeyboardTransition(for: .hidden) + } + .onDisappear { cancelTransientInput() } + .onChange(of: isTerminalInputSuspended) { _, isSuspended in + if isSuspended { cancelTransientInput() } + } + .onChange(of: screen.stateTraceLabel) { oldState, newState in + // A session lifecycle change must not let delayed or latched input + // reach a replacement surface. + if oldState != newState { cancelTransientInput() } + } + } + + private func toggleKeyboard() { + guard isTerminalInputAvailable else { return } + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: compositionState.inputCoordinator.keyboardMode, + isInputAvailable: isTerminalInputAvailable + ) + if let request = compositionState.keyboardTransitionCoordinator.transitionRequest(forToggle: projection) { + beginKeyboardTransition(request) + } + compositionState.inputCoordinator.toggleKeyboard(isInputAvailable: isTerminalInputAvailable) + if compositionState.inputCoordinator.keyboardMode == .hidden { _ = responderHandoff.transfer(to: .terminal) } + } + + private func activateTerminalInput() { + guard isTerminalInputAvailable else { return } + compositionState.inputCoordinator.showSystemKeyboard(isInputAvailable: true) + } + + private func reconcileViewport(_ size: CGSize) { + let observation = compositionState.reconcileViewport(size) + guard observation.didApplyStableSize else { return } + screen.prepareInitialViewport(size: observation.effectiveSize, scale: UIScreen.main.scale) + } + + private func updateKeyboardVisibility(with notification: Notification) { + let frame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue + ?? CGRect(x: 0, y: UIScreen.main.bounds.maxY, width: UIScreen.main.bounds.width, height: 0) + if let request = compositionState.applyKeyboardVisibility( + frameEnd: frame, + screenBounds: UIScreen.main.bounds, + animationDuration: (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue + ) { beginKeyboardTransition(request) } + } + + private func beginKeyboardTransition(_ request: GhosttyKeyboardViewportTransitionRequest) { + let begin = compositionState.beginKeyboardTransition(request) + Task { @MainActor in + try? await Task.sleep(for: .seconds(begin.fallbackDelay)) + _ = compositionState.completeKeyboardTransition(token: begin.fallbackToken) + } + } + + private func completeKeyboardTransition(for target: GhosttyKeyboardViewportTransitionTarget) { + let completion = GhosttyKeyboardViewportCompletionProjection( + eventTarget: target, + activeTransitionTarget: compositionState.viewportCoordinator.keyboardTransitionTarget, + keyboardMode: compositionState.inputCoordinator.keyboardMode, + isDismissSystemKeyboardRequested: compositionState.inputCoordinator.isDismissSystemKeyboardRequested, + isInputAvailable: isTerminalInputAvailable, + isSelectionSheetPresented: isTerminalInputSuspended, + isAwaitingSystemKeyboardPresentation: compositionState.keyboardTransitionCoordinator.isAwaitingSystemKeyboardPresentation, + isSceneActive: true + ) + guard completion.action == .complete else { return } + _ = compositionState.completeKeyboardTransition() + } + + private func sendTerminalText(_ text: String) -> Bool { + guard isTerminalInputAvailable else { return false } + return terminalInputController.performTextInput( + text, + submit: { screen.sendInputToFocusedSurface($0).isAccepted }, + schedulePrefixFlush: schedulePrefixFlush(token:), + // Mori selection is renderer-local; never enter server copy mode. + enterCopyMode: { false } + ) + } + + private func schedulePrefixFlush(token: UInt64) { + // A new token supersedes only the old timer. Flushing the input buffer + // here would consume the prefix that was just armed before this callback. + prefixFlushTask?.cancel() + let generation = sessionGeneration + prefixFlushTask = Task { @MainActor in + do { try await Task.sleep(for: .milliseconds(750)) } catch { return } + guard generation == sessionGeneration, + isTerminalInputAvailable, + let input = terminalInputController.flushPendingTmuxPrefixInput(matching: token) + else { return } + _ = screen.sendInputToFocusedSurface(input) + } + } + + private func cancelPrefixFlush() { + prefixFlushTask?.cancel() + prefixFlushTask = nil + _ = terminalInputController.flushPendingTmuxPrefixInput() + sessionGeneration &+= 1 + } + + private func cancelTransientInput() { + cancelPrefixFlush() + terminalInputController.clearModifiers() + } + + private func sendTerminalShortcut(_ text: String) -> Bool { + guard isTerminalInputAvailable else { return false } + // A menu shortcut is explicit terminal input, never the second half of + // a previously armed tmux prefix. Flush that prefix before sending the + // exact control/meta sequence and clear one-shot modifiers. + prefixFlushTask?.cancel() + prefixFlushTask = nil + if let pendingPrefix = terminalInputController.flushPendingTmuxPrefixInput() { + _ = screen.sendInputToFocusedSurface(pendingPrefix) + } + terminalInputController.clearModifiers() + return screen.sendInputToFocusedSurface(text).isAccepted + } + + private func sendTerminalPaste(_ text: String) -> Bool { + guard isTerminalInputAvailable else { return false } + return performTerminalPaste(text) + } + + /// The image sheet intentionally suspends ordinary responder input. Its + /// confirmed upload is the sole input allowed through that suspension. + private func insertUploadedImagePath(_ text: String) -> Bool { + performTerminalPaste(text) + } + + private func performTerminalPaste(_ text: String) -> Bool { + terminalInputController.performPaste( + text, + submitPendingPrefix: { screen.sendInputToFocusedSurface($0).isAccepted }, + sendPaste: { screen.sendPasteToFocusedSurface($0).isAccepted } + ) + } + + private func sendTerminalKey(_ event: GhosttySurfaceKeyEvent) -> Bool { + guard isTerminalInputAvailable else { return false } + return terminalInputController.performKeyEvent( + event, + submitPendingPrefix: { screen.sendInputToFocusedSurface($0).isAccepted }, + sendKey: { screen.sendKeyEventToFocusedSurface($0).isAccepted } + ) + } + + private var isTerminalInputSuspended: Bool { + isInputSuspended || isImageAttachmentPresented + } + + private var isTerminalInputAvailable: Bool { + GhosttyTerminalInputAvailabilityProjection( + isTerminalReady: screen.isInputAvailable, + isSuspended: isTerminalInputSuspended + ).isInputAvailable + } +} + +struct GhosttyTerminalInputAvailabilityProjection { + static func isInputAvailable(isTerminalReady: Bool, isSuspended: Bool) -> Bool { + isTerminalReady && !isSuspended + } + + let isInputAvailable: Bool + + init(isTerminalReady: Bool, isSuspended: Bool) { + isInputAvailable = Self.isInputAvailable( + isTerminalReady: isTerminalReady, + isSuspended: isSuspended + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift new file mode 100644 index 00000000..992f8687 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift @@ -0,0 +1,226 @@ +import Foundation + +enum GhosttyKeyboardOwner: Equatable { + case none + case terminal + case composer +} + +struct GhosttyTerminalInputCoordinator: Equatable { + private(set) var terminalActivationToken = 0 + private(set) var composerActivationToken = 0 + private(set) var keyboardMode: GhosttyKeyboardChromeMode = .hidden + private(set) var keyboardOwner: GhosttyKeyboardOwner = .none + private(set) var isDismissSystemKeyboardRequested = false + private(set) var isSoftwareKeyboardVisible = false + + mutating func showSystemKeyboard(isInputAvailable: Bool) { + showSystemKeyboard(owner: .terminal, isOwnerAvailable: isInputAvailable) + } + + mutating func showSystemKeyboard( + owner: GhosttyKeyboardOwner, + isOwnerAvailable: Bool + ) { + guard owner != .none, isOwnerAvailable else { return } + isDismissSystemKeyboardRequested = false + keyboardMode = .system + keyboardOwner = owner + switch owner { + case .terminal: + terminalActivationToken += 1 + case .composer: + composerActivationToken += 1 + case .none: + break + } + } + + mutating func toggleKeyboard(isInputAvailable: Bool) { + toggleKeyboard(owner: .terminal, isOwnerAvailable: isInputAvailable) + } + + mutating func toggleKeyboard( + owner: GhosttyKeyboardOwner, + isOwnerAvailable: Bool + ) { + if keyboardMode == .system, keyboardOwner == owner { + hideKeyboard() + return + } + + showSystemKeyboard(owner: owner, isOwnerAvailable: isOwnerAvailable) + } + + mutating func transferKeyboardOwnerIfActive( + to owner: GhosttyKeyboardOwner, + isOwnerAvailable: Bool + ) { + guard keyboardMode == .system else { return } + showSystemKeyboard(owner: owner, isOwnerAvailable: isOwnerAvailable) + } + + mutating func dismissKeyboard() { + hideKeyboard() + } + + mutating func refocusSystemKeyboardIfActive(isInputAvailable: Bool) { + guard keyboardMode == .system, keyboardOwner == .terminal else { return } + showSystemKeyboard(isInputAvailable: isInputAvailable) + } + + mutating func handleSelectionChange(isInputAvailable: Bool) { + switch (keyboardMode, keyboardOwner) { + case (.system, .terminal): + showSystemKeyboard(isInputAvailable: isInputAvailable) + case (.hidden, _): + isDismissSystemKeyboardRequested = false + case (.system, .composer), (.system, .none): + break + } + } + + mutating func updateSoftwareKeyboardVisibility(_ isVisible: Bool) { + isSoftwareKeyboardVisible = isVisible + + if isVisible { + // Presentation can finish after the user has already asked to + // dismiss the keyboard. Keep that explicit dismissal authoritative + // until UIKit confirms the keyboard is hidden. + guard !isDismissSystemKeyboardRequested else { return } + isDismissSystemKeyboardRequested = false + keyboardMode = keyboardMode.applyingSystemKeyboardVisibility(true) + if keyboardOwner == .none { + keyboardOwner = .terminal + } + return + } + + if isDismissSystemKeyboardRequested { + keyboardMode = keyboardMode.applyingSystemKeyboardVisibility(false) + keyboardOwner = .none + } + isDismissSystemKeyboardRequested = false + } + + private mutating func hideKeyboard() { + if keyboardMode == .system { + isDismissSystemKeyboardRequested = true + } else { + isDismissSystemKeyboardRequested = false + } + keyboardMode = .hidden + keyboardOwner = .none + } +} + +struct GhosttyTerminalInputController: Equatable { + enum TextAction: Equatable { + case submit(String) + case schedulePrefixFlush(token: UInt64) + case enterCopyMode(fallbackInput: String) + } + + struct PasteAction: Equatable { + var pendingPrefixInput: String? + var text: String + } + + struct KeyEventAction: Equatable { + var pendingPrefixInput: String? + var event: GhosttySurfaceKeyEvent + } + + private var modifierState = GhosttyModifierState() + private var tmuxPrefixInputBuffer = GhosttyTmuxPrefixInputBuffer() + + var isControlArmed: Bool { modifierState.isControlArmed } + var isAltArmed: Bool { modifierState.isAltArmed } + + mutating func toggleControl() { modifierState.toggleControl() } + mutating func toggleAlt() { modifierState.toggleAlt() } + mutating func clearControl() { modifierState.clearControl() } + mutating func clearAlt() { modifierState.clearAlt() } + mutating func clearModifiers() { + modifierState.clearControl() + modifierState.clearAlt() + } + + mutating func receiveText(_ text: String) -> TextAction { + let outbound = modifierState.apply(to: text) + switch tmuxPrefixInputBuffer.handleText(outbound) { + case .submit(let input): + return .submit(input) + case .armPrefix(let token): + return .schedulePrefixFlush(token: token) + case .enterCopyMode(let fallbackInput): + return .enterCopyMode(fallbackInput: fallbackInput) + } + } + + mutating func performTextInput( + _ text: String, + submit: (String) -> Bool, + schedulePrefixFlush: (UInt64) -> Void, + enterCopyMode: () -> Bool + ) -> Bool { + switch receiveText(text) { + case .submit(let input): + return submit(input) + case .schedulePrefixFlush(let token): + schedulePrefixFlush(token) + return true + case .enterCopyMode(let fallbackInput): + guard enterCopyMode() else { + return submit(fallbackInput) + } + return true + } + } + + mutating func receivePaste(_ text: String) -> PasteAction { + PasteAction( + pendingPrefixInput: tmuxPrefixInputBuffer.flushPendingInput(), + text: text + ) + } + + mutating func performPaste( + _ text: String, + submitPendingPrefix: (String) -> Bool, + sendPaste: (String) -> Bool + ) -> Bool { + let action = receivePaste(text) + if let pendingPrefixInput = action.pendingPrefixInput { + _ = submitPendingPrefix(pendingPrefixInput) + } + return sendPaste(action.text) + } + + mutating func receiveKeyEvent(_ event: GhosttySurfaceKeyEvent) -> KeyEventAction { + KeyEventAction( + pendingPrefixInput: tmuxPrefixInputBuffer.flushPendingInput(), + event: modifierState.apply(to: event) + ) + } + + mutating func performKeyEvent( + _ event: GhosttySurfaceKeyEvent, + submitPendingPrefix: (String) -> Bool, + sendKey: (GhosttySurfaceKeyEvent) -> Bool + ) -> Bool { + let action = receiveKeyEvent(event) + if let pendingPrefixInput = action.pendingPrefixInput { + _ = submitPendingPrefix(pendingPrefixInput) + } + return sendKey(action.event) + } + + mutating func flushPendingTmuxPrefixInput() -> String? { + tmuxPrefixInputBuffer.flushPendingInput() + } + + mutating func flushPendingTmuxPrefixInput(matching token: UInt64) -> String? { + tmuxPrefixInputBuffer.flushPendingInput(matching: token) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift new file mode 100644 index 00000000..81ec7271 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift @@ -0,0 +1,17 @@ +import Foundation + +/// MoriRemote hosts exactly one native pane surface in its viewport. Window +/// count is retained only for horizontal adjacent-window navigation. +struct GhosttyTerminalViewportPresentationProjection: Equatable { + static let empty = GhosttyTerminalViewportPresentationProjection( + surfaceID: nil, + windowCount: 0 + ) + + let surfaceID: UUID? + let windowCount: Int + + var canNavigateWindows: Bool { + windowCount > 1 + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderFocusPolicy.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderFocusPolicy.swift new file mode 100644 index 00000000..d40fdb71 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderFocusPolicy.swift @@ -0,0 +1,21 @@ +import Foundation + +struct GhosttyTerminalResponderFocusPolicy: Equatable { + let isSelected: Bool + let keyboardMode: GhosttyKeyboardChromeMode + let keyboardOwner: GhosttyKeyboardOwner + let isInputAvailable: Bool + let isTransientInputOwnerPresented: Bool + + var isResponderEnabled: Bool { + isInputAvailable + && !isTransientInputOwnerPresented + } + + var wantsFirstResponder: Bool { + isSelected + && keyboardMode.enablesSystemKeyboard + && keyboardOwner == .terminal + && isResponderEnabled + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift new file mode 100644 index 00000000..c7d675d3 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift @@ -0,0 +1,217 @@ +import UIKit + +// The terminal responder needs to conform to `UITextInput` so iOS will deliver +// the spacebar long-press floating-cursor gesture (`beginFloatingCursor` / +// `updateFloatingCursor` / `endFloatingCursor`). The terminal has no editable +// document, so this file provides safe stubs over a virtual one-character +// document. Marked text stays local while an IME builds candidates; only +// `insertText`, `replace`, or `unmarkText` commits bytes to the terminal. This +// prevents intermediate pinyin from leaking into the PTY or being duplicated +// when the user chooses a Chinese candidate. + +/// Stub UITextPosition backed by an integer offset in a virtual document of +/// length 1. +final class GhosttyVirtualTextPosition: UITextPosition { + let offset: Int + init(offset: Int) { + self.offset = offset + super.init() + } +} + +final class GhosttyVirtualTextRange: UITextRange { + let from: GhosttyVirtualTextPosition + let to: GhosttyVirtualTextPosition + + init(from: GhosttyVirtualTextPosition, to: GhosttyVirtualTextPosition) { + self.from = from + self.to = to + super.init() + } + + override var start: UITextPosition { from } + override var end: UITextPosition { to } + override var isEmpty: Bool { from.offset == to.offset } +} + +extension GhosttyTerminalResponderUIView: UITextInput { + var selectedTextRange: UITextRange? { + get { + let end = GhosttyVirtualTextPosition(offset: 1) + return GhosttyVirtualTextRange(from: end, to: end) + } + set { _ = newValue } + } + + var markedTextRange: UITextRange? { + guard !markedTextStorage.isEmpty else { return nil } + return GhosttyVirtualTextRange( + from: GhosttyVirtualTextPosition(offset: 0), + to: GhosttyVirtualTextPosition(offset: 1) + ) + } + var markedTextStyle: [NSAttributedString.Key: Any]? { + get { nil } + set { _ = newValue } + } + + var beginningOfDocument: UITextPosition { GhosttyVirtualTextPosition(offset: 0) } + var endOfDocument: UITextPosition { GhosttyVirtualTextPosition(offset: 1) } + var tokenizer: UITextInputTokenizer { floatingCursorTokenizer } + var selectionAffinity: UITextStorageDirection { + get { .forward } + set { _ = newValue } + } + + func text(in range: UITextRange) -> String? { + guard + let range = range as? GhosttyVirtualTextRange, + range.from.offset >= 0, + range.from.offset <= range.to.offset, + range.to.offset <= 1 + else { + return nil + } + + guard !range.isEmpty else { return "" } + // The whole composition is represented by one virtual character. When + // there is no composition, preserve the old sentinel so UIKit keeps + // native Backspace repeat behavior. + return markedTextStorage.isEmpty ? " " : markedTextStorage + } + + func replace(_ range: UITextRange, withText text: String) { + _ = range + clearMarkedText() + submitTextInput(text, source: "replaceText") + } + + func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + _ = selectedRange + updateMarkedText(markedText ?? "") + } + + func unmarkText() { + let committed = markedTextStorage + clearMarkedText() + guard !committed.isEmpty else { return } + submitTextInput(committed, source: "unmarkText") + } + + func clearMarkedText() { + guard !markedTextStorage.isEmpty else { return } + updateMarkedText("") + } + + private func updateMarkedText(_ text: String) { + guard markedTextStorage != text else { return } + inputDelegate?.textWillChange(self) + inputDelegate?.selectionWillChange(self) + markedTextStorage = text + inputDelegate?.selectionDidChange(self) + inputDelegate?.textDidChange(self) + } + + func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { + guard + let from = fromPosition as? GhosttyVirtualTextPosition, + let to = toPosition as? GhosttyVirtualTextPosition + else { + return nil + } + return GhosttyVirtualTextRange(from: from, to: to) + } + + func position(from position: UITextPosition, offset: Int) -> UITextPosition? { + guard let position = position as? GhosttyVirtualTextPosition else { return nil } + let next = max(0, min(1, position.offset + offset)) + return GhosttyVirtualTextPosition(offset: next) + } + + func position( + from position: UITextPosition, + in direction: UITextLayoutDirection, + offset: Int + ) -> UITextPosition? { + // Direction is meaningless against a single-character virtual document; + // delegate to the linear offset variant so UIKit's tokenizer keeps + // receiving non-nil positions. + self.position(from: position, offset: offset) + } + + func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { + guard + let lhs = position as? GhosttyVirtualTextPosition, + let rhs = other as? GhosttyVirtualTextPosition + else { + return .orderedSame + } + if lhs.offset < rhs.offset { return .orderedAscending } + if lhs.offset > rhs.offset { return .orderedDescending } + return .orderedSame + } + + func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { + guard + let lhs = from as? GhosttyVirtualTextPosition, + let rhs = toPosition as? GhosttyVirtualTextPosition + else { + return 0 + } + return rhs.offset - lhs.offset + } + + func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { + _ = direction + return range.end + } + + func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { + _ = direction + guard let position = position as? GhosttyVirtualTextPosition else { return nil } + return GhosttyVirtualTextRange(from: position, to: position) + } + + func baseWritingDirection( + for position: UITextPosition, + in direction: UITextStorageDirection + ) -> NSWritingDirection { + _ = (position, direction) + return .natural + } + + func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) { + _ = (writingDirection, range) + } + + func firstRect(for range: UITextRange) -> CGRect { + _ = range + return .zero + } + + func caretRect(for position: UITextPosition) -> CGRect { + _ = position + return .zero + } + + func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { + _ = range + return [] + } + + func closestPosition(to point: CGPoint) -> UITextPosition? { + _ = point + return GhosttyVirtualTextPosition(offset: 0) + } + + func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { + _ = (point, range) + return GhosttyVirtualTextPosition(offset: 0) + } + + func characterRange(at point: CGPoint) -> UITextRange? { + _ = point + let zero = GhosttyVirtualTextPosition(offset: 0) + return GhosttyVirtualTextRange(from: zero, to: zero) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift new file mode 100644 index 00000000..8144a596 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift @@ -0,0 +1,586 @@ +import SwiftUI +import UIKit + +@MainActor +final class GhosttyKeyboardResponderHandoff { + enum Target { + case terminal + case composer + } + + private weak var terminalResponder: UIView? + private weak var composerResponder: UIView? + + func register(_ responder: UIView, as target: Target) { + switch target { + case .terminal: + terminalResponder = responder + case .composer: + composerResponder = responder + } + } + + @discardableResult + func transfer(to target: Target) -> Bool { + let responder = switch target { + case .terminal: + terminalResponder + case .composer: + composerResponder + } + guard let responder, responder.window != nil else { return false } + return responder.becomeFirstResponder() + } +} + +struct GhosttyTerminalResponderRepresentable: UIViewRepresentable { + let isEnabled: Bool + let wantsFirstResponder: Bool + let activationToken: Int + let responderHandoff: GhosttyKeyboardResponderHandoff + let trackpadDriver: GhosttyKeyboardCursorTrackpadDriver + let keyboardAppearance: UIKeyboardAppearance + let sendText: (String) -> Bool + let sendPaste: (String) -> Bool + let sendKeyEvent: (GhosttySurfaceKeyEvent) -> Bool + let onTrackpadFeedbackChange: (GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void + let onFirstResponderChange: (Bool) -> Void + + init( + isEnabled: Bool, + wantsFirstResponder: Bool, + activationToken: Int, + responderHandoff: GhosttyKeyboardResponderHandoff, + trackpadDriver: GhosttyKeyboardCursorTrackpadDriver, + keyboardAppearance: UIKeyboardAppearance = .dark, + sendText: @escaping (String) -> Bool, + sendPaste: @escaping (String) -> Bool, + sendKeyEvent: @escaping (GhosttySurfaceKeyEvent) -> Bool, + onTrackpadFeedbackChange: @escaping (GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void, + onFirstResponderChange: @escaping (Bool) -> Void = { _ in } + ) { + self.isEnabled = isEnabled + self.wantsFirstResponder = wantsFirstResponder + self.activationToken = activationToken + self.responderHandoff = responderHandoff + self.trackpadDriver = trackpadDriver + self.keyboardAppearance = keyboardAppearance + self.sendText = sendText + self.sendPaste = sendPaste + self.sendKeyEvent = sendKeyEvent + self.onTrackpadFeedbackChange = onTrackpadFeedbackChange + self.onFirstResponderChange = onFirstResponderChange + } + + func makeUIView(context: Context) -> GhosttyTerminalResponderUIView { + let view = GhosttyTerminalResponderUIView(trackpadDriver: trackpadDriver) + view.backgroundColor = .clear + view.isAccessibilityElement = false + responderHandoff.register(view, as: .terminal) + return view + } + + func updateUIView(_ uiView: GhosttyTerminalResponderUIView, context: Context) { + uiView.update( + isEnabled: isEnabled, + wantsFirstResponder: wantsFirstResponder, + activationToken: activationToken, + keyboardAppearance: keyboardAppearance, + sendText: { sendText(GhosttyTerminalInputNormalizer.normalize($0)) }, + sendPaste: sendPaste, + sendKeyEvent: sendKeyEvent, + onTrackpadFeedbackChange: onTrackpadFeedbackChange, + onFirstResponderChange: onFirstResponderChange + ) + } + + static func dismantleUIView(_ uiView: GhosttyTerminalResponderUIView, coordinator: ()) { + // SwiftUI is dropping this representable while a trackpad gesture may + // still be live (surface revision, screen transition, disconnect). + uiView.cancelTrackpadGestureIfActive(reason: "dismantle") + } +} + +enum GhosttyTerminalInputNormalizer { + static func normalize(_ text: String) -> String { + text.replacingOccurrences(of: "\n", with: "\r") + } +} + +@MainActor +final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTraits { + override var canBecomeFirstResponder: Bool { isInputEnabled } + + var hasText: Bool { isInputEnabled } + var keyboardAppearance: UIKeyboardAppearance = .dark + var keyboardType: UIKeyboardType = .default + var returnKeyType: UIReturnKeyType = .default + var autocapitalizationType: UITextAutocapitalizationType = .none + var autocorrectionType: UITextAutocorrectionType = .no + var spellCheckingType: UITextSpellCheckingType = .no + var smartQuotesType: UITextSmartQuotesType = .no + var smartDashesType: UITextSmartDashesType = .no + var smartInsertDeleteType: UITextSmartInsertDeleteType = .no + var enablesReturnKeyAutomatically = false + + private var isInputEnabled = false + private var wantsFirstResponder = false + private var activationToken = -1 + private var pendingFirstResponderRequest = false + private var responderReconciliationScheduled = false + private var sendTextHandler: ((String) -> Bool)? + private var sendPasteHandler: ((String) -> Bool)? + private var sendKeyEventHandler: ((GhosttySurfaceKeyEvent) -> Bool)? + private var trackpadFeedbackHandler: ((GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void)? + private var firstResponderStateHandler: ((Bool) -> Void)? + private var lastReportedFirstResponderState: Bool? + /// IME composition is local until UIKit commits it. Sending marked pinyin + /// into the PTY would duplicate every intermediate candidate. + var markedTextStorage = "" + private let trackpadDriver: GhosttyKeyboardCursorTrackpadDriver + private let pasteboardString: () -> String? + lazy var floatingCursorTokenizer: UITextInputTokenizer = + UITextInputStringTokenizer(textInput: self) + weak var inputDelegate: UITextInputDelegate? + + init( + trackpadDriver: GhosttyKeyboardCursorTrackpadDriver, + pasteboardString: @escaping () -> String? = { UIPasteboard.general.string } + ) { + self.trackpadDriver = trackpadDriver + self.pasteboardString = pasteboardString + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + func update( + isEnabled: Bool, + wantsFirstResponder: Bool, + activationToken: Int, + keyboardAppearance: UIKeyboardAppearance = .dark, + sendText: @escaping (String) -> Bool, + sendPaste: @escaping (String) -> Bool, + sendKeyEvent: @escaping (GhosttySurfaceKeyEvent) -> Bool, + onTrackpadFeedbackChange: @escaping (GhosttyKeyboardCursorTrackpad.FeedbackState) -> Void = { _ in }, + onFirstResponderChange: @escaping (Bool) -> Void = { _ in } + ) { + let wasInputEnabled = self.isInputEnabled + let previouslyWantedFirstResponder = self.wantsFirstResponder + let previousActivationToken = self.activationToken + let previousKeyboardAppearance = self.keyboardAppearance + GhosttyRuntimeTrace.diagnostics( + "responder.update enabled=\(isEnabled) wasEnabled=\(wasInputEnabled) wantsFirstResponder=\(wantsFirstResponder) previousWantsFirstResponder=\(previouslyWantedFirstResponder) token=\(activationToken) previousToken=\(previousActivationToken) firstResponder=\(isFirstResponder) hasWindow=\(window != nil)" + ) + + self.isInputEnabled = isEnabled + self.wantsFirstResponder = wantsFirstResponder + self.keyboardAppearance = keyboardAppearance + self.sendTextHandler = sendText + self.sendPasteHandler = sendPaste + self.sendKeyEventHandler = sendKeyEvent + self.trackpadFeedbackHandler = onTrackpadFeedbackChange + self.firstResponderStateHandler = onFirstResponderChange + + if isFirstResponder, previousKeyboardAppearance != keyboardAppearance { + reloadInputViews() + } + + if !isEnabled { + clearMarkedText() + cancelTrackpadGestureIfActive(reason: "disabled") + pendingFirstResponderRequest = false + self.activationToken = activationToken + scheduleResponderReconciliationIfNeeded(reason: "disabled") + return + } + + guard wantsFirstResponder else { + pendingFirstResponderRequest = false + self.activationToken = activationToken + scheduleResponderReconciliationIfNeeded(reason: "not-wanted") + return + } + + let activationChanged = activationToken != self.activationToken + let enabledChanged = !wasInputEnabled + let wantsFirstResponderChanged = wantsFirstResponder != previouslyWantedFirstResponder + let needsFirstResponderRecovery = !isFirstResponder && !pendingFirstResponderRequest + guard activationChanged || enabledChanged || wantsFirstResponderChanged || needsFirstResponderRecovery else { return } + + self.activationToken = activationToken + pendingFirstResponderRequest = true + scheduleResponderReconciliationIfNeeded(reason: "request") + } + + func insertText(_ text: String) { + clearMarkedText() + submitTextInput(text, source: "insertText") + } + + func submitTextInput(_ text: String, source: String) { + guard isInputEnabled else { return } + guard !text.isEmpty else { return } + GhosttyRuntimeTrace.diagnostics( + "responder.\(source) bytes=\(text.lengthOfBytes(using: .utf8)) firstResponder=\(isFirstResponder) token=\(activationToken)" + ) + + _ = sendTextHandler?(text) + } + + override func didMoveToWindow() { + super.didMoveToWindow() + GhosttyRuntimeTrace.diagnostics( + "responder.didMoveToWindow hasWindow=\(window != nil) enabled=\(isInputEnabled) pending=\(pendingFirstResponderRequest) firstResponder=\(isFirstResponder) token=\(activationToken)" + ) + if window == nil { + // Detached from the view hierarchy mid-flight: end any active + // trackpad gesture so the SwiftUI HUD observer doesn't strand + // visible after the surface this responder belonged to is gone. + cancelTrackpadGestureIfActive(reason: "didMoveToWindow.nil") + } + scheduleResponderReconciliationIfNeeded(reason: "didMoveToWindow") + } + + override func becomeFirstResponder() -> Bool { + let didBecomeFirstResponder = super.becomeFirstResponder() + reportFirstResponderStateIfChanged() + + return didBecomeFirstResponder + } + + override func resignFirstResponder() -> Bool { + cancelTrackpadGestureIfActive(reason: "resignFirstResponder") + let didResignFirstResponder = super.resignFirstResponder() + reportFirstResponderStateIfChanged() + + return didResignFirstResponder + } + + private func reportFirstResponderStateIfChanged() { + let currentState = isFirstResponder + guard currentState != lastReportedFirstResponderState else { return } + lastReportedFirstResponderState = currentState + firstResponderStateHandler?(currentState) + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + // Now that this view conforms to UITextInput, UIKit otherwise advertises + // the standard text edit menu (Select / Select All / Copy / Cut). The + // terminal has no editable selection, so suppress those and keep paste + // wired through the existing handler. + switch action { + case #selector(UIResponderStandardEditActions.select(_:)), + #selector(UIResponderStandardEditActions.selectAll(_:)), + #selector(UIResponderStandardEditActions.copy(_:)), + #selector(UIResponderStandardEditActions.cut(_:)): + return false + default: + return super.canPerformAction(action, withSender: sender) + } + } + + func beginFloatingCursor(at point: CGPoint) { + guard isInputEnabled else { return } + trackpadDriver.begin( + owner: self, + at: point, + sendKeyEvent: { [weak self] event in + self?.sendKeyEventHandler?(event) == true + }, + onFeedbackChange: { [weak self] state in + self?.trackpadFeedbackHandler?(state) + } + ) + + } + + func updateFloatingCursor(at point: CGPoint) { + guard isInputEnabled else { return } + _ = trackpadDriver.update(owner: self, at: point) + } + + func endFloatingCursor() { + guard trackpadDriver.end(owner: self) != nil else { return } + + } + + func cancelTrackpadGestureIfActive(reason: String) { + guard trackpadDriver.cancel(owner: self) else { return } + + } + + func deleteBackward() { + guard isInputEnabled else { return } + if !markedTextStorage.isEmpty { + clearMarkedText() + return + } + GhosttyRuntimeTrace.diagnostics( + "responder.deleteBackward firstResponder=\(isFirstResponder) token=\(activationToken)" + ) + _ = sendKeyEventHandler?(.init(keyCode: .backspace)) + } + + override func paste(_ sender: Any?) { + guard + isInputEnabled, + let text = pasteboardString(), + !text.isEmpty + else { + return + } + + GhosttyRuntimeTrace.diagnostics( + "responder.paste bytes=\(text.lengthOfBytes(using: .utf8)) firstResponder=\(isFirstResponder) token=\(activationToken)" + ) + _ = sendPasteHandler?(text) + } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard isInputEnabled else { + GhosttyRuntimeTrace.diagnostics( + "responder.pressesBegan disabled count=\(presses.count) firstResponder=\(isFirstResponder) token=\(activationToken)" + ) + super.pressesBegan(presses, with: event) + return + } + + GhosttyRuntimeTrace.diagnostics( + "responder.pressesBegan count=\(presses.count) firstResponder=\(isFirstResponder) token=\(activationToken)" + ) + var unhandledPresses = Set() + for press in presses.sorted(by: Self.sortPressesByTimestamp) { + guard let key = press.key else { + unhandledPresses.insert(press) + continue + } + + guard let action = GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: key.keyCode, + modifiers: key.modifierFlags, + characters: key.characters, + charactersIgnoringModifiers: key.charactersIgnoringModifiers + ) else { + unhandledPresses.insert(press) + continue + } + + if case .text(let text) = action, + GhosttyTerminalHardwareCommandMapping.resolveHardwareText( + characters: key.characters, + modifiers: key.modifierFlags + ) == text { + GhosttyRuntimeTrace.diagnostics( + "responder.pressesBegan text keyCode=\(key.keyCode.rawValue) modifiers=\(key.modifierFlags.rawValue) bytes=\(text.lengthOfBytes(using: .utf8))" + ) + } else { + GhosttyRuntimeTrace.diagnostics( + "responder.pressesBegan action keyCode=\(key.keyCode.rawValue) modifiers=\(key.modifierFlags.rawValue)" + ) + } + handleHardwareCommandAction(action) + } + + if !unhandledPresses.isEmpty { + GhosttyRuntimeTrace.diagnostics( + "responder.pressesBegan unhandled count=\(unhandledPresses.count)" + ) + super.pressesBegan(unhandledPresses, with: event) + } + } + + private func handleHardwareCommandAction(_ action: GhosttyTerminalHardwareCommandAction) { + switch action { + case .keyEvent(let event): + _ = sendKeyEventHandler?(event) + case .text(let text): + _ = sendTextHandler?(text) + } + } + + private func scheduleResponderReconciliationIfNeeded(reason: String) { + guard needsResponderReconciliation else { return } + guard !responderReconciliationScheduled else { return } + + responderReconciliationScheduled = true + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reconcileResponderState() + } + } + + private var needsResponderReconciliation: Bool { + if isFirstResponder { + return !isInputEnabled || !wantsFirstResponder || pendingFirstResponderRequest + } + + return isInputEnabled && wantsFirstResponder && pendingFirstResponderRequest + } + + private func reconcileResponderState() { + responderReconciliationScheduled = false + + guard isInputEnabled else { + pendingFirstResponderRequest = false + guard isFirstResponder else { return } + GhosttyRuntimeTrace.diagnostics("responder.reconcile resign disabled token=\(activationToken)") + _ = resignFirstResponder() + return + } + + guard wantsFirstResponder else { + pendingFirstResponderRequest = false + guard isFirstResponder else { return } + GhosttyRuntimeTrace.diagnostics("responder.reconcile resign not-wanted token=\(activationToken)") + _ = resignFirstResponder() + return + } + + guard pendingFirstResponderRequest else { return } + guard window != nil else { + GhosttyRuntimeTrace.perf("responder.requestFirstResponder skip-no-window token=\(activationToken)") + return + } + + GhosttyRuntimeTrace.perf( + "responder.requestFirstResponder deferred token=\(activationToken) firstResponder=\(isFirstResponder)" + ) + + _ = attemptFirstResponderRequest(route: "reconcile") + } + + @discardableResult + private func attemptFirstResponderRequest(route: String) -> Bool { + if isFirstResponder { + reloadInputViews() + pendingFirstResponderRequest = false + GhosttyRuntimeTrace.perf( + "responder.requestFirstResponder result=true route=\(route) token=\(activationToken) firstResponder=true" + ) + + return true + } + + let traceStart = GhosttyRuntimeTrace.nowNanos() + + let didBecomeFirstResponder = becomeFirstResponder() + let elapsedMilliseconds = GhosttyRuntimeTrace.elapsedMilliseconds(from: traceStart) + GhosttyRuntimeTrace.perf( + "responder.requestFirstResponder result=\(didBecomeFirstResponder) route=\(route) token=\(activationToken) firstResponder=\(isFirstResponder) elapsed_ms=\(elapsedMilliseconds)" + ) + GhosttyRuntimeTrace.diagnostics( + "responder.requestFirstResponder result=\(didBecomeFirstResponder) route=\(route) token=\(activationToken) firstResponder=\(isFirstResponder)" + ) + + if didBecomeFirstResponder { + pendingFirstResponderRequest = false + } + return didBecomeFirstResponder + } + + private static func sortPressesByTimestamp(_ lhs: UIPress, _ rhs: UIPress) -> Bool { + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp < rhs.timestamp + } + + return (lhs.key?.keyCode.rawValue ?? 0) < (rhs.key?.keyCode.rawValue ?? 0) + } +} + +enum GhosttyTerminalHardwareCommandAction: Equatable { + case keyEvent(GhosttySurfaceKeyEvent) + case text(String) +} + +enum GhosttyTerminalHardwareCommandMapping { + private static let hardwareKeyCodes: [UIKeyboardHIDUsage: GhosttySurfaceKeyEvent.KeyCode] = [ + .keyboardDeleteOrBackspace: .backspace, + .keyboardDeleteForward: .delete, + .keyboardReturnOrEnter: .enter, + .keyboardTab: .tab, + .keyboardEscape: .escape, + .keyboardUpArrow: .arrowUp, + .keyboardDownArrow: .arrowDown, + .keyboardLeftArrow: .arrowLeft, + .keyboardRightArrow: .arrowRight, + .keyboardHome: .home, + .keyboardEnd: .end, + .keyboardPageUp: .pageUp, + .keyboardPageDown: .pageDown, + ] + + static func resolveHardwareKey( + keyCode: UIKeyboardHIDUsage, + modifiers: UIKeyModifierFlags, + charactersIgnoringModifiers: String? = nil + ) -> GhosttyTerminalHardwareCommandAction? { + if let mappedKeyCode = hardwareKeyCodes[keyCode] { + return .keyEvent( + .init( + keyCode: mappedKeyCode, + mods: ghosttyModifiers(from: modifiers) + ) + ) + } + + guard supportsControlTextTranslation(modifiers: modifiers) else { return nil } + guard let charactersIgnoringModifiers else { return nil } + guard let translated = GhosttyModifierState.controlText(for: charactersIgnoringModifiers) else { + return nil + } + + return .text(translated) + } + + static func resolveHardwarePress( + keyCode: UIKeyboardHIDUsage, + modifiers: UIKeyModifierFlags, + characters: String, + charactersIgnoringModifiers: String? + ) -> GhosttyTerminalHardwareCommandAction? { + if let action = resolveHardwareKey( + keyCode: keyCode, + modifiers: modifiers, + charactersIgnoringModifiers: charactersIgnoringModifiers + ) { + return action + } + + guard let text = resolveHardwareText(characters: characters, modifiers: modifiers) else { + return nil + } + return .text(text) + } + + static func resolveHardwareText( + characters: String, + modifiers: UIKeyModifierFlags + ) -> String? { + guard !characters.isEmpty else { return nil } + guard !modifiers.contains(.command) else { return nil } + guard !modifiers.contains(.control) else { return nil } + return characters + } + + private static func supportsControlTextTranslation(modifiers: UIKeyModifierFlags) -> Bool { + guard modifiers.contains(.control) else { return false } + return !modifiers.contains(.command) && !modifiers.contains(.alternate) + } + + private static func ghosttyModifiers(from modifiers: UIKeyModifierFlags) -> GhosttySurfaceKeyEvent.Mods { + var result: GhosttySurfaceKeyEvent.Mods = [] + + if modifiers.contains(.shift) { result.insert(.shift) } + if modifiers.contains(.control) { result.insert(.ctrl) } + if modifiers.contains(.alternate) { result.insert(.alt) } + if modifiers.contains(.command) { result.insert(.super) } + if modifiers.contains(.alphaShift) { result.insert(.caps) } + + return result + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalSurfaceInteractionOutcome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalSurfaceInteractionOutcome.swift new file mode 100644 index 00000000..538bccfe --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalSurfaceInteractionOutcome.swift @@ -0,0 +1,45 @@ +import Foundation + +enum FocusedTerminalInputSubmissionResult: Equatable, Sendable, CustomStringConvertible { + case accepted + case empty + case noFocusedSurface + case transportUnavailable + case surfaceRejected + + var isAccepted: Bool { + switch self { + case .accepted, .empty: + true + case .noFocusedSurface, .transportUnavailable, .surfaceRejected: + false + } + } + + var description: String { + switch self { + case .accepted: + "accepted" + case .empty: + "empty" + case .noFocusedSurface: + "noFocusedSurface" + case .transportUnavailable: + "transportUnavailable" + case .surfaceRejected: + "surfaceRejected" + } + } +} + +enum GhosttyMouseInputSubmissionOutcome: Equatable, Sendable { + case sent + case noFocusedSurface + case missingTarget(UUID) + case transportUnavailable + case surfaceRejected + + var isSent: Bool { + self == .sent + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalViewportCoordinator.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalViewportCoordinator.swift new file mode 100644 index 00000000..c2f00e9f --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalViewportCoordinator.swift @@ -0,0 +1,355 @@ +import CoreGraphics +import Foundation + +enum GhosttyTerminalViewportHoldReason: Hashable { + case sheet + case coveredPresentation + case keyboardTransition + case topologyRefocus + case unsizedInitialLayout + + var traceLabel: String { + switch self { + case .sheet: + return "sheet" + case .coveredPresentation: + return "coveredPresentation" + case .keyboardTransition: + return "keyboardTransition" + case .topologyRefocus: + return "topologyRefocus" + case .unsizedInitialLayout: + return "unsizedInitialLayout" + } + } +} + +enum GhosttyTerminalViewportGeometryHoldEffect: Equatable { + case hold(effectiveSize: CGSize) + case release(previousEffectiveSize: CGSize) +} + +enum GhosttyTerminalViewportTopologyRefocusEffect: Equatable { + case hold(effectiveSize: CGSize) + case release(previousEffectiveSize: CGSize) + case inactive +} + +struct GhosttyTerminalViewportLiveSizeObservation: Equatable { + enum Outcome: Equatable { + case unchanged + case observedWithoutStableUpdate + case appliedStableSize + } + + let previousLiveSize: CGSize + let liveSize: CGSize + let previousEffectiveSize: CGSize + let effectiveSize: CGSize + let wasFrozen: Bool + let outcome: Outcome + + var didChangeLiveSize: Bool { + previousLiveSize != liveSize + } + + var didApplyStableSize: Bool { + outcome == .appliedStableSize + } +} + +struct GhosttyTerminalViewportCoordinator: Equatable { + enum ReleasePolicy: Equatable { + case adoptLatestLive + case preserveCurrentEffective + } + + private(set) var lastLiveSize = CGSize(width: 1, height: 1) + private(set) var lastStableSize = CGSize(width: 1, height: 1) + private(set) var frozenSize: CGSize? + private(set) var holdReasons: Set = [] + private(set) var keyboardTransitionTarget: GhosttyKeyboardViewportTransitionTarget? + private var deferredReleasePolicy: ReleasePolicy? + + var latestLiveSize: CGSize { + lastLiveSize + } + + var isGeometryFrozen: Bool { + holdReasons.contains { Self.isGeometryHold($0) } + } + + var isFrozen: Bool { + isGeometryFrozen + } + + var isKeyboardTransitionActive: Bool { + holdReasons.contains(.keyboardTransition) + } + + var isTopologyRefocusActive: Bool { + holdReasons.contains(.topologyRefocus) + } + + var holdReasonTraceLabel: String { + guard !holdReasons.isEmpty else { return "none" } + return holdReasons + .map(\.traceLabel) + .sorted() + .joined(separator: ",") + } + + func effectiveSize(liveSize: CGSize) -> CGSize { + if isGeometryFrozen, let frozenSize { + return frozenSize + } + if Self.isUsable(lastStableSize) { + return lastStableSize + } + + let normalizedSize = Self.normalized(liveSize) + return Self.isUsable(normalizedSize) ? normalizedSize : lastStableSize + } + + @discardableResult + mutating func observeLiveSize(_ size: CGSize) -> GhosttyTerminalViewportLiveSizeObservation { + updateLiveSize(size, reconcilesStoredSize: false) + } + + @discardableResult + mutating func reconcileLiveSize(_ size: CGSize) -> GhosttyTerminalViewportLiveSizeObservation { + updateLiveSize(size, reconcilesStoredSize: true) + } + + private mutating func updateLiveSize( + _ size: CGSize, + reconcilesStoredSize: Bool + ) -> GhosttyTerminalViewportLiveSizeObservation { + let normalizedSize = Self.normalized(size) + let previousLiveSize = lastLiveSize + let previousEffectiveSize = effectiveSize(liveSize: previousLiveSize) + let wasFrozen = isGeometryFrozen + let didChangeLiveSize = lastLiveSize != normalizedSize + lastLiveSize = normalizedSize + + func observation( + outcome: GhosttyTerminalViewportLiveSizeObservation.Outcome + ) -> GhosttyTerminalViewportLiveSizeObservation { + GhosttyTerminalViewportLiveSizeObservation( + previousLiveSize: previousLiveSize, + liveSize: normalizedSize, + previousEffectiveSize: previousEffectiveSize, + effectiveSize: effectiveSize(liveSize: normalizedSize), + wasFrozen: wasFrozen, + outcome: outcome + ) + } + + guard didChangeLiveSize || reconcilesStoredSize else { + return observation(outcome: .unchanged) + } + guard Self.isUsable(normalizedSize) else { + holdReasons.insert(.unsizedInitialLayout) + freeze(using: normalizedSize) + return observation( + outcome: didChangeLiveSize ? .observedWithoutStableUpdate : .unchanged + ) + } + + let didReleaseUnsizedInitialLayout = holdReasons.contains(.unsizedInitialLayout) + if didReleaseUnsizedInitialLayout { + removeHold( + .unsizedInitialLayout, + liveSize: normalizedSize, + releasePolicy: .adoptLatestLive + ) + } + guard !isGeometryFrozen else { + return observation( + outcome: didChangeLiveSize ? .observedWithoutStableUpdate : .unchanged + ) + } + guard lastStableSize != normalizedSize else { + return observation( + outcome: didReleaseUnsizedInitialLayout + ? .appliedStableSize + : didChangeLiveSize ? .observedWithoutStableUpdate : .unchanged + ) + } + + lastStableSize = normalizedSize + return observation(outcome: .appliedStableSize) + } + + @discardableResult + mutating func setSheetPresented( + _ isPresented: Bool, + liveSize: CGSize + ) -> GhosttyTerminalViewportGeometryHoldEffect { + setGeometryHold(.sheet, isActive: isPresented, liveSize: liveSize) + } + + @discardableResult + mutating func setCoveredPresentation( + _ isCovered: Bool, + liveSize: CGSize + ) -> GhosttyTerminalViewportGeometryHoldEffect { + setGeometryHold(.coveredPresentation, isActive: isCovered, liveSize: liveSize) + } + + private mutating func setGeometryHold( + _ reason: GhosttyTerminalViewportHoldReason, + isActive: Bool, + liveSize: CGSize, + releasePolicy: ReleasePolicy = .adoptLatestLive + ) -> GhosttyTerminalViewportGeometryHoldEffect { + let previousEffectiveSize = effectiveSize(liveSize: liveSize) + if isActive { + holdReasons.insert(reason) + freeze(using: liveSize) + return .hold(effectiveSize: effectiveSize(liveSize: liveSize)) + } else { + removeHold(reason, liveSize: liveSize, releasePolicy: releasePolicy) + return .release(previousEffectiveSize: previousEffectiveSize) + } + } + + @discardableResult + mutating func beginKeyboardTransition( + target: GhosttyKeyboardViewportTransitionTarget?, + allowsTargetOverride: Bool, + liveSize: CGSize + ) -> Bool { + let wasActive = isKeyboardTransitionActive + holdReasons.insert(.keyboardTransition) + + if keyboardTransitionTarget == nil || allowsTargetOverride { + keyboardTransitionTarget = target + } + + return !wasActive + } + + mutating func completeKeyboardTransition(liveSize: CGSize) { + keyboardTransitionTarget = nil + removeHold(.keyboardTransition, liveSize: liveSize, releasePolicy: .adoptLatestLive) + } + + @discardableResult + mutating func requestTopologyRefocus(liveSize: CGSize) -> GhosttyTerminalViewportTopologyRefocusEffect { + holdReasons.insert(.topologyRefocus) + freeze(using: liveSize) + return .hold(effectiveSize: effectiveSize(liveSize: liveSize)) + } + + @discardableResult + mutating func completeTopologyRefocus( + liveSize: CGSize, + releasePolicy: ReleasePolicy + ) -> GhosttyTerminalViewportTopologyRefocusEffect { + let previousEffectiveSize = effectiveSize(liveSize: liveSize) + removeHold(.topologyRefocus, liveSize: liveSize, releasePolicy: releasePolicy) + return .release(previousEffectiveSize: previousEffectiveSize) + } + + @discardableResult + mutating func cancelTopologyRefocus(liveSize: CGSize) -> GhosttyTerminalViewportTopologyRefocusEffect { + guard isTopologyRefocusActive else { return .inactive } + let previousEffectiveSize = effectiveSize(liveSize: liveSize) + removeHold(.topologyRefocus, liveSize: liveSize, releasePolicy: .adoptLatestLive) + return .release(previousEffectiveSize: previousEffectiveSize) + } + + static func normalized(_ size: CGSize) -> CGSize { + CGSize( + width: normalizedDimension(size.width), + height: normalizedDimension(size.height) + ) + } + + private static func normalizedDimension(_ value: CGFloat) -> CGFloat { + guard value.isFinite, value > 1 else { return 1 } + return value + } + + private static func isUsable(_ size: CGSize) -> Bool { + size.width > 1 && size.height > 1 + } + + private static func isGeometryHold(_ reason: GhosttyTerminalViewportHoldReason) -> Bool { + switch reason { + case .sheet, .coveredPresentation, .topologyRefocus, .unsizedInitialLayout: + return true + case .keyboardTransition: + return false + } + } + + private mutating func freeze(using liveSize: CGSize) { + guard frozenSize == nil else { return } + if Self.isUsable(lastStableSize) { + frozenSize = lastStableSize + return + } + + let normalizedSize = Self.normalized(liveSize) + if Self.isUsable(normalizedSize) { + frozenSize = normalizedSize + } + } + + private mutating func removeHold( + _ reason: GhosttyTerminalViewportHoldReason, + liveSize: CGSize, + releasePolicy: ReleasePolicy + ) { + holdReasons.remove(reason) + guard Self.isGeometryHold(reason) else { return } + + if isGeometryFrozen { + rememberDeferredReleasePolicy(releasePolicy) + return + } + + releaseFreeze(liveSize: liveSize, releasePolicy: releasePolicy) + } + + private mutating func rememberDeferredReleasePolicy(_ releasePolicy: ReleasePolicy) { + guard releasePolicy == .preserveCurrentEffective else { return } + deferredReleasePolicy = .preserveCurrentEffective + } + + private mutating func releaseFreeze( + liveSize: CGSize, + releasePolicy: ReleasePolicy + ) { + let finalReleasePolicy = mergedReleasePolicy(releasePolicy) + let normalizedSize = Self.normalized(liveSize) + + switch finalReleasePolicy { + case .adoptLatestLive: + if Self.isUsable(normalizedSize) { + lastStableSize = normalizedSize + } else if let frozenSize, Self.isUsable(frozenSize) { + lastStableSize = frozenSize + } + + case .preserveCurrentEffective: + if let frozenSize, Self.isUsable(frozenSize) { + lastStableSize = frozenSize + } else if !Self.isUsable(lastStableSize), Self.isUsable(normalizedSize) { + lastStableSize = normalizedSize + } + } + + frozenSize = nil + deferredReleasePolicy = nil + } + + private func mergedReleasePolicy(_ releasePolicy: ReleasePolicy) -> ReleasePolicy { + if deferredReleasePolicy == .preserveCurrentEffective { + return .preserveCurrentEffective + } + return releasePolicy + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxPrefixInputBuffer.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxPrefixInputBuffer.swift new file mode 100644 index 00000000..ba67e29b --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxPrefixInputBuffer.swift @@ -0,0 +1,49 @@ +struct GhosttyTmuxPrefixInputBuffer: Equatable { + enum Action: Equatable { + case submit(String) + case armPrefix(token: UInt64) + case enterCopyMode(fallbackInput: String) + } + + static let defaultPrefixInput = "\u{2}" + + private var pendingInput: String? + private var flushToken: UInt64 = 0 + + mutating func handleText(_ text: String) -> Action { + if let pendingInput { + self.pendingInput = nil + invalidateFlushToken() + + guard text == "[" else { + return .submit(pendingInput + text) + } + + return .enterCopyMode(fallbackInput: pendingInput + text) + } + + guard text == Self.defaultPrefixInput else { + return .submit(text) + } + + pendingInput = text + invalidateFlushToken() + return .armPrefix(token: flushToken) + } + + mutating func flushPendingInput() -> String? { + guard let pendingInput else { return nil } + self.pendingInput = nil + invalidateFlushToken() + return pendingInput + } + + mutating func flushPendingInput(matching token: UInt64) -> String? { + guard flushToken == token else { return nil } + return flushPendingInput() + } + + private mutating func invalidateFlushToken() { + flushToken &+= 1 + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyViewportSizing.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyViewportSizing.swift new file mode 100644 index 00000000..4ac72d61 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyViewportSizing.swift @@ -0,0 +1,19 @@ +import CoreGraphics + +enum GhosttyViewportSizing { + static func normalizedHeight(_ height: CGFloat) -> CGFloat { + guard height.isFinite, height > 0 else { return 0 } + return ceil(height) + } +} + +struct GhosttySoftwareKeyboardVisibility { + static func visibleOverlapHeight(frameEnd: CGRect, screenBounds: CGRect) -> CGFloat { + guard frameEnd.width > 0, frameEnd.height > 0, + frameEnd.minY < screenBounds.maxY - 1 + else { return 0 } + let overlap = frameEnd.intersection(screenBounds) + guard !overlap.isNull, overlap.height.isFinite, overlap.height > 0 else { return 0 } + return overlap.height + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift b/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift new file mode 100644 index 00000000..e340b038 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift @@ -0,0 +1,37 @@ +import Foundation + +enum GhosttyRuntimeTrace { + private static let diagnosticsEnabled = + ProcessInfo.processInfo.environment["REMUX_TRACE_GHOSTTY_IO"] == "1" + || ProcessInfo.processInfo.environment["REMUX_TRACE_GHOSTTY_DIAGNOSTICS"] == "1" + static let perfEnabled = ProcessInfo.processInfo.environment["REMUX_TRACE_PERF"] == "1" + private static let tmuxViewportEnabled = + ProcessInfo.processInfo.environment["REMUX_TRACE_TMUX_VIEWPORT"] == "1" + + static func nowNanos() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + static func elapsedMilliseconds( + from start: UInt64, + to end: UInt64 = nowNanos() + ) -> String { + String(format: "%.3f", Double(end &- start) / 1_000_000) + } + + static func diagnostics(_ message: @autoclosure () -> String) { + guard diagnosticsEnabled else { return } + NSLog("MoriRemote diag %@", message()) + } + + static func perf(_ message: @autoclosure () -> String) { + guard perfEnabled else { return } + let thread = Thread.isMainThread ? "main" : (Thread.current.name ?? "bg") + NSLog("MoriRemote perf t=%llu thread=%@ %@", nowNanos(), thread, message()) + } + + static func tmuxViewport(_ message: @autoclosure () -> String) { + guard tmuxViewportEnabled else { return } + NSLog("MoriRemote tmuxViewport t=%llu %@", nowNanos(), message()) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxControlTransport.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxControlTransport.swift new file mode 100644 index 00000000..3e8b66ac --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxControlTransport.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The terminal core speaks only this sans-I/O wire boundary. Phase 2 will +/// adapt Mori's SSH transport; keeping it protocol-only prevents the transplant +/// from depending on SSH, account, SFTP, or forwarding types. +protocol TmuxControlTransport: Sendable { + var receivedBytes: AsyncThrowingStream { get } + func prepare() async + func start(initialViewport: TmuxControlViewport?) async throws + func send(_ data: Data) async throws + func close(disposition: TmuxControlTransportCloseDisposition) async +} + +protocol TmuxControlTransportLivenessChecking: Sendable { + func isControlChannelActive() async -> Bool +} + +enum TmuxControlTransportCloseDisposition: Equatable, Sendable { + case reusable + case invalidated +} + +extension TmuxControlTransport { + func prepare() async {} +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxControlViewport.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxControlViewport.swift new file mode 100644 index 00000000..be6602a5 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxControlViewport.swift @@ -0,0 +1,28 @@ +struct TmuxControlViewport: Equatable, Sendable { + static let `default` = TmuxControlViewport( + columns: 120, + rows: 40, + pixelWidth: 0, + pixelHeight: 0 + ) + + let columns: UInt16 + let rows: UInt16 + let pixelWidth: UInt32 + let pixelHeight: UInt32 +} + +extension TmuxControlViewport { + init(clientSize: TmuxSessionController.ClientSize) { + self.init( + columns: Self.clampedCellCount(clientSize.cols), + rows: Self.clampedCellCount(clientSize.rows), + pixelWidth: 0, + pixelHeight: 0 + ) + } + + private static func clampedCellCount(_ value: UInt32) -> UInt16 { + UInt16(min(max(value, 2), UInt32(UInt16.max))) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift new file mode 100644 index 00000000..8bf3bf9b --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Stable routing identity assigned by tmux. It identifies a pane within the +/// current tmux server lifetime; it does not identify a native render surface. +struct TmuxPaneID: RawRepresentable, Hashable, Comparable, Sendable, + CustomStringConvertible, ExpressibleByIntegerLiteral +{ + let rawValue: UInt64 + + init(rawValue: UInt64) { + self.rawValue = rawValue + } + + init(_ rawValue: UInt64) { + self.rawValue = rawValue + } + + init(integerLiteral value: UInt64) { + self.rawValue = value + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var description: String { + String(rawValue) + } +} + +/// Stable routing identity assigned by tmux. It identifies a window within +/// the current tmux server lifetime; it is not a presentation identity. +struct TmuxWindowID: RawRepresentable, Hashable, Comparable, Sendable, + CustomStringConvertible, ExpressibleByIntegerLiteral +{ + let rawValue: UInt64 + + init(rawValue: UInt64) { + self.rawValue = rawValue + } + + init(_ rawValue: UInt64) { + self.rawValue = rawValue + } + + init(integerLiteral value: UInt64) { + self.rawValue = value + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var description: String { + String(rawValue) + } +} + +/// Identity of one native Ghostty surface instance. Recreating the surface for +/// the same tmux pane creates a new value even though its `TmuxPaneID` is unchanged. +struct TerminalSurfaceInstanceID: RawRepresentable, Hashable, Sendable { + let rawValue: UUID + + init(rawValue: UUID) { + self.rawValue = rawValue + } + + init() { + self.rawValue = UUID() + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift new file mode 100644 index 00000000..4143c6b0 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift @@ -0,0 +1,701 @@ +import Foundation +import GhosttyKit +import QuartzCore +import UIKit + +/// MainActor owner of one retained canonical pane terminal and its current +/// renderer surface. Normal pane switches retain this entire object unchanged; +/// only renderer failure replaces the renderer over the same terminal and +/// UIView. Settings update the live surface in place. +@MainActor +final class TmuxPaneSurface { + let paneID: TmuxPaneID + let instanceID = TerminalSurfaceInstanceID() + let view: GhosttyKitSurfaceView + + private let app: ghostty_app_t + private let controller: TmuxSessionController + private let terminal: TmuxSessionController.RetainedPaneTerminal + private let callbackBox: CallbackBox + private let failureRelay: FailureRelay + private let onRendererFailure: (TmuxPaneID) -> Void + + private struct Renderer { + let handle: ghostty_terminal_surface_t + let control: GhosttyKitControlSurface + } + + private enum Lifecycle { + case active + case replacing + case closing + case closed + } + + private var renderer: Renderer? + private(set) var managedSurface: GhosttyManagedSurface? + private var lastPublishedViewportMetrics: GhosttySurfaceDisplayMetrics? + private var presented = false + private var sceneActive = true + private var lifecycle = Lifecycle.active + private var rendererFailureReported = false + private var closeCompletions: [@MainActor @Sendable () -> Void] = [] + private var framePublicationWait: FramePublicationWait? + private var presentationTask: Task? + private var presentationGeneration: UInt64 = 0 + private let frameRelay = FrameRelay() + private let publishedFrameObserver = GhosttyPublishedFrameObserver() + private var canonicalViewportMetrics: GhosttySurfaceDisplayMetrics + private var appliedDisplayMetrics: GhosttySurfaceDisplayMetrics + + enum CreateError: Error { + case surfaceCreationFailed(ghostty_terminal_surface_result_e) + case registrationFailed(TmuxSessionController.SurfaceRegistrationError) + } + + enum RendererReplacementResult: Equatable { + case replaced + case busy + case failed + } + + private final class FailureRelay { + weak var pane: TmuxPaneSurface? + } + + private final class FramePublicationWait: @unchecked Sendable { + let expectedWidth: UInt32 + let expectedHeight: UInt32 + var observation: NSKeyValueObservation? + var continuation: CheckedContinuation? + + init(expectedWidth: UInt32, expectedHeight: UInt32) { + self.expectedWidth = expectedWidth + self.expectedHeight = expectedHeight + } + } + + private final class FrameRelay: @unchecked Sendable { + weak var pane: TmuxPaneSurface? + } + + private final class LayerReference: @unchecked Sendable { + weak var layer: CALayer? + + init(_ layer: CALayer) { + self.layer = layer + } + } + + private final class CallbackBox: @unchecked Sendable { + let controller: TmuxSessionController + let paneID: TmuxPaneID + let failureRelay: FailureRelay + + init( + controller: TmuxSessionController, + paneID: TmuxPaneID, + failureRelay: FailureRelay + ) { + self.controller = controller + self.paneID = paneID + self.failureRelay = failureRelay + } + + static let writeCallback: ghostty_terminal_surface_write_cb = { userdata, pointer, count in + // ghostty.h: write_cb fires only from terminal-surface input + // operations on the presentation-owner thread, never output. + assert(Thread.isMainThread) + guard let userdata else { return false } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard count > 0 else { return true } + guard let pointer else { return false } + return box.controller.sendInput( + paneID: box.paneID, + Data(bytes: pointer, count: count) + ) + } + + static let healthCallback: ghostty_terminal_surface_renderer_health_cb = { userdata, health in + guard health == GHOSTTY_RENDERER_HEALTH_UNHEALTHY, let userdata else { return } + let box = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + DispatchQueue.main.async { [weak relay = box.failureRelay] in + MainActor.assumeIsolated { relay?.pane?.rendererDidFail() } + } + } + } + + static func create( + app: ghostty_app_t, + controller: TmuxSessionController, + terminal: TmuxSessionController.RetainedPaneTerminal, + baseConfig: ghostty_terminal_surface_config_s, + metrics: GhosttySurfaceDisplayMetrics, + theme: TerminalTheme, + onRendererFailure: @escaping @MainActor (TmuxPaneID) -> Void, + completion: @escaping @MainActor (Result) -> Void + ) { + let relay = FailureRelay() + let callbackBox = CallbackBox( + controller: controller, + paneID: terminal.paneID, + failureRelay: relay + ) + let view = GhosttyKitSurfaceView(frame: CGRect( + x: 0, + y: 0, + width: Double(metrics.pixelWidth) / metrics.contentScale, + height: Double(metrics.pixelHeight) / metrics.contentScale + )) + view.contentScaleFactor = metrics.contentScale + view.applyTerminalTheme(theme) + + var config = configured( + baseConfig, + view: view, + metrics: metrics, + callbackBox: callbackBox, + visible: false, + focused: false + ) + var nativeSurface: ghostty_terminal_surface_t? + let result = ghostty_terminal_surface_new( + app, + terminal.handle, + &config, + &nativeSurface + ) + guard result == GHOSTTY_TERMINAL_SURFACE_RESULT_OK, let nativeSurface else { + completion(.failure(.surfaceCreationFailed(result))) + return + } + view.alignGhosttyRendererSublayers() + + let pane = TmuxPaneSurface( + app: app, + controller: controller, + terminal: terminal, + view: view, + surface: nativeSurface, + metrics: metrics, + callbackBox: callbackBox, + failureRelay: relay, + onRendererFailure: onRendererFailure + ) + relay.pane = pane + controller.registerTerminalSurface( + paneID: terminal.paneID, + surface: nativeSurface + ) { result in + switch result { + case .success: + completion(.success(pane)) + case .failure(let error): + pane.destroyUnregisteredRenderer() + completion(.failure(.registrationFailed(error))) + } + } + } + + private init( + app: ghostty_app_t, + controller: TmuxSessionController, + terminal: TmuxSessionController.RetainedPaneTerminal, + view: GhosttyKitSurfaceView, + surface: ghostty_terminal_surface_t, + metrics: GhosttySurfaceDisplayMetrics, + callbackBox: CallbackBox, + failureRelay: FailureRelay, + onRendererFailure: @escaping (TmuxPaneID) -> Void + ) { + self.app = app + self.controller = controller + self.terminal = terminal + paneID = terminal.paneID + self.view = view + self.callbackBox = callbackBox + self.failureRelay = failureRelay + self.onRendererFailure = onRendererFailure + canonicalViewportMetrics = metrics + appliedDisplayMetrics = metrics + let control = GhosttyKitControlSurface( + surface: surface, + scaleFactor: metrics.contentScale, + onFailure: { [weak failureRelay] _ in + failureRelay?.pane?.rendererDidFail() + } + ) + renderer = Renderer(handle: surface, control: control) + frameRelay.pane = self + } + + var rawSurface: ghostty_terminal_surface_t? { renderer?.handle } + + func screenSurface( + onDisplayUpdate: @escaping (GhosttyManagedSurface, CGSize, CGFloat) -> Void + ) -> GhosttyManagedSurface { + if let managedSurface { + if renderer != nil { managedSurface.refreshInteractionState() } + managedSurface.onDisplayUpdate = onDisplayUpdate + return managedSurface + } + guard let renderer else { + preconditionFailure("first screen surface requires a registered renderer") + } + + let managed = GhosttyManagedSurface( + id: instanceID.rawValue, + view: view, + controlSurface: renderer.control, + paneOwner: self, + interactionState: renderer.control.interactionState() + ) + managed.onDisplayUpdate = onDisplayUpdate + managedSurface = managed + installPublishedFrameInteractionObservation() + applyPresentationActivity() + return managed + } + + func setPresented(_ presented: Bool) { + guard lifecycle != .closed, lifecycle != .closing else { return } + self.presented = presented + if presented { + installPublishedFrameInteractionObservation() + // A warm revisit may attach its retained genuine frame before a + // newer viewport-sized frame arrives. Resize first, then make the + // already-published pane surface visible and interactive. + _ = applyDisplayMetrics(canonicalViewportMetrics) + } + applyPresentationActivity() + } + + func setSceneActive(_ active: Bool) { + guard lifecycle != .closed, lifecycle != .closing else { return } + guard active != sceneActive else { return } + sceneActive = active + applyPresentationActivity() + } + + func refreshInteractionState() { + managedSurface?.refreshInteractionState() + } + + func updateCanonicalViewportMetrics(_ metrics: GhosttySurfaceDisplayMetrics) { + canonicalViewportMetrics = metrics + } + + @discardableResult + func updateDisplay(metrics: GhosttySurfaceDisplayMetrics) -> Bool { + canonicalViewportMetrics = metrics + return applyDisplayMetrics(metrics) + } + + func prepareForPresentation( + completion: @escaping @MainActor (Bool) -> Void + ) { + guard lifecycle == .active, !presented else { + completion(false) + return + } + cancelPresentationPreparation() + presentationGeneration &+= 1 + let generation = presentationGeneration + + if hasCurrentViewportFrame() { + completion(true) + return + } + + guard applyDisplayMetrics(canonicalViewportMetrics), + let rendererLayer = GhosttyRendererLayer.find(in: view.layer) + else { + completion(false) + return + } + + let expected = canonicalViewportMetrics + presentationTask = Task { @MainActor [weak self] in + guard let self else { return } + let didPublish = await waitForViewportFrame( + on: rendererLayer, + expectedWidth: expected.pixelWidth, + expectedHeight: expected.pixelHeight + ) + guard presentationGeneration == generation else { return } + presentationTask = nil + guard !Task.isCancelled, + expected == canonicalViewportMetrics, + didPublish + else { + completion(false) + return + } + lastPublishedViewportMetrics = expected + completion(true) + } + } + + func cancelPresentationPreparation() { + presentationGeneration &+= 1 + let hadTask = presentationTask != nil + let hadWait = framePublicationWait != nil + presentationTask?.cancel() + presentationTask = nil + if hadWait { + // The wait owns its transient visibility and hides exactly once. + cancelFramePublicationWait() + } else if hadTask, !presented { + // Publication may have completed with keep-visible immediately + // before the MainActor task is cancelled. + _ = renderer?.control.setVisible(false) + } + } + + @discardableResult + func applyTerminalConfiguration(theme: TerminalTheme) -> Bool { + guard lifecycle == .active, let renderer else { return false } + let result = ghostty_terminal_surface_update_config(renderer.handle) + guard result == GHOSTTY_TERMINAL_SURFACE_RESULT_OK else { + GhosttyRuntimeTrace.diagnostics( + "tmuxPane.configUpdate failed pane=\(paneID) result=\(String(describing: result))" + ) + return false + } + view.applyTerminalTheme(theme) + managedSurface?.notifyLocalSelectionGeometryChanged() + if !presented { lastPublishedViewportMetrics = nil } + return true + } + + func replaceRenderer( + baseConfig: ghostty_terminal_surface_config_s, + metrics: GhosttySurfaceDisplayMetrics, + theme: TerminalTheme, + completion: @escaping @MainActor (RendererReplacementResult) -> Void + ) { + guard lifecycle == .active else { + completion(lifecycle == .replacing ? .busy : .failed) + return + } + guard !presented else { + assertionFailure("session must unpublish before renderer replacement") + completion(.failed) + return + } + lifecycle = .replacing + publishedFrameObserver.invalidate() + // This call is the single replacement attempt for the triggering + // failure/settings change. Failures inside it return through + // completion; they must not recursively schedule another attempt. + rendererFailureReported = true + cancelPresentationPreparation() + lastPublishedViewportMetrics = nil + + let installReplacement = { [self] in + guard lifecycle == .replacing else { + finishCloseIfRendererless() + completion(.failed) + return + } + view.applyTerminalTheme(theme) + view.frame.size = CGSize( + width: Double(metrics.pixelWidth) / metrics.contentScale, + height: Double(metrics.pixelHeight) / metrics.contentScale + ) + view.contentScaleFactor = metrics.contentScale + canonicalViewportMetrics = metrics + appliedDisplayMetrics = metrics + + var config = Self.configured( + baseConfig, + view: view, + metrics: metrics, + callbackBox: callbackBox, + visible: false, + focused: false + ) + var replacement: ghostty_terminal_surface_t? + let result = ghostty_terminal_surface_new( + app, + terminal.handle, + &config, + &replacement + ) + guard result == GHOSTTY_TERMINAL_SURFACE_RESULT_OK, let replacement else { + lifecycle = .active + completion(.failed) + return + } + view.alignGhosttyRendererSublayers() + + let wrapper = GhosttyKitControlSurface( + surface: replacement, + scaleFactor: metrics.contentScale, + onFailure: { [weak failureRelay] _ in + failureRelay?.pane?.rendererDidFail() + } + ) + renderer = Renderer(handle: replacement, control: wrapper) + controller.registerTerminalSurface(paneID: paneID, surface: replacement) { [self] result in + guard case .success = result else { + wrapper.invalidate() + ghostty_terminal_surface_free(replacement) + renderer = nil + if lifecycle == .closing { + finishCloseIfRendererless() + } else { + lifecycle = .active + } + completion(.failed) + return + } + + guard lifecycle != .closing else { + controller.unregisterTerminalSurface( + paneID: paneID, + surface: replacement + ) { [self] in + wrapper.invalidate() + ghostty_terminal_surface_free(replacement) + renderer = nil + finishCloseIfRendererless() + completion(.failed) + } + return + } + lifecycle = .active + rendererFailureReported = false + managedSurface?.replaceControlSurface(wrapper) + installPublishedFrameInteractionObservation() + completion(.replaced) + } + } + + guard let oldRenderer = renderer else { + installReplacement() + return + } + controller.unregisterTerminalSurface( + paneID: paneID, + surface: oldRenderer.handle + ) { [self] in + oldRenderer.control.invalidate() + ghostty_terminal_surface_free(oldRenderer.handle) + if renderer?.handle == oldRenderer.handle { renderer = nil } + guard lifecycle != .closing else { + finishCloseIfRendererless() + completion(.failed) + return + } + installReplacement() + } + } + + var isClosing: Bool { lifecycle == .closing || lifecycle == .closed } + + func close(completion: @escaping @MainActor @Sendable () -> Void = {}) { + if lifecycle == .closed { + completion() + return + } + closeCompletions.append(completion) + guard lifecycle != .closing else { return } + let wasReplacing = lifecycle == .replacing + lifecycle = .closing + publishedFrameObserver.invalidate() + cancelPresentationPreparation() + frameRelay.pane = nil + failureRelay.pane = nil + managedSurface?.prepareForPermanentRemoval() + guard !wasReplacing else { return } + guard let renderer else { + finishCloseIfRendererless() + return + } + controller.unregisterTerminalSurface( + paneID: paneID, + surface: renderer.handle + ) { [self] in + renderer.control.invalidate() + ghostty_terminal_surface_free(renderer.handle) + if self.renderer?.handle == renderer.handle { self.renderer = nil } + finishCloseIfRendererless() + } + } + + private func installPublishedFrameInteractionObservation() { + guard lifecycle == .active, + let managedSurface, + let rendererLayer = GhosttyRendererLayer.find(in: view.layer) + else { return } + publishedFrameObserver.observe(rendererLayer, target: managedSurface) + } + + private func rendererDidFail() { + guard lifecycle == .active, !rendererFailureReported else { return } + rendererFailureReported = true + onRendererFailure(paneID) + } + + private func applyPresentationActivity() { + let active = presented && sceneActive + if let managedSurface { + managedSurface.setFocused(active) + managedSurface.setVisible(active) + } else { + _ = renderer?.control.setFocused(active) + _ = renderer?.control.setVisible(active) + } + } + + private func destroyUnregisteredRenderer() { + lifecycle = .closed + publishedFrameObserver.invalidate() + cancelPresentationPreparation() + frameRelay.pane = nil + failureRelay.pane = nil + renderer?.control.invalidate() + if let renderer { ghostty_terminal_surface_free(renderer.handle) } + renderer = nil + } + + private func finishCloseIfRendererless() { + guard lifecycle == .closing, renderer == nil else { return } + lifecycle = .closed + let completions = closeCompletions + closeCompletions.removeAll() + for completion in completions { completion() } + } + + private static func configured( + _ base: ghostty_terminal_surface_config_s, + view: GhosttyKitSurfaceView, + metrics: GhosttySurfaceDisplayMetrics, + callbackBox: CallbackBox, + visible: Bool, + focused: Bool + ) -> ghostty_terminal_surface_config_s { + var config = base + config.platform_tag = GHOSTTY_PLATFORM_IOS + config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( + uiview: Unmanaged.passUnretained(view).toOpaque() + )) + config.userdata = Unmanaged.passUnretained(callbackBox).toOpaque() + config.renderer_health_cb = CallbackBox.healthCallback + config.write_cb = CallbackBox.writeCallback + config.scale_factor = metrics.contentScale + config.width_px = metrics.pixelWidth + config.height_px = metrics.pixelHeight + config.visible = visible + config.focused = focused + return config + } + + private func waitForViewportFrame( + on layer: CALayer, + expectedWidth: UInt32, + expectedHeight: UInt32 + ) async -> Bool { + // Resize is ordered before visibility. The next published IOSurface is + // therefore the first frame safe to hand to the selected viewport. + layer.displayIfNeeded() + return await withCheckedContinuation { continuation in + guard !Task.isCancelled, framePublicationWait == nil else { + continuation.resume(returning: false) + return + } + let wait = FramePublicationWait( + expectedWidth: expectedWidth, + expectedHeight: expectedHeight + ) + let layerReference = LayerReference(layer) + let relay = frameRelay + wait.continuation = continuation + framePublicationWait = wait + wait.observation = layer.observe(\.contents, options: [.new]) { [weak wait] _, _ in + DispatchQueue.main.async { + MainActor.assumeIsolated { + guard let pane = relay.pane, + let wait, + let layer = layerReference.layer + else { return } + pane.finishFramePublicationIfMatching(wait, layer: layer) + } + } + } + _ = renderer?.control.setFocused(true) + guard renderer?.control.setVisible(true) == true else { + finishFramePublicationWait(wait, didPublish: false) + return + } + } + } + + private func finishFramePublicationIfMatching( + _ wait: FramePublicationWait, + layer: CALayer + ) { + guard framePublicationWait === wait, + let dimensions = GhosttyRendererLayer.dimensions(in: layer), + dimensions.width == Int(wait.expectedWidth), + dimensions.height == Int(wait.expectedHeight) + else { return } + finishFramePublicationWait(wait, didPublish: true) + } + + private func finishFramePublicationWait( + _ wait: FramePublicationWait, + didPublish: Bool + ) { + guard framePublicationWait === wait else { return } + wait.observation?.invalidate() + wait.observation = nil + framePublicationWait = nil + if !didPublish, !presented { + _ = renderer?.control.setVisible(false) + } + let continuation = wait.continuation + wait.continuation = nil + continuation?.resume(returning: didPublish) + } + + private func cancelFramePublicationWait() { + guard let wait = framePublicationWait else { return } + finishFramePublicationWait(wait, didPublish: false) + } + + private func hasCurrentViewportFrame() -> Bool { + guard lastPublishedViewportMetrics == canonicalViewportMetrics, + let layer = GhosttyRendererLayer.find(in: view.layer), + let dimensions = GhosttyRendererLayer.dimensions(in: layer) + else { return false } + return dimensions.width == Int(canonicalViewportMetrics.pixelWidth) + && dimensions.height == Int(canonicalViewportMetrics.pixelHeight) + } + + private func applyDisplayMetrics( + _ metrics: GhosttySurfaceDisplayMetrics + ) -> Bool { + guard let renderer, + metrics.contentScale == canonicalViewportMetrics.contentScale + else { return false } + if metrics == appliedDisplayMetrics { return true } + view.frame.size = CGSize( + width: Double(metrics.pixelWidth) / metrics.contentScale, + height: Double(metrics.pixelHeight) / metrics.contentScale + ) + view.contentScaleFactor = metrics.contentScale + view.alignGhosttyRendererSublayers() + guard renderer.control.updateDisplay(metrics: metrics) else { + return false + } + appliedDisplayMetrics = metrics + return true + } + + deinit { + let finalLifecycle = lifecycle + assert(finalLifecycle == .closed, "TmuxPaneSurface deinit without close()") + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift new file mode 100644 index 00000000..4474def9 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift @@ -0,0 +1,1168 @@ +import Foundation +import GhosttyKit + +private func decodeTmuxString(_ bytes: ghostty_tmux_bytes_s) -> String { + guard let pointer = bytes.ptr, bytes.len > 0 else { return "" } + return String( + decoding: UnsafeBufferPointer(start: pointer, count: bytes.len), + as: UTF8.self + ) +} + +/// Queue-confined host for Ghostty's sans-I/O tmux control client. +/// +/// The SSH transport owns the wire. This type owns protocol parsing, copied +/// topology, command correlation, canonical pane-terminal handoff, and the +/// borrowed renderer handles used to notify retained pane surfaces directly. +/// Every libghostty call for one client runs on `queue`. +final class TmuxSessionController: @unchecked Sendable { + enum SessionState: Equatable, Sendable { + case detached(DetachReason?) + case attaching + case syncing + case ready + case closed(CloseReason) + } + + enum DetachReason: Equatable, Sendable { + case serverExited(String?) + case channelAborted + case outOfMemory + case transportClosed + } + + enum CloseReason: Equatable, Sendable { + case unsupportedVersion(String) + } + + struct WindowInfo: Equatable, Identifiable, Sendable { + let id: TmuxWindowID + let name: String + let active: Bool + let zoomed: Bool + let width: UInt32 + let height: UInt32 + let activePaneID: TmuxPaneID? + } + + struct PaneInfo: Equatable, Identifiable, Sendable { + enum Phase: Equatable, Sendable { + case hydrating + case live + } + + let id: TmuxPaneID + let windowID: TmuxWindowID + let x: UInt32 + let y: UInt32 + let width: UInt32 + let height: UInt32 + let phase: Phase + } + + struct TopologySnapshot: Equatable, Sendable { + let sessionName: String + let windows: [WindowInfo] + let panes: [PaneInfo] + let activeWindowID: TmuxWindowID? + } + + enum Request: Equatable, Sendable { + case selectWindow + case selectPane + case sharedMutation + case sendInput + } + + enum SharedMutation: Sendable { + case newWindow + case splitHorizontal + case splitVertical + case closePane + case closeWindow + } + + struct ClientSize: Sendable, Equatable { + let cols: UInt32 + let rows: UInt32 + + var controlViewport: TmuxControlViewport? { + guard let columns = UInt16(exactly: cols), + let rows = UInt16(exactly: rows), + columns > 0, + rows > 0 + else { return nil } + return TmuxControlViewport( + columns: columns, + rows: rows, + pixelWidth: 0, + pixelHeight: 0 + ) + } + } + + enum StartError: Error { + case invalidInitialGrid + case alreadyStarted + case creationFailed(ghostty_tmux_result_e) + } + + enum SurfaceRegistrationError: Error { + case clientUnavailable + case paneUnknown + case alreadyRegistered + } + + struct AgentMetadataQueryResult: Sendable { + enum Status: Sendable { case success, skipped, failed } + let status: Status + let body: String + } + + /// One retained reference to ControlClient's canonical pane terminal. + /// Ownership transfers from the writer queue to MainActor exactly once. + final class RetainedPaneTerminal: @unchecked Sendable { + let paneID: TmuxPaneID + let handle: ghostty_terminal_t + + fileprivate init(paneID: TmuxPaneID, handle: ghostty_terminal_t) { + self.paneID = paneID + self.handle = handle + } + + deinit { + ghostty_terminal_release(handle) + } + } + + struct Callbacks: Sendable { + var onState: @Sendable (SessionState) -> Void = { _ in } + var onTopology: @Sendable (TopologySnapshot) -> Void = { _ in } + var onPaneRemoved: @Sendable (TmuxPaneID) -> Void = { _ in } + var onPaneTerminal: @Sendable (RetainedPaneTerminal) -> Void = { _ in } + var onPanePhaseChanged: @Sendable (TmuxPaneID, PaneInfo.Phase) -> Void = { _, _ in } + var onActivePaneChanged: @Sendable (TmuxPaneID) -> Void = { _ in } + var onPaneSurfaceFailed: @Sendable (TmuxPaneID) -> Void = { _ in } + var onRequestFailed: @Sendable (Request) -> Void = { _ in } + } + + /// Pointer values cross actor boundaries only as opaque native identities. + private struct TerminalSurfaceHandle: @unchecked Sendable, Equatable { + let value: ghostty_terminal_surface_t + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } + } + + private enum NavigationIntent: Equatable { + case pane(TmuxPaneID) + case window(TmuxWindowID, preferredPaneID: TmuxPaneID?) + } + + private enum OutstandingRequest { + case action(Request, topologyRevisionAtSubmission: UInt64) + case agentMetadata(@Sendable (AgentMetadataQueryResult) -> Void) + } + + private struct DesiredPaneRefresh { + let size: ClientSize + let failureRequest: Request + let requiredAfterPresentation: Bool + } + + private enum PaneRefreshState { + case deferred(DesiredPaneRefresh) + case inFlight(size: ClientSize, followUp: DesiredPaneRefresh?) + } + + let queue: DispatchQueue + + private let callbacks: Callbacks + private var client: ghostty_tmux_client_t? + private var state: SessionState = .detached(nil) + private var topology: TopologySnapshot? + private var retainedPaneIDs: Set = [] + private var engineSizeByPaneID: [TmuxPaneID: ClientSize] = [:] + private var refreshStateByPaneID: [TmuxPaneID: PaneRefreshState] = [:] + private var surfacesByPaneID: [TmuxPaneID: TerminalSurfaceHandle] = [:] + private var requestsByToken: [UInt64: OutstandingRequest] = [:] + private var deferredNavigationIntent: NavigationIntent? + private var successfulMutationRequiredAfterRevision: UInt64? + private var topologyRevision: UInt64 = 0 + private var outboundSink: (@Sendable (Data) -> Void)? + private var shuttingDown = false + private let historyLineLimit: Int + + init( + historyLineLimit: Int = 2_000, + callbacks: Callbacks, + queue: DispatchQueue = DispatchQueue(label: "remux.tmux.session.writer") + ) { + self.historyLineLimit = max(2_000, min(historyLineLimit, 10_000)) + self.callbacks = callbacks + self.queue = queue + } + + deinit { + assert(client == nil, "TmuxSessionController deinit without shutdown()") + } + + func setOutboundSink(_ sink: (@Sendable (Data) -> Void)?) { + queue.async { [self] in + outboundSink = sink + } + } + + /// Construct the native client only after transport.start has opened the + /// grouped shadow control channel. + /// Starts an unsized native client. tmux topology owns terminal engine + /// sizes; UIKit viewport metrics never become control-client dimensions. + func start(completion: @escaping @Sendable (Result) -> Void) { + queue.async { [self] in + guard client == nil, !shuttingDown else { + completion(.failure(.alreadyStarted)) + return + } + + var config = ghostty_tmux_client_config_new() + config.userdata = Unmanaged.passUnretained(self).toOpaque() + config.action_cb = { userdata, action in + guard let userdata, let action else { return } + let controller = Unmanaged + .fromOpaque(userdata).takeUnretainedValue() + controller.handleAction(action.pointee) + } + config.history_line_limit_is_set = true + config.history_line_limit = self.historyLineLimit + config.max_scrollback = 10_000 + config.initial_columns = 0 + config.initial_rows = 0 + + var created: ghostty_tmux_client_t? + let result = ghostty_tmux_client_new(&config, &created) + guard result == GHOSTTY_TMUX_RESULT_OK, let created else { + completion(.failure(.creationFailed(result))) + return + } + client = created + publishState(.attaching) + completion(.success(())) + } + } + + func transportClosed() { + queue.async { [self] in + guard !shuttingDown else { return } + failOutstandingAgentMetadataQueries() + deferredNavigationIntent = nil + successfulMutationRequiredAfterRevision = nil + guard case .closed = state else { + publishState(.detached(.transportClosed)) + return + } + } + } + + /// Publish an intentional attachment stop without classifying it as a + /// transport failure. Link teardown itself stays silent because it is + /// also used by startup-failure cleanup and session shutdown. + func attachmentStopped() { + queue.async { [self] in + guard !shuttingDown else { return } + failOutstandingAgentMetadataQueries() + deferredNavigationIntent = nil + successfulMutationRequiredAfterRevision = nil + guard case .closed = state else { + publishState(.detached(nil)) + return + } + } + } + + func shutdown(completion: @escaping @Sendable () -> Void = {}) { + queue.async { [self] in + shuttingDown = true + outboundSink = nil + let agentMetadataQueries = outstandingAgentMetadataQueries() + requestsByToken.removeAll() + agentMetadataQueries.forEach { $0(.init(status: .failed, body: "")) } + deferredNavigationIntent = nil + successfulMutationRequiredAfterRevision = nil + topology = nil + retainedPaneIDs.removeAll() + engineSizeByPaneID.removeAll() + refreshStateByPaneID.removeAll() + assert(surfacesByPaneID.isEmpty, "terminal surfaces must unregister before client free") + surfacesByPaneID.removeAll() + if let client { + let result = ghostty_tmux_client_free(client) + assert(result == GHOSTTY_TMUX_RESULT_OK, "ghostty_tmux_client_free failed: \(result)") + } + client = nil + DispatchQueue.main.async(execute: completion) + } + } + + func pump(_ data: Data) { + let enqueuedAt = GhosttyRuntimeTrace.perfEnabled ? GhosttyRuntimeTrace.nowNanos() : 0 + queue.async { [self, data] in + guard let client, !shuttingDown else { return } + let applyStart = GhosttyRuntimeTrace.perfEnabled ? GhosttyRuntimeTrace.nowNanos() : 0 + let result = data.withUnsafeBytes { bytes in + ghostty_tmux_client_feed( + client, + bytes.bindMemory(to: UInt8.self).baseAddress, + bytes.count + ) + } + if state == .attaching { + publishState(.syncing) + } + let outboundBytes = drainOutbound() + GhosttyRuntimeTrace.perf( + "tmuxFeed bytes=\(data.count) wait_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: enqueuedAt, to: applyStart)) apply_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: applyStart)) outbound_bytes=\(outboundBytes) result=\(result)" + ) + guard result == GHOSTTY_TMUX_RESULT_OK else { + handleClientFailure(result) + return + } + } + } + + @discardableResult + private func drainOutbound() -> Int { + preconditionOnWriterQueue() + guard let client else { return 0 } + var bytes = ghostty_tmux_bytes_s() + let result = ghostty_tmux_client_outbound(client, &bytes) + guard result == GHOSTTY_TMUX_RESULT_OK else { + handleClientFailure(result) + return 0 + } + guard bytes.len > 0 else { return 0 } + guard let pointer = bytes.ptr else { + handleClientFailure(GHOSTTY_TMUX_RESULT_CLIENT_FAILED) + return 0 + } + + let owned = Data(bytes: pointer, count: bytes.len) + let consumeResult = ghostty_tmux_client_consume(client, bytes.len) + guard consumeResult == GHOSTTY_TMUX_RESULT_OK else { + handleClientFailure(consumeResult) + return 0 + } + outboundSink?(owned) + return owned.count + } + + // MARK: Native actions + + private func handleAction(_ action: ghostty_tmux_action_s) { + preconditionOnWriterQueue() + switch action.tag { + case GHOSTTY_TMUX_ACTION_EXIT: + failOutstandingAgentMetadataQueries() + deferredNavigationIntent = nil + successfulMutationRequiredAfterRevision = nil + let exit = action.value.exit + let detail = decodeTmuxString(exit.detail) + switch exit.reason { + case GHOSTTY_TMUX_EXIT_UNSUPPORTED_VERSION: + publishState(.closed(.unsupportedVersion(detail))) + case GHOSTTY_TMUX_EXIT_SERVER: + publishState(.detached(.serverExited(detail.isEmpty ? nil : detail))) + default: + publishState(.detached(.channelAborted)) + } + + case GHOSTTY_TMUX_ACTION_TOPOLOGY: + handleTopology(action.value.topology) + + case GHOSTTY_TMUX_ACTION_PANE_CHANGED: + handlePaneChanged(TmuxPaneID(action.value.pane_id)) + + case GHOSTTY_TMUX_ACTION_COMMAND_COMPLETE: + handleCommandCompletion(action.value.command) + + case GHOSTTY_TMUX_ACTION_INPUT_FAILED: + _ = decodeTmuxString(action.value.input_failure) + DispatchQueue.main.async { self.callbacks.onRequestFailed(.sendInput) } + + default: + handleClientFailure(GHOSTTY_TMUX_RESULT_CLIENT_FAILED) + } + } + + private func handleTopology(_ action: ghostty_tmux_topology_action_s) { + preconditionOnWriterQueue() + var accumulator = TopologyAccumulator() + let visitResult = withUnsafeMutablePointer(to: &accumulator) { accumulator in + ghostty_tmux_topology_visit( + action.view, + UnsafeMutableRawPointer(accumulator), + { userdata, record in + guard let userdata, let record else { return } + userdata.assumingMemoryBound(to: TopologyAccumulator.self) + .pointee.append(record.pointee) + } + ) + } + guard visitResult == GHOSTTY_TMUX_RESULT_OK else { + handleClientFailure(visitResult) + return + } + + let snapshot = TopologySnapshot( + sessionName: decodeTmuxString(action.session_name), + windows: accumulator.windows, + panes: accumulator.panes, + activeWindowID: accumulator.windows.first(where: \.active)?.id + ) + let previousPaneIDs = Set(topology?.panes.map(\.id) ?? []) + let nextPaneIDs = Set(snapshot.panes.map(\.id)) + let removed = previousPaneIDs.subtracting(nextPaneIDs).sorted() + for pane in snapshot.panes where refreshStateByPaneID[pane.id] == nil { + // Topology resizes each non-refreshing canonical terminal to its + // effective tmux grid. A refresh owns its target grid until its + // deterministic PANE_CHANGED completion. + if let size = Self.effectiveEngineSize(for: pane, in: snapshot) { + engineSizeByPaneID[pane.id] = size + } else { + engineSizeByPaneID.removeValue(forKey: pane.id) + } + } + topology = snapshot + topologyRevision &+= 1 + clearSatisfiedMutationBarrier() + + for paneID in removed { + retainedPaneIDs.remove(paneID) + engineSizeByPaneID.removeValue(forKey: paneID) + refreshStateByPaneID.removeValue(forKey: paneID) + surfacesByPaneID.removeValue(forKey: paneID) + } + let didBecomeReady = state != .ready + state = .ready + DispatchQueue.main.async { + if didBecomeReady { self.callbacks.onState(.ready) } + for paneID in removed { self.callbacks.onPaneRemoved(paneID) } + self.callbacks.onTopology(snapshot) + } + admitDeferredNavigationIfPossible() + } + + private func handlePaneChanged(_ paneID: TmuxPaneID) { + preconditionOnWriterQueue() + guard let client else { return } + + let completedRefresh: (size: ClientSize, followUp: DesiredPaneRefresh?)? + if case .inFlight(let size, let followUp) = refreshStateByPaneID[paneID] { + completedRefresh = (size, followUp) + if let topology, + let pane = topology.panes.first(where: { $0.id == paneID }), + let actualSize = Self.effectiveEngineSize(for: pane, in: topology) { + engineSizeByPaneID[paneID] = actualSize + } else { + engineSizeByPaneID.removeValue(forKey: paneID) + } + refreshStateByPaneID.removeValue(forKey: paneID) + } else { + completedRefresh = nil + } + + if retainedPaneIDs.insert(paneID).inserted { + var terminal: ghostty_terminal_t? + let result = ghostty_tmux_client_retain_pane_terminal( + client, + paneID.rawValue, + &terminal + ) + guard result == GHOSTTY_TMUX_RESULT_OK, let terminal else { + retainedPaneIDs.remove(paneID) + DispatchQueue.main.async { self.callbacks.onPaneSurfaceFailed(paneID) } + return + } + let handoff = RetainedPaneTerminal(paneID: paneID, handle: terminal) + DispatchQueue.main.async { self.callbacks.onPaneTerminal(handoff) } + retryDeferredPaneRefreshIfNeeded(paneID, notifyPhaseChange: true) + return + } + + if let surface = surfacesByPaneID[paneID] { + let result = ghostty_terminal_surface_terminal_changed(surface.value) + if result != GHOSTTY_TERMINAL_SURFACE_RESULT_OK { + surfacesByPaneID.removeValue(forKey: paneID) + DispatchQueue.main.async { self.callbacks.onPaneSurfaceFailed(paneID) } + return + } + } + + if let completedRefresh { + if let followUp = completedRefresh.followUp { + refreshStateByPaneID[paneID] = .deferred(followUp) + retryDeferredPaneRefreshIfNeeded(paneID, notifyPhaseChange: false) + } + guard refreshStateByPaneID[paneID] != nil else { + DispatchQueue.main.async { + self.callbacks.onPanePhaseChanged(paneID, .live) + } + if activePaneID(in: topology) == paneID { + DispatchQueue.main.async { self.callbacks.onActivePaneChanged(paneID) } + } + return + } + return + } + + if activePaneID(in: topology) == paneID { + DispatchQueue.main.async { self.callbacks.onActivePaneChanged(paneID) } + } + } + + private func handleCommandCompletion(_ completion: ghostty_tmux_command_completion_s) { + preconditionOnWriterQueue() + guard let outstanding = requestsByToken.removeValue(forKey: completion.token) else { return } + switch outstanding { + case .agentMetadata(let completionHandler): + let status: AgentMetadataQueryResult.Status = switch completion.status { + case GHOSTTY_TMUX_COMMAND_SUCCESS: .success + case GHOSTTY_TMUX_COMMAND_SKIPPED: .skipped + default: .failed + } + completionHandler(.init(status: status, body: decodeTmuxString(completion.body))) + return + case .action(let request, let topologyRevisionAtSubmission): + handleActionCompletion( + completion, + request: request, + topologyRevisionAtSubmission: topologyRevisionAtSubmission + ) + } + } + + private func handleActionCompletion( + _ completion: ghostty_tmux_command_completion_s, + request: Request, + topologyRevisionAtSubmission: UInt64 + ) { + preconditionOnWriterQueue() + switch completion.status { + case GHOSTTY_TMUX_COMMAND_SUCCESS: + if requestMutatesTopology(request) { + successfulMutationRequiredAfterRevision = max( + successfulMutationRequiredAfterRevision ?? 0, + topologyRevisionAtSubmission + ) + } + case GHOSTTY_TMUX_COMMAND_SKIPPED: + break + case GHOSTTY_TMUX_COMMAND_ERROR_BLOCK: + _ = decodeTmuxString(completion.body) + DispatchQueue.main.async { self.callbacks.onRequestFailed(request) } + default: + DispatchQueue.main.async { self.callbacks.onRequestFailed(request) } + } + clearSatisfiedMutationBarrier() + admitDeferredNavigationIfPossible() + } + + // MARK: Renderer registration and lifetime fence + + func registerTerminalSurface( + paneID: TmuxPaneID, + surface: ghostty_terminal_surface_t, + completion: @escaping @MainActor @Sendable (Result) -> Void + ) { + let handle = TerminalSurfaceHandle(value: surface) + queue.async { [self, handle] in + let result: Result + if client == nil || shuttingDown { + result = .failure(.clientUnavailable) + } else if !retainedPaneIDs.contains(paneID) { + result = .failure(.paneUnknown) + } else if surfacesByPaneID[paneID] != nil { + result = .failure(.alreadyRegistered) + } else { + surfacesByPaneID[paneID] = handle + result = .success(()) + } + DispatchQueue.main.async { + MainActor.assumeIsolated { completion(result) } + } + } + } + + /// Completion is the happens-before fence: every earlier terminal-change + /// notification has returned and no later one can dereference the handle. + func unregisterTerminalSurface( + paneID: TmuxPaneID, + surface: ghostty_terminal_surface_t, + completion: @escaping @MainActor @Sendable () -> Void + ) { + let handle = TerminalSurfaceHandle(value: surface) + queue.async { [self, handle] in + if surfacesByPaneID[paneID] == handle { + surfacesByPaneID.removeValue(forKey: paneID) + } + DispatchQueue.main.async { + MainActor.assumeIsolated { completion() } + } + } + } + + // MARK: Input and commands + + /// Runs immediately before input on Mori's grouped shadow client. It only + /// leaves a stale server copy mode; renderer-local selection and scrolling + /// never issue this command. + static let cancelStaleSharedInputMode = "if-shell -F '#{pane_in_mode}' 'send-keys -X cancel' ''" + static let agentMetadataQuery = "list-panes -a -F '#{pane_id}\\t#{@mori-agent-state}\\t#{@mori-agent-name}'" + + func sendInput(paneID: TmuxPaneID, _ bytes: Data) -> Bool { + guard !bytes.isEmpty else { return true } + queue.async { [self, bytes] in + guard let client, !shuttingDown else { + DispatchQueue.main.async { self.callbacks.onRequestFailed(.sendInput) } + return + } + guard admitCommandOnWriter( + command: Self.cancelStaleSharedInputMode, + request: .sendInput + ) else { return } + let result = bytes.withUnsafeBytes { buffer in + ghostty_tmux_client_send_pane_input( + client, + paneID.rawValue, + buffer.bindMemory(to: UInt8.self).baseAddress, + buffer.count + ) + } + if result == GHOSTTY_TMUX_RESULT_OK { + _ = drainOutbound() + } else { + reportImmediateFailure(result, request: .sendInput) + } + } + return true + } + + /// Selectors target Mori's grouped shadow client only; they never resize + /// panes, toggle zoom, or mutate shared layout. + func requestSelectWindow( + windowID: TmuxWindowID, + preferredPaneID: TmuxPaneID? = nil + ) { + queue.async { [self] in + submitNavigation(.window(windowID, preferredPaneID: preferredPaneID)) + } + } + + func requestSelectPane(paneID: TmuxPaneID) { + queue.async { [self] in submitNavigation(.pane(paneID)) } + } + + /// Explicit, user-labelled shared mutations only. Navigation and renderer + /// gestures do not reach this API. + func requestSharedMutation(_ mutation: SharedMutation) { + queue.async { [self] in + guard let topology, let activeWindow = topology.activeWindowID else { + reportRequestFailure(.sharedMutation); return + } + let activePane = activePaneID(in: topology) + let command: String? + switch mutation { + case .newWindow: command = "new-window" + case .splitHorizontal: command = activePane.map { "split-window -h -t %\($0.rawValue)" } + case .splitVertical: command = activePane.map { "split-window -v -t %\($0.rawValue)" } + case .closePane: command = activePane.map { "kill-pane -t %\($0.rawValue)" } + case .closeWindow: command = "kill-window -t @\(activeWindow.rawValue)" + } + guard let command else { reportRequestFailure(.sharedMutation); return } + enqueueOnWriter(command: command, request: .sharedMutation) + } + } + + /// The sole app-domain query admitted by the terminal boundary. The + /// format is fixed; callers cannot inject arbitrary tmux commands. + func queryAgentMetadata(completion: @escaping @Sendable (AgentMetadataQueryResult) -> Void) { + queue.async { [self] in + guard let client, !shuttingDown else { completion(.init(status: .failed, body: "")); return } + let (result, token) = enqueueCommandTokenOnWriter(Self.agentMetadataQuery, client: client) + guard result == GHOSTTY_TMUX_RESULT_OK else { completion(.init(status: .failed, body: "")); return } + requestsByToken[token] = .agentMetadata(completion) + _ = drainOutbound() + } + } + + private func submitNavigation(_ intent: NavigationIntent) { + preconditionOnWriterQueue() + guard !navigationAdmissionBlocked else { + deferredNavigationIntent = intent + return + } + deferredNavigationIntent = nil + evaluateNavigation(intent, drainOutbound: true) + } + + private func clearSatisfiedMutationBarrier() { + preconditionOnWriterQueue() + guard let requiredRevision = successfulMutationRequiredAfterRevision, + topologyRevision > requiredRevision + else { return } + successfulMutationRequiredAfterRevision = nil + } + + private func admitDeferredNavigationIfPossible() { + preconditionOnWriterQueue() + guard !navigationAdmissionBlocked, + let deferredNavigationIntent + else { return } + self.deferredNavigationIntent = nil + // Native command admission is callback-safe. Outbound consume is not; + // the enclosing pump drains once after feed returns. + evaluateNavigation(deferredNavigationIntent, drainOutbound: false) + } + + private func evaluateNavigation( + _ intent: NavigationIntent, + drainOutbound: Bool + ) { + switch intent { + case .pane(let paneID): + enqueuePaneSelection(paneID, drainOutbound: drainOutbound) + case .window(let windowID, let preferredPaneID): + enqueueWindowSelection( + windowID: windowID, + preferredPaneID: preferredPaneID, + drainOutbound: drainOutbound + ) + } + } + + /// Navigation is local to Mori's grouped shadow client. It must never + /// alter server layout/zoom or client dimensions. + private func enqueuePaneSelection(_ paneID: TmuxPaneID, drainOutbound: Bool) { + guard let topology, + let pane = topology.panes.first(where: { $0.id == paneID }) + else { reportRequestFailure(.selectPane); return } + if topology.activeWindowID != pane.windowID { + enqueueWindowSelection(windowID: pane.windowID, preferredPaneID: paneID, drainOutbound: drainOutbound) + return + } + submitPanePresentationCommandOnWriter( + command: "select-pane -t %\(paneID.rawValue)", + request: .selectPane, + paneID: paneID, + drainOutbound: drainOutbound + ) + } + + private func enqueueWindowSelection( + windowID: TmuxWindowID, + preferredPaneID: TmuxPaneID?, + drainOutbound: Bool + ) { + guard let topology, + let window = topology.windows.first(where: { $0.id == windowID }) + else { reportRequestFailure(.selectWindow); return } + if let preferredPaneID, + !topology.panes.contains(where: { $0.id == preferredPaneID && $0.windowID == windowID }) { + reportRequestFailure(.selectWindow); return + } + let paneID = preferredPaneID ?? window.activePaneID + let commands = ["select-window -t @\(windowID.rawValue)"] + + (preferredPaneID.map { ["select-pane -t %\($0.rawValue)"] } ?? []) + guard let paneID else { + submitCommandOnWriter(command: commands[0], request: .selectWindow, drainOutbound: drainOutbound) + return + } + submitPanePresentationCommandGroupOnWriter( + commands: commands, + request: .selectWindow, + paneID: paneID, + drainOutbound: drainOutbound + ) + } + + private var hasOutstandingTopologyMutation: Bool { + requestsByToken.values.contains { + guard case .action(let request, _) = $0 else { return false } + return requestMutatesTopology(request) + } + } + + private var navigationAdmissionBlocked: Bool { + hasOutstandingTopologyMutation + || successfulMutationRequiredAfterRevision != nil + } + + private func requestMutatesTopology(_ request: Request) -> Bool { + switch request { + case .selectWindow, .selectPane, .sharedMutation: + true + case .sendInput: + false + } + } + + private func enqueue(command: String, request: Request) { + queue.async { [self] in + enqueueOnWriter(command: command, request: request) + } + } + + private func enqueueOnWriter(command: String, request: Request) { + submitCommandOnWriter( + command: command, + request: request, + drainOutbound: true + ) + } + + private func submitCommandOnWriter( + command: String, + request: Request, + drainOutbound: Bool + ) { + guard admitCommandOnWriter(command: command, request: request) else { return } + if drainOutbound { _ = self.drainOutbound() } + } + + private func submitPanePresentationCommandOnWriter( + command: String, + request: Request, + paneID: TmuxPaneID, + drainOutbound: Bool + ) { + guard admitCommandOnWriter(command: command, request: request) else { return } + _ = admitPaneRefreshIfNeeded( + paneID, + failureRequest: request, + followsPresentation: true + ) + if drainOutbound { _ = self.drainOutbound() } + } + + private func admitCommandOnWriter(command: String, request: Request) -> Bool { + preconditionOnWriterQueue() + guard let client, !shuttingDown else { + reportRequestFailure(request) + return false + } + let (result, token) = enqueueCommandTokenOnWriter(command, client: client) + guard result == GHOSTTY_TMUX_RESULT_OK else { + reportImmediateFailure(result, request: request) + return false + } + requestsByToken[token] = .action( + request, + topologyRevisionAtSubmission: topologyRevision + ) + return true + } + + private func enqueueCommandTokenOnWriter( + _ command: String, + client: ghostty_tmux_client_t + ) -> (ghostty_tmux_result_e, UInt64) { + preconditionOnWriterQueue() + var token: UInt64 = 0 + let result = command.utf8.withContiguousStorageIfAvailable { buffer in + ghostty_tmux_client_enqueue_command( + client, + ghostty_tmux_bytes_s(ptr: buffer.baseAddress, len: buffer.count), + &token + ) + } ?? Array(command.utf8).withUnsafeBufferPointer { buffer in + ghostty_tmux_client_enqueue_command( + client, + ghostty_tmux_bytes_s(ptr: buffer.baseAddress, len: buffer.count), + &token + ) + } + return (result, token) + } + + private func enqueueGroupOnWriter(commands: [String], request: Request) { + submitCommandGroupOnWriter( + commands: commands, + request: request, + drainOutbound: true + ) + } + + private func submitCommandGroupOnWriter( + commands: [String], + request: Request, + drainOutbound: Bool + ) { + guard admitCommandGroupOnWriter(commands: commands, request: request) else { return } + if drainOutbound { _ = self.drainOutbound() } + } + + private func submitPanePresentationCommandGroupOnWriter( + commands: [String], + request: Request, + paneID: TmuxPaneID, + drainOutbound: Bool + ) { + guard admitCommandGroupOnWriter(commands: commands, request: request) else { return } + _ = admitPaneRefreshIfNeeded( + paneID, + failureRequest: request, + followsPresentation: true + ) + if drainOutbound { _ = self.drainOutbound() } + } + + private func admitCommandGroupOnWriter(commands: [String], request: Request) -> Bool { + preconditionOnWriterQueue() + guard let client, !shuttingDown else { + reportRequestFailure(request) + return false + } + let encoded = commands.map { Array($0.utf8) } + var tokens = Array(repeating: UInt64(0), count: commands.count) + let result = withBorrowedCommandBytes(encoded, index: 0, bytes: []) { bytes in + bytes.withUnsafeBufferPointer { commandBuffer in + tokens.withUnsafeMutableBufferPointer { tokenBuffer in + ghostty_tmux_client_enqueue_command_group( + client, + commandBuffer.baseAddress, + commandBuffer.count, + tokenBuffer.baseAddress + ) + } + } + } + guard result == GHOSTTY_TMUX_RESULT_OK else { + reportImmediateFailure(result, request: request) + return false + } + for token in tokens { + requestsByToken[token] = .action( + request, + topologyRevisionAtSubmission: topologyRevision + ) + } + return true + } + + private func withBorrowedCommandBytes( + _ commands: [[UInt8]], + index: Int, + bytes: [ghostty_tmux_bytes_s], + body: ([ghostty_tmux_bytes_s]) -> Result + ) -> Result { + guard index < commands.count else { return body(bytes) } + return commands[index].withUnsafeBufferPointer { buffer in + withBorrowedCommandBytes( + commands, + index: index + 1, + bytes: bytes + [ghostty_tmux_bytes_s(ptr: buffer.baseAddress, len: buffer.count)], + body: body + ) + } + } + + @discardableResult + private func admitPaneRefreshIfNeeded( + _ paneID: TmuxPaneID, + failureRequest: Request, + notifyPhaseChange: Bool = true, + followsPresentation: Bool = false + ) -> Bool { + preconditionOnWriterQueue() + guard let topology, + let pane = topology.panes.first(where: { $0.id == paneID }), + let size = Self.effectiveEngineSize(for: pane, in: topology) + else { return false } + let desired = DesiredPaneRefresh( + size: size, + failureRequest: failureRequest, + requiredAfterPresentation: followsPresentation + ) + + if case .inFlight(let inFlightSize, let existingFollowUp) = + refreshStateByPaneID[paneID] { + let requiredAfterPresentation = followsPresentation + || existingFollowUp?.requiredAfterPresentation == true + refreshStateByPaneID[paneID] = .inFlight( + size: inFlightSize, + followUp: requiredAfterPresentation || size != inFlightSize + ? DesiredPaneRefresh( + size: size, + failureRequest: failureRequest, + requiredAfterPresentation: requiredAfterPresentation + ) + : nil + ) + return false + } + + if engineSizeByPaneID[paneID] == size { + refreshStateByPaneID.removeValue(forKey: paneID) + return false + } + + if case .deferred = refreshStateByPaneID[paneID] { + refreshStateByPaneID[paneID] = .deferred(desired) + return false + } + + guard let client, !shuttingDown else { + reportRequestFailure(failureRequest) + return false + } + let result = ghostty_tmux_client_refresh_pane(client, paneID.rawValue) + switch result { + case GHOSTTY_TMUX_RESULT_OK: + refreshStateByPaneID[paneID] = .inFlight(size: size, followUp: nil) + if notifyPhaseChange { + DispatchQueue.main.async { + self.callbacks.onPanePhaseChanged(paneID, .hydrating) + } + } + return true + case GHOSTTY_TMUX_RESULT_NOT_READY: + refreshStateByPaneID[paneID] = .deferred(desired) + return false + default: + reportImmediateFailure(result, request: failureRequest) + return false + } + } + + private func retryDeferredPaneRefreshIfNeeded( + _ paneID: TmuxPaneID, + notifyPhaseChange: Bool + ) { + preconditionOnWriterQueue() + guard case .deferred(let desired) = refreshStateByPaneID[paneID] else { return } + refreshStateByPaneID.removeValue(forKey: paneID) + _ = admitPaneRefreshIfNeeded( + paneID, + failureRequest: desired.failureRequest, + notifyPhaseChange: notifyPhaseChange, + followsPresentation: desired.requiredAfterPresentation + ) + } + + // MARK: Helpers + + static func effectiveEngineSize( + for pane: PaneInfo, + in topology: TopologySnapshot + ) -> ClientSize? { + guard let window = topology.windows.first(where: { $0.id == pane.windowID }) + else { return nil } + if window.zoomed, window.activePaneID == pane.id { + return ClientSize(cols: window.width, rows: window.height) + } + return ClientSize(cols: pane.width, rows: pane.height) + } + + private func publishState(_ next: SessionState) { + preconditionOnWriterQueue() + guard state != next else { return } + state = next + DispatchQueue.main.async { self.callbacks.onState(next) } + } + + private func handleClientFailure(_ result: ghostty_tmux_result_e) { + preconditionOnWriterQueue() + guard !shuttingDown else { return } + failOutstandingAgentMetadataQueries() + deferredNavigationIntent = nil + successfulMutationRequiredAfterRevision = nil + switch result { + case GHOSTTY_TMUX_RESULT_OUT_OF_MEMORY: + publishState(.detached(.outOfMemory)) + case GHOSTTY_TMUX_RESULT_CLOSED: + if case .closed = state { return } + if case .detached = state { return } + publishState(.detached(.channelAborted)) + default: + publishState(.detached(.channelAborted)) + } + } + + private func reportImmediateFailure(_ result: ghostty_tmux_result_e, request: Request) { + preconditionOnWriterQueue() + guard result != GHOSTTY_TMUX_RESULT_OK else { return } + reportRequestFailure(request) + if result == GHOSTTY_TMUX_RESULT_CLIENT_FAILED || result == GHOSTTY_TMUX_RESULT_CLOSED { + handleClientFailure(result) + } + } + + private func reportRequestFailure(_ request: Request) { + DispatchQueue.main.async { self.callbacks.onRequestFailed(request) } + } + + private func outstandingAgentMetadataQueries() -> [@Sendable (AgentMetadataQueryResult) -> Void] { + requestsByToken.values.compactMap { + guard case .agentMetadata(let completion) = $0 else { return nil } + return completion + } + } + + private func failOutstandingAgentMetadataQueries() { + preconditionOnWriterQueue() + let completions = outstandingAgentMetadataQueries() + requestsByToken = requestsByToken.filter { + guard case .agentMetadata = $0.value else { return true } + return false + } + completions.forEach { $0(.init(status: .failed, body: "")) } + } + + private func preconditionOnWriterQueue() { + dispatchPrecondition(condition: .onQueue(queue)) + } + + private func activePaneID(in topology: TopologySnapshot?) -> TmuxPaneID? { + guard let topology, let windowID = topology.activeWindowID else { return nil } + return topology.windows.first(where: { $0.id == windowID })?.activePaneID + } + +} + +private struct TopologyAccumulator { + var windows: [TmuxSessionController.WindowInfo] = [] + var panes: [TmuxSessionController.PaneInfo] = [] + + mutating func append(_ record: ghostty_tmux_topology_record_s) { + switch record.tag { + case GHOSTTY_TMUX_TOPOLOGY_WINDOW: + let window = record.value.window + windows.append(TmuxSessionController.WindowInfo( + id: TmuxWindowID(window.id), + name: decodeTmuxString(window.name), + active: window.active, + zoomed: window.zoomed, + width: Self.uint32(window.width), + height: Self.uint32(window.height), + activePaneID: TmuxPaneID(window.active_pane_id) + )) + case GHOSTTY_TMUX_TOPOLOGY_PANE: + let pane = record.value.pane + panes.append(TmuxSessionController.PaneInfo( + id: TmuxPaneID(pane.id), + windowID: TmuxWindowID(pane.window_id), + x: Self.uint32(pane.x), + y: Self.uint32(pane.y), + width: Self.uint32(pane.width), + height: Self.uint32(pane.height), + phase: pane.phase == GHOSTTY_TMUX_PANE_LIVE ? .live : .hydrating + )) + default: + break + } + } + + private static func uint32(_ value: Int) -> UInt32 { + UInt32(clamping: value) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift new file mode 100644 index 00000000..a840727c --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift @@ -0,0 +1,142 @@ +import Foundation +import GhosttyKit + +/// Connects one `TmuxControlTransport` to one `TmuxSessionController`: +/// inbound SSH bytes feed the client on its writer queue, outbound +/// wire bytes are written to the SSH channel strictly in order (a +/// single consumer task drains an ordered stream fed from the writer +/// queue), and transport loss closes this attachment promptly. +/// +/// Viewport ownership stays in `TmuxTerminalSession`. The control client is +/// deliberately unsized; all local viewport metrics stay renderer-only. +actor TmuxSessionLink { + let controller: TmuxSessionController + + private let transport: any TmuxControlTransport + private var readTask: Task? + private var writeTask: Task? + private let outbound: AsyncStream + private let outboundContinuation: AsyncStream.Continuation + private var transportClosed = false + private var transportCloseDisposition = TmuxControlTransportCloseDisposition.reusable + private var stopped = false + + init( + controller: TmuxSessionController, + transport: any TmuxControlTransport + ) { + self.controller = controller + self.transport = transport + + var continuation: AsyncStream.Continuation! + self.outbound = AsyncStream { continuation = $0 } + self.outboundContinuation = continuation + } + + /// Establish the control channel, then create an unsized native client. + func start() async throws { + guard !stopped else { throw LinkError.stopped } + + // Idempotent transport prewarm (auth/root channel) before the + // session channel opens. + await transport.prepare() + guard !stopped else { throw LinkError.stopped } + + // Re-target the controller's wire bytes at this link. `yield` + // is synchronous on the writer queue, preserving order into + // the single consumer below. + controller.setOutboundSink { [outboundContinuation] data in + outboundContinuation.yield(data) + } + + // Single ordered writer for the session's wire bytes. + writeTask = Task { [weak self, transport, outbound] in + for await data in outbound { + do { + try await transport.send(data) + } catch { + await self?.invalidateTransportAfterWriteFailure() + break + } + } + } + + try await transport.start(initialViewport: nil) + guard !stopped else { throw LinkError.stopped } + try await withCheckedThrowingContinuation { continuation in + controller.start { result in + continuation.resume(with: result) + } + } + guard !stopped else { throw LinkError.stopped } + + readTask = Task { [weak self, transport, controller] in + do { + for try await data in transport.receivedBytes { + controller.pump(data) + } + } catch { + // Fall through: any stream end is a transport loss. + } + guard !Task.isCancelled else { return } + await self?.invalidateTransportAfterReadEnd() + } + } + + func controlChannelIsActive() async -> Bool? { + guard let transport = transport as? any TmuxControlTransportLivenessChecking else { + return nil + } + return await transport.isControlChannelActive() + } + + func invalidateTransport() async { + await closeTransport(disposition: .invalidated) + controller.transportClosed() + } + + /// Tear this one-shot attachment down. Replacement reconnect creates a + /// new model, controller, client, and transport. + func stop() async { + guard !stopped else { return } + stopped = true + controller.setOutboundSink(nil) + outboundContinuation.finish() + + let pendingReadTask = readTask + let pendingWriteTask = writeTask + self.readTask = nil + self.writeTask = nil + + pendingReadTask?.cancel() + pendingWriteTask?.cancel() + await closeTransport(disposition: .reusable) + _ = await pendingReadTask?.result + _ = await pendingWriteTask?.result + } + + private func invalidateTransportAfterWriteFailure() async { + await closeTransport(disposition: .invalidated) + controller.transportClosed() + } + + private func invalidateTransportAfterReadEnd() async { + await closeTransport(disposition: .invalidated) + controller.transportClosed() + } + + private func closeTransport(disposition: TmuxControlTransportCloseDisposition) async { + if disposition == .invalidated { + transportCloseDisposition = .invalidated + } + guard !transportClosed else { return } + + transportClosed = true + await transport.close(disposition: transportCloseDisposition) + } +} + +private enum LinkError: Error { + case missingInitialViewport + case stopped +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift new file mode 100644 index 00000000..469716bb --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift @@ -0,0 +1,222 @@ +import Combine +import CoreGraphics +import Foundation +import GhosttyKit + + +/// Projects one `TmuxTerminalSession` into the active MoriRemote viewport and +/// routes local input back to its retained pane surface. +/// +/// Topology mapping: tmux window/pane IDs (UInt64) are mapped to stable UUIDs +/// for the screen's projections. The session may retain multiple real pane +/// surfaces, while this adapter publishes only the active pane's stable +/// `GhosttyManagedSurface` to the phone viewport. +@MainActor +final class TmuxTerminalScreenAdapter: ObservableObject { + private weak var session: TmuxTerminalSession? + private var controller: TmuxSessionController? + + /// The last topology emitted by `session.$topology`. All adapter reads go + /// through this value, never `session.topology`: `@Published` emits from + /// `willSet`, so reading the property inside a sink returns the previous + /// snapshot and the projection lags one topology update behind. + private var latestTopology: TmuxSessionController.TopologySnapshot? + + private var activeManagedSurface: GhosttyManagedSurface? + private var initialViewportHandler: ((CGSize, CGFloat) -> Void)? + + private var subscriptions: [AnyCancellable] = [] + + /// Connects the adapter to a live session. Called once, right after the + /// session is created. + func activate( + session: TmuxTerminalSession, + initialViewportHandler: @escaping (CGSize, CGFloat) -> Void + ) { + self.session = session + self.controller = session.controller + self.initialViewportHandler = initialViewportHandler + + session.$state + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &subscriptions) + // Subscribed before $paneSurface so the replayed initial value seeds + // latestTopology ahead of the surface rebuild below. + session.$topology + .sink { [weak self] topology in + guard let self else { return } + self.latestTopology = topology + self.objectWillChange.send() + } + .store(in: &subscriptions) + session.$paneSurface + .sink { [weak self] paneSurface in + self?.rebuildActiveManagedSurface(for: paneSurface) + self?.objectWillChange.send() + } + .store(in: &subscriptions) + } + + func invalidate() { + subscriptions.removeAll() + activeManagedSurface = nil + session = nil + controller = nil + initialViewportHandler = nil + latestTopology = nil + } + + private var isTransportWritable: Bool { + session?.state == .ready + } + + // MARK: Managed surface lifecycle + + private func rebuildActiveManagedSurface(for paneSurface: TmuxPaneSurface?) { + activeManagedSurface = paneSurface?.screenSurface { _, _, _ in } + } + + private func managedSurface(for id: UUID) -> GhosttyManagedSurface? { + if let active = activeManagedSurface, active.id == id { + return active + } + return nil + } + + private var focusedManagedSurface: GhosttyManagedSurface? { + activeManagedSurface + } +} + +extension TmuxTerminalScreenAdapter { + func prepareInitialViewport(size: CGSize, scale: CGFloat) { + initialViewportHandler?(size, scale) + } + + var terminalViewportPresentationProjection: GhosttyTerminalViewportPresentationProjection { + GhosttyTerminalViewportPresentationProjection( + surfaceID: activeManagedSurface?.id, + windowCount: latestTopology?.windows.count ?? 0 + ) + } + + var isInputAvailable: Bool { + isTransportWritable && activeManagedSurface != nil + } + + var terminalManagedSurfaceLookup: GhosttyManagedSurfaceLookup { + GhosttyManagedSurfaceLookup { [weak self] id in + self?.managedSurface(for: id) + } + } + + var stateTraceLabel: String { + guard let session else { return "released" } + return switch session.state { + case .detached: "detached" + case .attaching: "attaching" + case .syncing: "syncing" + case .ready: "ready" + case .closed: "closed" + } + } + + // MARK: Input routing + + private func preflightFocusedInput() -> FocusedTerminalInputSubmissionResult? { + guard isTransportWritable else { return .transportUnavailable } + guard focusedManagedSurface != nil else { return .noFocusedSurface } + return nil + } + + func sendInputToFocusedSurface(_ text: String) -> FocusedTerminalInputSubmissionResult { + if let preflight = preflightFocusedInput() { return preflight } + return focusedManagedSurface?.sendInput(text) ?? .noFocusedSurface + } + + func sendPasteToFocusedSurface(_ text: String) -> FocusedTerminalInputSubmissionResult { + if let preflight = preflightFocusedInput() { return preflight } + return focusedManagedSurface?.sendPaste(text) ?? .noFocusedSurface + } + + func sendKeyEventToFocusedSurface(_ event: GhosttySurfaceKeyEvent) -> FocusedTerminalInputSubmissionResult { + if let preflight = preflightFocusedInput() { return preflight } + return focusedManagedSurface?.sendKeyEvent(event) ?? .noFocusedSurface + } + + func isMouseCaptured(for surfaceID: UUID) -> Bool { + managedSurface(for: surfaceID)?.controlSurface.isMouseCaptured() ?? false + } + + func sendMouseButton( + to surfaceID: UUID, + _ event: GhosttySurfaceMouseButtonEvent + ) -> GhosttyMouseInputSubmissionOutcome { + guard let managed = managedSurface(for: surfaceID) else { + return .missingTarget(surfaceID) + } + return managed.sendMouseButton(event) ? .sent : .surfaceRejected + } + + func sendMousePosition( + to surfaceID: UUID, + _ position: CGPoint, + mods: GhosttySurfaceKeyEvent.Mods + ) -> GhosttyMouseInputSubmissionOutcome { + guard let managed = managedSurface(for: surfaceID) else { + return .missingTarget(surfaceID) + } + managed.sendMousePosition(position, mods: mods) + return .sent + } + + func sendMouseScroll( + to surfaceID: UUID, + _ event: GhosttySurfaceMouseScrollEvent + ) -> GhosttyMouseInputSubmissionOutcome { + guard let managed = managedSurface(for: surfaceID) else { + return .missingTarget(surfaceID) + } + managed.sendMouseScroll(event) + return .sent + } + + // MARK: tmux topology actions + + func focusAdjacentTmuxTopLevel( + _ direction: GhosttyRuntimeSelectionDirection + ) { + guard + let controller, + let topology = latestTopology, + !topology.windows.isEmpty, + let activeWindowID = topology.activeWindowID, + let activeIndex = topology.windows.firstIndex(where: { $0.id == activeWindowID }) + else { return } + + let targetIndex = direction.advancedIndex( + from: activeIndex, + count: topology.windows.count + ) + guard targetIndex != activeIndex else { return } + let targetWindow = topology.windows[targetIndex] + requestWindowSelection(targetWindow, in: topology, controller: controller) + } + + private func requestWindowSelection( + _ targetWindow: TmuxSessionController.WindowInfo, + in topology: TmuxSessionController.TopologySnapshot, + controller: TmuxSessionController + ) { + if topology.activeWindowID != targetWindow.id, + let targetPaneID = targetWindow.activePaneID { + session?.prepareForPaneSelection(paneID: targetPaneID) + } + + controller.requestSelectWindow( + windowID: targetWindow.id, + preferredPaneID: targetWindow.activePaneID + ) + } + +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift new file mode 100644 index 00000000..6d3e5f5c --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift @@ -0,0 +1,515 @@ +import Foundation +import GhosttyKit + +/// MainActor owner of one control attachment and one retained real renderer +/// surface for every live pane. Only `paneSurface` is published to the phone +/// viewport; the retained map is not presentation state. +@MainActor +final class TmuxTerminalSession: ObservableObject { + @Published private(set) var state: TmuxSessionController.SessionState = .detached(nil) + @Published private(set) var topology: TmuxSessionController.TopologySnapshot? + @Published private(set) var paneSurface: TmuxPaneSurface? + @Published private(set) var livePaneIDs: Set = [] + var onStateChange: (@MainActor (TmuxSessionController.SessionState) -> Void)? + var onTopologyChange: (@MainActor (TmuxSessionController.TopologySnapshot) -> Void)? + + private let app: ghostty_app_t + private(set) var controller: TmuxSessionController! + private let link: TmuxSessionLink + private let baseSurfaceConfig: () -> ghostty_terminal_surface_config_s + private let paneViewTheme: () -> TerminalTheme + + typealias PaneSurfaceCreator = @MainActor ( + ghostty_app_t, + TmuxSessionController, + TmuxSessionController.RetainedPaneTerminal, + ghostty_terminal_surface_config_s, + GhosttySurfaceDisplayMetrics, + TerminalTheme, + @escaping @MainActor (TmuxPaneID) -> Void, + @escaping @MainActor (Result) -> Void + ) -> Void + private let createPaneSurface: PaneSurfaceCreator + + private var surfacesByPaneID: [TmuxPaneID: TmuxPaneSurface] = [:] + private var pendingTerminalsByPaneID: [ + TmuxPaneID: TmuxSessionController.RetainedPaneTerminal + ] = [:] + private var creatingPaneIDs: Set = [] + private var failedCreationPaneIDs: Set = [] + private var pendingPaneID: TmuxPaneID? + private var preparingSurface: TmuxPaneSurface? + private var viewportMetrics: GhosttySurfaceDisplayMetrics? + private var isAppActive = true + private var didStartLink = false + private var linkIsActive = false + private var isShutDown = false + private var shutdownDrainContinuation: CheckedContinuation? + + private final class Relay: @unchecked Sendable { + weak var target: TmuxTerminalSession? + } + + init( + app: ghostty_app_t, + transport: any TmuxControlTransport, + historyLineLimit: Int = 2_000, + baseSurfaceConfig: @escaping () -> ghostty_terminal_surface_config_s, + paneViewTheme: @escaping () -> TerminalTheme, + createPaneSurface: @escaping PaneSurfaceCreator = TmuxPaneSurface.create + ) { + self.app = app + self.baseSurfaceConfig = baseSurfaceConfig + self.paneViewTheme = paneViewTheme + self.createPaneSurface = createPaneSurface + + let relay = Relay() + let controller = TmuxSessionController(historyLineLimit: historyLineLimit, callbacks: TmuxSessionController.Callbacks( + onState: { state in + MainActor.assumeIsolated { relay.target?.handleState(state) } + }, + onTopology: { topology in + MainActor.assumeIsolated { relay.target?.handleTopology(topology) } + }, + onPaneRemoved: { paneID in + MainActor.assumeIsolated { relay.target?.handlePaneRemoved(paneID) } + }, + onPaneTerminal: { terminal in + MainActor.assumeIsolated { relay.target?.handlePaneTerminal(terminal) } + }, + onPanePhaseChanged: { paneID, phase in + MainActor.assumeIsolated { + relay.target?.handlePanePhaseChanged(paneID, phase: phase) + } + }, + onActivePaneChanged: { paneID in + MainActor.assumeIsolated { relay.target?.handleActivePaneChanged(paneID) } + }, + onPaneSurfaceFailed: { paneID in + MainActor.assumeIsolated { relay.target?.handleRendererFailure(paneID) } + }, + onRequestFailed: { request in + MainActor.assumeIsolated { relay.target?.handleRequestFailed(request) } + } + )) + self.controller = controller + self.link = TmuxSessionLink(controller: controller, transport: transport) + relay.target = self + } + + // MARK: Connection + + func connect() async throws { + guard !isShutDown, !didStartLink else { return } + didStartLink = true + linkIsActive = true + let link = self.link + do { + try await link.start() + } catch { + await connectFailed(link: link, error: error) + throw error + } + } + + private func connectFailed(link failed: TmuxSessionLink, error _: any Error) async { + await failed.stop() + guard !isShutDown, link === failed, linkIsActive else { return } + linkIsActive = false + state = .detached(nil) + } + + func disconnect() async { + guard linkIsActive else { return } + linkIsActive = false + await link.stop() + controller.attachmentStopped() + } + + func controlChannelIsActive() async -> Bool { + await link.controlChannelIsActive() ?? false + } + + func shutdown() async { + guard !isShutDown else { return } + isShutDown = true + pendingPaneID = nil + cancelPendingPresentation() + livePaneIDs.removeAll() + pendingTerminalsByPaneID.removeAll() + unpublishPane() + + if !creatingPaneIDs.isEmpty { + await withCheckedContinuation { shutdownDrainContinuation = $0 } + } + await closeAllRetainedSurfaces() + linkIsActive = false + await link.stop() + await withCheckedContinuation { continuation in + controller.shutdown { continuation.resume() } + } + } + + private func closeAllRetainedSurfaces() async { + let surfaces = Array(surfacesByPaneID.values) + guard !surfaces.isEmpty else { return } + await withCheckedContinuation { continuation in + var remaining = surfaces.count + for surface in surfaces { + surface.close { [weak self, weak surface] in + if let self, let surface, + self.surfacesByPaneID[surface.paneID] === surface { + self.surfacesByPaneID.removeValue(forKey: surface.paneID) + } + remaining -= 1 + if remaining == 0 { continuation.resume() } + } + } + } + } + + private func resumeShutdownDrainIfQuiescent() { + guard creatingPaneIDs.isEmpty, let continuation = shutdownDrainContinuation else { return } + shutdownDrainContinuation = nil + continuation.resume() + } + + // MARK: Native callbacks + + private func handleState(_ newState: TmuxSessionController.SessionState) { + state = newState + onStateChange?(newState) + switch newState { + case .detached, .closed: + pendingPaneID = nil + cancelPendingPresentation() + linkIsActive = false + Task { await link.stop() } + case .ready: + if let topology { presentActivePane(from: topology) } + case .attaching, .syncing: + break + } + } + + func handleTopology(_ snapshot: TmuxSessionController.TopologySnapshot) { + topology = snapshot + onTopologyChange?(snapshot) + let paneIDs = Set(snapshot.panes.map(\.id)) + livePaneIDs = Set(snapshot.panes.lazy.filter { $0.phase == .live }.map(\.id)) + pendingTerminalsByPaneID = pendingTerminalsByPaneID.filter { paneIDs.contains($0.key) } + failedCreationPaneIDs.formIntersection(paneIDs) + presentActivePane(from: snapshot) + } + + private func handlePaneRemoved(_ paneID: TmuxPaneID) { + livePaneIDs.remove(paneID) + pendingTerminalsByPaneID.removeValue(forKey: paneID) + failedCreationPaneIDs.remove(paneID) + if pendingPaneID == paneID { pendingPaneID = nil } + if preparingSurface?.paneID == paneID { cancelPendingPresentation() } + if paneSurface?.paneID == paneID { unpublishPane() } + guard let surface = surfacesByPaneID[paneID] else { return } + closeRetainedSurface(surface) + } + + private func handlePaneTerminal( + _ terminal: TmuxSessionController.RetainedPaneTerminal + ) { + let paneID = terminal.paneID + guard !isShutDown, + topology?.panes.contains(where: { $0.id == paneID }) == true + else { return } + + // The retained terminal handoff is the native client's live boundary. + // Hydration completion does not emit a second topology snapshot, so a + // pane first reported as hydrating must become capture-eligible here. + markPaneLiveAfterTerminalHandoff(paneID) + + guard + surfacesByPaneID[paneID] == nil, + pendingTerminalsByPaneID[paneID] == nil + else { return } + pendingTerminalsByPaneID[paneID] = terminal + createSurfaceIfPossible(paneID: paneID) + } + + private func markPaneLiveAfterTerminalHandoff(_ paneID: TmuxPaneID) { + livePaneIDs.insert(paneID) + } + + private func handlePanePhaseChanged( + _ paneID: TmuxPaneID, + phase: TmuxSessionController.PaneInfo.Phase + ) { + switch phase { + case .hydrating: + livePaneIDs.remove(paneID) + if preparingSurface?.paneID == paneID { cancelPendingPresentation() } + case .live: + livePaneIDs.insert(paneID) + if let topology, activePaneID(in: topology) == paneID { + presentActivePane(from: topology) + } + } + } + + private func handleActivePaneChanged(_ paneID: TmuxPaneID) { + guard paneSurface?.paneID == paneID else { return } + paneSurface?.refreshInteractionState() + } + + private func handleRendererFailure(_ paneID: TmuxPaneID) { + guard !isShutDown, + let surface = surfacesByPaneID[paneID], + let viewportMetrics + else { return } + relinquishPresentationOwnership(of: surface) + surface.replaceRenderer( + baseConfig: baseSurfaceConfig(), + metrics: viewportMetrics, + theme: paneViewTheme() + ) { [weak self, weak surface] result in + guard let self else { return } + switch result { + case .replaced: + if let surface, + surfacesByPaneID[paneID] === surface { + _ = surface.applyTerminalConfiguration(theme: paneViewTheme()) + if let currentViewportMetrics = self.viewportMetrics { + surface.updateCanonicalViewportMetrics(currentViewportMetrics) + } + if let topology { presentActivePane(from: topology) } + } + case .busy: + break + case .failed: + GhosttyRuntimeTrace.diagnostics( + "tmuxPane.rendererReplacement failed pane=\(paneID)" + ) + } + } + } + + private func handleRequestFailed(_ request: TmuxSessionController.Request) { + if request == .selectPane || request == .selectWindow { + pendingPaneID = nil + cancelPendingPresentation() + } + if let topology { presentActivePane(from: topology) } + } + + // MARK: Viewport and surface creation + + func updateViewportMetrics(size: CGSize, scale: CGFloat) { + let metrics = GhosttySurfaceDisplayMetrics(size: size, scale: scale) + let changed = metrics != viewportMetrics + viewportMetrics = metrics + for surface in surfacesByPaneID.values { + surface.updateCanonicalViewportMetrics(metrics) + } + if changed, preparingSurface != nil { + cancelPendingPresentation() + } + for paneID in pendingTerminalsByPaneID.keys.sorted() { + createSurfaceIfPossible(paneID: paneID) + } + if changed, let topology { + presentActivePane(from: topology) + } + } + + private func createSurfaceIfPossible(paneID: TmuxPaneID) { + guard !isShutDown, + let metrics = viewportMetrics, + let terminal = pendingTerminalsByPaneID.removeValue(forKey: paneID), + surfacesByPaneID[paneID] == nil, + creatingPaneIDs.insert(paneID).inserted + else { return } + + createPaneSurface( + app, + controller, + terminal, + baseSurfaceConfig(), + metrics, + paneViewTheme(), + { [weak self] paneID in self?.handleRendererFailure(paneID) } + ) { [weak self] result in + guard let self else { + if case .success(let surface) = result { surface.close() } + return + } + creatingPaneIDs.remove(paneID) + switch result { + case .failure(let error): + failedCreationPaneIDs.insert(paneID) + GhosttyRuntimeTrace.diagnostics( + "tmuxPane.createFailed pane=\(paneID) error=\(String(describing: error))" + ) + case .success(let surface): + guard !isShutDown, + topology?.panes.contains(where: { $0.id == paneID }) == true + else { + surface.close() + resumeShutdownDrainIfQuiescent() + return + } + surface.setSceneActive(isAppActive) + surfacesByPaneID[paneID] = surface + if let topology { presentActivePane(from: topology) } + } + resumeShutdownDrainIfQuiescent() + } + } + + // MARK: Singular presentation + + func prepareForPaneSelection(paneID: TmuxPaneID) { + guard !isShutDown, + isAppActive, + let topology, + topology.panes.contains(where: { $0.id == paneID }) + else { return } + if activePaneID(in: topology) == paneID { + let hasConflictingIntent = pendingPaneID != nil && pendingPaneID != paneID + if !hasConflictingIntent { + if paneSurface?.paneID == paneID || preparingSurface?.paneID == paneID { + return + } + pendingPaneID = nil + cancelPendingPresentation() + presentActivePane(from: topology) + return + } + } + cancelPendingPresentation() + pendingPaneID = paneID + unpublishPane() + } + + private func presentActivePane(from snapshot: TmuxSessionController.TopologySnapshot) { + guard !isShutDown, isAppActive, state == .ready, + let paneID = activePaneID(in: snapshot) + else { return } + if let pendingPaneID, pendingPaneID != paneID { return } + guard livePaneIDs.contains(paneID) else { + // A refresh of the pane already on screen changes its terminal + // contents in place. Keep that real surface focused so input can + // remain ordered through the control-client queue; only a pane + // that has not yet been presented must wait for hydration. + if paneSurface?.paneID == paneID { + paneSurface?.setSceneActive(true) + return + } + if preparingSurface?.paneID == paneID { cancelPendingPresentation() } + unpublishPane() + return + } + + + guard paneSurface?.paneID != paneID else { + pendingPaneID = nil + paneSurface?.setSceneActive(true) + return + } + guard !failedCreationPaneIDs.contains(paneID), + let surface = surfacesByPaneID[paneID], + !surface.isClosing + else { return } + if preparingSurface === surface { return } + + cancelPendingPresentation() + unpublishPane() + preparingSurface = surface + surface.prepareForPresentation { [weak self, weak surface] ready in + guard let self, let surface, + preparingSurface === surface + else { return } + preparingSurface = nil + guard ready, + !isShutDown, + isAppActive, + state == .ready, + let topology, + activePaneID(in: topology) == surface.paneID, + livePaneIDs.contains(surface.paneID), + pendingPaneID == nil || pendingPaneID == surface.paneID + else { + surface.cancelPresentationPreparation() + return + } + paneSurface = surface + pendingPaneID = nil + surface.setSceneActive(isAppActive) + surface.setPresented(true) + } + } + + private func cancelPendingPresentation() { + guard let surface = preparingSurface else { return } + preparingSurface = nil + surface.cancelPresentationPreparation() + } + + private func unpublishPane() { + guard let surface = paneSurface else { return } + surface.setPresented(false) + paneSurface = nil + } + + private func relinquishPresentationOwnership(of surface: TmuxPaneSurface) { + if preparingSurface === surface { + cancelPendingPresentation() + } + if paneSurface === surface { + unpublishPane() + } + } + + private func closeRetainedSurface(_ surface: TmuxPaneSurface) { + surface.close { [weak self, weak surface] in + guard let self, let surface else { return } + if surfacesByPaneID[surface.paneID] === surface { + surfacesByPaneID.removeValue(forKey: surface.paneID) + } + } + } + + func setAppActive(_ active: Bool) { + isAppActive = active + if !active { + cancelPendingPresentation() + paneSurface?.setSceneActive(false) + } + if active, let topology { presentActivePane(from: topology) } + } + + func applyTerminalConfiguration(theme: TerminalTheme) { + guard !isShutDown else { return } + for surface in surfacesByPaneID.values where !surface.isClosing { + _ = surface.applyTerminalConfiguration(theme: theme) + } + } + + private func activePaneID( + in snapshot: TmuxSessionController.TopologySnapshot + ) -> TmuxPaneID? { + guard let windowID = snapshot.activeWindowID else { return nil } + return snapshot.windows.first(where: { $0.id == windowID })?.activePaneID + } + +#if DEBUG + var pendingPaneIDForTesting: TmuxPaneID? { pendingPaneID } + var creatingPaneIDsForTesting: Set { creatingPaneIDs } + func handleStateForTesting(_ state: TmuxSessionController.SessionState) { handleState(state) } + func handleRequestFailedForTesting(_ request: TmuxSessionController.Request) { + handleRequestFailed(request) + } + func handlePaneRemovedForTesting(_ paneID: TmuxPaneID) { handlePaneRemoved(paneID) } + func handlePaneTerminalForTesting(_ paneID: TmuxPaneID) { + guard !isShutDown, topology?.panes.contains(where: { $0.id == paneID }) == true else { return } + markPaneLiveAfterTerminalHandoff(paneID) + } +#endif +} diff --git a/MoriRemote/MoriRemoteTerminalTests/DeterministicTmuxControlTransport.swift b/MoriRemote/MoriRemoteTerminalTests/DeterministicTmuxControlTransport.swift new file mode 100644 index 00000000..079a8cf4 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/DeterministicTmuxControlTransport.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import MoriRemoteTerminal + +actor DeterministicTmuxControlTransport: TmuxControlTransport { + nonisolated let receivedBytes: AsyncThrowingStream + + private let continuation: AsyncThrowingStream.Continuation + private let chunks: [Data] + private var started = false + private var sentCommands: [Data] = [] + + init(chunks: [Data]) { + self.chunks = chunks + + var capturedContinuation: AsyncThrowingStream.Continuation? + receivedBytes = AsyncThrowingStream { continuation in + capturedContinuation = continuation + } + continuation = capturedContinuation! + } + + func start(initialViewport: TmuxControlViewport?) async throws { + _ = initialViewport + guard !started else { return } + started = true + + for chunk in chunks { + continuation.yield(chunk) + } + } + + func send(_ data: Data) async throws { + sentCommands.append(data) + } + + func close(disposition: TmuxControlTransportCloseDisposition) async { + _ = disposition + continuation.finish() + } + + func commandsSentByGhostty() -> [Data] { + sentCommands + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyImageAttachmentTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyImageAttachmentTests.swift new file mode 100644 index 00000000..322e4958 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyImageAttachmentTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyImageAttachmentTests: XCTestCase { + func testRemotePathInsertionPreservesShellExpansionAndQuotesUnsafeNames() { + XCTAssertEqual( + GhosttyImageTerminalPathFormatter.insertionText(for: "~/.cache/mori/image.png"), + "~/.cache/mori/image.png" + ) + XCTAssertEqual( + GhosttyImageTerminalPathFormatter.insertionText(for: "~/.cache/mori/screen shot's.png"), + #"~/'.cache/mori/screen shot'"'"'s.png'"# + ) + } + + func testRemotePathInsertionRejectsControlCharacters() { + XCTAssertNil(GhosttyImageTerminalPathFormatter.insertionText(for: "~/image\n.png")) + XCTAssertNil(GhosttyImageTerminalPathFormatter.insertionText(for: "")) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift new file mode 100644 index 00000000..068b468e --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyKeyboardChromeActionsTests: XCTestCase { + func testTerminalKeysRouteToExpectedEvents() { + var events: [GhosttySurfaceKeyEvent] = [] + let actions = makeActions(sendKey: { events.append($0); return true }) + + XCTAssertTrue(actions.perform(.escape)) + XCTAssertTrue(actions.perform(.tab)) + XCTAssertTrue(actions.perform(.shiftTab)) + XCTAssertTrue(actions.perform(.arrowLeft)) + XCTAssertTrue(actions.perform(.arrowUp)) + XCTAssertTrue(actions.perform(.arrowDown)) + XCTAssertTrue(actions.perform(.arrowRight)) + XCTAssertTrue(actions.perform(.home)) + XCTAssertTrue(actions.perform(.end)) + XCTAssertTrue(actions.perform(.pageUp)) + XCTAssertTrue(actions.perform(.pageDown)) + XCTAssertTrue(actions.perform(.questionMark)) + XCTAssertTrue(actions.perform(.slash)) + XCTAssertEqual(events.map(\.keyCode), [ + .escape, .tab, .tab, .arrowLeft, .arrowUp, .arrowDown, .arrowRight, + .home, .end, .pageUp, .pageDown, .slash, .slash, + ]) + XCTAssertEqual(events[2].mods, .shift) + XCTAssertEqual(events[11].text, "?") + XCTAssertEqual(events[11].mods, .shift) + XCTAssertEqual(events[12].text, "/") + } + + func testSelectorsAndModifiersInvokeTheirRetainedActions() { + var calls: [String] = [] + let actions = GhosttyKeyboardChromeActions( + showNavigator: { calls.append("navigator") }, + toggleKeyboard: { calls.append("keyboard") }, + toggleControl: { calls.append("control") }, + toggleAlt: { calls.append("alt") }, + requestSharedMutation: { mutation in + switch mutation { + case .newWindow: calls.append("new-window") + case .splitHorizontal: calls.append("split-horizontal") + case .splitVertical: calls.append("split-vertical") + case .closePane: calls.append("close-pane") + case .closeWindow: calls.append("close-window") + } + }, + sendShortcut: { value in calls.append(value); return true }, + sendKey: { _ in false } + ) + + XCTAssertTrue(actions.perform(.navigator)) + XCTAssertTrue(actions.perform(.keyboard)) + XCTAssertTrue(actions.perform(.control)) + XCTAssertTrue(actions.perform(.alt)) + XCTAssertTrue(actions.perform(.newWindow)) + XCTAssertTrue(actions.perform(.splitHorizontal)) + XCTAssertTrue(actions.perform(.splitVertical)) + XCTAssertTrue(actions.perform(.closePane)) + XCTAssertTrue(actions.perform(.closeWindow)) + XCTAssertEqual(calls, [ + "navigator", "keyboard", "control", "alt", + "new-window", "split-horizontal", "split-vertical", "close-pane", "close-window", + ]) + } + + func testCommonShortcutsSendExactTerminalSequences() { + var sequences: [String] = [] + let actions = GhosttyKeyboardChromeActions( + showNavigator: {}, + toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, + requestSharedMutation: { _ in }, + sendShortcut: { sequences.append($0); return true }, + sendKey: { _ in false } + ) + + for action in [ + GhosttyKeyboardChromeActions.Action.ctrlC, .ctrlD, .ctrlZ, .ctrlL, + .ctrlA, .ctrlE, .ctrlR, .ctrlU, .ctrlK, .ctrlW, .altB, .altF, + ] { + XCTAssertTrue(actions.perform(action)) + } + XCTAssertEqual(sequences, [ + "\u{03}", "\u{04}", "\u{1A}", "\u{0C}", "\u{01}", "\u{05}", + "\u{12}", "\u{15}", "\u{0B}", "\u{17}", "\u{1B}b", "\u{1B}f", + ]) + } + + private func makeActions( + sendKey: @escaping (GhosttySurfaceKeyEvent) -> Bool + ) -> GhosttyKeyboardChromeActions { + GhosttyKeyboardChromeActions( + showNavigator: {}, + toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, + requestSharedMutation: { _ in }, sendShortcut: { _ in false }, sendKey: sendKey + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeModeTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeModeTests.swift new file mode 100644 index 00000000..b089d14d --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeModeTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyKeyboardChromeModeTests: XCTestCase { + func testKeyboardToggleShowsSystemKeyboardFromHiddenMode() { + XCTAssertEqual(GhosttyKeyboardChromeMode.hidden.toggledKeyboard(), .system) + } + + func testKeyboardToggleHidesSystemKeyboard() { + XCTAssertEqual(GhosttyKeyboardChromeMode.system.toggledKeyboard(), .hidden) + } + + func testSystemKeyboardVisibilitySyncsHiddenAndSystemModes() { + XCTAssertEqual( + GhosttyKeyboardChromeMode.hidden.applyingSystemKeyboardVisibility(true), + .system + ) + XCTAssertEqual( + GhosttyKeyboardChromeMode.system.applyingSystemKeyboardVisibility(false), + .hidden + ) + } + + func testKeyboardModeOnlyControlsKeyboardIntent() { + XCTAssertFalse(GhosttyKeyboardChromeMode.hidden.enablesSystemKeyboard) + XCTAssertTrue(GhosttyKeyboardChromeMode.system.enablesSystemKeyboard) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardVisibilityProjectionTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardVisibilityProjectionTests.swift new file mode 100644 index 00000000..6dbc5925 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardVisibilityProjectionTests.swift @@ -0,0 +1,604 @@ +import CoreGraphics +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyKeyboardVisibilityProjectionTests: XCTestCase { + private let screenBounds = CGRect(x: 0, y: 0, width: 390, height: 844) + + private func performKeyboardToggleTransaction( + _ projection: GhosttyKeyboardToggleProjection, + resultingMode: GhosttyKeyboardChromeMode, + coordinator: inout GhosttyKeyboardViewportTransitionCoordinator + ) -> [String] { + var calls: [String] = [] + coordinator.performKeyboardToggleTransition( + projection: projection, + beginTransition: { + calls.append("begin:\($0.target.traceLabel)") + }, + applyKeyboardToggle: { + calls.append("toggle") + return resultingMode + }, + completeTransition: { + calls.append("complete") + } + ) + return calls + } + + func testVisibleOverlappingKeyboardBeginsShownTransitionInSystemMode() { + let projection = GhosttyKeyboardVisibilityProjection( + frameEnd: CGRect(x: 0, y: 544.4, width: 390, height: 299.6), + screenBounds: screenBounds, + animationDuration: 0.42, + keyboardMode: .system, + isDismissSystemKeyboardRequested: false + ) + + XCTAssertTrue(projection.isVisible) + XCTAssertEqual(projection.overlapHeight, 300) + XCTAssertEqual(projection.transitionTarget, .shown) + XCTAssertEqual(projection.animationDuration, 0.42) + XCTAssertEqual(projection.fallbackDelay, 0.44, accuracy: 0.0001) + XCTAssertTrue(projection.shouldBeginViewportTransition) + } + + func testZeroHeightKeyboardFrameIsHiddenWithZeroOverlap() { + let projection = GhosttyKeyboardVisibilityProjection( + frameEnd: CGRect(x: 0, y: 844, width: 390, height: 0), + screenBounds: screenBounds, + animationDuration: 0.25, + keyboardMode: .system, + isDismissSystemKeyboardRequested: false + ) + + XCTAssertFalse(projection.isVisible) + XCTAssertEqual(projection.overlapHeight, 0) + XCTAssertEqual(projection.transitionTarget, .hidden) + XCTAssertFalse(projection.shouldBeginViewportTransition) + } + + func testBottomEdgeNonOverlappingKeyboardFrameIsHidden() { + let projection = GhosttyKeyboardVisibilityProjection( + frameEnd: CGRect(x: 0, y: 844, width: 390, height: 300), + screenBounds: screenBounds, + animationDuration: 0.25, + keyboardMode: .hidden, + isDismissSystemKeyboardRequested: false + ) + + XCTAssertFalse(projection.isVisible) + XCTAssertEqual(projection.overlapHeight, 0) + XCTAssertEqual(projection.transitionTarget, .hidden) + XCTAssertTrue(projection.shouldBeginViewportTransition) + } + + func testHiddenNotificationWithoutExplicitSystemDismissDoesNotBeginTransition() { + let projection = GhosttyKeyboardVisibilityProjection( + frameEnd: CGRect(x: 0, y: 844, width: 390, height: 300), + screenBounds: screenBounds, + animationDuration: 0.25, + keyboardMode: .system, + isDismissSystemKeyboardRequested: false + ) + + XCTAssertEqual(projection.transitionTarget, .hidden) + XCTAssertFalse(projection.shouldBeginViewportTransition) + } + + func testRequestedHideBeginsHiddenTransition() { + let projection = GhosttyKeyboardVisibilityProjection( + frameEnd: CGRect(x: 0, y: 844, width: 390, height: 300), + screenBounds: screenBounds, + animationDuration: 0.25, + keyboardMode: .hidden, + isDismissSystemKeyboardRequested: true + ) + + XCTAssertEqual(projection.transitionTarget, .hidden) + XCTAssertTrue(projection.shouldBeginViewportTransition) + } + + func testShownNotificationWhileKeyboardModeIsHiddenDoesNotBeginTransition() { + let projection = GhosttyKeyboardVisibilityProjection( + frameEnd: CGRect(x: 0, y: 544, width: 390, height: 300), + screenBounds: screenBounds, + animationDuration: 0.25, + keyboardMode: .hidden, + isDismissSystemKeyboardRequested: false + ) + + XCTAssertTrue(projection.isVisible) + XCTAssertEqual(projection.transitionTarget, .shown) + XCTAssertFalse(projection.shouldBeginViewportTransition) + } + + func testDefaultAnimationDurationFeedsFallbackDelay() { + let projection = GhosttyKeyboardVisibilityProjection( + frameEnd: CGRect(x: 0, y: 544, width: 390, height: 300), + screenBounds: screenBounds, + animationDuration: nil, + keyboardMode: .system, + isDismissSystemKeyboardRequested: false + ) + + XCTAssertEqual(projection.animationDuration, 0.35, accuracy: 0.0001) + XCTAssertEqual(projection.fallbackDelay, 0.37, accuracy: 0.0001) + } + + func testFallbackDelayClampsToMinimumAndMaximum() { + XCTAssertEqual( + GhosttyKeyboardVisibilityProjection.fallbackDelay(animationDuration: 0.01), + 0.25, + accuracy: 0.0001 + ) + XCTAssertEqual( + GhosttyKeyboardVisibilityProjection.fallbackDelay(animationDuration: 2.0), + 1.0, + accuracy: 0.0001 + ) + } + + func testToggleProjectionShowsSystemKeyboardWhenInputIsAvailable() throws { + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .hidden, + isInputAvailable: true + ) + + XCTAssertEqual(projection.previousMode, .hidden) + XCTAssertEqual(projection.expectedMode, .system) + XCTAssertTrue(projection.isInputAvailable) + XCTAssertTrue(projection.startsSystemKeyboardTransition) + XCTAssertEqual(projection.transitionTarget, .shown) + XCTAssertEqual(try XCTUnwrap(projection.fallbackDelay), 2.0, accuracy: 0.0001) + XCTAssertTrue(projection.shouldAwaitSystemKeyboardPresentation) + } + + func testToggleProjectionDoesNotStartShownTransitionWithoutInput() { + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .hidden, + isInputAvailable: false + ) + + XCTAssertEqual(projection.previousMode, .hidden) + XCTAssertEqual(projection.expectedMode, .system) + XCTAssertFalse(projection.isInputAvailable) + XCTAssertFalse(projection.startsSystemKeyboardTransition) + XCTAssertNil(projection.transitionTarget) + XCTAssertNil(projection.fallbackDelay) + XCTAssertFalse(projection.shouldAwaitSystemKeyboardPresentation) + } + + func testToggleProjectionHidesSystemKeyboardWhenInputIsAvailable() throws { + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .system, + isInputAvailable: true + ) + + XCTAssertEqual(projection.previousMode, .system) + XCTAssertEqual(projection.expectedMode, .hidden) + XCTAssertTrue(projection.isInputAvailable) + XCTAssertTrue(projection.startsSystemKeyboardTransition) + XCTAssertEqual(projection.transitionTarget, .hidden) + XCTAssertEqual(try XCTUnwrap(projection.fallbackDelay), 1.0, accuracy: 0.0001) + XCTAssertFalse(projection.shouldAwaitSystemKeyboardPresentation) + } + + func testToggleProjectionDoesNotStartHiddenTransitionWithoutInput() { + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .system, + isInputAvailable: false + ) + + XCTAssertEqual(projection.previousMode, .system) + XCTAssertEqual(projection.expectedMode, .hidden) + XCTAssertFalse(projection.isInputAvailable) + XCTAssertFalse(projection.startsSystemKeyboardTransition) + XCTAssertNil(projection.transitionTarget) + XCTAssertNil(projection.fallbackDelay) + XCTAssertFalse(projection.shouldAwaitSystemKeyboardPresentation) + } + + func testTransitionCoordinatorToggleShowRecordsAwaitingPresentation() throws { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .hidden, + isInputAvailable: true + ) + + let request = try XCTUnwrap(coordinator.transitionRequest(forToggle: projection)) + + XCTAssertEqual( + request, + GhosttyKeyboardViewportTransitionRequest( + target: .shown, + allowsTargetOverride: true, + fallbackDelay: GhosttyKeyboardViewportTransitionTiming.systemPresentationFallbackDelay + ) + ) + XCTAssertTrue(coordinator.isAwaitingSystemKeyboardPresentation) + } + + func testTransitionCoordinatorToggleHideClearsAwaitingPresentation() throws { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + _ = coordinator.prepareUnexpectedHideRecovery() + + let request = try XCTUnwrap( + coordinator.transitionRequest( + forToggle: GhosttyKeyboardToggleProjection( + keyboardMode: .system, + isInputAvailable: true + ) + ) + ) + + XCTAssertEqual( + request, + GhosttyKeyboardViewportTransitionRequest( + target: .hidden, + allowsTargetOverride: true, + fallbackDelay: GhosttyKeyboardViewportTransitionTiming.defaultFallbackDelay + ) + ) + XCTAssertFalse(coordinator.isAwaitingSystemKeyboardPresentation) + } + + func testTransitionCoordinatorToggleTransactionBeginsShownTransitionBeforeApplyingInputMode() { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .hidden, + isInputAvailable: true + ) + + let calls = performKeyboardToggleTransaction( + projection, + resultingMode: .system, + coordinator: &coordinator + ) + + XCTAssertEqual(calls, ["begin:shown", "toggle"]) + XCTAssertTrue(coordinator.isAwaitingSystemKeyboardPresentation) + } + + func testTransitionCoordinatorToggleTransactionBeginsHiddenTransitionBeforeApplyingInputMode() { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + _ = coordinator.prepareUnexpectedHideRecovery() + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .system, + isInputAvailable: true + ) + + let calls = performKeyboardToggleTransaction( + projection, + resultingMode: .hidden, + coordinator: &coordinator + ) + + XCTAssertEqual(calls, ["begin:hidden", "toggle"]) + XCTAssertFalse(coordinator.isAwaitingSystemKeyboardPresentation) + } + + func testTransitionCoordinatorToggleTransactionSkipsTransitionWhenInputIsUnavailable() { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .hidden, + isInputAvailable: false + ) + + let calls = performKeyboardToggleTransaction( + projection, + resultingMode: .hidden, + coordinator: &coordinator + ) + + XCTAssertEqual(calls, ["toggle"]) + XCTAssertFalse(coordinator.isAwaitingSystemKeyboardPresentation) + } + + func testTransitionCoordinatorToggleTransactionCompletesWhenInputModeMissesExpectedState() { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: .hidden, + isInputAvailable: true + ) + + let calls = performKeyboardToggleTransaction( + projection, + resultingMode: .hidden, + coordinator: &coordinator + ) + + XCTAssertEqual(calls, ["begin:shown", "toggle", "complete"]) + XCTAssertTrue(coordinator.isAwaitingSystemKeyboardPresentation) + } + + func testTransitionCoordinatorVisibleKeyboardClearsAwaitingPresentation() { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + _ = coordinator.prepareUnexpectedHideRecovery() + + coordinator.observeKeyboardVisibility(isVisible: false) + XCTAssertTrue(coordinator.isAwaitingSystemKeyboardPresentation) + + coordinator.observeKeyboardVisibility(isVisible: true) + XCTAssertFalse(coordinator.isAwaitingSystemKeyboardPresentation) + } + + func testTransitionCoordinatorBeginRecordsKeyboardLifecycleAndIssuesFallbackToken() { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + var viewport = GhosttyTerminalViewportCoordinator() + let liveSize = CGSize(width: 402, height: 726) + + XCTAssertEqual(viewport.observeLiveSize(liveSize).outcome, .appliedStableSize) + let result = coordinator.beginTransition( + GhosttyKeyboardViewportTransitionRequest( + target: .hidden, + fallbackDelay: 0.42 + ), + viewportCoordinator: &viewport, + liveSize: liveSize + ) + + XCTAssertTrue(result.didStart) + XCTAssertEqual(result.fallbackToken, 1) + XCTAssertEqual(result.fallbackDelay, 0.42, accuracy: 0.0001) + XCTAssertTrue(viewport.isKeyboardTransitionActive) + XCTAssertFalse(viewport.isFrozen) + XCTAssertEqual(viewport.keyboardTransitionTarget, .hidden) + } + + func testTransitionCoordinatorAlreadyActiveBeginReschedulesFallbackAndUpdatesOverride() { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + var viewport = GhosttyTerminalViewportCoordinator() + let liveSize = CGSize(width: 402, height: 726) + + XCTAssertEqual(viewport.observeLiveSize(liveSize).outcome, .appliedStableSize) + let first = coordinator.beginTransition( + GhosttyKeyboardViewportTransitionRequest( + target: .shown, + fallbackDelay: 0.25 + ), + viewportCoordinator: &viewport, + liveSize: liveSize + ) + let second = coordinator.beginTransition( + GhosttyKeyboardViewportTransitionRequest( + target: .hidden, + allowsTargetOverride: true, + fallbackDelay: 0.5 + ), + viewportCoordinator: &viewport, + liveSize: liveSize + ) + + XCTAssertTrue(first.didStart) + XCTAssertFalse(second.didStart) + XCTAssertEqual(first.fallbackToken, 1) + XCTAssertEqual(second.fallbackToken, 2) + XCTAssertEqual(viewport.keyboardTransitionTarget, .hidden) + } + + func testTransitionCoordinatorCompletionInvalidatesFallbackTokenAfterGeometryUpdate() throws { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + var viewport = GhosttyTerminalViewportCoordinator() + let keyboardSize = CGSize(width: 402, height: 452) + let fullSize = CGSize(width: 402, height: 726) + + XCTAssertEqual(viewport.observeLiveSize(keyboardSize).outcome, .appliedStableSize) + _ = coordinator.beginTransition( + GhosttyKeyboardViewportTransitionRequest(target: .hidden), + viewportCoordinator: &viewport, + liveSize: keyboardSize + ) + XCTAssertTrue(viewport.observeLiveSize(fullSize).didApplyStableSize) + + let completion = try XCTUnwrap( + coordinator.completeTransition( + viewportCoordinator: &viewport, + liveSize: fullSize + ) + ) + + XCTAssertEqual(completion.target, .hidden) + XCTAssertFalse(viewport.isKeyboardTransitionActive) + XCTAssertEqual(viewport.effectiveSize(liveSize: fullSize), fullSize) + } + + func testTransitionCoordinatorFallbackCompletionRejectsStaleTokenAndCompletesCurrentActive() throws { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + var viewport = GhosttyTerminalViewportCoordinator() + let keyboardSize = CGSize(width: 402, height: 452) + let fullSize = CGSize(width: 402, height: 726) + + XCTAssertEqual(viewport.observeLiveSize(keyboardSize).outcome, .appliedStableSize) + let first = coordinator.beginTransition( + GhosttyKeyboardViewportTransitionRequest(target: .hidden), + viewportCoordinator: &viewport, + liveSize: keyboardSize + ) + let second = coordinator.beginTransition( + GhosttyKeyboardViewportTransitionRequest(target: .hidden), + viewportCoordinator: &viewport, + liveSize: keyboardSize + ) + + XCTAssertNil( + coordinator.completeTransitionFromFallback( + token: first.fallbackToken, + viewportCoordinator: &viewport, + liveSize: fullSize + ) + ) + XCTAssertTrue(viewport.isKeyboardTransitionActive) + + let completion = try XCTUnwrap( + coordinator.completeTransitionFromFallback( + token: second.fallbackToken, + viewportCoordinator: &viewport, + liveSize: fullSize + ) + ) + + XCTAssertEqual(completion.target, .hidden) + XCTAssertFalse(viewport.isKeyboardTransitionActive) + } + + func testTransitionCoordinatorUnexpectedHideRecoveryRequestsShownTransition() { + var coordinator = GhosttyKeyboardViewportTransitionCoordinator() + + let request = coordinator.prepareUnexpectedHideRecovery() + + XCTAssertEqual( + request, + GhosttyKeyboardViewportTransitionRequest( + target: .shown, + allowsTargetOverride: true, + fallbackDelay: GhosttyKeyboardViewportTransitionTiming.systemPresentationFallbackDelay + ) + ) + XCTAssertTrue(coordinator.isAwaitingSystemKeyboardPresentation) + } + + func testFallbackTokenGateIssuesIncreasingTokens() { + var gate = GhosttyKeyboardViewportFallbackTokenGate() + + let first = gate.issueToken() + let second = gate.issueToken() + + XCTAssertEqual(first, 1) + XCTAssertEqual(second, 2) + } + + func testFallbackTokenGateAcceptsCurrentToken() { + var gate = GhosttyKeyboardViewportFallbackTokenGate() + + let token = gate.issueToken() + + XCTAssertTrue(gate.accepts(token)) + } + + func testFallbackTokenGateRescheduleRejectsOlderToken() { + var gate = GhosttyKeyboardViewportFallbackTokenGate() + + let first = gate.issueToken() + let second = gate.issueToken() + + XCTAssertFalse(gate.accepts(first)) + XCTAssertTrue(gate.accepts(second)) + } + + func testFallbackTokenGateInvalidationRejectsCurrentToken() { + var gate = GhosttyKeyboardViewportFallbackTokenGate() + + let token = gate.issueToken() + gate.invalidate() + + XCTAssertFalse(gate.accepts(token)) + } + + func testCompletionProjectionCompletesDidShowForMatchingTarget() { + let projection = GhosttyKeyboardViewportCompletionProjection( + eventTarget: .shown, + activeTransitionTarget: .shown, + keyboardMode: .hidden, + isDismissSystemKeyboardRequested: true, + isInputAvailable: false, + isSelectionSheetPresented: true, + isAwaitingSystemKeyboardPresentation: true, + isSceneActive: false + ) + + XCTAssertEqual(projection.action, .complete) + } + + func testCompletionProjectionIgnoresDidShowTargetMismatchWithoutRecoveryPolicy() { + let projection = GhosttyKeyboardViewportCompletionProjection( + eventTarget: .shown, + activeTransitionTarget: .hidden, + keyboardMode: .system, + isDismissSystemKeyboardRequested: false, + isInputAvailable: true, + isSelectionSheetPresented: false, + isAwaitingSystemKeyboardPresentation: false, + isSceneActive: true + ) + + XCTAssertEqual(projection.action, .ignoreTargetMismatch) + } + + func testCompletionProjectionCompletesDidHideWhenPolicyAllowsMatchingTarget() { + let projection = GhosttyKeyboardViewportCompletionProjection( + eventTarget: .hidden, + activeTransitionTarget: nil, + keyboardMode: .hidden, + isDismissSystemKeyboardRequested: true, + isInputAvailable: true, + isSelectionSheetPresented: false, + isAwaitingSystemKeyboardPresentation: false, + isSceneActive: true + ) + + XCTAssertEqual(projection.action, .complete) + } + + func testCompletionProjectionIgnoresDidHideTargetMismatchWhenPolicyAllows() { + let projection = GhosttyKeyboardViewportCompletionProjection( + eventTarget: .hidden, + activeTransitionTarget: .shown, + keyboardMode: .hidden, + isDismissSystemKeyboardRequested: true, + isInputAvailable: true, + isSelectionSheetPresented: false, + isAwaitingSystemKeyboardPresentation: false, + isSceneActive: true + ) + + XCTAssertEqual(projection.action, .ignoreTargetMismatch) + } + + func testCompletionProjectionIgnoresDidHideByPolicyWhenRecoveryIsIneligible() { + let projection = GhosttyKeyboardViewportCompletionProjection( + eventTarget: .hidden, + activeTransitionTarget: .hidden, + keyboardMode: .system, + isDismissSystemKeyboardRequested: false, + isInputAvailable: false, + isSelectionSheetPresented: false, + isAwaitingSystemKeyboardPresentation: false, + isSceneActive: true + ) + + XCTAssertEqual(projection.action, .ignorePolicy) + } + + func testCompletionProjectionRecoversUnexpectedHideWhenPolicyRejectsAndRecoveryIsEligible() { + let projection = GhosttyKeyboardViewportCompletionProjection( + eventTarget: .hidden, + activeTransitionTarget: .shown, + keyboardMode: .system, + isDismissSystemKeyboardRequested: false, + isInputAvailable: true, + isSelectionSheetPresented: false, + isAwaitingSystemKeyboardPresentation: false, + isSceneActive: true + ) + + XCTAssertEqual(projection.action, .recoverUnexpectedHide) + } + + func testCompletionProjectionDoesNotRecoverUnexpectedHideWhileTransientInputOwnerIsPresented() { + let projection = GhosttyKeyboardViewportCompletionProjection( + eventTarget: .hidden, + activeTransitionTarget: .shown, + keyboardMode: .system, + isDismissSystemKeyboardRequested: false, + isInputAvailable: true, + isSelectionSheetPresented: false, + isTransientInputOwnerPresented: true, + isAwaitingSystemKeyboardPresentation: false, + isSceneActive: true + ) + + XCTAssertEqual(projection.action, .ignorePolicy) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKitControlSurfaceTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKitControlSurfaceTests.swift new file mode 100644 index 00000000..5b9107c2 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKitControlSurfaceTests.swift @@ -0,0 +1,265 @@ +import CoreGraphics +import GhosttyKit +import XCTest +@testable import MoriRemoteTerminal + +@MainActor +final class GhosttyKitControlSurfaceTests: XCTestCase { + func testDisplayMetricsUseScaleForPixelDimensions() { + XCTAssertEqual( + GhosttySurfaceDisplayMetrics( + size: CGSize(width: 390, height: 641), + scale: 3 + ), + GhosttySurfaceDisplayMetrics( + contentScale: 3, + pixelWidth: 1170, + pixelHeight: 1923 + ) + ) + + XCTAssertEqual( + TmuxControlViewport( + ghosttySurfaceSize: ghostty_surface_size_s( + columns: 41, + rows: 28, + width_px: 1170, + height_px: 1923, + cell_width_px: 28, + cell_height_px: 68 + ) + ), + TmuxControlViewport( + columns: 41, + rows: 28, + pixelWidth: 1170, + pixelHeight: 1923 + ) + ) + XCTAssertNil( + TmuxControlViewport( + ghosttySurfaceSize: ghostty_surface_size_s( + columns: 0, + rows: 28, + width_px: 1170, + height_px: 1923, + cell_width_px: 28, + cell_height_px: 68 + ) + ) + ) + XCTAssertNil( + TmuxControlViewport( + ghosttySurfaceSize: ghostty_surface_size_s( + columns: 41, + rows: 0, + width_px: 1170, + height_px: 1923, + cell_width_px: 28, + cell_height_px: 68 + ) + ) + ) + } + + func testDisplayMetricsClampTransientInvalidScale() { + XCTAssertEqual( + GhosttySurfaceDisplayMetrics( + size: CGSize(width: 390, height: 641), + scale: 0 + ), + GhosttySurfaceDisplayMetrics( + contentScale: 1, + pixelWidth: 390, + pixelHeight: 641 + ) + ) + } + + func testDisplayMetricsClampNonFiniteScale() { + XCTAssertEqual( + GhosttySurfaceDisplayMetrics( + size: CGSize(width: 390, height: 641), + scale: .nan + ), + GhosttySurfaceDisplayMetrics( + contentScale: 1, + pixelWidth: 390, + pixelHeight: 641 + ) + ) + XCTAssertEqual( + GhosttySurfaceDisplayMetrics( + size: CGSize(width: 390, height: 641), + scale: .infinity + ), + GhosttySurfaceDisplayMetrics( + contentScale: 1, + pixelWidth: 390, + pixelHeight: 641 + ) + ) + } + + func testDisplayMetricsClampInvalidSizeToOnePixel() { + XCTAssertEqual( + GhosttySurfaceDisplayMetrics( + size: CGSize(width: 0, height: CGFloat.nan), + scale: 3 + ), + GhosttySurfaceDisplayMetrics( + contentScale: 3, + pixelWidth: 1, + pixelHeight: 1 + ) + ) + } + + func testDisplayMetricsClampOversizedPixelDimensions() { + XCTAssertEqual( + GhosttySurfaceDisplayMetrics( + size: CGSize(width: CGFloat(UInt32.max), height: 10), + scale: 3 + ), + GhosttySurfaceDisplayMetrics( + contentScale: 3, + pixelWidth: UInt32.max, + pixelHeight: 30 + ) + ) + } + + func testDisplayUpdateTrackerSuppressesUnchangedMetrics() { + var tracker = GhosttySurfaceDisplayUpdateTracker() + let size = CGSize(width: 390, height: 641) + + XCTAssertEqual( + tracker.nextMetrics(size: size, scale: 3), + GhosttySurfaceDisplayMetrics( + contentScale: 3, + pixelWidth: 1170, + pixelHeight: 1923 + ) + ) + XCTAssertNil(tracker.nextMetrics(size: size, scale: 3)) + } + + func testDisplayUpdateTrackerEmitsWhenRoundedPixelSizeChanges() { + var tracker = GhosttySurfaceDisplayUpdateTracker() + + XCTAssertNotNil(tracker.nextMetrics(size: CGSize(width: 390, height: 641), scale: 3)) + XCTAssertNil(tracker.nextMetrics(size: CGSize(width: 390, height: 641), scale: 3)) + XCTAssertEqual( + tracker.nextMetrics(size: CGSize(width: 390, height: 640.5), scale: 3), + GhosttySurfaceDisplayMetrics( + contentScale: 3, + pixelWidth: 1170, + pixelHeight: 1922 + ) + ) + } + + func testDisplayUpdateTrackerResetAllowsSameMetricsAgain() { + var tracker = GhosttySurfaceDisplayUpdateTracker() + let size = CGSize(width: 390, height: 641) + + XCTAssertNotNil(tracker.nextMetrics(size: size, scale: 3)) + XCTAssertNil(tracker.nextMetrics(size: size, scale: 3)) + + tracker.reset() + + XCTAssertNotNil(tracker.nextMetrics(size: size, scale: 3)) + } + + func testSelectionSnapshotConvertsBackingPixelsWithoutSwappingEndpointRoles() { + let snapshot = GhosttyLocalSelectionSnapshot( + cValue: ghostty_terminal_surface_selection_snapshot_s( + start: ghostty_terminal_surface_selection_rect_s( + x_px: 180, + y_px: 30, + width_px: 24, + height_px: 60, + visible: true + ), + end: ghostty_terminal_surface_selection_rect_s( + x_px: 60, + y_px: 90, + width_px: 24, + height_px: 60, + visible: true + ), + active: true, + rectangle: false + ), + scaleFactor: 3 + ) + + XCTAssertTrue(snapshot.isActive) + XCTAssertEqual(snapshot.start, CGRect(x: 60, y: 10, width: 8, height: 20)) + XCTAssertEqual(snapshot.end, CGRect(x: 20, y: 30, width: 8, height: 20)) + } + + func testSelectionSnapshotOmitsInvisibleEndpointGeometry() { + let snapshot = GhosttyLocalSelectionSnapshot( + cValue: ghostty_terminal_surface_selection_snapshot_s( + start: ghostty_terminal_surface_selection_rect_s( + x_px: 30, + y_px: 60, + width_px: 24, + height_px: 60, + visible: true + ), + end: ghostty_terminal_surface_selection_rect_s( + x_px: 0, + y_px: 0, + width_px: 0, + height_px: 0, + visible: false + ), + active: true, + rectangle: false + ), + scaleFactor: 3 + ) + + XCTAssertEqual(snapshot.start, CGRect(x: 10, y: 20, width: 8, height: 20)) + XCTAssertNil(snapshot.end) + } + + + func testDecodeGhosttyTextReturnsEmptyStringForMissingBuffer() { + XCTAssertEqual( + GhosttyKitControlSurface.decodeGhosttyText( + ghostty_text_s( + tl_px_x: 0, + tl_px_y: 0, + offset_start: 0, + offset_len: 0, + text: nil, + text_len: 0 + ) + ), + "" + ) + } + + func testDecodeGhosttyTextPreservesUtf8Content() { + let value = "café λ" + + let decoded = value.withCString { pointer in + GhosttyKitControlSurface.decodeGhosttyText( + ghostty_text_s( + tl_px_x: 0, + tl_px_y: 0, + offset_start: 0, + offset_len: UInt32(value.utf8.count), + text: pointer, + text_len: UInt(value.utf8.count) + ) + ) + } + + XCTAssertEqual(decoded, value) + } + +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift new file mode 100644 index 00000000..ecdd2cac --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyModifierStateTests: XCTestCase { + func testControlLatchTransformsLetterAndClears() { + var state = GhosttyModifierState() + state.toggleControl() + + XCTAssertEqual(state.apply(to: "c"), "\u{03}") + XCTAssertFalse(state.isControlArmed) + } + + func testControlLatchTransformsBracketIntoEscape() { + var state = GhosttyModifierState() + state.toggleControl() + + XCTAssertEqual(state.apply(to: "["), "\u{1B}") + XCTAssertFalse(state.isControlArmed) + } + + func testControlLatchTransformsSpaceIntoNul() { + var state = GhosttyModifierState() + state.toggleControl() + + XCTAssertEqual(state.apply(to: " "), "\u{00}") + XCTAssertFalse(state.isControlArmed) + } + + func testControlLatchFallsBackToPlainTextAndClears() { + var state = GhosttyModifierState() + state.toggleControl() + + XCTAssertEqual(state.apply(to: "7"), "7") + XCTAssertFalse(state.isControlArmed) + } + + func testControlLatchAddsCtrlModifierToKeyEvent() { + var state = GhosttyModifierState() + state.toggleControl() + let event = GhosttySurfaceKeyEvent(keyCode: .arrowUp) + + XCTAssertEqual( + state.apply(to: event), + GhosttySurfaceKeyEvent(keyCode: .arrowUp, mods: [.ctrl]) + ) + XCTAssertFalse(state.isControlArmed) + } + + func testAltLatchPrefixesTextWithEscapeAndClears() { + var state = GhosttyModifierState() + state.toggleAlt() + + XCTAssertEqual(state.apply(to: "b"), "\u{1B}b") + XCTAssertFalse(state.isAltArmed) + } + + func testControlAndAltLatchesComposeForText() { + var state = GhosttyModifierState() + state.toggleControl() + state.toggleAlt() + + XCTAssertEqual(state.apply(to: "c"), "\u{1B}\u{03}") + XCTAssertFalse(state.isControlArmed) + XCTAssertFalse(state.isAltArmed) + } + + func testAltLatchAddsModifierToKeyEventAndClearsBothLatches() { + var state = GhosttyModifierState() + state.toggleControl() + state.toggleAlt() + + XCTAssertEqual( + state.apply(to: GhosttySurfaceKeyEvent(keyCode: .arrowLeft)), + GhosttySurfaceKeyEvent(keyCode: .arrowLeft, mods: [.ctrl, .alt]) + ) + XCTAssertFalse(state.isControlArmed) + XCTAssertFalse(state.isAltArmed) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyPublishedFrameObserverTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyPublishedFrameObserverTests.swift new file mode 100644 index 00000000..387df439 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyPublishedFrameObserverTests.swift @@ -0,0 +1,47 @@ +import QuartzCore +import XCTest +@testable import MoriRemoteTerminal + +@MainActor +final class GhosttyPublishedFrameObserverTests: XCTestCase { + func testPublishedContentsRefreshesTargetInteractionState() async { + let layer = CALayer() + let target = RefreshTarget() + let observer = GhosttyPublishedFrameObserver() + let refreshed = expectation(description: "published frame refreshes interaction state") + target.onRefresh = { refreshed.fulfill() } + observer.observe(layer, target: target) + + layer.contents = NSObject() + + await fulfillment(of: [refreshed], timeout: 1) + XCTAssertEqual(target.refreshCount, 1) + } + + func testInvalidationRejectsLaterPublications() async { + let layer = CALayer() + let target = RefreshTarget() + let observer = GhosttyPublishedFrameObserver() + let rejected = expectation(description: "invalidated observer stays silent") + rejected.isInverted = true + target.onRefresh = { rejected.fulfill() } + observer.observe(layer, target: target) + observer.invalidate() + + layer.contents = NSObject() + + await fulfillment(of: [rejected], timeout: 0.05) + XCTAssertEqual(target.refreshCount, 0) + } +} + +@MainActor +private final class RefreshTarget: GhosttyInteractionStateRefreshing { + var refreshCount = 0 + var onRefresh: (() -> Void)? + + func refreshInteractionState() { + refreshCount += 1 + onRefresh?() + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyScrollDeltaBudgetTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyScrollDeltaBudgetTests.swift new file mode 100644 index 00000000..664a77b9 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyScrollDeltaBudgetTests.swift @@ -0,0 +1,181 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyScrollDeltaBudgetTests: XCTestCase { + func testDefaultBurstBoundsInstantDump() { + // At the pager cap (150 ticks/s equivalent) the default burst + // must not allow a screenful teleport at gesture start. + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 150) + let instantDump = budget.clamp(10_000, at: 0) + XCTAssertLessThanOrEqual(instantDump, 150 * 0.1) + } + + func testRouteForwardedGainDefaultsToDeviceTunedValue() { + // No env override in the test process: the resolved gain is + // the shipped default. + XCTAssertEqual(GhosttyScrollTuning.routeForwardedGain, 1.5) + XCTAssertEqual(GhosttyScrollTuning.routeForwardedDefaultGain, 1.5) + } + + func testClampPassesDeltaWithinBudget() { + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 100, burstSeconds: 0.5) + + XCTAssertEqual(budget.clamp(30, at: 0), 30) + XCTAssertEqual(budget.clamp(-20, at: 0.01), -20, accuracy: 1) + } + + func testClampPreservesSign() { + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 100, burstSeconds: 0.1) + + XCTAssertEqual(budget.clamp(-500, at: 0), -10) + } + + func testClampDropsExcessBeyondBurstCapacity() { + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 100, burstSeconds: 0.25) + + // Burst capacity is 25 units; a violent first delta saturates. + XCTAssertEqual(budget.clamp(1_000, at: 0), 25) + // Immediately after, nothing is available. + XCTAssertEqual(budget.clamp(1_000, at: 0), 0) + } + + func testBudgetRefillsWithElapsedTime() { + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 100, burstSeconds: 0.25) + + XCTAssertEqual(budget.clamp(1_000, at: 0), 25) + // 100 ms later: 10 more units became available. + XCTAssertEqual(budget.clamp(1_000, at: 0.1), 10, accuracy: 0.000_1) + // Refill never exceeds burst capacity even after long idle. + XCTAssertEqual(budget.clamp(1_000, at: 60), 25, accuracy: 0.000_1) + } + + func testSustainedRateConvergesToConfiguredThroughput() { + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 100, burstSeconds: 0.25) + + // Simulate a 2-second deceleration delivering far more delta + // than the budget allows, in 60Hz callbacks. + var emitted = 0.0 + var now = 0.0 + while now < 2.0 { + emitted += budget.clamp(50, at: now) + now += 1.0 / 60.0 + } + + // Burst (25) + 2s of refill (200), within one frame's tolerance. + XCTAssertEqual(emitted, 225, accuracy: 50.0 / 60.0 + 0.001) + } + + func testZeroDeltaConsumesNothing() { + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 100, burstSeconds: 0.25) + + XCTAssertEqual(budget.clamp(0, at: 0), 0) + XCTAssertEqual(budget.clamp(25, at: 0), 25) + } + + func testRearmResetsAvailabilityAndRate() { + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 100, burstSeconds: 0.25) + XCTAssertEqual(budget.clamp(1_000, at: 0), 25) + + budget.rearm(unitsPerSecond: 200) + + // Fresh burst at the new rate, with no stale refill timestamp: + // capacity is 200 * 0.25 = 50 immediately. + XCTAssertEqual(budget.clamp(1_000, at: 0), 50) + } + + func testVelocityGainIsUnityForSlowDrags() { + // The pure mapping: identical inputs always produce identical + // gain (determinism), and slow speeds are untouched. + XCTAssertEqual(GhosttyScrollVelocityGain.multiplier(forVelocity: 0), 1) + XCTAssertEqual(GhosttyScrollVelocityGain.multiplier(forVelocity: 899), 1) + XCTAssertEqual(GhosttyScrollVelocityGain.multiplier(forVelocity: 900), 1) + } + + func testVelocityGainRampsMonotonicallyAndClamps() { + let mid = GhosttyScrollVelocityGain.multiplier(forVelocity: 2_200) + XCTAssertGreaterThan(mid, 1) + XCTAssertLessThan(mid, GhosttyScrollVelocityGain.maxMultiplier) + XCTAssertEqual( + GhosttyScrollVelocityGain.multiplier(forVelocity: 3_500), + GhosttyScrollVelocityGain.maxMultiplier + ) + XCTAssertEqual( + GhosttyScrollVelocityGain.multiplier(forVelocity: 50_000), + GhosttyScrollVelocityGain.maxMultiplier + ) + // Monotonic across the ramp. + var last = 0.0 + for v in stride(from: 0.0, through: 5_000, by: 250) { + let m = GhosttyScrollVelocityGain.multiplier(forVelocity: v) + XCTAssertGreaterThanOrEqual(m, last) + last = m + } + } + + func testVelocityGainSmoothingTracksSustainedSpeed() { + var gain = GhosttyScrollVelocityGain(smoothing: 1) + // First sample only seeds the clock. + XCTAssertEqual(gain.multiplier(delta: 0, at: 0), 1) + // Sustained 3500 pt/s: full multiplier. + XCTAssertEqual( + gain.multiplier(delta: 3_500 / 60, at: 1.0 / 60), + GhosttyScrollVelocityGain.maxMultiplier + ) + // Sustained slow speed drops back to unity. + XCTAssertEqual(gain.multiplier(delta: 5, at: 2.0 / 60), 1) + } + + func testVelocityGainResetForgetsSpeed() { + var gain = GhosttyScrollVelocityGain(smoothing: 1) + _ = gain.multiplier(delta: 0, at: 0) + _ = gain.multiplier(delta: 100, at: 1.0 / 60) + gain.reset() + XCTAssertEqual(gain.multiplier(delta: 100, at: 1), 1) + } + + func testTailCutoffStopsBelowQuantizationFloor() { + var cutoff = GhosttyScrollTailCutoff(smoothing: 1) + let cell = 20.0 + + // 10 cells/s: well above the floor, keep coasting. + XCTAssertFalse(cutoff.shouldStop(delta: 0, at: 0, cellHeightPoints: cell)) + XCTAssertFalse(cutoff.shouldStop(delta: 3.33, at: 1.0 / 60, cellHeightPoints: cell)) + + // Decayed to ~1 cell/s: below the 2.5 cells/s floor. + XCTAssertTrue(cutoff.shouldStop(delta: 0.33, at: 2.0 / 60, cellHeightPoints: cell)) + } + + func testTailCutoffSmoothingIgnoresSingleShortFrame() { + var cutoff = GhosttyScrollTailCutoff(smoothing: 0.3) + let cell = 20.0 + + _ = cutoff.shouldStop(delta: 0, at: 0, cellHeightPoints: cell) + // Sustained fast coast builds a high smoothed speed. + var now = 0.0 + for _ in 0..<10 { + now += 1.0 / 60 + XCTAssertFalse(cutoff.shouldStop(delta: 10, at: now, cellHeightPoints: cell)) + } + // One near-zero frame (display-link hiccup) must not stop it. + now += 1.0 / 60 + XCTAssertFalse(cutoff.shouldStop(delta: 0.2, at: now, cellHeightPoints: cell)) + } + + func testTailCutoffResetForgetsHistory() { + var cutoff = GhosttyScrollTailCutoff(smoothing: 1) + let cell = 20.0 + _ = cutoff.shouldStop(delta: 0, at: 0, cellHeightPoints: cell) + XCTAssertTrue(cutoff.shouldStop(delta: 0.1, at: 1.0 / 60, cellHeightPoints: cell)) + + cutoff.reset() + // After reset the first sample only seeds the clock. + XCTAssertFalse(cutoff.shouldStop(delta: 0.1, at: 1, cellHeightPoints: cell)) + } + + func testZeroRateBudgetBlocksEverything() { + var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 0) + + XCTAssertEqual(budget.clamp(100, at: 0), 0) + XCTAssertEqual(budget.clamp(100, at: 10), 0) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttySurfaceScrollGestureTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttySurfaceScrollGestureTests.swift new file mode 100644 index 00000000..113d552d --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttySurfaceScrollGestureTests.swift @@ -0,0 +1,413 @@ +import CoreGraphics +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttySurfaceScrollGestureTests: XCTestCase { + func testPaneScrollGeometryRejectsUnsizedDisplayViewport() { + XCTAssertNil(GhosttyPaneScrollGeometry.displayViewportSize(for: .zero)) + XCTAssertNil(GhosttyPaneScrollGeometry.displayViewportSize(for: CGRect(x: 0, y: 0, width: 120, height: 0))) + XCTAssertNil(GhosttyPaneScrollGeometry.displayViewportSize(for: CGRect(x: 0, y: 0, width: 0, height: 240))) + XCTAssertNil(GhosttyPaneScrollGeometry.displayViewportSize(for: CGRect(x: 0, y: 0, width: CGFloat.infinity, height: 240))) + } + + func testPaneScrollGeometryReturnsPositiveDisplayViewport() { + XCTAssertEqual( + GhosttyPaneScrollGeometry.displayViewportSize(for: CGRect(x: 0, y: 0, width: 120, height: 240)), + CGSize(width: 120, height: 240) + ) + } + + func testVerticalDominantVelocityAllowsRouteForwardingPanToBegin() { + XCTAssertTrue(GhosttySurfacePanGesture.verticalScrollShouldBegin(forVelocity: CGPoint(x: 30, y: 80))) + XCTAssertFalse(GhosttySurfacePanGesture.horizontalNavigationShouldBegin(forVelocity: CGPoint(x: 30, y: 80))) + } + + func testHorizontalDominantVelocityAllowsWindowPanToBegin() { + XCTAssertTrue(GhosttySurfacePanGesture.horizontalNavigationShouldBegin(forVelocity: CGPoint(x: 120, y: 40))) + XCTAssertFalse(GhosttySurfacePanGesture.verticalScrollShouldBegin(forVelocity: CGPoint(x: 120, y: 40))) + } + + func testSurfaceContainerPanRejectsSingleWindow() { + XCTAssertFalse( + GhosttySurfacePanGesture.surfaceContainerPanShouldBegin( + topLevelCount: 1, + velocity: CGPoint(x: 120, y: 0) + ) + ) + } + + func testSurfaceContainerPanAllowsZeroVelocityForTranslationDrivenNavigation() { + XCTAssertTrue( + GhosttySurfacePanGesture.surfaceContainerPanShouldBegin( + topLevelCount: 2, + velocity: .zero + ) + ) + } + + func testSurfaceContainerPanRejectsVerticalIntent() { + XCTAssertFalse( + GhosttySurfacePanGesture.surfaceContainerPanShouldBegin( + topLevelCount: 2, + velocity: CGPoint(x: 30, y: 120) + ) + ) + } + + func testZeroVelocityDoesNotCommitPanAxis() { + XCTAssertFalse(GhosttySurfacePanGesture.horizontalNavigationShouldBegin(forVelocity: .zero)) + XCTAssertFalse(GhosttySurfacePanGesture.verticalScrollShouldBegin(forVelocity: .zero)) + } + + func testRouteForwardingPanUsesTranslationForSlowVerticalDrag() { + XCTAssertTrue( + GhosttySurfacePanGesture.routeForwardingScrollShouldBegin( + forVelocity: .zero, + translation: CGPoint(x: 2, y: 12) + ) + ) + } + + func testRouteForwardingPanRejectsResolvedHorizontalTranslation() { + XCTAssertFalse( + GhosttySurfacePanGesture.routeForwardingScrollShouldBegin( + forVelocity: CGPoint(x: 0, y: 120), + translation: CGPoint(x: 12, y: 2) + ) + ) + } + + func testSmallTranslationDoesNotResolveGestureAxis() { + XCTAssertNil( + GhosttySurfacePanGesture.axis( + forTranslation: CGPoint(x: 2, y: 5) + ) + ) + } + + func testVerticalDominantTranslationResolvesVerticalAxis() { + XCTAssertEqual( + GhosttySurfacePanGesture.axis( + forTranslation: CGPoint(x: 4, y: 12) + ), + .vertical + ) + } + + func testHorizontalDominantTranslationResolvesHorizontalAxis() { + XCTAssertEqual( + GhosttySurfacePanGesture.axis( + forTranslation: CGPoint(x: 12, y: 4) + ), + .horizontal + ) + } + + func testAxisResolutionPreservesExistingDecision() { + XCTAssertEqual( + GhosttySurfacePanGesture.axis( + forTranslation: CGPoint(x: 100, y: 1), + currentAxis: .vertical + ), + .vertical + ) + } + + func testZeroTranslationProducesNoScrollEvent() { + var gesture = GhosttyRouteForwardingScrollGesture() + + XCTAssertTrue( + gesture.events( + forTranslation: .zero + ).isEmpty + ) + } + + func testFirstVerticalDragStartsPreciseScrollSession() { + var gesture = GhosttyRouteForwardingScrollGesture() + + let events = gesture.events( + forTranslation: CGPoint(x: 0, y: 12) + ) + + XCTAssertEqual(events.count, 1) + let event = events.first + XCTAssertEqual(event?.deltaX, 0) + XCTAssertEqual(event?.deltaY, 24) + XCTAssertEqual(event?.mods, .init(precision: true, momentum: .began)) + } + + func testCustomPreciseScaleAppliesToEmittedDeltas() { + var gesture = GhosttyRouteForwardingScrollGesture(preciseScale: 1) + + let events = gesture.events( + forTranslation: CGPoint(x: 0, y: 12) + ) + + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.deltaY, 12) + } + + func testDefaultPreciseScaleMatchesLongStandingValue() { + XCTAssertEqual(GhosttyRouteForwardingScrollGesture().preciseScale, 2) + XCTAssertEqual(GhosttyRouteForwardingScrollGesture.defaultPreciseScale, 2) + } + + func testSecondVerticalDragContinuesPreciseScrollSession() { + var gesture = GhosttyRouteForwardingScrollGesture() + + _ = gesture.events( + forTranslation: CGPoint(x: 0, y: 12) + ) + let events = gesture.events( + forTranslation: CGPoint(x: 0, y: -4) + ) + + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.deltaY, -8) + XCTAssertEqual(events.first?.mods, .init(precision: true, momentum: .changed)) + } + + func testTinyVerticalDeltasAreAccumulatedUntilDispatchable() { + var gesture = GhosttyRouteForwardingScrollGesture() + + XCTAssertTrue( + gesture.events( + forTranslation: CGPoint(x: 0, y: 0.2) + ).isEmpty + ) + + let events = gesture.events( + forTranslation: CGPoint(x: 0, y: 0.3) + ) + + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.deltaY, 1) + XCTAssertEqual(events.first?.mods, .init(precision: true, momentum: .began)) + } + + func testHorizontalNavigationUsesTranslationDirection() { + XCTAssertEqual( + GhosttySurfacePanGesture.windowNavigationDirection( + forTranslation: CGPoint(x: -72, y: 4), + velocity: CGPoint(x: -120, y: 0), + axis: .horizontal, + didNavigate: false + ), + .next + ) + + XCTAssertEqual( + GhosttySurfacePanGesture.windowNavigationDirection( + forTranslation: CGPoint(x: 72, y: 4), + velocity: CGPoint(x: 120, y: 0), + axis: .horizontal, + didNavigate: false + ), + .previous + ) + } + + func testHorizontalNavigationCanUseFlingVelocityBeforeLargeTranslation() { + XCTAssertEqual( + GhosttySurfacePanGesture.windowNavigationDirection( + forTranslation: CGPoint(x: -18, y: 2), + velocity: CGPoint(x: -520, y: 20), + axis: .horizontal, + didNavigate: false + ), + .next + ) + } + + func testHorizontalNavigationRequiresHorizontalIntent() { + XCTAssertNil( + GhosttySurfacePanGesture.windowNavigationDirection( + forTranslation: CGPoint(x: 80, y: 2), + velocity: CGPoint(x: 600, y: 0), + axis: .vertical, + didNavigate: false + ) + ) + } + + func testHorizontalNavigationIsSuppressedAfterNavigationFires() { + XCTAssertNil( + GhosttySurfacePanGesture.windowNavigationDirection( + forTranslation: CGPoint(x: -120, y: 0), + velocity: CGPoint(x: -700, y: 0), + axis: .horizontal, + didNavigate: true + ) + ) + } + + func testHorizontalNavigationRejectsAmbiguousDiagonalMovement() { + XCTAssertNil( + GhosttySurfacePanGesture.windowNavigationDirection( + forTranslation: CGPoint(x: 58, y: 54), + velocity: CGPoint(x: 300, y: 290), + axis: .horizontal, + didNavigate: false + ) + ) + } + + func testEndedPhaseClosesActiveScrollSessionEvenWithZeroFinalDelta() { + var gesture = GhosttyRouteForwardingScrollGesture() + + _ = gesture.events( + forTranslation: CGPoint(x: 0, y: 4) + ) + let events = gesture.events( + forTranslation: .zero, + phase: .ended + ) + + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.deltaY, 0) + XCTAssertEqual(events.first?.mods, .init(precision: true, momentum: .ended)) + } + + func testCancelledPhaseClosesActiveScrollSessionWithFinalDelta() { + var gesture = GhosttyRouteForwardingScrollGesture() + + _ = gesture.events( + forTranslation: CGPoint(x: 0, y: 4) + ) + let events = gesture.events( + forTranslation: CGPoint(x: 0, y: -3), + phase: .cancelled + ) + + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.deltaY, -6) + XCTAssertEqual(events.first?.mods, .init(precision: true, momentum: .cancelled)) + } + + func testResetStartsNextVerticalScrollSessionFresh() { + var gesture = GhosttyRouteForwardingScrollGesture() + + _ = gesture.events( + forTranslation: CGPoint(x: 0, y: 4) + ) + gesture.reset() + let events = gesture.events( + forTranslation: CGPoint(x: 0, y: 2) + ) + + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.deltaY, 4) + XCTAssertEqual(events.first?.mods, .init(precision: true, momentum: .began)) + } + + func testPaneScrollGeometryMapsScrollbarStateToUIKitOffset() { + let state = GhosttySurfaceScrollState( + total: 100, + offset: 12, + len: 20, + cellOffset: 0.5 + ) + + XCTAssertEqual( + GhosttyPaneScrollGeometry.contentOffsetY( + for: state, + cellHeight: 10, + maxContentOffsetY: 800 + ), + 125 + ) + } + + func testPaneScrollGeometryMapsBottomStateToUIKitBottomOffset() { + let state = GhosttySurfaceScrollState( + total: 100, + offset: 80, + len: 20, + cellOffset: 0 + ) + + XCTAssertEqual( + GhosttyPaneScrollGeometry.contentOffsetY( + for: state, + cellHeight: 10, + maxContentOffsetY: 900 + ), + 900 + ) + } + + func testPaneScrollGeometryMapsScrollbarStateToScrollPosition() { + let state = GhosttySurfaceScrollState( + total: 100, + offset: 12, + len: 20, + cellOffset: 0.5 + ) + + XCTAssertEqual( + GhosttyPaneScrollGeometry.position(for: state), + GhosttyPaneScrollPosition(row: 12, cellOffset: 0.5) + ) + } + + func testPaneScrollGeometryClampsMaxRowPositionToCellBoundary() { + let state = GhosttySurfaceScrollState( + total: 100, + offset: 90, + len: 20, + cellOffset: 0.5 + ) + + XCTAssertEqual( + GhosttyPaneScrollGeometry.position(for: state), + GhosttyPaneScrollPosition(row: 80, cellOffset: 0) + ) + } + + func testPaneScrollPositionUsesToleranceForDuplicateScrollSubmissions() { + let position = GhosttyPaneScrollPosition(row: 12, cellOffset: 0.5) + + XCTAssertTrue(position.approximatelyEquals(GhosttyPaneScrollPosition(row: 12, cellOffset: 0.500_000_5))) + XCTAssertFalse(position.approximatelyEquals(GhosttyPaneScrollPosition(row: 12, cellOffset: 0.500_002))) + XCTAssertFalse(position.approximatelyEquals(GhosttyPaneScrollPosition(row: 13, cellOffset: 0.5))) + } + + func testPaneScrollGeometryMapsUIKitOffsetToFractionalPosition() { + let state = GhosttySurfaceScrollState( + total: 100, + offset: 0, + len: 20, + cellOffset: 0 + ) + + XCTAssertEqual( + GhosttyPaneScrollGeometry.position( + forContentOffsetY: 125, + cellHeight: 10, + state: state, + maxContentOffsetY: 800 + ), + GhosttyPaneScrollPosition(row: 12, cellOffset: 0.5) + ) + } + + func testPaneScrollGeometryClampsBottomToAlignedRow() { + let state = GhosttySurfaceScrollState( + total: 100, + offset: 0, + len: 20, + cellOffset: 0 + ) + + XCTAssertEqual( + GhosttyPaneScrollGeometry.position( + forContentOffsetY: 900, + cellHeight: 10, + state: state, + maxContentOffsetY: 800 + ), + GhosttyPaneScrollPosition(row: 80, cellOffset: 0) + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCompositionStateTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCompositionStateTests.swift new file mode 100644 index 00000000..a8d45360 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCompositionStateTests.swift @@ -0,0 +1,33 @@ +import CoreGraphics +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyTerminalCompositionStateTests: XCTestCase { + func testKeyboardShowAndDismissPreserveViewportUntilCompletion() { + var state = GhosttyTerminalCompositionState() + _ = state.reconcileViewport(CGSize(width: 390, height: 700)) + state.inputCoordinator.showSystemKeyboard(isInputAvailable: true) + + let request = state.applyKeyboardVisibility( + frameEnd: CGRect(x: 0, y: 400, width: 390, height: 444), + screenBounds: CGRect(x: 0, y: 0, width: 390, height: 844), + animationDuration: 0.25 + ) + XCTAssertEqual(state.keyboardOverlapHeight, 444) + XCTAssertEqual(request?.target, .shown) + let begin = state.beginKeyboardTransition(request!) + XCTAssertTrue(state.viewportCoordinator.isKeyboardTransitionActive) + XCTAssertEqual(state.viewportCoordinator.effectiveSize(liveSize: CGSize(width: 390, height: 400)), CGSize(width: 390, height: 700)) + XCTAssertNotNil(state.completeKeyboardTransition(token: begin.fallbackToken)) + XCTAssertFalse(state.viewportCoordinator.isKeyboardTransitionActive) + + state.inputCoordinator.dismissKeyboard() + let hide = state.applyKeyboardVisibility( + frameEnd: CGRect(x: 0, y: 844, width: 390, height: 0), + screenBounds: CGRect(x: 0, y: 0, width: 390, height: 844), + animationDuration: 0.25 + ) + XCTAssertEqual(state.keyboardOverlapHeight, 0) + XCTAssertEqual(hide?.target, .hidden) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift new file mode 100644 index 00000000..f942330e --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift @@ -0,0 +1,31 @@ +import XCTest +import SwiftUI +@testable import MoriRemoteTerminal + +@MainActor +final class GhosttyTerminalCoreViewTests: XCTestCase { + func testCompositionRootConstructsWithDetachedAdapterAndNoSSH() { + let adapter = TmuxTerminalScreenAdapter() + let view = GhosttyTerminalCoreView(screen: adapter) + + // Construction is intentionally side-effect free: no transport, SSH + // account, or persistence object is required before Phase 2 wiring. + XCTAssertNotNil(view) + XCTAssertEqual(adapter.stateTraceLabel, "released") + } + + func testInputSuspensionOverridesTerminalReadiness() { + XCTAssertTrue(GhosttyTerminalInputAvailabilityProjection( + isTerminalReady: true, + isSuspended: false + ).isInputAvailable) + XCTAssertFalse(GhosttyTerminalInputAvailabilityProjection( + isTerminalReady: true, + isSuspended: true + ).isInputAvailable) + XCTAssertFalse(GhosttyTerminalInputAvailabilityProjection( + isTerminalReady: false, + isSuspended: false + ).isInputAvailable) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalPrefixFlushLifecycleTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalPrefixFlushLifecycleTests.swift new file mode 100644 index 00000000..2084ee46 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalPrefixFlushLifecycleTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyTerminalPrefixFlushLifecycleTests: XCTestCase { + func testLonePrefixFlushesOnlyForItsScheduledToken() { + var controller = GhosttyTerminalInputController() + guard case .schedulePrefixFlush(let token) = controller.receiveText("\u{02}") else { + return XCTFail("prefix must schedule delayed flush") + } + + XCTAssertEqual(controller.flushPendingTmuxPrefixInput(matching: token), "\u{02}") + XCTAssertNil(controller.flushPendingTmuxPrefixInput(matching: token)) + } + + func testStaleFlushTokenCannotSubmitAfterFollowupInputConsumesPrefix() { + var controller = GhosttyTerminalInputController() + guard case .schedulePrefixFlush(let stale) = controller.receiveText("\u{02}"), + case .submit(let combined) = controller.receiveText("x") + else { return XCTFail("follow-up input must consume pending prefix") } + + XCTAssertEqual(combined, "\u{02}x") + XCTAssertNil(controller.flushPendingTmuxPrefixInput(matching: stale)) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderFocusPolicyTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderFocusPolicyTests.swift new file mode 100644 index 00000000..5f421c6b --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderFocusPolicyTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyTerminalResponderFocusPolicyTests: XCTestCase { + func testTerminalOwnsSystemKeyboardWhenSelectedAndAvailable() { + let policy = GhosttyTerminalResponderFocusPolicy( + isSelected: true, keyboardMode: .system, keyboardOwner: .terminal, + isInputAvailable: true, isTransientInputOwnerPresented: false + ) + XCTAssertTrue(policy.isResponderEnabled) + XCTAssertTrue(policy.wantsFirstResponder) + } + + func testTransientInputOwnerSuspendsTerminalResponder() { + let policy = GhosttyTerminalResponderFocusPolicy( + isSelected: true, keyboardMode: .system, keyboardOwner: .terminal, + isInputAvailable: true, isTransientInputOwnerPresented: true + ) + XCTAssertFalse(policy.isResponderEnabled) + XCTAssertFalse(policy.wantsFirstResponder) + } + + func testHiddenKeyboardAndNonTerminalOwnerDoNotRequestFirstResponder() { + XCTAssertFalse(GhosttyTerminalResponderFocusPolicy( + isSelected: true, keyboardMode: .hidden, keyboardOwner: .none, + isInputAvailable: true, isTransientInputOwnerPresented: false + ).wantsFirstResponder) + XCTAssertFalse(GhosttyTerminalResponderFocusPolicy( + isSelected: true, keyboardMode: .system, keyboardOwner: .composer, + isInputAvailable: true, isTransientInputOwnerPresented: false + ).wantsFirstResponder) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderViewTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderViewTests.swift new file mode 100644 index 00000000..4fe74e91 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderViewTests.swift @@ -0,0 +1,937 @@ +import UIKit +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyTerminalResponderViewTests: XCTestCase { + @MainActor + func testResponderReportsTextWhenEnabled() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + XCTAssertTrue(view.hasText) + } + + @MainActor + func testDeleteBackwardSendsBackspaceKeyEvent() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedEvents: [GhosttySurfaceKeyEvent] = [] + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { + receivedEvents.append($0) + return true + } + ) + + view.deleteBackward() + + XCTAssertEqual(receivedEvents, [.init(keyCode: .backspace)]) + } + + @MainActor + func testDeleteBackwardIsIgnoredWhenDisabled() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedEvents: [GhosttySurfaceKeyEvent] = [] + + view.update( + isEnabled: false, + wantsFirstResponder: false, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { + receivedEvents.append($0) + return true + } + ) + + view.deleteBackward() + + XCTAssertTrue(receivedEvents.isEmpty) + } + + @MainActor + func testInsertTextSendsRawTerminalInputWhenEnabled() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedText: [String] = [] + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { + receivedText.append($0) + return true + }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + view.insertText("hello") + + XCTAssertEqual(receivedText, ["hello"]) + } + + @MainActor + func testReplaceTextSendsCommittedTerminalInputWhenEnabled() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedText: [String] = [] + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { + receivedText.append($0) + return true + }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + view.replace(view.selectedTextRange!, withText: "hello") + + XCTAssertEqual(receivedText, ["hello"]) + } + + @MainActor + func testInsertTextIsIgnoredWhenDisabled() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedText: [String] = [] + + view.update( + isEnabled: false, + wantsFirstResponder: false, + activationToken: 1, + sendText: { + receivedText.append($0) + return true + }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + view.insertText("ignored") + + XCTAssertTrue(receivedText.isEmpty) + } + + @MainActor + func testReplaceTextIsIgnoredWhenDisabled() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedText: [String] = [] + + view.update( + isEnabled: false, + wantsFirstResponder: false, + activationToken: 1, + sendText: { + receivedText.append($0) + return true + }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + view.replace(view.selectedTextRange!, withText: "ignored") + + XCTAssertTrue(receivedText.isEmpty) + } + + @MainActor + func testPasteUsesPasteHandlerInsteadOfRawTextHandler() { + let view = GhosttyTerminalResponderUIView( + trackpadDriver: GhosttyKeyboardCursorTrackpadDriver(), + pasteboardString: { "first\nsecond" } + ) + var rawText: [String] = [] + var pastedText: [String] = [] + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { + rawText.append($0) + return true + }, + sendPaste: { + pastedText.append($0) + return true + }, + sendKeyEvent: { _ in true } + ) + + view.paste(nil) + + XCTAssertTrue(rawText.isEmpty) + XCTAssertEqual(pastedText, ["first\nsecond"]) + } + + @MainActor + func testPasteIgnoresEmptyPasteboardString() { + let view = GhosttyTerminalResponderUIView( + trackpadDriver: GhosttyKeyboardCursorTrackpadDriver(), + pasteboardString: { "" } + ) + var pastedText: [String] = [] + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { + pastedText.append($0) + return true + }, + sendKeyEvent: { _ in true } + ) + + view.paste(nil) + + XCTAssertTrue(pastedText.isEmpty) + } + + func testHardwareCommandMappingResolvesBackspaceHIDUsage() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardDeleteOrBackspace, + modifiers: [] + ), + .keyEvent(.init(keyCode: .backspace)) + ) + } + + func testHardwareCommandMappingResolvesForwardDeleteHIDUsage() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardDeleteForward, + modifiers: [] + ), + .keyEvent(.init(keyCode: .delete)) + ) + } + + func testHardwareCommandMappingResolvesCoreNavigationHIDUsages() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardReturnOrEnter, + modifiers: [] + ), + .keyEvent(.init(keyCode: .enter)) + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardTab, + modifiers: [] + ), + .keyEvent(.init(keyCode: .tab)) + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardEscape, + modifiers: [] + ), + .keyEvent(.init(keyCode: .escape)) + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardUpArrow, + modifiers: [] + ), + .keyEvent(.init(keyCode: .arrowUp)) + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardRightArrow, + modifiers: [] + ), + .keyEvent(.init(keyCode: .arrowRight)) + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardHome, + modifiers: [] + ), + .keyEvent(.init(keyCode: .home)) + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardEnd, + modifiers: [] + ), + .keyEvent(.init(keyCode: .end)) + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardPageUp, + modifiers: [] + ), + .keyEvent(.init(keyCode: .pageUp)) + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardPageDown, + modifiers: [] + ), + .keyEvent(.init(keyCode: .pageDown)) + ) + } + + func testHardwareCommandMappingPreservesHIDModifiers() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardLeftArrow, + modifiers: [.shift, .control] + ), + .keyEvent(.init(keyCode: .arrowLeft, mods: [.shift, .ctrl])) + ) + } + + func testHardwarePressResolutionPrefersMappedHIDUsageOverPrintableText() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardReturnOrEnter, + modifiers: [], + characters: "x", + charactersIgnoringModifiers: "x" + ), + .keyEvent(.init(keyCode: .enter)) + ) + } + + func testHardwarePressResolutionUsesControlTextFromCharactersIgnoringModifiers() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardC, + modifiers: .control, + characters: "c", + charactersIgnoringModifiers: "c" + ), + .text("\u{03}") + ) + } + + func testHardwarePressResolutionUsesPrintableCharactersAfterUnmappedHID() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardA, + modifiers: [], + characters: "a", + charactersIgnoringModifiers: "a" + ), + .text("a") + ) + } + + func testHardwarePressResolutionRejectsCommandPrintableText() { + XCTAssertNil( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardA, + modifiers: .command, + characters: "a", + charactersIgnoringModifiers: "a" + ) + ) + } + + func testHardwarePressResolutionRejectsControlPrintableTextWithoutControlTranslationInput() { + XCTAssertNil( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardA, + modifiers: .control, + characters: "a", + charactersIgnoringModifiers: nil + ) + ) + } + + func testHardwarePressResolutionReturnsNilForUnmappedEmptyPress() { + XCTAssertNil( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardA, + modifiers: [], + characters: "", + charactersIgnoringModifiers: nil + ) + ) + } + + func testHardwareCommandMappingRejectsUnmappedHIDUsageWithoutControlModifiers() { + XCTAssertNil( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardA, + modifiers: [] + ) + ) + } + + func testHardwareCommandMappingResolvesCtrlHardwareLetterToText() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardA, + modifiers: .control, + charactersIgnoringModifiers: "a" + ), + .text("\u{01}") + ) + } + + func testHardwarePressResolutionResolvesControlPunctuationToText() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardOpenBracket, + modifiers: .control, + characters: "[", + charactersIgnoringModifiers: "[" + ), + .text("\u{1B}") + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardSpacebar, + modifiers: .control, + characters: " ", + charactersIgnoringModifiers: " " + ), + .text("\u{00}") + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwarePress( + keyCode: .keyboardHyphen, + modifiers: .control, + characters: "-", + charactersIgnoringModifiers: "-" + ), + .text("\u{1F}") + ) + } + + func testHardwareCommandMappingResolvesCommonControlCombosToText() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardC, + modifiers: .control, + charactersIgnoringModifiers: "c" + ), + .text("\u{03}") + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardD, + modifiers: .control, + charactersIgnoringModifiers: "d" + ), + .text("\u{04}") + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardL, + modifiers: .control, + charactersIgnoringModifiers: "l" + ), + .text("\u{0C}") + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardZ, + modifiers: .control, + charactersIgnoringModifiers: "z" + ), + .text("\u{1A}") + ) + } + + func testHardwareCommandMappingRejectsControlTextWhenCommandIsHeld() { + XCTAssertNil( + GhosttyTerminalHardwareCommandMapping.resolveHardwareKey( + keyCode: .keyboardC, + modifiers: [.command, .control], + charactersIgnoringModifiers: "c" + ) + ) + } + + func testHardwareCommandMappingResolvesPrintableHardwareText() { + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareText( + characters: "a", + modifiers: [] + ), + "a" + ) + XCTAssertEqual( + GhosttyTerminalHardwareCommandMapping.resolveHardwareText( + characters: "A", + modifiers: .shift + ), + "A" + ) + } + + func testHardwareCommandMappingDoesNotTurnShortcutsIntoPrintableText() { + XCTAssertNil( + GhosttyTerminalHardwareCommandMapping.resolveHardwareText( + characters: "c", + modifiers: .command + ) + ) + XCTAssertNil( + GhosttyTerminalHardwareCommandMapping.resolveHardwareText( + characters: "c", + modifiers: .control + ) + ) + } + + func testTerminalInputNormalizerMapsLinefeedToCarriageReturn() { + XCTAssertEqual( + GhosttyTerminalInputNormalizer.normalize("echo hello\n"), + "echo hello\r" + ) + } + + func testTerminalInputNormalizerPreservesExistingCarriageReturn() { + XCTAssertEqual( + GhosttyTerminalInputNormalizer.normalize("echo hello\r"), + "echo hello\r" + ) + } + + @MainActor + func testResponderRequestsFirstResponderWhenInputBecomesEnabledWithSameActivationToken() async { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.rootViewController = UIViewController() + window.rootViewController?.view.addSubview(view) + window.makeKeyAndVisible() + defer { + _ = view.resignFirstResponder() + view.removeFromSuperview() + window.isHidden = true + window.rootViewController = nil + } + + view.update( + isEnabled: false, + wantsFirstResponder: false, + activationToken: 7, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 7, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + let becameFirstResponder = await waitUntil { view.isFirstResponder } + XCTAssertTrue(becameFirstResponder) + } + + @MainActor + func testResponderDefersBecomeWhenWanted() async { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.rootViewController = UIViewController() + window.rootViewController?.view.addSubview(view) + window.makeKeyAndVisible() + defer { + _ = view.resignFirstResponder() + view.removeFromSuperview() + window.isHidden = true + window.rootViewController = nil + } + + view.update( + isEnabled: true, + wantsFirstResponder: false, + activationToken: 3, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + XCTAssertTrue(view.canBecomeFirstResponder) + XCTAssertFalse(view.isFirstResponder) + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 3, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + XCTAssertFalse(view.isFirstResponder) + let becameFirstResponder = await waitUntil { view.isFirstResponder } + XCTAssertTrue(becameFirstResponder) + } + + @MainActor + func testResponderReportsActualFirstResponderTransitions() async { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.rootViewController = UIViewController() + window.rootViewController?.view.addSubview(view) + window.makeKeyAndVisible() + defer { + _ = view.resignFirstResponder() + view.removeFromSuperview() + window.isHidden = true + window.rootViewController = nil + } + + var reportedStates: [Bool] = [] + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 9, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true }, + onFirstResponderChange: { reportedStates.append($0) } + ) + + let becameFirstResponder = await waitUntil { view.isFirstResponder } + XCTAssertTrue(becameFirstResponder) + XCTAssertEqual(reportedStates.last, true) + + XCTAssertTrue(view.resignFirstResponder()) + let resignedFirstResponder = await waitUntil { !view.isFirstResponder } + XCTAssertTrue(resignedFirstResponder) + XCTAssertEqual(reportedStates.suffix(2), [true, false]) + } + + @MainActor + func testResponderRecoversFirstResponderWhenStillEnabledWithSameActivationToken() async { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.rootViewController = UIViewController() + window.rootViewController?.view.addSubview(view) + window.makeKeyAndVisible() + defer { + _ = view.resignFirstResponder() + view.removeFromSuperview() + window.isHidden = true + window.rootViewController = nil + } + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 3, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + let initiallyBecameFirstResponder = await waitUntil { view.isFirstResponder } + XCTAssertTrue(initiallyBecameFirstResponder) + + XCTAssertTrue(view.resignFirstResponder()) + let didResignFirstResponder = await waitUntil { !view.isFirstResponder } + XCTAssertTrue(didResignFirstResponder) + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 3, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + let recoveredFirstResponder = await waitUntil { view.isFirstResponder } + XCTAssertTrue(recoveredFirstResponder) + } + + @MainActor + func testResponderDefersResignWhenNoLongerWanted() async { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.rootViewController = UIViewController() + window.rootViewController?.view.addSubview(view) + window.makeKeyAndVisible() + defer { + _ = view.resignFirstResponder() + view.removeFromSuperview() + window.isHidden = true + window.rootViewController = nil + } + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 3, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + let initiallyBecameFirstResponder = await waitUntil { view.isFirstResponder } + XCTAssertTrue(initiallyBecameFirstResponder) + + view.update( + isEnabled: true, + wantsFirstResponder: false, + activationToken: 3, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + XCTAssertTrue(view.isFirstResponder) + let didResignFirstResponder = await waitUntil { !view.isFirstResponder } + XCTAssertTrue(didResignFirstResponder) + } + + @MainActor + private func waitUntil( + timeout: TimeInterval = 1, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() + } + + @MainActor + func testResponderRejectsTextEditMenuActionsAfterUITextInputConformance() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + XCTAssertFalse( + view.canPerformAction(#selector(UIResponderStandardEditActions.selectAll(_:)), withSender: nil) + ) + XCTAssertFalse( + view.canPerformAction(#selector(UIResponderStandardEditActions.select(_:)), withSender: nil) + ) + XCTAssertFalse( + view.canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil) + ) + XCTAssertFalse( + view.canPerformAction(#selector(UIResponderStandardEditActions.cut(_:)), withSender: nil) + ) + } + + @MainActor + func testResponderProvidesCoherentVirtualTextDocument() throws { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + + let beginning = try XCTUnwrap( + view.beginningOfDocument as? GhosttyVirtualTextPosition + ) + let end = try XCTUnwrap( + view.endOfDocument as? GhosttyVirtualTextPosition + ) + let selection = try XCTUnwrap( + view.selectedTextRange as? GhosttyVirtualTextRange + ) + let document = try XCTUnwrap( + view.textRange(from: beginning, to: end) + ) + + XCTAssertEqual(beginning.offset, 0) + XCTAssertEqual(end.offset, 1) + XCTAssertEqual(selection.from.offset, 1) + XCTAssertEqual(selection.to.offset, 1) + XCTAssertEqual(view.text(in: document), " ") + XCTAssertEqual(view.text(in: selection), "") + XCTAssertNil(view.markedTextRange) + let position = view.position(from: beginning, offset: 0) + XCTAssertNotNil(position, "tokenizer requires non-nil position for offset 0") + } + + @MainActor + func testIMECompositionCommitsOnlyFinalText() throws { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedText: [String] = [] + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { receivedText.append($0); return true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true } + ) + + view.setMarkedText("ni", selectedRange: NSRange(location: 2, length: 0)) + XCTAssertTrue(receivedText.isEmpty) + XCTAssertNotNil(view.markedTextRange) + let document = try XCTUnwrap(view.textRange(from: view.beginningOfDocument, to: view.endOfDocument)) + XCTAssertEqual(view.text(in: document), "ni") + + view.insertText("你") + XCTAssertEqual(receivedText, ["你"]) + XCTAssertNil(view.markedTextRange) + + view.setMarkedText("好", selectedRange: NSRange(location: 1, length: 0)) + view.unmarkText() + view.unmarkText() + XCTAssertEqual(receivedText, ["你", "好"], "unmark must commit the composition exactly once") + } + + @MainActor + func testBackspaceClearsIMECompositionBeforeSendingTerminalBackspace() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedEvents: [GhosttySurfaceKeyEvent] = [] + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { receivedEvents.append($0); return true } + ) + + view.setMarkedText("zhong", selectedRange: NSRange(location: 5, length: 0)) + view.deleteBackward() + XCTAssertNil(view.markedTextRange) + XCTAssertTrue(receivedEvents.isEmpty) + + view.deleteBackward() + XCTAssertEqual(receivedEvents, [.init(keyCode: .backspace)]) + } + + @MainActor + func testFloatingCursorCrossingFirstTierEmitsArrowAndPublishesTier() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedEvents: [GhosttySurfaceKeyEvent] = [] + var feedback: [GhosttyKeyboardCursorTrackpad.FeedbackState] = [] + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { + receivedEvents.append($0) + return true + }, + onTrackpadFeedbackChange: { feedback.append($0) } + ) + + view.beginFloatingCursor(at: .zero) + view.updateFloatingCursor(at: .init(x: 36, y: 0)) + view.endFloatingCursor() + + XCTAssertEqual(receivedEvents.map(\.keyCode), [.arrowRight]) + XCTAssertEqual(feedback, [ + .active, + .init( + isVisible: true, + direction: .right, + committedTier: .one, + armingTier: nil, + armingProgress: 0 + ), + .hidden, + ]) + } + + @MainActor + func testFloatingCursorPartialArmingSendsNothing() { + let view = GhosttyTerminalResponderUIView(trackpadDriver: GhosttyKeyboardCursorTrackpadDriver()) + var receivedEvents: [GhosttySurfaceKeyEvent] = [] + var feedback: [GhosttyKeyboardCursorTrackpad.FeedbackState] = [] + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { + receivedEvents.append($0) + return true + }, + onTrackpadFeedbackChange: { feedback.append($0) } + ) + + view.beginFloatingCursor(at: .zero) + view.updateFloatingCursor(at: .init(x: 22, y: 0)) + view.endFloatingCursor() + + XCTAssertTrue(receivedEvents.isEmpty) + XCTAssertEqual(feedback, [ + .active, + .init( + isVisible: true, + direction: .right, + committedTier: .neutral, + armingTier: .one, + armingProgress: 0.5 + ), + .hidden, + ]) + } + + @MainActor + func testDisablingResponderCancelsActiveTrackpadGesture() { + let driver = GhosttyKeyboardCursorTrackpadDriver() + let view = GhosttyTerminalResponderUIView(trackpadDriver: driver) + var receivedEvents: [GhosttySurfaceKeyEvent] = [] + var feedback: [GhosttyKeyboardCursorTrackpad.FeedbackState] = [] + + view.update( + isEnabled: true, + wantsFirstResponder: true, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { + receivedEvents.append($0) + return true + }, + onTrackpadFeedbackChange: { feedback.append($0) } + ) + + view.beginFloatingCursor(at: .zero) + view.updateFloatingCursor(at: .init(x: 36, y: 0)) + let eventCountBeforeDisable = receivedEvents.count + + view.update( + isEnabled: false, + wantsFirstResponder: false, + activationToken: 1, + sendText: { _ in true }, + sendPaste: { _ in true }, + sendKeyEvent: { _ in true }, + onTrackpadFeedbackChange: { feedback.append($0) } + ) + + XCTAssertEqual(feedback.last, .hidden) + driver.repeatTick(at: .greatestFiniteMagnitude) + XCTAssertEqual(receivedEvents.count, eventCountBeforeDisable) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalSurfaceInteractionOutcomeTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalSurfaceInteractionOutcomeTests.swift new file mode 100644 index 00000000..e6c0d3a1 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalSurfaceInteractionOutcomeTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyTerminalSurfaceInteractionOutcomeTests: XCTestCase { + func testFocusedTerminalInputSubmissionAcceptedStates() { + XCTAssertTrue(FocusedTerminalInputSubmissionResult.accepted.isAccepted) + XCTAssertTrue(FocusedTerminalInputSubmissionResult.empty.isAccepted) + XCTAssertFalse(FocusedTerminalInputSubmissionResult.noFocusedSurface.isAccepted) + XCTAssertFalse(FocusedTerminalInputSubmissionResult.transportUnavailable.isAccepted) + XCTAssertFalse(FocusedTerminalInputSubmissionResult.surfaceRejected.isAccepted) + } + + func testFocusedTerminalInputSubmissionDescriptions() { + let cases: [(FocusedTerminalInputSubmissionResult, String)] = [ + (.accepted, "accepted"), + (.empty, "empty"), + (.noFocusedSurface, "noFocusedSurface"), + (.transportUnavailable, "transportUnavailable"), + (.surfaceRejected, "surfaceRejected"), + ] + + for (result, description) in cases { + XCTAssertEqual(result.description, description) + } + } + + func testMouseInputSubmissionSentState() { + let missingTarget = UUID() + + XCTAssertTrue(GhosttyMouseInputSubmissionOutcome.sent.isSent) + XCTAssertFalse(GhosttyMouseInputSubmissionOutcome.noFocusedSurface.isSent) + XCTAssertFalse(GhosttyMouseInputSubmissionOutcome.missingTarget(missingTarget).isSent) + XCTAssertFalse(GhosttyMouseInputSubmissionOutcome.transportUnavailable.isSent) + XCTAssertFalse(GhosttyMouseInputSubmissionOutcome.surfaceRejected.isSent) + } + +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalViewportCoordinatorTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalViewportCoordinatorTests.swift new file mode 100644 index 00000000..e27a0853 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalViewportCoordinatorTests.swift @@ -0,0 +1,409 @@ +import CoreGraphics +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyTerminalViewportCoordinatorTests: XCTestCase { + func testLiveSizeObservationReportsAppliedStableSizeTransaction() { + var coordinator = GhosttyTerminalViewportCoordinator() + let full = CGSize(width: 402, height: 726) + + let observation = coordinator.observeLiveSize(full) + + XCTAssertEqual(observation.previousLiveSize, CGSize(width: 1, height: 1)) + XCTAssertEqual(observation.liveSize, full) + XCTAssertEqual(observation.previousEffectiveSize, CGSize(width: 1, height: 1)) + XCTAssertEqual(observation.effectiveSize, full) + XCTAssertFalse(observation.wasFrozen) + XCTAssertTrue(observation.didChangeLiveSize) + XCTAssertTrue(observation.didApplyStableSize) + XCTAssertEqual(observation.outcome, .appliedStableSize) + } + + func testLiveSizeObservationReportsHeldStableSizeTransaction() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + coordinator.setSheetPresented(true, liveSize: keyboard) + + let observation = coordinator.observeLiveSize(full) + + XCTAssertEqual(observation.previousLiveSize, keyboard) + XCTAssertEqual(observation.liveSize, full) + XCTAssertEqual(observation.previousEffectiveSize, keyboard) + XCTAssertEqual(observation.effectiveSize, keyboard) + XCTAssertTrue(observation.wasFrozen) + XCTAssertTrue(observation.didChangeLiveSize) + XCTAssertFalse(observation.didApplyStableSize) + XCTAssertEqual(observation.outcome, .observedWithoutStableUpdate) + } + + func testLiveSizeObservationReportsUnchangedTransaction() { + var coordinator = GhosttyTerminalViewportCoordinator() + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(full).outcome, .appliedStableSize) + + let observation = coordinator.observeLiveSize(full) + + XCTAssertEqual(observation.previousLiveSize, full) + XCTAssertEqual(observation.liveSize, full) + XCTAssertEqual(observation.previousEffectiveSize, full) + XCTAssertEqual(observation.effectiveSize, full) + XCTAssertFalse(observation.wasFrozen) + XCTAssertFalse(observation.didChangeLiveSize) + XCTAssertFalse(observation.didApplyStableSize) + XCTAssertEqual(observation.outcome, .unchanged) + } + + func testLiveSizeReconciliationAdoptsStaleStoredViewport() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + coordinator.requestTopologyRefocus(liveSize: keyboard) + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + coordinator.completeTopologyRefocus( + liveSize: full, + releasePolicy: .preserveCurrentEffective + ) + XCTAssertEqual(coordinator.latestLiveSize, full) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + + let observation = coordinator.reconcileLiveSize(full) + + XCTAssertFalse(observation.didChangeLiveSize) + XCTAssertTrue(observation.didApplyStableSize) + XCTAssertEqual(observation.effectiveSize, full) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + } + + func testLiveSizeReconciliationRespectsActiveGeometryHold() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + coordinator.setSheetPresented(true, liveSize: keyboard) + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + + let observation = coordinator.reconcileLiveSize(full) + + XCTAssertFalse(observation.didChangeLiveSize) + XCTAssertFalse(observation.didApplyStableSize) + XCTAssertEqual(observation.outcome, .unchanged) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + } + + func testTopologyRefocusPreservesPreviousStableViewportThroughKeyboardChurn() { + var coordinator = GhosttyTerminalViewportCoordinator() + let full = CGSize(width: 402, height: 726) + let keyboard = CGSize(width: 402, height: 452) + let partial = CGSize(width: 402, height: 527) + + XCTAssertEqual(coordinator.observeLiveSize(full).outcome, .appliedStableSize) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + XCTAssertEqual(coordinator.effectiveSize(liveSize: keyboard), keyboard) + + coordinator.setSheetPresented(true, liveSize: keyboard) + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + + coordinator.requestTopologyRefocus(liveSize: full) + coordinator.beginKeyboardTransition( + target: .shown, + allowsTargetOverride: true, + liveSize: full + ) + coordinator.setSheetPresented(false, liveSize: full) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + + XCTAssertFalse(coordinator.observeLiveSize(keyboard).didApplyStableSize) + coordinator.completeKeyboardTransition(liveSize: keyboard) + XCTAssertEqual(coordinator.effectiveSize(liveSize: keyboard), keyboard) + + coordinator.beginKeyboardTransition( + target: .shown, + allowsTargetOverride: true, + liveSize: keyboard + ) + XCTAssertFalse(coordinator.observeLiveSize(partial).didApplyStableSize) + coordinator.completeKeyboardTransition(liveSize: partial) + XCTAssertEqual(coordinator.effectiveSize(liveSize: partial), keyboard) + + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + XCTAssertEqual( + coordinator.completeTopologyRefocus( + liveSize: full, + releasePolicy: .preserveCurrentEffective + ), + .release(previousEffectiveSize: keyboard) + ) + + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + } + + func testGenericKeyboardTransitionTracksUsableLiveViewportWithoutTopologyHold() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + coordinator.beginKeyboardTransition( + target: .hidden, + allowsTargetOverride: true, + liveSize: keyboard + ) + XCTAssertTrue(coordinator.observeLiveSize(full).didApplyStableSize) + XCTAssertTrue(coordinator.isKeyboardTransitionActive) + XCTAssertFalse(coordinator.isFrozen) + coordinator.completeKeyboardTransition(liveSize: full) + + XCTAssertFalse(coordinator.isFrozen) + XCTAssertFalse(coordinator.isKeyboardTransitionActive) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + } + + func testSystemKeyboardShowTracksUsableLiveViewportBeforePresentationCompletes() { + var coordinator = GhosttyTerminalViewportCoordinator() + let full = CGSize(width: 402, height: 726) + let keyboard = CGSize(width: 402, height: 452) + + XCTAssertEqual(coordinator.observeLiveSize(full).outcome, .appliedStableSize) + coordinator.beginKeyboardTransition( + target: .shown, + allowsTargetOverride: true, + liveSize: full + ) + + XCTAssertTrue(coordinator.observeLiveSize(keyboard).didApplyStableSize) + XCTAssertTrue(coordinator.isKeyboardTransitionActive) + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: keyboard), keyboard) + XCTAssertEqual(coordinator.lastStableSize, keyboard) + + coordinator.completeKeyboardTransition(liveSize: keyboard) + + XCTAssertFalse(coordinator.isFrozen) + XCTAssertFalse(coordinator.isKeyboardTransitionActive) + XCTAssertEqual(coordinator.effectiveSize(liveSize: keyboard), keyboard) + } + + func testSheetDismissalReleasesGeometryWhileKeyboardTransitionRemainsActive() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + XCTAssertEqual( + coordinator.setSheetPresented(true, liveSize: keyboard), + .hold(effectiveSize: keyboard) + ) + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + coordinator.beginKeyboardTransition( + target: .hidden, + allowsTargetOverride: true, + liveSize: full + ) + + XCTAssertEqual( + coordinator.setSheetPresented(false, liveSize: full), + .release(previousEffectiveSize: keyboard) + ) + XCTAssertTrue(coordinator.isKeyboardTransitionActive) + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + + coordinator.completeKeyboardTransition(liveSize: full) + XCTAssertFalse(coordinator.isFrozen) + XCTAssertFalse(coordinator.isKeyboardTransitionActive) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + } + + func testUsableLiveSizeReleasesUnsizedInitialLayoutFreeze() { + var coordinator = GhosttyTerminalViewportCoordinator() + let full = CGSize(width: 402, height: 726) + let keyboard = CGSize(width: 402, height: 452) + + XCTAssertEqual(coordinator.observeLiveSize(full).outcome, .appliedStableSize) + XCTAssertEqual( + coordinator.observeLiveSize(CGSize(width: 0, height: 0)).outcome, + .observedWithoutStableUpdate + ) + XCTAssertTrue(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: CGSize(width: 0, height: 0)), full) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + XCTAssertFalse(coordinator.isFrozen) + XCTAssertNil(coordinator.frozenSize) + XCTAssertEqual(coordinator.lastStableSize, keyboard) + XCTAssertEqual(coordinator.effectiveSize(liveSize: keyboard), keyboard) + } + + func testSheetPresentationReportsCurrentEffectiveHoldForReplacement() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + XCTAssertEqual( + coordinator.setSheetPresented(true, liveSize: keyboard), + .hold(effectiveSize: keyboard) + ) + + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + XCTAssertEqual( + coordinator.setSheetPresented(true, liveSize: full), + .hold(effectiveSize: keyboard) + ) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + } + + func testTopologyRefocusRequestReportsEffectiveHold() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + + XCTAssertEqual( + coordinator.requestTopologyRefocus(liveSize: full), + .hold(effectiveSize: keyboard) + ) + XCTAssertTrue(coordinator.isTopologyRefocusActive) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + } + + func testTopologyRefocusCancelReportsReleaseAndAdoptsLatestLiveViewport() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + XCTAssertEqual( + coordinator.requestTopologyRefocus(liveSize: keyboard), + .hold(effectiveSize: keyboard) + ) + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + + XCTAssertEqual( + coordinator.cancelTopologyRefocus(liveSize: full), + .release(previousEffectiveSize: keyboard) + ) + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + } + + func testInactiveTopologyRefocusCancelReportsNoEffect() { + var coordinator = GhosttyTerminalViewportCoordinator() + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(full).outcome, .appliedStableSize) + + XCTAssertEqual(coordinator.cancelTopologyRefocus(liveSize: full), .inactive) + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + } + + func testCoveredPresentationKeepsKeyboardViewportWhileLiveLayoutExpands() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertTrue(coordinator.observeLiveSize(keyboard).didApplyStableSize) + XCTAssertEqual( + coordinator.setCoveredPresentation(true, liveSize: keyboard), + .hold(effectiveSize: keyboard) + ) + + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + + XCTAssertEqual( + coordinator.setCoveredPresentation(false, liveSize: keyboard), + .release(previousEffectiveSize: keyboard) + ) + XCTAssertEqual(coordinator.effectiveSize(liveSize: keyboard), keyboard) + } + + func testCoveredPresentationWithoutKeyboardReleasesAtUnchangedFullViewport() { + var coordinator = GhosttyTerminalViewportCoordinator() + let full = CGSize(width: 402, height: 726) + + XCTAssertTrue(coordinator.observeLiveSize(full).didApplyStableSize) + coordinator.setCoveredPresentation(true, liveSize: full) + + XCTAssertEqual( + coordinator.setCoveredPresentation(false, liveSize: full), + .release(previousEffectiveSize: full) + ) + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + } + + func testCoveredPresentationReleasesAtFinalRotatedViewport() { + var coordinator = GhosttyTerminalViewportCoordinator() + let portrait = CGSize(width: 402, height: 452) + let coveredLandscape = CGSize(width: 852, height: 360) + let finalLandscape = CGSize(width: 852, height: 248) + + XCTAssertTrue(coordinator.observeLiveSize(portrait).didApplyStableSize) + coordinator.setCoveredPresentation(true, liveSize: portrait) + XCTAssertFalse(coordinator.observeLiveSize(coveredLandscape).didApplyStableSize) + XCTAssertFalse(coordinator.reconcileLiveSize(finalLandscape).didApplyStableSize) + + coordinator.setCoveredPresentation(false, liveSize: finalLandscape) + + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.latestLiveSize, finalLandscape) + XCTAssertEqual(coordinator.effectiveSize(liveSize: finalLandscape), finalLandscape) + } + + func testCoveredPresentationComposesWithSheetAndTopologyHolds() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertTrue(coordinator.observeLiveSize(keyboard).didApplyStableSize) + coordinator.setSheetPresented(true, liveSize: keyboard) + coordinator.setCoveredPresentation(true, liveSize: keyboard) + coordinator.requestTopologyRefocus(liveSize: keyboard) + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + + coordinator.setCoveredPresentation(false, liveSize: full) + XCTAssertTrue(coordinator.isFrozen) + coordinator.setSheetPresented(false, liveSize: full) + XCTAssertTrue(coordinator.isFrozen) + coordinator.completeTopologyRefocus(liveSize: full, releasePolicy: .adoptLatestLive) + + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), full) + } + + func testTopologyRefocusCompleteReportsReleaseAndPreservesCurrentEffectiveViewport() { + var coordinator = GhosttyTerminalViewportCoordinator() + let keyboard = CGSize(width: 402, height: 452) + let full = CGSize(width: 402, height: 726) + + XCTAssertEqual(coordinator.observeLiveSize(keyboard).outcome, .appliedStableSize) + XCTAssertEqual( + coordinator.requestTopologyRefocus(liveSize: keyboard), + .hold(effectiveSize: keyboard) + ) + XCTAssertFalse(coordinator.observeLiveSize(full).didApplyStableSize) + + XCTAssertEqual( + coordinator.completeTopologyRefocus( + liveSize: full, + releasePolicy: .preserveCurrentEffective + ), + .release(previousEffectiveSize: keyboard) + ) + XCTAssertFalse(coordinator.isFrozen) + XCTAssertEqual(coordinator.effectiveSize(liveSize: full), keyboard) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTmuxPrefixInputBufferTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTmuxPrefixInputBufferTests.swift new file mode 100644 index 00000000..e84883ce --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTmuxPrefixInputBufferTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyTmuxPrefixInputBufferTests: XCTestCase { + func testNormalTextSubmitsImmediately() { + var buffer = GhosttyTmuxPrefixInputBuffer() + + XCTAssertEqual(buffer.handleText("ls\r"), .submit("ls\r")) + } + + func testPrefixArmsBufferWithFlushToken() { + var buffer = GhosttyTmuxPrefixInputBuffer() + + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 1) + ) + } + + func testPendingPrefixWithNormalTextSubmitsCombinedInputAndInvalidatesToken() { + var buffer = GhosttyTmuxPrefixInputBuffer() + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 1) + ) + + XCTAssertEqual(buffer.handleText("c"), .submit("\u{2}c")) + XCTAssertNil(buffer.flushPendingInput(matching: 1)) + } + + func testPendingPrefixWithBracketRequestsCopyModeFallbackAndInvalidatesToken() { + var buffer = GhosttyTmuxPrefixInputBuffer() + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 1) + ) + + XCTAssertEqual(buffer.handleText("["), .enterCopyMode(fallbackInput: "\u{2}[")) + XCTAssertNil(buffer.flushPendingInput(matching: 1)) + } + + func testCurrentTokenFlushReturnsPendingPrefixAndClearsBuffer() { + var buffer = GhosttyTmuxPrefixInputBuffer() + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 1) + ) + + XCTAssertEqual(buffer.flushPendingInput(matching: 1), GhosttyTmuxPrefixInputBuffer.defaultPrefixInput) + XCTAssertNil(buffer.flushPendingInput()) + } + + func testStaleTokenFlushReturnsNilWithoutClearingCurrentPendingPrefix() { + var buffer = GhosttyTmuxPrefixInputBuffer() + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 1) + ) + XCTAssertEqual(buffer.flushPendingInput(matching: 1), GhosttyTmuxPrefixInputBuffer.defaultPrefixInput) + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 3) + ) + + XCTAssertNil(buffer.flushPendingInput(matching: 1)) + XCTAssertEqual(buffer.flushPendingInput(matching: 3), GhosttyTmuxPrefixInputBuffer.defaultPrefixInput) + } + + func testUnconditionalFlushReturnsPendingPrefixAndInvalidatesToken() { + var buffer = GhosttyTmuxPrefixInputBuffer() + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 1) + ) + + XCTAssertEqual(buffer.flushPendingInput(), GhosttyTmuxPrefixInputBuffer.defaultPrefixInput) + XCTAssertNil(buffer.flushPendingInput(matching: 1)) + } + + func testSecondPrefixAfterConsumedStateArmsFreshToken() { + var buffer = GhosttyTmuxPrefixInputBuffer() + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 1) + ) + XCTAssertEqual(buffer.handleText("c"), .submit("\u{2}c")) + + XCTAssertEqual( + buffer.handleText(GhosttyTmuxPrefixInputBuffer.defaultPrefixInput), + .armPrefix(token: 3) + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift new file mode 100644 index 00000000..6a5dcd6d --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift @@ -0,0 +1,73 @@ +import Foundation +import XCTest +@testable import MoriRemoteTerminal + +@MainActor +final class MoriRemoteTerminalFacadeTests: XCTestCase { + func testStartFailurePublishesDisconnectedStateAndError() async throws { + let session = try MoriRemoteTerminalSession(transport: failingTransport()) + + do { + try await session.start() + XCTFail("expected transport start failure") + } catch { + XCTAssertEqual(session.connectionState, .disconnected) + XCTAssertEqual(session.lastError, "synthetic transport failure") + } + await session.stop() + } + + func testLateSyncCannotRegressRenderedTopologyToConnecting() { + XCTAssertEqual( + MoriRemoteTerminalConnectionProjection.applying(.connecting, hasTopology: true), + .ready + ) + XCTAssertEqual( + MoriRemoteTerminalConnectionProjection.applying(.connecting, hasTopology: false), + .connecting + ) + } + + func testStoppedSessionReturnsFixedFailedMetadataResult() async throws { + let session = try MoriRemoteTerminalSession(transport: inertTransport()) + await session.stop() + + let result = await session.queryAgentMetadata() + XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.body, "") + } + + func testFixedMetadataCommandUsesMoriHookOptionNames() { + XCTAssertEqual( + TmuxSessionController.agentMetadataQuery, + "list-panes -a -F '#{pane_id}\\t#{@mori-agent-state}\\t#{@mori-agent-name}'" + ) + } + + private func failingTransport() -> MoriRemoteTerminalTransport { + let stream = AsyncThrowingStream { $0.finish() } + return .init( + receivedBytes: stream, + start: { throw FacadeFailure.synthetic }, + send: { _ in }, + close: { _ in }, + isActive: { false } + ) + } + + private func inertTransport() -> MoriRemoteTerminalTransport { + let stream = AsyncThrowingStream { $0.finish() } + return .init( + receivedBytes: stream, + start: {}, + send: { _ in }, + close: { _ in }, + isActive: { false } + ) + } +} + +private enum FacadeFailure: LocalizedError { + case synthetic + var errorDescription: String? { "synthetic transport failure" } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/MoriTmuxIsolationTests.swift b/MoriRemote/MoriRemoteTerminalTests/MoriTmuxIsolationTests.swift new file mode 100644 index 00000000..1c6dcd4e --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/MoriTmuxIsolationTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class MoriTmuxIsolationTests: XCTestCase { + func testForbiddenServerCommandsAreNotInTerminalCoreSources() throws { + // The behavioural controller uses only shadow-local selection plus the + // input-only stale-mode cancellation. Keep this manifest explicit so a + // future UI convenience action cannot smuggle shared mutations back in. + let forbidden = ["refresh-client", "resize-pane -Z", "copy-mode -t", "requestZoomPane", "requestCopyMode", "setClientSize"] + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("MoriRemoteTerminal") + let source = try String(contentsOf: root.appendingPathComponent("Tmux/TmuxSessionController.swift")) + for command in forbidden { XCTAssertFalse(source.contains(command), command) } + } + + func testNarrowViewportDoesNotChangeTwoHundredColumnServerGrid() { + // The initial control grid is a server attachment contract. A phone + // viewport is renderer-local and cannot produce a tmux resize command. + let server = TmuxControlViewport(columns: 200, rows: 60, pixelWidth: 2000, pixelHeight: 900) + let narrowPhone = CGSize(width: 320, height: 480) + XCTAssertEqual(server.columns, 200) + XCTAssertEqual(GhosttyTerminalViewportCoordinator.normalized(narrowPhone), narrowPhone) + XCTAssertFalse(TmuxSessionController.cancelStaleSharedInputMode.contains("resize")) + } + + func testTopologyGridOwnsHydrationSizeNotPhoneViewport() throws { + let pane = TmuxSessionController.PaneInfo( + id: 10, windowID: 1, x: 0, y: 0, width: 200, height: 60, phase: .hydrating + ) + let topology = TmuxSessionController.TopologySnapshot( + sessionName: "main", + windows: [.init(id: 1, name: "main", active: true, zoomed: false, width: 200, height: 60, activePaneID: 10)], + panes: [pane], activeWindowID: 1 + ) + let size = try XCTUnwrap(TmuxSessionController.effectiveEngineSize(for: pane, in: topology)) + XCTAssertEqual(size.cols, 200) + XCTAssertEqual(size.rows, 60) + XCTAssertFalse(TmuxSessionController.cancelStaleSharedInputMode.contains("resize")) + } + + func testStaleSharedModeCancellationIsInputOnly() { + XCTAssertEqual( + TmuxSessionController.cancelStaleSharedInputMode, + "if-shell -F '#{pane_in_mode}' 'send-keys -X cancel' ''" + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/MoriTmuxNativeStartupIsolationTests.swift b/MoriRemote/MoriRemoteTerminalTests/MoriTmuxNativeStartupIsolationTests.swift new file mode 100644 index 00000000..6f2196ab --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/MoriTmuxNativeStartupIsolationTests.swift @@ -0,0 +1,64 @@ +import Foundation +import XCTest +@testable import MoriRemoteTerminal + +@MainActor +final class MoriTmuxNativeStartupIsolationTests: XCTestCase { + func testUnsizedNativeStartupBootstrapsWithoutRefreshClient() async throws { + // GhosttyKitRuntime must outlive the native client: it owns Ghostty's + // initialized backend and app for this production-equivalent harness. + let runtime = try GhosttyKitRuntime() + withExtendedLifetime(runtime) {} + let writes = LockedWrites() + let controller = TmuxSessionController(callbacks: .init()) + controller.setOutboundSink { writes.append($0) } + + try await withCheckedThrowingContinuation { continuation in + controller.start { continuation.resume(with: $0) } + } + controller.pump(Data("%begin 1 1 0\n%end 1 1 0\n%session-changed $42 main\n".utf8)) + try await Task.sleep(for: .milliseconds(50)) + + let outbound = writes.text + XCTAssertTrue(outbound.contains("version"), outbound) + XCTAssertTrue(outbound.contains("list-windows"), outbound) + XCTAssertFalse(outbound.contains("refresh-client"), outbound) + await withCheckedContinuation { continuation in controller.shutdown { continuation.resume() } } + } + + func testInputCancelsStaleModeBeforePaneInputOutbound() async throws { + let runtime = try GhosttyKitRuntime() + let writes = LockedWrites() + let controller = TmuxSessionController(callbacks: .init()) + controller.setOutboundSink { writes.append($0) } + try await withCheckedThrowingContinuation { continuation in + controller.start { continuation.resume(with: $0) } + } + controller.pump(Data("%begin 1 1 0\n%end 1 1 0\n%session-changed $42 main\n".utf8)) + controller.pump(Data("%begin 2 2 1\n3.1\n%end 2 2 1\n%begin 3 3 1\n$42 @0 1 %0 83 44 b7dd,83x44,0,0,0 b7dd,83x44,0,0,0 shell\n%end 3 3 1\n".utf8)) + controller.pump(Data("%begin 4 4 1\n%end 4 4 1\n%begin 5 5 1\n%end 5 5 1\n%begin 6 6 1\n%end 6 6 1\n%begin 7 7 1\n%end 7 7 1\n%begin 8 8 1\n%end 8 8 1\n".utf8)) + try await Task.sleep(for: .milliseconds(50)) + writes.reset() + XCTAssertTrue(controller.sendInput(paneID: 0, Data("ls\n".utf8))) + try await Task.sleep(for: .milliseconds(50)) + let outbound = writes.text + guard let cancel = outbound.range(of: "pane_in_mode"), + let input = outbound.range(of: "send-keys -H -t %0") else { + XCTFail("expected cancellation and pane input: \(outbound)") + await withCheckedContinuation { continuation in controller.shutdown { continuation.resume() } } + withExtendedLifetime(runtime) {} + return + } + XCTAssertLessThan(cancel.lowerBound, input.lowerBound, outbound) + await withCheckedContinuation { continuation in controller.shutdown { continuation.resume() } } + withExtendedLifetime(runtime) {} + } +} + +private final class LockedWrites: @unchecked Sendable { + private let lock = NSLock() + private var values: [Data] = [] + func append(_ value: Data) { lock.lock(); defer { lock.unlock() }; values.append(value) } + var text: String { lock.lock(); defer { lock.unlock() }; return String(decoding: values.joined(), as: UTF8.self) } + func reset() { lock.lock(); defer { lock.unlock() }; values.removeAll() } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift new file mode 100644 index 00000000..339f0a5a --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift @@ -0,0 +1,186 @@ +import Foundation +import XCTest + +@testable import MoriRemoteTerminal + +@MainActor +final class TmuxSessionLinkWriteFailureTests: XCTestCase { + func testSendFailureInvalidatesTransportBeforeDisconnectingController() async throws { + let runtime = try GhosttyKitRuntime() + let stateRecorder = SessionStateRecorder() + let transport = LinkTestTransport(failWrites: true) + let controller = TmuxSessionController( + callbacks: TmuxSessionController.Callbacks( + onState: { state in + stateRecorder.append(state) + } + ) + ) + let link = TmuxSessionLink(controller: controller, transport: transport) + + try await link.start() + + try await waitUntil("transport was not invalidated after send failure") { + await transport.closeDispositions().first == .invalidated + } + try await waitUntil("controller did not publish transportClosed after send failure") { + stateRecorder.contains(.detached(.transportClosed)) + } + + await link.stop() + let closeDispositions = await transport.closeDispositions() + XCTAssertEqual(closeDispositions, [.invalidated]) + await withCheckedContinuation { continuation in + controller.shutdown { continuation.resume() } + } + withExtendedLifetime(runtime) {} + } + + func testExplicitStopDoesNotPublishTransportClosed() async throws { + let runtime = try GhosttyKitRuntime() + let stateRecorder = SessionStateRecorder() + let transport = LinkTestTransport(failWrites: false) + let controller = TmuxSessionController( + callbacks: TmuxSessionController.Callbacks( + onState: { state in + stateRecorder.append(state) + } + ) + ) + let link = TmuxSessionLink(controller: controller, transport: transport) + + try await link.start() + try await waitUntil("startup commands were not sent") { + await transport.sendCount() > 0 + } + + await link.stop() + await withCheckedContinuation { continuation in + controller.shutdown { continuation.resume() } + } + + XCTAssertFalse(stateRecorder.contains(.detached(.transportClosed))) + let closeDispositions = await transport.closeDispositions() + XCTAssertEqual(closeDispositions, [.reusable]) + withExtendedLifetime(runtime) {} + } + + func testUnexpectedReadEndInvalidatesTransportBeforeDisconnectingController() async throws { + let runtime = try GhosttyKitRuntime() + let stateRecorder = SessionStateRecorder() + let transport = LinkTestTransport(failWrites: false) + let controller = TmuxSessionController( + callbacks: TmuxSessionController.Callbacks( + onState: { state in + stateRecorder.append(state) + } + ) + ) + let link = TmuxSessionLink(controller: controller, transport: transport) + + try await link.start() + await transport.finishInput() + + try await waitUntil("transport was not invalidated after read end") { + await transport.closeDispositions().first == .invalidated + } + try await waitUntil("controller did not publish transportClosed after read end") { + stateRecorder.contains(.detached(.transportClosed)) + } + + await link.stop() + let closeDispositions = await transport.closeDispositions() + XCTAssertEqual(closeDispositions, [.invalidated]) + await withCheckedContinuation { continuation in + controller.shutdown { continuation.resume() } + } + withExtendedLifetime(runtime) {} + } + + private func waitUntil( + _ failureMessage: String, + timeout: Duration = .seconds(2), + condition: () async -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if await condition() { + return + } + try await Task.sleep(for: .milliseconds(20)) + } + XCTFail(failureMessage) + } +} + +private final class SessionStateRecorder: @unchecked Sendable { + private let lock = NSLock() + private var states: [TmuxSessionController.SessionState] = [] + + func append(_ state: TmuxSessionController.SessionState) { + lock.lock() + defer { lock.unlock() } + states.append(state) + } + + func contains(_ state: TmuxSessionController.SessionState) -> Bool { + lock.lock() + defer { lock.unlock() } + return states.contains(state) + } +} + +private actor LinkTestTransport: TmuxControlTransport { + enum SendFailure: Error { + case failed + } + + nonisolated let receivedBytes: AsyncThrowingStream + + private let failWrites: Bool + private let continuation: AsyncThrowingStream.Continuation + private var recordedCloseDispositions: [TmuxControlTransportCloseDisposition] = [] + private var recordedSendCount = 0 + + init(failWrites: Bool) { + self.failWrites = failWrites + var capturedContinuation: AsyncThrowingStream.Continuation? + receivedBytes = AsyncThrowingStream { continuation in + capturedContinuation = continuation + } + continuation = capturedContinuation! + } + + func start(initialViewport: TmuxControlViewport?) async throws { + _ = initialViewport + continuation.yield( + Data("%begin 1 1 0\n%end 1 1 0\n%session-changed $42 main\n".utf8) + ) + } + + func send(_ data: Data) async throws { + _ = data + recordedSendCount += 1 + if failWrites { + throw SendFailure.failed + } + } + + func close(disposition: TmuxControlTransportCloseDisposition) async { + recordedCloseDispositions.append(disposition) + continuation.finish() + } + + func closeDispositions() -> [TmuxControlTransportCloseDisposition] { + recordedCloseDispositions + } + + func sendCount() -> Int { + recordedSendCount + } + + func finishInput() { + continuation.finish() + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift new file mode 100644 index 00000000..50222733 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift @@ -0,0 +1,87 @@ +import GhosttyKit +import XCTest + +@testable import MoriRemoteTerminal + +@MainActor +final class TmuxTerminalScreenAdapterTests: XCTestCase { + func testTopologyProjectionReflectsEmittedTopologyImmediately() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + let adapter = TmuxTerminalScreenAdapter() + adapter.activate( + session: session, + initialViewportHandler: { _, _ in } + ) + + session.handleTopology(.init( + sessionName: "fresh-test", + windows: [ + window(id: 1, active: true, paneID: 10), + window(id: 2, active: false, paneID: 20), + ], + panes: [pane(id: 10, windowID: 1), pane(id: 20, windowID: 2)], + activeWindowID: 1 + )) + + let first = adapter.terminalViewportPresentationProjection + XCTAssertEqual(first.windowCount, 2) + + session.handleTopology(.init( + sessionName: "fresh-test", + windows: [window(id: 1, active: true, paneID: 10)], + panes: [pane(id: 10, windowID: 1)], + activeWindowID: 1 + )) + + let second = adapter.terminalViewportPresentationProjection + XCTAssertEqual(second.windowCount, 1) + + await session.shutdown() + } + + private func makeSession(runtime: GhosttyKitRuntime) -> TmuxTerminalSession { + TmuxTerminalSession( + app: runtime.appHandleForTesting, + transport: DeterministicTmuxControlTransport(chunks: []), + baseSurfaceConfig: { runtime.makeTmuxBaseSurfaceConfig() }, + paneViewTheme: { .remuxDark }, + createPaneSurface: { _, _, _, _, _, _, _, completion in + completion(.failure(.surfaceCreationFailed( + GHOSTTY_TERMINAL_SURFACE_RESULT_INVALID_INPUT + ))) + } + ) + } + + private func window( + id: TmuxWindowID, + active: Bool, + paneID: TmuxPaneID? + ) -> TmuxSessionController.WindowInfo { + TmuxSessionController.WindowInfo( + id: id, + name: "", + active: active, + zoomed: true, + width: 80, + height: 24, + activePaneID: paneID + ) + } + + private func pane( + id: TmuxPaneID, + windowID: TmuxWindowID + ) -> TmuxSessionController.PaneInfo { + TmuxSessionController.PaneInfo( + id: id, + windowID: windowID, + x: 0, + y: 0, + width: 80, + height: 24, + phase: .live + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift new file mode 100644 index 00000000..2bbd90da --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift @@ -0,0 +1,196 @@ +import GhosttyKit +import XCTest + +@testable import MoriRemoteTerminal + +@MainActor +final class TmuxTerminalSessionShutdownDrainTests: XCTestCase { + func testRetainedTerminalHandoffPromotesHydratingPaneToLive() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(snapshot(phase: .hydrating)) + + XCTAssertTrue(session.livePaneIDs.isEmpty) + session.handlePaneTerminalForTesting(10) + XCTAssertEqual(session.livePaneIDs, [10]) + + session.handlePaneRemovedForTesting(10) + XCTAssertTrue(session.livePaneIDs.isEmpty) + await session.shutdown() + } + + func testRetainedTerminalHandoffForUnknownPaneIsIgnored() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(snapshot(phase: .hydrating)) + + session.handlePaneTerminalForTesting(99) + + XCTAssertTrue(session.livePaneIDs.isEmpty) + await session.shutdown() + } + + func testLiveTopologySeedsPickerEligibilityWithoutHandoff() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + + session.handleTopology(snapshot(phase: .live)) + + XCTAssertEqual(session.livePaneIDs, [10]) + await session.shutdown() + } + + func testTopologyRemovalReconcilesLivePaneSet() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(snapshot(phase: .live)) + + session.handleTopology(emptySnapshot()) + + XCTAssertTrue(session.livePaneIDs.isEmpty) + await session.shutdown() + } + + func testPreparedSelectionClearsWhenSessionDetaches() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(twoPaneSnapshot(activePaneID: 10)) + + session.prepareForPaneSelection(paneID: 11) + XCTAssertEqual(session.pendingPaneIDForTesting, 11) + + session.handleStateForTesting(.detached(.transportClosed)) + XCTAssertNil(session.pendingPaneIDForTesting) + await session.shutdown() + } + + func testActivePaneRollbackRemainsPendingAcrossIntermediateTopology() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(twoPaneSnapshot(activePaneID: 10)) + + session.prepareForPaneSelection(paneID: 11) + session.prepareForPaneSelection(paneID: 10) + + XCTAssertEqual( + session.pendingPaneIDForTesting, + 10, + "A → B → A must preserve A as the latest unconfirmed intent" + ) + + session.handleTopology(twoPaneSnapshot(activePaneID: 11)) + + XCTAssertEqual( + session.pendingPaneIDForTesting, + 10, + "B's intermediate topology must not replace the latest A intent" + ) + await session.shutdown() + } + + func testShutdownCompletesWithoutNativePaneHandoff() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(snapshot(phase: .hydrating)) + + await session.shutdown() + + XCTAssertTrue(session.livePaneIDs.isEmpty) + } + + private func makeSession(runtime: GhosttyKitRuntime) -> TmuxTerminalSession { + TmuxTerminalSession( + app: runtime.appHandleForTesting, + transport: DeterministicTmuxControlTransport(chunks: []), + baseSurfaceConfig: { runtime.makeTmuxBaseSurfaceConfig() }, + paneViewTheme: { .remuxDark }, + createPaneSurface: { _, _, _, _, _, _, _, _ in + XCTFail("topology alone must not create a pane renderer") + } + ) + } + + private func snapshot( + phase: TmuxSessionController.PaneInfo.Phase + ) -> TmuxSessionController.TopologySnapshot { + .init( + sessionName: "session", + windows: [window(activePaneID: 10)], + panes: [pane(id: 10, phase: phase)], + activeWindowID: 1 + ) + } + + private func twoPaneSnapshot( + activePaneID: TmuxPaneID, + zoomed: Bool = true + ) -> TmuxSessionController.TopologySnapshot { + .init( + sessionName: "session", + windows: [window(activePaneID: activePaneID, zoomed: zoomed)], + panes: [pane(id: 10, phase: .live), pane(id: 11, phase: .live)], + activeWindowID: 1 + ) + } + + private func crossWindowSnapshot( + activeWindowID: TmuxWindowID, + targetActivePaneID: TmuxPaneID + ) -> TmuxSessionController.TopologySnapshot { + .init( + sessionName: "session", + windows: [ + window(id: 1, active: activeWindowID == 1, activePaneID: 10), + window( + id: 2, + active: activeWindowID == 2, + activePaneID: targetActivePaneID, + zoomed: false + ), + ], + panes: [ + pane(id: 10, windowID: 1, phase: .live), + pane(id: 20, windowID: 2, phase: .live), + pane(id: 21, windowID: 2, phase: .live), + ], + activeWindowID: activeWindowID + ) + } + + private func emptySnapshot() -> TmuxSessionController.TopologySnapshot { + .init(sessionName: "session", windows: [], panes: [], activeWindowID: nil) + } + + private func window( + id: TmuxWindowID = 1, + active: Bool = true, + activePaneID: TmuxPaneID, + zoomed: Bool = true + ) -> TmuxSessionController.WindowInfo { + .init( + id: id, + name: "", + active: active, + zoomed: zoomed, + width: 80, + height: 24, + activePaneID: activePaneID + ) + } + + private func pane( + id: TmuxPaneID, + windowID: TmuxWindowID = 1, + phase: TmuxSessionController.PaneInfo.Phase + ) -> TmuxSessionController.PaneInfo { + .init( + id: id, + windowID: windowID, + x: 0, + y: 0, + width: 80, + height: 24, + phase: phase + ) + } +} diff --git a/MoriRemote/MoriRemoteTests/ImageInputTests.swift b/MoriRemote/MoriRemoteTests/ImageInputTests.swift new file mode 100644 index 00000000..bc73083c --- /dev/null +++ b/MoriRemote/MoriRemoteTests/ImageInputTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +@testable import MoriRemote + +@Suite("Image input") +struct ImageInputTests { + @Test("remote image paths are isolated, sanitized, and shell-visible") + func pathBuilder() throws { + let workspaceID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + let transferID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + let paths = try SSHImageUploadPathBuilder().paths( + workspaceID: workspaceID, + transferID: transferID, + filename: "../screen shot.png" + ) + + #expect(paths.directory == ".cache/mori/attachments/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222") + #expect(paths.temporary.hasSuffix("/.screen shot.png.part")) + #expect(paths.final.hasSuffix("/screen shot.png")) + #expect(paths.terminal.hasPrefix("~/.cache/mori/attachments/")) + #expect(!paths.final.contains("../")) + } + + @Test("upload creates every directory and atomically renames the completed image") + func transferOrdering() async throws { + let session = RecordingUploadSession() + let localURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data("image".utf8).write(to: localURL) + defer { try? FileManager.default.removeItem(at: localURL) } + let paths = SSHImageUploadPaths( + directory: ".cache/mori/attachments/workspace/transfer", + temporary: ".cache/mori/attachments/workspace/transfer/.image.png.part", + final: ".cache/mori/attachments/workspace/transfer/image.png", + terminal: "~/.cache/mori/attachments/workspace/transfer/image.png" + ) + let progress = ProgressRecorder() + + try await SSHImageUploadTransfer.run( + session: session, + localURL: localURL, + paths: paths, + totalBytes: 5, + progress: { uploaded, total in await progress.append(uploaded, total) } + ) + + #expect(await session.events == [ + "mkdir:.cache", + "mkdir:.cache/mori", + "mkdir:.cache/mori/attachments", + "mkdir:.cache/mori/attachments/workspace", + "mkdir:.cache/mori/attachments/workspace/transfer", + "remove:\(paths.temporary)", + "upload:\(paths.temporary)", + "rename:\(paths.temporary)->\(paths.final)", + ]) + #expect(await progress.values.map(\.0) == [5]) + #expect(await progress.values.map(\.1) == [5]) + } +} + +private actor ProgressRecorder { + private(set) var values: [(Int64, Int64)] = [] + func append(_ uploaded: Int64, _ total: Int64) { values.append((uploaded, total)) } +} + +private actor RecordingUploadSession: SSHFileUploadSession { + private(set) var events: [String] = [] + + func ensureDirectoryExists(atPath path: String) async throws { + events.append("mkdir:\(path)") + } + + func uploadFile( + from localURL: URL, + to remotePath: String, + progress: @escaping SSHFileUploadProgressHandler + ) async throws { + events.append("upload:\(remotePath)") + await progress(Int64((try Data(contentsOf: localURL)).count)) + } + + func renameFile(from temporaryPath: String, to finalPath: String) async throws { + events.append("rename:\(temporaryPath)->\(finalPath)") + } + + func removeFileIfExists(atPath path: String) async throws { + events.append("remove:\(path)") + } + + func close() async throws { + events.append("close") + } +} diff --git a/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift b/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift index 07479e16..dabf0e72 100644 --- a/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift @@ -98,6 +98,28 @@ import Testing #expect(root.commands()[4].contains("'kill-session'")) } + @Test("session discovery uses one read-only tmux child and returns its root lease") + func discoversSessions() async throws { + let runtimeID = UUID(uuidString: "00000000-0000-0000-0000-000000000123")! + let root = FakeRoot(plans: [ + .finished("tmux 3.2a\n"), + .finished("ops\nmain--mori-remote-\(runtimeID.uuidString.lowercased())\nmain\n"), + ]) + let pool = SSHRootPool(idleTimeout: .milliseconds(20)) + let discovery = SSHTmuxSessionDiscovery( + connector: FakeConnector(roots: [root]), + pool: pool, + poolKey: try key() + ) + + #expect(try await discovery.load() == ["main", "ops"]) + #expect(root.commands().count == 2) + #expect(root.commands()[0].contains("'-V'")) + #expect(root.commands()[1].contains("'list-sessions'")) + #expect(!root.commands().contains { $0.contains("'attach-session'") }) + try await eventually { root.closed } + } + @Test("control child bytes reach the public transport stream") func forwardsControlBytes() async throws { let expected = Data("%session-changed $0 workspace\n".utf8) @@ -239,6 +261,46 @@ import Testing } } +private extension SSHTmuxControlTransport { + init( + connector: any SSHRootConnecting, + pool: SSHRootPool, + poolKey: SSHRootPool.Key, + tmuxExecutable: String = "tmux", + sourceSession: String, + runtimeID: UUID = UUID() + ) { + self.init( + rootSource: AuthenticatedSSHRootSource( + pool: pool, + key: poolKey, + connector: connector + ), + tmuxExecutable: tmuxExecutable, + sourceSession: sourceSession, + runtimeID: runtimeID + ) + } +} + +private extension SSHTmuxSessionDiscovery { + init( + connector: any SSHRootConnecting, + pool: SSHRootPool, + poolKey: SSHRootPool.Key, + tmuxExecutable: String = "tmux" + ) { + self.init( + rootSource: AuthenticatedSSHRootSource( + pool: pool, + key: poolKey, + connector: connector + ), + tmuxExecutable: tmuxExecutable + ) + } +} + private final class TrustSequencingConnector: @unchecked Sendable { let server: SavedServer let trust: SSHHostTrustResolver diff --git a/MoriRemote/MoriRemoteTests/Phase3RuntimeTests.swift b/MoriRemote/MoriRemoteTests/Phase3RuntimeTests.swift deleted file mode 100644 index 9ec2e354..00000000 --- a/MoriRemote/MoriRemoteTests/Phase3RuntimeTests.swift +++ /dev/null @@ -1,158 +0,0 @@ -import Foundation -import Testing -@testable import MoriRemote - -@Suite("Phase 3 Ghostty control boundaries") struct Phase3RuntimeTests { - @Test("deterministic transport preserves delayed chunks and writes") - func deterministicTranscript() async throws { - let transport = DeterministicTmuxControlTransport(events: [.chunk("first"), .chunk("second", after: 1_000_000)]) - try await transport.start() - var received: [Data] = [] - for try await chunk in transport.receivedBytes { received.append(chunk) } - try await transport.send(Data("select-window -t @1\n".utf8)) - #expect(received == [Data("first".utf8), Data("second".utf8)]) - #expect(await transport.sentWrites() == [Data("select-window -t @1\n".utf8)]) - } - - @Test("deterministic transport exposes terminal errors") - func deterministicError() async throws { - enum Failure: Error { case expected } - let transport = DeterministicTmuxControlTransport(events: [.chunk("prefix"), .failure(Failure.expected)]) - try await transport.start() - var chunks = 0 - do { for try await _ in transport.receivedBytes { chunks += 1 }; Issue.record("expected failure") } catch { #expect(chunks == 1) } - } - - @Test("link preserves controller batch admission order") - func linkOrder() async throws { - let transport = DeterministicTmuxControlTransport(transcript: [], holdOpen: true) - let link = TmuxSessionLink(transport: transport, receive: { _ in }, disconnected: {}) - try await link.start(); link.enqueue(Data("first".utf8)); link.enqueue(Data("second".utf8)) - try await Task.sleep(for: .milliseconds(20)) - #expect(await transport.sentWrites() == [Data("first".utf8), Data("second".utf8)]) - await link.stop() - } - - @Test("deterministic transport captures write failure") - func deterministicWriteFailure() async throws { - enum Failure: Error { case write } - let transport = DeterministicTmuxControlTransport(transcript: [], writeError: Failure.write) - do { try await transport.send(Data("x".utf8)); Issue.record("expected write failure") } catch {} - #expect(await transport.sentWrites() == [Data("x".utf8)]) - } - - @Test("native controller parses the upstream startup transcript") @MainActor func nativeTranscript() async throws { - let runtime = try GhosttyKitRuntime(); let observed = NativeObserver() - let controller = TmuxSessionController(callbacks: .init(topology: { observed.topology($0) }, terminal: { observed.terminal($0) })) - controller.setOutboundSink { observed.write($0) } - try await withCheckedThrowingContinuation { continuation in controller.start(columns: 83, rows: 44) { continuation.resume(with: $0) } } - let window = "$42 @0 1 %0 83 44 b7dd,83x44,0,0,0 b7dd,83x44,0,0,0 probe\n" - let pane = "%0;83;44;0;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;;;0;0;43;8,16\n" - controller.pump(Data(("%begin 1 1 0\n%end 1 1 0\n%session-changed $42 main\n%begin 2 2 1\n3.1\n%end 2 2 1\n%begin 3 3 1\n%end 3 3 1\n%begin 4 4 1\n" + window + "%end 4 4 1\n%begin 5 5 1\n" + pane + "%end 5 5 1\n" + (6...9).map { "%begin \($0) \($0) 1\n%end \($0) \($0) 1\n" }.joined()).utf8)) - await drain(controller) - #expect(observed.snapshot?.activePaneID == TmuxPaneID(0)); #expect(observed.snapshot?.activeWindowID == TmuxWindowID(0)); #expect(observed.terminals.contains(TmuxPaneID(0))); #expect(!observed.writes.isEmpty) - await shutdown(controller) - runtime.shutdown() - } - - @Test("local history ceilings are explicit") func historyCeilings() { - #expect(TmuxSessionController.initialHistoryLineLimit == 2_000) - #expect(TmuxSessionController.maximumScrollbackBytes == 2_560_000) - } - - @Test("surface ledger rejects unknown and duplicate handles and fences removal") - func surfaceLedger() { - var ledger = TmuxSurfaceRegistrationLedger(); let pane = TmuxPaneID(7) - #expect(ledger.register(paneID: pane, identity: 1, clientAvailable: true, retained: []) == .unknownPane) - #expect(ledger.register(paneID: pane, identity: 1, clientAvailable: false, retained: [pane]) == .unavailable) - #expect(ledger.register(paneID: pane, identity: 1, clientAvailable: true, retained: [pane]) == .registered) - #expect(ledger.register(paneID: pane, identity: 2, clientAvailable: true, retained: [pane]) == .duplicate) - #expect(ledger.unregister(paneID: pane, identity: 2) == .ignored) - #expect(ledger.unregister(paneID: pane, identity: 1) == .removed) - #expect(ledger.isEmpty) - } - - @Test("topology projection retains active window and pane") func topologyProjection() { - let window = TmuxSessionController.Window(id: .init(2), name: "work", active: true, activePaneID: .init(9)) - let topology = TmuxSessionController.Topology(revision: 4, sessionName: "main", windows: [window], panes: [.init(id: .init(9), windowID: .init(2), width: 80, height: 24, phase: .live)], activeWindowID: window.id) - #expect(topology.revision == 4); #expect(topology.activePaneID == TmuxPaneID(9)); #expect(topology.panes[0].phase == .live) - } - - @Test("runtime gate rejects stale and stopped callbacks") func runtimeGate() { - let id = UUID(); var gate = GhosttyRuntimeCallbackGate(instanceID: id) - #expect(gate.accepts(id)); #expect(!gate.accepts(UUID())); gate.stop(); #expect(!gate.accepts(id)) - } - - @Test("surface close fence retains ownership through native free") func surfaceCloseFence() { - var fence = GhosttySurfaceCloseFence() - #expect(fence.state == .open) - let began = fence.beginClose() - #expect(began) - #expect(fence.state == .awaitingNativeFree) - let beganAgain = fence.beginClose() - #expect(!beganAgain) - fence.finishNativeFree() - #expect(fence.state == .released) - } - - @Test("client-local selection commands never admit forbidden server mutations") - func commandPolicy() { - let window = TmuxClientCommandPolicy.selectWindow(.init(3)); let pane = TmuxClientCommandPolicy.selectPane(.init(4)) - #expect(window == "select-window -t @3"); #expect(pane == "select-pane -t %4") - #expect(TmuxClientCommandPolicy.isAllowed(window)); #expect(TmuxClientCommandPolicy.isAllowed(pane)) - for forbidden in ["refresh-client -C 80x24", "resize-pane -Z -t %4", "copy-mode -t %4"] { #expect(!TmuxClientCommandPolicy.isAllowed(forbidden)) } - } - - @Test("command result preserves success skipped error body and cause") - func commandResults() { - #expect(TmuxSessionController.CommandResult(status: .success, body: "ok", causeToken: 0).status == .success) - let skipped = TmuxSessionController.CommandResult(status: .skipped, body: "", causeToken: 12) - #expect(skipped.status == .skipped && skipped.causeToken == 12) - #expect(TmuxSessionController.CommandResult(status: .error, body: "denied", causeToken: 1).body == "denied") - } - - @Test("marked CJK text commits once and replaces intermediate composition") - func markedText() { - var composition = GhosttyMarkedTextComposition(); composition.update("ni"); composition.update("你") - #expect(composition.isActive) - #expect(composition.commit("") == "你"); #expect(composition.commit("") == nil) - } - - @Test("text input shim exposes marked range and bounded virtual positions") - @MainActor func textInputShim() { - let responder = GhosttyTerminalResponderView() - responder.setMarkedText("ni", selectedRange: NSRange(location: 2, length: 0)) - #expect(responder.markedTextRange != nil) - let start = responder.beginningOfDocument - #expect((responder.position(from: start, offset: 9) as? GhosttyVirtualTextPosition)?.offset == 1) - responder.unmarkText() - #expect(responder.markedTextRange == nil) - } - - @Test("scroll projection preserves terminal follow-bottom and user offset") - func scrollProjection() { - let projection = GhosttyScrollProjection() - #expect(projection.synchronize(currentOffset: 12, contentHeight: 300, viewportHeight: 100, followsBottom: true) == 200) - #expect(projection.synchronize(currentOffset: 12, contentHeight: 300, viewportHeight: 100, followsBottom: false) == 12) - } - - @Test("scroll budget bounds a burst and refills deterministically") func scrollBudget() { - var budget = GhosttyScrollDeltaBudget(unitsPerSecond: 100, burstSeconds: 0.1) - #expect(budget.clamp(99, now: 0) == 10); #expect(budget.clamp(-1, now: 0) == 0); #expect(budget.clamp(-8, now: 0.08) == -8) - } - - @Test("hardware keys and Ctrl text map to terminal protocol") @MainActor func hardwareKeyMapping() { - #expect(GhosttySurfaceKeyEvent.backspace.keyCode == 0x33); #expect(GhosttySurfaceKeyEvent.enter.keyCode == 0x24) - #expect(GhosttySurfaceKeyEvent.home.keyCode == 0x73); #expect(GhosttySurfaceKeyEvent.pageDown.keyCode == 0x79) - #expect(GhosttyTerminalHardwareCommandMapping.command(characters: "c", keyCode: .keyboardC, modifiers: .control) == .text("\u{03}")) - #expect(GhosttyTerminalHardwareCommandMapping.command(characters: " ", keyCode: .keyboardSpacebar, modifiers: .control) == .text("\0")) - #expect(GhosttyTerminalHardwareCommandMapping.command(characters: "\u{03}", keyCode: .keyboardC, modifiers: .control) == .text("\u{03}")) - } - - - private func drain(_ controller: TmuxSessionController) async { await withCheckedContinuation { continuation in controller.queue.async { continuation.resume() } } } - private func shutdown(_ controller: TmuxSessionController) async { await withCheckedContinuation { continuation in controller.shutdown { continuation.resume() } } } -} - -private final class NativeObserver: @unchecked Sendable { private let lock = NSLock(); private(set) var snapshot: TmuxSessionController.Topology?; private(set) var terminals: [TmuxPaneID] = []; private(set) var writes: [Data] = []; func topology(_ value: TmuxSessionController.Topology) { lock.withLock { snapshot = value } }; func terminal(_ value: TmuxSessionController.RetainedTerminal) { lock.withLock { terminals.append(value.paneID) } }; func write(_ value: Data) { lock.withLock { writes.append(value) } } -} diff --git a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift index 74faf2e4..4aaa7ed3 100644 --- a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift @@ -1,23 +1,12 @@ import Foundation +import Observation import Security import Testing +import MoriRemoteTerminal @testable import MoriRemote @Suite("Phase 4 app shell contracts") struct Phase4ShellTests { - @Test("input-only copy-mode cancellation is ordered but never exposed as browsing") - func inputModePolicy() { - #expect(TmuxClientCommandPolicy.isAllowed(TmuxClientCommandPolicy.cancelStaleInputMode)) - #expect(!TmuxClientCommandPolicy.isAllowed("copy-mode -t %1")) - #expect(!TmuxClientCommandPolicy.isAllowed("resize-pane -Z -t %1")) - #expect(!TmuxClientCommandPolicy.isAllowed("refresh-client -C 80x24")) - } - @Test("shared mutations are explicit and bounded") - func sharedMutations() { - for mutation in [TmuxClientCommandPolicy.SharedMutation.splitHorizontal, .splitVertical, .newWindow, .closePane] { - #expect(TmuxClientCommandPolicy.isAllowed(TmuxClientCommandPolicy.shared(mutation))) - } - } @Test("reconnect policy retries only a transport loss once") func reconnectPolicy() { @@ -28,34 +17,98 @@ import Testing #expect(!policy.mayReconnect(status: .connecting, attempts: 0)) } - @Test("workspace draft owns a distinct record and rejects unsafe sessions") - func workspaceDraftValidation() throws { - let serverID = UUID() - var draft = WorkspaceDraft(serverID: serverID) - draft.name = "Logs" - draft.tmuxSession = "logs" - let workspace = try draft.record() - #expect(workspace.serverID == serverID) - #expect(workspace.id != serverID) - draft.tmuxSession = "bad\nname" - #expect(throws: SavedModelValidationError.invalidTmuxSession) { try draft.record() } + @Test("runtime state changes invalidate direct observers") + @MainActor + func runtimeObservation() async throws { + let workspace = try SavedWorkspace( + serverID: UUID(), + name: "main", + tmuxSession: "main" + ).validated() + let bytes = AsyncThrowingStream { $0.finish() } + let runtime = try ActiveWorkspaceRuntime( + workspace: workspace, + settings: .default, + transport: .init( + receivedBytes: bytes, + start: {}, + send: { _ in }, + close: { _ in }, + isActive: { false } + ) + ) + let observation = ObservationFlag() + withObservationTracking { + _ = runtime.status + } onChange: { + observation.markChanged() + } + + await runtime.stop() + + #expect(observation.didChange) } - @Test("profile draft keeps server identity and rejects unsafe sessions") + @Test("new profile draft saves only the server and identity") func profileDraftValidation() throws { - var draft = ServerWorkspaceDraft() + var draft = ServerProfileDraft() draft.serverName = "Build" draft.host = "build.example" draft.port = "22" draft.username = "mori" - draft.workspaceName = "Build" - draft.tmuxSession = "build" let records = try draft.records() - #expect(records.0.id == records.1?.serverID) - #expect(records.0.identityID == records.2.id) - #expect(records.2.serverID == records.0.id) - draft.tmuxSession = "bad\nname" - #expect(throws: SavedModelValidationError.invalidTmuxSession) { try draft.records() } + #expect(records.0.identityID == records.1.id) + #expect(records.1.serverID == records.0.id) + } + + @Test("tmux discovery lists source sessions and hides MoriRemote shadows") + func tmuxSessionDiscoveryProjection() throws { + let shadowID = UUID(uuidString: "00000000-0000-0000-0000-000000000123")! + let output = "zeta\nmain\nmain--mori-remote-\(shadowID.uuidString.lowercased())\nalpha\nmain\n" + #expect(TmuxSessionList.parse(output).names == ["alpha", "main", "zeta"]) + #expect(try TmuxCommandBuilder.listSessions(executable: "tmux").contains("'list-sessions' '-F' '#{session_name}'")) + } + + @Test("discovered sessions reuse saved identities and add only missing sessions") + func synchronizeDiscoveredSessions() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let storage = MoriRemoteStorage(root: root) + let library = RemoteLibrary(storage: storage, migrator: LegacyServerMigrator(storage: storage, legacyServersURL: root.appendingPathComponent("legacy.json"))) + let serverID = UUID(), workspaceID = UUID() + let server = SavedServer(id: serverID, name: "Build", host: "build.example", username: "mori", identityID: serverID) + let identity = SSHIdentity(id: serverID, serverID: serverID, kind: .password) + let main = SavedWorkspace(id: workspaceID, serverID: serverID, name: "Main", tmuxSession: "main") + _ = try await library.save(server: server, identity: identity, credential: nil) + _ = try storage.workspaces.insertIfAbsent(main) + + let first = try await library.synchronizeDiscoveredSessions(serverID: serverID, names: ["main", "ops"]) + #expect(first.workspaces.first(where: { $0.tmuxSession == "main" })?.id == workspaceID) + #expect(Set(first.workspaces.map(\.tmuxSession)) == ["main", "ops"]) + let second = try await library.synchronizeDiscoveredSessions(serverID: serverID, names: ["main", "ops"]) + #expect(second.workspaces.count == 2) + } + + @Test("navigator filters sessions, windows, and panes within their scopes") + func navigatorFiltering() { + let serverID = UUID() + let sessions = [ + SavedWorkspace(serverID: serverID, name: "Main", tmuxSession: "cs/main"), + SavedWorkspace(serverID: serverID, name: "Backup", tmuxSession: "cs/backup-v2"), + ] + #expect(RemoteNavigatorProjection.sessions(sessions, matching: "BACKUP").map(\.tmuxSession) == ["cs/backup-v2"]) + + let windows = [ + MoriRemoteTerminalWindow(id: 1, title: "editor", active: true, activePaneID: 10), + MoriRemoteTerminalWindow(id: 2, title: "deploy", active: false, activePaneID: 20), + ] + let panes = [ + MoriRemoteTerminalPane(id: 10, windowID: 1, columns: 120, rows: 40), + MoriRemoteTerminalPane(id: 20, windowID: 2, columns: 80, rows: 24), + ] + #expect(RemoteNavigatorProjection.windows(windows, matching: "DEPLOY").map(\.id) == [2]) + #expect(RemoteNavigatorProjection.panes(panes, windows: windows, matching: "editor").map(\.id) == [10]) + #expect(RemoteNavigatorProjection.panes(panes, windows: windows, matching: "20").map(\.id) == [20]) } @Test("connection attempt admission is synchronous and stale tokens cannot finish") @@ -74,19 +127,19 @@ import Testing #expect(!attempts.isCurrent(replacement, for: workspace)) } - @Test("profile edits preserve the selected workspace identity and recency") - func profileDraftPreservesWorkspace() throws { - let serverID = UUID(), workspaceID = UUID(), identityID = UUID() + @Test("profile drafts preserve server identity and recency") + func profileDraftPreservesServer() throws { + let serverID = UUID(), identityID = UUID() let date = Date(timeIntervalSince1970: 123) let server = SavedServer(id: serverID, name: "Build", host: "build.example", username: "mori", identityID: identityID, lastConnectedAt: date) - let workspace = SavedWorkspace(id: workspaceID, serverID: serverID, name: "Build", tmuxSession: "build", lastConnectedAt: date) - let draft = ServerWorkspaceDraft(server: server, workspace: workspace, identity: SSHIdentity(id: identityID, serverID: serverID, kind: .password)) + let draft = ServerProfileDraft( + server: server, + identity: SSHIdentity(id: identityID, serverID: serverID, kind: .password) + ) let records = try draft.records(existingIdentityID: identityID) - #expect(records.1?.id == workspaceID) + #expect(records.0.id == serverID) #expect(records.0.lastConnectedAt == date) - #expect(records.1?.lastConnectedAt == date) - let serverOnly = ServerWorkspaceDraft(server: server, identity: SSHIdentity(id: identityID, serverID: serverID, kind: .password)) - #expect(try serverOnly.records(existingIdentityID: identityID).1 == nil) + #expect(records.1.id == identityID) } @Test("profile persistence preserves recency and never inserts a server-edit workspace") @@ -100,17 +153,13 @@ import Testing let originalServer = SavedServer(id: serverID, name: "Build", host: "build.example", username: "mori", identityID: identityID, lastConnectedAt: date) let originalWorkspace = SavedWorkspace(id: workspaceID, serverID: serverID, name: "Build", tmuxSession: "build", lastConnectedAt: date) let identity = SSHIdentity(id: identityID, serverID: serverID, kind: .password) - _ = try await library.save(server: originalServer, workspace: originalWorkspace, identity: identity, credential: nil) + _ = try await library.save(server: originalServer, identity: identity, credential: nil) + _ = try storage.workspaces.insertIfAbsent(originalWorkspace) let editedServer = SavedServer(id: serverID, name: "Renamed", host: "build.example", username: "mori", identityID: identityID) - let editedWorkspace = SavedWorkspace(id: workspaceID, serverID: serverID, name: "Renamed", tmuxSession: "build") - let snapshot = try await library.save(server: editedServer, workspace: editedWorkspace, identity: identity, credential: nil) + let snapshot = try await library.save(server: editedServer, identity: identity, credential: nil) #expect(snapshot.servers.first?.lastConnectedAt == date) - #expect(snapshot.workspaces == [SavedWorkspace(id: workspaceID, serverID: serverID, name: "Renamed", tmuxSession: "build", lastConnectedAt: date)]) - _ = try await library.save(server: editedServer, workspace: nil, identity: identity, credential: nil) - let workspaceOnlyEdit = SavedWorkspace(id: workspaceID, serverID: serverID, name: "Workspace only", tmuxSession: "build") - let workspaceSnapshot = try await library.save(workspace: workspaceOnlyEdit) - #expect(workspaceSnapshot.workspaces == [SavedWorkspace(id: workspaceID, serverID: serverID, name: "Workspace only", tmuxSession: "build", lastConnectedAt: date)]) - #expect((try await library.reload()).workspaces.count == 1) + #expect(snapshot.workspaces == [originalWorkspace]) + #expect((try await library.reload()).workspaces == [originalWorkspace]) } @Test("stale SSH trust challenges become localized errors rather than disappearing") @@ -167,7 +216,8 @@ import Testing let workspace = SavedWorkspace(id: UUID(), serverID: serverID, name: "Build", tmuxSession: "build") let privateKey = SSHPrivateKeyInspector.generateEd25519(comment: "audit").privateKeyPEM let passphrase = "phase6-passphrase" - _ = try await library.save(server: server, workspace: workspace, identity: identity, credential: .privateKey(.init(privateKeyPEM: privateKey, passphrase: passphrase))) + _ = try await library.save(server: server, identity: identity, credential: .privateKey(.init(privateKeyPEM: privateKey, passphrase: passphrase))) + _ = try storage.workspaces.insertIfAbsent(workspace) let persisted = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) .filter { $0.pathExtension == "json" } .reduce(into: "") { $0 += (try? String(contentsOf: $1, encoding: .utf8)) ?? "" } @@ -176,16 +226,15 @@ import Testing _ = try await library.delete(serverID: serverID) } - @Test("terminal host replacement only reuses the same surface identity") - func terminalHostAttachmentPolicy() { - final class Surface {} - let first = Surface(), second = Surface() - let firstID = ObjectIdentifier(first) - #expect(!GhosttyTerminalHostAttachmentPolicy.needsReplacement(current: firstID, next: firstID)) - #expect(GhosttyTerminalHostAttachmentPolicy.needsReplacement(current: firstID, next: ObjectIdentifier(second))) - #expect(GhosttyTerminalHostAttachmentPolicy.ownsPaneView(superviewIsHostScroll: true)) - #expect(!GhosttyTerminalHostAttachmentPolicy.ownsPaneView(superviewIsHostScroll: false)) - } + +} + +private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var changed = false + + var didChange: Bool { lock.withLock { changed } } + func markChanged() { lock.withLock { changed = true } } } private final class MemoryProfilePasswords: CredentialStoring, @unchecked Sendable { diff --git a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift index 44eae517..f242b0db 100644 --- a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift @@ -2,16 +2,16 @@ import Foundation import Testing @testable import MoriRemote -@Suite("Phase 5 agent metadata and adaptive presentation") struct Phase5AgentMetadataTests { - @Test("parser strictly normalizes pane options and bounds untrusted output") +@Suite("Agent metadata facade projection") struct Phase5AgentMetadataTests { + @Test("parser strictly normalizes UInt64 pane IDs and bounds untrusted output") func parserNormalization() { let parser = AgentMetadataResponseParser() let metadata = parser.parse("%1\tworking\tclaude\n%2\tWAITING\tcodex\n%3\tdone\tpi\ninvalid\tworking\tbad\n%4\twaiting\tbad\u{0000}name\n") - #expect(metadata[.init(1)] == .init(state: .working, name: "claude")) - #expect(metadata[.init(2)] == .init(state: .unknown, name: "codex")) - #expect(metadata[.init(3)] == .init(state: .done, name: "pi")) - #expect(metadata[.init(4)] == .init(state: .waiting, name: nil)) - #expect(metadata[.init(99)] == nil) + #expect(metadata[1] == .init(state: .working, name: "claude")) + #expect(metadata[2] == .init(state: .unknown, name: "codex")) + #expect(metadata[3] == .init(state: .done, name: "pi")) + #expect(metadata[4] == .init(state: .waiting, name: nil)) + #expect(metadata[99] == nil) #expect(parser.parse(String(repeating: "x", count: AgentMetadataResponseParser.maximumResponseBytes + 1)).isEmpty) } @@ -30,62 +30,59 @@ import Testing @Test("authoritative merge clears missing records and ignores removed panes") func projectionMerge() { - let topology = makeTopology(paneIDs: [.init(1), .init(2)]) - let records: [TmuxPaneID: AgentMetadata] = [ - .init(1): .init(state: .working, name: "claude"), - .init(9): .init(state: .done, name: "other") + let records: [UInt64: AgentMetadata] = [ + 1: .init(state: .working, name: "claude"), + 9: .init(state: .done, name: "other") ] - let merged = AgentMetadataProjection.merge(records, into: topology) + let merged = AgentMetadataProjection.merge(records, paneIDs: [1, 2]) #expect(merged == [ - .init(1): .init(state: .working, name: "claude"), - .init(2): .unknown + 1: .init(state: .working, name: "claude"), + 2: .unknown ]) } - @Test("duplicate topology panes are deterministically uniqued") + @Test("duplicate facade topology panes are deterministically uniqued") func projectionDuplicateTopology() { - let topology = makeTopology(paneIDs: [.init(1), .init(1), .init(2)]) - let merged = AgentMetadataProjection.merge([.init(1): .init(state: .done, name: "pi")], into: topology) - #expect(merged == [.init(1): .init(state: .done, name: "pi"), .init(2): .unknown]) + let merged = AgentMetadataProjection.merge([1: .init(state: .done, name: "pi")], paneIDs: [1, 1, 2]) + #expect(merged == [1: .init(state: .done, name: "pi"), 2: .unknown]) } - @Test("visible projector observes option changes without topology or terminal interruption") @MainActor + @Test("visible projector consumes the facade's fixed success result") @MainActor func projectorRefreshesOptions() async { let relay = QueryRelay() - let projector = AgentMetadataProjector(instanceID: UUID()) { relay.set($0) } - projector.topologyDidChange(makeTopology(paneIDs: [.init(1)])) + let projector = AgentMetadataProjector(instanceID: UUID()) { await relay.query() } + projector.topologyDidChange(paneIDs: [1]) projector.setVisible(true) + await relay.waitUntilRequested() - relay.complete(.success, "%1\tworking\tclaude\n") - await Task.yield() - #expect(projector.metadata[.init(1)] == .init(state: .working, name: "claude")) - - projector.foregrounded() - relay.complete(.success, "%1\twaiting\tclaude\n") - await Task.yield() - #expect(projector.metadata[.init(1)] == .init(state: .waiting, name: "claude")) + relay.complete(.init(succeeded: true, body: "%1\tworking\tclaude\n")) + await eventually { projector.metadata[1] == .init(state: .working, name: "claude") } + #expect(projector.metadata[1] == .init(state: .working, name: "claude")) projector.foregrounded() - relay.complete(.success, "%1\tdone\tclaude\n") - await Task.yield() - #expect(projector.metadata[.init(1)] == .init(state: .done, name: "claude")) + await relay.waitUntilRequested() + relay.complete(.init(succeeded: true, body: "%1\twaiting\tclaude\n")) + await eventually { projector.metadata[1] == .init(state: .waiting, name: "claude") } + #expect(projector.metadata[1] == .init(state: .waiting, name: "claude")) projector.stop() } @Test("failed query yields unknown and cancellation rejects a late response") @MainActor func queryFailureAndCancellation() async { let relay = QueryRelay() - let projector = AgentMetadataProjector(instanceID: UUID()) { relay.set($0) } - projector.topologyDidChange(makeTopology(paneIDs: [.init(1)])) + let projector = AgentMetadataProjector(instanceID: UUID()) { await relay.query() } + projector.topologyDidChange(paneIDs: [1]) projector.setVisible(true) - relay.complete(.error, "transport closed") - await Task.yield() - #expect(projector.metadata[.init(1)] == .unknown) + await relay.waitUntilRequested() + relay.complete(.init(succeeded: false, body: "transport closed")) + await eventually { projector.lastFailure == "transport closed" } + #expect(projector.metadata[1] == .unknown) #expect(projector.lastFailure == "transport closed") projector.foregrounded() + await relay.waitUntilRequested() projector.stop() - relay.complete(.success, "%1\tworking\tlate\n") + relay.complete(.init(succeeded: true, body: "%1\tworking\tlate\n")) await Task.yield() #expect(projector.metadata.isEmpty) } @@ -93,77 +90,80 @@ import Testing @Test("hiding clears badges and a late generation cannot repopulate them") @MainActor func hideReshowDropsLateResponse() async { let relay = QueryRelay() - let projector = AgentMetadataProjector(instanceID: UUID()) { relay.set($0) } - var changes = 0 - projector.onChange = { changes += 1 } - projector.topologyDidChange(makeTopology(paneIDs: [.init(1)])) + let projector = AgentMetadataProjector(instanceID: UUID()) { await relay.query() } + projector.topologyDidChange(paneIDs: [1]) projector.setVisible(true) - relay.complete(.success, "%1\tworking\tclaude\n") - await Task.yield() - #expect(projector.metadata[.init(1)]?.state == .working) + await relay.waitUntilRequested() + relay.complete(.init(succeeded: true, body: "%1\tworking\tclaude\n")) + await eventually { projector.metadata[1] == .init(state: .working, name: "claude") } - projector.foregrounded() // leave this generation in flight + projector.foregrounded() + await relay.waitUntilRequested() projector.setVisible(false) #expect(projector.metadata.isEmpty) - #expect(changes >= 3) projector.setVisible(true) - relay.complete(.success, "%1\tdone\tlate\n") + await relay.waitUntilRequested() + relay.complete(.init(succeeded: true, body: "%1\tdone\tlate\n")) await Task.yield() #expect(projector.metadata.isEmpty) - relay.complete(.success, "%1\twaiting\tclaude\n") - await Task.yield() - #expect(projector.metadata[.init(1)] == .init(state: .waiting, name: "claude")) + relay.complete(.init(succeeded: true, body: "%1\twaiting\tclaude\n")) + await eventually { projector.metadata[1] == .init(state: .waiting, name: "claude") } + #expect(projector.metadata[1] == .init(state: .waiting, name: "claude")) projector.stop() } - @Test("replaced runtime projector cannot publish an old response") @MainActor + @Test("replaced runtime projector cannot publish an old facade response") @MainActor func runtimeReplacementFence() async { let oldRelay = QueryRelay() - let old = AgentMetadataProjector(instanceID: UUID()) { oldRelay.set($0) } - old.topologyDidChange(makeTopology(paneIDs: [.init(1)])) + let old = AgentMetadataProjector(instanceID: UUID()) { await oldRelay.query() } + old.topologyDidChange(paneIDs: [1]) old.setVisible(true) + await oldRelay.waitUntilRequested() old.stop() let newRelay = QueryRelay() - let replacement = AgentMetadataProjector(instanceID: UUID()) { newRelay.set($0) } - replacement.topologyDidChange(makeTopology(paneIDs: [.init(1)])) + let replacement = AgentMetadataProjector(instanceID: UUID()) { await newRelay.query() } + replacement.topologyDidChange(paneIDs: [1]) replacement.setVisible(true) - oldRelay.complete(.success, "%1\tdone\told\n") - newRelay.complete(.success, "%1\tworking\tnew\n") - await Task.yield() + await newRelay.waitUntilRequested() + oldRelay.complete(.init(succeeded: true, body: "%1\tdone\told\n")) + newRelay.complete(.init(succeeded: true, body: "%1\tworking\tnew\n")) + await eventually { replacement.metadata[1] == .init(state: .working, name: "new") } #expect(old.metadata.isEmpty) - #expect(replacement.metadata[.init(1)] == .init(state: .working, name: "new")) + #expect(replacement.metadata[1] == .init(state: .working, name: "new")) replacement.stop() } +} - @Test("fixed metadata command stays inside the sole controller admission boundary") - func metadataQueryPolicy() { - #expect(TmuxClientCommandPolicy.isAllowed(TmuxClientCommandPolicy.agentMetadataQuery)) - #expect(!TmuxClientCommandPolicy.isAllowed("list-panes -a")) - #expect(!TmuxClientCommandPolicy.isAllowed("set-option -p @mori-agent-state working")) - #expect(!TmuxClientCommandPolicy.agentMetadataQuery.contains("refresh-client")) +@MainActor +private func eventually(_ condition: @escaping @MainActor () -> Bool) async { + for _ in 0..<100 { + if condition() { return } + try? await Task.sleep(for: .milliseconds(1)) } + Issue.record("condition did not become true") +} - @Test("compact and regular presentation preserve terminal runtime identity") - func presentationIdentity() { - let instance = UUID() - #expect(RemoteTerminalPresentation.identity(for: instance, mode: .compact) == instance) - #expect(RemoteTerminalPresentation.identity(for: instance, mode: .regular) == instance) - #expect(RemoteTerminalPresentation.identity(for: UUID(), mode: .regular) != instance) +@MainActor +private final class QueryRelay { + private var continuations: [CheckedContinuation] = [] + private var requestedWaiters: [CheckedContinuation] = [] + + func query() async -> AgentMetadataQueryResult { + await withCheckedContinuation { continuation in + continuations.append(continuation) + requestedWaiters.forEach { $0.resume() } + requestedWaiters.removeAll() + } } - private func makeTopology(paneIDs: [TmuxPaneID]) -> TmuxSessionController.Topology { - let window = TmuxSessionController.Window(id: .init(1), name: "build", active: true, activePaneID: paneIDs.first ?? .init(0)) - let panes = paneIDs.map { TmuxSessionController.Pane(id: $0, windowID: window.id, width: 80, height: 24, phase: .live) } - return .init(revision: 1, sessionName: "workspace", windows: [window], panes: panes, activeWindowID: window.id) + func waitUntilRequested() async { + guard continuations.isEmpty else { return } + await withCheckedContinuation { requestedWaiters.append($0) } } -} -private final class QueryRelay: @unchecked Sendable { - private var completions: [@Sendable (TmuxSessionController.CommandResult) -> Void] = [] - func set(_ completion: @escaping @Sendable (TmuxSessionController.CommandResult) -> Void) { completions.append(completion) } - func complete(_ status: TmuxSessionController.CommandStatus, _ body: String) { - guard !completions.isEmpty else { return } - completions.removeFirst()(.init(status: status, body: body, causeToken: 1)) + func complete(_ result: AgentMetadataQueryResult) { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume(returning: result) } } diff --git a/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift b/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift new file mode 100644 index 00000000..ae983d13 --- /dev/null +++ b/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import MoriRemote + +@Suite("Terminal facade ownership") struct Phase6TerminalOwnershipTests { + @Test("app target has no direct native terminal owner or Ghostty link") + func oneOwnerInvariant() throws { + let remoteRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let appSources = remoteRoot.appendingPathComponent("MoriRemote") + let forbiddenPaths = [ + "Ghostty/GhosttyKitRuntime.swift", + "Ghostty/GhosttyPaneSurface.swift", + "Ghostty/GhosttyTmuxRuntime.swift", + "Ghostty/GhosttyTerminalProbe.swift", + "GhosttyKitABIProbe.swift", + "Tmux/TmuxControl.swift", + "Tmux/TmuxSessionController.swift", + "Tmux/DeterministicTmuxControlTransport.swift", + ] + for path in forbiddenPaths { + #expect(!FileManager.default.fileExists(atPath: appSources.appendingPathComponent(path).path)) + } + + let appSwiftFiles = try FileManager.default.contentsOfDirectory( + at: appSources, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + let directImports = try appSwiftFiles.flatMap { root in + try recursiveSwiftFiles(at: root) + }.filter { url in + try String(contentsOf: url).contains("import GhosttyKit") + } + #expect(directImports.isEmpty) + + let project = try String(contentsOf: remoteRoot.appendingPathComponent("project.yml")) + let appTarget = try #require(project.components(separatedBy: " MoriRemoteTerminal:").first) + #expect(!appTarget.contains("GhosttyKit.xcframework")) + #expect(!appTarget.contains("ghostty_tmux_client_config_new")) + #expect(appTarget.contains("- target: MoriRemoteTerminal\n embed: false")) + } + + private func recursiveSwiftFiles(at url: URL) throws -> [URL] { + if url.pathExtension == "swift" { return [url] } + guard (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { return [] } + return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + .flatMap { try recursiveSwiftFiles(at: $0) } + } +} diff --git a/MoriRemote/MoriRemoteTests/SSHTransportTests.swift b/MoriRemote/MoriRemoteTests/SSHTransportTests.swift index 4e2e2fde..72fbe81e 100644 --- a/MoriRemote/MoriRemoteTests/SSHTransportTests.swift +++ b/MoriRemote/MoriRemoteTests/SSHTransportTests.swift @@ -48,56 +48,6 @@ import Testing let command = try TmuxCommandBuilder.createShadow(executable: "/opt/tools/tmux", source: "project/main", runtimeID: id) #expect(command.contains("'project/main'")); #expect(!command.contains("refresh-client -C")); #expect(throws: TmuxCommandError.self) { _ = try TmuxCommandBuilder.createShadow(executable: "tmux", source: "bad\nkill", runtimeID: id) }; #expect(throws: TmuxCommandError.self) { _ = try TmuxCommandBuilder.cleanupPlan(executable: "tmux", source: "project/main", shadow: shadow + "x", runtimeID: id) } } - @Test("chunked inbound, sequential submissions, and EOF preserve transport lifecycle") func link() async throws { - let transport = TestTransport() - let received = Recorder() - let disconnected = Flag() - let link = TmuxSessionLink( - transport: transport, - receive: { received.add($0) }, - disconnected: { disconnected.set() } - ) - try await link.start() - await transport.push(Data("a".utf8)) - await transport.push(Data("b".utf8)) - for value in ["first", "second", "third"] { - await link.send(Data(value.utf8)) - } - try await Task.sleep(for: .milliseconds(30)) - #expect(await transport.writes() == [Data("first".utf8), Data("second".utf8), Data("third".utf8)]) - - await transport.finish() - try await Task.sleep(for: .milliseconds(30)) - #expect(received.values() == [Data("a".utf8), Data("b".utf8)]) - #expect(disconnected.value()) - #expect(await transport.dispositions() == [.invalidated]) - } } private struct Credentials: SSHCredentialReading { let values: [UUID: SSHCredential]; init(_ values: [UUID: SSHCredential]) { self.values = values }; func credential(for id: UUID) throws -> SSHCredential? { values[id] } } -private actor TestTransport: TmuxControlTransport { - nonisolated let receivedBytes: AsyncThrowingStream - private let continuation: AsyncThrowingStream.Continuation - private var submittedWrites: [Data] = [] - private var closes: [TmuxControlTransportCloseDisposition] = [] - - init() { - var continuation: AsyncThrowingStream.Continuation! - receivedBytes = AsyncThrowingStream { continuation = $0 } - self.continuation = continuation - } - - func start() async throws {} - func send(_ data: Data) async throws { submittedWrites.append(data) } - func isActive() async -> Bool { closes.isEmpty } - func close(disposition: TmuxControlTransportCloseDisposition) async { - closes.append(disposition) - continuation.finish() - } - func push(_ data: Data) { continuation.yield(data) } - func finish() { continuation.finish() } - func writes() -> [Data] { submittedWrites } - func dispositions() -> [TmuxControlTransportCloseDisposition] { closes } -} -private final class Recorder: @unchecked Sendable { private let lock = NSLock(); private var data: [Data] = []; func add(_ value: Data) { lock.withLock { data.append(value) } }; func values() -> [Data] { lock.withLock { data } } } -private final class Flag: @unchecked Sendable { private let lock = NSLock(); private var flag = false; func set() { lock.withLock { flag = true } }; func value() -> Bool { lock.withLock { flag } } } diff --git a/MoriRemote/UPSTREAM.md b/MoriRemote/UPSTREAM.md index 0a51a49c..96a75e7f 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -1,59 +1,96 @@ -# MoriRemote remux upstreams - -## Mori-built universal GhosttyKit - -Mori builds one **untracked** `Frameworks/GhosttyKit.xcframework` from the -pinned remux Ghostty source. It contains the universal macOS slice and the iOS -arm64 device and simulator slices; both Mori and MoriRemote link that one -framework without embedding it in either app bundle. - -| Field | Value | -| --- | --- | -| Source repository | | -| Source commit | `aeb8f73790946d9c9ad175b3dafaec9911ef36bb` | -| Upstream Ghostty base | `b213a72c03b427607b43c89ff4223a7baa079fe8` | -| Added remux ABI | `ghostty_tmux_client_*` | -| Reference application | at `b3a3e5f5dfa4759ab189e203b9a03749e821540c` | - -`scripts/build-ghostty.sh --universal` builds the framework with -`ReleaseFast`; `scripts/verify-ghosttykit.sh` fails closed unless its provenance -matches the pinned source and generated framework content digest, the macOS and -iOS arm64 slices are present, iOS minimum OS is at most 17, and the custom tmux -ABI compiles and exports from all slices. CI and `release-ios.yml` build this artifact through the reusable -`build-ghosttykit.yml` workflow and download it only from that same workflow -run. There is no third-party prebuilt or mirror fallback. - -The framework derives from Ghostty as modified by `h3nock/remux-ghostty`. -Ghostty and the adapted remux source are MIT licensed; complete distributed -notices are in [`../THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md). - -## Citadel / NIOSSH package identity (Phase 2) - -MoriRemote pins h3nock/Citadel at `1d0eadd81d0a521b00ede6663c8b3301f5fc252e`. -Citadel pins h3nock's `swift-nio-ssh` fork at -`7588777b8f6439efa1a33117f86cb2729abd864c`. MoriRemote no longer links the legacy `MoriSSH` package. The fork remains a -direct MoriRemote dependency because Citadel uses that exact `NIOSSH` module; -macOS package resolution is independent and must not be changed as a side -effect of an iOS artifact update. - -## Phase 3 Ghostty tmux core slice - -The reference is `h3nock/remux` commit -`b3a3e5f5dfa4759ab189e203b9a03749e821540c`. The initial Mori adaptation is -intentionally limited to the native runtime and control boundary: - -| Mori production file | Upstream production reference | Upstream test reference | Mori coverage / deviation | +# MoriRemote terminal transplant upstreams + +## Pinned source + +The Phase-1 terminal transplant is derived from `h3nock/remux` commit +[`b3a3e5f5dfa4759ab189e203b9a03749e821540c`](https://github.com/h3nock/remux/tree/b3a3e5f5dfa4759ab189e203b9a03749e821540c), inspected from `/tmp/remux-scout`. + +`MoriRemoteTerminal` is a separate iOS 17 **static framework** target +(`MACH_O_TYPE = staticlib`). It links only Mori's +`../Frameworks/GhosttyKit.xcframework`; it has no package dependency and +exports the public `MoriRemoteTerminalSession` facade used by the production +SSH shell. The app target imports `MoriRemoteTerminal`, never `GhosttyKit`: +the static terminal archive is the sole native Ghostty owner. This is not a +permanent binary-distribution mechanism; the static archive supplies a module +boundary without embedding a second terminal dylib. + +## Imported source boundary + +The framework preserves upstream file and directory names for the terminal +core: + +- `Tmux/`: identity, viewport, control protocol/link, session controller, + terminal session, pane surface, screen adapter, and runtime trace. +- `Ghostty/`: runtime/control and managed surfaces, pane/local viewport and + scroll physics, key/mouse/scroll mappings, responder/text-input/focus/input + coordination, modifier state, keyboard visibility/trackpad, compact keypad, + system-keyboard chrome, topology projection, and `GhosttyTerminalCoreView` + (the minimal upstream-derived composition root). +- `Domain/TerminalSettings.swift`: terminal appearance only. + +The paired `MoriRemoteTerminalTests` target owns the deterministic transport +fixture and ports the matching upstream tests +for controller/session/link/adapter teardown, scrolling and viewport state, +responder and keyboard input, modifier state, and local text selection. + +## Explicit exclusions + +No files from remux account/profile repositories, SSH services/transports, +live forwarding, pane-preview/selection sheets, full composer/voice, generic +file attachments, or shortcut marketplace/editor are linked into +`MoriRemoteTerminal`. Image input is the deliberate exception: the terminal +module owns photo/clipboard staging and preview UI, while a narrow +`MoriRemoteTerminalImageUploader` delegates authenticated SFTP upload through +Mori's app-owned `SSHRootAccess`, the same concrete root recipe used by session +discovery and terminal control. Each operation still owns its exact TOFU retry. + +`TmuxControlTransport` remains a terminal-internal protocol-only seam. The app +crosses it only through `MoriRemoteTerminalTransport`, whose byte lifecycle +closures are adapted by `SSHTmuxControlTransport.asTerminalTransport()`. +Image upload is a separate typed operation and cannot execute arbitrary tmux or +shell commands; it atomically uploads below `~/.cache/mori/attachments` and +inserts a shell-escaped path without pressing Enter. The deterministic +transport remains solely a terminal-core test fixture. + +## Required adaptations and iOS 17 deviations + +| Area | Change | Why | +| --- | --- | --- | +| `TmuxControlTransport.swift` | Protocol-only; removes SFTP/live-forward refinements. | Keeps the core independent of SSH/Citadel and forwarding. | +| `TmuxSessionController.swift` | Native client starts with `initial_columns = initial_rows = 0`; pane hydration derives dimensions from the authoritative tmux topology (window/pane grid), and exposes only fixed correlated agent-metadata query. | Prevents an implicit startup `refresh-client -C` or phone viewport dimensions from resizing the shared tmux client while keeping arbitrary tmux execution out of the app boundary. | +| `App/MoriRemoteTerminalFacade.swift` | Public deep facade directly owns `GhosttyKitRuntime`, `TmuxTerminalSession`, and `TmuxTerminalScreenAdapter`; it exposes state/topology, fixed metadata results, labeled shared mutations, presentation lifecycle, type-erased SSH byte lifecycle closures, and one typed image-uploader closure. | App code retains Citadel/trust/persistence/SFTP without importing GhosttyKit or terminal controller/surface types; the one-caller `TmuxScreenModel` wrapper and one-adapter modeling protocols were removed. | +| `GhosttyTerminalDisconnectReasonClassifier.swift` | Transport-agnostic classifier. | No Phase-1 dependency on NIO, SSH errors, or host-trust types. | +| Runtime status types | Local terminal-only `TerminalRuntimeState` / disconnect vocabulary. | Avoids importing remux connection/account domain objects. | +| `GhosttySingleViewportView.swift` | Subtractive adaptation of upstream's viewport. It retains local text-selection long press/update/end, selection handles and endpoint drag, selection-geometry recovery, copy edit-menu, surface tap/focus, horizontal window swipe, and mouse routing. | Preview candidate resolution/action is removed; copy remains. Mori's app-owned Navigator is the only window/pane browser. | +| `TmuxPaneSurface.swift` + `GhosttyPublishedFrameObserver.swift` | Adds a post-publication interaction-state refresh when Ghostty replaces the renderer layer contents. | The pinned upstream callback polls scrollbar state immediately after `terminalChanged`, before the renderer necessarily applies new output. Without the completed-frame refresh, UIKit can retain an undersized local scroll document and stop above the true bottom. | +| `GhosttyKeyboardChrome.swift` + `GhosttyKeypadSheet.swift` + `GhosttyImageAttachmentSheet.swift` | Keeps remux's trailing keyboard placement in a slim four-icon input accessory, combines terminal shortcuts in one categorized keypad, and exposes remux-derived photo/clipboard image staging from that panel. App-owned and image-picker modal presentation suspends the hidden terminal responder. | Stable controls avoid localized-label drift; responder suspension protects text fields and system pickers; confirmed images upload through the typed facade and insert only an escaped path. Full composer/voice and shortcut-store domains remain excluded. | +| `GhosttyTerminalCompositionState.swift` + `GhosttyTerminalCoreView.swift` | Small upstream-derived composition root over `TmuxTerminalScreenAdapter`; consumes keyboard notifications, visibility projection, viewport holds, responder callbacks, delayed prefix flush, viewport, text selection, cursor-trackpad HUD, and chrome. | Host/session/window/pane navigation belongs to Mori's app boundary, where server discovery and metadata already live. | +| Upstream pane preview and selection sheets | Removed after the final app-owned searchable Navigator replaced them. Renderer-frame publication remains only for safe surface presentation and post-frame scroll-state refresh; no pixel copy/cache or temporary picker-grid resize remains. | Two navigation concepts created dead UI and substantial IOSurface lifecycle machinery with no production caller. | +| Upstream composer command support | Removed tracked input completion and pane-current-directory queries with the excluded composer/submission path. Ordinary terminal input retains local routing through the sole controller queue; the fixed agent-metadata query is its only app-domain query. | The copied completion and directory-query machinery had no Mori production or test caller after composer removal. | +| Terminal presentation projection | Retains only the active surface identity, window count, and input availability consumed by Mori's single viewport. Removed readiness snapshots, status overlays, disconnect projections, and command-failure presentation with no UI consumer. | App-owned workspace chrome already presents connection state; duplicating an unrendered terminal status model added state without leverage. | +| `GhosttyRuntimeTrace.swift` | Retains gated diagnostics, performance timing, and tmux-viewport logging. Producerless flow/latency stores and their no-op call sites are removed. | Mori does not import remux's flow-start or marker-registration producers; preserving only consumers made every flow event unreachable. | +| iOS 17 | Terminal framework deployment target remains `17.0`. | The closed source slice uses no required iOS 18 API. | + +## Test provenance + +The following table records the pinned upstream tests reviewed for each production +area and the local equivalent. “Adapted” means the assertion remains but was +made deterministic or detached from excluded remux domains. + +| Production area | Pinned remux tests reviewed | MoriRemoteTerminalTests | Untranslated gap | | --- | --- | --- | --- | -| `Ghostty/GhosttyKitRuntime.swift` | `Ghostty/GhosttyKitRuntime.swift` | `GhosttyKitRuntimeTests.swift` | iOS 17 runtime/app ownership only; settings/theme warmup is deferred with the shell. | -| `Tmux/TmuxSessionController.swift` | `Tmux/TmuxSessionController.swift` | `TmuxSessionControllerClientSizeTests.swift` | One writer queue owns every client call, parser action, command token, outbound consume, native surface notification, topology revision, and retained canonical terminal. `Phase3RuntimeTests` translates the local history, topology projection, command admission, tracked-input failure, shutdown, and surface-fence contracts. Deliberately omits upstream `refresh-client -C`, `resize-pane -Z`, zoom, and server copy-mode commands. | -| `Tmux/TmuxControl.swift` | `Tmux/TmuxSessionLink.swift` | `TmuxSessionLinkWriteFailureTests.swift` | Adds a narrow `beforeReceive` gate: the client is created after SSH attach but before inbound pumping, preventing startup bytes from bypassing Ghostty. `DeterministicTmuxControlTransport` adds delayed chunks, terminal errors, and captured writes for those tests. | -| `Ghostty/GhosttyTmuxRuntime.swift` | `Tmux/TmuxTerminalSession.swift` | `GhosttyRuntimeSurfaceTopologySnapshotTests.swift` | One-shot runtime composition, callback instance fence, and stop order (link → every unregister fence → controller shutdown). `GhosttyRuntimeCallbackGate` is tested as a pure projection because a fabricated C surface would make a false ABI claim. | -| `Ghostty/GhosttyTerminalProbe.swift` | debug terminal fixture patterns | n/a | DEBUG-only deterministic route (`--ghostty-terminal-probe`); it does not replace the production root or require credentials. | - -The upstream managed surface, responder, input, viewport, and scrolling files -were reviewed but not copied wholesale. `Ghostty/GhosttyPaneSurface.swift` -provides the local-only iOS 17 adaptation: CAMetal rendering, native surface -registration fences, hardware/software keyboard and IME input, paste, -selection/copy, and bounded local scrolling. It deliberately omits remux's -server zoom, server copy-mode browsing, and viewport resize commands because -those would violate MoriRemote's isolated-client invariants. +| tmux client/session/link/adapter | `TmuxSessionControllerClientSizeTests.swift`, `TmuxTerminalScreenAdapterTests.swift`, `TmuxTerminalSessionShutdownDrainTests.swift` | `MoriTmuxNativeStartupIsolationTests.swift`, `MoriTmuxIsolationTests.swift`, facade state/error tests, plus matching session/link/adapter tests | Native startup harness retains `GhosttyKitRuntime`, proves zero-grid startup emits version/list-windows but no `refresh-client`; facade failures complete fixed metadata queries; SSH transport integration remains excluded. | +| responder, text input and paste | `GhosttyTerminalResponderViewTests.swift`, `GhosttyTerminalInputCoordinatorTests.swift` | Same filenames, plus marked-text composition coverage | Simulator-global text pasteboard integration is injected; IME marked text remains local and commits once through insert/replace/unmark. | +| keyboard visibility and viewport continuity | `GhosttyKeyboardVisibilityProjectionTests.swift` | `GhosttyKeyboardVisibilityProjectionTests.swift`, `GhosttyTerminalViewportCoordinatorTests.swift`, `GhosttyTerminalCompositionStateTests.swift` | No device keyboard-animation screenshot test. | +| delayed tmux prefix input | `GhosttyTerminalInputCoordinatorTests.swift` | `GhosttyTerminalInputCoordinatorTests.swift`, `GhosttyTerminalPrefixFlushLifecycleTests.swift` | Scheduler wall-clock timing is not asserted; token fencing and flush routing are deterministic. | +| local terminal selection/copy/gesture | `GhosttyKitControlSurfaceTests.swift`, `GhosttySurfaceMouseEventTests.swift`, `GhosttySurfaceScrollGestureTests.swift` | Same filenames | No end-to-end UIKit edit-menu presentation test; selection geometry, text decoding, mouse/tap and gesture reducers are deterministic. Preview-menu assertion is excluded with preview. | +| keyboard chrome | `GhosttyKeyboardChromeModeTests.swift` | `GhosttyKeyboardChromeModeTests.swift`, `GhosttyKeyboardChromeActionsTests.swift` | SwiftUI pixel/snapshot tests are not imported. | +| composition root | `GhosttySurfaceScreen.swift` (production call graph reviewed) | `GhosttyTerminalCoreViewTests.swift`, `GhosttyTerminalCompositionStateTests.swift`, `GhosttyImageAttachmentTests.swift`, app-side `ImageInputTests.swift` | Full composer/voice, generic files, shortcut UI and account actions remain excluded; image path formatting and atomic upload ordering are deterministic. | + +## GhosttyKit provenance + +Mori builds one untracked `Frameworks/GhosttyKit.xcframework` from the pinned +remux Ghostty source. It includes iOS arm64 device/simulator slices and exposes +the `ghostty_tmux_client_*` ABI used by the upstream controller. See +`ghosttykit-lock.json` and `scripts/verify-ghosttykit.sh` for the artifact +provenance and ABI checks. diff --git a/MoriRemote/project.yml b/MoriRemote/project.yml index 7ff2d251..6717315b 100644 --- a/MoriRemote/project.yml +++ b/MoriRemote/project.yml @@ -21,6 +21,10 @@ targets: sources: - path: MoriRemote dependencies: + # This target is a static framework: link its archive into the app but + # never copy a synthetic framework bundle into the signed payload. + - target: MoriRemoteTerminal + embed: false - package: Citadel product: Citadel - package: NIO @@ -29,18 +33,13 @@ targets: product: NIOPosix - package: NIOSSH product: NIOSSH - # Static iOS framework: link only; do not embed/sign it into the app bundle. - - framework: ../Frameworks/GhosttyKit.xcframework - embed: false settings: base: PRODUCT_NAME: MoriRemote PRODUCT_BUNDLE_IDENTIFIER: com.vaayne.mori-remote - # Force-load one inert ABI symbol: validate linkage without changing runtime flow. OTHER_LDFLAGS: - $(inherited) - -lc++ - - -Wl,-u,_ghostty_tmux_client_config_new INFOPLIST_FILE: MoriRemote/Info.plist SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete @@ -74,6 +73,48 @@ targets: ditto "$SRCROOT/../THIRD_PARTY_NOTICES.md" "$destination/THIRD_PARTY_NOTICES.md" rm -rf "$destination/THIRD_PARTY_LICENSES" ditto "$SRCROOT/../THIRD_PARTY_LICENSES" "$destination/THIRD_PARTY_LICENSES" + MoriRemoteTerminal: + type: framework + platform: iOS + deploymentTarget: "17.0" + sources: + - path: MoriRemoteTerminal + dependencies: + # The transplant target deliberately links no upstream app package. Its + # only binary dependency is Mori's pinned universal GhosttyKit artifact. + - framework: ../Frameworks/GhosttyKit.xcframework + embed: false + settings: + base: + PRODUCT_NAME: MoriRemoteTerminal + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + GENERATE_INFOPLIST_FILE: YES + EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 + OTHER_LDFLAGS: + - $(inherited) + - -lc++ + # The archive contains the sole native Ghostty owner used by MoriRemote. + # It is linked statically, never embedded as a second terminal dylib. + MACH_O_TYPE: staticlib + MoriRemoteTerminalTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + sources: + - path: MoriRemoteTerminalTests + dependencies: + - target: MoriRemoteTerminal + settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + GENERATE_INFOPLIST_FILE: YES + # Static terminal archive exposes GhosttyKit C++ symbols to XCTest. + OTHER_LDFLAGS: + - $(inherited) + - -lc++ MoriRemoteTests: type: bundle.unit-test platform: iOS @@ -97,3 +138,4 @@ schemes: test: targets: - MoriRemoteTests + - MoriRemoteTerminalTests diff --git a/README.md b/README.md index cefd8f6f..e4643bf3 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ uses explicit host-key confirmation, supports passwords or imported OpenSSH private keys, and keeps mobile navigation isolated from other tmux clients. It requires tmux 3.2 or newer on the server. Local terminal history and selection stay on the device; split, close, and new-window actions are shared -workspace mutations. +workspace mutations. The software keyboard supports IME composition. Images +chosen from Photos or the clipboard are uploaded through the authenticated SSH +connection, then inserted as a shell-escaped remote path without auto-submit. Building MoriRemote from source requires Xcode, XcodeGen, and the pinned remux Ghostty source. The local task builds one universal macOS + iOS XCFramework; diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 09de8d80..c073e858 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -57,7 +57,7 @@ brew install --cask mori ### MoriRemote -MoriRemote 是 iPhone/iPad 上的 SSH 与 tmux 伴侣,不是远程桌面。它要求显式确认主机密钥,支持密码和导入的 OpenSSH 私钥,并保证移动端导航不干扰其他 tmux 客户端。服务器需要 tmux 3.2 或更高版本。本地终端历史和选择只保留在设备上;分屏、关闭与新建窗口属于共享工作区操作。 +MoriRemote 是 iPhone/iPad 上的 SSH 与 tmux 伴侣,不是远程桌面。它要求显式确认主机密钥,支持密码和导入的 OpenSSH 私钥,并保证移动端导航不干扰其他 tmux 客户端。服务器需要 tmux 3.2 或更高版本。本地终端历史和选择只保留在设备上;分屏、关闭与新建窗口属于共享工作区操作。软件键盘支持输入法组合文本;从照片或剪贴板选择的图片会通过已认证 SSH 连接上传,再以经过 shell 转义的远程路径插入终端,不会自动提交。 从源码构建 MoriRemote 需要 Xcode、XcodeGen 和固定的 remux Ghostty 源码。本地任务会构建一份同时支持 macOS 与 iOS 的通用 XCFramework;CI 也只构建一次同一源码制品,并由 macOS 和 iOS job 共享。 diff --git a/scripts/smoke-moriremote-simulator.sh b/scripts/smoke-moriremote-simulator.sh index 167a0d67..d4a511a1 100755 --- a/scripts/smoke-moriremote-simulator.sh +++ b/scripts/smoke-moriremote-simulator.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Install and prove both the library and deterministic Ghostty paths stay alive. +# Install and prove both the library and deterministic terminal-facade paths stay alive. # simctl launch returning a PID is intentionally not accepted as success. set -euo pipefail @@ -66,7 +66,7 @@ wait_for_probe_result() { while ((SECONDS < deadline)); do logs="$(probe_logs "$pid")" if grep -Fq 'MORI_GHOSTTY_PROBE_RESULT success=false' <<<"$logs"; then - echo "Ghostty probe reported native rendering failure:" >&2 + echo "Ghostty terminal-facade probe reported startup failure:" >&2 printf '%s\n' "$logs" >&2 return 1 fi @@ -75,7 +75,7 @@ wait_for_probe_result() { fi sleep 1 done - echo "Ghostty probe did not report successful native rendering within 30 seconds:" >&2 + echo "Ghostty terminal-facade probe did not report successful startup within 30 seconds:" >&2 probe_logs "$pid" >&2 || true return 1 } @@ -97,7 +97,15 @@ launch_and_capture() { } launch_and_capture library -launch_and_capture ghostty-terminal --ghostty-terminal-probe +if [[ "$configuration" == "Debug" ]]; then + launch_and_capture ghostty-terminal --ghostty-terminal-probe + screenshots="$output_dir/library.png, $output_dir/ghostty-terminal.png" +else + # The deterministic transport is intentionally absent from production. + # Release smoke proves the signed app launches; real-host acceptance owns + # terminal interaction coverage. + screenshots="$output_dir/library.png" +fi echo "✅ MoriRemote simulator smoke passed on $device" -echo " Screenshots: $output_dir/library.png, $output_dir/ghostty-terminal.png" +echo " Screenshots: $screenshots"