From 73f70808a35592d2de3825f36f1bd4548dd61bfb Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 15:32:43 +0800 Subject: [PATCH 01/22] moriremote: transplant remux terminal core --- .../MoriRemote.xcodeproj/project.pbxproj | 561 ++++++ .../xcschemes/MoriRemote.xcscheme | 11 + .../App/ActiveSessionSwitcherView.swift | 47 + .../MoriRemoteTerminal/App/Haptic.swift | 72 + .../App/TerminalRuntimeTypes.swift | 22 + .../Domain/TerminalSettings.swift | 189 ++ .../Ghostty/GhosttyIOSurfaceFrame.swift | 129 ++ .../Ghostty/GhosttyKeyboardChrome.swift | 141 ++ .../GhosttyKeyboardCursorTrackpad.swift | 458 +++++ .../GhosttyKeyboardCursorTrackpadHUD.swift | 209 +++ .../GhosttyKeyboardVisibilityProjection.swift | 411 +++++ .../Ghostty/GhosttyKitControlSurface.swift | 518 ++++++ .../Ghostty/GhosttyKitRuntime.swift | 370 ++++ .../Ghostty/GhosttyManagedSurface.swift | 207 +++ .../Ghostty/GhosttyManagedSurfaceLookup.swift | 16 + .../Ghostty/GhosttyModifierState.swift | 73 + .../Ghostty/GhosttyPanePreviewSession.swift | 206 +++ .../GhosttyPaneScrollContainerView.swift | 704 ++++++++ ...hosttyRuntimeSurfaceTopologySnapshot.swift | 36 + .../Ghostty/GhosttyScrollPhysicsView.swift | 218 +++ .../Ghostty/GhosttySingleViewportView.swift | 850 +++++++++ .../Ghostty/GhosttySurfaceKeyEvent.swift | 117 ++ .../Ghostty/GhosttySurfaceMouseEvent.swift | 166 ++ .../Ghostty/GhosttySurfaceScrollGesture.swift | 241 +++ .../GhosttySurfaceSelectionSheet.swift | 709 ++++++++ .../GhosttyTerminalCompositionState.swift | 61 + .../Ghostty/GhosttyTerminalCoreView.swift | 260 +++ ...tyTerminalDisconnectReasonClassifier.swift | 13 + .../GhosttyTerminalInputCoordinator.swift | 377 ++++ ...GhosttyTerminalPresentationProjector.swift | 526 ++++++ .../GhosttyTerminalResponderFocusPolicy.swift | 21 + ...hosttyTerminalResponderTextInputShim.swift | 189 ++ .../GhosttyTerminalResponderView.swift | 672 ++++++++ .../GhosttyTerminalScreenModeling.swift | 181 ++ ...ttyTerminalSurfaceInteractionOutcome.swift | 45 + .../GhosttyTerminalViewportCoordinator.swift | 355 ++++ .../GhosttyTmuxActionTargetResolver.swift | 12 + .../GhosttyTmuxPrefixInputBuffer.swift | 49 + .../Ghostty/GhosttyTopLevelSurface.swift | 24 + .../Ghostty/GhosttyViewportSizing.swift | 19 + .../Ghostty/PanePreviewLayout.swift | 207 +++ .../Ghostty/TerminalSelectionSheetStyle.swift | 243 +++ .../DeterministicTmuxControlTransport.swift | 43 + .../Tmux/GhosttyRuntimeTrace.swift | 564 ++++++ .../Tmux/TmuxControlTransport.swift | 25 + .../Tmux/TmuxControlViewport.swift | 28 + .../Tmux/TmuxIdentity.swift | 110 ++ .../Tmux/TmuxPanePreviewImageCache.swift | 87 + .../Tmux/TmuxPaneSurface.swift | 1054 ++++++++++++ .../Tmux/TmuxScreenModel.swift | 41 + .../Tmux/TmuxSessionController.swift | 1526 +++++++++++++++++ .../Tmux/TmuxSessionLink.swift | 150 ++ .../Tmux/TmuxTerminalScreenAdapter.swift | 807 +++++++++ .../Tmux/TmuxTerminalSession.swift | 594 +++++++ ...ActiveSessionSwitcherProjectionTests.swift | 27 + .../GhosttyKeyboardChromeActionsTests.swift | 41 + .../GhosttyKeyboardChromeModeTests.swift | 28 + ...ttyKeyboardVisibilityProjectionTests.swift | 604 +++++++ .../GhosttyKitControlSurfaceTests.swift | 265 +++ .../GhosttyModifierStateTests.swift | 49 + .../GhosttyScrollDeltaBudgetTests.swift | 181 ++ .../GhosttySurfaceScrollGestureTests.swift | 413 +++++ ...GhosttyTerminalCompositionStateTests.swift | 33 + .../GhosttyTerminalCoreViewTests.swift | 16 + ...ttyTerminalPrefixFlushLifecycleTests.swift | 24 + ...ttyTerminalResponderFocusPolicyTests.swift | 33 + .../GhosttyTerminalResponderViewTests.swift | 886 ++++++++++ ...rminalSurfaceInteractionOutcomeTests.swift | 37 + ...sttyTerminalViewportCoordinatorTests.swift | 409 +++++ .../GhosttyTmuxPrefixInputBufferTests.swift | 93 + .../GhosttyTopLevelSurfaceTests.swift | 48 + ...TmuxSessionControllerClientSizeTests.swift | 1203 +++++++++++++ .../TmuxSessionLinkWriteFailureTests.swift | 186 ++ .../TmuxTerminalScreenAdapterTests.swift | 275 +++ ...muxTerminalSessionShutdownDrainTests.swift | 241 +++ MoriRemote/UPSTREAM.md | 144 +- MoriRemote/project.yml | 44 + 77 files changed, 20186 insertions(+), 58 deletions(-) create mode 100644 MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift create mode 100644 MoriRemote/MoriRemoteTerminal/App/Haptic.swift create mode 100644 MoriRemote/MoriRemoteTerminal/App/TerminalRuntimeTypes.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Domain/TerminalSettings.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyIOSurfaceFrame.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardCursorTrackpad.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardCursorTrackpadHUD.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardVisibilityProjection.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKitControlSurface.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKitRuntime.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurfaceLookup.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPanePreviewSession.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPaneScrollContainerView.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRuntimeSurfaceTopologySnapshot.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyScrollPhysicsView.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceMouseEvent.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceScrollGesture.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceSelectionSheet.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCompositionState.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalDisconnectReasonClassifier.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderFocusPolicy.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalSurfaceInteractionOutcome.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalViewportCoordinator.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxActionTargetResolver.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxPrefixInputBuffer.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyViewportSizing.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/PanePreviewLayout.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/TerminalSelectionSheetStyle.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/DeterministicTmuxControlTransport.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxControlTransport.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxControlViewport.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxPanePreviewImageCache.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeModeTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardVisibilityProjectionTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyKitControlSurfaceTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyScrollDeltaBudgetTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttySurfaceScrollGestureTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCompositionStateTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalPrefixFlushLifecycleTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderFocusPolicyTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderViewTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalSurfaceInteractionOutcomeTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalViewportCoordinatorTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTmuxPrefixInputBufferTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTopLevelSurfaceTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/TmuxSessionControllerClientSizeTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index d60fadd4..e0674f08 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -7,46 +7,128 @@ objects = { /* Begin PBXBuildFile section */ + 01D6EAEBDB1ECC8192C0CA11 /* GhosttyTopLevelSurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */; }; + 04BF2F5618DC52CA420109BF /* GhosttyTerminalResponderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 678146824749C1C540C8D179 /* GhosttyTerminalResponderViewTests.swift */; }; + 055D7CE8194A8EB00C6AB48F /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AF4F5E129749B9F473BA1D /* DeterministicTmuxControlTransport.swift */; }; + 09212452679FFE001555BFCB /* GhosttyTerminalScreenModeling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */; }; + 0A3E5C8D6D19481EDBA77835 /* GhosttyKitControlSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */; }; + 0D9936CC7BE5A981314D56F0 /* GhosttyTopLevelSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.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 */; }; + 160AABFF71D3C5A31ECC918F /* TmuxScreenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20C034CF2CE559BF155AD4 /* TmuxScreenModel.swift */; }; 16DF0B72EA0BA73D616F61D5 /* CitadelSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77CF33F94CEBE45B61807FB /* CitadelSSHTransport.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 */; }; 29E5C06C8FB1718904533EEE /* TmuxControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E335F20D27CE6353A85E4 /* TmuxControl.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 */; }; + 32D5675A41236D9050F9D6FC /* TerminalSelectionSheetStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFD021A4570F40A100E02F4 /* TerminalSelectionSheetStyle.swift */; }; + 3465552A378696C80FABA606 /* TmuxSessionLinkWriteFailureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F848DC8CEFD90068DE779866 /* TmuxSessionLinkWriteFailureTests.swift */; }; 376EEC1B30EE8565C4B085D1 /* MoriRemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F3182F8D251430A10B298E /* MoriRemoteApp.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 */; }; 3D5ABC739FD3170547500C7E /* GhosttyKitABIProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.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 */; }; + 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 */; }; + 52DDD20FA3AEF27F1DFAE907 /* GhosttyTerminalCoreViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20230C5AB4EBA556DBF90A42 /* GhosttyTerminalCoreViewTests.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 */; }; + 560C8AF64E8C5031E37DA781 /* GhosttyPanePreviewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71772BE1F5108FFC309BCC46 /* GhosttyPanePreviewSession.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 */; }; + 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 */; }; + 6A48E864C5F4578CE6968AE3 /* TmuxSessionControllerClientSizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69BE7750EAB869848B155AB4 /* TmuxSessionControllerClientSizeTests.swift */; }; + 6BE7A55C31DA48C075ED4886 /* GhosttyTmuxActionTargetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.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 */; }; + 7608ABD730F2113B6100141F /* TmuxSessionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6FCBEFA092CA08F879265D1 /* TmuxSessionController.swift */; }; 7847C4893AE9B6481BD63CEB /* Phase3RuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */; }; 78FE94F2F5065228E0F8B259 /* Phase2TransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */; }; + 7ACD00781983EB3E3052D10F /* TmuxIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2B40FFD54FA5E83ED47253 /* TmuxIdentity.swift */; }; + 7B1B93EEB1DE05CA1966D556 /* TmuxPaneSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F69592AA4A0788015A2A51 /* TmuxPaneSurface.swift */; }; 7EB9D2C181E73D43B40C9485 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E788C063B75F299CBC1D0165 /* PrivacyInfo.xcprivacy */; }; + 83049E3D188D4C2A9784F9FB /* GhosttyTerminalDisconnectReasonClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */; }; 85ADFC9BAA17017ECA067C5B /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = F391794B759D1B5CD2C36000 /* Citadel */; }; 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 */; }; + 91626D0DA7669135F90E633C /* GhosttySurfaceSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618CC6C670DB54BA190E5827 /* GhosttySurfaceSelectionSheet.swift */; }; + 94A3CF3A125B7DE1A0C08E11 /* PanePreviewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66685A19C08961A3417917DE /* PanePreviewLayout.swift */; }; 950C33367217BF06FA564D2B /* NIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6712048F2C2EC6961F582380 /* NIO */; }; + 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 */; }; + 9A408E6D89845AF1D2306836 /* ActiveSessionSwitcherProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB11F550ECE58BA0965DFC7 /* ActiveSessionSwitcherProjectionTests.swift */; }; + 9F941D728EA6D2D9DDD8E2DC /* GhosttyKeyboardVisibilityProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */; }; + A17B820D2070516F1E059C96 /* GhosttyKeyboardVisibilityProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */; }; A64AA54831409250D2D7AC3A /* GhosttyPaneSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.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 */; }; + B4BB4188870E6A3BD8A84509 /* TerminalRuntimeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */; }; BE15F82CE37D35B536E662ED /* GhosttyTmuxRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */; }; + C07777BEE0CE5B8012A02C74 /* GhosttyTerminalResponderFocusPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62BCDBA618379FE5456A1C57 /* GhosttyTerminalResponderFocusPolicyTests.swift */; }; + C15730869E5A9CF770E3D2CC /* GhosttyTmuxPrefixInputBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */; }; + C22FAD380B109CB1806083A6 /* TmuxPanePreviewImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D155521D2475C24D89AE1677 /* TmuxPanePreviewImageCache.swift */; }; + C34E3D3F89FF5F790D22D0C0 /* TmuxControlViewport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */; }; + C5CA42EC2B7413DB3A326D73 /* GhosttyScrollPhysicsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C290B472A5F07F07F8B1DA7 /* GhosttyScrollPhysicsView.swift */; }; C7B874D9B7973D33D6D7E5D6 /* NIOSSH in Frameworks */ = {isa = PBXBuildFile; productRef = 2B047F037703450AD7431F98 /* NIOSSH */; }; + 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 */; }; DE5D4372E774A67A9B411667 /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */; }; + E75088D081F5457454778A3D /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E9E52C78F643675B58F0BA /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */; }; + E84DA64184225212B72BE0EA /* GhosttyScrollDeltaBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523D89AB51C587C880ABC74F /* GhosttyScrollDeltaBudgetTests.swift */; }; + EA704DB81EBCED68696937A6 /* GhosttyIOSurfaceFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.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 */; }; + F6EB8B544E101B7B7FE4ED5F /* GhosttyRuntimeSurfaceTopologySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.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 */; }; + FCE41412A438AF5DEC891B2E /* ActiveSessionSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36EE2B381CC5804E01C1CD73 /* ActiveSessionSwitcherView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 5BF2A3B9CF68B3969A12FD35 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 04D4F671C0A85A6697E3F07E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9C93F42E4A058106E7F7B7D9; + remoteInfo = MoriRemoteTerminal; + }; F94EA73662EAB769336ACE5F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 04D4F671C0A85A6697E3F07E /* Project object */; @@ -58,45 +140,136 @@ /* Begin PBXFileReference section */ 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 = ""; }; + 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; }; + 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxActionTargetResolver.swift; sourceTree = ""; }; + 1839CAA9C61F3104CB8231C6 /* GhosttyKeyboardCursorTrackpadHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardCursorTrackpadHUD.swift; sourceTree = ""; }; 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalProbe.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 = ""; }; + 36EE2B381CC5804E01C1CD73 /* ActiveSessionSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherView.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 = ""; }; + 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 = ""; }; + 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChrome.swift; sourceTree = ""; }; 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterministicTmuxControlTransport.swift; sourceTree = ""; }; 495654252F6CE455BE0201B3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 4D20C034CF2CE559BF155AD4 /* TmuxScreenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxScreenModel.swift; sourceTree = ""; }; + 4E8CA8956F700B0020F44AED /* GhosttyTerminalViewportCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewportCoordinatorTests.swift; sourceTree = ""; }; + 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRuntimeSurfaceTopologySnapshot.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 = ""; }; + 618CC6C670DB54BA190E5827 /* GhosttySurfaceSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceSelectionSheet.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; }; + 66685A19C08961A3417917DE /* PanePreviewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanePreviewLayout.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 = ""; }; + 69BE7750EAB869848B155AB4 /* TmuxSessionControllerClientSizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionControllerClientSizeTests.swift; sourceTree = ""; }; 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControl.swift; sourceTree = ""; }; 6C511C1314958A8D89FC53C8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 6CB11F550ECE58BA0965DFC7 /* ActiveSessionSwitcherProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherProjectionTests.swift; sourceTree = ""; }; + 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurface.swift; sourceTree = ""; }; + 71772BE1F5108FFC309BCC46 /* GhosttyPanePreviewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPanePreviewSession.swift; sourceTree = ""; }; 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; + 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySingleViewportView.swift; sourceTree = ""; }; + 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurfaceTests.swift; sourceTree = ""; }; 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitRuntime.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 = ""; }; + 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChromeActionsTests.swift; sourceTree = ""; }; 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitABIProbe.swift; sourceTree = ""; }; + 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControlViewport.swift; sourceTree = ""; }; + 8BFD021A4570F40A100E02F4 /* TerminalSelectionSheetStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSelectionSheetStyle.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 = ""; }; 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPaneSurface.swift; sourceTree = ""; }; + 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalScreenModeling.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 = ""; }; + A42139069AA3C15AAA45B5F1 /* TerminalSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettings.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 = ""; }; + A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalDisconnectReasonClassifier.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 = ""; }; + 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 = ""; }; + B6AF4F5E129749B9F473BA1D /* DeterministicTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterministicTmuxControlTransport.swift; sourceTree = ""; }; + B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRuntimeTypes.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 = ""; }; + 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 = ""; }; + BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceKeyEvent.swift; sourceTree = ""; }; + C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardVisibilityProjection.swift; 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 = ""; }; + 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 = ""; }; + D155521D2475C24D89AE1677 /* TmuxPanePreviewImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxPanePreviewImageCache.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 = ""; }; + 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 = ""; }; + F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIOSurfaceFrame.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 = ""; }; /* 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; @@ -132,6 +305,25 @@ path = SSH; sourceTree = ""; }; + 30DDCCC0FF189CEABC730FB5 /* Tmux */ = { + isa = PBXGroup; + children = ( + B6AF4F5E129749B9F473BA1D /* DeterministicTmuxControlTransport.swift */, + CF39528D1FAB67FE7906605F /* GhosttyRuntimeTrace.swift */, + C6CD869EC2CF2DD3481DE8E9 /* TmuxControlTransport.swift */, + 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */, + 8C2B40FFD54FA5E83ED47253 /* TmuxIdentity.swift */, + D155521D2475C24D89AE1677 /* TmuxPanePreviewImageCache.swift */, + A7F69592AA4A0788015A2A51 /* TmuxPaneSurface.swift */, + 4D20C034CF2CE559BF155AD4 /* TmuxScreenModel.swift */, + F6FCBEFA092CA08F879265D1 /* TmuxSessionController.swift */, + 076F9259ECBB8E9DE2737FB2 /* TmuxSessionLink.swift */, + E68FBB36ECEC4F7C77BA31B4 /* TmuxTerminalScreenAdapter.swift */, + B32DC599E9268D13F97F75BC /* TmuxTerminalSession.swift */, + ); + path = Tmux; + sourceTree = ""; + }; 3AA0698F729DC6B09848C13A /* MoriRemote */ = { isa = PBXGroup; children = ( @@ -163,6 +355,34 @@ path = Ghostty; sourceTree = ""; }; + 5C295A7834704597EC4619C0 /* MoriRemoteTerminalTests */ = { + isa = PBXGroup; + children = ( + 6CB11F550ECE58BA0965DFC7 /* ActiveSessionSwitcherProjectionTests.swift */, + 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */, + BE2FE3FEDF880BE280656A2D /* GhosttyKeyboardChromeModeTests.swift */, + EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */, + 991EF1D262BD8AA86A113A21 /* GhosttyKitControlSurfaceTests.swift */, + C87D3C5A05813B59CE76559C /* GhosttyModifierStateTests.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 */, + 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */, + 69BE7750EAB869848B155AB4 /* TmuxSessionControllerClientSizeTests.swift */, + F848DC8CEFD90068DE779866 /* TmuxSessionLinkWriteFailureTests.swift */, + F127EC360B82F2E804AF82D3 /* TmuxTerminalScreenAdapterTests.swift */, + 07F95080D57FCEAFA52CA791 /* TmuxTerminalSessionShutdownDrainTests.swift */, + ); + path = MoriRemoteTerminalTests; + sourceTree = ""; + }; 63BE1FDACD0B6E72C3E5E7E5 /* App */ = { isa = PBXGroup; children = ( @@ -185,6 +405,17 @@ 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 +445,8 @@ isa = PBXGroup; children = ( 3AA0698F729DC6B09848C13A /* MoriRemote */, + 93B99C852B502FC6650738F1 /* MoriRemoteTerminal */, + 5C295A7834704597EC4619C0 /* MoriRemoteTerminalTests */, EC1D22246639767E9C4475A3 /* MoriRemoteTests */, E6A9207E06A0AA3FF84EEAD4 /* Frameworks */, CF688A13389F8A9D4F04A9A0 /* Products */, @@ -224,11 +457,56 @@ isa = PBXGroup; children = ( E6317A1B56EED0B86D2F4D5D /* MoriRemote.app */, + CDAD6C595773E27C2302A0E2 /* MoriRemoteTerminal.framework */, + 11D7B7A9198618BF7CCA853B /* MoriRemoteTerminalTests.xctest */, 64BCCA48638A9FE1582D7514 /* MoriRemoteTests.xctest */, ); name = Products; sourceTree = ""; }; + D08B8BC473350934D03F8E02 /* Ghostty */ = { + isa = PBXGroup; + children = ( + F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.swift */, + 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */, + D5A15305D227B6DD4A6C8CBE /* GhosttyKeyboardCursorTrackpad.swift */, + 1839CAA9C61F3104CB8231C6 /* GhosttyKeyboardCursorTrackpadHUD.swift */, + C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */, + 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */, + 0F28E32D6C407ED02A977516 /* GhosttyKitRuntime.swift */, + 20D69DE7D59B2C5BA8D15339 /* GhosttyManagedSurface.swift */, + B3EBA5074382AC1C70BEB15E /* GhosttyManagedSurfaceLookup.swift */, + DB587FE0CD381E9401F1040A /* GhosttyModifierState.swift */, + 71772BE1F5108FFC309BCC46 /* GhosttyPanePreviewSession.swift */, + 04577A2C3BBC8458C5A60A76 /* GhosttyPaneScrollContainerView.swift */, + 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.swift */, + 3C290B472A5F07F07F8B1DA7 /* GhosttyScrollPhysicsView.swift */, + 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */, + BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */, + 3DF6A6CC81A7E40BF7BB825E /* GhosttySurfaceMouseEvent.swift */, + 44A50387A301B14D8E30A167 /* GhosttySurfaceScrollGesture.swift */, + 618CC6C670DB54BA190E5827 /* GhosttySurfaceSelectionSheet.swift */, + 9BD23FE8E78A324650AA77CC /* GhosttyTerminalCompositionState.swift */, + EC055273524821D3351EF36A /* GhosttyTerminalCoreView.swift */, + A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */, + A597D49CF7A1A57D33B9A7E2 /* GhosttyTerminalInputCoordinator.swift */, + A7D76C58B99537D89078D58B /* GhosttyTerminalPresentationProjector.swift */, + E137716C1A4146436A28685D /* GhosttyTerminalResponderFocusPolicy.swift */, + 1B49983C9FB3783CBF224C23 /* GhosttyTerminalResponderTextInputShim.swift */, + 24733F909F325E7D558F8E31 /* GhosttyTerminalResponderView.swift */, + 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */, + B29878C376F0AD7940205B86 /* GhosttyTerminalSurfaceInteractionOutcome.swift */, + 9659C2FCA72254982C81D686 /* GhosttyTerminalViewportCoordinator.swift */, + 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.swift */, + 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */, + 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */, + 676FDCFD6BAB360A6FDE7151 /* GhosttyViewportSizing.swift */, + 66685A19C08961A3417917DE /* PanePreviewLayout.swift */, + 8BFD021A4570F40A100E02F4 /* TerminalSelectionSheetStyle.swift */, + ); + path = Ghostty; + sourceTree = ""; + }; E6A9207E06A0AA3FF84EEAD4 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -237,6 +515,14 @@ name = Frameworks; sourceTree = ""; }; + E9BDACDAE156BE4F317E876B /* Domain */ = { + isa = PBXGroup; + children = ( + A42139069AA3C15AAA45B5F1 /* TerminalSettings.swift */, + ); + path = Domain; + sourceTree = ""; + }; EC1D22246639767E9C4475A3 /* MoriRemoteTests */ = { isa = PBXGroup; children = ( @@ -250,6 +536,16 @@ path = MoriRemoteTests; sourceTree = ""; }; + FEF282EBB6D60331E0FEA772 /* App */ = { + isa = PBXGroup; + children = ( + 36EE2B381CC5804E01C1CD73 /* ActiveSessionSwitcherView.swift */, + F7595020B1AEF0AE384FF639 /* Haptic.swift */, + B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */, + ); + path = App; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -277,6 +573,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 +609,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 +663,8 @@ projectRoot = ""; targets = ( 219FEED5B577C565EBA81436 /* MoriRemote */, + 9C93F42E4A058106E7F7B7D9 /* MoriRemoteTerminal */, + CC5FEE20D68E195A5B4E8935 /* MoriRemoteTerminalTests */, B6AEABE71E27F93510CA50A8 /* MoriRemoteTests */, ); }; @@ -371,6 +706,65 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 60D1997B92E100918C22BD89 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FCE41412A438AF5DEC891B2E /* ActiveSessionSwitcherView.swift in Sources */, + 055D7CE8194A8EB00C6AB48F /* DeterministicTmuxControlTransport.swift in Sources */, + EA704DB81EBCED68696937A6 /* GhosttyIOSurfaceFrame.swift in Sources */, + 25683F5BC20807C3F9ADE249 /* GhosttyKeyboardChrome.swift in Sources */, + F1D09D202AF835D9ED07031C /* GhosttyKeyboardCursorTrackpad.swift in Sources */, + F4684D11FED84F66904D7C0D /* GhosttyKeyboardCursorTrackpadHUD.swift in Sources */, + 9F941D728EA6D2D9DDD8E2DC /* GhosttyKeyboardVisibilityProjection.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 */, + 560C8AF64E8C5031E37DA781 /* GhosttyPanePreviewSession.swift in Sources */, + D59928A947468A35FBE0FA53 /* GhosttyPaneScrollContainerView.swift in Sources */, + F6EB8B544E101B7B7FE4ED5F /* GhosttyRuntimeSurfaceTopologySnapshot.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 */, + 91626D0DA7669135F90E633C /* GhosttySurfaceSelectionSheet.swift in Sources */, + 2302A7B4A772047379C73067 /* GhosttyTerminalCompositionState.swift in Sources */, + 6D55ADE98CE693CA6802197D /* GhosttyTerminalCoreView.swift in Sources */, + 83049E3D188D4C2A9784F9FB /* GhosttyTerminalDisconnectReasonClassifier.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 */, + 09212452679FFE001555BFCB /* GhosttyTerminalScreenModeling.swift in Sources */, + 4990AE0712D61323469D3EF4 /* GhosttyTerminalSurfaceInteractionOutcome.swift in Sources */, + 3A1491877954A342656AEBF9 /* GhosttyTerminalViewportCoordinator.swift in Sources */, + 6BE7A55C31DA48C075ED4886 /* GhosttyTmuxActionTargetResolver.swift in Sources */, + C15730869E5A9CF770E3D2CC /* GhosttyTmuxPrefixInputBuffer.swift in Sources */, + 0D9936CC7BE5A981314D56F0 /* GhosttyTopLevelSurface.swift in Sources */, + B191529CCEFA8B8A6161B20E /* GhosttyViewportSizing.swift in Sources */, + 31097F77B38B348F9E7E0BB3 /* Haptic.swift in Sources */, + 94A3CF3A125B7DE1A0C08E11 /* PanePreviewLayout.swift in Sources */, + B4BB4188870E6A3BD8A84509 /* TerminalRuntimeTypes.swift in Sources */, + 32D5675A41236D9050F9D6FC /* TerminalSelectionSheetStyle.swift in Sources */, + ACE22CEBB527F6CDA6C44470 /* TerminalSettings.swift in Sources */, + AE2B7491776C8FC853899464 /* TmuxControlTransport.swift in Sources */, + C34E3D3F89FF5F790D22D0C0 /* TmuxControlViewport.swift in Sources */, + 7ACD00781983EB3E3052D10F /* TmuxIdentity.swift in Sources */, + C22FAD380B109CB1806083A6 /* TmuxPanePreviewImageCache.swift in Sources */, + 7B1B93EEB1DE05CA1966D556 /* TmuxPaneSurface.swift in Sources */, + 160AABFF71D3C5A31ECC918F /* TmuxScreenModel.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; @@ -414,6 +808,34 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D8FB46F5FAA72D03EC61EB9D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9A408E6D89845AF1D2306836 /* ActiveSessionSwitcherProjectionTests.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 */, + 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 */, + 01D6EAEBDB1ECC8192C0CA11 /* GhosttyTopLevelSurfaceTests.swift in Sources */, + 6A48E864C5F4578CE6968AE3 /* TmuxSessionControllerClientSizeTests.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 +844,11 @@ target = 219FEED5B577C565EBA81436 /* MoriRemote */; targetProxy = F94EA73662EAB769336ACE5F /* PBXContainerItemProxy */; }; + F0CE8679B7360DD5B9C75B49 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9C93F42E4A058106E7F7B7D9 /* MoriRemoteTerminal */; + targetProxy = 5BF2A3B9CF68B3969A12FD35 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -456,6 +883,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 = { @@ -551,6 +1000,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 = { @@ -683,6 +1226,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 +1244,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/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift b/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift new file mode 100644 index 00000000..ddbe1073 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// Account-free projection used by the terminal shell. Mori's profile model is +/// intentionally adapted at the Phase-2 composition boundary, not imported. +struct ActiveSessionSwitcherItem: Identifiable, Equatable { + let id: UUID + let sessionName: String + let subtitle: String + let runtimeState: TerminalRuntimeState + let isSelected: Bool + let lastOpenedAt: Date +} + +enum ActiveSessionSwitcherProjection { + static func items(_ sessions: [ActiveSessionSwitcherItem]) -> [ActiveSessionSwitcherItem] { + sessions.sorted { + if $0.isSelected != $1.isSelected { return $0.isSelected } + return $0.lastOpenedAt > $1.lastOpenedAt + } + } +} + +struct ActiveSessionSwitcherView: View { + @Environment(\.dismiss) private var dismiss + let sessions: [ActiveSessionSwitcherItem] + let onSelectSession: (UUID) -> Void + let onDisconnectSession: (UUID) -> Void + + var body: some View { + List(ActiveSessionSwitcherProjection.items(sessions)) { session in + Button { + onSelectSession(session.id) + dismiss() + } label: { + VStack(alignment: .leading) { + Text(session.sessionName) + Text(session.subtitle).font(.footnote).foregroundStyle(.secondary) + } + } + .swipeActions { + Button(role: .destructive) { onDisconnectSession(session.id) } label: { + Label("Disconnect", systemImage: "bolt.slash") + } + } + } + } +} 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/TerminalRuntimeTypes.swift b/MoriRemote/MoriRemoteTerminal/App/TerminalRuntimeTypes.swift new file mode 100644 index 00000000..5e77a297 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/App/TerminalRuntimeTypes.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Terminal-only status vocabulary. Transport/account policy intentionally stays +/// outside the transplant until Phase 2 supplies a Mori-owned composition root. +struct TerminalDisconnectReason: Equatable, Sendable { + enum Kind: Equatable, Sendable { case transportIO, remoteExit, runtime, unknown } + let kind: Kind + let message: String +} + +enum TerminalRuntimeState: Equatable, Sendable { + case connecting + case connected + case disconnected(TerminalDisconnectReason) +} + +enum GhosttyTerminalRuntimePhase: Equatable, Sendable { + case idle + case starting + case running + case failed(message: String, reason: TerminalDisconnectReason?) +} 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/GhosttyIOSurfaceFrame.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyIOSurfaceFrame.swift new file mode 100644 index 00000000..7e0ee2d3 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyIOSurfaceFrame.swift @@ -0,0 +1,129 @@ +import CoreGraphics +import Foundation +import IOSurface +import QuartzCore + +/// Swift-owned pixels copied from one frame already published by Ghostty's +/// IOSurface renderer layer. Reading never asks the renderer to draw. +struct GhosttyIOSurfaceFrame: Sendable { + enum ReadError: Error { + case lockFailed + case invalidGeometry + case unsupportedPixelFormat(UInt32) + case imageCreationFailed + } + + private static let bgraPixelFormat: UInt32 = 0x4247_5241 + + let width: Int + let height: Int + let bytesPerRow: Int + let bytes: Data + + static func rendererLayer(in viewLayer: CALayer) -> CALayer? { + let layers = viewLayer.sublayers ?? [] + if let published = layers.first(where: { iosurface(from: $0) != nil }) { + return published + } + // Ghostty's iOS Metal renderer installs exactly one direct sublayer. + // Before its first frame that layer has no contents yet. + 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)) + } + + /// Copies the currently published IOSurface while the caller still owns + /// the layer publication boundary. Retaining the IOSurface is not enough: + /// Metal reuses its fixed frame targets, so later draws may overwrite the + /// same allocation even while another owner holds a CF reference. + static func read(from layer: CALayer) throws -> Self { + guard let surface = iosurface(from: layer) else { + throw ReadError.invalidGeometry + } + guard IOSurfaceLock(surface, .readOnly, nil) == 0 else { + throw ReadError.lockFailed + } + defer { IOSurfaceUnlock(surface, .readOnly, nil) } + + let width = IOSurfaceGetWidth(surface) + let height = IOSurfaceGetHeight(surface) + let bytesPerRow = IOSurfaceGetBytesPerRow(surface) + guard width > 0, height > 0, bytesPerRow >= width * 4 else { + throw ReadError.invalidGeometry + } + let optionalBase: UnsafeMutableRawPointer? = IOSurfaceGetBaseAddress(surface) + guard let base = optionalBase else { + throw ReadError.invalidGeometry + } + let pixelFormat = IOSurfaceGetPixelFormat(surface) + guard pixelFormat == bgraPixelFormat else { + throw ReadError.unsupportedPixelFormat(pixelFormat) + } + let (byteCount, overflow) = bytesPerRow.multipliedReportingOverflow(by: height) + guard !overflow else { throw ReadError.invalidGeometry } + return Self( + width: width, + height: height, + bytesPerRow: bytesPerRow, + bytes: Data(bytes: base, count: byteCount) + ) + } + + func image(maxWidth: UInt32, maxHeight: UInt32) throws -> CGImage { + guard maxWidth > 0, maxHeight > 0, + let provider = CGDataProvider(data: bytes as CFData), + let source = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: bytesPerRow, + space: Self.colorSpace, + bitmapInfo: Self.bitmapInfo, + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) + else { throw ReadError.imageCreationFailed } + + let scale = min( + 1, + min(Double(maxWidth) / Double(width), Double(maxHeight) / Double(height)) + ) + guard scale < 1 else { return source } + + let targetWidth = max(1, Int((Double(width) * scale).rounded(.down))) + let targetHeight = max(1, Int((Double(height) * scale).rounded(.down))) + let targetBytesPerRow = targetWidth * 4 + guard let context = CGContext( + data: nil, + width: targetWidth, + height: targetHeight, + bitsPerComponent: 8, + bytesPerRow: targetBytesPerRow, + space: Self.colorSpace, + bitmapInfo: Self.bitmapInfo.rawValue + ) else { throw ReadError.imageCreationFailed } + context.interpolationQuality = .medium + context.draw(source, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)) + guard let image = context.makeImage() else { throw ReadError.imageCreationFailed } + return image + } + + private static let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) + ?? CGColorSpaceCreateDeviceRGB() + private static let bitmapInfo = CGBitmapInfo.byteOrder32Little.union( + CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipFirst.rawValue) + ) + + 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/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift new file mode 100644 index 00000000..4722e683 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -0,0 +1,141 @@ +import SwiftUI + +/// Upstream-compatible keyboard intent. The Phase-1 bar intentionally omits +/// composer and shortcut-marketplace actions, not terminal controls. +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 } + +/// The semantic, testable action boundary behind the retained upstream dock. +/// It has no account, composer, or shortcut-store dependency. +struct GhosttyKeyboardChromeActions { + let showSessions: () -> Void + let showWindows: () -> Void + let showPanes: () -> Void + let toggleKeyboard: () -> Void + let toggleControl: () -> Void + let sendKey: (GhosttySurfaceKeyEvent) -> Bool + + func perform(_ action: Action) -> Bool { + switch action { + case .sessions: showSessions(); return true + case .windows: showWindows(); return true + case .panes: showPanes(); return true + case .keyboard: toggleKeyboard(); return true + case .control: toggleControl(); return true + case .escape: return sendKey(.init(keyCode: .escape)) + case .tab: return sendKey(.init(keyCode: .tab)) + } + } + + enum Action { case sessions, windows, panes, keyboard, control, escape, tab } +} + +/// The retained terminal portion of remux's keyboard chrome. It keeps Ctrl, +/// Esc, Tab, session/window/pane selectors, and system-keyboard control. +struct GhosttyKeyboardChrome: View { + let keyboardMode: GhosttyKeyboardChromeMode + let isEnabled: Bool + let isCompact: Bool + let isControlArmed: Bool + let windowCount: Int + let paneCount: Int + let actions: GhosttyKeyboardChromeActions + + var body: some View { + HStack(spacing: isCompact ? 6 : 10) { + group { + key("ctrl", id: "terminal.ctrl", active: isControlArmed) { actions.perform(.control) } + key("esc", id: "terminal.esc") { actions.perform(.escape) } + key("tab", id: "terminal.tab") { actions.perform(.tab) } + } + group { + icon("rectangle.stack", id: "terminal.sessions", label: "Sessions") { actions.perform(.sessions) } + icon("rectangle.on.rectangle", id: "terminal.windows", label: "Windows", enabled: windowCount > 0) { actions.perform(.windows) } + icon("square.split.2x1", id: "terminal.panes", label: "Panes", enabled: paneCount > 0) { actions.perform(.panes) } + } + group { + icon("keyboard", id: "terminal.keyboard", label: keyboardMode == .hidden ? "Show keyboard" : "Hide keyboard") { actions.perform(.keyboard) } + } + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + } + + private func group(@ViewBuilder _ content: () -> Content) -> some View { + HStack(spacing: 2, content: content) + .padding(4) + .background(.thinMaterial, in: Capsule()) + } + + private func key(_ title: String, id: String, active: Bool = false, action: @escaping () -> Bool) -> some View { + Button { _ = action() } label: { Text(title).font(.system(size: 12, weight: .semibold)) } + .buttonStyle(ChromeButtonStyle(active: active)) + .accessibilityIdentifier(id) + .disabled(!isEnabled) + } + + private func icon(_ name: String, id: String, label: String, enabled: Bool = true, action: @escaping () -> Bool) -> some View { + Button { _ = action() } label: { Image(systemName: name).font(.system(size: 16, weight: .semibold)) } + .buttonStyle(ChromeButtonStyle(active: id == "terminal.keyboard" && keyboardMode == .system)) + .accessibilityLabel(label) + .accessibilityIdentifier(id) + .disabled(!isEnabled || !enabled) + } +} + +private struct ChromeButtonStyle: ButtonStyle { + let active: Bool + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: GhosttyKeyboardChromeSizing.dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight) + .foregroundStyle(active ? Color.accentColor : Color.primary) + .background(active ? Color.accentColor.opacity(0.18) : Color.clear, in: RoundedRectangle(cornerRadius: GhosttyKeyboardChromeSizing.dockButtonCornerRadius, style: .continuous)) + .opacity(configuration.isPressed ? 0.65 : 1) + } +} + +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/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..bd5a5207 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift @@ -0,0 +1,207 @@ +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 + } + + func sendPasteAwaitingCommandCompletion(_ text: String) async -> Bool { + guard !text.isEmpty, let paneOwner else { return false } + return await paneOwner.sendPasteAwaitingCommandCompletion(text) + } + + @discardableResult + func sendKeyEvent(_ event: GhosttySurfaceKeyEvent) -> FocusedTerminalInputSubmissionResult { + guard controlSurface.sendKeyEvent(event) else { return .surfaceRejected } + onLocalSelectionGeometryChange?() + return .accepted + } + + func sendKeyEventAwaitingCommandCompletion( + _ event: GhosttySurfaceKeyEvent + ) async -> Bool { + guard let paneOwner else { return false } + let delivered = await paneOwner.sendKeyEventAwaitingCommandCompletion(event) + if delivered { + onLocalSelectionGeometryChange?() + } + return delivered + } + + 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..a203af53 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift @@ -0,0 +1,73 @@ +import Foundation + +struct GhosttyModifierState: Equatable { + private(set) var controlArmed = false + + var isControlArmed: Bool { + controlArmed + } + + mutating func toggleControl() { + controlArmed.toggle() + } + + mutating func clearControl() { + controlArmed = false + } + + mutating func apply(to text: String) -> String { + guard controlArmed else { return text } + defer { controlArmed = false } + return Self.controlText(for: text) ?? text + } + + mutating func apply(to event: GhosttySurfaceKeyEvent) -> GhosttySurfaceKeyEvent { + guard controlArmed else { return event } + defer { controlArmed = false } + + return GhosttySurfaceKeyEvent( + action: event.action, + keyCode: event.keyCode, + text: event.text, + composing: event.composing, + mods: event.mods.union(.ctrl), + 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/GhosttyPanePreviewSession.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPanePreviewSession.swift new file mode 100644 index 00000000..980d6e94 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPanePreviewSession.swift @@ -0,0 +1,206 @@ +import CoreGraphics +import Foundation + +/// Picker-scoped cached image state plus one sequential asynchronous capture +/// task. Native renderer and terminal lifetime remain owned by the tmux +/// session; this type owns no handles, callbacks, retries, or render queue. +@MainActor +final class GhosttyPanePreviewSession: ObservableObject { + struct FullViewportProvenance: Equatable { + let surfaceID: UUID + let pixelWidth: UInt32 + let pixelHeight: UInt32 + } + + struct PaneGeometryProvenance: Equatable { + let surfaceID: UUID + let columns: UInt32 + let rows: UInt32 + } + + enum PreviewSource: Equatable { + case paneGeometry(PaneGeometryProvenance) + case fullViewport(FullViewportProvenance) + } + + struct RenderedPreview { + let image: CGImage + let source: PreviewSource + } + + struct PixelBudget: Equatable, Sendable { + let width: UInt32 + let height: UInt32 + } + + struct PreviewClient { + let capture: @MainActor (UUID, PixelBudget) async -> RenderedPreview? + let cancelCapture: @MainActor (UUID) -> Void + let cachedPreview: @MainActor (UUID) -> RenderedPreview? + let shouldRefreshCachedImage: @MainActor (UUID) -> Bool + let cacheRenderedPreview: @MainActor (UUID, RenderedPreview) -> Void + + init( + capture: @escaping @MainActor (UUID, PixelBudget) async -> RenderedPreview?, + cancelCapture: @escaping @MainActor (UUID) -> Void = { _ in }, + cachedPreview: @escaping @MainActor (UUID) -> RenderedPreview? = { _ in nil }, + shouldRefreshCachedImage: @escaping @MainActor (UUID) -> Bool = { _ in true }, + cacheRenderedPreview: @escaping @MainActor (UUID, RenderedPreview) -> Void = { _, _ in } + ) { + self.capture = capture + self.cancelCapture = cancelCapture + self.cachedPreview = cachedPreview + self.shouldRefreshCachedImage = shouldRefreshCachedImage + self.cacheRenderedPreview = cacheRenderedPreview + } + } + + enum PreviewSizing: Equatable { + case paneGrid(availableWidth: CGFloat) + case windowGrid(availableWidth: CGFloat) + + @MainActor + static var paneGridForCurrentScreen: PreviewSizing { + .paneGrid(availableWidth: PanePreviewLayout.currentSheetContentWidth()) + } + + @MainActor + static var windowGridForCurrentScreen: PreviewSizing { + .windowGrid(availableWidth: PanePreviewLayout.currentSheetContentWidth()) + } + } + + enum PreviewState { + case pending + case ready(RenderedPreview) + case failed + } + + let id = UUID() + @Published private(set) var imagesByPaneID: [UUID: PreviewState] = [:] + + private let displayScale: CGFloat + private let previewSizing: PreviewSizing + private let client: PreviewClient + private var trackedLeafIDs: [UUID] + private var refreshTask: Task? + private var didStartRefreshing = false + private var cancelled = false + private var generation: UInt64 = 0 + private var activeCaptureLeafID: UUID? + + init( + leafIDs: [UUID], + scale: CGFloat = PanePreviewLayout.currentScale(), + previewSizing: PreviewSizing? = nil, + client: PreviewClient + ) { + displayScale = scale + self.previewSizing = previewSizing ?? .paneGridForCurrentScreen + self.client = client + trackedLeafIDs = Self.unique(leafIDs) + seedCachedImages(for: trackedLeafIDs) + } + + func startRefreshing() { + guard !didStartRefreshing, !cancelled else { return } + didStartRefreshing = true + restartRefresh() + } + + func reconcile(leafIDs: [UUID]) { + let next = Self.unique(leafIDs) + let nextSet = Set(next) + for removed in imagesByPaneID.keys where !nextSet.contains(removed) { + imagesByPaneID.removeValue(forKey: removed) + } + trackedLeafIDs = next + seedCachedImages(for: next) + guard didStartRefreshing, !cancelled else { return } + restartRefresh() + } + + func cancelAll() { + guard !cancelled else { return } + cancelled = true + generation &+= 1 + cancelActiveCapture() + refreshTask?.cancel() + refreshTask = nil + } + + private func restartRefresh() { + generation &+= 1 + let currentGeneration = generation + let leafIDs = trackedLeafIDs + let budget = pixelBudget(itemCount: max(leafIDs.count, 1)) + cancelActiveCapture() + refreshTask?.cancel() + refreshTask = Task { @MainActor [weak self] in + guard let self else { return } + for leafID in leafIDs { + guard !Task.isCancelled, + !cancelled, + generation == currentGeneration, + trackedLeafIDs.contains(leafID) + else { return } + + let cached = client.cachedPreview(leafID) + if let cached { imagesByPaneID[leafID] = .ready(cached) } + guard cached == nil || client.shouldRefreshCachedImage(leafID) else { continue } + if cached == nil { imagesByPaneID[leafID] = .pending } + + activeCaptureLeafID = leafID + let preview = await client.capture(leafID, budget) + if activeCaptureLeafID == leafID { activeCaptureLeafID = nil } + guard !Task.isCancelled, + !cancelled, + generation == currentGeneration, + trackedLeafIDs.contains(leafID) + else { return } + if let preview { + client.cacheRenderedPreview(leafID, preview) + imagesByPaneID[leafID] = .ready(preview) + } else if cached == nil { + imagesByPaneID[leafID] = .failed + } + } + } + } + + private func cancelActiveCapture() { + guard let leafID = activeCaptureLeafID else { return } + activeCaptureLeafID = nil + client.cancelCapture(leafID) + } + + private func seedCachedImages(for leafIDs: [UUID]) { + for leafID in leafIDs where imagesByPaneID[leafID] == nil { + if let cached = client.cachedPreview(leafID) { + imagesByPaneID[leafID] = .ready(cached) + } + } + } + + private func pixelBudget(itemCount: Int) -> PixelBudget { + let dimensions: (width: UInt32, height: UInt32) = switch previewSizing { + case .paneGrid(let availableWidth): + PanePreviewLayout.physicalPixelBudget( + paneCount: itemCount, + availableWidth: availableWidth, + scale: displayScale + ) + case .windowGrid(let availableWidth): + PanePreviewLayout.windowPhysicalPixelBudget( + availableWidth: availableWidth, + scale: displayScale + ) + } + return PixelBudget(width: dimensions.width, height: dimensions.height) + } + + private static func unique(_ leafIDs: [UUID]) -> [UUID] { + var seen: Set = [] + return leafIDs.filter { seen.insert($0).inserted } + } +} 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/GhosttyRuntimeSurfaceTopologySnapshot.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRuntimeSurfaceTopologySnapshot.swift new file mode 100644 index 00000000..f6c40677 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRuntimeSurfaceTopologySnapshot.swift @@ -0,0 +1,36 @@ +import Foundation + +struct GhosttyRuntimeSurfaceTopologySnapshot: Equatable { + static var empty: GhosttyRuntimeSurfaceTopologySnapshot { + GhosttyRuntimeSurfaceTopologySnapshot( + topLevels: [], + selectedTopLevelID: nil + ) + } + + let topLevels: [GhosttyTopLevelSurface] + let selectedTopLevelID: UUID? + let selectedTopLevel: GhosttyTopLevelSurface? + let selectedTopLevelIndex: Int? + + init( + topLevels: [GhosttyTopLevelSurface], + selectedTopLevelID: UUID? + ) { + self.topLevels = topLevels + self.selectedTopLevelID = selectedTopLevelID + + guard let selectedTopLevelID, + let selectedTopLevelIndex = topLevels.firstIndex(where: { $0.id == selectedTopLevelID }) + else { + self.selectedTopLevel = nil + self.selectedTopLevelIndex = nil + return + } + + let selectedTopLevel = topLevels[selectedTopLevelIndex] + + self.selectedTopLevel = selectedTopLevel + self.selectedTopLevelIndex = selectedTopLevelIndex + } +} 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..29caa4e1 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift @@ -0,0 +1,850 @@ +import GhosttyKit +import SwiftUI +import UIKit + +/// Hosts the single terminal surface presented by Remux. Pane/window topology belongs to +/// the picker model; it never participates in 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() + } + GhosttyRuntimeTrace.flowEndIfActive( + GhosttyRuntimeTrace.paneSwitchFlow, + event: "presentation.reveal.ready", + fields: [ + "surface_uuid": surface.id.uuidString, + "wall_ns": "\(GhosttyRuntimeTrace.wallNanos())", + ] + ) + 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..a0855113 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift @@ -0,0 +1,117 @@ +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) + } + + 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/GhosttySurfaceSelectionSheet.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceSelectionSheet.swift new file mode 100644 index 00000000..c42b5cff --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceSelectionSheet.swift @@ -0,0 +1,709 @@ +import SwiftUI + +enum GhosttySurfaceSelectionSheet: Identifiable { + case windows(GhosttyPanePreviewSession) + case panes(topLevelID: UUID, previews: GhosttyPanePreviewSession) + + var id: String { + switch self { + case .windows(_): + "windows" + case .panes(let topLevelID, let previews): + "panes-\(topLevelID.uuidString)-\(previews.id.uuidString)" + } + } + + var paneTopLevelIDForTopologyValidation: UUID? { + switch self { + case .windows(_): + nil + case .panes(let topLevelID, _): + topLevelID + } + } +} + +struct GhosttyWindowSelectionSheet: View { + @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + @ObservedObject var session: GhosttyPanePreviewSession + @State private var pendingRemoval: GhosttyWindowRemovalRequest? + @State private var pendingContextAction: GhosttyWindowRemovalRequest? + + let projection: GhosttyWindowSelectionSheetRenderProjection + let sessionName: String + let onCreateWindow: (() -> Void)? + let onSelect: (UUID) -> Void + let onRemoveWindow: (UUID) -> Void + + var body: some View { + let layout = PanePreviewLayout.windowMetricsForCurrentScreen() + + TerminalSelectionSheetScaffold( + title: "Windows", + context: "\(sessionName) · \(projection.windows.count) \(projection.windows.count == 1 ? "window" : "windows")", + closeAccessibilityIdentifier: "terminal.windows.close" + ) { + ScrollView(showsIndicators: false) { + windowGrid( + windows: projection.windows, + layout: layout + ) + } + .accessibilityIdentifier("terminal.windows.scroll") + .contentMargins(.horizontal, 16, for: .scrollContent) + } actions: { + TerminalSelectionSheetActionButton( + title: "New Window", + systemName: "plus", + accessibilityIdentifier: "terminal.window.new", + action: onCreateWindow + ) + } + .task(id: session.id) { + session.reconcile(leafIDs: projection.previewLeafIDs) + await Task.yield() + guard !Task.isCancelled else { return } + GhosttyRuntimeTrace.perf("panePreview.presentation activate kind=windows") + session.startRefreshing() + } + .onChange(of: projection.previewLeafIDs) { _, newValue in + session.reconcile(leafIDs: newValue) + } + .overlayPreferenceValue(GhosttySelectionTileBoundsPreferenceKey.self) { bounds in + GhosttySelectionContextActionOverlay( + bounds: bounds, + action: pendingContextAction.map { + GhosttySelectionContextActionPresentation( + id: $0.id, + title: "Remove Window \($0.displayIndex)", + accessibilityIdentifier: "terminal.window.remove.\($0.displayIndex)" + ) + }, + perform: confirmPendingContextAction, + dismiss: dismissPendingContextAction + ) + } + .confirmationDialog( + "Remove Window?", + isPresented: pendingRemovalBinding, + titleVisibility: .visible, + presenting: pendingRemoval + ) { request in + Button("Remove Window \(request.displayIndex)", role: .destructive) { + onRemoveWindow(request.id) + pendingRemoval = nil + } + .accessibilityIdentifier("terminal.window.remove.confirm.\(request.displayIndex)") + } message: { request in + Text(windowRemovalMessage(for: request)) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("terminal.windows.sheet") + } + + private func windowGrid( + windows: [GhosttyWindowSelectionSheetRenderProjection.Window], + layout: PanePreviewLayout.Metrics + ) -> some View { + LazyVGrid( + columns: Array( + repeating: GridItem(.fixed(layout.tilePointSize.width), spacing: layout.gridSpacing), + count: layout.columnCount + ), + alignment: .center, + spacing: layout.gridSpacing + ) { + ForEach(windows) { window in + Button { + Haptic.selection() + onSelect(window.id) + } label: { + GhosttyWindowSelectionTile( + displayIndex: window.displayIndex, + displayName: window.displayName, + totalCount: window.totalCount, + paneCount: window.paneCount, + isSelected: window.isSelected, + previewState: window.focusedPreviewPaneID + .flatMap { session.imagesByPaneID[$0] }, + chromeStyle: chromeStyle, + layout: layout + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("terminal.window.tile.\(window.displayIndex)") + .anchorPreference(key: GhosttySelectionTileBoundsPreferenceKey.self, value: .bounds) { + [window.id: $0] + } + .highPriorityGesture( + LongPressGesture(minimumDuration: 0.42, maximumDistance: 18) + .onEnded { _ in + Haptic.warning() + pendingContextAction = GhosttyWindowRemovalRequest( + id: window.id, + displayIndex: window.displayIndex, + paneCount: window.paneCount + ) + } + ) + .accessibilityAction(named: Text("Remove Window \(window.displayIndex)")) { + Haptic.warning() + pendingRemoval = GhosttyWindowRemovalRequest( + id: window.id, + displayIndex: window.displayIndex, + paneCount: window.paneCount + ) + } + } + + } + .frame(maxWidth: .infinity, alignment: .top) + } + + private var pendingRemovalBinding: Binding { + Binding( + get: { pendingRemoval != nil }, + set: { isPresented in + if !isPresented { + pendingRemoval = nil + pendingContextAction = nil + } + } + ) + } + + private func confirmPendingContextAction() { + pendingRemoval = pendingContextAction + pendingContextAction = nil + } + + private func dismissPendingContextAction() { + pendingContextAction = nil + } + + private func windowRemovalMessage(for request: GhosttyWindowRemovalRequest) -> String { + "This will close Window \(request.displayIndex) and \(request.paneCount) \(request.paneCount == 1 ? "pane" : "panes")." + } +} + +struct GhosttyPaneSelectionSheet: View { + @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + @ObservedObject var session: GhosttyPanePreviewSession + @State private var pendingRemoval: GhosttyPaneRemovalRequest? + @State private var pendingContextAction: GhosttyPaneRemovalRequest? + + let projection: GhosttyPaneSelectionSheetRenderProjection + let onSplitPane: (() -> Void)? + let onStackPane: (() -> Void)? + let onSelect: (UUID) -> Void + let onRemovePane: (UUID) -> Void + + var body: some View { + let layout = PanePreviewLayout.metricsForCurrentScreen(for: projection.paneCount) + + TerminalSelectionSheetScaffold( + title: "Panes", + context: "\(projection.paneCount) \(projection.paneCount == 1 ? "pane" : "panes")", + closeAccessibilityIdentifier: "terminal.panes.close" + ) { + ScrollView(showsIndicators: false) { + paneLayout( + panes: projection.panes, + layout: layout, + onRemove: { pane in + pendingContextAction = GhosttyPaneRemovalRequest( + id: pane.id, + displayIndex: pane.displayIndex, + isOnlyPane: projection.paneCount == 1 + ) + } + ) + } + .accessibilityIdentifier("terminal.panes.scroll") + .contentMargins(.horizontal, 16, for: .scrollContent) + } actions: { + HStack(spacing: 10) { + TerminalSelectionSheetActionButton( + title: "Split", + systemName: "square.split.2x1", + accessibilityIdentifier: "terminal.pane.split", + action: onSplitPane + ) + + TerminalSelectionSheetActionButton( + title: "Stack", + systemName: "square.split.1x2", + accessibilityIdentifier: "terminal.pane.stack", + action: onStackPane + ) + } + } + .task(id: session.id) { + // First-render reconcile closes the gap between tap-time session + // creation and the sheet's initial body render. If pane + // membership changed during presentation, the session must align + // immediately with the leaf IDs the sheet is actually showing. + session.reconcile(leafIDs: projection.previewLeafIDs) + await Task.yield() + guard !Task.isCancelled else { return } + GhosttyRuntimeTrace.perf("panePreview.presentation activate kind=panes") + session.startRefreshing() + } + .onChange(of: projection.previewLeafIDs) { _, newValue in + session.reconcile(leafIDs: newValue) + } + .overlayPreferenceValue(GhosttySelectionTileBoundsPreferenceKey.self) { bounds in + GhosttySelectionContextActionOverlay( + bounds: bounds, + action: pendingContextAction.map { + GhosttySelectionContextActionPresentation( + id: $0.id, + title: "Remove Pane \($0.displayIndex)", + accessibilityIdentifier: "terminal.pane.remove.\($0.displayIndex)" + ) + }, + perform: confirmPendingContextAction, + dismiss: dismissPendingContextAction + ) + } + .confirmationDialog( + "Remove Pane?", + isPresented: pendingRemovalBinding, + titleVisibility: .visible, + presenting: pendingRemoval + ) { request in + Button("Remove Pane \(request.displayIndex)", role: .destructive) { + onRemovePane(request.id) + pendingRemoval = nil + } + .accessibilityIdentifier("terminal.pane.remove.confirm.\(request.displayIndex)") + } message: { request in + Text(paneRemovalMessage(for: request)) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("terminal.panes.sheet") + } + + private func paneLayout( + panes: [GhosttyPaneSelectionSheetRenderProjection.Pane], + layout: PanePreviewLayout.Metrics, + onRemove: @escaping (GhosttyPaneSelectionSheetRenderProjection.Pane) -> Void + ) -> some View { + LazyVGrid( + columns: Array( + repeating: GridItem(.fixed(layout.tilePointSize.width), spacing: layout.gridSpacing), + count: layout.columnCount + ), + alignment: .center, + spacing: layout.gridSpacing + ) { + ForEach(panes) { pane in + Button { + Haptic.selection() + onSelect(pane.id) + } label: { + GhosttyPaneSelectionTile( + displayIndex: pane.displayIndex, + totalCount: pane.totalCount, + isSelected: pane.isSelected, + state: session.imagesByPaneID[pane.id], + chromeStyle: chromeStyle, + layout: layout + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("terminal.pane.tile.\(pane.displayIndex)") + .anchorPreference(key: GhosttySelectionTileBoundsPreferenceKey.self, value: .bounds) { + [pane.id: $0] + } + .highPriorityGesture( + LongPressGesture(minimumDuration: 0.42, maximumDistance: 18) + .onEnded { _ in + Haptic.warning() + onRemove(pane) + } + ) + .accessibilityAction(named: Text("Remove Pane \(pane.displayIndex)")) { + Haptic.warning() + pendingRemoval = GhosttyPaneRemovalRequest( + id: pane.id, + displayIndex: pane.displayIndex, + isOnlyPane: pane.totalCount == 1 + ) + } + } + } + .frame(maxWidth: .infinity, alignment: .top) + } + + private var pendingRemovalBinding: Binding { + Binding( + get: { pendingRemoval != nil }, + set: { isPresented in + if !isPresented { + pendingRemoval = nil + pendingContextAction = nil + } + } + ) + } + + private func confirmPendingContextAction() { + pendingRemoval = pendingContextAction + pendingContextAction = nil + } + + private func dismissPendingContextAction() { + pendingContextAction = nil + } + + private func paneRemovalMessage(for request: GhosttyPaneRemovalRequest) -> String { + if request.isOnlyPane { + return "This is the only pane in the window, so removing it can close the window too." + } + return "This will close Pane \(request.displayIndex)." + } +} + +private struct GhosttyWindowRemovalRequest: Identifiable { + let id: UUID + let displayIndex: Int + let paneCount: Int +} + +private struct GhosttyPaneRemovalRequest: Identifiable { + let id: UUID + let displayIndex: Int + let isOnlyPane: Bool +} + +private struct GhosttySelectionContextActionPresentation: Identifiable, Equatable { + let id: UUID + let title: String + let accessibilityIdentifier: String +} + +private struct GhosttySelectionTileBoundsPreferenceKey: PreferenceKey { + static let defaultValue: [UUID: Anchor] = [:] + + static func reduce(value: inout [UUID: Anchor], nextValue: () -> [UUID: Anchor]) { + value.merge(nextValue(), uniquingKeysWith: { _, newValue in newValue }) + } +} + +private struct GhosttySelectionContextActionOverlay: View { + let bounds: [UUID: Anchor] + let action: GhosttySelectionContextActionPresentation? + let perform: () -> Void + let dismiss: () -> Void + + var body: some View { + GeometryReader { proxy in + if let action, let anchor = bounds[action.id] { + let tileFrame = proxy[anchor] + + ZStack { + Color.black.opacity(0.001) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture(perform: dismiss) + + GhosttySelectionContextActionButton( + title: action.title, + accessibilityIdentifier: action.accessibilityIdentifier, + action: perform + ) + .position(actionPosition(for: tileFrame, in: proxy.size)) + .transition(.scale(scale: 0.94).combined(with: .opacity)) + } + .animation(.spring(response: 0.24, dampingFraction: 0.82), value: action) + } + } + } + + private func actionPosition(for tileFrame: CGRect, in containerSize: CGSize) -> CGPoint { + let actionSize = GhosttySelectionContextActionButton.metrics.size + let edgeMargin: CGFloat = 10 + let cornerInset: CGFloat = 18 + let x = min( + max(tileFrame.maxX - cornerInset, actionSize.width / 2 + edgeMargin), + containerSize.width - actionSize.width / 2 - edgeMargin + ) + let y = min( + max(tileFrame.minY + cornerInset, actionSize.height / 2 + edgeMargin), + containerSize.height - actionSize.height / 2 - edgeMargin + ) + return CGPoint(x: x, y: y) + } +} + +private struct GhosttySelectionContextActionButton: View { + struct Metrics { + let size = CGSize(width: 44, height: 44) + } + + static let metrics = Metrics() + + let title: String + let accessibilityIdentifier: String + let action: () -> Void + + var body: some View { + Button { + Haptic.tap() + action() + } label: { + Image(systemName: "trash") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(GhosttySelectionContextActionPalette.destructiveText) + .frame(width: Self.metrics.size.width, height: Self.metrics.size.height) + .ghosttySelectionContextActionSurface() + } + .buttonStyle(GhosttySelectionContextActionButtonStyle()) + .accessibilityIdentifier(accessibilityIdentifier) + .accessibilityLabel(title) + } +} + +private struct GhosttySelectionContextActionButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.975 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +private enum GhosttySelectionContextActionPalette { + static let fallbackFill = Color(uiColor: .secondarySystemBackground).opacity(0.92) + static let glassTint = Color.primary.opacity(0.055) + static let destructiveText = Color(uiColor: .systemRed) + static let stroke = Color.primary.opacity(0.11) + static let shadow = Color.black.opacity(0.20) +} + +private struct GhosttyRenderedPreviewSurface: View { + let preview: GhosttyPanePreviewSession.RenderedPreview + let size: CGSize + + var body: some View { + Image(decorative: preview.image, scale: PanePreviewLayout.currentScale()) + .resizable() + .aspectRatio(contentMode: contentMode) + .frame(width: size.width, height: size.height) + .background(Color.black.opacity(0.30)) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + + private var contentMode: ContentMode { + switch preview.source { + case .fullViewport: .fill + case .paneGeometry: .fit + } + } +} + +private struct GhosttyWindowSelectionTile: View { + let displayIndex: Int + let displayName: String + let totalCount: Int + let paneCount: Int + let isSelected: Bool + let previewState: GhosttyPanePreviewSession.PreviewState? + let chromeStyle: GhosttyTerminalChromeStyle + let layout: PanePreviewLayout.Metrics + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + previewSurface + caption + } + .padding(layout.tilePadding) + .frame( + width: layout.tilePointSize.width, + height: layout.tilePointSize.height, + alignment: .topLeading + ) + .terminalSelectionTileChrome(isSelected: isSelected, chromeStyle: chromeStyle) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(previewState.accessibilityValue) + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + } + + @ViewBuilder + private var previewSurface: some View { + switch previewState { + case .ready(let preview): + GhosttyRenderedPreviewSurface( + preview: preview, + size: layout.previewPointSize + ) + + case .pending, .none, .failed: + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.black.opacity(0.30)) + .frame( + width: layout.previewPointSize.width, + height: layout.previewPointSize.height + ) + } + } + + private var caption: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("\(displayIndex)") + .font(.system(size: 11, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(TerminalSelectionSheetPalette.tertiary) + + if !displayName.isEmpty { + Text(displayName) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 0) + } + + if paneCount > 1 { + HStack(spacing: 6) { + Text("\(paneCount)") + .monospacedDigit() + + Text("panes") + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 2) + } + + private var accessibilityLabel: String { + let paneText = "\(paneCount) \(paneCount == 1 ? "pane" : "panes")" + let positional = "Window \(displayIndex) of \(totalCount)" + let named = displayName.isEmpty ? positional : "\(positional), \(displayName)" + if isSelected { + return "\(named), \(paneText), active" + } + return "\(named), \(paneText)" + } +} + +private struct GhosttyPaneSelectionTile: View { + let displayIndex: Int + let totalCount: Int + let isSelected: Bool + let state: GhosttyPanePreviewSession.PreviewState? + let chromeStyle: GhosttyTerminalChromeStyle + let layout: PanePreviewLayout.Metrics + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + previewSurface + captionRow + } + .padding(layout.tilePadding) + .frame( + width: layout.tilePointSize.width, + height: layout.tilePointSize.height, + alignment: .topLeading + ) + .terminalSelectionTileChrome(isSelected: isSelected, chromeStyle: chromeStyle) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(state.accessibilityValue) + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + } + + private var accessibilityLabel: String { + let positional = "Pane \(displayIndex) of \(totalCount)" + return isSelected ? "\(positional), active" : positional + } + + @ViewBuilder + private var previewSurface: some View { + switch state { + case .ready(let preview): + GhosttyRenderedPreviewSurface( + preview: preview, + size: layout.previewPointSize + ) + + case .pending, .none: + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.black.opacity(0.30)) + .frame( + width: layout.previewPointSize.width, + height: layout.previewPointSize.height + ) + + case .failed: + // Failed state still shows a neutral placeholder; we don't + // surface different copy per status reason in v1. + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.black.opacity(0.30)) + .frame( + width: layout.previewPointSize.width, + height: layout.previewPointSize.height + ) + } + } + + private var captionRow: some View { + HStack(spacing: 6) { + Text("\(displayIndex)") + .font(.system(size: 11, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(TerminalSelectionSheetPalette.tertiary) + + Spacer(minLength: 0) + } + .padding(.horizontal, 2) + } +} + +private extension Optional where Wrapped == GhosttyPanePreviewSession.PreviewState { + var accessibilityValue: String { + switch self { + case .ready: + "Preview ready" + case .failed: + "Preview unavailable" + case .pending, .none: + "Preview loading" + } + } +} + +private extension View { + @ViewBuilder + func ghosttySelectionContextActionSurface() -> some View { + let shape = Circle() + + if #available(iOS 26.0, *) { + self + .glassEffect(.regular.tint(GhosttySelectionContextActionPalette.glassTint).interactive(), in: shape) + .overlay { + shape.strokeBorder(GhosttySelectionContextActionPalette.stroke, lineWidth: 0.75) + } + .shadow(color: GhosttySelectionContextActionPalette.shadow, radius: 18, y: 9) + } else { + self + .background(.regularMaterial, in: shape) + .background { + shape.fill(GhosttySelectionContextActionPalette.fallbackFill) + } + .overlay { + shape.strokeBorder(GhosttySelectionContextActionPalette.stroke, lineWidth: 1) + } + .shadow(color: GhosttySelectionContextActionPalette.shadow, radius: 18, y: 10) + } + } + +} 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..3bcecb22 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -0,0 +1,260 @@ +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, upstream selector +/// sheets, and terminal keyboard chrome. Its only construction input is the +/// adapter, so deterministic tests never need Mori SSH or persistence. +struct GhosttyTerminalCoreView: View { + @ObservedObject private var screen: TmuxTerminalScreenAdapter + private let onShowSessions: () -> Void + @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 selectionSheet: GhosttySurfaceSelectionSheet? + @State private var compositionState = GhosttyTerminalCompositionState() + @State private var prefixFlushTask: Task? + @State private var sessionGeneration: UInt64 = 0 + + init( + screen: TmuxTerminalScreenAdapter, + onShowSessions: @escaping () -> Void = {} + ) { + self.screen = screen + self.onShowSessions = onShowSessions + } + + var body: some View { + let projection = screen.terminalScreenPresentationProjection + let interaction = projection.interaction + 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: projection.viewport, + terminalTheme: .ghosttyDefault, + trackpadDriver: trackpadDriver, + onSurfaceTap: { _ in activateTerminalInput() }, + onWindowSwipe: { _ = screen.focusAdjacentTmuxTopLevel($0) }, + sendKeyEvent: sendTerminalKey, + onTrackpadFeedbackChange: { trackpadFeedback = $0 }, + isMouseCaptured: { screen.isMouseCaptured(for: $0) }, + submitMouseButton: { screen.sendMouseButton(to: $0, $1) }, + submitMousePosition: { screen.sendMousePosition(to: $0, $1, mods: $2) }, + submitMouseScroll: { screen.sendMouseScroll(to: $0, $1) } + ) + .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: interaction.isInputAvailable, + wantsFirstResponder: 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: screen.terminalInteractionProjection.isInputAvailable) + } + } + ) + .frame(width: 1, height: 1) + .accessibilityHidden(true) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + GhosttyKeyboardChrome( + keyboardMode: compositionState.inputCoordinator.keyboardMode, + isEnabled: interaction.isInputAvailable, + isCompact: false, + isControlArmed: terminalInputController.isControlArmed, + windowCount: interaction.windowCount, + paneCount: interaction.paneCount, + actions: .init( + showSessions: onShowSessions, + showWindows: showWindows, + showPanes: showPanes, + toggleKeyboard: toggleKeyboard, + toggleControl: { terminalInputController.toggleControl() }, + sendKey: sendTerminalKey + ) + ) + .padding(.horizontal, 12) + .padding(.vertical, 4) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { + updateKeyboardVisibility(with: $0) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + completeKeyboardTransition(for: .shown) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)) { _ in + completeKeyboardTransition(for: .hidden) + } + .onDisappear { cancelPrefixFlush() } + .onChange(of: screen.stateTraceLabel) { oldState, newState in + // A session lifecycle change must not let a delayed key reach a + // replacement surface. The input buffer is cleared and its task + // generation fenced before the next state can accept input. + if oldState != newState { cancelPrefixFlush() } + } + .sheet(item: $selectionSheet) { sheet in + switch sheet { + case .windows(let previews): + GhosttyWindowSelectionSheet( + session: previews, + projection: screen.windowSelectionSheetRenderProjection(), + sessionName: "tmux", + onCreateWindow: { _ = screen.createTmuxWindow() }, + onSelect: { _ = screen.focusTmuxTopLevel($0) }, + onRemoveWindow: { _ = screen.closeTmuxWindow($0) } + ) + case .panes(let topLevelID, let previews): + GhosttyPaneSelectionSheet( + session: previews, + projection: screen.paneSelectionSheetRenderProjection(topLevelID: topLevelID), + onSplitPane: { _ = screen.splitFocusedTmuxPane(ghostty_action_split_direction_e(rawValue: 0)) }, + onStackPane: nil, + onSelect: { _ = screen.focusTmuxPane($0) }, + onRemovePane: { _ = screen.closeTmuxPane($0) } + ) + } + } + } + + private func toggleKeyboard() { + let projection = GhosttyKeyboardToggleProjection( + keyboardMode: compositionState.inputCoordinator.keyboardMode, + isInputAvailable: screen.terminalInteractionProjection.isInputAvailable + ) + if let request = compositionState.keyboardTransitionCoordinator.transitionRequest(forToggle: projection) { + beginKeyboardTransition(request) + } + compositionState.inputCoordinator.toggleKeyboard(isInputAvailable: screen.terminalInteractionProjection.isInputAvailable) + if compositionState.inputCoordinator.keyboardMode == .hidden { _ = responderHandoff.transfer(to: .terminal) } + } + + private func activateTerminalInput() { + guard screen.terminalInteractionProjection.isInputAvailable 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: screen.terminalInteractionProjection.isInputAvailable, + isSelectionSheetPresented: selectionSheet != nil, + isAwaitingSystemKeyboardPresentation: compositionState.keyboardTransitionCoordinator.isAwaitingSystemKeyboardPresentation, + isSceneActive: true + ) + guard completion.action == .complete else { return } + _ = compositionState.completeKeyboardTransition() + } + + private func sendTerminalText(_ text: String) -> Bool { + terminalInputController.performTextInput( + text, + submit: { screen.sendInputToFocusedSurface($0).isAccepted }, + schedulePrefixFlush: schedulePrefixFlush(token:), + enterCopyMode: { screen.enterFocusedTmuxCopyMode().isHandled } + ) + } + + 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, + let input = terminalInputController.flushPendingTmuxPrefixInput(matching: token) + else { return } + _ = screen.sendInputToFocusedSurface(input) + } + } + + private func cancelPrefixFlush() { + prefixFlushTask?.cancel() + prefixFlushTask = nil + _ = terminalInputController.flushPendingTmuxPrefixInput() + sessionGeneration &+= 1 + } + + private func sendTerminalPaste(_ text: String) -> Bool { + terminalInputController.performPaste( + text, + submitPendingPrefix: { screen.sendInputToFocusedSurface($0).isAccepted }, + sendPaste: { screen.sendPasteToFocusedSurface($0).isAccepted } + ) + } + + private func sendTerminalKey(_ event: GhosttySurfaceKeyEvent) -> Bool { + terminalInputController.performKeyEvent( + event, + submitPendingPrefix: { screen.sendInputToFocusedSurface($0).isAccepted }, + sendKey: { screen.sendKeyEventToFocusedSurface($0).isAccepted } + ) + } + + private func showWindows() { + guard let projection = screen.windowSheetPresentationProjection() else { return } + selectionSheet = .windows(screen.makePanePreviewSession( + leafIDs: projection.previewLeafIDs, + previewSizing: .windowGridForCurrentScreen + )) + } + + private func showPanes() { + guard let projection = screen.selectedPaneSheetPresentationProjection() else { return } + selectionSheet = .panes( + topLevelID: projection.topLevelID, + previews: screen.makePanePreviewSession( + leafIDs: projection.previewLeafIDs, + previewSizing: .paneGridForCurrentScreen + ) + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalDisconnectReasonClassifier.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalDisconnectReasonClassifier.swift new file mode 100644 index 00000000..d6d1c36c --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalDisconnectReasonClassifier.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Classification is intentionally transport-agnostic until Phase 2 supplies +/// Mori's SSH adapter. No SSH/NIO type is referenced by the core target. +enum GhosttyTerminalDisconnectReasonClassifier { + static func transportStartFailure(_ error: any Error) -> TerminalDisconnectReason { + .init(kind: .unknown, message: String(describing: error)) + } + + static func foregroundMissingHost() -> TerminalDisconnectReason { + .init(kind: .transportIO, message: "tmux transport unavailable after foreground") + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift new file mode 100644 index 00000000..65b2f4a5 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift @@ -0,0 +1,377 @@ +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 + } + + mutating func toggleControl() { + modifierState.toggleControl() + } + + mutating func clearControl() { + modifierState.clearControl() + } + + 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) + } +} + +struct GhosttyPendingTopologyInputRefocus: Equatable { + private var isPending = false + private var sourceActiveLeafID: UUID? + private(set) var ownsKeyboardTransition = false + + var isActive: Bool { + isPending + } + + @discardableResult + mutating func request( + from activeLeafID: UUID?, + keyboardMode: GhosttyKeyboardChromeMode, + keyboardOwner: GhosttyKeyboardOwner = .terminal + ) -> Bool { + guard keyboardMode == .system, keyboardOwner == .terminal else { return false } + isPending = true + sourceActiveLeafID = activeLeafID + ownsKeyboardTransition = false + return true + } + + mutating func markKeyboardTransitionOwned() { + guard isActive else { return } + ownsKeyboardTransition = true + } + + mutating func consumeIfActiveLeafChanged(to activeLeafID: UUID?) -> Bool { + guard isPending else { return false } + guard activeLeafID != sourceActiveLeafID else { return false } + + isPending = false + self.sourceActiveLeafID = nil + ownsKeyboardTransition = false + return true + } + + mutating func cancel() { + isPending = false + sourceActiveLeafID = nil + ownsKeyboardTransition = false + } +} + +struct GhosttyTopologyActionInputRefocusCoordinator: Equatable { + enum Effect: Equatable { + case requestRefocus + case dismissSelectionSheet + case cancelRefocus(ownsKeyboardTransition: Bool) + case completeRefocus + } + + enum EffectApplicationFeedback: Equatable { + case none + case refocusKeyboardTransitionStarted + } + + private var pendingRefocus = GhosttyPendingTopologyInputRefocus() + + var isActive: Bool { + pendingRefocus.isActive + } + + mutating func prepare( + actionEffect: GhosttyTmuxTopologyActionInteractionEffect, + activeLeafID: UUID?, + keyboardMode: GhosttyKeyboardChromeMode, + keyboardOwner: GhosttyKeyboardOwner = .terminal + ) -> Effect? { + guard actionEffect.requestsInputRefocus else { return nil } + guard pendingRefocus.request( + from: activeLeafID, + keyboardMode: keyboardMode, + keyboardOwner: keyboardOwner + ) else { + return nil + } + return .requestRefocus + } + + mutating func complete( + actionEffect: GhosttyTmuxTopologyActionInteractionEffect, + outcome: GhosttyTmuxModelActionOutcome + ) -> Effect? { + guard outcome.isQueued else { + guard actionEffect.requestsInputRefocus else { return nil } + guard pendingRefocus.isActive else { return nil } + + let ownsKeyboardTransition = pendingRefocus.ownsKeyboardTransition + pendingRefocus.cancel() + return .cancelRefocus(ownsKeyboardTransition: ownsKeyboardTransition) + } + + guard actionEffect.dismissesSelectionSheetOnQueued else { return nil } + return .dismissSelectionSheet + } + + mutating func consumeActiveLeafChange(to activeLeafID: UUID?) -> Effect? { + guard pendingRefocus.consumeIfActiveLeafChanged(to: activeLeafID) else { + return nil + } + return .completeRefocus + } + + mutating func cancelForCommandFailure() -> Effect? { + guard pendingRefocus.isActive else { return nil } + + let ownsKeyboardTransition = pendingRefocus.ownsKeyboardTransition + pendingRefocus.cancel() + return .cancelRefocus(ownsKeyboardTransition: ownsKeyboardTransition) + } + + @discardableResult + mutating func perform( + actionEffect: GhosttyTmuxTopologyActionInteractionEffect, + activeLeafID: UUID?, + keyboardMode: GhosttyKeyboardChromeMode, + keyboardOwner: GhosttyKeyboardOwner = .terminal, + apply: (Effect) -> EffectApplicationFeedback, + action: () -> GhosttyTmuxModelActionOutcome + ) -> GhosttyTmuxModelActionOutcome { + if let effect = prepare( + actionEffect: actionEffect, + activeLeafID: activeLeafID, + keyboardMode: keyboardMode, + keyboardOwner: keyboardOwner + ) { + applyEffect(effect, using: apply) + } + + let outcome = action() + + if let effect = complete(actionEffect: actionEffect, outcome: outcome) { + applyEffect(effect, using: apply) + } + + return outcome + } + + private mutating func applyEffect( + _ effect: Effect, + using apply: (Effect) -> EffectApplicationFeedback + ) { + let feedback = apply(effect) + guard case .requestRefocus = effect else { return } + guard feedback == .refocusKeyboardTransitionStarted else { return } + + pendingRefocus.markKeyboardTransitionOwned() + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift new file mode 100644 index 00000000..fde1dd74 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift @@ -0,0 +1,526 @@ +import Foundation + +struct TerminalReadinessSnapshot: Equatable, Sendable { + let phase: GhosttyTerminalRuntimePhase + let transportWritable: Bool + let topLevelCount: Int + let selectedActiveLeafID: UUID? + + init( + phase: GhosttyTerminalRuntimePhase, + transportWritable: Bool, + topLevelCount: Int, + selectedActiveLeafID: UUID? + ) { + precondition(topLevelCount >= 0, "topLevelCount must be non-negative") + self.phase = phase + self.transportWritable = transportWritable + self.topLevelCount = topLevelCount + self.selectedActiveLeafID = selectedActiveLeafID + } + + var hasFocusedSurface: Bool { + selectedActiveLeafID != nil + } +} + +enum TerminalReadinessProjector { + static func snapshot( + phase: GhosttyTerminalRuntimePhase, + transportWritable: Bool, + topLevelCount: Int, + selectedActiveLeafID: UUID? + ) -> TerminalReadinessSnapshot { + TerminalReadinessSnapshot( + phase: phase, + transportWritable: transportWritable, + topLevelCount: topLevelCount, + selectedActiveLeafID: selectedActiveLeafID + ) + } + + static func runtimeState(_ snapshot: TerminalReadinessSnapshot) -> TerminalRuntimeState { + runtimeState( + phase: snapshot.phase, + hasFocusedSurface: snapshot.hasFocusedSurface + ) + } + + static func runtimeState( + phase: GhosttyTerminalRuntimePhase, + hasFocusedSurface: Bool + ) -> TerminalRuntimeState { + if phase == .running, hasFocusedSurface { + return .connected + } + + switch phase { + case .idle, .starting, .running: + return .connecting + case .failed(let message, let reason): + return .disconnected( + reason ?? TerminalDisconnectReason( + kind: .unknown, + message: message + ) + ) + } + } + + static func isInputAvailable(_ snapshot: TerminalReadinessSnapshot) -> Bool { + isInputAvailable( + phase: snapshot.phase, + hasFocusedSurface: snapshot.hasFocusedSurface + ) + } + + static func isInputAvailable( + phase: GhosttyTerminalRuntimePhase, + hasFocusedSurface: Bool + ) -> Bool { + phase == .running && hasFocusedSurface + } + + static func isTransportAvailableForInput(_ snapshot: TerminalReadinessSnapshot) -> Bool { + isTransportAvailableForInput( + phase: snapshot.phase, + transportWritable: snapshot.transportWritable + ) + } + + static func isTransportAvailableForInput( + phase: GhosttyTerminalRuntimePhase, + transportWritable: Bool + ) -> Bool { + phase == .running && transportWritable + } + + static func canSubmitInput(_ snapshot: TerminalReadinessSnapshot) -> Bool { + canSubmitInput( + phase: snapshot.phase, + transportWritable: snapshot.transportWritable, + hasFocusedSurface: snapshot.hasFocusedSurface + ) + } + + static func uiTestInputReady(_ snapshot: TerminalReadinessSnapshot) -> Bool { + canSubmitInput(snapshot) + } + + static func canSubmitInput( + phase: GhosttyTerminalRuntimePhase, + transportWritable: Bool, + hasFocusedSurface: Bool + ) -> Bool { + isInputAvailable(phase: phase, hasFocusedSurface: hasFocusedSurface) + && isTransportAvailableForInput(phase: phase, transportWritable: transportWritable) + } + + static func isWaitingForPanes(_ snapshot: TerminalReadinessSnapshot) -> Bool { + isWaitingForPanes(phase: snapshot.phase, topLevelCount: snapshot.topLevelCount) + } + + static func isWaitingForPanes( + phase: GhosttyTerminalRuntimePhase, + topLevelCount: Int + ) -> Bool { + precondition(topLevelCount >= 0, "topLevelCount must be non-negative") + return phase == .running && topLevelCount == 0 + } + + static func isTerminalStatusReady( + _ snapshot: TerminalReadinessSnapshot, + commandFailureMessage: String? + ) -> Bool { + snapshot.phase == .running + && snapshot.topLevelCount > 0 + && commandFailureMessage == nil + } + + static func shouldTraceTerminalReady(_ snapshot: TerminalReadinessSnapshot) -> Bool { + snapshot.phase == .running && snapshot.topLevelCount > 0 + } + + static func terminalReadyTraceFields( + _ snapshot: TerminalReadinessSnapshot, + managedSurfaceCount: Int, + workspaceID: UUID + ) -> [String: String] { + precondition(managedSurfaceCount >= 0, "managedSurfaceCount must be non-negative") + return [ + "topLevels": "\(snapshot.topLevelCount)", + "managedSurfaces": "\(managedSurfaceCount)", + "workspaceID": workspaceID.uuidString, + "phase": traceValue(for: snapshot.phase), + "transportWritable": "\(snapshot.transportWritable)", + "selectedActiveLeafID": ghosttyDiagnosticShortID(snapshot.selectedActiveLeafID), + ] + } + + private static func traceValue(for phase: GhosttyTerminalRuntimePhase) -> String { + switch phase { + case .idle: + "idle" + case .starting: + "starting" + case .running: + "running" + case .failed: + "failed" + } + } +} + +struct GhosttyTerminalInteractionProjection: Equatable, Sendable { + let isInputAvailable: Bool + let hasFocusedSurface: Bool + let selectedActiveLeafID: UUID? + let selectedWindowIndex: Int? + let windowCount: Int + let selectedPaneIndex: Int? + let paneCount: Int + let isWaitingForPanes: Bool +} + +enum GhosttyTerminalStatusOverlayProjection: Equatable, Sendable { + case starting + case commandFailure(String) + case waitingForPanes(debugStatus: String, registryDebugSummary: String) + case ready + case failed(message: String, reason: TerminalDisconnectReason?) +} + +struct GhosttyTerminalScreenPresentationProjection: Equatable { + let readiness: TerminalReadinessSnapshot + let interaction: GhosttyTerminalInteractionProjection + let viewport: GhosttyTerminalViewportPresentationProjection + let statusOverlay: GhosttyTerminalStatusOverlayProjection +} + +/// Remux presents exactly one tmux pane per app viewport on every supported +/// device class. Topology identities remain stable for picker actions; this +/// projection identifies the one native surface instance currently hosted. +struct GhosttyTerminalViewportPresentationProjection: Equatable { + static let empty = GhosttyTerminalViewportPresentationProjection( + surfaceID: nil, + windowCount: 0 + ) + + let surfaceID: UUID? + let windowCount: Int + + var canNavigateWindows: Bool { + windowCount > 1 + } +} + +enum GhosttyTmuxTopologyActionInteractionEffect: Equatable, Sendable { + case none + case refocusOnly + case refocusAndDismissOnQueued + + var requestsInputRefocus: Bool { + switch self { + case .none: + false + case .refocusOnly, .refocusAndDismissOnQueued: + true + } + } + + var dismissesSelectionSheetOnQueued: Bool { + self == .refocusAndDismissOnQueued + } +} + +struct GhosttyWindowSheetPresentationProjection: Equatable, Sendable { + let previewLeafIDs: [UUID] +} + +struct GhosttyPaneSheetPresentationProjection: Equatable, Sendable { + let topLevelID: UUID + let previewLeafIDs: [UUID] +} + +struct GhosttyPaneSelectionSheetTopologyProjection: Equatable, Sendable { + let topLevelID: UUID? + let shouldDismissPaneSheet: Bool +} + +struct GhosttyWindowSelectionSheetRenderProjection: Equatable, Sendable { + struct Window: Identifiable, Equatable, Sendable { + let id: UUID + let displayName: String + let displayIndex: Int + let totalCount: Int + let paneCount: Int + let isSelected: Bool + let focusedPreviewPaneID: UUID? + } + + let windows: [Window] + let selectedWindowID: UUID? + let previewLeafIDs: [UUID] +} + +struct GhosttyPaneSelectionSheetRenderProjection: Equatable, Sendable { + struct Pane: Identifiable, Equatable, Sendable { + let id: UUID + let displayIndex: Int + let totalCount: Int + let isSelected: Bool + } + + let topLevelID: UUID + let panes: [Pane] + let selectedPaneID: UUID? + let previewLeafIDs: [UUID] + let paneCount: Int +} + +@MainActor +enum GhosttyTerminalPresentationProjector { + static func terminalScreenPresentationProjection( + phase: GhosttyTerminalRuntimePhase, + transportWritable: Bool, + commandFailureMessage: String?, + debugStatus: String, + registryDebugSummary: String, + presentedSurfaceID: UUID?, + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyTerminalScreenPresentationProjection { + let readiness = TerminalReadinessProjector.snapshot( + phase: phase, + transportWritable: transportWritable, + topLevelCount: snapshot.topLevels.count, + selectedActiveLeafID: presentedSurfaceID + ) + + return GhosttyTerminalScreenPresentationProjection( + readiness: readiness, + interaction: terminalInteractionProjection( + phase: phase, + presentedSurfaceID: presentedSurfaceID, + snapshot: snapshot + ), + viewport: GhosttyTerminalViewportPresentationProjection( + surfaceID: presentedSurfaceID, + windowCount: snapshot.topLevels.count + ), + statusOverlay: terminalStatusOverlayProjection( + readiness: readiness, + commandFailureMessage: commandFailureMessage, + debugStatus: debugStatus, + registryDebugSummary: registryDebugSummary + ) + ) + } + + static func terminalStatusOverlayProjection( + readiness: TerminalReadinessSnapshot, + commandFailureMessage: String?, + debugStatus: String, + registryDebugSummary: String + ) -> GhosttyTerminalStatusOverlayProjection { + switch readiness.phase { + case .idle, .starting: + return .starting + case .failed(let message, let reason): + return .failed(message: message, reason: reason) + case .running: + if let commandFailureMessage { + return .commandFailure(commandFailureMessage) + } + let waitingProjection = GhosttyTerminalStatusOverlayProjection.waitingForPanes( + debugStatus: debugStatus, + registryDebugSummary: registryDebugSummary + ) + if TerminalReadinessProjector.isWaitingForPanes(readiness) { + return waitingProjection + } + if TerminalReadinessProjector.isTerminalStatusReady( + readiness, + commandFailureMessage: nil + ) { + return .ready + } + return waitingProjection + } + } + + static func terminalInteractionProjection( + phase: GhosttyTerminalRuntimePhase, + presentedSurfaceID: UUID?, + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyTerminalInteractionProjection { + let selectedTopLevel = snapshot.selectedTopLevel + let selectedPaneIndex = selectedTopLevel.flatMap { topLevel -> Int? in + guard let focusedLeafID = topLevel.resolvedFocusedLeafID else { return nil } + return topLevel.leafIDs.firstIndex(of: focusedLeafID) + } + let hasFocusedSurface = presentedSurfaceID != nil + + return GhosttyTerminalInteractionProjection( + isInputAvailable: TerminalReadinessProjector.isInputAvailable( + phase: phase, + hasFocusedSurface: hasFocusedSurface + ), + hasFocusedSurface: hasFocusedSurface, + selectedActiveLeafID: presentedSurfaceID, + selectedWindowIndex: snapshot.selectedTopLevelIndex, + windowCount: snapshot.topLevels.count, + selectedPaneIndex: selectedPaneIndex, + paneCount: selectedTopLevel?.leafIDs.count ?? 0, + isWaitingForPanes: TerminalReadinessProjector.isWaitingForPanes( + phase: phase, + topLevelCount: snapshot.topLevels.count + ) + ) + } + + static func createTmuxWindowInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect { + .refocusAndDismissOnQueued + } + + static func splitFocusedTmuxPaneInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect { + .refocusAndDismissOnQueued + } + + static func closeTmuxWindowInteractionEffect( + _ id: UUID, + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyTmuxTopologyActionInteractionEffect { + guard snapshot.topLevels.contains(where: { $0.id == id }) else { + return .none + } + + return snapshot.topLevels.count <= 1 ? .refocusAndDismissOnQueued : .none + } + + static func closeTmuxPaneInteractionEffect( + _ id: UUID, + inTopLevel topLevelID: UUID, + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyTmuxTopologyActionInteractionEffect { + guard + let topLevel = snapshot.topLevels.first(where: { $0.id == topLevelID }), + topLevel.leafIDs.contains(id) + else { + return .none + } + + return topLevel.leafIDs.count == 1 ? .refocusOnly : .none + } + + static func windowSheetPresentationProjection( + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyWindowSheetPresentationProjection? { + guard !snapshot.topLevels.isEmpty else { return nil } + + return GhosttyWindowSheetPresentationProjection( + previewLeafIDs: snapshot.topLevels.compactMap(\.resolvedFocusedLeafID) + ) + } + + static func selectedPaneSheetPresentationProjection( + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyPaneSheetPresentationProjection? { + guard let topLevel = snapshot.selectedTopLevel else { return nil } + + return GhosttyPaneSheetPresentationProjection( + topLevelID: topLevel.id, + previewLeafIDs: topLevel.leafIDs + ) + } + + static func paneCount( + topLevelID: UUID, + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> Int { + snapshot.topLevels.first(where: { $0.id == topLevelID })?.leafIDs.count ?? 0 + } + + static func paneSelectionSheetTopologyProjection( + topLevelID: UUID?, + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyPaneSelectionSheetTopologyProjection { + guard let topLevelID else { + return GhosttyPaneSelectionSheetTopologyProjection( + topLevelID: nil, + shouldDismissPaneSheet: false + ) + } + + let topLevelExists = snapshot.topLevels.contains { $0.id == topLevelID } + return GhosttyPaneSelectionSheetTopologyProjection( + topLevelID: topLevelID, + shouldDismissPaneSheet: !topLevelExists + ) + } + + static func windowSelectionSheetRenderProjection( + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyWindowSelectionSheetRenderProjection { + let topLevels = snapshot.topLevels + let selectedWindowID = snapshot.selectedTopLevel?.id + let totalCount = topLevels.count + let windows = topLevels.enumerated().map { index, topLevel in + GhosttyWindowSelectionSheetRenderProjection.Window( + id: topLevel.id, + displayName: displaySafeWindowName(topLevel.name), + displayIndex: index + 1, + totalCount: totalCount, + paneCount: topLevel.leafIDs.count, + isSelected: topLevel.id == selectedWindowID, + focusedPreviewPaneID: topLevel.resolvedFocusedLeafID + ) + } + + return GhosttyWindowSelectionSheetRenderProjection( + windows: windows, + selectedWindowID: selectedWindowID, + previewLeafIDs: windows.compactMap(\.focusedPreviewPaneID) + ) + } + + private static func displaySafeWindowName(_ name: String) -> String { + name.unicodeScalars.reduce(into: "") { result, scalar in + guard scalar.properties.generalCategory != .control else { return } + result.unicodeScalars.append(scalar) + } + } + + static func paneSelectionSheetRenderProjection( + topLevelID: UUID, + snapshot: GhosttyRuntimeSurfaceTopologySnapshot + ) -> GhosttyPaneSelectionSheetRenderProjection { + guard let topLevel = snapshot.topLevels.first(where: { $0.id == topLevelID }) else { + return GhosttyPaneSelectionSheetRenderProjection( + topLevelID: topLevelID, + panes: [], + selectedPaneID: nil, + previewLeafIDs: [], + paneCount: 0 + ) + } + + let selectedPaneID = topLevel.resolvedFocusedLeafID + let totalCount = topLevel.leafIDs.count + let panes = topLevel.leafIDs.enumerated().map { index, paneID in + GhosttyPaneSelectionSheetRenderProjection.Pane( + id: paneID, + displayIndex: index + 1, + totalCount: totalCount, + isSelected: paneID == selectedPaneID + ) + } + + return GhosttyPaneSelectionSheetRenderProjection( + topLevelID: topLevelID, + panes: panes, + selectedPaneID: selectedPaneID, + previewLeafIDs: topLevel.leafIDs, + paneCount: totalCount + ) + } +} 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..2a3788d6 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift @@ -0,0 +1,189 @@ +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. Everything here exists solely to keep UIKit's protocol-required +// calls happy without surfacing autocorrect, marked text, an edit menu, or +// other text-input behaviors. UIKit may still deliver committed software +// keyboard input through `replace(_:withText:)`, so committed replacement text +// is forwarded to the same terminal path as `insertText`. + +/// 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? { nil } + 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 + } + + // Keep the virtual document coherent so UIKit sees one deletable + // character and drives its native Backspace repeat behavior. + return range.isEmpty ? "" : " " + } + + func replace(_ range: UITextRange, withText text: String) { + _ = range + submitTextInput(text, source: "replaceText") + } + + func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + _ = (markedText, selectedRange) + } + + func unmarkText() {} + + 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..2f41e2c2 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift @@ -0,0 +1,672 @@ +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? + 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)" + ) + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.update", + fields: [ + "enabled": "\(isEnabled)", + "firstResponder": "\(isFirstResponder)", + "hasWindow": "\(window != nil)", + "token": "\(activationToken)", + "wasEnabled": "\(wasInputEnabled)", + "wantsFirstResponder": "\(wantsFirstResponder)", + "previousWantsFirstResponder": "\(previouslyWantedFirstResponder)", + ] + ) + 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 { + 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) { + 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)" + ) + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.\(source)", + fields: [ + "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() + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.becomeFirstResponder.result", + fields: [ + "firstResponder": "\(isFirstResponder)", + "result": "\(didBecomeFirstResponder)", + "token": "\(activationToken)", + ] + ) + return didBecomeFirstResponder + } + + override func resignFirstResponder() -> Bool { + cancelTrackpadGestureIfActive(reason: "resignFirstResponder") + let didResignFirstResponder = super.resignFirstResponder() + reportFirstResponderStateIfChanged() + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.resignFirstResponder.result", + fields: [ + "firstResponder": "\(isFirstResponder)", + "result": "\(didResignFirstResponder)", + "token": "\(activationToken)", + ] + ) + 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) + } + ) + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.trackpad.begin", + fields: [ + "firstResponder": "\(isFirstResponder)", + "token": "\(activationToken)", + ] + ) + } + + 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 } + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.trackpad.end", + fields: [ + "firstResponder": "\(isFirstResponder)", + "token": "\(activationToken)", + ] + ) + } + + func cancelTrackpadGestureIfActive(reason: String) { + guard trackpadDriver.cancel(owner: self) else { return } + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.trackpad.cancel", + fields: [ + "reason": reason, + "token": "\(activationToken)", + ] + ) + } + + func deleteBackward() { + guard isInputEnabled else { 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 + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.reconcile.scheduled", + fields: [ + "reason": reason, + "token": "\(activationToken)", + ] + ) + 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)" + ) + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.becomeFirstResponder.scheduled", + fields: [ + "route": "reconcile", + "token": "\(activationToken)", + ] + ) + _ = 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" + ) + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.becomeFirstResponder.already", + fields: [ + "route": route, + "token": "\(activationToken)", + ] + ) + return true + } + + let traceStart = GhosttyRuntimeTrace.nowNanos() + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.becomeFirstResponder.begin", + fields: [ + "route": route, + "token": "\(activationToken)", + ], + at: traceStart + ) + 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)" + ) + GhosttyRuntimeTrace.flowEventIfActive( + "terminal.input", + event: "responder.becomeFirstResponder.end", + fields: [ + "elapsed_ms": elapsedMilliseconds, + "result": "\(didBecomeFirstResponder)", + "route": route, + "token": "\(activationToken)", + ] + ) + 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/GhosttyTerminalScreenModeling.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift new file mode 100644 index 00000000..0e8c866c --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift @@ -0,0 +1,181 @@ +import CoreGraphics +import Foundation +import GhosttyKit + +/// The model surface `GhosttySurfaceScreen` renders against: projections of +/// terminal readiness/topology, focused-surface input routing, tmux topology +/// actions, and the selection-sheet/preview plumbing. +/// +/// The tmux session stack implements it (`TmuxTerminalScreenAdapter`). The +/// screen owns presentation behavior only; everything engine-specific flows +/// through this boundary. +enum GhosttyTmuxModelActionOutcome: Equatable, Sendable { + case queued + case missingTarget(GhosttyTmuxActionMissingTarget) + + var isHandled: Bool { + switch self { + case .queued: + true + case .missingTarget: + false + } + } + + var isQueued: Bool { + self == .queued + } +} + +struct GhosttyTmuxCommandFailureEvent: Equatable { + let token: UInt64 + let message: String +} + +/// App-level scene lifecycle phases forwarded into terminal screen models. +enum GhosttyAppLifecyclePhase: Equatable { + case active + case inactive + case background +} + +@MainActor +protocol GhosttyTerminalRenderingModeling: ObservableObject { + var terminalScreenPresentationProjection: GhosttyTerminalScreenPresentationProjection { get } + var terminalInteractionProjection: GhosttyTerminalInteractionProjection { get } + var terminalManagedSurfaceLookup: GhosttyManagedSurfaceLookup { get } + var commandFailureEvent: GhosttyTmuxCommandFailureEvent? { get } + var stateTraceLabel: String { get } + + func prepareInitialViewport(size: CGSize, scale: CGFloat) + + /// Host hint that the terminal viewport is (not) in its settled + /// shape — false while a transient overlay (software keyboard) is + /// changing the layout. Engines use it to decide which reported + /// viewport is safe to carry into a reconnect. + func setViewportStabilityHint(stable: Bool) +} + +@MainActor +protocol GhosttyTerminalInputModeling: ObservableObject { + // MARK: Focused/targeted input routing + + @discardableResult + func sendInputToFocusedSurface(_ text: String) -> FocusedTerminalInputSubmissionResult + + @discardableResult + func sendPasteToFocusedSurface(_ text: String) -> FocusedTerminalInputSubmissionResult + + @discardableResult + func sendPaste(_ text: String, to surfaceID: UUID) -> FocusedTerminalInputSubmissionResult + + func sendPasteAwaitingCommandCompletion(_ text: String, to surfaceID: UUID) async -> Bool + + @discardableResult + func sendKeyEvent( + _ event: GhosttySurfaceKeyEvent, + to surfaceID: UUID + ) -> FocusedTerminalInputSubmissionResult + + func sendKeyEventAwaitingCommandCompletion( + _ event: GhosttySurfaceKeyEvent, + to surfaceID: UUID + ) async -> Bool + + @discardableResult + func sendKeyEventToFocusedSurface(_ event: GhosttySurfaceKeyEvent) -> FocusedTerminalInputSubmissionResult + + func isMouseCaptured(for surfaceID: UUID) -> Bool + + @discardableResult + func sendMouseButton( + to surfaceID: UUID, + _ event: GhosttySurfaceMouseButtonEvent + ) -> GhosttyMouseInputSubmissionOutcome + + @discardableResult + func sendMousePosition( + to surfaceID: UUID, + _ position: CGPoint, + mods: GhosttySurfaceKeyEvent.Mods + ) -> GhosttyMouseInputSubmissionOutcome + + @discardableResult + func sendMouseScroll( + to surfaceID: UUID, + _ event: GhosttySurfaceMouseScrollEvent + ) -> GhosttyMouseInputSubmissionOutcome + +} + +@MainActor +protocol GhosttyTmuxActionModeling: ObservableObject { + // MARK: tmux topology actions + + @discardableResult + func focusTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome + + @discardableResult + func focusTmuxTopLevel(_ id: UUID) -> GhosttyTmuxModelActionOutcome + + @discardableResult + func focusAdjacentTmuxTopLevel( + _ direction: GhosttyRuntimeSelectionDirection + ) -> GhosttyTmuxModelActionOutcome + + @discardableResult + func createTmuxWindow() -> GhosttyTmuxModelActionOutcome + + @discardableResult + func splitFocusedTmuxPane( + _ direction: ghostty_action_split_direction_e + ) -> GhosttyTmuxModelActionOutcome + + @discardableResult + func closeTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome + + @discardableResult + func closeTmuxWindow(_ id: UUID) -> GhosttyTmuxModelActionOutcome + + @discardableResult + func enterFocusedTmuxCopyMode() -> GhosttyTmuxModelActionOutcome + + // MARK: Topology action interaction effects + + func createTmuxWindowInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect + func splitFocusedTmuxPaneInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect + func closeTmuxWindowInteractionEffect(_ id: UUID) -> GhosttyTmuxTopologyActionInteractionEffect + func closeTmuxPaneInteractionEffect( + _ id: UUID, + inTopLevel topLevelID: UUID + ) -> GhosttyTmuxTopologyActionInteractionEffect +} + +@MainActor +protocol GhosttyTmuxSelectionModeling: ObservableObject { + func makePanePreviewSession( + leafIDs: [UUID], + previewSizing: GhosttyPanePreviewSession.PreviewSizing + ) -> GhosttyPanePreviewSession + + // MARK: Selection sheets + + func windowSheetPresentationProjection() -> GhosttyWindowSheetPresentationProjection? + func selectedPaneSheetPresentationProjection() -> GhosttyPaneSheetPresentationProjection? + func paneCount(topLevelID: UUID) -> Int + func paneSelectionSheetTopologyProjection( + topLevelID: UUID? + ) -> GhosttyPaneSelectionSheetTopologyProjection + func windowSelectionSheetRenderProjection() -> GhosttyWindowSelectionSheetRenderProjection + func paneSelectionSheetRenderProjection( + topLevelID: UUID + ) -> GhosttyPaneSelectionSheetRenderProjection +} + +@MainActor +protocol GhosttyTerminalScreenModeling: + GhosttyTerminalRenderingModeling, + GhosttyTerminalInputModeling, + GhosttyTmuxActionModeling, + GhosttyTmuxSelectionModeling +{} 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/GhosttyTmuxActionTargetResolver.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxActionTargetResolver.swift new file mode 100644 index 00000000..7c56d437 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxActionTargetResolver.swift @@ -0,0 +1,12 @@ +import Foundation + +enum GhosttyTmuxActionMissingTarget: Equatable, Sendable { + case host + case pane(UUID) + case focusedPane + case window(UUID) + case windowPane(UUID) + case selectedWindow + case adjacentWindow +} + 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/GhosttyTopLevelSurface.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift new file mode 100644 index 00000000..a1e24a68 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift @@ -0,0 +1,24 @@ +import Foundation + +struct GhosttyTopLevelSurface: Identifiable, Equatable { + let id: UUID + let name: String + let leafIDs: [UUID] + let focusedLeafID: UUID? + + init( + id: UUID = UUID(), + name: String = "", + leafIDs: [UUID], + focusedLeafID: UUID? = nil + ) { + self.id = id + self.name = name + self.leafIDs = leafIDs + self.focusedLeafID = focusedLeafID.flatMap { leafIDs.contains($0) ? $0 : nil } + } + + var resolvedFocusedLeafID: UUID? { + focusedLeafID ?? leafIDs.first + } +} 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/Ghostty/PanePreviewLayout.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/PanePreviewLayout.swift new file mode 100644 index 00000000..d7c660ab --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/PanePreviewLayout.swift @@ -0,0 +1,207 @@ +import CoreGraphics +import UIKit + +/// Single source of truth for pane-preview tile geometry and the physical +/// pixel budget used when downscaling local renderer frames. +/// +/// Used by: +/// - `GhosttyPanePreviewSession` for the local image budget +/// - `GhosttyPaneSelectionTile` for fixed tile sizing +/// +/// Capture once per session at session-init time. Rotation while the panes +/// sheet is open does not justify reissuing previews; we keep the originally +/// requested image regardless. +enum PanePreviewLayout { + struct Metrics: Equatable { + let columnCount: Int + let tilePointSize: CGSize + let previewPointSize: CGSize + let gridSpacing: CGFloat + let tilePadding: CGFloat + + func gridHeight(itemCount: Int) -> CGFloat { + guard itemCount > 0 else { return 0 } + let rows = (itemCount + columnCount - 1) / columnCount + return CGFloat(rows) * tilePointSize.height + + CGFloat(rows - 1) * gridSpacing + } + } + + /// Height for a selector sheet's scrollable grid. The whole grid shows + /// exactly whenever it fits within the height budget — the sheet grows + /// rather than hiding part of the final row. Only grids larger than the + /// budget scroll, showing complete rows plus half of the next tile so + /// the cut is an unmistakable scroll affordance. + @MainActor + static func gridIdealHeight(itemCount: Int, metrics: Metrics) -> CGFloat { + let fullHeight = metrics.gridHeight(itemCount: itemCount) + let budget = UIScreen.main.bounds.height * 0.72 + guard fullHeight > budget else { return fullHeight } + + let tile = metrics.tilePointSize.height + let spacing = metrics.gridSpacing + let peek = tile * 0.5 + + func height(fullRows: Int) -> CGFloat { + CGFloat(fullRows) * tile + CGFloat(fullRows - 1) * spacing + spacing + peek + } + + var rows = 1 + while height(fullRows: rows + 1) <= budget { + rows += 1 + } + return min(height(fullRows: rows), fullHeight) + } + + private static let defaultSheetContentWidth: CGFloat = 361 + private static let sheetHorizontalPadding: CGFloat = 32 + private static let defaultPreviewAspectRatio: CGFloat = 4.0 / 3.0 + private static let tilePadding: CGFloat = 8 + private static let captionHeight: CGFloat = 14 + private static let windowCaptionHeight: CGFloat = 30 + private static let tileCaptionSpacing: CGFloat = 6 + private static let maxSingleTileWidth: CGFloat = 390 + + /// Window grid uses a fixed two-column layout. The "New Window" affordance + /// is a fixed sheet action, not a trailing grid cell, so dense sessions can + /// scroll windows without hiding the create command. + private static let windowGridColumnCount: Int = 2 + private static let windowGridSpacing: CGFloat = 10 + + static func metrics(for paneCount: Int) -> Metrics { + metrics(for: paneCount, availableWidth: defaultSheetContentWidth) + } + + static func metrics( + for paneCount: Int, + availableWidth: CGFloat + ) -> Metrics { + let paneCount = max(paneCount, 1) + let columnCount = paneCount == 1 ? 1 : 2 + let gridSpacing: CGFloat = paneCount == 1 ? 12 : 10 + let safeAvailableWidth = max(availableWidth, 1) + let contentWidth = paneCount == 1 + ? min(safeAvailableWidth, maxSingleTileWidth) + : safeAvailableWidth + let totalGridSpacing = CGFloat(columnCount - 1) * gridSpacing + let tileWidth = max( + 1, + floor((contentWidth - totalGridSpacing) / CGFloat(columnCount)) + ) + let previewWidth = max(1, tileWidth - tilePadding * 2) + let previewHeight = ceil(previewWidth / defaultPreviewAspectRatio) + let tileHeight = previewHeight + tileCaptionSpacing + captionHeight + tilePadding * 2 + return .init( + columnCount: columnCount, + tilePointSize: CGSize(width: tileWidth, height: tileHeight), + previewPointSize: CGSize(width: previewWidth, height: previewHeight), + gridSpacing: gridSpacing, + tilePadding: tilePadding + ) + } + + /// Display scale captured once at session init. Avoids touching + /// UIScreen.main during request construction or rendering. + @MainActor + static func currentScale() -> CGFloat { + let scale = UIScreen.main.scale + return scale.isFinite && scale > 0 ? scale : 1 + } + + @MainActor + static func currentSheetContentWidth() -> CGFloat { + let width = UIScreen.main.bounds.width - sheetHorizontalPadding + return width.isFinite && width > 0 ? width : defaultSheetContentWidth + } + + @MainActor + static func metricsForCurrentScreen(for paneCount: Int) -> Metrics { + metrics(for: paneCount, availableWidth: currentSheetContentWidth()) + } + + @MainActor + static func windowMetricsForCurrentScreen() -> Metrics { + windowMetrics(availableWidth: currentSheetContentWidth()) + } + + static func windowMetrics( + availableWidth: CGFloat + ) -> Metrics { + let safeAvailableWidth = max(availableWidth, 1) + let columnCount = windowGridColumnCount + let totalGridSpacing = CGFloat(columnCount - 1) * windowGridSpacing + let tileWidth = max( + 1, + floor((safeAvailableWidth - totalGridSpacing) / CGFloat(columnCount)) + ) + let previewWidth = max(1, tileWidth - tilePadding * 2) + let previewHeight = ceil(previewWidth / defaultPreviewAspectRatio) + let tileHeight = previewHeight + tileCaptionSpacing + windowCaptionHeight + tilePadding * 2 + return .init( + columnCount: columnCount, + tilePointSize: CGSize(width: tileWidth, height: tileHeight), + previewPointSize: CGSize(width: previewWidth, height: previewHeight), + gridSpacing: windowGridSpacing, + tilePadding: tilePadding + ) + } + + /// Physical pixel budget for local picker images at the given display + /// scale. Returned dimensions are clamped to UInt32. + @MainActor + static func physicalPixelBudget( + paneCount: Int, + scale: CGFloat + ) -> (width: UInt32, height: UInt32) { + physicalPixelBudget( + paneCount: paneCount, + availableWidth: currentSheetContentWidth(), + scale: scale + ) + } + + static func physicalPixelBudget( + paneCount: Int, + availableWidth: CGFloat, + scale: CGFloat + ) -> (width: UInt32, height: UInt32) { + let metrics = metrics(for: paneCount, availableWidth: availableWidth) + let safeScale = max(scale, 1) + let widthPx = (metrics.previewPointSize.width * safeScale).rounded(.up) + let heightPx = (metrics.previewPointSize.height * safeScale).rounded(.up) + return ( + clampUInt32(widthPx), + clampUInt32(heightPx) + ) + } + + @MainActor + static func windowPhysicalPixelBudget( + scale: CGFloat + ) -> (width: UInt32, height: UInt32) { + windowPhysicalPixelBudget( + availableWidth: currentSheetContentWidth(), + scale: scale + ) + } + + static func windowPhysicalPixelBudget( + availableWidth: CGFloat, + scale: CGFloat + ) -> (width: UInt32, height: UInt32) { + let metrics = windowMetrics(availableWidth: availableWidth) + let safeScale = max(scale, 1) + let widthPx = (metrics.previewPointSize.width * safeScale).rounded(.up) + let heightPx = (metrics.previewPointSize.height * safeScale).rounded(.up) + return ( + clampUInt32(widthPx), + clampUInt32(heightPx) + ) + } + + private static func clampUInt32(_ value: CGFloat) -> UInt32 { + guard value.isFinite, value > 0 else { return 1 } + let clamped = min(value, CGFloat(UInt32.max)) + return max(1, UInt32(clamped)) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/TerminalSelectionSheetStyle.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/TerminalSelectionSheetStyle.swift new file mode 100644 index 00000000..3d487103 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/TerminalSelectionSheetStyle.swift @@ -0,0 +1,243 @@ +import SwiftUI +import UIKit + +enum TerminalSelectionSheetPalette { + static let row = Color(uiColor: .secondarySystemFill) + static let stroke = Color.primary.opacity(0.12) + static let controlFill = Color(uiColor: .secondarySystemFill) + static let primary = Color.primary.opacity(0.92) + static let secondary = Color.secondary.opacity(0.78) + static let tertiary = Color.secondary.opacity(0.56) + + static func selectedStroke(_ chromeStyle: GhosttyTerminalChromeStyle) -> Color { + chromeStyle.selectedStroke + } +} + +/// Single source of truth for selector-sheet chrome heights. The scaffold +/// lays its rows out from these tokens and `sheetHeight` sums the same +/// tokens for the presentation detent, so the sheet always cleanly fits +/// its content: the views and the height math cannot drift apart. +enum TerminalSelectionSheetLayout { + static let headerTopPadding: CGFloat = 14 + static let headerHeight: CGFloat = 36 + static let headerBottomPadding: CGFloat = 12 + static let contextHeight: CGFloat = 16 + static let contextToContentSpacing: CGFloat = 12 + static let contentToActionsSpacing: CGFloat = 16 + static let actionBarHeight: CGFloat = 44 + static let actionsBottomPadding: CGFloat = 8 + + /// The `.height()` detent excludes the bottom safe area (verified by + /// measurement: adding it produced exactly one safe-area of slack), so + /// the sum covers only the content rows the scaffold lays out. + static func sheetHeight(gridHeight: CGFloat) -> CGFloat { + headerTopPadding + headerHeight + headerBottomPadding + + contextHeight + contextToContentSpacing + + gridHeight + + contentToActionsSpacing + actionBarHeight + actionsBottomPadding + } +} + +// Existing non-selector sheets keep their established palette name and styling. +typealias GhosttySheetPalette = TerminalSelectionSheetPalette + +struct TerminalSelectionSheetContextLabel: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: TerminalSelectionSheetLayout.contextHeight) + } +} + +struct TerminalSelectionSheetCloseButton: View { + let title: String + let accessibilityIdentifier: String + let action: () -> Void + + var body: some View { + Button { + Haptic.tap() + action() + } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .frame(width: 36, height: 36) + .background(TerminalSelectionSheetPalette.controlFill, in: Circle()) + } + .accessibilityLabel("Close \(title)") + .accessibilityIdentifier(accessibilityIdentifier) + } +} + +struct TerminalSelectionTileCheckmark: View { + let chromeStyle: GhosttyTerminalChromeStyle + + var body: some View { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(chromeStyle.accentForeground) + .frame(width: 22, height: 22) + .background(chromeStyle.accent, in: Circle()) + .overlay { + Circle().strokeBorder(Color.white.opacity(0.24), lineWidth: 0.5) + } + .accessibilityHidden(true) + } +} + +/// Shared anatomy for the terminal selector sheets. Owns its header (title +/// and close button) as plain content — no navigation bar — so the sheet's +/// natural height is fully defined by views the app controls, which is what +/// lets fitted presentation size the sheet to its content. +struct TerminalSelectionSheetScaffold: View { + @Environment(\.dismiss) private var dismiss + + let title: String + let context: String + let closeAccessibilityIdentifier: String + let content: Content + let actions: Actions + + init( + title: String, + context: String, + closeAccessibilityIdentifier: String, + @ViewBuilder content: () -> Content, + @ViewBuilder actions: () -> Actions + ) { + self.title = title + self.context = context + self.closeAccessibilityIdentifier = closeAccessibilityIdentifier + self.content = content() + self.actions = actions() + } + + var body: some View { + VStack(spacing: 0) { + HStack { + TerminalSelectionSheetCloseButton( + title: title, + accessibilityIdentifier: closeAccessibilityIdentifier, + action: dismiss.callAsFunction + ) + + Spacer(minLength: 0) + } + .overlay { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .lineLimit(1) + } + .padding(.horizontal, 16) + .frame(height: TerminalSelectionSheetLayout.headerHeight) + .padding(.top, TerminalSelectionSheetLayout.headerTopPadding) + .padding(.bottom, TerminalSelectionSheetLayout.headerBottomPadding) + + VStack(alignment: .leading, spacing: TerminalSelectionSheetLayout.contextToContentSpacing) { + TerminalSelectionSheetContextLabel(text: context) + .padding(.horizontal, 16) + + content + .frame(maxWidth: .infinity, alignment: .top) + } + .frame(maxWidth: .infinity, alignment: .top) + + actions + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + .frame(height: TerminalSelectionSheetLayout.actionBarHeight) + .padding(.top, TerminalSelectionSheetLayout.contentToActionsSpacing) + .padding(.bottom, TerminalSelectionSheetLayout.actionsBottomPadding) + } + } +} + +struct TerminalSelectionSheetActionButton: View { + let title: String + let systemName: String + let accessibilityIdentifier: String + let action: (() -> Void)? + + var body: some View { + let button = Button { + Haptic.tap() + action?() + } label: { + Label(title, systemImage: systemName) + .font(.body.weight(.semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .accessibilityIdentifier(accessibilityIdentifier) + .disabled(action == nil) + + if #available(iOS 26.0, *) { + button + .buttonStyle(.glass) + .buttonSizing(.flexible) + .controlSize(.regular) + } else { + button + .buttonStyle(.bordered) + .controlSize(.regular) + } + } +} + +extension View { + func terminalSelectionTileChrome( + isSelected: Bool, + chromeStyle: GhosttyTerminalChromeStyle + ) -> some View { + background(TerminalSelectionSheetPalette.row) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + isSelected + ? TerminalSelectionSheetPalette.selectedStroke(chromeStyle) + : TerminalSelectionSheetPalette.stroke, + lineWidth: isSelected ? 1.25 : 1 + ) + } + .overlay(alignment: .topTrailing) { + if isSelected { + TerminalSelectionTileCheckmark(chromeStyle: chromeStyle) + .padding(6) + } + } + } + + func terminalSelectionSheetPresentation( + colorScheme: ColorScheme, + chromeStyle: GhosttyTerminalChromeStyle + ) -> some View { + presentationDetents([.medium]) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.hidden) + .terminalSelectionSheetPresentationBackground() + .ghosttyTerminalChromePresentation( + colorScheme, + chromeStyle: chromeStyle + ) + } + + @ViewBuilder + func terminalSelectionSheetPresentationBackground() -> some View { + if #available(iOS 26.0, *) { + self + } else { + self.presentationBackground(.regularMaterial) + } + } + +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/DeterministicTmuxControlTransport.swift b/MoriRemote/MoriRemoteTerminal/Tmux/DeterministicTmuxControlTransport.swift new file mode 100644 index 00000000..454caee8 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/DeterministicTmuxControlTransport.swift @@ -0,0 +1,43 @@ +import Foundation + +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/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift b/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift new file mode 100644 index 00000000..51c22086 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift @@ -0,0 +1,564 @@ +import Foundation + +enum GhosttyRuntimeTrace { + static let paneSwitchFlow = "tmux.paneSwitch" + static let isEnabled = ProcessInfo.processInfo.environment["REMUX_TRACE_GHOSTTY_IO"] == "1" + private static let latencyMode = ProcessInfo.processInfo.environment["REMUX_TRACE_LATENCY"] + static let latencyEnabled = latencyMode == "1" || latencyMode == "minimal" + private static let verboseLatencyEnabled = latencyMode == "1" + static let diagnosticsEnabled = isEnabled || + ProcessInfo.processInfo.environment["REMUX_TRACE_GHOSTTY_DIAGNOSTICS"] == "1" + static let perfEnabled = ProcessInfo.processInfo.environment["REMUX_TRACE_PERF"] == "1" + static let tmuxViewportEnabled = ProcessInfo.processInfo.environment["REMUX_TRACE_TMUX_VIEWPORT"] == "1" + + private static let latencyProbeStore = GhosttyLatencyProbeStore() + private static let latencyMarkerAccumulator = GhosttyLatencyMarkerAccumulator() + private static let flowTraceStore = GhosttyFlowTraceStore() + + static func nowNanos() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + /// Wall-clock nanoseconds used only to correlate Remux milestones with + /// renderer completion timestamps emitted inside libghostty. Durations + /// continue to use the monotonic clock above. + static func wallNanos() -> UInt64 { + var value = timespec() + clock_gettime(CLOCK_REALTIME, &value) + return UInt64(value.tv_sec) * 1_000_000_000 + UInt64(value.tv_nsec) + } + + static func diagnostics(_ message: @autoclosure () -> String) { + guard diagnosticsEnabled else { return } + NSLog("Remux diag %@", message()) + } + + /// Lightweight perf signpost. Gated on REMUX_TRACE_PERF=1 so it's a true + /// no-op (and the message autoclosure is not evaluated) in normal builds. + /// `thread` is captured because some Ghostty callbacks fire off-main and + /// we want to see which queue is actually doing the work. + static func perf(_ message: @autoclosure () -> String) { + guard perfEnabled else { return } + let threadLabel = Thread.isMainThread ? "main" : (Thread.current.name ?? "bg") + NSLog("Remux perf t=%llu thread=%@ %@", nowNanos(), threadLabel, message()) + } + + /// Wraps a block, recording its entry thread and elapsed duration when + /// REMUX_TRACE_PERF=1. Always cheap when disabled; the only cost is one + /// `nowNanos()` call before invoking the body. + static func perfMeasure(_ label: @autoclosure () -> String, _ body: () -> T) -> T { + guard perfEnabled else { return body() } + let entryThread = Thread.isMainThread ? "main" : (Thread.current.name ?? "bg") + let start = nowNanos() + let result = body() + NSLog( + "Remux perf t=%llu thread=%@ %@ elapsed_ms=%@", + start, + entryThread, + label(), + elapsedMilliseconds(from: start) + ) + return result + } + + static func latency(_ message: @autoclosure () -> String) { + guard latencyEnabled else { return } + let resolvedMessage = message() + guard verboseLatencyEnabled || isMinimalLatencyMessage(resolvedMessage) else { return } + + NSLog("Remux latency t=%llu %@", nowNanos(), resolvedMessage) + } + + static func tmuxViewport(_ message: @autoclosure () -> String) { + guard tmuxViewportEnabled else { return } + NSLog("Remux tmuxViewport t=%llu %@", nowNanos(), message()) + } + + static func viewportDescription(_ viewport: TmuxControlViewport) -> String { + "\(viewport.columns)x\(viewport.rows) px=\(viewport.pixelWidth)x\(viewport.pixelHeight)" + } + + static func formatTraceFields(_ fields: [String: String]) -> String { + fields.keys.sorted().map { key in + "\(key)=\(sanitizeTraceValue(fields[key] ?? ""))" + }.joined(separator: " ") + } + + private static func sanitizeTraceValue(_ value: String) -> String { + value + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } + + static func flowBegin( + _ flow: String, + event: String, + fields: @autoclosure () -> [String: String] = [:], + startedAt: UInt64? = nil + ) { + guard flowTraceEnabled else { return } + let timestamp = startedAt ?? nowNanos() + flowTraceStore.begin(flow: flow, at: timestamp) + logFlow(flow, event: event, startedAt: timestamp, at: timestamp, fields: fields()) + } + + static func flowEvent( + _ flow: String, + event: String, + fields: @autoclosure () -> [String: String] = [:], + at timestamp: UInt64? = nil + ) { + guard flowTraceEnabled else { return } + let eventTimestamp = timestamp ?? nowNanos() + let start = flowTraceStore.start(for: flow) ?? eventTimestamp + logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) + } + + static func flowEventIfActive( + _ flow: String, + event: String, + fields: @autoclosure () -> [String: String] = [:], + at timestamp: UInt64? = nil + ) { + guard flowTraceEnabled else { return } + guard let start = flowTraceStore.start(for: flow) else { return } + let eventTimestamp = timestamp ?? nowNanos() + logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) + } + + static func flowEventSince( + _ flow: String, + event: String, + startedAt: UInt64, + fields: @autoclosure () -> [String: String] = [:], + at timestamp: UInt64? = nil + ) { + guard flowTraceEnabled else { return } + let eventTimestamp = timestamp ?? nowNanos() + logFlow(flow, event: event, startedAt: startedAt, at: eventTimestamp, fields: fields()) + } + + /// Like `flowEventIfActive`, but logs only the first occurrence of + /// `event` per flow lifetime — for emission sites that fire + /// repeatedly (SwiftUI view init, layout passes) where only the + /// first occurrence is the milestone. + static func flowEventOnce( + _ flow: String, + event: String, + fields: @autoclosure () -> [String: String] = [:], + at timestamp: UInt64? = nil + ) { + guard flowTraceEnabled else { return } + guard let start = flowTraceStore.markOnce(flow: flow, event: event) else { return } + let eventTimestamp = timestamp ?? nowNanos() + logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) + } + + static func flowEnd( + _ flow: String, + event: String, + fields: @autoclosure () -> [String: String] = [:], + at timestamp: UInt64? = nil + ) { + guard flowTraceEnabled else { return } + let eventTimestamp = timestamp ?? nowNanos() + let start = flowTraceStore.end(flow: flow) ?? eventTimestamp + logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) + } + + static func flowEndIfActive( + _ flow: String, + event: String, + fields: @autoclosure () -> [String: String] = [:], + at timestamp: UInt64? = nil + ) { + guard flowTraceEnabled else { return } + guard let start = flowTraceStore.end(flow: flow) else { return } + let eventTimestamp = timestamp ?? nowNanos() + logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) + } + + static func isFlowActive(_ flow: String) -> Bool { + guard flowTraceEnabled else { return false } + return flowTraceStore.start(for: flow) != nil + } + + static func flowStartIfActive(_ flow: String) -> UInt64? { + guard flowTraceEnabled else { return nil } + return flowTraceStore.start(for: flow) + } + + static func elapsedMilliseconds(from start: UInt64, to end: UInt64 = nowNanos()) -> String { + String(format: "%.3f", Double(end &- start) / 1_000_000) + } + + static func registerLatencyProbe(marker: String, label: String, submittedAt: UInt64? = nil) { + guard latencyEnabled else { return } + let timestamp = submittedAt ?? nowNanos() + latencyProbeStore.register(marker: marker, label: label, submittedAt: timestamp) + latency("probe_register label=\(label) marker=\(marker)") + } + + static func registerLatencyMarkers(in text: String, label: String, submittedAt: UInt64? = nil) { + guard latencyEnabled else { return } + let timestamp = submittedAt ?? nowNanos() + + for marker in latencyMarkerAccumulator.append(text) { + registerLatencyProbe(marker: marker, label: label, submittedAt: timestamp) + } + } + + static func observeInboundData(_ data: Data, source: String) { + guard latencyEnabled, !data.isEmpty else { return } + + let now = nowNanos() + let hits = latencyProbeStore.recordHits(in: data) + for hit in hits { + latency( + "probe_hit label=\(hit.label) marker=\(hit.marker) hit=\(hit.hitCount) source=\(source) bytes=\(data.count) offset=\(hit.offset) delta_ms=\(elapsedMilliseconds(from: hit.submittedAt, to: now)) preview=\(preview(data, limit: 160))" + ) + flowEventIfActive( + "terminal.input", + event: "probe.hit", + fields: [ + "label": hit.label, + "marker": hit.marker, + "source": source, + "delta_ms": elapsedMilliseconds(from: hit.submittedAt, to: now), + ], + at: now + ) + } + } + + static func preview(_ data: Data, limit: Int = 48) -> String { + data + .prefix(limit) + .map { byte in + if byte >= 0x20, byte <= 0x7E { + return String(UnicodeScalar(byte)) + } + + return String(format: "\\x%02X", byte) + } + .joined() + } + + private static func isMinimalLatencyMessage(_ message: String) -> Bool { + message.hasPrefix("probe_") || + message.hasPrefix("debugLatencyProbe") + } + + static let flowTraceEnabled = perfEnabled || latencyEnabled || + ProcessInfo.processInfo.environment["REMUX_TRACE_FLOWS"] == "1" + + private static func logFlow( + _ flow: String, + event: String, + startedAt: UInt64, + at timestamp: UInt64, + fields: [String: String] + ) { + var parts = [ + "flow=\(flow)", + "event=\(event)", + "since_ms=\(elapsedMilliseconds(from: startedAt, to: timestamp))", + ] + for (key, value) in fields.sorted(by: { $0.key < $1.key }) { + parts.append("\(key)=\(normalizeFieldValue(value))") + } + NSLog("Remux flow t=%llu %@", timestamp, parts.joined(separator: " ")) + } + + private static func normalizeFieldValue(_ value: String) -> String { + value + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } +} + +struct RemuxTransportStartupTrace: Sendable { + private let flowID: String? + private let startedAt: UInt64 + + init(flowID: String?, startedAt: UInt64 = GhosttyRuntimeTrace.nowNanos()) { + self.flowID = flowID + self.startedAt = startedAt + } + + func event( + _ name: String, + fields: [String: String] = [:], + at timestamp: UInt64 = GhosttyRuntimeTrace.nowNanos() + ) { + GhosttyRuntimeTrace.latency( + "transport.startup.\(name) since_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: startedAt, to: timestamp))\(latencyFields(fields))" + ) + + if let flowID { + GhosttyRuntimeTrace.flowEventIfActive( + flowID, + event: "transport.startup.\(name)", + fields: fields, + at: timestamp + ) + } + } + + func stage( + _ name: String, + fields: [String: String] = [:], + operation: () async throws -> T + ) async throws -> T { + let stageStart = GhosttyRuntimeTrace.nowNanos() + event("\(name).begin", fields: fields, at: stageStart) + + do { + let result = try await operation() + let finishedAt = GhosttyRuntimeTrace.nowNanos() + event( + "\(name).end", + fields: stageFields(fields, stageStart: stageStart, finishedAt: finishedAt), + at: finishedAt + ) + return result + } catch { + let failedAt = GhosttyRuntimeTrace.nowNanos() + var failureFields = stageFields(fields, stageStart: stageStart, finishedAt: failedAt) + failureFields["error"] = String(describing: error) + event("\(name).failed", fields: failureFields, at: failedAt) + throw error + } + } + + private func stageFields( + _ fields: [String: String], + stageStart: UInt64, + finishedAt: UInt64 + ) -> [String: String] { + var stageFields = fields + stageFields["elapsed_ms"] = GhosttyRuntimeTrace.elapsedMilliseconds(from: stageStart, to: finishedAt) + return stageFields + } + + private func latencyFields(_ fields: [String: String]) -> String { + guard !fields.isEmpty else { return "" } + + return " " + fields + .sorted(by: { $0.key < $1.key }) + .map { key, value in "\(key)=\(sanitizeLatencyField(value))" } + .joined(separator: " ") + } + + private func sanitizeLatencyField(_ value: String) -> String { + value + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } +} + +enum GhosttyTmuxActionTrace { + enum Action: Equatable, Sendable { + case newWindow + case splitPane + + var flow: String { + switch self { + case .newWindow: + "tmux.newWindow" + case .splitPane: + "tmux.splitPane" + } + } + + } + + static func traceActiveTopologyFlows( + event: String, + fields: @autoclosure () -> [String: String] = [:], + at timestamp: UInt64? = nil + ) { + guard GhosttyRuntimeTrace.flowTraceEnabled else { return } + + var resolvedFields: [String: String]? + for action in [Action.newWindow, .splitPane] where GhosttyRuntimeTrace.isFlowActive(action.flow) { + if resolvedFields == nil { + resolvedFields = fields() + } + GhosttyRuntimeTrace.flowEventIfActive( + action.flow, + event: event, + fields: resolvedFields ?? [:], + at: timestamp + ) + } + } +} + +final class GhosttyFlowTraceStore: @unchecked Sendable { + private let lock = NSLock() + private var starts: [String: UInt64] = [:] + private var onceEvents: Set = [] + + func begin(flow: String, at timestamp: UInt64) { + lock.withLock { + starts[flow] = timestamp + clearOnceEventsLocked(flow: flow) + } + } + + func start(for flow: String) -> UInt64? { + lock.withLock { + starts[flow] + } + } + + func end(flow: String) -> UInt64? { + lock.withLock { + clearOnceEventsLocked(flow: flow) + return starts.removeValue(forKey: flow) + } + } + + /// First occurrence of `event` for an active flow: returns the + /// flow's start time exactly once per flow lifetime, nil after + /// (and always nil for inactive flows). A new `begin` re-arms. + func markOnce(flow: String, event: String) -> UInt64? { + lock.withLock { + guard let start = starts[flow] else { return nil } + guard onceEvents.insert("\(flow)#\(event)").inserted else { return nil } + return start + } + } + + private func clearOnceEventsLocked(flow: String) { + let prefix = "\(flow)#" + onceEvents = onceEvents.filter { !$0.hasPrefix(prefix) } + } +} + +final class GhosttyLatencyMarkerAccumulator: @unchecked Sendable { + private let lock = NSLock() + private let prefix = "__REMUX_LATENCY_" + private let maxBufferedCharacters: Int + private var buffer = "" + + init(maxBufferedCharacters: Int = 256) { + self.maxBufferedCharacters = max(32, maxBufferedCharacters) + } + + func append(_ text: String) -> [String] { + lock.withLock { + appendLocked(text) + } + } + + private func appendLocked(_ text: String) -> [String] { + guard !text.isEmpty else { return [] } + + buffer.append(text) + var markers: [String] = [] + + while true { + guard let prefixRange = buffer.range(of: prefix) else { + preservePossiblePrefixSuffix() + return markers + } + + if prefixRange.lowerBound > buffer.startIndex { + buffer.removeSubrange(buffer.startIndex.. maxBufferedCharacters else { return } + buffer = String(buffer.suffix(maxBufferedCharacters)) + } +} + +final class GhosttyLatencyProbeStore: @unchecked Sendable { + struct Hit { + let marker: String + let label: String + let submittedAt: UInt64 + let hitCount: Int + let offset: Int + } + + private struct Probe { + let marker: String + let markerData: Data + let label: String + let submittedAt: UInt64 + var hitCount: Int + } + + private let lock = NSLock() + private var probes: [String: Probe] = [:] + private var recentData = Data() + + func register(marker: String, label: String, submittedAt: UInt64) { + lock.withLock { + probes[marker] = Probe( + marker: marker, + markerData: Data(marker.utf8), + label: label, + submittedAt: submittedAt, + hitCount: 0 + ) + } + } + + func recordHits(in data: Data) -> [Hit] { + lock.withLock { + var hits: [Hit] = [] + var searchableData = recentData + let previousByteCount = searchableData.count + searchableData.append(data) + + for marker in probes.keys.sorted() { + guard var probe = probes[marker] else { continue } + guard let range = searchableData.range(of: probe.markerData) else { continue } + guard range.upperBound > previousByteCount else { continue } + + probe.hitCount += 1 + probes[marker] = probe + hits.append( + Hit( + marker: probe.marker, + label: probe.label, + submittedAt: probe.submittedAt, + hitCount: probe.hitCount, + offset: max(0, range.lowerBound - previousByteCount) + ) + ) + } + + if let maxMarkerLength = probes.values.map(\.markerData.count).max(), maxMarkerLength > 1 { + recentData = searchableData.suffix(maxMarkerLength - 1) + } else { + recentData.removeAll(keepingCapacity: true) + } + + return hits + } + } +} 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..7d18fb2e --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift @@ -0,0 +1,110 @@ +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() + } +} + +/// Owns the reversible identity boundary between tmux's typed numeric IDs and +/// the UUIDs used by terminal presentation projections. +struct TmuxTerminalIdentityRegistry { + private var paneSurfaceIDsByTmuxID: [TmuxPaneID: UUID] = [:] + private var paneTmuxIDsBySurfaceID: [UUID: TmuxPaneID] = [:] + private var windowSurfaceIDsByTmuxID: [TmuxWindowID: UUID] = [:] + private var windowTmuxIDsBySurfaceID: [UUID: TmuxWindowID] = [:] + + mutating func surfaceID(for paneID: TmuxPaneID) -> UUID { + if let existing = paneSurfaceIDsByTmuxID[paneID] { + return existing + } + + let surfaceID = UUID() + paneSurfaceIDsByTmuxID[paneID] = surfaceID + paneTmuxIDsBySurfaceID[surfaceID] = paneID + return surfaceID + } + + func paneID(for surfaceID: UUID) -> TmuxPaneID? { + paneTmuxIDsBySurfaceID[surfaceID] + } + + mutating func surfaceID(for windowID: TmuxWindowID) -> UUID { + if let existing = windowSurfaceIDsByTmuxID[windowID] { + return existing + } + + let surfaceID = UUID() + windowSurfaceIDsByTmuxID[windowID] = surfaceID + windowTmuxIDsBySurfaceID[surfaceID] = windowID + return surfaceID + } + + func windowID(for surfaceID: UUID) -> TmuxWindowID? { + windowTmuxIDsBySurfaceID[surfaceID] + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPanePreviewImageCache.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPanePreviewImageCache.swift new file mode 100644 index 00000000..611ef797 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPanePreviewImageCache.swift @@ -0,0 +1,87 @@ +import CoreGraphics +import Foundation + +/// Byte-bounded last-known pane thumbnails. Cold panes retain their first +/// local pane-geometry render; visiting one replaces it with the pane's latest +/// full-viewport image. The cache is deliberately a Remux concern: libghostty +/// renders surfaces but does not know that this client presents one tmux pane +/// per phone viewport. +struct TmuxPanePreviewImageCache { + struct Entry { + let preview: GhosttyPanePreviewSession.RenderedPreview + let byteCost: Int + var lastAccess: UInt64 + } + + let byteLimit: Int + private(set) var entries: [TmuxPaneID: Entry] = [:] + private(set) var totalByteCost = 0 + private var accessSequence: UInt64 = 0 + + init(byteLimit: Int) { + precondition(byteLimit > 0) + self.byteLimit = byteLimit + } + + mutating func preview( + for paneID: TmuxPaneID + ) -> GhosttyPanePreviewSession.RenderedPreview? { + guard var entry = entries[paneID] else { return nil } + accessSequence &+= 1 + entry.lastAccess = accessSequence + entries[paneID] = entry + return entry.preview + } + + @discardableResult + mutating func store( + _ preview: GhosttyPanePreviewSession.RenderedPreview, + for paneID: TmuxPaneID + ) -> [TmuxPaneID] { + let image = preview.image + let (byteCost, overflow) = image.bytesPerRow.multipliedReportingOverflow(by: image.height) + guard !overflow, byteCost > 0, byteCost <= byteLimit else { return [] } + + if let replaced = entries.removeValue(forKey: paneID) { + totalByteCost -= replaced.byteCost + } + accessSequence &+= 1 + entries[paneID] = Entry( + preview: preview, + byteCost: byteCost, + lastAccess: accessSequence + ) + totalByteCost += byteCost + + var evictedPaneIDs: [TmuxPaneID] = [] + while totalByteCost > byteLimit, + let oldest = entries.min(by: { $0.value.lastAccess < $1.value.lastAccess }) { + entries.removeValue(forKey: oldest.key) + totalByteCost -= oldest.value.byteCost + evictedPaneIDs.append(oldest.key) + } + return evictedPaneIDs + } + + @discardableResult + mutating func retainOnly(_ paneIDs: Set) -> [TmuxPaneID] { + let removedPaneIDs = entries.keys.filter { !paneIDs.contains($0) } + for paneID in removedPaneIDs { + if let removed = entries.removeValue(forKey: paneID) { + totalByteCost -= removed.byteCost + } + } + return removedPaneIDs + } + + mutating func remove(_ paneID: TmuxPaneID) { + guard let removed = entries.removeValue(forKey: paneID) else { return } + totalByteCost -= removed.byteCost + } + + mutating func removeAll() { + entries.removeAll() + totalByteCost = 0 + accessSequence = 0 + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift new file mode 100644 index 00000000..6ec55dcd --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift @@ -0,0 +1,1054 @@ +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(set) var lastFullViewportProvenance: + GhosttyPanePreviewSession.FullViewportProvenance? + private var fullViewportFrameNeedsRefresh = false + 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 previewRelay = PreviewRelay() + 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 enum FramePublication { + case ready + case captured(GhosttyIOSurfaceFrame) + } + + private final class FramePublicationWait: @unchecked Sendable { + let transientVisibility: Bool + let keepVisibleAfterSuccess: Bool + let captureOwnedPixels: Bool + let expectedWidth: UInt32 + let expectedHeight: UInt32 + var observation: NSKeyValueObservation? + var continuation: CheckedContinuation? + + init( + transientVisibility: Bool, + keepVisibleAfterSuccess: Bool, + captureOwnedPixels: Bool, + expectedWidth: UInt32, + expectedHeight: UInt32 + ) { + self.transientVisibility = transientVisibility + self.keepVisibleAfterSuccess = keepVisibleAfterSuccess + self.captureOwnedPixels = captureOwnedPixels + self.expectedWidth = expectedWidth + self.expectedHeight = expectedHeight + } + } + + private final class PreviewRelay: @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 { + enum TrackedWriteTransport { + case exact + case literal + } + + private struct TrackedWrite { + let transport: TrackedWriteTransport + let completion: @Sendable (Bool) -> Void + } + + let controller: TmuxSessionController + let paneID: TmuxPaneID + let failureRelay: FailureRelay + private var trackedWrite: TrackedWrite? + + 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 from the + // output feed. `trackedWrite` is single-threaded because of + // this contract. + 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 } + if let trackedWrite = box.trackedWrite { + box.trackedWrite = nil + let bytes = Data(bytes: pointer, count: count) + let admitted = switch trackedWrite.transport { + case .exact: + box.controller.sendTrackedInput( + paneID: box.paneID, + bytes, + completion: trackedWrite.completion + ) + case .literal: + box.controller.sendTrackedLiteralInput( + paneID: box.paneID, + bytes, + completion: trackedWrite.completion + ) + } + if !admitted { + trackedWrite.completion(false) + } + return admitted + } + return box.controller.sendInput( + paneID: box.paneID, + Data(bytes: pointer, count: count) + ) + } + + func performTrackedWrite( + transport: TrackedWriteTransport, + completion: @escaping @Sendable (Bool) -> Void, + _ operation: () -> Bool + ) { + MainActor.preconditionIsolated() + precondition(trackedWrite == nil) + trackedWrite = TrackedWrite(transport: transport, completion: completion) + _ = operation() + guard trackedWrite != nil else { return } + trackedWrite = nil + completion(false) + } + + 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) + previewRelay.pane = self + } + + var rawSurface: ghostty_terminal_surface_t? { renderer?.handle } + + func sendPasteAwaitingCommandCompletion(_ text: String) async -> Bool { + guard !text.isEmpty, lifecycle == .active, let renderer else { return false } + return await performInputAwaitingCommandCompletion(transport: .literal) { + renderer.control.sendPaste(text) + } + } + + func sendKeyEventAwaitingCommandCompletion( + _ event: GhosttySurfaceKeyEvent + ) async -> Bool { + guard lifecycle == .active, let renderer else { return false } + return await performInputAwaitingCommandCompletion(transport: .exact) { + renderer.control.sendKeyEvent(event) + } + } + + private func performInputAwaitingCommandCompletion( + transport: CallbackBox.TrackedWriteTransport, + _ operation: () -> Bool + ) async -> Bool { + await withCheckedContinuation { continuation in + callbackBox.performTrackedWrite( + transport: transport, + completion: { continuation.resume(returning: $0) }, + operation + ) + } + } + + 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 + applyPresentationActivity() + return managed + } + + func setPresented(_ presented: Bool) { + guard lifecycle != .closed, lifecycle != .closing else { return } + self.presented = presented + if presented { + // 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 hasCurrentFullViewportFrame() { + completion(true) + return + } + + guard applyDisplayMetrics(canonicalViewportMetrics), + let rendererLayer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer) + else { + completion(false) + return + } + + let expected = canonicalViewportMetrics + presentationTask = Task { @MainActor [weak self] in + guard let self else { return } + let publication = await matchingPublication( + on: rendererLayer, + transientVisibility: true, + keepVisibleAfterSuccess: true, + captureOwnedPixels: false, + expectedWidth: expected.pixelWidth, + expectedHeight: expected.pixelHeight + ) + guard presentationGeneration == generation else { return } + presentationTask = nil + guard !Task.isCancelled, + expected == canonicalViewportMetrics, + publication != nil + else { + completion(false) + return + } + lastFullViewportProvenance = .init( + surfaceID: instanceID.rawValue, + pixelWidth: expected.pixelWidth, + pixelHeight: expected.pixelHeight + ) + fullViewportFrameNeedsRefresh = false + 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, lastFullViewportProvenance != nil { + fullViewportFrameNeedsRefresh = true + } + 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 + // 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() + lastFullViewportProvenance = nil + fullViewportFrameNeedsRefresh = false + + 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) + 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 + cancelPresentationPreparation() + previewRelay.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() + } + } + + /// Cancel any transient detached render before pane selection owns the + /// surface. Cancellation hides immediately and invalidates KVO, so no + /// delayed completion can hide the newly selected pane. + func cancelPickerCaptureForPresentation() { + guard framePublicationWait?.keepVisibleAfterSuccess == false else { return } + cancelFramePublicationWait() + } + + func capturePickerPreview( + columns: UInt32, + rows: UInt32, + budget: GhosttyPanePreviewSession.PixelBudget + ) async -> GhosttyPanePreviewSession.RenderedPreview? { + guard lifecycle == .active, + framePublicationWait == nil, + presentationTask == nil, + columns > 0, rows > 0, + let rendererLayer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer) + else { return nil } + + if let provenance = lastFullViewportProvenance { + if fullViewportFrameNeedsRefresh { + guard !presented, + applyDisplayMetrics(canonicalViewportMetrics), + let publication = await matchingPublication( + on: rendererLayer, + transientVisibility: true, + keepVisibleAfterSuccess: false, + captureOwnedPixels: true, + expectedWidth: canonicalViewportMetrics.pixelWidth, + expectedHeight: canonicalViewportMetrics.pixelHeight + ), + case .captured(let frame) = publication, + let image = await makePreviewImage(from: frame, budget: budget) + else { return nil } + let refreshedProvenance = GhosttyPanePreviewSession.FullViewportProvenance( + surfaceID: instanceID.rawValue, + pixelWidth: canonicalViewportMetrics.pixelWidth, + pixelHeight: canonicalViewportMetrics.pixelHeight + ) + lastFullViewportProvenance = refreshedProvenance + fullViewportFrameNeedsRefresh = false + return .init(image: image, source: .fullViewport(refreshedProvenance)) + } + + if let frame = retainedFullViewportFrame( + in: rendererLayer, + provenance: provenance + ), + let image = await makePreviewImage(from: frame, budget: budget) { + return .init(image: image, source: .fullViewport(provenance)) + } + + guard presented, + let current = renderer?.control.currentSize(), + isViewportSized(current), + let dimensions = GhosttyIOSurfaceFrame.dimensions(in: rendererLayer), + dimensions.width == Int(canonicalViewportMetrics.pixelWidth), + dimensions.height == Int(canonicalViewportMetrics.pixelHeight), + let frame = try? GhosttyIOSurfaceFrame.read(from: rendererLayer) + else { return nil } + let currentProvenance = GhosttyPanePreviewSession.FullViewportProvenance( + surfaceID: instanceID.rawValue, + pixelWidth: current.width_px, + pixelHeight: current.height_px + ) + lastFullViewportProvenance = currentProvenance + fullViewportFrameNeedsRefresh = false + guard let image = await makePreviewImage(from: frame, budget: budget) else { + return nil + } + return .init(image: image, source: .fullViewport(currentProvenance)) + } + + guard !presented, + resizeForPickerGrid(columns: columns, rows: rows), + let current = renderer?.control.currentSize(), + current.columns == columns, + current.rows == rows, + let publication = await matchingPublication( + on: rendererLayer, + transientVisibility: true, + keepVisibleAfterSuccess: false, + captureOwnedPixels: true, + expectedWidth: current.width_px, + expectedHeight: current.height_px + ), + case .captured(let frame) = publication, + let image = await makePreviewImage(from: frame, budget: budget) + else { return nil } + + let source: GhosttyPanePreviewSession.PreviewSource + if isViewportSized(current) { + let provenance = GhosttyPanePreviewSession.FullViewportProvenance( + surfaceID: instanceID.rawValue, + pixelWidth: current.width_px, + pixelHeight: current.height_px + ) + lastFullViewportProvenance = provenance + fullViewportFrameNeedsRefresh = false + source = .fullViewport(provenance) + } else { + source = .paneGeometry(.init( + surfaceID: instanceID.rawValue, + columns: columns, + rows: rows + )) + } + return .init(image: image, source: source) + } + + 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 + cancelPresentationPreparation() + previewRelay.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 matchingPublication( + on layer: CALayer, + transientVisibility: Bool, + keepVisibleAfterSuccess: Bool, + captureOwnedPixels: Bool, + expectedWidth: UInt32, + expectedHeight: UInt32 + ) async -> FramePublication? { + // Drain any stale display invalidation before observing. The visibility + // mailbox below is ordered after resize and is what requests the real + // updateFrame/draw whose IOSurface publication we accept. + layer.displayIfNeeded() + return await withCheckedContinuation { continuation in + guard !Task.isCancelled, framePublicationWait == nil else { + continuation.resume(returning: nil) + return + } + let wait = FramePublicationWait( + transientVisibility: transientVisibility, + keepVisibleAfterSuccess: keepVisibleAfterSuccess, + captureOwnedPixels: captureOwnedPixels, + expectedWidth: expectedWidth, + expectedHeight: expectedHeight + ) + let layerReference = LayerReference(layer) + let relay = previewRelay + 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) + } + } + } + if transientVisibility { + _ = renderer?.control.setFocused(keepVisibleAfterSuccess) + guard renderer?.control.setVisible(true) == true else { + finishFramePublicationWait(wait, publication: nil) + return + } + } + } + } + + private func finishFramePublicationIfMatching( + _ wait: FramePublicationWait, + layer: CALayer + ) { + guard framePublicationWait === wait, + let dimensions = GhosttyIOSurfaceFrame.dimensions(in: layer) + else { return } + guard dimensions.width == Int(wait.expectedWidth), + dimensions.height == Int(wait.expectedHeight) + else { return } + guard wait.captureOwnedPixels else { + finishFramePublicationWait(wait, publication: .ready) + return + } + let frame: GhosttyIOSurfaceFrame + do { + frame = try GhosttyIOSurfaceFrame.read(from: layer) + } catch { + GhosttyRuntimeTrace.diagnostics( + "tmuxPane.frameRead failed pane=\(paneID) error=\(String(describing: error))" + ) + finishFramePublicationWait(wait, publication: nil) + return + } + finishFramePublicationWait(wait, publication: .captured(frame)) + } + + private func finishFramePublicationWait( + _ wait: FramePublicationWait, + publication: FramePublication? + ) { + guard framePublicationWait === wait else { return } + wait.observation?.invalidate() + wait.observation = nil + framePublicationWait = nil + if wait.transientVisibility, + (publication == nil || !wait.keepVisibleAfterSuccess), + !presented { + _ = renderer?.control.setVisible(false) + } + let continuation = wait.continuation + wait.continuation = nil + continuation?.resume(returning: publication) + } + + private func cancelFramePublicationWait() { + guard let wait = framePublicationWait else { return } + finishFramePublicationWait(wait, publication: nil) + } + + private func resizeForPickerGrid(columns: UInt32, rows: UInt32) -> Bool { + guard let renderer else { return false } + let current = renderer.control.currentSize() + guard current.columns > 0, current.rows > 0, + current.cell_width_px > 0, current.cell_height_px > 0, + let width = Self.pixelDimension( + targetCells: columns, + currentCells: UInt32(current.columns), + cellPixels: current.cell_width_px, + currentPixels: current.width_px + ), + let height = Self.pixelDimension( + targetCells: rows, + currentCells: UInt32(current.rows), + cellPixels: current.cell_height_px, + currentPixels: current.height_px + ), + applyPickerSize(width: width, height: height) + else { return false } + + let measured = renderer.control.currentSize() + if measured.columns == columns, measured.rows == rows { return true } + + let correctedWidth = Self.correctedPixelDimension( + currentPixels: measured.width_px, + actualCells: UInt32(measured.columns), + targetCells: columns, + cellPixels: measured.cell_width_px + ) + let correctedHeight = Self.correctedPixelDimension( + currentPixels: measured.height_px, + actualCells: UInt32(measured.rows), + targetCells: rows, + cellPixels: measured.cell_height_px + ) + guard let correctedWidth, let correctedHeight, + applyPickerSize(width: correctedWidth, height: correctedHeight) + else { return false } + let verified = renderer.control.currentSize() + return verified.columns == columns && verified.rows == rows + } + + private func applyPickerSize(width: UInt32, height: UInt32) -> Bool { + applyDisplayMetrics(.init( + contentScale: canonicalViewportMetrics.contentScale, + pixelWidth: width, + pixelHeight: height + )) + } + + private func makePreviewImage( + from frame: GhosttyIOSurfaceFrame, + budget: GhosttyPanePreviewSession.PixelBudget + ) async -> CGImage? { + let paneID = paneID + return await Task.detached(priority: .userInitiated) { + do { + return try frame.image( + maxWidth: budget.width, + maxHeight: budget.height + ) + } catch { + GhosttyRuntimeTrace.diagnostics( + "tmuxPane.previewRead failed pane=\(paneID) error=\(String(describing: error))" + ) + return nil + } + }.value + } + + private func isViewportSized(_ size: ghostty_surface_size_s) -> Bool { + size.width_px == canonicalViewportMetrics.pixelWidth + && size.height_px == canonicalViewportMetrics.pixelHeight + } + + private func hasCurrentFullViewportFrame() -> Bool { + guard !fullViewportFrameNeedsRefresh, + let provenance = lastFullViewportProvenance, + provenance.pixelWidth == canonicalViewportMetrics.pixelWidth, + provenance.pixelHeight == canonicalViewportMetrics.pixelHeight, + let layer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer) + else { return false } + return publishedFrameMatches(in: layer, provenance: provenance) + } + + private func retainedFullViewportFrame( + in layer: CALayer, + provenance: GhosttyPanePreviewSession.FullViewportProvenance + ) -> GhosttyIOSurfaceFrame? { + guard publishedFrameMatches(in: layer, provenance: provenance) else { + return nil + } + return try? GhosttyIOSurfaceFrame.read(from: layer) + } + + private func publishedFrameMatches( + in layer: CALayer, + provenance: GhosttyPanePreviewSession.FullViewportProvenance + ) -> Bool { + guard provenance.surfaceID == instanceID.rawValue, + let dimensions = GhosttyIOSurfaceFrame.dimensions(in: layer) + else { return false } + return dimensions.width == Int(provenance.pixelWidth) + && dimensions.height == Int(provenance.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 + } + + private static func pixelDimension( + targetCells: UInt32, + currentCells: UInt32, + cellPixels: UInt32, + currentPixels: UInt32 + ) -> UInt32? { + let currentGridPixels = UInt64(currentCells) * UInt64(cellPixels) + guard UInt64(currentPixels) >= currentGridPixels else { return nil } + let padding = UInt64(currentPixels) - currentGridPixels + let target = UInt64(targetCells) * UInt64(cellPixels) + padding + return UInt32(exactly: target) + } + + private static func correctedPixelDimension( + currentPixels: UInt32, + actualCells: UInt32, + targetCells: UInt32, + cellPixels: UInt32 + ) -> UInt32? { + guard cellPixels > 0 else { return nil } + let correction = (Int64(targetCells) - Int64(actualCells)) * Int64(cellPixels) + let corrected = Int64(currentPixels) + correction + guard corrected > 0 else { return nil } + return UInt32(exactly: corrected) + } + + deinit { + let finalLifecycle = lifecycle + assert(finalLifecycle == .closed, "TmuxPaneSurface deinit without close()") + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift new file mode 100644 index 00000000..5a485681 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift @@ -0,0 +1,41 @@ +import Foundation +import GhosttyKit + +/// Phase-1 composition seam. It owns an upstream session and screen adapter, +/// but deliberately does not construct a transport or touch Mori persistence. +@MainActor +final class TmuxScreenModel: ObservableObject { + let terminalScreenAdapter = TmuxTerminalScreenAdapter() + @Published private(set) var session: TmuxTerminalSession? + @Published private(set) var startupFailure: String? + + init( + app: ghostty_app_t, + transport: any TmuxControlTransport, + baseSurfaceConfig: @escaping () -> ghostty_terminal_surface_config_s, + paneViewTheme: @escaping () -> TerminalTheme + ) { + let session = TmuxTerminalSession( + app: app, + transport: transport, + baseSurfaceConfig: baseSurfaceConfig, + paneViewTheme: paneViewTheme + ) + self.session = session + terminalScreenAdapter.activate( + session: session, + initialViewportHandler: { [weak session] size, scale in + session?.updateViewportMetrics(size: size, scale: scale) + }, + clientSizeHandler: { _ in }, + viewportStabilityHandler: { _ in } + ) + } + + func stop() async { + terminalScreenAdapter.invalidate() + guard let session else { return } + await session.shutdown() + self.session = nil + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift new file mode 100644 index 00000000..c6add0fc --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift @@ -0,0 +1,1526 @@ +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 newWindow + case splitPane + case closePane + case closeWindow + case selectWindow + case selectPane + case zoomPane + case copyMode + case setClientSize + case sendInput + } + + enum SplitDirection: Sendable { + case left + case right + case up + case down + } + + 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 + } + + enum PaneCurrentDirectoryError: LocalizedError, Equatable, Sendable { + case sessionUnavailable + case paneUnavailable + case commandSkipped + case commandFailed(String) + case invalidResponse + + var errorDescription: String? { + switch self { + case .sessionUnavailable: + return "The terminal session is no longer available." + case .paneUnavailable: + return "The originating terminal pane is no longer available." + case .commandSkipped: + return "tmux did not execute the current-directory query." + case .commandFailed(let detail): + return detail.isEmpty + ? "tmux could not resolve the terminal's current directory." + : detail + case .invalidResponse: + return "tmux returned an invalid current directory." + } + } + } + + /// 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?) + case zoom(TmuxPaneID) + } + + private enum OutstandingRequest { + case action(Request, topologyRevisionAtSubmission: UInt64) + case paneCurrentDirectory( + @Sendable (Result) -> Void + ) + case trackedInput(@Sendable (Bool) -> 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 clientSize: ClientSize? + 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 + + init( + callbacks: Callbacks, + queue: DispatchQueue = DispatchQueue(label: "remux.tmux.session.writer") + ) { + 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 + /// control channel with the same real viewport. The native initial grid is + /// immutable and emits the sole startup refresh-client command. + func start( + initialSize: ClientSize, + completion: @escaping @Sendable (Result) -> Void + ) { + queue.async { [self] in + guard client == nil, !shuttingDown else { + completion(.failure(.alreadyStarted)) + return + } + guard let columns = UInt16(exactly: initialSize.cols), + let rows = UInt16(exactly: initialSize.rows), + columns > 0, + rows > 0 + else { + completion(.failure(.invalidInitialGrid)) + return + } + clientSize = initialSize + + 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 = 2_000 + config.max_scrollback = 10_000 + 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(.creationFailed(result))) + return + } + client = created + publishState(.attaching) + completion(.success(())) + } + } + + func transportClosed() { + queue.async { [self] in + guard !shuttingDown else { return } + failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) + failOutstandingTrackedInput() + 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 } + failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) + failOutstandingTrackedInput() + 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 directoryQueries = outstandingPaneDirectoryQueries() + let trackedInputCompletions = outstandingTrackedInputCompletions() + requestsByToken.removeAll() + directoryQueries.forEach { $0(.failure(.sessionUnavailable)) } + trackedInputCompletions.forEach { $0(false) } + deferredNavigationIntent = nil + successfulMutationRequiredAfterRevision = nil + topology = nil + clientSize = 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: + failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) + failOutstandingTrackedInput() + 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 = 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 = 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 .trackedInput(let completionHandler): + let succeeded = completion.status == GHOSTTY_TMUX_COMMAND_SUCCESS + if !succeeded { + reportRequestFailure(.sendInput) + } + completionHandler(succeeded) + return + case .paneCurrentDirectory(let completionHandler): + completionHandler(paneCurrentDirectoryResult(for: completion)) + 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 + + 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 + } + 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 + } + + /// Sends one terminal payload and completes only when tmux answers for + /// that exact send-keys command (or the session can no longer do so). + func sendTrackedInput( + paneID: TmuxPaneID, + _ bytes: Data, + completion: @escaping @Sendable (Bool) -> Void + ) -> Bool { + sendTrackedInput( + paneID: paneID, + bytes, + transport: .exact, + completion: completion + ) + } + + func sendTrackedLiteralInput( + paneID: TmuxPaneID, + _ bytes: Data, + completion: @escaping @Sendable (Bool) -> Void + ) -> Bool { + sendTrackedInput( + paneID: paneID, + bytes, + transport: .literal, + completion: completion + ) + } + + private enum TrackedInputTransport { + case exact + case literal + } + + private func sendTrackedInput( + paneID: TmuxPaneID, + _ bytes: Data, + transport: TrackedInputTransport, + completion: @escaping @Sendable (Bool) -> Void + ) -> Bool { + guard !bytes.isEmpty else { return false } + queue.async { [self, bytes] in + guard let client, outboundSink != nil, !shuttingDown else { + reportRequestFailure(.sendInput) + completion(false) + return + } + var token: UInt64 = 0 + let result = bytes.withUnsafeBytes { buffer in + let pointer = buffer.bindMemory(to: UInt8.self).baseAddress + return switch transport { + case .exact: + ghostty_tmux_client_send_pane_input_tracked( + client, + paneID.rawValue, + pointer, + buffer.count, + &token + ) + case .literal: + ghostty_tmux_client_send_pane_literal_input_tracked( + client, + paneID.rawValue, + pointer, + buffer.count, + &token + ) + } + } + guard result == GHOSTTY_TMUX_RESULT_OK else { + reportImmediateFailure(result, request: .sendInput) + completion(false) + return + } + requestsByToken[token] = .trackedInput(completion) + _ = drainOutbound() + } + return true + } + + func setClientSize(cols: UInt32, rows: UInt32) { + guard cols > 0, rows > 0, cols <= UInt16.max, rows <= UInt16.max else { + DispatchQueue.main.async { self.callbacks.onRequestFailed(.setClientSize) } + return + } + let nextSize = ClientSize(cols: cols, rows: rows) + queue.async { [self] in + guard clientSize != nextSize else { return } + guard admitCommandOnWriter( + command: "refresh-client -C \(cols)x\(rows)", + request: .setClientSize + ) else { return } + clientSize = nextSize + if let paneID = activePaneID(in: topology) { + _ = admitPaneRefreshIfNeeded( + paneID, + failureRequest: .setClientSize + ) + } + _ = drainOutbound() + } + } + + func requestNewWindow() { + enqueue(command: "new-window", request: .newWindow) + } + + func requestSplit(paneID: TmuxPaneID, direction: SplitDirection, zoom: Bool) { + let flags = switch direction { + case .left: "-h -b" + case .right: "-h" + case .up: "-v -b" + case .down: "-v" + } + let zoomFlag = zoom ? " -Z" : "" + enqueue( + command: "split-window \(flags)\(zoomFlag) -t %\(paneID.rawValue)", + request: .splitPane + ) + } + + func requestClosePane(paneID: TmuxPaneID) { + enqueue(command: "kill-pane -t %\(paneID.rawValue)", request: .closePane) + } + + func requestCloseWindow(windowID: TmuxWindowID) { + enqueue(command: "kill-window -t @\(windowID.rawValue)", request: .closeWindow) + } + + 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)) + } + } + + func requestZoomPane(paneID: TmuxPaneID) { + queue.async { [self] in + submitNavigation(.zoom(paneID)) + } + } + + func requestCopyMode(paneID: TmuxPaneID) { + enqueue(command: "copy-mode -t %\(paneID.rawValue)", request: .copyMode) + } + + func paneCurrentDirectory(for paneID: TmuxPaneID) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + queue.async { [self] in + submitPaneCurrentDirectoryQueryOnWriter( + paneID: paneID, + completion: { continuation.resume(with: $0) } + ) + } + } + } + + 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 + ) + case .zoom(let paneID): + enqueueZoomPane(paneID, drainOutbound: drainOutbound) + } + } + + private func enqueueZoomPane( + _ paneID: TmuxPaneID, + drainOutbound: Bool + ) { + guard let topology, + let pane = topology.panes.first(where: { $0.id == paneID }), + let window = topology.windows.first(where: { $0.id == pane.windowID }) + else { + reportRequestFailure(.zoomPane) + return + } + let hasSibling = topology.panes.contains { + $0.windowID == window.id && $0.id != paneID + } + guard hasSibling, !window.zoomed else { + let admittedRefresh = admitPaneRefreshIfNeeded( + paneID, + failureRequest: .zoomPane + ) + if admittedRefresh, drainOutbound { _ = self.drainOutbound() } + return + } + submitPanePresentationCommandOnWriter( + command: "resize-pane -Z -t %\(paneID.rawValue)", + request: .zoomPane, + paneID: paneID, + drainOutbound: drainOutbound + ) + } + + private func enqueuePaneSelection( + _ paneID: TmuxPaneID, + drainOutbound: Bool + ) { + preconditionOnWriterQueue() + guard let topology, + let pane = topology.panes.first(where: { $0.id == paneID }), + let window = topology.windows.first(where: { $0.id == pane.windowID }) + else { + reportRequestFailure(.selectPane) + return + } + if topology.activeWindowID != window.id { + enqueueWindowSelection( + windowID: window.id, + preferredPaneID: paneID, + drainOutbound: drainOutbound + ) + return + } + + let hasSibling = topology.panes.contains { + $0.windowID == window.id && $0.id != paneID + } + if window.activePaneID == paneID, window.zoomed || !hasSibling { + let admittedRefresh = admitPaneRefreshIfNeeded( + paneID, + failureRequest: .selectPane + ) + if admittedRefresh, drainOutbound { _ = self.drainOutbound() } + return + } + let command = window.zoomed + ? "select-pane -Z -t %\(paneID.rawValue)" + : "resize-pane -Z -t %\(paneID.rawValue)" + submitPanePresentationCommandOnWriter( + command: command, + request: .selectPane, + paneID: paneID, + drainOutbound: drainOutbound + ) + } + + private func enqueueWindowSelection( + windowID: TmuxWindowID, + preferredPaneID: TmuxPaneID?, + drainOutbound: Bool + ) { + preconditionOnWriterQueue() + guard let topology, + let window = topology.windows.first(where: { $0.id == windowID }) + else { + reportRequestFailure(.selectWindow) + return + } + let paneID = preferredPaneID ?? window.activePaneID + let hasSibling = topology.panes.contains { pane in + pane.windowID == windowID && pane.id != paneID + } + + if topology.activeWindowID == windowID { + guard let paneID else { return } + if window.zoomed || !hasSibling { + let admittedRefresh = admitPaneRefreshIfNeeded( + paneID, + failureRequest: .selectWindow + ) + if admittedRefresh, drainOutbound { _ = self.drainOutbound() } + return + } + submitPanePresentationCommandOnWriter( + command: "resize-pane -Z -t %\(paneID.rawValue)", + request: .selectWindow, + paneID: paneID, + drainOutbound: drainOutbound + ) + return + } + + if let preferredPaneID, + !topology.panes.contains(where: { + $0.id == preferredPaneID && $0.windowID == windowID + }) { + reportRequestFailure(.selectWindow) + return + } + let commands = Self.crossWindowSelectionCommands( + windowID: windowID, + activePaneID: window.activePaneID, + preferredPaneID: preferredPaneID, + zoomed: window.zoomed, + hasSibling: hasSibling + ) + if commands.count == 1 { + guard let paneID else { + submitCommandOnWriter( + command: commands[0], + request: .selectWindow, + drainOutbound: drainOutbound + ) + return + } + submitPanePresentationCommandOnWriter( + command: commands[0], + request: .selectWindow, + paneID: paneID, + drainOutbound: drainOutbound + ) + } else { + guard let paneID else { + reportRequestFailure(.selectWindow) + 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 .newWindow, .splitPane, .closePane, .closeWindow, + .selectWindow, .selectPane, .zoomPane: + true + case .copyMode, .setClientSize, .sendInput: + false + } + } + + static func crossWindowSelectionCommands( + windowID: TmuxWindowID, + activePaneID: TmuxPaneID?, + preferredPaneID: TmuxPaneID?, + zoomed: Bool, + hasSibling: Bool + ) -> [String] { + let selectWindow = "select-window -t @\(windowID.rawValue)" + guard hasSibling, + let preferredPaneID + else { return [selectWindow] } + if zoomed, preferredPaneID == activePaneID { + return [selectWindow] + } + let selectPane = zoomed ? "select-pane" : "resize-pane" + return [ + selectWindow, + "\(selectPane) -Z -t %\(preferredPaneID.rawValue)", + ] + } + + 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 submitPaneCurrentDirectoryQueryOnWriter( + paneID: TmuxPaneID, + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + preconditionOnWriterQueue() + guard let client, !shuttingDown else { + completion(.failure(.sessionUnavailable)) + return + } + guard retainedPaneIDs.contains(paneID) else { + completion(.failure(.paneUnavailable)) + return + } + + let command = "display-message -p -t %\(paneID.rawValue) '#{pane_current_path}'" + let (result, token) = enqueueCommandTokenOnWriter(command, client: client) + guard result == GHOSTTY_TMUX_RESULT_OK else { + completion(.failure(.commandFailed(String(describing: result)))) + if result == GHOSTTY_TMUX_RESULT_CLIENT_FAILED + || result == GHOSTTY_TMUX_RESULT_CLOSED { + handleClientFailure(result) + } + return + } + requestsByToken[token] = .paneCurrentDirectory(completion) + _ = drainOutbound() + } + + 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 size = clientSize 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 + + private 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 } + failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) + failOutstandingTrackedInput() + 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 paneCurrentDirectoryResult( + for completion: ghostty_tmux_command_completion_s + ) -> Result { + switch completion.status { + case GHOSTTY_TMUX_COMMAND_SUCCESS: + let path = decodeTmuxString(completion.body) + .trimmingCharacters(in: .newlines) + guard path.hasPrefix("/"), + !path.contains("\0"), + !path.contains("\n"), + !path.contains("\r") + else { return .failure(.invalidResponse) } + return .success(path) + case GHOSTTY_TMUX_COMMAND_SKIPPED: + return .failure(.commandSkipped) + case GHOSTTY_TMUX_COMMAND_ERROR_BLOCK: + let detail = decodeTmuxString(completion.body) + .trimmingCharacters(in: .newlines) + return .failure(.commandFailed(detail)) + default: + return .failure(.invalidResponse) + } + } + + private func outstandingPaneDirectoryQueries() -> [ + @Sendable (Result) -> Void + ] { + requestsByToken.values.compactMap { + guard case .paneCurrentDirectory(let completion) = $0 else { return nil } + return completion + } + } + + private func failOutstandingPaneDirectoryQueries( + with error: PaneCurrentDirectoryError + ) { + preconditionOnWriterQueue() + let completions = outstandingPaneDirectoryQueries() + requestsByToken = requestsByToken.filter { + guard case .paneCurrentDirectory = $0.value else { return true } + return false + } + completions.forEach { $0(.failure(error)) } + } + + private func outstandingTrackedInputCompletions() -> [@Sendable (Bool) -> Void] { + requestsByToken.values.compactMap { + guard case .trackedInput(let completion) = $0 else { return nil } + return completion + } + } + + private func failOutstandingTrackedInput() { + preconditionOnWriterQueue() + let completions = outstandingTrackedInputCompletions() + requestsByToken = requestsByToken.filter { + guard case .trackedInput = $0.value else { return true } + return false + } + completions.forEach { $0(false) } + } + + 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..9b1a25f5 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift @@ -0,0 +1,150 @@ +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 the screen model. The link only passes the +/// already-known viewport to the SSH attach command's initial `-x -y`. +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 with the real grid, then create the + /// native client with that same grid. This order prevents its initial + /// refresh/list batch from racing transport opening. + func start(viewport: TmuxControlViewport?) async throws { + guard !stopped else { throw LinkError.stopped } + guard let viewport else { throw LinkError.missingInitialViewport } + + // 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: viewport) + guard !stopped else { throw LinkError.stopped } + try await withCheckedThrowingContinuation { continuation in + controller.start( + initialSize: TmuxSessionController.ClientSize( + cols: UInt32(viewport.columns), + rows: UInt32(viewport.rows) + ) + ) { 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..31b7c540 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift @@ -0,0 +1,807 @@ +import Combine +import CoreGraphics +import Foundation +import GhosttyKit + + +/// Presents the new tmux session stack (`TmuxTerminalSession`) through the +/// `GhosttyTerminalScreenModeling` boundary so `GhosttySurfaceScreen` — the +/// full terminal UX — renders it unchanged. +/// +/// 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 static let panePreviewCacheByteLimit = 8 * 1024 * 1024 + + 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 identities = TmuxTerminalIdentityRegistry() + + private var activeManagedSurface: GhosttyManagedSurface? + private var activeManagedPaneID: TmuxPaneID? + private var initialViewportHandler: ((CGSize, CGFloat) -> Void)? + private var clientSizeHandler: ((TmuxSessionController.ClientSize) -> Void)? + private var viewportStabilityHandler: ((Bool) -> Void)? + private var cachedTopologySnapshot = GhosttyRuntimeSurfaceTopologySnapshot.empty + private var panePreviewCache = TmuxPanePreviewImageCache( + byteLimit: TmuxTerminalScreenAdapter.panePreviewCacheByteLimit + ) + + private var commandFailureMessage: String? + private(set) var commandFailureEvent: GhosttyTmuxCommandFailureEvent? + private var commandFailureToken: UInt64 = 0 + + 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, + clientSizeHandler: @escaping (TmuxSessionController.ClientSize) -> Void, + viewportStabilityHandler: @escaping (Bool) -> Void + ) { + self.session = session + self.controller = session.controller + self.initialViewportHandler = initialViewportHandler + self.clientSizeHandler = clientSizeHandler + self.viewportStabilityHandler = viewportStabilityHandler + + session.$state + .sink { [weak self] state in + guard let self else { return } + if case .detached = state { + self.clearPanePreviewCache(reason: "detached") + } else if case .closed = state { + self.clearPanePreviewCache(reason: "closed") + } + 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 + if let topology { + self.reconcilePanePreviewCache(with: topology) + } else { + self.clearPanePreviewCache(reason: "topology-unavailable") + } + self.rebuildTopologySnapshot() + self.objectWillChange.send() + } + .store(in: &subscriptions) + session.$paneSurface + .sink { [weak self] paneSurface in + self?.rebuildActiveManagedSurface(for: paneSurface) + self?.objectWillChange.send() + } + .store(in: &subscriptions) + session.$lastFailedRequest + .sink { [weak self] request in + guard let request else { return } + self?.presentCommandFailure(for: request) + } + .store(in: &subscriptions) + session.$transportFailure + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &subscriptions) + } + + func invalidate() { + subscriptions.removeAll() + activeManagedSurface = nil + activeManagedPaneID = nil + clearPanePreviewCache(reason: "invalidate") + session = nil + controller = nil + initialViewportHandler = nil + clientSizeHandler = nil + viewportStabilityHandler = nil + latestTopology = nil + cachedTopologySnapshot = Self.emptyTopologySnapshot + } + + func terminalConfigurationDidChange() { + clearPanePreviewCache(reason: "appearance-change") + if let activeManagedSurface { + reportClientSizeIfActive(activeManagedSurface) + } + } + + func tmuxPaneID(for surfaceID: UUID) -> TmuxPaneID? { + let paneID = activeManagedSurface?.id == surfaceID + ? activeManagedPaneID + : identities.paneID(for: surfaceID) + guard let paneID, + latestTopology?.panes.contains(where: { $0.id == paneID }) == true + else { return nil } + return paneID + } + + // MARK: Topology synthesis + + private static var emptyTopologySnapshot: GhosttyRuntimeSurfaceTopologySnapshot { + GhosttyRuntimeSurfaceTopologySnapshot.empty + } + + private var topologySnapshot: GhosttyRuntimeSurfaceTopologySnapshot { + cachedTopologySnapshot + } + + private func rebuildTopologySnapshot() { + guard let topology = latestTopology else { + cachedTopologySnapshot = Self.emptyTopologySnapshot + return + } + + let topLevels = topology.windows.map { window in + let paneIDs = topology.panes + .filter { $0.windowID == window.id } + .sorted { lhs, rhs in + (lhs.y, lhs.x, lhs.id) < (rhs.y, rhs.x, rhs.id) + } + .map { identities.surfaceID(for: $0.id) } + return GhosttyTopLevelSurface( + id: identities.surfaceID(for: window.id), + name: window.name, + leafIDs: paneIDs, + focusedLeafID: window.activePaneID.map { identities.surfaceID(for: $0) } + ) + } + + cachedTopologySnapshot = GhosttyRuntimeSurfaceTopologySnapshot( + topLevels: topLevels, + selectedTopLevelID: topology.activeWindowID.map { identities.surfaceID(for: $0) } + ) + } + + private var runtimePhase: GhosttyTerminalRuntimePhase { + guard let session else { + return .failed(message: "terminal session unavailable", reason: nil) + } + switch session.state { + case .attaching, .syncing: + return .starting + case .ready: + return .running + case .detached(nil): + if let failure = session.transportFailure { + return .failed(message: failure.message, reason: failure) + } + // Pre-connect; the first connect is imminent. + return .starting + case .detached(.some(let reason)): + let mapped = reason.terminalDisconnectReason + return .failed(message: mapped.message, reason: mapped) + case .closed(let reason): + let mapped = reason.terminalDisconnectReason + return .failed(message: mapped.message, reason: mapped) + } + } + + private var isTransportWritable: Bool { + session?.state == .ready + } + + // MARK: Managed surface lifecycle + + private func rebuildActiveManagedSurface(for paneSurface: TmuxPaneSurface?) { + if activeManagedSurface != nil { + activeManagedSurface = nil + activeManagedPaneID = nil + } + + guard let paneSurface else { return } + + let paneID = paneSurface.paneID + if case .paneGeometry? = panePreviewCache.entries[paneID]?.preview.source { + panePreviewCache.remove(paneID) + } + let wasAlreadyWrapped = paneSurface.managedSurface != nil + let managed = paneSurface.screenSurface { [weak self, weak paneSurface] managed, size, _ in + guard size.width > 1, size.height > 1 else { return } + GhosttyRuntimeTrace.flowEventOnce( + GhosttyRuntimeTrace.paneSwitchFlow, + event: "presentation.layout.ready", + fields: [ + "height": "\(size.height)", + "pane": "\(paneID)", + "surface": paneSurface.map { String(describing: $0.rawSurface) } ?? "released", + "width": "\(size.width)", + ] + ) + self?.reportClientSizeIfActive(managed) + } + activeManagedSurface = managed + activeManagedPaneID = paneID + if !wasAlreadyWrapped { + GhosttyRuntimeTrace.flowEventIfActive( + GhosttyRuntimeTrace.paneSwitchFlow, + event: "presentation.managedSurface.ready", + fields: [ + "pane": "\(paneID)", + "surface": String(describing: paneSurface.rawSurface), + "surface_uuid": managed.id.uuidString, + ] + ) + } + } + + private func managedSurface(for id: UUID) -> GhosttyManagedSurface? { + if let active = activeManagedSurface, active.id == id { + return active + } + return nil + } + + private var focusedManagedSurface: GhosttyManagedSurface? { + activeManagedSurface + } + + private func reportClientSizeIfActive(_ managed: GhosttyManagedSurface) { + guard activeManagedSurface === managed else { return } + let size = managed.controlSurface.currentSize() + guard size.columns >= 2, size.rows >= 2 else { return } + clientSizeHandler?(TmuxSessionController.ClientSize( + cols: UInt32(size.columns), + rows: UInt32(size.rows) + )) + } + + // MARK: Command failures + + private func presentCommandFailure(for request: TmuxSessionController.Request) { + commandFailureToken &+= 1 + let message = "tmux: \(Self.failureLabel(for: request)) failed" + commandFailureMessage = message + commandFailureEvent = GhosttyTmuxCommandFailureEvent( + token: commandFailureToken, + message: message + ) + objectWillChange.send() + + let token = commandFailureToken + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(4)) + guard let self, self.commandFailureToken == token else { return } + self.commandFailureMessage = nil + self.objectWillChange.send() + } + } + + private static func failureLabel(for request: TmuxSessionController.Request) -> String { + switch request { + case .newWindow: "new window" + case .splitPane: "split pane" + case .closePane: "close pane" + case .closeWindow: "close window" + case .selectWindow: "select window" + case .selectPane: "select pane" + case .zoomPane: "zoom pane" + case .copyMode: "copy mode" + case .setClientSize: "resize" + case .sendInput: "input" + } + } +} + +// MARK: - GhosttyTerminalScreenModeling + +extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { + func prepareInitialViewport(size: CGSize, scale: CGFloat) { + initialViewportHandler?(size, scale) + } + + var terminalScreenPresentationProjection: GhosttyTerminalScreenPresentationProjection { + GhosttyTerminalPresentationProjector.terminalScreenPresentationProjection( + phase: runtimePhase, + transportWritable: isTransportWritable, + commandFailureMessage: commandFailureMessage, + debugStatus: stateTraceLabel, + registryDebugSummary: "tmux session stack", + presentedSurfaceID: activeManagedSurface?.id, + snapshot: topologySnapshot + ) + } + + var terminalInteractionProjection: GhosttyTerminalInteractionProjection { + GhosttyTerminalPresentationProjector.terminalInteractionProjection( + phase: runtimePhase, + presentedSurfaceID: activeManagedSurface?.id, + snapshot: topologySnapshot + ) + } + + 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" + } + } + + func setViewportStabilityHint(stable: Bool) { + viewportStabilityHandler?(stable) + } + + func makePanePreviewSession( + leafIDs: [UUID], + previewSizing: GhosttyPanePreviewSession.PreviewSizing + ) -> GhosttyPanePreviewSession { + return newPanePreviewSession( + leafIDs: leafIDs, + previewSizing: previewSizing + ) + } + + private func newPanePreviewSession( + leafIDs: [UUID], + previewSizing: GhosttyPanePreviewSession.PreviewSizing + ) -> GhosttyPanePreviewSession { + GhosttyPanePreviewSession( + leafIDs: leafIDs, + previewSizing: previewSizing, + client: GhosttyPanePreviewSession.PreviewClient( + capture: { [weak self] leafID, budget in + guard let self, + let session = self.session, + let paneID = self.identities.paneID(for: leafID), + let pane = self.latestTopology?.panes.first(where: { $0.id == paneID }) + else { return nil } + return await session.capturePickerPreview( + paneID: paneID, + columns: pane.width, + rows: pane.height, + budget: budget + ) + }, + cancelCapture: { [weak self] leafID in + guard let self, + let paneID = self.identities.paneID(for: leafID) + else { return } + self.session?.cancelPickerPreview(paneID: paneID) + }, + cachedPreview: { [weak self] leafID in + guard let self, + let paneID = self.identities.paneID(for: leafID) + else { return nil } + guard let preview = self.panePreviewCache.preview(for: paneID) else { + GhosttyRuntimeTrace.perf( + "tmuxPane.preview.cache pane=\(paneID) result=miss" + ) + return nil + } + if case .paneGeometry(let provenance) = preview.source, + self.latestTopology?.panes.first(where: { $0.id == paneID }).map({ + $0.width != provenance.columns || $0.height != provenance.rows + }) != false { + self.panePreviewCache.remove(paneID) + return nil + } + GhosttyRuntimeTrace.perf( + "tmuxPane.preview.cache pane=\(paneID) result=hit source=\(Self.previewSourceLabel(preview.source)) bytes=\(preview.image.bytesPerRow * preview.image.height)" + ) + return preview + }, + shouldRefreshCachedImage: { [weak self] leafID in + guard let self, + let paneID = self.identities.paneID(for: leafID) + else { return false } + return self.activeManagedPaneID == paneID + }, + cacheRenderedPreview: { [weak self] leafID, preview in + guard let self, + self.session?.state == .ready, + let paneID = self.identities.paneID(for: leafID), + self.latestTopology?.panes.contains(where: { $0.id == paneID }) == true + else { return } + let evictedPaneIDs = self.panePreviewCache.store( + preview, + for: paneID + ) + guard self.panePreviewCache.entries[paneID]?.preview.image === preview.image else { + GhosttyRuntimeTrace.perf( + "tmuxPane.preview.cache pane=\(paneID) result=reject-oversize bytes=\(preview.image.bytesPerRow * preview.image.height) limit=\(self.panePreviewCache.byteLimit)" + ) + return + } + GhosttyRuntimeTrace.perf( + "tmuxPane.preview.cache pane=\(paneID) result=store source=\(Self.previewSourceLabel(preview.source)) bytes=\(preview.image.bytesPerRow * preview.image.height) total=\(self.panePreviewCache.totalByteCost)" + ) + if !evictedPaneIDs.isEmpty { + GhosttyRuntimeTrace.perf( + "tmuxPane.preview.cache result=evict panes=\(evictedPaneIDs) total=\(self.panePreviewCache.totalByteCost)" + ) + } + } + ) + ) + } + + private static func previewSourceLabel( + _ source: GhosttyPanePreviewSession.PreviewSource + ) -> String { + switch source { + case .paneGeometry(let provenance): + return "pane-geometry-\(provenance.columns)x\(provenance.rows)" + case .fullViewport(let provenance): + return "full-viewport-\(provenance.pixelWidth)x\(provenance.pixelHeight)" + } + } + + private func reconcilePanePreviewCache( + with topology: TmuxSessionController.TopologySnapshot + ) { + let removedPaneIDs = panePreviewCache.retainOnly(Set(topology.panes.map(\.id))) + guard !removedPaneIDs.isEmpty else { return } + GhosttyRuntimeTrace.perf( + "tmuxPane.preview.cache result=topology-remove panes=\(removedPaneIDs) total=\(panePreviewCache.totalByteCost)" + ) + } + + private func clearPanePreviewCache(reason: String) { + guard !panePreviewCache.entries.isEmpty else { return } + panePreviewCache.removeAll() + GhosttyRuntimeTrace.perf( + "tmuxPane.preview.cache result=clear reason=\(reason)" + ) + } + + // 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 sendPaste(_ text: String, to surfaceID: UUID) -> FocusedTerminalInputSubmissionResult { + guard isTransportWritable else { return .transportUnavailable } + guard let managed = managedSurface(for: surfaceID) else { return .noFocusedSurface } + return managed.sendPaste(text) + } + + func sendPasteAwaitingCommandCompletion(_ text: String, to surfaceID: UUID) async -> Bool { + guard isTransportWritable, + let managed = managedSurface(for: surfaceID) + else { return false } + return await managed.sendPasteAwaitingCommandCompletion(text) + } + + func sendKeyEvent( + _ event: GhosttySurfaceKeyEvent, + to surfaceID: UUID + ) -> FocusedTerminalInputSubmissionResult { + guard isTransportWritable else { return .transportUnavailable } + guard let managed = managedSurface(for: surfaceID) else { return .noFocusedSurface } + return managed.sendKeyEvent(event) + } + + func sendKeyEventAwaitingCommandCompletion( + _ event: GhosttySurfaceKeyEvent, + to surfaceID: UUID + ) async -> Bool { + guard isTransportWritable, + let managed = managedSurface(for: surfaceID) + else { return false } + return await managed.sendKeyEventAwaitingCommandCompletion(event) + } + + 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 focusTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome { + guard let paneID = identities.paneID(for: id), let controller else { + GhosttyRuntimeTrace.flowEventIfActive( + GhosttyRuntimeTrace.paneSwitchFlow, + event: "adapter.resolve.failed", + fields: ["target_uuid": id.uuidString] + ) + return .missingTarget(.pane(id)) + } + GhosttyRuntimeTrace.flowEventIfActive( + GhosttyRuntimeTrace.paneSwitchFlow, + event: "adapter.resolve.ready", + fields: [ + "pane": "\(paneID)", + "target_uuid": id.uuidString, + ] + ) + session?.prepareForPaneSelection(paneID: paneID) + controller.requestSelectPane(paneID: paneID) + return .queued + } + + func focusTmuxTopLevel(_ id: UUID) -> GhosttyTmuxModelActionOutcome { + guard let windowID = identities.windowID(for: id), let controller else { + return .missingTarget(.window(id)) + } + if let topology = latestTopology, + let targetWindow = topology.windows.first(where: { $0.id == windowID }) { + requestWindowSelection(targetWindow, in: topology, controller: controller) + } else { + controller.requestSelectWindow(windowID: windowID) + } + return .queued + } + + func focusAdjacentTmuxTopLevel( + _ direction: GhosttyRuntimeSelectionDirection + ) -> GhosttyTmuxModelActionOutcome { + guard + let controller, + let topology = latestTopology, + !topology.windows.isEmpty, + let activeWindowID = topology.activeWindowID, + let activeIndex = topology.windows.firstIndex(where: { $0.id == activeWindowID }) + else { + return .missingTarget(.adjacentWindow) + } + + let targetIndex = direction.advancedIndex( + from: activeIndex, + count: topology.windows.count + ) + guard targetIndex != activeIndex else { + return .missingTarget(.adjacentWindow) + } + let targetWindow = topology.windows[targetIndex] + requestWindowSelection(targetWindow, in: topology, controller: controller) + return .queued + } + + 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 + ) + } + + func createTmuxWindow() -> GhosttyTmuxModelActionOutcome { + guard let controller else { return .missingTarget(.host) } + controller.requestNewWindow() + return .queued + } + + func splitFocusedTmuxPane( + _ direction: ghostty_action_split_direction_e + ) -> GhosttyTmuxModelActionOutcome { + guard let controller, let paneSurface = session?.paneSurface else { + return .missingTarget(.focusedPane) + } + controller.requestSplit( + paneID: paneSurface.paneID, + direction: TmuxSessionController.SplitDirection(actionDirection: direction), + zoom: true + ) + return .queued + } + + func closeTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome { + guard let paneID = identities.paneID(for: id), let controller else { + return .missingTarget(.pane(id)) + } + controller.requestClosePane(paneID: paneID) + return .queued + } + + func closeTmuxWindow(_ id: UUID) -> GhosttyTmuxModelActionOutcome { + guard let windowID = identities.windowID(for: id), let controller else { + return .missingTarget(.window(id)) + } + controller.requestCloseWindow(windowID: windowID) + return .queued + } + + func enterFocusedTmuxCopyMode() -> GhosttyTmuxModelActionOutcome { + guard let controller, let paneSurface = session?.paneSurface else { + return .missingTarget(.focusedPane) + } + controller.requestCopyMode(paneID: paneSurface.paneID) + return .queued + } + + // MARK: Selection sheet projections + + func createTmuxWindowInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect { + GhosttyTerminalPresentationProjector.createTmuxWindowInteractionEffect() + } + + func splitFocusedTmuxPaneInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect { + GhosttyTerminalPresentationProjector.splitFocusedTmuxPaneInteractionEffect() + } + + func closeTmuxWindowInteractionEffect(_ id: UUID) -> GhosttyTmuxTopologyActionInteractionEffect { + GhosttyTerminalPresentationProjector.closeTmuxWindowInteractionEffect( + id, + snapshot: topologySnapshot + ) + } + + func closeTmuxPaneInteractionEffect( + _ id: UUID, + inTopLevel topLevelID: UUID + ) -> GhosttyTmuxTopologyActionInteractionEffect { + GhosttyTerminalPresentationProjector.closeTmuxPaneInteractionEffect( + id, + inTopLevel: topLevelID, + snapshot: topologySnapshot + ) + } + + func windowSheetPresentationProjection() -> GhosttyWindowSheetPresentationProjection? { + GhosttyTerminalPresentationProjector.windowSheetPresentationProjection( + snapshot: topologySnapshot + ) + } + + func selectedPaneSheetPresentationProjection() -> GhosttyPaneSheetPresentationProjection? { + GhosttyTerminalPresentationProjector.selectedPaneSheetPresentationProjection( + snapshot: topologySnapshot + ) + } + + func paneCount(topLevelID: UUID) -> Int { + GhosttyTerminalPresentationProjector.paneCount( + topLevelID: topLevelID, + snapshot: topologySnapshot + ) + } + + func paneSelectionSheetTopologyProjection( + topLevelID: UUID? + ) -> GhosttyPaneSelectionSheetTopologyProjection { + GhosttyTerminalPresentationProjector.paneSelectionSheetTopologyProjection( + topLevelID: topLevelID, + snapshot: topologySnapshot + ) + } + + func windowSelectionSheetRenderProjection() -> GhosttyWindowSelectionSheetRenderProjection { + GhosttyTerminalPresentationProjector.windowSelectionSheetRenderProjection( + snapshot: topologySnapshot + ) + } + + func paneSelectionSheetRenderProjection( + topLevelID: UUID + ) -> GhosttyPaneSelectionSheetRenderProjection { + GhosttyTerminalPresentationProjector.paneSelectionSheetRenderProjection( + topLevelID: topLevelID, + snapshot: topologySnapshot + ) + } +} + +// MARK: - Shared reason mapping + +extension TmuxSessionController.DetachReason { + var terminalDisconnectReason: TerminalDisconnectReason { + switch self { + case .serverExited(let message): + TerminalDisconnectReason( + kind: .remoteExit, + message: message ?? "tmux server exited" + ) + case .transportClosed: + TerminalDisconnectReason( + kind: .transportIO, + message: "connection lost" + ) + case .channelAborted: + TerminalDisconnectReason( + kind: .runtime, + message: "tmux control protocol error" + ) + case .outOfMemory: + TerminalDisconnectReason( + kind: .runtime, + message: "tmux session sync failed" + ) + } + } +} + +extension TmuxSessionController.CloseReason { + var terminalDisconnectReason: TerminalDisconnectReason { + switch self { + case .unsupportedVersion(let version): + TerminalDisconnectReason( + kind: .runtime, + message: "unsupported tmux version \(version) (requires 3.1+)" + ) + } + } +} + +private extension TmuxSessionController.SplitDirection { + init(actionDirection: ghostty_action_split_direction_e) { + switch actionDirection { + case GHOSTTY_SPLIT_DIRECTION_LEFT: self = .left + case GHOSTTY_SPLIT_DIRECTION_UP: self = .up + case GHOSTTY_SPLIT_DIRECTION_DOWN: self = .down + default: self = .right + } + } +} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift new file mode 100644 index 00000000..e5de2890 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift @@ -0,0 +1,594 @@ +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 = [] + @Published private(set) var lastFailedRequest: TmuxSessionController.Request? + @Published private(set) var transportFailure: TerminalDisconnectReason? + + 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 zoomRequestedPaneID: 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, + 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(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(viewport: TmuxControlViewport?) { + guard !isShutDown, !didStartLink, let viewport else { return } + didStartLink = true + linkIsActive = true + transportFailure = nil + let link = self.link + Task.detached(priority: .userInitiated) { [weak self] in + do { + try await link.start(viewport: viewport) + } catch { + await self?.connectFailed(link: link, error: error) + } + } + } + + private func connectFailed(link failed: TmuxSessionLink, error: any Error) async { + await failed.stop() + guard !isShutDown, link === failed, linkIsActive else { return } + linkIsActive = false + transportFailure = GhosttyTerminalDisconnectReasonClassifier.transportStartFailure(error) + state = .detached(nil) + } + + func disconnect() async { + guard linkIsActive else { return } + linkIsActive = false + await link.stop() + controller.attachmentStopped() + } + + func invalidateInactiveTransportOnForeground( + willInvalidate: (TerminalDisconnectReason) -> Void + ) async -> TerminalDisconnectReason? { + guard linkIsActive else { return nil } + guard let isActive = await link.controlChannelIsActive(), !isActive else { return nil } + guard linkIsActive, !isShutDown else { return nil } + let reason = GhosttyTerminalDisconnectReasonClassifier.foregroundMissingHost() + willInvalidate(reason) + await link.invalidateTransport() + return reason + } + + func shutdown() async { + guard !isShutDown else { return } + isShutDown = true + pendingPaneID = nil + zoomRequestedPaneID = 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 + switch newState { + case .detached, .closed: + pendingPaneID = nil + zoomRequestedPaneID = 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 + 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) + if let zoomRequestedPaneID, + activePaneID(in: snapshot) != zoomRequestedPaneID + || isFullViewport(paneID: zoomRequestedPaneID, in: snapshot) { + self.zoomRequestedPaneID = nil + } + presentActivePane(from: snapshot) + } + + private func handlePaneRemoved(_ paneID: TmuxPaneID) { + livePaneIDs.remove(paneID) + pendingTerminalsByPaneID.removeValue(forKey: paneID) + failedCreationPaneIDs.remove(paneID) + if pendingPaneID == paneID { pendingPaneID = nil } + if zoomRequestedPaneID == paneID { zoomRequestedPaneID = 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) { + lastFailedRequest = request + if request == .selectPane || request == .selectWindow { + pendingPaneID = nil + zoomRequestedPaneID = nil + cancelPendingPresentation() + } + if request == .zoomPane { + // Keep the terminal unpresented: split geometry is not the phone's + // canonical terminal viewport. + return + } + 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, + isFullViewport(paneID: paneID, in: topology) { + let hasConflictingIntent = pendingPaneID != nil && pendingPaneID != paneID + if !hasConflictingIntent { + if paneSurface?.paneID == paneID || preparingSurface?.paneID == paneID { + return + } + pendingPaneID = nil + cancelPendingPresentation() + presentActivePane(from: topology) + return + } + } + surfacesByPaneID[paneID]?.cancelPickerCaptureForPresentation() + cancelPendingPresentation() + pendingPaneID = paneID + zoomRequestedPaneID = isFullViewport(paneID: paneID, in: topology) + ? nil + : paneID + unpublishPane() + } + + func capturePickerPreview( + paneID: TmuxPaneID, + columns: UInt32, + rows: UInt32, + budget: GhosttyPanePreviewSession.PixelBudget + ) async -> GhosttyPanePreviewSession.RenderedPreview? { + guard !isShutDown, + state == .ready, + livePaneIDs.contains(paneID), + let surface = surfacesByPaneID[paneID], + !surface.isClosing + else { return nil } + return await surface.capturePickerPreview( + columns: columns, + rows: rows, + budget: budget + ) + } + + func cancelPickerPreview(paneID: TmuxPaneID) { + surfacesByPaneID[paneID]?.cancelPickerCaptureForPresentation() + } + + 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, + isFullViewport(paneID: paneID, in: snapshot) { + paneSurface?.setSceneActive(true) + return + } + if preparingSurface?.paneID == paneID { cancelPendingPresentation() } + unpublishPane() + return + } + + guard isFullViewport(paneID: paneID, in: snapshot) else { + unpublishPane() + if zoomRequestedPaneID != paneID { + zoomRequestedPaneID = paneID + controller.requestZoomPane(paneID: paneID) + } + return + } + zoomRequestedPaneID = nil + + 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, + isFullViewport(paneID: surface.paneID, in: topology) + 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 + } + + private func isFullViewport( + paneID: TmuxPaneID, + in snapshot: TmuxSessionController.TopologySnapshot + ) -> Bool { + guard let pane = snapshot.panes.first(where: { $0.id == paneID }), + let window = snapshot.windows.first(where: { $0.id == pane.windowID }) + else { return false } + if window.zoomed { return true } + return !snapshot.panes.contains { + $0.windowID == window.id && $0.id != paneID + } + } + + #if DEBUG + var pendingPaneIDForTesting: TmuxPaneID? { pendingPaneID } + var zoomRequestedPaneIDForTesting: TmuxPaneID? { zoomRequestedPaneID } + 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/ActiveSessionSwitcherProjectionTests.swift b/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift new file mode 100644 index 00000000..555368dd --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift @@ -0,0 +1,27 @@ +import Foundation +import XCTest +@testable import MoriRemoteTerminal + +final class ActiveSessionSwitcherProjectionTests: XCTestCase { + func testItemsPlaceSelectedSessionBeforeRecencyOrder() { + let selected = item(name: "codex", selected: true, opened: 100) + let recent = item(name: "api", selected: false, opened: 200) + let older = item(name: "web", selected: false, opened: 50) + + XCTAssertEqual( + ActiveSessionSwitcherProjection.items([recent, older, selected]).map(\.id), + [selected.id, recent.id, older.id] + ) + } + + private func item(name: String, selected: Bool, opened: TimeInterval) -> ActiveSessionSwitcherItem { + .init( + id: UUID(), + sessionName: name, + subtitle: "Mori", + runtimeState: .connected, + isSelected: selected, + lastOpenedAt: Date(timeIntervalSince1970: opened) + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift new file mode 100644 index 00000000..c6b8cd55 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift @@ -0,0 +1,41 @@ +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)) + XCTAssertEqual(events.map(\.keyCode), [.escape, .tab]) + } + + func testSelectorsAndModifiersInvokeTheirRetainedActions() { + var calls: [String] = [] + let actions = GhosttyKeyboardChromeActions( + showSessions: { calls.append("sessions") }, + showWindows: { calls.append("windows") }, + showPanes: { calls.append("panes") }, + toggleKeyboard: { calls.append("keyboard") }, + toggleControl: { calls.append("control") }, + sendKey: { _ in false } + ) + + XCTAssertTrue(actions.perform(.sessions)) + XCTAssertTrue(actions.perform(.windows)) + XCTAssertTrue(actions.perform(.panes)) + XCTAssertTrue(actions.perform(.keyboard)) + XCTAssertTrue(actions.perform(.control)) + XCTAssertEqual(calls, ["sessions", "windows", "panes", "keyboard", "control"]) + } + + private func makeActions( + sendKey: @escaping (GhosttySurfaceKeyEvent) -> Bool + ) -> GhosttyKeyboardChromeActions { + GhosttyKeyboardChromeActions( + showSessions: {}, showWindows: {}, showPanes: {}, + toggleKeyboard: {}, toggleControl: {}, 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..253c1d10 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift @@ -0,0 +1,49 @@ +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) + } + +} 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..0b6c8fab --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift @@ -0,0 +1,16 @@ +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") + } +} 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..35a90c2d --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderViewTests.swift @@ -0,0 +1,886 @@ +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 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/GhosttyTopLevelSurfaceTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTopLevelSurfaceTests.swift new file mode 100644 index 00000000..1ea20a2e --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTopLevelSurfaceTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import MoriRemoteTerminal + +final class GhosttyTopLevelSurfaceTests: XCTestCase { + func testPreservesOrderedLeafIDsAndValidFocus() { + let first = UUID() + let second = UUID() + let third = UUID() + + let topLevel = GhosttyTopLevelSurface( + leafIDs: [first, second, third], + focusedLeafID: third + ) + + XCTAssertEqual(topLevel.leafIDs, [first, second, third]) + XCTAssertEqual(topLevel.focusedLeafID, third) + XCTAssertEqual(topLevel.resolvedFocusedLeafID, third) + } + + func testResolvedFocusFallsBackToFirstLeaf() { + let first = UUID() + let second = UUID() + let topLevel = GhosttyTopLevelSurface(leafIDs: [first, second]) + + XCTAssertNil(topLevel.focusedLeafID) + XCTAssertEqual(topLevel.resolvedFocusedLeafID, first) + } + + func testInitializerNormalizesMissingFocus() { + let first = UUID() + let missing = UUID() + + let topLevel = GhosttyTopLevelSurface( + leafIDs: [first], + focusedLeafID: missing + ) + + XCTAssertNil(topLevel.focusedLeafID) + XCTAssertEqual(topLevel.resolvedFocusedLeafID, first) + } + + func testEmptyTopLevelHasNoResolvedFocus() { + let topLevel = GhosttyTopLevelSurface(leafIDs: []) + + XCTAssertNil(topLevel.focusedLeafID) + XCTAssertNil(topLevel.resolvedFocusedLeafID) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxSessionControllerClientSizeTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxSessionControllerClientSizeTests.swift new file mode 100644 index 00000000..b24286c8 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxSessionControllerClientSizeTests.swift @@ -0,0 +1,1203 @@ +import Foundation +import XCTest + +@testable import MoriRemoteTerminal + +@MainActor +final class TmuxSessionControllerClientSizeTests: XCTestCase { + func testCrossWindowSelectionCommandPolicy() { + XCTAssertEqual( + TmuxSessionController.crossWindowSelectionCommands( + windowID: 2, + activePaneID: 20, + preferredPaneID: 20, + zoomed: false, + hasSibling: true + ), + ["select-window -t @2", "resize-pane -Z -t %20"] + ) + XCTAssertEqual( + TmuxSessionController.crossWindowSelectionCommands( + windowID: 2, + activePaneID: 20, + preferredPaneID: 21, + zoomed: true, + hasSibling: true + ), + ["select-window -t @2", "select-pane -Z -t %21"] + ) + XCTAssertEqual( + TmuxSessionController.crossWindowSelectionCommands( + windowID: 2, + activePaneID: 20, + preferredPaneID: 20, + zoomed: true, + hasSibling: true + ), + ["select-window -t @2"] + ) + XCTAssertEqual( + TmuxSessionController.crossWindowSelectionCommands( + windowID: 2, + activePaneID: 20, + preferredPaneID: nil, + zoomed: false, + hasSibling: true + ), + ["select-window -t @2"] + ) + } + + func testPaneCurrentDirectoryUsesOneTargetedCommandAndReturnsItsBody() async throws { + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + let query = Task { + try await harness.controller.paneCurrentDirectory(for: 1) + } + try await waitUntil("current-directory query was not written") { + harness.recorder.hasWrites + } + XCTAssertEqual( + harness.recorder.takeStrings(), + ["display-message -p -t %1 '#{pane_current_path}'\n"] + ) + + harness.controller.pump(Data(responseBlock( + commandNumber: &nextCommandNumber, + body: "/Users/macbook/scratchpad\n" + ).utf8)) + let currentDirectory = try await query.value + XCTAssertEqual(currentDirectory, "/Users/macbook/scratchpad") + } + + func testPaneCurrentDirectoryReturnsCommandFailureWithoutRequestFailureUI() async throws { + let requestFailure = expectation(description: "no request failure callback") + requestFailure.isInverted = true + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3, + callbacks: .init(onRequestFailed: { _ in requestFailure.fulfill() }) + ) + var nextCommandNumber = harness.nextCommandNumber + + let query = Task { + try await harness.controller.paneCurrentDirectory(for: 1) + } + try await waitUntil("current-directory query was not written") { + harness.recorder.hasWrites + } + _ = harness.recorder.takeStrings() + harness.controller.pump(Data(errorBlock( + commandNumber: &nextCommandNumber, + body: "can't find pane: %1" + ).utf8)) + + do { + _ = try await query.value + XCTFail("expected the query to fail") + } catch { + XCTAssertEqual( + error as? TmuxSessionController.PaneCurrentDirectoryError, + .commandFailed("can't find pane: %1") + ) + } + await fulfillment(of: [requestFailure], timeout: 0.05) + } + + func testShutdownResumesOutstandingPaneCurrentDirectoryQuery() async throws { + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3 + ) + let query = Task { + try await harness.controller.paneCurrentDirectory(for: 1) + } + try await waitUntil("current-directory query was not written") { + harness.recorder.hasWrites + } + _ = harness.recorder.takeStrings() + + await shutDown(harness.controller) + + do { + _ = try await query.value + XCTFail("expected shutdown to fail the query") + } catch { + XCTAssertEqual( + error as? TmuxSessionController.PaneCurrentDirectoryError, + .sessionUnavailable + ) + } + } + + func testSideBySideNavigationCoalescesAndRefreshesOnEachRevisit() async throws { + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectPane(paneID: 1) + harness.controller.requestSelectPane(paneID: 2) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + ("%window-pane-changed @0 %1\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %2", + paneID: 2, + ) + + harness.controller.pump(Data( + ("%window-pane-changed @0 %2\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 2, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + XCTAssertTrue(harness.recorder.takeStrings().isEmpty) + + harness.controller.requestSelectPane(paneID: 1) + harness.controller.requestSelectPane(paneID: 2) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + ("%window-pane-changed @0 %1\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %2", + paneID: 2, + ) + } + + func testPaneNavigationWaitsWhenCommandEndsBeforeTopology() async throws { + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectPane(paneID: 1) + harness.controller.requestSelectPane(paneID: 0) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + responseBlock(commandNumber: &nextCommandNumber).utf8 + )) + await drain(harness.controller) + XCTAssertTrue( + harness.recorder.takeStrings().isEmpty, + "command completion must not admit the deferred intent against stale topology" + ) + + harness.controller.pump(Data( + ("%window-pane-changed @0 %1\n" + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %0", + paneID: 0, + ) + + harness.controller.pump(Data( + ("%window-pane-changed @0 %0\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 0, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + XCTAssertTrue(harness.recorder.takeStrings().isEmpty) + } + + func testPaneNavigationUsesTopologyThatArrivedBeforeCompletion() async throws { + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectPane(paneID: 1) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data("%window-pane-changed @0 %1\n".utf8)) + await drain(harness.controller) + harness.controller.requestSelectPane(paneID: 2) + await drain(harness.controller) + XCTAssertTrue( + harness.recorder.takeStrings().isEmpty, + "the outstanding mutation must still coalesce later navigation" + ) + + harness.controller.pump(Data( + (responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %2", + paneID: 2, + ) + } + + func testPaneNavigationWaitsWhenSubmittedAfterCompletionBeforeTopology() async throws { + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectPane(paneID: 1) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + responseBlock(commandNumber: &nextCommandNumber).utf8 + )) + await drain(harness.controller) + harness.controller.requestSelectPane(paneID: 2) + await drain(harness.controller) + XCTAssertTrue( + harness.recorder.takeStrings().isEmpty, + "a successful mutation remains pending until newer topology arrives" + ) + + harness.controller.pump(Data( + ("%window-pane-changed @0 %1\n" + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %2", + paneID: 2, + ) + } + + func testPaneNavigationReevaluatesImmediatelyAfterCommandError() async throws { + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectPane(paneID: 1) + harness.controller.requestSelectPane(paneID: 2) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + errorBlock(commandNumber: &nextCommandNumber, body: "can't find pane: %1").utf8 + )) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %2", + paneID: 2, + ) + } + + func testFailedPresentationRetryKeepsSameTargetRefreshAfterInFlightCompletion() async throws { + let harness = try await readyController( + listWindowsBody: Self.threePaneZoomedWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectPane(paneID: 1) + harness.controller.requestSelectPane(paneID: 1) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + errorBlock(commandNumber: &nextCommandNumber, body: "can't find pane: %1").utf8 + )) + await drain(harness.controller) + XCTAssertEqual( + harness.recorder.takeStrings(), + ["select-pane -Z -t %1\n"], + "the retry executes after the already queued refresh" + ) + + harness.controller.pump(Data( + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + ).utf8 + )) + await drain(harness.controller) + let retryRefreshWrites = harness.recorder.takeStrings() + XCTAssertEqual(retryRefreshWrites.count, 1) + let retryRefresh = try XCTUnwrap(retryRefreshWrites.first) + XCTAssertTrue(retryRefresh.hasPrefix("display-message -p -t %1 ")) + XCTAssertEqual(retryRefresh.components(separatedBy: "capture-pane").count - 1, 4) + + harness.controller.pump(Data( + ("%window-pane-changed @0 %1\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + XCTAssertTrue(harness.recorder.takeStrings().isEmpty) + } + + func testRepeatedCrossWindowUnzoomedSelectionDoesNotQueueSecondToggle() async throws { + let harness = try await readyController( + listWindowsBody: Self.splitTargetWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectWindow(windowID: 1, preferredPaneID: 1) + harness.controller.requestSelectWindow(windowID: 1, preferredPaneID: 1) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-window -t @1 ; resize-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + ("%session-window-changed $42 @1\n" + + responseBlock(commandNumber: &nextCommandNumber) + + "%layout-change @1 9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2} b7de,83x44,0,0,1 *Z\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + XCTAssertTrue( + harness.recorder.takeStrings().isEmpty, + "the repeated intent must re-evaluate as zero after the first group zooms" + ) + } + + func testDeferredZoomDoesNotToggleAfterWindowSelectionAlreadyZooms() async throws { + let harness = try await readyController( + listWindowsBody: Self.splitTargetWindow, + expectedPaneCount: 3 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectWindow(windowID: 1, preferredPaneID: 1) + harness.controller.requestZoomPane(paneID: 1) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-window -t @1 ; resize-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + ("%session-window-changed $42 @1\n" + + responseBlock(commandNumber: &nextCommandNumber) + + "%layout-change @1 9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2} b7de,83x44,0,0,1 *Z\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + XCTAssertTrue( + harness.recorder.takeStrings().isEmpty, + "the deferred zoom must re-evaluate to a no-op after the selection group zooms" + ) + } + + func testWindowNavigationCoalescesRollbackToLatestWindow() async throws { + let harness = try await readyController( + listWindowsBody: Self.twoSinglePaneWindows, + expectedPaneCount: 2 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectWindow(windowID: 1, preferredPaneID: 1) + harness.controller.requestSelectWindow(windowID: 0, preferredPaneID: 0) + await drain(harness.controller) + XCTAssertEqual(harness.recorder.takeStrings(), ["select-window -t @1\n"]) + + harness.controller.pump(Data( + ("%session-window-changed $42 @1\n" + + responseBlock(commandNumber: &nextCommandNumber)).utf8 + )) + await drain(harness.controller) + XCTAssertEqual(harness.recorder.takeStrings(), ["select-window -t @0\n"]) + } + + func testColdSplitSelectionEnqueuesPresentationThenRefreshInOneWrite() async throws { + let harness = try await readyController( + listWindowsBody: Self.twoPaneUnzoomedWindow, + expectedPaneCount: 2 + ) + + harness.controller.requestSelectPane(paneID: 2) + await drain(harness.controller) + + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "resize-pane -Z -t %2", + paneID: 2, + ) + } + + func testRefreshCompletionReleasesReadinessWithoutSecondTerminalHandoff() async throws { + let lifecycle = ControllerLifecycleRecorder() + let harness = try await readyController( + listWindowsBody: Self.twoPaneUnzoomedWindow, + expectedPaneCount: 2, + callbacks: lifecycle.callbacks + ) + try await waitUntil("initial terminals were not handed off") { + lifecycle.terminalPaneIDs.count == 2 + } + let initialTerminalPaneIDs = lifecycle.terminalPaneIDs + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectPane(paneID: 2) + await drain(harness.controller) + _ = harness.recorder.takeStrings() + try await waitUntil("refresh did not gate pane readiness") { + lifecycle.phaseChanges.contains { $0.paneID == 2 && $0.phase == .hydrating } + } + + harness.controller.pump(Data( + ("%window-pane-changed @1 %2\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 2, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + try await waitUntil("refresh completion did not restore pane readiness") { + lifecycle.phaseChanges.contains { $0.paneID == 2 && $0.phase == .live } + } + + XCTAssertEqual(lifecycle.terminalPaneIDs, initialTerminalPaneIDs) + } + + func testTopBottomRoundTripRefreshesEachFullToSplitGridChange() async throws { + let harness = try await readyController( + listWindowsBody: Self.twoPaneSameColumnZoomedWindow, + expectedPaneCount: 2 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.requestSelectPane(paneID: 1) + await drain(harness.controller) + + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %1", + paneID: 1, + ) + + harness.controller.pump(Data( + ("%window-pane-changed @0 %1\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 1, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + + harness.controller.requestSelectPane(paneID: 0) + await drain(harness.controller) + assertPresentationWrite( + harness.recorder.takeStrings(), + command: "select-pane -Z -t %0", + paneID: 0, + ) + } + + func testClientSizeRefreshesForRowOrColumnChangesInOneWrite() async throws { + let harness = try await readyController( + listWindowsBody: Self.onePaneWindow, + expectedPaneCount: 1 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.setClientSize(cols: 83, rows: 44) + await drain(harness.controller) + XCTAssertTrue( + harness.recorder.takeStrings().isEmpty, + "the renderer reporting the already-submitted initial grid must do no work" + ) + + harness.controller.setClientSize(cols: 83, rows: 40) + await drain(harness.controller) + var writes = harness.recorder.takeStrings() + XCTAssertEqual(writes.count, 1) + var write = try XCTUnwrap(writes.first) + XCTAssertTrue(write.hasPrefix("refresh-client -C 83x40\ndisplay-message")) + XCTAssertEqual(write.components(separatedBy: "capture-pane").count - 1, 4) + harness.controller.pump(Data( + (responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 0, + rows: 40, + commandNumber: &nextCommandNumber + )).utf8 + )) + await drain(harness.controller) + + harness.controller.setClientSize(cols: 100, rows: 40) + await drain(harness.controller) + writes = harness.recorder.takeStrings() + XCTAssertEqual(writes.count, 1) + write = try XCTUnwrap(writes.first) + XCTAssertTrue( + write.hasPrefix("refresh-client -C 100x40\ndisplay-message -p -t %0 ") + ) + XCTAssertEqual(write.components(separatedBy: "capture-pane").count - 1, 4) + } + + func testInFlightGridRefreshFollowsViewportRevertExactlyOnce() async throws { + let harness = try await readyController( + listWindowsBody: Self.onePaneWindow, + expectedPaneCount: 1 + ) + var nextCommandNumber = harness.nextCommandNumber + + harness.controller.setClientSize(cols: 100, rows: 40) + await drain(harness.controller) + let outbound100 = try XCTUnwrap(harness.recorder.takeStrings().first) + XCTAssertTrue(outbound100.hasPrefix("refresh-client -C 100x40\ndisplay-message")) + + harness.controller.setClientSize(cols: 83, rows: 44) + await drain(harness.controller) + XCTAssertEqual(harness.recorder.takeStrings(), ["refresh-client -C 83x44\n"]) + + harness.controller.pump(Data( + ("%layout-change @0 aa7d,100x40,0,0,0 aa7d,100x40,0,0,0 *\n" + + responseBlock(commandNumber: &nextCommandNumber) + + refreshResponseBlocks( + paneID: 0, + columns: 100, + rows: 40, + commandNumber: &nextCommandNumber + ) + + responseBlock(commandNumber: &nextCommandNumber)).utf8 + )) + await drain(harness.controller) + + let followUpWrites = harness.recorder.takeStrings() + XCTAssertEqual(followUpWrites.count, 1) + let followUp = try XCTUnwrap(followUpWrites.first) + XCTAssertTrue(followUp.hasPrefix("display-message -p -t %0 ")) + XCTAssertEqual(followUp.components(separatedBy: "capture-pane").count - 1, 4) + + harness.controller.pump(Data( + ("%layout-change @0 b7dd,83x44,0,0,0 b7dd,83x44,0,0,0 *\n" + + refreshResponseBlocks( + paneID: 0, + columns: 83, + commandNumber: &nextCommandNumber)).utf8 + )) + await drain(harness.controller) + XCTAssertTrue(harness.recorder.takeStrings().isEmpty) + } + + func testNewWindowAndSplitCommandsDoNotGainRefreshWork() async throws { + let harness = try await readyController( + listWindowsBody: Self.onePaneWindow, + expectedPaneCount: 1 + ) + + harness.controller.requestSelectPane(paneID: 0) + await drain(harness.controller) + XCTAssertTrue(harness.recorder.takeStrings().isEmpty) + + harness.controller.requestNewWindow() + await drain(harness.controller) + XCTAssertEqual(harness.recorder.takeStrings(), ["new-window\n"]) + + harness.controller.requestSplit(paneID: 0, direction: .right, zoom: true) + await drain(harness.controller) + XCTAssertEqual(harness.recorder.takeStrings(), ["split-window -h -Z -t %0\n"]) + } + + func testNotReadyRefreshRetriesOnceAndDrainsAfterInitialPaneChanged() async throws { + let harness = try await hydratingController( + listWindowsBody: Self.twoPaneUnzoomedWindow, + expectedPaneCount: 2 + ) + + harness.controller.requestZoomPane(paneID: 1) + await drain(harness.controller) + XCTAssertEqual( + harness.recorder.takeStrings(), + ["resize-pane -Z -t %1\n"], + "initial hydration cannot yet accept the refresh" + ) + + let hydrationEnd = harness.firstHydrationCommandNumber + harness.hydrationCommandCount + let hydration = (harness.firstHydrationCommandNumber.. ReadyControllerHarness { + let harness = try await hydratingController( + listWindowsBody: listWindowsBody, + expectedPaneCount: expectedPaneCount, + callbacks: callbacks + ) + let hydrationEnd = harness.firstHydrationCommandNumber + harness.hydrationCommandCount + let hydration = (harness.firstHydrationCommandNumber.. HydratingControllerHarness { + let runtime = try GhosttyKitRuntime() + let recorder = ControllerOutboundRecorder() + let controller = TmuxSessionController(callbacks: callbacks) + addTeardownBlock { + await withCheckedContinuation { continuation in + controller.shutdown { continuation.resume() } + } + } + controller.setOutboundSink { recorder.append($0) } + await drain(controller) + try await withCheckedThrowingContinuation { continuation in + controller.start(initialSize: .init(cols: 83, rows: 44)) { result in + continuation.resume(with: result) + } + } + controller.pump(Data( + "%begin 1 1 0\n%end 1 1 0\n%session-changed $42 main\n".utf8 + )) + await drain(controller) + XCTAssertEqual( + recorder.takeStrings(), + [ + "display-message -p '#{version}'\n" + + "refresh-client -C 83x44\n" + + "list-windows -F '#{session_id} #{window_id} #{window_active} #{pane_id} #{window_width} #{window_height} #{window_layout} #{window_visible_layout} #{window_name}'\n" + ] + ) + controller.pump(Data( + ("%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\(listWindowsBody)%end 4 4 1\n").utf8 + )) + await drain(controller) + let hydrationWrites = recorder.takeStrings() + let hydrationOutbound = hydrationWrites.joined() + let hydrationCommandCount = hydrationOutbound + .split(separator: "\n") + .reduce(0) { count, line in + count + 1 + line.components(separatedBy: " ; ").count - 1 + } + XCTAssertEqual( + hydrationCommandCount, + 1 + expectedPaneCount * 4, + "hydration must use one pane-state scan and four captures per pane" + ) + let firstHydrationCommandNumber = 5 + return HydratingControllerHarness( + runtime: runtime, + controller: controller, + recorder: recorder, + firstHydrationCommandNumber: firstHydrationCommandNumber, + hydrationCommandCount: hydrationCommandCount + ) + } + + private func responseBlock(commandNumber: inout Int) -> String { + defer { commandNumber += 1 } + return "%begin \(commandNumber) \(commandNumber) 1\n" + + "%end \(commandNumber) \(commandNumber) 1\n" + } + + private func responseBlock( + commandNumber: inout Int, + body: String + ) -> String { + defer { commandNumber += 1 } + return "%begin \(commandNumber) \(commandNumber) 1\n" + + body + + "%end \(commandNumber) \(commandNumber) 1\n" + } + + private func refreshResponseBlocks( + paneID: TmuxPaneID, + columns: UInt32 = 83, + rows: UInt32 = 44, + commandNumber: inout Int + ) -> String { + let cursorY = rows - 1 + let state = "%\(paneID.rawValue);\(columns);\(rows);0;0;1;;;;0;" + + "4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;;;0;0;\(cursorY);8,16\n" + return responseBlock(commandNumber: &commandNumber, body: state) + + responseBlock(commandNumber: &commandNumber) + + responseBlock(commandNumber: &commandNumber) + + responseBlock(commandNumber: &commandNumber) + + responseBlock(commandNumber: &commandNumber) + } + + private func assertPresentationWrite( + _ writes: [String], + command: String, + paneID: TmuxPaneID, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(writes.count, 1, file: file, line: line) + guard let write = writes.first else { return } + XCTAssertTrue( + write.hasPrefix(command + "\ndisplay-message -p -t %\(paneID.rawValue) "), + "presentation must precede refresh in the same outbound write: \(write)", + file: file, + line: line + ) + let refresh = String(write.dropFirst(command.utf8.count + 1)) + XCTAssertEqual( + refresh.components(separatedBy: "capture-pane").count - 1, + 4, + file: file, + line: line + ) + XCTAssertEqual( + refresh.components(separatedBy: " ; ").count - 1, + 4, + file: file, + line: line + ) + } + + private func errorBlock(commandNumber: inout Int, body: String) -> String { + defer { commandNumber += 1 } + return "%begin \(commandNumber) \(commandNumber) 1\n" + + "\(body)\n" + + "%error \(commandNumber) \(commandNumber) 1\n" + } + + 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 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 static let threePaneZoomedWindow = windowRecord( + id: 0, + active: true, + paneID: 0, + layout: "85ff,83x44,0,0{27x44,0,0,0,27x44,28,0,1,27x44,56,0,2}", + visibleLayout: "b7dd,83x44,0,0,0", + name: "window-0" + ) + + private static let splitTargetWindow = windowRecord( + id: 0, + active: true, + paneID: 0, + layout: "b7dd,83x44,0,0,0", + visibleLayout: "b7dd,83x44,0,0,0", + name: "window-0" + ) + windowRecord( + id: 1, + active: false, + paneID: 1, + layout: "9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2}", + visibleLayout: "9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2}", + name: "window-1" + ) + + private static let twoSinglePaneWindows = windowRecord( + id: 0, + active: true, + paneID: 0, + layout: "b7dd,83x44,0,0,0", + visibleLayout: "b7dd,83x44,0,0,0", + name: "window-0" + ) + windowRecord( + id: 1, + active: false, + paneID: 1, + layout: "b7de,83x44,0,0,1", + visibleLayout: "b7de,83x44,0,0,1", + name: "window-1" + ) + + private static let onePaneWindow = windowRecord( + id: 0, + active: true, + paneID: 0, + layout: "b7dd,83x44,0,0,0", + visibleLayout: "b7dd,83x44,0,0,0", + name: "window-0" + ) + + private static let twoPaneUnzoomedWindow = windowRecord( + id: 1, + active: true, + paneID: 1, + layout: "9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2}", + visibleLayout: "9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2}", + name: "window-1" + ) + + private static let twoPaneSameColumnZoomedWindow = windowRecord( + id: 0, + active: true, + paneID: 0, + layout: "607b,83x44,0,0[83x22,0,0,0,83x21,0,23,1]", + visibleLayout: "b7dd,83x44,0,0,0", + name: "window-0" + ) + + private static func windowRecord( + id: Int, + active: Bool, + paneID: Int, + layout: String, + visibleLayout: String, + name: String + ) -> String { + "$42 @\(id) \(active ? 1 : 0) %\(paneID) 83 44 " + + "\(layout) \(visibleLayout) \(name)\n" + } +} + +private actor RequestFailureRecorder { + private var recordedRequests: [TmuxSessionController.Request] = [] + + func append(_ request: TmuxSessionController.Request) { + recordedRequests.append(request) + } + + func requests() -> [TmuxSessionController.Request] { + recordedRequests + } +} + +private final class ControllerOutboundRecorder: @unchecked Sendable { + private let lock = NSLock() + private var writes: [Data] = [] + + func append(_ data: Data) { + lock.withLock { writes.append(data) } + } + + var hasWrites: Bool { + lock.withLock { !writes.isEmpty } + } + + func takeStrings() -> [String] { + lock.withLock { + defer { writes.removeAll() } + return writes.map { String(decoding: $0, as: UTF8.self) } + } + } +} + +private final class ControllerLifecycleRecorder: @unchecked Sendable { + struct PhaseChange: Equatable { + let paneID: TmuxPaneID + let phase: TmuxSessionController.PaneInfo.Phase + } + + private let lock = NSLock() + private var terminals: [TmuxSessionController.RetainedPaneTerminal] = [] + private var phases: [PhaseChange] = [] + + var callbacks: TmuxSessionController.Callbacks { + .init( + onPaneTerminal: { [self] terminal in + lock.withLock { terminals.append(terminal) } + }, + onPanePhaseChanged: { [self] paneID, phase in + lock.withLock { phases.append(.init(paneID: paneID, phase: phase)) } + } + ) + } + + var terminalPaneIDs: [TmuxPaneID] { + lock.withLock { terminals.map(\.paneID).sorted() } + } + + var phaseChanges: [PhaseChange] { + lock.withLock { phases } + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift new file mode 100644 index 00000000..0db790f2 --- /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(viewport: .default) + + 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(viewport: .default) + 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(viewport: .default) + 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..1324c09d --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift @@ -0,0 +1,275 @@ +import GhosttyKit +import XCTest + +@testable import MoriRemoteTerminal + +@MainActor +final class TmuxTerminalScreenAdapterTests: XCTestCase { + func testIdentityRegistryKeepsPaneRoundTripStable() { + var registry = TmuxTerminalIdentityRegistry() + let paneID = TmuxPaneID(41) + + let surfaceID = registry.surfaceID(for: paneID) + + XCTAssertEqual(registry.surfaceID(for: paneID), surfaceID) + XCTAssertEqual(registry.paneID(for: surfaceID), paneID) + XCTAssertNil(registry.paneID(for: UUID())) + } + + func testIdentityRegistryKeepsWindowRoundTripStable() { + var registry = TmuxTerminalIdentityRegistry() + let windowID = TmuxWindowID(17) + + let surfaceID = registry.surfaceID(for: windowID) + + XCTAssertEqual(registry.surfaceID(for: windowID), surfaceID) + XCTAssertEqual(registry.windowID(for: surfaceID), windowID) + XCTAssertNil(registry.windowID(for: UUID())) + } + + 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?, + name: String = "", + zoomed: Bool = true + ) -> TmuxSessionController.WindowInfo { + TmuxSessionController.WindowInfo( + id: id, + name: name, + active: active, + zoomed: zoomed, + 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 + ) + } + + func testWindowProjectionReflectsEmittedTopologyImmediately() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + let adapter = TmuxTerminalScreenAdapter() + adapter.activate( + session: session, + initialViewportHandler: { _, _ in }, + clientSizeHandler: { _ in }, + viewportStabilityHandler: { _ in } + ) + + let twoWindows = TmuxSessionController.TopologySnapshot( + sessionName: "fresh-test", + windows: [ + window(id: 1, active: true, paneID: 10, name: "editor"), + window(id: 2, active: false, paneID: 20, name: "logs") + ], + panes: [pane(id: 10, windowID: 1), pane(id: 20, windowID: 2)], + activeWindowID: 1 + ) + session.handleTopology(twoWindows) + + let first = adapter.windowSelectionSheetRenderProjection() + XCTAssertEqual( + first.windows.count, 2, + "the first emitted topology must project immediately, not lag one update behind" + ) + XCTAssertEqual(first.windows.map(\.displayName), ["editor", "logs"]) + let firstPaneSurfaceID = try XCTUnwrap(first.previewLeafIDs.first) + XCTAssertEqual(adapter.tmuxPaneID(for: firstPaneSurfaceID), 10) + + let oneWindow = TmuxSessionController.TopologySnapshot( + sessionName: "fresh-test", + windows: [window(id: 1, active: true, paneID: 10, name: "renamed")], + panes: [pane(id: 10, windowID: 1)], + activeWindowID: 1 + ) + session.handleTopology(oneWindow) + + let second = adapter.windowSelectionSheetRenderProjection() + XCTAssertEqual( + second.windows.count, 1, + "removing a non-current window must drop its tile on the same topology update" + ) + XCTAssertEqual(second.windows.first?.totalCount, 1) + XCTAssertEqual(second.windows.first?.displayName, "renamed") + + await session.shutdown() + } + + func testNameOnlyTopologyUpdatePreservesSurfaceIdentityAndPanePreview() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + let adapter = TmuxTerminalScreenAdapter() + adapter.activate( + session: session, + initialViewportHandler: { _, _ in }, + clientSizeHandler: { _ in }, + viewportStabilityHandler: { _ in } + ) + + let initial = TmuxSessionController.TopologySnapshot( + sessionName: "rename-test", + windows: [window(id: 1, active: true, paneID: 10, name: "editor")], + panes: [pane(id: 10, windowID: 1)], + activeWindowID: 1 + ) + session.handleTopology(initial) + let before = adapter.windowSelectionSheetRenderProjection() + let beforeWindowID = try XCTUnwrap(before.windows.first?.id) + let beforePaneID = try XCTUnwrap(before.previewLeafIDs.first) + + let image = try makeImage(width: 4, height: 4) + var cache = TmuxPanePreviewImageCache(byteLimit: 1_024) + cache.store(preview(image), for: 10) + let initialByteCost = cache.totalByteCost + + let renamed = TmuxSessionController.TopologySnapshot( + sessionName: "rename-test", + windows: [window(id: 1, active: true, paneID: 10, name: "déploy-漢字")], + panes: [pane(id: 10, windowID: 1)], + activeWindowID: 1 + ) + session.handleTopology(renamed) + let after = adapter.windowSelectionSheetRenderProjection() + + XCTAssertEqual(after.windows.first?.displayName, "déploy-漢字") + XCTAssertEqual(after.windows.first?.id, beforeWindowID) + XCTAssertEqual(after.previewLeafIDs.first, beforePaneID) + XCTAssertEqual( + cache.retainOnly(Set(renamed.panes.map(\.id))), + [], + "a name-only topology update must not evict any pane preview" + ) + XCTAssertTrue(cache.preview(for: 10)?.image === image) + XCTAssertEqual(cache.totalByteCost, initialByteCost) + + await session.shutdown() + } + + func testPanePreviewCacheEvictsLeastRecentlyUsedImageWithinByteLimit() throws { + let first = try makeImage(width: 4, height: 4) + let second = try makeImage(width: 4, height: 4) + let third = try makeImage(width: 4, height: 4) + let imageCost = first.bytesPerRow * first.height + var cache = TmuxPanePreviewImageCache(byteLimit: imageCost * 2) + + XCTAssertEqual(cache.store(preview(first), for: 1), []) + XCTAssertEqual(cache.store(preview(second), for: 2), []) + XCTAssertNotNil(cache.preview(for: 1), "reading pane 1 must refresh its LRU age") + XCTAssertEqual(cache.store(preview(third), for: 3), [2]) + XCTAssertNotNil(cache.preview(for: 1)) + XCTAssertNil(cache.preview(for: 2)) + XCTAssertNotNil(cache.preview(for: 3)) + XCTAssertEqual(cache.totalByteCost, imageCost * 2) + } + + func testPanePreviewCacheDropsRemovedTopologyPanes() throws { + let image = try makeImage(width: 4, height: 4) + var cache = TmuxPanePreviewImageCache(byteLimit: 1024) + cache.store(preview(image), for: 1) + cache.store(preview(image), for: 2) + + XCTAssertEqual(Set(cache.retainOnly(Set([2]))), Set([1])) + XCTAssertNil(cache.preview(for: 1)) + XCTAssertNotNil(cache.preview(for: 2)) + } + + func testPanePreviewCacheRejectsImageLargerThanByteLimit() throws { + let image = try makeImage(width: 4, height: 4) + var cache = TmuxPanePreviewImageCache( + byteLimit: image.bytesPerRow * image.height - 1 + ) + + XCTAssertEqual(cache.store(preview(image), for: 1), []) + XCTAssertNil(cache.preview(for: 1)) + XCTAssertEqual(cache.totalByteCost, 0) + } + + func testPanePreviewCacheRetainsFullViewportProvenance() throws { + let image = try makeImage(width: 4, height: 4) + let expected = provenance() + var cache = TmuxPanePreviewImageCache(byteLimit: 1024) + + cache.store( + .init(image: image, source: .fullViewport(expected)), + for: 1 + ) + + XCTAssertEqual(cache.entries[1]?.preview.source, .fullViewport(expected)) + } + + func testPanePreviewCacheRetainsPaneGeometrySource() throws { + let image = try makeImage(width: 4, height: 4) + var cache = TmuxPanePreviewImageCache(byteLimit: 1024) + + cache.store(preview(image), for: 1) + + guard case .paneGeometry(let provenance)? = cache.preview(for: 1)?.source else { + return XCTFail("expected pane geometry provenance") + } + XCTAssertEqual(provenance.columns, 80) + XCTAssertEqual(provenance.rows, 24) + } + + private func makeImage(width: Int, height: Int) throws -> CGImage { + let context = try XCTUnwrap(CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + return try XCTUnwrap(context.makeImage()) + } + + private func provenance() -> GhosttyPanePreviewSession.FullViewportProvenance { + GhosttyPanePreviewSession.FullViewportProvenance( + surfaceID: UUID(), + pixelWidth: 390, + pixelHeight: 709 + ) + } + + private func preview( + _ image: CGImage + ) -> GhosttyPanePreviewSession.RenderedPreview { + .init( + image: image, + source: .paneGeometry(.init( + surfaceID: UUID(), + columns: 80, + rows: 24 + )) + ) + } +} diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift new file mode 100644 index 00000000..7c9fe5b2 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift @@ -0,0 +1,241 @@ +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 testSameWindowSelectionSuppressesDuplicateZoomForIntermediateTopology() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(twoPaneSnapshot(activePaneID: 10, zoomed: false)) + + session.prepareForPaneSelection(paneID: 11) + session.handleStateForTesting(.ready) + session.handleTopology(twoPaneSnapshot(activePaneID: 11, zoomed: false)) + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(session.pendingPaneIDForTesting, 11) + XCTAssertEqual(session.zoomRequestedPaneIDForTesting, 11) + XCTAssertNil(session.lastFailedRequest, "intermediate topology must not enqueue a second zoom") + await session.shutdown() + } + + func testCrossWindowSelectionSuppressesDuplicateZoomForIntermediateTopology() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(crossWindowSnapshot(activeWindowID: 1, targetActivePaneID: 20)) + + session.prepareForPaneSelection(paneID: 21) + session.handleStateForTesting(.ready) + session.handleTopology(crossWindowSnapshot(activeWindowID: 2, targetActivePaneID: 21)) + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(session.pendingPaneIDForTesting, 21) + XCTAssertEqual(session.zoomRequestedPaneIDForTesting, 21) + XCTAssertNil(session.lastFailedRequest, "group intermediate topology must not toggle zoom again") + await session.shutdown() + } + + func testSelectionFailureClearsPendingZoomIntent() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + session.handleTopology(twoPaneSnapshot(activePaneID: 10, zoomed: false)) + session.prepareForPaneSelection(paneID: 11) + + session.handleRequestFailedForTesting(.selectPane) + + XCTAssertNil(session.pendingPaneIDForTesting) + XCTAssertNil(session.zoomRequestedPaneIDForTesting) + 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/UPSTREAM.md b/MoriRemote/UPSTREAM.md index 0a51a49c..2c469a79 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -1,59 +1,87 @@ -# 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 is +not yet instantiated by the production SSH shell. This is not a permanent +binary-distribution mechanism: Phase 2 links this archive into `MoriRemote`, +removes `GhosttyKit` from the app target's direct dependencies, and makes the +app the sole bundle consumer of GhosttyKit. A static framework is the first +correct rung because it supplies a module boundary now without embedding a +second dynamic terminal binary later. + +## 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/model, pane preview cache, + runtime trace, and deterministic test transport. +- `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, the retained + Ctrl/Esc/Tab/session/window/pane/system-keyboard chrome, preview layout, + topology/selection projections, selection sheets, and + `GhosttyTerminalCoreView` (the minimal upstream-derived composition root). +- `App/ActiveSessionSwitcherView.swift`: an account-free active-session + switcher projection and view. +- `Domain/TerminalSettings.swift`: terminal appearance only. + +The paired `MoriRemoteTerminalTests` target ports the matching upstream tests +for controller/session/link/adapter teardown, scrolling and viewport state, +responder and keyboard input, modifier state, selection projections, and the +active-session switcher. + +## Explicit Phase-1 exclusions + +No files from remux account/profile repositories, SSH services/transports, +SFTP/live forwarding, terminal preview, attachments, composer/voice, or +shortcut marketplace/editor are linked into `MoriRemoteTerminal`. + +`TmuxControlTransport` is a small protocol-only seam. It deliberately omits +remux SFTP and live-forward provider protocols. The deterministic transport is +retained solely as a terminal-core test fixture. + +## Required adaptations and iOS 17 deviations + +| Area | Change | Why | +| --- | --- | --- | +| `TmuxScreenModel.swift` | Reduced to injected `ghostty_app_t` + `TmuxControlTransport` composition. | Upstream constructs account targets, runtime status reporting, and preview services; those are Phase 2+ concerns. | +| `TmuxControlTransport.swift` | Protocol-only; removes SFTP/live-forward refinements. | Keeps the core independent of SSH/Citadel and forwarding. | +| `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | +| `GhosttyKeyboardChrome.swift` | Retains the actionable Ctrl/Esc/Tab, session/window/pane selectors, and system keyboard controls; removes composer and shortcut-store actions. | Those excluded surfaces require domains explicitly outside Phase 1. | +| `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, chrome, and picker sheets. | No SSH construction or persistence dependency. | +| `ActiveSessionSwitcherView.swift` | Uses `UUID`/title/subtitle DTOs and select/disconnect callbacks. | Prevents profile/repository types from entering terminal core. | +| iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | + +## 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` | Same filenames | SSH transport integration deliberately excluded. | +| responder, text input and paste | `GhosttyTerminalResponderViewTests.swift`, `GhosttyTerminalInputCoordinatorTests.swift` | Same filenames | Simulator-global `UIPasteboard` integration replaced by injected deterministic source; routing remains tested. | +| 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` | Composer, attachments, shortcut UI and account actions deliberately excluded. | + +## 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..73d9afe1 100644 --- a/MoriRemote/project.yml +++ b/MoriRemote/project.yml @@ -74,6 +74,49 @@ 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++ + # Phase 2 links this archive into MoriRemote. Keep it static now: the + # app will then be the single bundle consumer of GhosttyKit, with no + # second embedded 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 +140,4 @@ schemes: test: targets: - MoriRemoteTests + - MoriRemoteTerminalTests From 24471ece2317ebbe91cd615a8a9c4392e9a0c92d Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 17:10:31 +0800 Subject: [PATCH 02/22] moriremote: cut over to remux terminal runtime --- .../MoriRemote.xcodeproj/project.pbxproj | 99 +- .../App/MoriRemoteDependencies.swift | 7 - .../MoriRemote/App/RemoteRootModel.swift | 144 +- .../Ghostty/GhosttyKitRuntime.swift | 155 --- .../Ghostty/GhosttyPaneSurface.swift | 526 ------- .../Ghostty/GhosttyTerminalProbe.swift | 111 -- .../Ghostty/GhosttyTmuxRuntime.swift | 116 -- .../MoriRemote/GhosttyKitABIProbe.swift | 9 - MoriRemote/MoriRemote/MoriRemoteApp.swift | 3 +- .../Resources/en.lproj/Localizable.strings | 5 + .../zh-Hans.lproj/Localizable.strings | 5 + MoriRemote/MoriRemote/SSH/SSHRootPool.swift | 5 +- .../Tmux/AgentMetadataProjector.swift | 79 +- .../DeterministicTmuxControlTransport.swift | 39 - .../Tmux/SSHTmuxControlTransport.swift | 21 +- MoriRemote/MoriRemote/Tmux/TmuxControl.swift | 206 --- .../Tmux/TmuxSessionController.swift | 319 ----- .../MoriRemote/Tmux/TmuxShellCommand.swift | 111 +- .../MoriRemote/Views/RemoteRootView.swift | 125 +- .../App/ActiveSessionSwitcherView.swift | 37 +- .../App/MoriRemoteTerminalFacade.swift | 195 +++ .../App/MoriRemoteTerminalProbe.swift | 129 ++ .../Ghostty/GhosttyTerminalCoreView.swift | 11 +- .../GhosttyTerminalScreenModeling.swift | 27 - .../Tmux/TmuxScreenModel.swift | 9 +- .../Tmux/TmuxSessionController.swift | 381 ++---- .../Tmux/TmuxSessionLink.swift | 20 +- .../Tmux/TmuxTerminalScreenAdapter.swift | 107 +- .../Tmux/TmuxTerminalSession.swift | 91 +- ...ActiveSessionSwitcherProjectionTests.swift | 1 - .../MoriRemoteTerminalFacadeTests.swift | 62 + .../MoriTmuxIsolationTests.swift | 48 + .../MoriTmuxNativeStartupIsolationTests.swift | 64 + ...TmuxSessionControllerClientSizeTests.swift | 1203 ----------------- .../TmuxSessionLinkWriteFailureTests.swift | 6 +- .../TmuxTerminalScreenAdapterTests.swift | 2 - ...muxTerminalSessionShutdownDrainTests.swift | 45 - .../MoriRemoteTests/Phase3RuntimeTests.swift | 158 --- .../MoriRemoteTests/Phase4ShellTests.swift | 24 +- .../Phase5AgentMetadataTests.swift | 156 +-- .../Phase6TerminalOwnershipTests.swift | 58 + .../MoriRemoteTests/SSHTransportTests.swift | 50 - MoriRemote/UPSTREAM.md | 25 +- MoriRemote/project.yml | 11 +- scripts/smoke-moriremote-simulator.sh | 6 +- 45 files changed, 1256 insertions(+), 3755 deletions(-) delete mode 100644 MoriRemote/MoriRemote/Ghostty/GhosttyKitRuntime.swift delete mode 100644 MoriRemote/MoriRemote/Ghostty/GhosttyPaneSurface.swift delete mode 100644 MoriRemote/MoriRemote/Ghostty/GhosttyTerminalProbe.swift delete mode 100644 MoriRemote/MoriRemote/Ghostty/GhosttyTmuxRuntime.swift delete mode 100644 MoriRemote/MoriRemote/GhosttyKitABIProbe.swift delete mode 100644 MoriRemote/MoriRemote/Tmux/DeterministicTmuxControlTransport.swift delete mode 100644 MoriRemote/MoriRemote/Tmux/TmuxControl.swift delete mode 100644 MoriRemote/MoriRemote/Tmux/TmuxSessionController.swift create mode 100644 MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift create mode 100644 MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/MoriTmuxIsolationTests.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/MoriTmuxNativeStartupIsolationTests.swift delete mode 100644 MoriRemote/MoriRemoteTerminalTests/TmuxSessionControllerClientSizeTests.swift delete mode 100644 MoriRemote/MoriRemoteTests/Phase3RuntimeTests.swift create mode 100644 MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index e0674f08..d3a4a942 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 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 */; }; - 29E5C06C8FB1718904533EEE /* TmuxControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E335F20D27CE6353A85E4 /* TmuxControl.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 */; }; @@ -35,16 +34,14 @@ 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 */; }; - 3D5ABC739FD3170547500C7E /* GhosttyKitABIProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.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 */; }; 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 */; }; 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 */; }; @@ -52,22 +49,20 @@ 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 */; }; 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 */; }; - 6A48E864C5F4578CE6968AE3 /* TmuxSessionControllerClientSizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69BE7750EAB869848B155AB4 /* TmuxSessionControllerClientSizeTests.swift */; }; 6BE7A55C31DA48C075ED4886 /* GhosttyTmuxActionTargetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.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 */; }; 7608ABD730F2113B6100141F /* TmuxSessionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6FCBEFA092CA08F879265D1 /* TmuxSessionController.swift */; }; - 7847C4893AE9B6481BD63CEB /* Phase3RuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */; }; 78FE94F2F5065228E0F8B259 /* Phase2TransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.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 */; }; 83049E3D188D4C2A9784F9FB /* GhosttyTerminalDisconnectReasonClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */; }; 85ADFC9BAA17017ECA067C5B /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = F391794B759D1B5CD2C36000 /* Citadel */; }; @@ -75,15 +70,17 @@ 8A46F1EDB03E40047674D287 /* MoriRemoteTerminal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CDAD6C595773E27C2302A0E2 /* MoriRemoteTerminal.framework */; }; 914D5C34DBB457D970A90C25 /* GhosttySurfaceKeyEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */; }; 91626D0DA7669135F90E633C /* GhosttySurfaceSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618CC6C670DB54BA190E5827 /* GhosttySurfaceSelectionSheet.swift */; }; + 9213AD0FB635C3806A8C0010 /* MoriRemoteTerminalProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80AB026D4A388B7B0DCEBD4 /* MoriRemoteTerminalProbe.swift */; }; 94A3CF3A125B7DE1A0C08E11 /* PanePreviewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66685A19C08961A3417917DE /* PanePreviewLayout.swift */; }; 950C33367217BF06FA564D2B /* NIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6712048F2C2EC6961F582380 /* NIO */; }; + 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 */; }; 9A408E6D89845AF1D2306836 /* ActiveSessionSwitcherProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB11F550ECE58BA0965DFC7 /* ActiveSessionSwitcherProjectionTests.swift */; }; 9F941D728EA6D2D9DDD8E2DC /* GhosttyKeyboardVisibilityProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */; }; A17B820D2070516F1E059C96 /* GhosttyKeyboardVisibilityProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */; }; - A64AA54831409250D2D7AC3A /* GhosttyPaneSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.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 */; }; @@ -93,24 +90,23 @@ AFE0787AFAB5605F12537763 /* GhosttyRuntimeTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF39528D1FAB67FE7906605F /* GhosttyRuntimeTrace.swift */; }; B191529CCEFA8B8A6161B20E /* GhosttyViewportSizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 676FDCFD6BAB360A6FDE7151 /* GhosttyViewportSizing.swift */; }; B4BB4188870E6A3BD8A84509 /* TerminalRuntimeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */; }; - BE15F82CE37D35B536E662ED /* GhosttyTmuxRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */; }; C07777BEE0CE5B8012A02C74 /* GhosttyTerminalResponderFocusPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62BCDBA618379FE5456A1C57 /* GhosttyTerminalResponderFocusPolicyTests.swift */; }; C15730869E5A9CF770E3D2CC /* GhosttyTmuxPrefixInputBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */; }; C22FAD380B109CB1806083A6 /* TmuxPanePreviewImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D155521D2475C24D89AE1677 /* TmuxPanePreviewImageCache.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 */; }; 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 */; }; - DE5D4372E774A67A9B411667 /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */; }; E75088D081F5457454778A3D /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E9E52C78F643675B58F0BA /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */; }; E84DA64184225212B72BE0EA /* GhosttyScrollDeltaBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523D89AB51C587C880ABC74F /* GhosttyScrollDeltaBudgetTests.swift */; }; EA704DB81EBCED68696937A6 /* GhosttyIOSurfaceFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.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 */; }; @@ -129,6 +125,13 @@ 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 */; @@ -152,7 +155,6 @@ 11D7B7A9198618BF7CCA853B /* MoriRemoteTerminalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MoriRemoteTerminalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxActionTargetResolver.swift; sourceTree = ""; }; 1839CAA9C61F3104CB8231C6 /* GhosttyKeyboardCursorTrackpadHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardCursorTrackpadHUD.swift; sourceTree = ""; }; - 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalProbe.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 = ""; }; @@ -161,6 +163,7 @@ 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 = ""; }; 36EE2B381CC5804E01C1CD73 /* ActiveSessionSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherView.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 = ""; }; @@ -170,7 +173,6 @@ 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMigrationTests.swift; sourceTree = ""; }; 46F3182F8D251430A10B298E /* MoriRemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteApp.swift; sourceTree = ""; }; 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChrome.swift; sourceTree = ""; }; - 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterministicTmuxControlTransport.swift; sourceTree = ""; }; 495654252F6CE455BE0201B3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 4D20C034CF2CE559BF155AD4 /* TmuxScreenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxScreenModel.swift; sourceTree = ""; }; 4E8CA8956F700B0020F44AED /* GhosttyTerminalViewportCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewportCoordinatorTests.swift; sourceTree = ""; }; @@ -183,8 +185,7 @@ 66685A19C08961A3417917DE /* PanePreviewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanePreviewLayout.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 = ""; }; - 69BE7750EAB869848B155AB4 /* TmuxSessionControllerClientSizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionControllerClientSizeTests.swift; sourceTree = ""; }; - 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControl.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 = ""; }; 6CB11F550ECE58BA0965DFC7 /* ActiveSessionSwitcherProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherProjectionTests.swift; sourceTree = ""; }; 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurface.swift; sourceTree = ""; }; @@ -192,21 +193,19 @@ 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySingleViewportView.swift; sourceTree = ""; }; 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurfaceTests.swift; sourceTree = ""; }; - 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitRuntime.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 = ""; }; 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChromeActionsTests.swift; sourceTree = ""; }; - 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitABIProbe.swift; sourceTree = ""; }; 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControlViewport.swift; sourceTree = ""; }; 8BFD021A4570F40A100E02F4 /* TerminalSelectionSheetStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSelectionSheetStyle.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 = ""; }; - 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPaneSurface.swift; sourceTree = ""; }; 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalScreenModeling.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 = ""; }; A42139069AA3C15AAA45B5F1 /* TerminalSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettings.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 = ""; }; @@ -220,13 +219,11 @@ B6AF4F5E129749B9F473BA1D /* DeterministicTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterministicTmuxControlTransport.swift; sourceTree = ""; }; B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRuntimeTypes.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 = ""; }; BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceKeyEvent.swift; sourceTree = ""; }; C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardVisibilityProjection.swift; 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 = ""; }; 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 = ""; }; @@ -236,6 +233,7 @@ 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 = ""; }; @@ -251,6 +249,7 @@ 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -274,11 +273,11 @@ 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; }; @@ -329,14 +328,12 @@ 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 */, @@ -344,17 +341,6 @@ path = MoriRemote; sourceTree = ""; }; - 4951FA280BE48E9B2C0D8D06 /* Ghostty */ = { - isa = PBXGroup; - children = ( - 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */, - 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */, - 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.swift */, - BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */, - ); - path = Ghostty; - sourceTree = ""; - }; 5C295A7834704597EC4619C0 /* MoriRemoteTerminalTests */ = { isa = PBXGroup; children = ( @@ -375,7 +361,9 @@ 4E8CA8956F700B0020F44AED /* GhosttyTerminalViewportCoordinatorTests.swift */, 0544BADF4E27E64B50E2BBC9 /* GhosttyTmuxPrefixInputBufferTests.swift */, 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */, - 69BE7750EAB869848B155AB4 /* TmuxSessionControllerClientSizeTests.swift */, + 9DAD61A087944EDE21D81BDA /* MoriRemoteTerminalFacadeTests.swift */, + 6973C2936B36B0BFE904B022 /* MoriTmuxIsolationTests.swift */, + BDD098C91CB7C0CA340592E1 /* MoriTmuxNativeStartupIsolationTests.swift */, F848DC8CEFD90068DE779866 /* TmuxSessionLinkWriteFailureTests.swift */, F127EC360B82F2E804AF82D3 /* TmuxTerminalScreenAdapterTests.swift */, 07F95080D57FCEAFA52CA791 /* TmuxTerminalSessionShutdownDrainTests.swift */, @@ -396,10 +384,7 @@ isa = PBXGroup; children = ( 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */, - 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */, 21CE86B5FC370573653D3245 /* SSHTmuxControlTransport.swift */, - 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */, - C1C4381824F6C5E7604AAF7A /* TmuxSessionController.swift */, D631082FBB8A05D4BA1F801F /* TmuxShellCommand.swift */, ); path = Tmux; @@ -528,9 +513,9 @@ children = ( 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */, 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */, - C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */, 0B2C6DECBA8C1C75B1A429CD /* Phase4ShellTests.swift */, 0A0323091B8EC347B211B0AF /* Phase5AgentMetadataTests.swift */, + 34AD49A6DC5B4A977C92A64A /* Phase6TerminalOwnershipTests.swift */, B402FCCB191BE4C43EE332CF /* SSHTransportTests.swift */, ); path = MoriRemoteTests; @@ -541,6 +526,8 @@ children = ( 36EE2B381CC5804E01C1CD73 /* ActiveSessionSwitcherView.swift */, F7595020B1AEF0AE384FF639 /* Haptic.swift */, + FCE453EA5ED8C3C00E2EFF00 /* MoriRemoteTerminalFacade.swift */, + D80AB026D4A388B7B0DCEBD4 /* MoriRemoteTerminalProbe.swift */, B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */, ); path = App; @@ -561,6 +548,7 @@ buildRules = ( ); dependencies = ( + B1A880AA7F84442B2FBEBFCD /* PBXTargetDependency */, ); name = MoriRemote; packageProductDependencies = ( @@ -748,6 +736,8 @@ 0D9936CC7BE5A981314D56F0 /* GhosttyTopLevelSurface.swift in Sources */, B191529CCEFA8B8A6161B20E /* GhosttyViewportSizing.swift in Sources */, 31097F77B38B348F9E7E0BB3 /* Haptic.swift in Sources */, + C3F4EADB4D52F73F75D6E750 /* MoriRemoteTerminalFacade.swift in Sources */, + 9213AD0FB635C3806A8C0010 /* MoriRemoteTerminalProbe.swift in Sources */, 94A3CF3A125B7DE1A0C08E11 /* PanePreviewLayout.swift in Sources */, B4BB4188870E6A3BD8A84509 /* TerminalRuntimeTypes.swift in Sources */, 32D5675A41236D9050F9D6FC /* TerminalSelectionSheetStyle.swift in Sources */, @@ -771,9 +761,9 @@ files = ( 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; @@ -784,12 +774,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 */, @@ -802,8 +786,6 @@ 64772A470A3EB7EE2CD92BA8 /* SSHTmuxControlTransport.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; @@ -829,7 +811,9 @@ DACDAB6BF863B2DE4F81F8A9 /* GhosttyTerminalViewportCoordinatorTests.swift in Sources */, 29F224E2CCC19FD837908D80 /* GhosttyTmuxPrefixInputBufferTests.swift in Sources */, 01D6EAEBDB1ECC8192C0CA11 /* GhosttyTopLevelSurfaceTests.swift in Sources */, - 6A48E864C5F4578CE6968AE3 /* TmuxSessionControllerClientSizeTests.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 */, @@ -844,6 +828,11 @@ target = 219FEED5B577C565EBA81436 /* MoriRemote */; targetProxy = F94EA73662EAB769336ACE5F /* PBXContainerItemProxy */; }; + B1A880AA7F84442B2FBEBFCD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9C93F42E4A058106E7F7B7D9 /* MoriRemoteTerminal */; + targetProxy = C44F5AB5EF476577CC2B93BD /* PBXContainerItemProxy */; + }; F0CE8679B7360DD5B9C75B49 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 9C93F42E4A058106E7F7B7D9 /* MoriRemoteTerminal */; @@ -917,10 +906,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; @@ -932,7 +917,6 @@ OTHER_LDFLAGS = ( "$(inherited)", "-lc++", - "-Wl,-u,_ghostty_tmux_client_config_new", ); PRODUCT_BUNDLE_IDENTIFIER = "com.vaayne.mori-remote"; PRODUCT_NAME = MoriRemote; @@ -1188,10 +1172,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; @@ -1203,7 +1183,6 @@ OTHER_LDFLAGS = ( "$(inherited)", "-lc++", - "-Wl,-u,_ghostty_tmux_client_config_new", ); PRODUCT_BUNDLE_IDENTIFIER = "com.vaayne.mori-remote"; PRODUCT_NAME = MoriRemote; diff --git a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift index bfef0879..e93f5076 100644 --- a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift +++ b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift @@ -8,7 +8,6 @@ final class MoriRemoteDependencies { let library: RemoteLibrary let trustedHosts: TrustedHostStore let roots = SSHRootPool() - private var ghosttyRuntime: GhosttyKitRuntime? init(storage: MoriRemoteStorage, legacyServersURL: URL) { trustedHosts = storage.trustedHosts @@ -26,12 +25,6 @@ final class MoriRemoteDependencies { } } - func terminalRuntime() throws -> GhosttyKitRuntime { - if let ghosttyRuntime { return ghosttyRuntime } - let runtime = try GhosttyKitRuntime() - ghosttyRuntime = runtime - return runtime - } } struct RemoteLibrarySnapshot: Sendable { diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index 26444d78..b0b0cd21 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -1,4 +1,5 @@ import Foundation +import MoriRemoteTerminal import Observation import SwiftUI @@ -106,6 +107,12 @@ 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. +enum WorkspaceTerminalPresentation: Sendable { + static func identity(for sessionInstanceID: UUID) -> UUID { sessionInstanceID } +} + /// 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 { @@ -139,84 +146,82 @@ struct WorkspaceConnectionAttemptLedger: Sendable { 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(set) var topology: MoriRemoteTerminalTopology? + var agentMetadata: [UInt64: AgentMetadata] { 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 + 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.metadataProjector.topologyDidChange(paneIDs: topology.panes.map(\.id)) self.onChange?() } - 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.")) + onChange?() + 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); onChange?() } + func performSharedMutation(_ mutation: MoriRemoteTerminalSharedMutation) { session.performSharedMutation(mutation) } + + private func receive(_ state: MoriRemoteTerminalConnectionState) { + switch state { + case .connecting: + 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) } + } + onChange?() + } } @MainActor @Observable @@ -251,7 +256,7 @@ final class RemoteRootModel { var activeRuntime: ActiveWorkspaceRuntime? { activeWorkspaceID.flatMap { runtimes[$0] } } var activeWorkspaces: [SavedWorkspace] { workspaces.filter { runtimes[$0.id] != nil } } 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 } @@ -356,7 +361,12 @@ final class RemoteRootModel { sourceSession: material.0.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: material.0, + settings: material.3, + 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) } @@ -424,22 +434,22 @@ 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) + } /// 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 +482,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 +499,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..88f1ec5d 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -185,7 +185,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 +200,7 @@ "Waiting for the active tmux pane." = "Waiting for the active tmux pane."; "Windows" = "Windows"; "Panes" = "Panes"; +"Active workspaces" = "Active Workspaces"; "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..8028a1d8 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -185,7 +185,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 +200,7 @@ "Waiting for the active tmux pane." = "正在等待活动的 tmux 面板。"; "Windows" = "窗口"; "Panes" = "面板"; +"Active workspaces" = "活动工作区"; "Workspace controls" = "工作区控制"; "Server" = "服务器"; "Name" = "名称"; diff --git a/MoriRemote/MoriRemote/SSH/SSHRootPool.swift b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift index 1560967d..2c1b0757 100644 --- a/MoriRemote/MoriRemote/SSH/SSHRootPool.swift +++ b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift @@ -1,4 +1,5 @@ import Foundation +import MoriRemoteTerminal protocol SSHChildChannel: AnyObject, Sendable { var receivedBytes: AsyncThrowingStream { get } @@ -91,7 +92,7 @@ actor SSHRootPool { } } - fileprivate func release(_ lease: SSHRootLease, disposition: TmuxControlTransportCloseDisposition) async { + fileprivate func release(_ lease: SSHRootLease, disposition: MoriRemoteTerminalCloseDisposition) async { guard let key = lease.key, let token = lease.token else { await lease.root.close() return @@ -196,7 +197,7 @@ struct SSHRootLease: Sendable { self.token = token } - func release(_ disposition: TmuxControlTransportCloseDisposition) async { + func release(_ disposition: MoriRemoteTerminalCloseDisposition) 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..889d21bf 100644 --- a/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift +++ b/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift @@ -1,9 +1,10 @@ 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 } @@ -44,6 +45,18 @@ 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 } @@ -143,7 +156,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 +199,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 +219,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 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..fc9b211b 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,110 @@ 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 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 } + } +} diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 0376864f..f68437ad 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 { @@ -97,7 +98,7 @@ 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, compact: sizeClass == .compact, showLibrary: { sheet = .library }) } else if sizeClass == .compact { NavigationStack { library } } else { @@ -308,64 +309,64 @@ private struct AgentMetadataBadge: View { } @MainActor -private struct RemoteTerminalView: View { +private struct RemoteTerminalDetailView: View { let root: RemoteRootModel let runtime: ActiveWorkspaceRuntime let compact: Bool let showLibrary: () -> Void + @State private var showsSessions = false @State private var showsPanes = false - @State private var confirmsClosePane = 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, onShowSessions: { showsSessions = true }) + .id(WorkspaceTerminalPresentation.identity(for: runtime.session.instanceID)) + .background(Color.black) } .background(Color.black.ignoresSafeArea()) + .sheet(isPresented: $showsSessions) { sessionSwitcher } .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() } + .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 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")) } Button { showsPanes = true } label: { VStack(alignment: .leading, spacing: 1) { - Text(verbatim: runtime.topology?.sessionName ?? runtime.workspace.name) - .lineLimit(1) + Text(verbatim: 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))) + AgentMetadataBadge(metadata: runtime.metadata(for: runtime.focusedPaneID ?? 0)) } } } 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 } + Button(String(localized: "Split right (shared)")) { pendingSharedMutation = .splitHorizontal } + Button(String(localized: "Split down (shared)")) { pendingSharedMutation = .splitVertical } + Button(String(localized: "New window (shared)")) { pendingSharedMutation = .newWindow } + Button(String(localized: "Close pane (shared)"), role: .destructive) { pendingSharedMutation = .closePane } + Button(String(localized: "Close window (shared)"), role: .destructive) { pendingSharedMutation = .closeWindow } } 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")) } @@ -375,15 +376,46 @@ private struct RemoteTerminalView: View { .background(Color(white: 0.12)) } + private var sharedMutationConfirmationBinding: Binding { + .init(get: { pendingSharedMutation != nil }, set: { if !$0 { pendingSharedMutation = nil } }) + } + + private var sessionSwitcher: some View { + NavigationStack { + ActiveSessionSwitcherView( + sessions: root.activeWorkspaces.map { workspace in + let activeRuntime = root.runtimes[workspace.id] + return ActiveSessionSwitcherItem( + id: workspace.id, + sessionName: workspace.name, + subtitle: activeRuntime?.status.title ?? String(localized: "Disconnected"), + isSelected: workspace.id == root.activeWorkspaceID, + lastOpenedAt: workspace.lastConnectedAt ?? .distantPast + ) + }, + onSelectSession: { root.connect(workspaceID: $0) }, + onDisconnectSession: { workspaceID in + Task { await root.disconnect(workspaceID: workspaceID) } + } + ) + .navigationTitle(String(localized: "Active workspaces")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "Done")) { showsSessions = false } + } + } + } + } + private var panePicker: some View { NavigationStack { List { Section(String(localized: "Windows")) { - ForEach(runtime.topology?.windows ?? [], id: \.id) { window in + ForEach(runtime.topology?.windows ?? []) { window in Button { root.selectWindow(window.id) } label: { HStack { Label { - Text(verbatim: window.name) + Text(verbatim: window.title) } icon: { Image(systemName: window.active ? "rectangle.inset.filled" : "rectangle") } @@ -394,17 +426,17 @@ private struct RemoteTerminalView: View { } } Section(String(localized: "Panes")) { - ForEach(runtime.topology?.panes ?? [], id: \.id) { pane in + ForEach(runtime.topology?.panes ?? []) { pane in Button { root.selectPane(pane.id) showsPanes = false } label: { HStack { - Text(verbatim: "%\(pane.id.rawValue)") + Text(verbatim: "%\(pane.id)") .font(.body.monospaced()) AgentMetadataBadge(metadata: runtime.metadata(for: pane.id)) Spacer() - Text(verbatim: "\(pane.width)×\(pane.height)") + Text(verbatim: "\(pane.columns)×\(pane.rows)") .font(.caption.monospaced()) .foregroundStyle(.secondary) } @@ -417,14 +449,37 @@ private struct RemoteTerminalView: View { } } - private func windowMetadata(_ window: TmuxSessionController.Window) -> AgentMetadata { + 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 + + 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 { diff --git a/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift b/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift index ddbe1073..cab2d11f 100644 --- a/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift +++ b/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift @@ -2,13 +2,20 @@ import SwiftUI /// Account-free projection used by the terminal shell. Mori's profile model is /// intentionally adapted at the Phase-2 composition boundary, not imported. -struct ActiveSessionSwitcherItem: Identifiable, Equatable { - let id: UUID - let sessionName: String - let subtitle: String - let runtimeState: TerminalRuntimeState - let isSelected: Bool - let lastOpenedAt: Date +public struct ActiveSessionSwitcherItem: Identifiable, Equatable { + public let id: UUID + public let sessionName: String + public let subtitle: String + public let isSelected: Bool + public let lastOpenedAt: Date + + public init(id: UUID, sessionName: String, subtitle: String, isSelected: Bool, lastOpenedAt: Date) { + self.id = id + self.sessionName = sessionName + self.subtitle = subtitle + self.isSelected = isSelected + self.lastOpenedAt = lastOpenedAt + } } enum ActiveSessionSwitcherProjection { @@ -20,13 +27,23 @@ enum ActiveSessionSwitcherProjection { } } -struct ActiveSessionSwitcherView: View { +public struct ActiveSessionSwitcherView: View { @Environment(\.dismiss) private var dismiss let sessions: [ActiveSessionSwitcherItem] let onSelectSession: (UUID) -> Void let onDisconnectSession: (UUID) -> Void - var body: some View { + public init( + sessions: [ActiveSessionSwitcherItem], + onSelectSession: @escaping (UUID) -> Void, + onDisconnectSession: @escaping (UUID) -> Void + ) { + self.sessions = sessions + self.onSelectSession = onSelectSession + self.onDisconnectSession = onDisconnectSession + } + + public var body: some View { List(ActiveSessionSwitcherProjection.items(sessions)) { session in Button { onSelectSession(session.id) @@ -39,7 +56,7 @@ struct ActiveSessionSwitcherView: View { } .swipeActions { Button(role: .destructive) { onDisconnectSession(session.id) } label: { - Label("Disconnect", systemImage: "bolt.slash") + Label(String(localized: "Disconnect"), systemImage: "bolt.slash") } } } diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift new file mode 100644 index 00000000..b1f60f3f --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -0,0 +1,195 @@ +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 +} + +public struct MoriRemoteTerminalPane: Identifiable, Equatable, Sendable { + public let id: UInt64 + public let windowID: UInt64 + public let columns: UInt32 + public let rows: UInt32 +} + +public struct MoriRemoteTerminalWindow: Identifiable, Equatable, Sendable { + public let id: UInt64 + public let title: String + public let active: Bool + public let activePaneID: UInt64? +} + +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 +} + +/// 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: ObservableObject { + public let instanceID: UUID + @Published public private(set) var connectionState: MoriRemoteTerminalConnectionState = .connecting + @Published public private(set) var topology: MoriRemoteTerminalTopology? + @Published public private(set) var isPresentationReady = false + @Published public private(set) var lastError: String? + + public var onConnectionStateChange: (@MainActor (MoriRemoteTerminalConnectionState) -> Void)? + public var onTopologyChange: (@MainActor (MoriRemoteTerminalTopology) -> Void)? + + private let runtime: GhosttyKitRuntime + fileprivate let screen: TmuxScreenModel + + public init( + transport: MoriRemoteTerminalTransport, + initialScrollbackLines: Int = 2_000, + instanceID: UUID = UUID() + ) throws { + self.instanceID = instanceID + let runtime = try GhosttyKitRuntime() + self.runtime = runtime + screen = TmuxScreenModel( + app: runtime.appHandle, + transport: ClosureTmuxControlTransport(base: transport), + historyLineLimit: initialScrollbackLines, + baseSurfaceConfig: { runtime.makeTmuxBaseSurfaceConfig() }, + paneViewTheme: { .ghosttyDefault } + ) + screen.session?.onStateChange = { [weak self] state in self?.receive(state) } + screen.session?.onTopologyChange = { [weak self] snapshot in self?.receive(snapshot) } + screen.session?.onPresentationChange = { [weak self] ready in self?.isPresentationReady = ready } + } + + public func start() async throws { + do { + try await screen.connect() + } catch { + lastError = error.localizedDescription + publishConnectionState(.disconnected) + throw error + } + } + + public func stop() async { + await screen.stop() + publishConnectionState(.disconnected) + } + + public func setPresentationActive(_ active: Bool) { + screen.session?.setAppActive(active) + } + + public func isControlChannelActive() async -> Bool { await screen.session?.controlChannelIsActive() ?? false } + + public func selectWindow(_ id: UInt64) { screen.session?.controller.requestSelectWindow(windowID: .init(id)) } + public func selectPane(_ id: UInt64) { screen.session?.controller.requestSelectPane(paneID: .init(id)) } + public func queryAgentMetadata() async -> MoriRemoteTerminalAgentMetadataResult { + await withCheckedContinuation { continuation in + screen.session?.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)) + } ?? continuation.resume(returning: .init(status: .failed, 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 + } + screen.session?.controller.requestSharedMutation(value) + } + + private func receive(_ state: TmuxSessionController.SessionState) { + switch state { + case .ready: + lastError = nil + publishConnectionState(.ready) + case .attaching, .syncing: + publishConnectionState(.connecting) + 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 { + @ObservedObject private var session: MoriRemoteTerminalSession + private let onShowSessions: () -> Void + public init(session: MoriRemoteTerminalSession, onShowSessions: @escaping () -> Void = {}) { + self.session = session; self.onShowSessions = onShowSessions + } + public var body: some View { + GhosttyTerminalCoreView(screen: session.screen.screenAdapter, onShowSessions: onShowSessions) + } +} diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift new file mode 100644 index 00000000..5a44a997 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift @@ -0,0 +1,129 @@ +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() } + .onChange(of: model.session?.isPresentationReady) { _, ready in + if ready == true { model.recordSuccess() } + } + .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?.isPresentationReady != true 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/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index 3bcecb22..394cf60d 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -120,18 +120,18 @@ struct GhosttyTerminalCoreView: View { session: previews, projection: screen.windowSelectionSheetRenderProjection(), sessionName: "tmux", - onCreateWindow: { _ = screen.createTmuxWindow() }, + onCreateWindow: nil, onSelect: { _ = screen.focusTmuxTopLevel($0) }, - onRemoveWindow: { _ = screen.closeTmuxWindow($0) } + onRemoveWindow: { _ in } ) case .panes(let topLevelID, let previews): GhosttyPaneSelectionSheet( session: previews, projection: screen.paneSelectionSheetRenderProjection(topLevelID: topLevelID), - onSplitPane: { _ = screen.splitFocusedTmuxPane(ghostty_action_split_direction_e(rawValue: 0)) }, + onSplitPane: nil, onStackPane: nil, onSelect: { _ = screen.focusTmuxPane($0) }, - onRemovePane: { _ = screen.closeTmuxPane($0) } + onRemovePane: { _ in } ) } } @@ -198,7 +198,8 @@ struct GhosttyTerminalCoreView: View { text, submit: { screen.sendInputToFocusedSurface($0).isAccepted }, schedulePrefixFlush: schedulePrefixFlush(token:), - enterCopyMode: { screen.enterFocusedTmuxCopyMode().isHandled } + // Mori selection is renderer-local; never enter server copy mode. + enterCopyMode: { false } ) } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift index 0e8c866c..a431ee2f 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift @@ -122,33 +122,6 @@ protocol GhosttyTmuxActionModeling: ObservableObject { func focusAdjacentTmuxTopLevel( _ direction: GhosttyRuntimeSelectionDirection ) -> GhosttyTmuxModelActionOutcome - - @discardableResult - func createTmuxWindow() -> GhosttyTmuxModelActionOutcome - - @discardableResult - func splitFocusedTmuxPane( - _ direction: ghostty_action_split_direction_e - ) -> GhosttyTmuxModelActionOutcome - - @discardableResult - func closeTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome - - @discardableResult - func closeTmuxWindow(_ id: UUID) -> GhosttyTmuxModelActionOutcome - - @discardableResult - func enterFocusedTmuxCopyMode() -> GhosttyTmuxModelActionOutcome - - // MARK: Topology action interaction effects - - func createTmuxWindowInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect - func splitFocusedTmuxPaneInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect - func closeTmuxWindowInteractionEffect(_ id: UUID) -> GhosttyTmuxTopologyActionInteractionEffect - func closeTmuxPaneInteractionEffect( - _ id: UUID, - inTopLevel topLevelID: UUID - ) -> GhosttyTmuxTopologyActionInteractionEffect } @MainActor diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift index 5a485681..698c15ce 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift @@ -12,12 +12,14 @@ final class TmuxScreenModel: ObservableObject { init( app: ghostty_app_t, transport: any TmuxControlTransport, + historyLineLimit: Int, baseSurfaceConfig: @escaping () -> ghostty_terminal_surface_config_s, paneViewTheme: @escaping () -> TerminalTheme ) { let session = TmuxTerminalSession( app: app, transport: transport, + historyLineLimit: historyLineLimit, baseSurfaceConfig: baseSurfaceConfig, paneViewTheme: paneViewTheme ) @@ -27,11 +29,16 @@ final class TmuxScreenModel: ObservableObject { initialViewportHandler: { [weak session] size, scale in session?.updateViewportMetrics(size: size, scale: scale) }, - clientSizeHandler: { _ in }, viewportStabilityHandler: { _ in } ) } + func connect() async throws { + try await session?.connect() + } + + var screenAdapter: TmuxTerminalScreenAdapter { terminalScreenAdapter } + func stop() async { terminalScreenAdapter.invalidate() guard let session else { return } diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift index c6add0fc..38c1a03e 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift @@ -68,23 +68,18 @@ final class TmuxSessionController: @unchecked Sendable { } enum Request: Equatable, Sendable { - case newWindow - case splitPane - case closePane - case closeWindow case selectWindow case selectPane - case zoomPane - case copyMode - case setClientSize + case sharedMutation case sendInput } - enum SplitDirection: Sendable { - case left - case right - case up - case down + enum SharedMutation: Sendable { + case newWindow + case splitHorizontal + case splitVertical + case closePane + case closeWindow } struct ClientSize: Sendable, Equatable { @@ -118,6 +113,12 @@ final class TmuxSessionController: @unchecked Sendable { case alreadyRegistered } + struct AgentMetadataQueryResult: Sendable { + enum Status: Sendable { case success, skipped, failed } + let status: Status + let body: String + } + enum PaneCurrentDirectoryError: LocalizedError, Equatable, Sendable { case sessionUnavailable case paneUnavailable @@ -182,7 +183,6 @@ final class TmuxSessionController: @unchecked Sendable { private enum NavigationIntent: Equatable { case pane(TmuxPaneID) case window(TmuxWindowID, preferredPaneID: TmuxPaneID?) - case zoom(TmuxPaneID) } private enum OutstandingRequest { @@ -190,6 +190,7 @@ final class TmuxSessionController: @unchecked Sendable { case paneCurrentDirectory( @Sendable (Result) -> Void ) + case agentMetadata(@Sendable (AgentMetadataQueryResult) -> Void) case trackedInput(@Sendable (Bool) -> Void) } @@ -210,7 +211,6 @@ final class TmuxSessionController: @unchecked Sendable { private var client: ghostty_tmux_client_t? private var state: SessionState = .detached(nil) private var topology: TopologySnapshot? - private var clientSize: ClientSize? private var retainedPaneIDs: Set = [] private var engineSizeByPaneID: [TmuxPaneID: ClientSize] = [:] private var refreshStateByPaneID: [TmuxPaneID: PaneRefreshState] = [:] @@ -221,11 +221,14 @@ final class TmuxSessionController: @unchecked Sendable { 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 } @@ -241,26 +244,15 @@ final class TmuxSessionController: @unchecked Sendable { } /// Construct the native client only after transport.start has opened the - /// control channel with the same real viewport. The native initial grid is - /// immutable and emits the sole startup refresh-client command. - func start( - initialSize: ClientSize, - completion: @escaping @Sendable (Result) -> Void - ) { + /// 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 } - guard let columns = UInt16(exactly: initialSize.cols), - let rows = UInt16(exactly: initialSize.rows), - columns > 0, - rows > 0 - else { - completion(.failure(.invalidInitialGrid)) - return - } - clientSize = initialSize var config = ghostty_tmux_client_config_new() config.userdata = Unmanaged.passUnretained(self).toOpaque() @@ -271,10 +263,10 @@ final class TmuxSessionController: @unchecked Sendable { controller.handleAction(action.pointee) } config.history_line_limit_is_set = true - config.history_line_limit = 2_000 + config.history_line_limit = self.historyLineLimit config.max_scrollback = 10_000 - config.initial_columns = columns - config.initial_rows = rows + config.initial_columns = 0 + config.initial_rows = 0 var created: ghostty_tmux_client_t? let result = ghostty_tmux_client_new(&config, &created) @@ -292,6 +284,7 @@ final class TmuxSessionController: @unchecked Sendable { queue.async { [self] in guard !shuttingDown else { return } failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) + failOutstandingAgentMetadataQueries() failOutstandingTrackedInput() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -309,6 +302,7 @@ final class TmuxSessionController: @unchecked Sendable { queue.async { [self] in guard !shuttingDown else { return } failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) + failOutstandingAgentMetadataQueries() failOutstandingTrackedInput() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -324,14 +318,15 @@ final class TmuxSessionController: @unchecked Sendable { shuttingDown = true outboundSink = nil let directoryQueries = outstandingPaneDirectoryQueries() + let agentMetadataQueries = outstandingAgentMetadataQueries() let trackedInputCompletions = outstandingTrackedInputCompletions() requestsByToken.removeAll() directoryQueries.forEach { $0(.failure(.sessionUnavailable)) } + agentMetadataQueries.forEach { $0(.init(status: .failed, body: "")) } trackedInputCompletions.forEach { $0(false) } deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil topology = nil - clientSize = nil retainedPaneIDs.removeAll() engineSizeByPaneID.removeAll() refreshStateByPaneID.removeAll() @@ -405,6 +400,7 @@ final class TmuxSessionController: @unchecked Sendable { switch action.tag { case GHOSTTY_TMUX_ACTION_EXIT: failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) + failOutstandingAgentMetadataQueries() failOutstandingTrackedInput() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -469,7 +465,7 @@ final class TmuxSessionController: @unchecked Sendable { // 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 = effectiveEngineSize(for: pane, in: snapshot) { + if let size = Self.effectiveEngineSize(for: pane, in: snapshot) { engineSizeByPaneID[pane.id] = size } else { engineSizeByPaneID.removeValue(forKey: pane.id) @@ -504,7 +500,7 @@ final class TmuxSessionController: @unchecked Sendable { completedRefresh = (size, followUp) if let topology, let pane = topology.panes.first(where: { $0.id == paneID }), - let actualSize = effectiveEngineSize(for: pane, in: topology) { + let actualSize = Self.effectiveEngineSize(for: pane, in: topology) { engineSizeByPaneID[paneID] = actualSize } else { engineSizeByPaneID.removeValue(forKey: paneID) @@ -577,6 +573,14 @@ final class TmuxSessionController: @unchecked Sendable { case .paneCurrentDirectory(let completionHandler): completionHandler(paneCurrentDirectoryResult(for: completion)) return + 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, @@ -658,6 +662,12 @@ final class TmuxSessionController: @unchecked Sendable { // 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 @@ -665,6 +675,10 @@ final class TmuxSessionController: @unchecked Sendable { 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, @@ -728,6 +742,10 @@ final class TmuxSessionController: @unchecked Sendable { completion(false) return } + guard admitCommandOnWriter( + command: Self.cancelStaleSharedInputMode, + request: .sendInput + ) else { completion(false); return } var token: UInt64 = 0 let result = bytes.withUnsafeBytes { buffer in let pointer = buffer.bindMemory(to: UInt8.self).baseAddress @@ -761,55 +779,8 @@ final class TmuxSessionController: @unchecked Sendable { return true } - func setClientSize(cols: UInt32, rows: UInt32) { - guard cols > 0, rows > 0, cols <= UInt16.max, rows <= UInt16.max else { - DispatchQueue.main.async { self.callbacks.onRequestFailed(.setClientSize) } - return - } - let nextSize = ClientSize(cols: cols, rows: rows) - queue.async { [self] in - guard clientSize != nextSize else { return } - guard admitCommandOnWriter( - command: "refresh-client -C \(cols)x\(rows)", - request: .setClientSize - ) else { return } - clientSize = nextSize - if let paneID = activePaneID(in: topology) { - _ = admitPaneRefreshIfNeeded( - paneID, - failureRequest: .setClientSize - ) - } - _ = drainOutbound() - } - } - - func requestNewWindow() { - enqueue(command: "new-window", request: .newWindow) - } - - func requestSplit(paneID: TmuxPaneID, direction: SplitDirection, zoom: Bool) { - let flags = switch direction { - case .left: "-h -b" - case .right: "-h" - case .up: "-v -b" - case .down: "-v" - } - let zoomFlag = zoom ? " -Z" : "" - enqueue( - command: "split-window \(flags)\(zoomFlag) -t %\(paneID.rawValue)", - request: .splitPane - ) - } - - func requestClosePane(paneID: TmuxPaneID) { - enqueue(command: "kill-pane -t %\(paneID.rawValue)", request: .closePane) - } - - func requestCloseWindow(windowID: TmuxWindowID) { - enqueue(command: "kill-window -t @\(windowID.rawValue)", request: .closeWindow) - } - + /// 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 @@ -820,19 +791,40 @@ final class TmuxSessionController: @unchecked Sendable { } func requestSelectPane(paneID: TmuxPaneID) { - queue.async { [self] in - submitNavigation(.pane(paneID)) - } + queue.async { [self] in submitNavigation(.pane(paneID)) } } - func requestZoomPane(paneID: TmuxPaneID) { + /// Explicit, user-labelled shared mutations only. Navigation and renderer + /// gestures do not reach this API. + func requestSharedMutation(_ mutation: SharedMutation) { queue.async { [self] in - submitNavigation(.zoom(paneID)) + 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) } } - func requestCopyMode(paneID: TmuxPaneID) { - enqueue(command: "copy-mode -t %\(paneID.rawValue)", request: .copyMode) + /// 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() + } } func paneCurrentDirectory(for paneID: TmuxPaneID) async throws -> String { @@ -881,88 +873,28 @@ final class TmuxSessionController: @unchecked Sendable { ) { switch intent { case .pane(let paneID): - enqueuePaneSelection( - paneID, - drainOutbound: drainOutbound - ) + enqueuePaneSelection(paneID, drainOutbound: drainOutbound) case .window(let windowID, let preferredPaneID): enqueueWindowSelection( windowID: windowID, preferredPaneID: preferredPaneID, drainOutbound: drainOutbound ) - case .zoom(let paneID): - enqueueZoomPane(paneID, drainOutbound: drainOutbound) } } - private func enqueueZoomPane( - _ paneID: TmuxPaneID, - drainOutbound: Bool - ) { + /// 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 }), - let window = topology.windows.first(where: { $0.id == pane.windowID }) - else { - reportRequestFailure(.zoomPane) - return - } - let hasSibling = topology.panes.contains { - $0.windowID == window.id && $0.id != paneID - } - guard hasSibling, !window.zoomed else { - let admittedRefresh = admitPaneRefreshIfNeeded( - paneID, - failureRequest: .zoomPane - ) - if admittedRefresh, drainOutbound { _ = self.drainOutbound() } + 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: "resize-pane -Z -t %\(paneID.rawValue)", - request: .zoomPane, - paneID: paneID, - drainOutbound: drainOutbound - ) - } - - private func enqueuePaneSelection( - _ paneID: TmuxPaneID, - drainOutbound: Bool - ) { - preconditionOnWriterQueue() - guard let topology, - let pane = topology.panes.first(where: { $0.id == paneID }), - let window = topology.windows.first(where: { $0.id == pane.windowID }) - else { - reportRequestFailure(.selectPane) - return - } - if topology.activeWindowID != window.id { - enqueueWindowSelection( - windowID: window.id, - preferredPaneID: paneID, - drainOutbound: drainOutbound - ) - return - } - - let hasSibling = topology.panes.contains { - $0.windowID == window.id && $0.id != paneID - } - if window.activePaneID == paneID, window.zoomed || !hasSibling { - let admittedRefresh = admitPaneRefreshIfNeeded( - paneID, - failureRequest: .selectPane - ) - if admittedRefresh, drainOutbound { _ = self.drainOutbound() } - return - } - let command = window.zoomed - ? "select-pane -Z -t %\(paneID.rawValue)" - : "resize-pane -Z -t %\(paneID.rawValue)" - submitPanePresentationCommandOnWriter( - command: command, + command: "select-pane -t %\(paneID.rawValue)", request: .selectPane, paneID: paneID, drainOutbound: drainOutbound @@ -974,78 +906,26 @@ final class TmuxSessionController: @unchecked Sendable { preferredPaneID: TmuxPaneID?, drainOutbound: Bool ) { - preconditionOnWriterQueue() guard let topology, let window = topology.windows.first(where: { $0.id == windowID }) - else { - reportRequestFailure(.selectWindow) - return + 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 hasSibling = topology.panes.contains { pane in - pane.windowID == windowID && pane.id != paneID - } - - if topology.activeWindowID == windowID { - guard let paneID else { return } - if window.zoomed || !hasSibling { - let admittedRefresh = admitPaneRefreshIfNeeded( - paneID, - failureRequest: .selectWindow - ) - if admittedRefresh, drainOutbound { _ = self.drainOutbound() } - return - } - submitPanePresentationCommandOnWriter( - command: "resize-pane -Z -t %\(paneID.rawValue)", - request: .selectWindow, - paneID: paneID, - drainOutbound: drainOutbound - ) - return - } - - if let preferredPaneID, - !topology.panes.contains(where: { - $0.id == preferredPaneID && $0.windowID == windowID - }) { - reportRequestFailure(.selectWindow) + 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 } - let commands = Self.crossWindowSelectionCommands( - windowID: windowID, - activePaneID: window.activePaneID, - preferredPaneID: preferredPaneID, - zoomed: window.zoomed, - hasSibling: hasSibling + submitPanePresentationCommandGroupOnWriter( + commands: commands, + request: .selectWindow, + paneID: paneID, + drainOutbound: drainOutbound ) - if commands.count == 1 { - guard let paneID else { - submitCommandOnWriter( - command: commands[0], - request: .selectWindow, - drainOutbound: drainOutbound - ) - return - } - submitPanePresentationCommandOnWriter( - command: commands[0], - request: .selectWindow, - paneID: paneID, - drainOutbound: drainOutbound - ) - } else { - guard let paneID else { - reportRequestFailure(.selectWindow) - return - } - submitPanePresentationCommandGroupOnWriter( - commands: commands, - request: .selectWindow, - paneID: paneID, - drainOutbound: drainOutbound - ) - } } private var hasOutstandingTopologyMutation: Bool { @@ -1062,35 +942,13 @@ final class TmuxSessionController: @unchecked Sendable { private func requestMutatesTopology(_ request: Request) -> Bool { switch request { - case .newWindow, .splitPane, .closePane, .closeWindow, - .selectWindow, .selectPane, .zoomPane: + case .selectWindow, .selectPane, .sharedMutation: true - case .copyMode, .setClientSize, .sendInput: + case .sendInput: false } } - static func crossWindowSelectionCommands( - windowID: TmuxWindowID, - activePaneID: TmuxPaneID?, - preferredPaneID: TmuxPaneID?, - zoomed: Bool, - hasSibling: Bool - ) -> [String] { - let selectWindow = "select-window -t @\(windowID.rawValue)" - guard hasSibling, - let preferredPaneID - else { return [selectWindow] } - if zoomed, preferredPaneID == activePaneID { - return [selectWindow] - } - let selectPane = zoomed ? "select-pane" : "resize-pane" - return [ - selectWindow, - "\(selectPane) -Z -t %\(preferredPaneID.rawValue)", - ] - } - private func enqueue(command: String, request: Request) { queue.async { [self] in enqueueOnWriter(command: command, request: request) @@ -1289,7 +1147,10 @@ final class TmuxSessionController: @unchecked Sendable { followsPresentation: Bool = false ) -> Bool { preconditionOnWriterQueue() - guard let size = clientSize else { return false } + 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, @@ -1363,7 +1224,7 @@ final class TmuxSessionController: @unchecked Sendable { // MARK: Helpers - private func effectiveEngineSize( + static func effectiveEngineSize( for pane: PaneInfo, in topology: TopologySnapshot ) -> ClientSize? { @@ -1386,6 +1247,7 @@ final class TmuxSessionController: @unchecked Sendable { preconditionOnWriterQueue() guard !shuttingDown else { return } failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) + failOutstandingAgentMetadataQueries() failOutstandingTrackedInput() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -1459,6 +1321,23 @@ final class TmuxSessionController: @unchecked Sendable { completions.forEach { $0(.failure(error)) } } + 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 outstandingTrackedInputCompletions() -> [@Sendable (Bool) -> Void] { requestsByToken.values.compactMap { guard case .trackedInput(let completion) = $0 else { return nil } diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift index 9b1a25f5..0c5d0ba6 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift @@ -7,8 +7,8 @@ import GhosttyKit /// single consumer task drains an ordered stream fed from the writer /// queue), and transport loss closes this attachment promptly. /// -/// Viewport ownership stays in the screen model. The link only passes the -/// already-known viewport to the SSH attach command's initial `-x -y`. +/// Viewport ownership stays in the screen model. The control client is +/// deliberately unsized; all local viewport metrics stay renderer-only. actor TmuxSessionLink { let controller: TmuxSessionController @@ -33,12 +33,9 @@ actor TmuxSessionLink { self.outboundContinuation = continuation } - /// Establish the control channel with the real grid, then create the - /// native client with that same grid. This order prevents its initial - /// refresh/list batch from racing transport opening. - func start(viewport: TmuxControlViewport?) async throws { + /// Establish the control channel, then create an unsized native client. + func start() async throws { guard !stopped else { throw LinkError.stopped } - guard let viewport else { throw LinkError.missingInitialViewport } // Idempotent transport prewarm (auth/root channel) before the // session channel opens. @@ -64,15 +61,10 @@ actor TmuxSessionLink { } } - try await transport.start(initialViewport: viewport) + try await transport.start(initialViewport: nil) guard !stopped else { throw LinkError.stopped } try await withCheckedThrowingContinuation { continuation in - controller.start( - initialSize: TmuxSessionController.ClientSize( - cols: UInt32(viewport.columns), - rows: UInt32(viewport.rows) - ) - ) { result in + controller.start { result in continuation.resume(with: result) } } diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift index 31b7c540..a74cd794 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift @@ -29,7 +29,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { private var activeManagedSurface: GhosttyManagedSurface? private var activeManagedPaneID: TmuxPaneID? private var initialViewportHandler: ((CGSize, CGFloat) -> Void)? - private var clientSizeHandler: ((TmuxSessionController.ClientSize) -> Void)? private var viewportStabilityHandler: ((Bool) -> Void)? private var cachedTopologySnapshot = GhosttyRuntimeSurfaceTopologySnapshot.empty private var panePreviewCache = TmuxPanePreviewImageCache( @@ -47,13 +46,11 @@ final class TmuxTerminalScreenAdapter: ObservableObject { func activate( session: TmuxTerminalSession, initialViewportHandler: @escaping (CGSize, CGFloat) -> Void, - clientSizeHandler: @escaping (TmuxSessionController.ClientSize) -> Void, viewportStabilityHandler: @escaping (Bool) -> Void ) { self.session = session self.controller = session.controller self.initialViewportHandler = initialViewportHandler - self.clientSizeHandler = clientSizeHandler self.viewportStabilityHandler = viewportStabilityHandler session.$state @@ -107,7 +104,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { session = nil controller = nil initialViewportHandler = nil - clientSizeHandler = nil viewportStabilityHandler = nil latestTopology = nil cachedTopologySnapshot = Self.emptyTopologySnapshot @@ -115,9 +111,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { func terminalConfigurationDidChange() { clearPanePreviewCache(reason: "appearance-change") - if let activeManagedSurface { - reportClientSizeIfActive(activeManagedSurface) - } } func tmuxPaneID(for surfaceID: UUID) -> TmuxPaneID? { @@ -210,7 +203,7 @@ final class TmuxTerminalScreenAdapter: ObservableObject { panePreviewCache.remove(paneID) } let wasAlreadyWrapped = paneSurface.managedSurface != nil - let managed = paneSurface.screenSurface { [weak self, weak paneSurface] managed, size, _ in + let managed = paneSurface.screenSurface { [weak paneSurface] managed, size, _ in guard size.width > 1, size.height > 1 else { return } GhosttyRuntimeTrace.flowEventOnce( GhosttyRuntimeTrace.paneSwitchFlow, @@ -222,7 +215,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { "width": "\(size.width)", ] ) - self?.reportClientSizeIfActive(managed) } activeManagedSurface = managed activeManagedPaneID = paneID @@ -250,15 +242,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { activeManagedSurface } - private func reportClientSizeIfActive(_ managed: GhosttyManagedSurface) { - guard activeManagedSurface === managed else { return } - let size = managed.controlSurface.currentSize() - guard size.columns >= 2, size.rows >= 2 else { return } - clientSizeHandler?(TmuxSessionController.ClientSize( - cols: UInt32(size.columns), - rows: UInt32(size.rows) - )) - } // MARK: Command failures @@ -283,15 +266,9 @@ final class TmuxTerminalScreenAdapter: ObservableObject { private static func failureLabel(for request: TmuxSessionController.Request) -> String { switch request { - case .newWindow: "new window" - case .splitPane: "split pane" - case .closePane: "close pane" - case .closeWindow: "close window" case .selectWindow: "select window" case .selectPane: "select pane" - case .zoomPane: "zoom pane" - case .copyMode: "copy mode" - case .setClientSize: "resize" + case .sharedMutation: "shared workspace action" case .sendInput: "input" } } @@ -562,6 +539,12 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { // MARK: tmux topology actions + func performSharedMutation(_ mutation: TmuxSessionController.SharedMutation) -> GhosttyTmuxModelActionOutcome { + guard let controller else { return .missingTarget(.host) } + controller.requestSharedMutation(mutation) + return .queued + } + func focusTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome { guard let paneID = identities.paneID(for: id), let controller else { GhosttyRuntimeTrace.flowEventIfActive( @@ -638,77 +621,16 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { ) } - func createTmuxWindow() -> GhosttyTmuxModelActionOutcome { - guard let controller else { return .missingTarget(.host) } - controller.requestNewWindow() - return .queued - } - func splitFocusedTmuxPane( - _ direction: ghostty_action_split_direction_e - ) -> GhosttyTmuxModelActionOutcome { - guard let controller, let paneSurface = session?.paneSurface else { - return .missingTarget(.focusedPane) - } - controller.requestSplit( - paneID: paneSurface.paneID, - direction: TmuxSessionController.SplitDirection(actionDirection: direction), - zoom: true - ) - return .queued - } - func closeTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome { - guard let paneID = identities.paneID(for: id), let controller else { - return .missingTarget(.pane(id)) - } - controller.requestClosePane(paneID: paneID) - return .queued - } - func closeTmuxWindow(_ id: UUID) -> GhosttyTmuxModelActionOutcome { - guard let windowID = identities.windowID(for: id), let controller else { - return .missingTarget(.window(id)) - } - controller.requestCloseWindow(windowID: windowID) - return .queued - } - func enterFocusedTmuxCopyMode() -> GhosttyTmuxModelActionOutcome { - guard let controller, let paneSurface = session?.paneSurface else { - return .missingTarget(.focusedPane) - } - controller.requestCopyMode(paneID: paneSurface.paneID) - return .queued - } // MARK: Selection sheet projections - func createTmuxWindowInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect { - GhosttyTerminalPresentationProjector.createTmuxWindowInteractionEffect() - } - func splitFocusedTmuxPaneInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect { - GhosttyTerminalPresentationProjector.splitFocusedTmuxPaneInteractionEffect() - } - func closeTmuxWindowInteractionEffect(_ id: UUID) -> GhosttyTmuxTopologyActionInteractionEffect { - GhosttyTerminalPresentationProjector.closeTmuxWindowInteractionEffect( - id, - snapshot: topologySnapshot - ) - } - func closeTmuxPaneInteractionEffect( - _ id: UUID, - inTopLevel topLevelID: UUID - ) -> GhosttyTmuxTopologyActionInteractionEffect { - GhosttyTerminalPresentationProjector.closeTmuxPaneInteractionEffect( - id, - inTopLevel: topLevelID, - snapshot: topologySnapshot - ) - } func windowSheetPresentationProjection() -> GhosttyWindowSheetPresentationProjection? { GhosttyTerminalPresentationProjector.windowSheetPresentationProjection( @@ -789,19 +711,8 @@ extension TmuxSessionController.CloseReason { case .unsupportedVersion(let version): TerminalDisconnectReason( kind: .runtime, - message: "unsupported tmux version \(version) (requires 3.1+)" + message: "unsupported tmux version \(version) (requires 3.2+)" ) } } } - -private extension TmuxSessionController.SplitDirection { - init(actionDirection: ghostty_action_split_direction_e) { - switch actionDirection { - case GHOSTTY_SPLIT_DIRECTION_LEFT: self = .left - case GHOSTTY_SPLIT_DIRECTION_UP: self = .up - case GHOSTTY_SPLIT_DIRECTION_DOWN: self = .down - default: self = .right - } - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift index e5de2890..23a66a59 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift @@ -12,6 +12,9 @@ final class TmuxTerminalSession: ObservableObject { @Published private(set) var livePaneIDs: Set = [] @Published private(set) var lastFailedRequest: TmuxSessionController.Request? @Published private(set) var transportFailure: TerminalDisconnectReason? + var onStateChange: (@MainActor (TmuxSessionController.SessionState) -> Void)? + var onTopologyChange: (@MainActor (TmuxSessionController.TopologySnapshot) -> Void)? + var onPresentationChange: (@MainActor (Bool) -> Void)? private let app: ghostty_app_t private(set) var controller: TmuxSessionController! @@ -38,7 +41,6 @@ final class TmuxTerminalSession: ObservableObject { private var creatingPaneIDs: Set = [] private var failedCreationPaneIDs: Set = [] private var pendingPaneID: TmuxPaneID? - private var zoomRequestedPaneID: TmuxPaneID? private var preparingSurface: TmuxPaneSurface? private var viewportMetrics: GhosttySurfaceDisplayMetrics? private var isAppActive = true @@ -54,6 +56,7 @@ final class TmuxTerminalSession: ObservableObject { 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 @@ -64,7 +67,7 @@ final class TmuxTerminalSession: ObservableObject { self.createPaneSurface = createPaneSurface let relay = Relay() - let controller = TmuxSessionController(callbacks: TmuxSessionController.Callbacks( + let controller = TmuxSessionController(historyLineLimit: historyLineLimit, callbacks: TmuxSessionController.Callbacks( onState: { state in MainActor.assumeIsolated { relay.target?.handleState(state) } }, @@ -99,18 +102,17 @@ final class TmuxTerminalSession: ObservableObject { // MARK: Connection - func connect(viewport: TmuxControlViewport?) { - guard !isShutDown, !didStartLink, let viewport else { return } + func connect() async throws { + guard !isShutDown, !didStartLink else { return } didStartLink = true linkIsActive = true transportFailure = nil let link = self.link - Task.detached(priority: .userInitiated) { [weak self] in - do { - try await link.start(viewport: viewport) - } catch { - await self?.connectFailed(link: link, error: error) - } + do { + try await link.start() + } catch { + await connectFailed(link: link, error: error) + throw error } } @@ -129,6 +131,10 @@ final class TmuxTerminalSession: ObservableObject { controller.attachmentStopped() } + func controlChannelIsActive() async -> Bool { + await link.controlChannelIsActive() ?? false + } + func invalidateInactiveTransportOnForeground( willInvalidate: (TerminalDisconnectReason) -> Void ) async -> TerminalDisconnectReason? { @@ -145,7 +151,6 @@ final class TmuxTerminalSession: ObservableObject { guard !isShutDown else { return } isShutDown = true pendingPaneID = nil - zoomRequestedPaneID = nil cancelPendingPresentation() livePaneIDs.removeAll() pendingTerminalsByPaneID.removeAll() @@ -190,11 +195,11 @@ final class TmuxTerminalSession: ObservableObject { private func handleState(_ newState: TmuxSessionController.SessionState) { state = newState + onStateChange?(newState) switch newState { case .detached, .closed: pendingPaneID = nil - zoomRequestedPaneID = nil - cancelPendingPresentation() + cancelPendingPresentation() linkIsActive = false Task { await link.stop() } case .ready: @@ -206,15 +211,11 @@ final class TmuxTerminalSession: ObservableObject { 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) - if let zoomRequestedPaneID, - activePaneID(in: snapshot) != zoomRequestedPaneID - || isFullViewport(paneID: zoomRequestedPaneID, in: snapshot) { - self.zoomRequestedPaneID = nil - } presentActivePane(from: snapshot) } @@ -223,7 +224,6 @@ final class TmuxTerminalSession: ObservableObject { pendingTerminalsByPaneID.removeValue(forKey: paneID) failedCreationPaneIDs.remove(paneID) if pendingPaneID == paneID { pendingPaneID = nil } - if zoomRequestedPaneID == paneID { zoomRequestedPaneID = nil } if preparingSurface?.paneID == paneID { cancelPendingPresentation() } if paneSurface?.paneID == paneID { unpublishPane() } guard let surface = surfacesByPaneID[paneID] else { return } @@ -312,13 +312,7 @@ final class TmuxTerminalSession: ObservableObject { lastFailedRequest = request if request == .selectPane || request == .selectWindow { pendingPaneID = nil - zoomRequestedPaneID = nil - cancelPendingPresentation() - } - if request == .zoomPane { - // Keep the terminal unpresented: split geometry is not the phone's - // canonical terminal viewport. - return + cancelPendingPresentation() } if let topology { presentActivePane(from: topology) } } @@ -395,8 +389,7 @@ final class TmuxTerminalSession: ObservableObject { let topology, topology.panes.contains(where: { $0.id == paneID }) else { return } - if activePaneID(in: topology) == paneID, - isFullViewport(paneID: paneID, in: topology) { + if activePaneID(in: topology) == paneID { let hasConflictingIntent = pendingPaneID != nil && pendingPaneID != paneID if !hasConflictingIntent { if paneSurface?.paneID == paneID || preparingSurface?.paneID == paneID { @@ -411,9 +404,6 @@ final class TmuxTerminalSession: ObservableObject { surfacesByPaneID[paneID]?.cancelPickerCaptureForPresentation() cancelPendingPresentation() pendingPaneID = paneID - zoomRequestedPaneID = isFullViewport(paneID: paneID, in: topology) - ? nil - : paneID unpublishPane() } @@ -450,8 +440,7 @@ final class TmuxTerminalSession: ObservableObject { // 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, - isFullViewport(paneID: paneID, in: snapshot) { + if paneSurface?.paneID == paneID { paneSurface?.setSceneActive(true) return } @@ -460,15 +449,6 @@ final class TmuxTerminalSession: ObservableObject { return } - guard isFullViewport(paneID: paneID, in: snapshot) else { - unpublishPane() - if zoomRequestedPaneID != paneID { - zoomRequestedPaneID = paneID - controller.requestZoomPane(paneID: paneID) - } - return - } - zoomRequestedPaneID = nil guard paneSurface?.paneID != paneID else { pendingPaneID = nil @@ -496,13 +476,13 @@ final class TmuxTerminalSession: ObservableObject { let topology, activePaneID(in: topology) == surface.paneID, livePaneIDs.contains(surface.paneID), - pendingPaneID == nil || pendingPaneID == surface.paneID, - isFullViewport(paneID: surface.paneID, in: topology) + pendingPaneID == nil || pendingPaneID == surface.paneID else { surface.cancelPresentationPreparation() return } paneSurface = surface + onPresentationChange?(true) pendingPaneID = nil surface.setSceneActive(isAppActive) surface.setPresented(true) @@ -519,6 +499,7 @@ final class TmuxTerminalSession: ObservableObject { guard let surface = paneSurface else { return } surface.setPresented(false) paneSurface = nil + onPresentationChange?(false) } private func relinquishPresentationOwnership(of surface: TmuxPaneSurface) { @@ -562,22 +543,8 @@ final class TmuxTerminalSession: ObservableObject { return snapshot.windows.first(where: { $0.id == windowID })?.activePaneID } - private func isFullViewport( - paneID: TmuxPaneID, - in snapshot: TmuxSessionController.TopologySnapshot - ) -> Bool { - guard let pane = snapshot.panes.first(where: { $0.id == paneID }), - let window = snapshot.windows.first(where: { $0.id == pane.windowID }) - else { return false } - if window.zoomed { return true } - return !snapshot.panes.contains { - $0.windowID == window.id && $0.id != paneID - } - } - - #if DEBUG +#if DEBUG var pendingPaneIDForTesting: TmuxPaneID? { pendingPaneID } - var zoomRequestedPaneIDForTesting: TmuxPaneID? { zoomRequestedPaneID } var creatingPaneIDsForTesting: Set { creatingPaneIDs } func handleStateForTesting(_ state: TmuxSessionController.SessionState) { handleState(state) } func handleRequestFailedForTesting(_ request: TmuxSessionController.Request) { @@ -585,10 +552,8 @@ final class TmuxTerminalSession: ObservableObject { } func handlePaneRemovedForTesting(_ paneID: TmuxPaneID) { handlePaneRemoved(paneID) } func handlePaneTerminalForTesting(_ paneID: TmuxPaneID) { - guard !isShutDown, - topology?.panes.contains(where: { $0.id == paneID }) == true - else { return } + guard !isShutDown, topology?.panes.contains(where: { $0.id == paneID }) == true else { return } markPaneLiveAfterTerminalHandoff(paneID) } - #endif +#endif } diff --git a/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift b/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift index 555368dd..ba9fd8f8 100644 --- a/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift @@ -19,7 +19,6 @@ final class ActiveSessionSwitcherProjectionTests: XCTestCase { id: UUID(), sessionName: name, subtitle: "Mori", - runtimeState: .connected, isSelected: selected, lastOpenedAt: Date(timeIntervalSince1970: opened) ) diff --git a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift new file mode 100644 index 00000000..ea41e419 --- /dev/null +++ b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift @@ -0,0 +1,62 @@ +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 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/TmuxSessionControllerClientSizeTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxSessionControllerClientSizeTests.swift deleted file mode 100644 index b24286c8..00000000 --- a/MoriRemote/MoriRemoteTerminalTests/TmuxSessionControllerClientSizeTests.swift +++ /dev/null @@ -1,1203 +0,0 @@ -import Foundation -import XCTest - -@testable import MoriRemoteTerminal - -@MainActor -final class TmuxSessionControllerClientSizeTests: XCTestCase { - func testCrossWindowSelectionCommandPolicy() { - XCTAssertEqual( - TmuxSessionController.crossWindowSelectionCommands( - windowID: 2, - activePaneID: 20, - preferredPaneID: 20, - zoomed: false, - hasSibling: true - ), - ["select-window -t @2", "resize-pane -Z -t %20"] - ) - XCTAssertEqual( - TmuxSessionController.crossWindowSelectionCommands( - windowID: 2, - activePaneID: 20, - preferredPaneID: 21, - zoomed: true, - hasSibling: true - ), - ["select-window -t @2", "select-pane -Z -t %21"] - ) - XCTAssertEqual( - TmuxSessionController.crossWindowSelectionCommands( - windowID: 2, - activePaneID: 20, - preferredPaneID: 20, - zoomed: true, - hasSibling: true - ), - ["select-window -t @2"] - ) - XCTAssertEqual( - TmuxSessionController.crossWindowSelectionCommands( - windowID: 2, - activePaneID: 20, - preferredPaneID: nil, - zoomed: false, - hasSibling: true - ), - ["select-window -t @2"] - ) - } - - func testPaneCurrentDirectoryUsesOneTargetedCommandAndReturnsItsBody() async throws { - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - let query = Task { - try await harness.controller.paneCurrentDirectory(for: 1) - } - try await waitUntil("current-directory query was not written") { - harness.recorder.hasWrites - } - XCTAssertEqual( - harness.recorder.takeStrings(), - ["display-message -p -t %1 '#{pane_current_path}'\n"] - ) - - harness.controller.pump(Data(responseBlock( - commandNumber: &nextCommandNumber, - body: "/Users/macbook/scratchpad\n" - ).utf8)) - let currentDirectory = try await query.value - XCTAssertEqual(currentDirectory, "/Users/macbook/scratchpad") - } - - func testPaneCurrentDirectoryReturnsCommandFailureWithoutRequestFailureUI() async throws { - let requestFailure = expectation(description: "no request failure callback") - requestFailure.isInverted = true - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3, - callbacks: .init(onRequestFailed: { _ in requestFailure.fulfill() }) - ) - var nextCommandNumber = harness.nextCommandNumber - - let query = Task { - try await harness.controller.paneCurrentDirectory(for: 1) - } - try await waitUntil("current-directory query was not written") { - harness.recorder.hasWrites - } - _ = harness.recorder.takeStrings() - harness.controller.pump(Data(errorBlock( - commandNumber: &nextCommandNumber, - body: "can't find pane: %1" - ).utf8)) - - do { - _ = try await query.value - XCTFail("expected the query to fail") - } catch { - XCTAssertEqual( - error as? TmuxSessionController.PaneCurrentDirectoryError, - .commandFailed("can't find pane: %1") - ) - } - await fulfillment(of: [requestFailure], timeout: 0.05) - } - - func testShutdownResumesOutstandingPaneCurrentDirectoryQuery() async throws { - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3 - ) - let query = Task { - try await harness.controller.paneCurrentDirectory(for: 1) - } - try await waitUntil("current-directory query was not written") { - harness.recorder.hasWrites - } - _ = harness.recorder.takeStrings() - - await shutDown(harness.controller) - - do { - _ = try await query.value - XCTFail("expected shutdown to fail the query") - } catch { - XCTAssertEqual( - error as? TmuxSessionController.PaneCurrentDirectoryError, - .sessionUnavailable - ) - } - } - - func testSideBySideNavigationCoalescesAndRefreshesOnEachRevisit() async throws { - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectPane(paneID: 1) - harness.controller.requestSelectPane(paneID: 2) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - ("%window-pane-changed @0 %1\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %2", - paneID: 2, - ) - - harness.controller.pump(Data( - ("%window-pane-changed @0 %2\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 2, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - XCTAssertTrue(harness.recorder.takeStrings().isEmpty) - - harness.controller.requestSelectPane(paneID: 1) - harness.controller.requestSelectPane(paneID: 2) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - ("%window-pane-changed @0 %1\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %2", - paneID: 2, - ) - } - - func testPaneNavigationWaitsWhenCommandEndsBeforeTopology() async throws { - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectPane(paneID: 1) - harness.controller.requestSelectPane(paneID: 0) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - responseBlock(commandNumber: &nextCommandNumber).utf8 - )) - await drain(harness.controller) - XCTAssertTrue( - harness.recorder.takeStrings().isEmpty, - "command completion must not admit the deferred intent against stale topology" - ) - - harness.controller.pump(Data( - ("%window-pane-changed @0 %1\n" - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %0", - paneID: 0, - ) - - harness.controller.pump(Data( - ("%window-pane-changed @0 %0\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 0, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - XCTAssertTrue(harness.recorder.takeStrings().isEmpty) - } - - func testPaneNavigationUsesTopologyThatArrivedBeforeCompletion() async throws { - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectPane(paneID: 1) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data("%window-pane-changed @0 %1\n".utf8)) - await drain(harness.controller) - harness.controller.requestSelectPane(paneID: 2) - await drain(harness.controller) - XCTAssertTrue( - harness.recorder.takeStrings().isEmpty, - "the outstanding mutation must still coalesce later navigation" - ) - - harness.controller.pump(Data( - (responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %2", - paneID: 2, - ) - } - - func testPaneNavigationWaitsWhenSubmittedAfterCompletionBeforeTopology() async throws { - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectPane(paneID: 1) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - responseBlock(commandNumber: &nextCommandNumber).utf8 - )) - await drain(harness.controller) - harness.controller.requestSelectPane(paneID: 2) - await drain(harness.controller) - XCTAssertTrue( - harness.recorder.takeStrings().isEmpty, - "a successful mutation remains pending until newer topology arrives" - ) - - harness.controller.pump(Data( - ("%window-pane-changed @0 %1\n" - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %2", - paneID: 2, - ) - } - - func testPaneNavigationReevaluatesImmediatelyAfterCommandError() async throws { - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectPane(paneID: 1) - harness.controller.requestSelectPane(paneID: 2) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - errorBlock(commandNumber: &nextCommandNumber, body: "can't find pane: %1").utf8 - )) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %2", - paneID: 2, - ) - } - - func testFailedPresentationRetryKeepsSameTargetRefreshAfterInFlightCompletion() async throws { - let harness = try await readyController( - listWindowsBody: Self.threePaneZoomedWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectPane(paneID: 1) - harness.controller.requestSelectPane(paneID: 1) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - errorBlock(commandNumber: &nextCommandNumber, body: "can't find pane: %1").utf8 - )) - await drain(harness.controller) - XCTAssertEqual( - harness.recorder.takeStrings(), - ["select-pane -Z -t %1\n"], - "the retry executes after the already queued refresh" - ) - - harness.controller.pump(Data( - refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - ).utf8 - )) - await drain(harness.controller) - let retryRefreshWrites = harness.recorder.takeStrings() - XCTAssertEqual(retryRefreshWrites.count, 1) - let retryRefresh = try XCTUnwrap(retryRefreshWrites.first) - XCTAssertTrue(retryRefresh.hasPrefix("display-message -p -t %1 ")) - XCTAssertEqual(retryRefresh.components(separatedBy: "capture-pane").count - 1, 4) - - harness.controller.pump(Data( - ("%window-pane-changed @0 %1\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - XCTAssertTrue(harness.recorder.takeStrings().isEmpty) - } - - func testRepeatedCrossWindowUnzoomedSelectionDoesNotQueueSecondToggle() async throws { - let harness = try await readyController( - listWindowsBody: Self.splitTargetWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectWindow(windowID: 1, preferredPaneID: 1) - harness.controller.requestSelectWindow(windowID: 1, preferredPaneID: 1) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-window -t @1 ; resize-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - ("%session-window-changed $42 @1\n" - + responseBlock(commandNumber: &nextCommandNumber) - + "%layout-change @1 9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2} b7de,83x44,0,0,1 *Z\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - XCTAssertTrue( - harness.recorder.takeStrings().isEmpty, - "the repeated intent must re-evaluate as zero after the first group zooms" - ) - } - - func testDeferredZoomDoesNotToggleAfterWindowSelectionAlreadyZooms() async throws { - let harness = try await readyController( - listWindowsBody: Self.splitTargetWindow, - expectedPaneCount: 3 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectWindow(windowID: 1, preferredPaneID: 1) - harness.controller.requestZoomPane(paneID: 1) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-window -t @1 ; resize-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - ("%session-window-changed $42 @1\n" - + responseBlock(commandNumber: &nextCommandNumber) - + "%layout-change @1 9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2} b7de,83x44,0,0,1 *Z\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - XCTAssertTrue( - harness.recorder.takeStrings().isEmpty, - "the deferred zoom must re-evaluate to a no-op after the selection group zooms" - ) - } - - func testWindowNavigationCoalescesRollbackToLatestWindow() async throws { - let harness = try await readyController( - listWindowsBody: Self.twoSinglePaneWindows, - expectedPaneCount: 2 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectWindow(windowID: 1, preferredPaneID: 1) - harness.controller.requestSelectWindow(windowID: 0, preferredPaneID: 0) - await drain(harness.controller) - XCTAssertEqual(harness.recorder.takeStrings(), ["select-window -t @1\n"]) - - harness.controller.pump(Data( - ("%session-window-changed $42 @1\n" - + responseBlock(commandNumber: &nextCommandNumber)).utf8 - )) - await drain(harness.controller) - XCTAssertEqual(harness.recorder.takeStrings(), ["select-window -t @0\n"]) - } - - func testColdSplitSelectionEnqueuesPresentationThenRefreshInOneWrite() async throws { - let harness = try await readyController( - listWindowsBody: Self.twoPaneUnzoomedWindow, - expectedPaneCount: 2 - ) - - harness.controller.requestSelectPane(paneID: 2) - await drain(harness.controller) - - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "resize-pane -Z -t %2", - paneID: 2, - ) - } - - func testRefreshCompletionReleasesReadinessWithoutSecondTerminalHandoff() async throws { - let lifecycle = ControllerLifecycleRecorder() - let harness = try await readyController( - listWindowsBody: Self.twoPaneUnzoomedWindow, - expectedPaneCount: 2, - callbacks: lifecycle.callbacks - ) - try await waitUntil("initial terminals were not handed off") { - lifecycle.terminalPaneIDs.count == 2 - } - let initialTerminalPaneIDs = lifecycle.terminalPaneIDs - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectPane(paneID: 2) - await drain(harness.controller) - _ = harness.recorder.takeStrings() - try await waitUntil("refresh did not gate pane readiness") { - lifecycle.phaseChanges.contains { $0.paneID == 2 && $0.phase == .hydrating } - } - - harness.controller.pump(Data( - ("%window-pane-changed @1 %2\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 2, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - try await waitUntil("refresh completion did not restore pane readiness") { - lifecycle.phaseChanges.contains { $0.paneID == 2 && $0.phase == .live } - } - - XCTAssertEqual(lifecycle.terminalPaneIDs, initialTerminalPaneIDs) - } - - func testTopBottomRoundTripRefreshesEachFullToSplitGridChange() async throws { - let harness = try await readyController( - listWindowsBody: Self.twoPaneSameColumnZoomedWindow, - expectedPaneCount: 2 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.requestSelectPane(paneID: 1) - await drain(harness.controller) - - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %1", - paneID: 1, - ) - - harness.controller.pump(Data( - ("%window-pane-changed @0 %1\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 1, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - - harness.controller.requestSelectPane(paneID: 0) - await drain(harness.controller) - assertPresentationWrite( - harness.recorder.takeStrings(), - command: "select-pane -Z -t %0", - paneID: 0, - ) - } - - func testClientSizeRefreshesForRowOrColumnChangesInOneWrite() async throws { - let harness = try await readyController( - listWindowsBody: Self.onePaneWindow, - expectedPaneCount: 1 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.setClientSize(cols: 83, rows: 44) - await drain(harness.controller) - XCTAssertTrue( - harness.recorder.takeStrings().isEmpty, - "the renderer reporting the already-submitted initial grid must do no work" - ) - - harness.controller.setClientSize(cols: 83, rows: 40) - await drain(harness.controller) - var writes = harness.recorder.takeStrings() - XCTAssertEqual(writes.count, 1) - var write = try XCTUnwrap(writes.first) - XCTAssertTrue(write.hasPrefix("refresh-client -C 83x40\ndisplay-message")) - XCTAssertEqual(write.components(separatedBy: "capture-pane").count - 1, 4) - harness.controller.pump(Data( - (responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 0, - rows: 40, - commandNumber: &nextCommandNumber - )).utf8 - )) - await drain(harness.controller) - - harness.controller.setClientSize(cols: 100, rows: 40) - await drain(harness.controller) - writes = harness.recorder.takeStrings() - XCTAssertEqual(writes.count, 1) - write = try XCTUnwrap(writes.first) - XCTAssertTrue( - write.hasPrefix("refresh-client -C 100x40\ndisplay-message -p -t %0 ") - ) - XCTAssertEqual(write.components(separatedBy: "capture-pane").count - 1, 4) - } - - func testInFlightGridRefreshFollowsViewportRevertExactlyOnce() async throws { - let harness = try await readyController( - listWindowsBody: Self.onePaneWindow, - expectedPaneCount: 1 - ) - var nextCommandNumber = harness.nextCommandNumber - - harness.controller.setClientSize(cols: 100, rows: 40) - await drain(harness.controller) - let outbound100 = try XCTUnwrap(harness.recorder.takeStrings().first) - XCTAssertTrue(outbound100.hasPrefix("refresh-client -C 100x40\ndisplay-message")) - - harness.controller.setClientSize(cols: 83, rows: 44) - await drain(harness.controller) - XCTAssertEqual(harness.recorder.takeStrings(), ["refresh-client -C 83x44\n"]) - - harness.controller.pump(Data( - ("%layout-change @0 aa7d,100x40,0,0,0 aa7d,100x40,0,0,0 *\n" - + responseBlock(commandNumber: &nextCommandNumber) - + refreshResponseBlocks( - paneID: 0, - columns: 100, - rows: 40, - commandNumber: &nextCommandNumber - ) - + responseBlock(commandNumber: &nextCommandNumber)).utf8 - )) - await drain(harness.controller) - - let followUpWrites = harness.recorder.takeStrings() - XCTAssertEqual(followUpWrites.count, 1) - let followUp = try XCTUnwrap(followUpWrites.first) - XCTAssertTrue(followUp.hasPrefix("display-message -p -t %0 ")) - XCTAssertEqual(followUp.components(separatedBy: "capture-pane").count - 1, 4) - - harness.controller.pump(Data( - ("%layout-change @0 b7dd,83x44,0,0,0 b7dd,83x44,0,0,0 *\n" - + refreshResponseBlocks( - paneID: 0, - columns: 83, - commandNumber: &nextCommandNumber)).utf8 - )) - await drain(harness.controller) - XCTAssertTrue(harness.recorder.takeStrings().isEmpty) - } - - func testNewWindowAndSplitCommandsDoNotGainRefreshWork() async throws { - let harness = try await readyController( - listWindowsBody: Self.onePaneWindow, - expectedPaneCount: 1 - ) - - harness.controller.requestSelectPane(paneID: 0) - await drain(harness.controller) - XCTAssertTrue(harness.recorder.takeStrings().isEmpty) - - harness.controller.requestNewWindow() - await drain(harness.controller) - XCTAssertEqual(harness.recorder.takeStrings(), ["new-window\n"]) - - harness.controller.requestSplit(paneID: 0, direction: .right, zoom: true) - await drain(harness.controller) - XCTAssertEqual(harness.recorder.takeStrings(), ["split-window -h -Z -t %0\n"]) - } - - func testNotReadyRefreshRetriesOnceAndDrainsAfterInitialPaneChanged() async throws { - let harness = try await hydratingController( - listWindowsBody: Self.twoPaneUnzoomedWindow, - expectedPaneCount: 2 - ) - - harness.controller.requestZoomPane(paneID: 1) - await drain(harness.controller) - XCTAssertEqual( - harness.recorder.takeStrings(), - ["resize-pane -Z -t %1\n"], - "initial hydration cannot yet accept the refresh" - ) - - let hydrationEnd = harness.firstHydrationCommandNumber + harness.hydrationCommandCount - let hydration = (harness.firstHydrationCommandNumber.. ReadyControllerHarness { - let harness = try await hydratingController( - listWindowsBody: listWindowsBody, - expectedPaneCount: expectedPaneCount, - callbacks: callbacks - ) - let hydrationEnd = harness.firstHydrationCommandNumber + harness.hydrationCommandCount - let hydration = (harness.firstHydrationCommandNumber.. HydratingControllerHarness { - let runtime = try GhosttyKitRuntime() - let recorder = ControllerOutboundRecorder() - let controller = TmuxSessionController(callbacks: callbacks) - addTeardownBlock { - await withCheckedContinuation { continuation in - controller.shutdown { continuation.resume() } - } - } - controller.setOutboundSink { recorder.append($0) } - await drain(controller) - try await withCheckedThrowingContinuation { continuation in - controller.start(initialSize: .init(cols: 83, rows: 44)) { result in - continuation.resume(with: result) - } - } - controller.pump(Data( - "%begin 1 1 0\n%end 1 1 0\n%session-changed $42 main\n".utf8 - )) - await drain(controller) - XCTAssertEqual( - recorder.takeStrings(), - [ - "display-message -p '#{version}'\n" - + "refresh-client -C 83x44\n" - + "list-windows -F '#{session_id} #{window_id} #{window_active} #{pane_id} #{window_width} #{window_height} #{window_layout} #{window_visible_layout} #{window_name}'\n" - ] - ) - controller.pump(Data( - ("%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\(listWindowsBody)%end 4 4 1\n").utf8 - )) - await drain(controller) - let hydrationWrites = recorder.takeStrings() - let hydrationOutbound = hydrationWrites.joined() - let hydrationCommandCount = hydrationOutbound - .split(separator: "\n") - .reduce(0) { count, line in - count + 1 + line.components(separatedBy: " ; ").count - 1 - } - XCTAssertEqual( - hydrationCommandCount, - 1 + expectedPaneCount * 4, - "hydration must use one pane-state scan and four captures per pane" - ) - let firstHydrationCommandNumber = 5 - return HydratingControllerHarness( - runtime: runtime, - controller: controller, - recorder: recorder, - firstHydrationCommandNumber: firstHydrationCommandNumber, - hydrationCommandCount: hydrationCommandCount - ) - } - - private func responseBlock(commandNumber: inout Int) -> String { - defer { commandNumber += 1 } - return "%begin \(commandNumber) \(commandNumber) 1\n" - + "%end \(commandNumber) \(commandNumber) 1\n" - } - - private func responseBlock( - commandNumber: inout Int, - body: String - ) -> String { - defer { commandNumber += 1 } - return "%begin \(commandNumber) \(commandNumber) 1\n" - + body - + "%end \(commandNumber) \(commandNumber) 1\n" - } - - private func refreshResponseBlocks( - paneID: TmuxPaneID, - columns: UInt32 = 83, - rows: UInt32 = 44, - commandNumber: inout Int - ) -> String { - let cursorY = rows - 1 - let state = "%\(paneID.rawValue);\(columns);\(rows);0;0;1;;;;0;" - + "4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;;;0;0;\(cursorY);8,16\n" - return responseBlock(commandNumber: &commandNumber, body: state) - + responseBlock(commandNumber: &commandNumber) - + responseBlock(commandNumber: &commandNumber) - + responseBlock(commandNumber: &commandNumber) - + responseBlock(commandNumber: &commandNumber) - } - - private func assertPresentationWrite( - _ writes: [String], - command: String, - paneID: TmuxPaneID, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertEqual(writes.count, 1, file: file, line: line) - guard let write = writes.first else { return } - XCTAssertTrue( - write.hasPrefix(command + "\ndisplay-message -p -t %\(paneID.rawValue) "), - "presentation must precede refresh in the same outbound write: \(write)", - file: file, - line: line - ) - let refresh = String(write.dropFirst(command.utf8.count + 1)) - XCTAssertEqual( - refresh.components(separatedBy: "capture-pane").count - 1, - 4, - file: file, - line: line - ) - XCTAssertEqual( - refresh.components(separatedBy: " ; ").count - 1, - 4, - file: file, - line: line - ) - } - - private func errorBlock(commandNumber: inout Int, body: String) -> String { - defer { commandNumber += 1 } - return "%begin \(commandNumber) \(commandNumber) 1\n" - + "\(body)\n" - + "%error \(commandNumber) \(commandNumber) 1\n" - } - - 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 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 static let threePaneZoomedWindow = windowRecord( - id: 0, - active: true, - paneID: 0, - layout: "85ff,83x44,0,0{27x44,0,0,0,27x44,28,0,1,27x44,56,0,2}", - visibleLayout: "b7dd,83x44,0,0,0", - name: "window-0" - ) - - private static let splitTargetWindow = windowRecord( - id: 0, - active: true, - paneID: 0, - layout: "b7dd,83x44,0,0,0", - visibleLayout: "b7dd,83x44,0,0,0", - name: "window-0" - ) + windowRecord( - id: 1, - active: false, - paneID: 1, - layout: "9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2}", - visibleLayout: "9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2}", - name: "window-1" - ) - - private static let twoSinglePaneWindows = windowRecord( - id: 0, - active: true, - paneID: 0, - layout: "b7dd,83x44,0,0,0", - visibleLayout: "b7dd,83x44,0,0,0", - name: "window-0" - ) + windowRecord( - id: 1, - active: false, - paneID: 1, - layout: "b7de,83x44,0,0,1", - visibleLayout: "b7de,83x44,0,0,1", - name: "window-1" - ) - - private static let onePaneWindow = windowRecord( - id: 0, - active: true, - paneID: 0, - layout: "b7dd,83x44,0,0,0", - visibleLayout: "b7dd,83x44,0,0,0", - name: "window-0" - ) - - private static let twoPaneUnzoomedWindow = windowRecord( - id: 1, - active: true, - paneID: 1, - layout: "9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2}", - visibleLayout: "9c1f,83x44,0,0{41x44,0,0,1,41x44,42,0,2}", - name: "window-1" - ) - - private static let twoPaneSameColumnZoomedWindow = windowRecord( - id: 0, - active: true, - paneID: 0, - layout: "607b,83x44,0,0[83x22,0,0,0,83x21,0,23,1]", - visibleLayout: "b7dd,83x44,0,0,0", - name: "window-0" - ) - - private static func windowRecord( - id: Int, - active: Bool, - paneID: Int, - layout: String, - visibleLayout: String, - name: String - ) -> String { - "$42 @\(id) \(active ? 1 : 0) %\(paneID) 83 44 " - + "\(layout) \(visibleLayout) \(name)\n" - } -} - -private actor RequestFailureRecorder { - private var recordedRequests: [TmuxSessionController.Request] = [] - - func append(_ request: TmuxSessionController.Request) { - recordedRequests.append(request) - } - - func requests() -> [TmuxSessionController.Request] { - recordedRequests - } -} - -private final class ControllerOutboundRecorder: @unchecked Sendable { - private let lock = NSLock() - private var writes: [Data] = [] - - func append(_ data: Data) { - lock.withLock { writes.append(data) } - } - - var hasWrites: Bool { - lock.withLock { !writes.isEmpty } - } - - func takeStrings() -> [String] { - lock.withLock { - defer { writes.removeAll() } - return writes.map { String(decoding: $0, as: UTF8.self) } - } - } -} - -private final class ControllerLifecycleRecorder: @unchecked Sendable { - struct PhaseChange: Equatable { - let paneID: TmuxPaneID - let phase: TmuxSessionController.PaneInfo.Phase - } - - private let lock = NSLock() - private var terminals: [TmuxSessionController.RetainedPaneTerminal] = [] - private var phases: [PhaseChange] = [] - - var callbacks: TmuxSessionController.Callbacks { - .init( - onPaneTerminal: { [self] terminal in - lock.withLock { terminals.append(terminal) } - }, - onPanePhaseChanged: { [self] paneID, phase in - lock.withLock { phases.append(.init(paneID: paneID, phase: phase)) } - } - ) - } - - var terminalPaneIDs: [TmuxPaneID] { - lock.withLock { terminals.map(\.paneID).sorted() } - } - - var phaseChanges: [PhaseChange] { - lock.withLock { phases } - } -} diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift index 0db790f2..339f0a5a 100644 --- a/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxSessionLinkWriteFailureTests.swift @@ -18,7 +18,7 @@ final class TmuxSessionLinkWriteFailureTests: XCTestCase { ) let link = TmuxSessionLink(controller: controller, transport: transport) - try await link.start(viewport: .default) + try await link.start() try await waitUntil("transport was not invalidated after send failure") { await transport.closeDispositions().first == .invalidated @@ -49,7 +49,7 @@ final class TmuxSessionLinkWriteFailureTests: XCTestCase { ) let link = TmuxSessionLink(controller: controller, transport: transport) - try await link.start(viewport: .default) + try await link.start() try await waitUntil("startup commands were not sent") { await transport.sendCount() > 0 } @@ -78,7 +78,7 @@ final class TmuxSessionLinkWriteFailureTests: XCTestCase { ) let link = TmuxSessionLink(controller: controller, transport: transport) - try await link.start(viewport: .default) + try await link.start() await transport.finishInput() try await waitUntil("transport was not invalidated after read end") { diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift index 1324c09d..ded9a8a1 100644 --- a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift @@ -81,7 +81,6 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { adapter.activate( session: session, initialViewportHandler: { _, _ in }, - clientSizeHandler: { _ in }, viewportStabilityHandler: { _ in } ) @@ -131,7 +130,6 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { adapter.activate( session: session, initialViewportHandler: { _, _ in }, - clientSizeHandler: { _ in }, viewportStabilityHandler: { _ in } ) diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift index 7c9fe5b2..2bbd90da 100644 --- a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalSessionShutdownDrainTests.swift @@ -64,51 +64,6 @@ final class TmuxTerminalSessionShutdownDrainTests: XCTestCase { await session.shutdown() } - func testSameWindowSelectionSuppressesDuplicateZoomForIntermediateTopology() async throws { - let runtime = try GhosttyKitRuntime() - let session = makeSession(runtime: runtime) - session.handleTopology(twoPaneSnapshot(activePaneID: 10, zoomed: false)) - - session.prepareForPaneSelection(paneID: 11) - session.handleStateForTesting(.ready) - session.handleTopology(twoPaneSnapshot(activePaneID: 11, zoomed: false)) - try await Task.sleep(for: .milliseconds(50)) - - XCTAssertEqual(session.pendingPaneIDForTesting, 11) - XCTAssertEqual(session.zoomRequestedPaneIDForTesting, 11) - XCTAssertNil(session.lastFailedRequest, "intermediate topology must not enqueue a second zoom") - await session.shutdown() - } - - func testCrossWindowSelectionSuppressesDuplicateZoomForIntermediateTopology() async throws { - let runtime = try GhosttyKitRuntime() - let session = makeSession(runtime: runtime) - session.handleTopology(crossWindowSnapshot(activeWindowID: 1, targetActivePaneID: 20)) - - session.prepareForPaneSelection(paneID: 21) - session.handleStateForTesting(.ready) - session.handleTopology(crossWindowSnapshot(activeWindowID: 2, targetActivePaneID: 21)) - try await Task.sleep(for: .milliseconds(50)) - - XCTAssertEqual(session.pendingPaneIDForTesting, 21) - XCTAssertEqual(session.zoomRequestedPaneIDForTesting, 21) - XCTAssertNil(session.lastFailedRequest, "group intermediate topology must not toggle zoom again") - await session.shutdown() - } - - func testSelectionFailureClearsPendingZoomIntent() async throws { - let runtime = try GhosttyKitRuntime() - let session = makeSession(runtime: runtime) - session.handleTopology(twoPaneSnapshot(activePaneID: 10, zoomed: false)) - session.prepareForPaneSelection(paneID: 11) - - session.handleRequestFailedForTesting(.selectPane) - - XCTAssertNil(session.pendingPaneIDForTesting) - XCTAssertNil(session.zoomRequestedPaneIDForTesting) - await session.shutdown() - } - func testActivePaneRollbackRemainsPendingAcrossIntermediateTopology() async throws { let runtime = try GhosttyKitRuntime() let session = makeSession(runtime: runtime) 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..296eaecc 100644 --- a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift @@ -4,20 +4,7 @@ import Testing @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() { @@ -176,16 +163,7 @@ 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 MemoryProfilePasswords: CredentialStoring, @unchecked Sendable { diff --git a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift index 44eae517..486d034f 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") + relay.complete(.init(succeeded: true, body: "%1\tworking\tclaude\n")) await Task.yield() - #expect(projector.metadata[.init(1)] == .init(state: .working, name: "claude")) + #expect(projector.metadata[1] == .init(state: .working, name: "claude")) projector.foregrounded() - relay.complete(.success, "%1\twaiting\tclaude\n") + await relay.waitUntilRequested() + relay.complete(.init(succeeded: true, body: "%1\twaiting\tclaude\n")) await Task.yield() - #expect(projector.metadata[.init(1)] == .init(state: .waiting, name: "claude")) - - projector.foregrounded() - relay.complete(.success, "%1\tdone\tclaude\n") - await Task.yield() - #expect(projector.metadata[.init(1)] == .init(state: .done, 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 relay.waitUntilRequested() + relay.complete(.init(succeeded: false, body: "transport closed")) await Task.yield() - #expect(projector.metadata[.init(1)] == .unknown) + #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 relay.waitUntilRequested() + relay.complete(.init(succeeded: true, body: "%1\tworking\tclaude\n")) await Task.yield() - #expect(projector.metadata[.init(1)]?.state == .working) - 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") + relay.complete(.init(succeeded: true, body: "%1\twaiting\tclaude\n")) await Task.yield() - #expect(projector.metadata[.init(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..<40 { + if condition() { return } + await Task.yield() } + 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..79c1db57 --- /dev/null +++ b/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing +@testable import MoriRemote + +@Suite("Terminal facade ownership") struct Phase6TerminalOwnershipTests { + @Test("adaptive workspace chrome retains its terminal session identity") + func workspaceSessionIdentity() { + let session = UUID() + #expect(WorkspaceTerminalPresentation.identity(for: session) == session) + #expect(WorkspaceTerminalPresentation.identity(for: session) == session) + #expect(WorkspaceTerminalPresentation.identity(for: UUID()) != session) + } + + @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")) + } + + 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 2c469a79..3c805278 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -7,13 +7,12 @@ The Phase-1 terminal transplant is derived from `h3nock/remux` commit `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 is -not yet instantiated by the production SSH shell. This is not a permanent -binary-distribution mechanism: Phase 2 links this archive into `MoriRemote`, -removes `GhosttyKit` from the app target's direct dependencies, and makes the -app the sole bundle consumer of GhosttyKit. A static framework is the first -correct rung because it supplies a module boundary now without embedding a -second dynamic terminal binary later. +`../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 @@ -44,9 +43,11 @@ No files from remux account/profile repositories, SSH services/transports, SFTP/live forwarding, terminal preview, attachments, composer/voice, or shortcut marketplace/editor are linked into `MoriRemoteTerminal`. -`TmuxControlTransport` is a small protocol-only seam. It deliberately omits -remux SFTP and live-forward provider protocols. The deterministic transport is -retained solely as a terminal-core test fixture. +`TmuxControlTransport` is a terminal-internal protocol-only seam. The app +crosses it only through `MoriRemoteTerminalTransport`, whose byte lifecycle +closures are adapted by `SSHTmuxControlTransport.asTerminalTransport()`. This +omits remux SFTP and live-forward provider protocols. The deterministic +transport remains solely a terminal-core test fixture. ## Required adaptations and iOS 17 deviations @@ -54,6 +55,8 @@ retained solely as a terminal-core test fixture. | --- | --- | --- | | `TmuxScreenModel.swift` | Reduced to injected `ghostty_app_t` + `TmuxControlTransport` composition. | Upstream constructs account targets, runtime status reporting, and preview services; those are Phase 2+ concerns. | | `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 owns `GhosttyKitRuntime` + screen model, exposes state/topology, fixed metadata results, labeled shared mutations, presentation lifecycle, and type-erased SSH byte lifecycle closures. | App code retains Citadel/trust/persistence without importing GhosttyKit or terminal controller/surface types. | | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | @@ -70,7 +73,7 @@ made deterministic or detached from excluded remux domains. | Production area | Pinned remux tests reviewed | MoriRemoteTerminalTests | Untranslated gap | | --- | --- | --- | --- | -| tmux client/session/link/adapter | `TmuxSessionControllerClientSizeTests.swift`, `TmuxTerminalScreenAdapterTests.swift`, `TmuxTerminalSessionShutdownDrainTests.swift` | Same filenames | SSH transport integration deliberately excluded. | +| 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 | Simulator-global `UIPasteboard` integration replaced by injected deterministic source; routing remains tested. | | 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. | diff --git a/MoriRemote/project.yml b/MoriRemote/project.yml index 73d9afe1..36658058 100644 --- a/MoriRemote/project.yml +++ b/MoriRemote/project.yml @@ -21,6 +21,7 @@ targets: sources: - path: MoriRemote dependencies: + - target: MoriRemoteTerminal - package: Citadel product: Citadel - package: NIO @@ -29,18 +30,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 @@ -96,9 +92,8 @@ targets: OTHER_LDFLAGS: - $(inherited) - -lc++ - # Phase 2 links this archive into MoriRemote. Keep it static now: the - # app will then be the single bundle consumer of GhosttyKit, with no - # second embedded terminal dylib. + # 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 diff --git a/scripts/smoke-moriremote-simulator.sh b/scripts/smoke-moriremote-simulator.sh index 167a0d67..c99e7917 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 } From 5a6d229709a4d7b1fb5039b7bf0a8737452f9802 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 17:14:58 +0800 Subject: [PATCH 03/22] moriremote: verify adaptive release shell --- scripts/smoke-moriremote-simulator.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-moriremote-simulator.sh b/scripts/smoke-moriremote-simulator.sh index c99e7917..d4a511a1 100755 --- a/scripts/smoke-moriremote-simulator.sh +++ b/scripts/smoke-moriremote-simulator.sh @@ -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" From ae1d4d9601bdae84bc35a5fbc814e52b0550ac50 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 17:29:09 +0800 Subject: [PATCH 04/22] moriremote: expand terminal shortcut bar --- CHANGELOG.md | 1 + CHANGELOG.zh-Hans.md | 1 + .../Ghostty/GhosttyKeyboardChrome.swift | 64 +++++++++++++++---- .../Ghostty/GhosttyModifierState.swift | 41 +++++++----- .../Ghostty/GhosttySurfaceKeyEvent.swift | 1 + .../Ghostty/GhosttyTerminalCoreView.swift | 16 +++-- .../GhosttyTerminalInputCoordinator.swift | 18 +++--- .../GhosttyKeyboardChromeActionsTests.swift | 19 +++++- .../GhosttyModifierStateTests.swift | 30 +++++++++ MoriRemote/UPSTREAM.md | 2 +- 10 files changed, 148 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b20995db..efe1e98d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ 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)**: Expanded the terminal shortcut bar with one-shot Ctrl and Alt, Esc, Tab, Shift-Tab, arrow keys, `?`, and `/`. The shortcut row scrolls horizontally while the keyboard toggle stays pinned at the left edge. ### 🐛 Bug Fixes diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index a5f737b3..76b9d53d 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -10,6 +10,7 @@ ### ✨ 新功能 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 +- **iOS(MoriRemote)**:终端快捷键条新增一次性 Ctrl/Alt、Esc、Tab、Shift-Tab、方向键、`?` 和 `/`。快捷键区域可横向滚动,键盘开关固定在最左侧。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift index 4722e683..30941ccf 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -53,6 +53,7 @@ struct GhosttyKeyboardChromeActions { let showPanes: () -> Void let toggleKeyboard: () -> Void let toggleControl: () -> Void + let toggleAlt: () -> Void let sendKey: (GhosttySurfaceKeyEvent) -> Bool func perform(_ action: Action) -> Bool { @@ -62,12 +63,25 @@ struct GhosttyKeyboardChromeActions { case .panes: showPanes(); return true case .keyboard: toggleKeyboard(); return true case .control: toggleControl(); return true + case .alt: toggleAlt(); return true 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 .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 sessions, windows, panes, keyboard, control, escape, tab } + enum Action { + case sessions, windows, panes, keyboard, control, alt, escape, tab, shiftTab + case arrowLeft, arrowUp, arrowDown, arrowRight, questionMark, slash + } } /// The retained terminal portion of remux's keyboard chrome. It keeps Ctrl, @@ -77,6 +91,7 @@ struct GhosttyKeyboardChrome: View { let isEnabled: Bool let isCompact: Bool let isControlArmed: Bool + let isAltArmed: Bool let windowCount: Int let paneCount: Int let actions: GhosttyKeyboardChromeActions @@ -84,18 +99,36 @@ struct GhosttyKeyboardChrome: View { var body: some View { HStack(spacing: isCompact ? 6 : 10) { group { - key("ctrl", id: "terminal.ctrl", active: isControlArmed) { actions.perform(.control) } - key("esc", id: "terminal.esc") { actions.perform(.escape) } - key("tab", id: "terminal.tab") { actions.perform(.tab) } + icon("keyboard", id: "terminal.keyboard", label: keyboardMode == .hidden ? "Show keyboard" : "Hide keyboard") { actions.perform(.keyboard) } + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: isCompact ? 6 : 10) { + group { + key("esc", id: "terminal.esc") { actions.perform(.escape) } + key("tab", id: "terminal.tab") { actions.perform(.tab) } + key("ctrl", id: "terminal.ctrl", active: isControlArmed) { actions.perform(.control) } + key("alt", id: "terminal.alt", active: isAltArmed) { actions.perform(.alt) } + } + group { + key("←", id: "terminal.left", label: "Left arrow") { actions.perform(.arrowLeft) } + key("↑", id: "terminal.up", label: "Up arrow") { actions.perform(.arrowUp) } + key("↓", id: "terminal.down", label: "Down arrow") { actions.perform(.arrowDown) } + key("→", id: "terminal.right", label: "Right arrow") { actions.perform(.arrowRight) } + } + group { + key("⇧tab", id: "terminal.shift-tab", label: "Shift Tab", width: 54) { actions.perform(.shiftTab) } + key("?", id: "terminal.question-mark") { actions.perform(.questionMark) } + key("/", id: "terminal.slash") { actions.perform(.slash) } + } + } } + group { icon("rectangle.stack", id: "terminal.sessions", label: "Sessions") { actions.perform(.sessions) } icon("rectangle.on.rectangle", id: "terminal.windows", label: "Windows", enabled: windowCount > 0) { actions.perform(.windows) } icon("square.split.2x1", id: "terminal.panes", label: "Panes", enabled: paneCount > 0) { actions.perform(.panes) } } - group { - icon("keyboard", id: "terminal.keyboard", label: keyboardMode == .hidden ? "Show keyboard" : "Hide keyboard") { actions.perform(.keyboard) } - } } .frame(maxWidth: .infinity) .accessibilityElement(children: .contain) @@ -107,16 +140,24 @@ struct GhosttyKeyboardChrome: View { .background(.thinMaterial, in: Capsule()) } - private func key(_ title: String, id: String, active: Bool = false, action: @escaping () -> Bool) -> some View { + private func key( + _ title: String, + id: String, + label: String? = nil, + active: Bool = false, + width: CGFloat = GhosttyKeyboardChromeSizing.dockButtonWidth, + action: @escaping () -> Bool + ) -> some View { Button { _ = action() } label: { Text(title).font(.system(size: 12, weight: .semibold)) } - .buttonStyle(ChromeButtonStyle(active: active)) + .buttonStyle(ChromeButtonStyle(active: active, width: width)) + .accessibilityLabel(label ?? title) .accessibilityIdentifier(id) .disabled(!isEnabled) } private func icon(_ name: String, id: String, label: String, enabled: Bool = true, action: @escaping () -> Bool) -> some View { Button { _ = action() } label: { Image(systemName: name).font(.system(size: 16, weight: .semibold)) } - .buttonStyle(ChromeButtonStyle(active: id == "terminal.keyboard" && keyboardMode == .system)) + .buttonStyle(ChromeButtonStyle(active: id == "terminal.keyboard" && keyboardMode == .system, width: GhosttyKeyboardChromeSizing.dockButtonWidth)) .accessibilityLabel(label) .accessibilityIdentifier(id) .disabled(!isEnabled || !enabled) @@ -125,9 +166,10 @@ struct GhosttyKeyboardChrome: View { private struct ChromeButtonStyle: ButtonStyle { let active: Bool + let width: CGFloat func makeBody(configuration: Configuration) -> some View { configuration.label - .frame(width: GhosttyKeyboardChromeSizing.dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight) + .frame(width: width, height: GhosttyKeyboardChromeSizing.dockButtonHeight) .foregroundStyle(active ? Color.accentColor : Color.primary) .background(active ? Color.accentColor.opacity(0.18) : Color.clear, in: RoundedRectangle(cornerRadius: GhosttyKeyboardChromeSizing.dockButtonCornerRadius, style: .continuous)) .opacity(configuration.isPressed ? 0.65 : 1) diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift index a203af53..7d2cfabd 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyModifierState.swift @@ -2,35 +2,44 @@ import Foundation struct GhosttyModifierState: Equatable { private(set) var controlArmed = false + private(set) var altArmed = false - var isControlArmed: Bool { - controlArmed - } - - mutating func toggleControl() { - controlArmed.toggle() - } + var isControlArmed: Bool { controlArmed } + var isAltArmed: Bool { altArmed } - mutating func clearControl() { - controlArmed = false - } + 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 else { return text } - defer { controlArmed = false } - return Self.controlText(for: text) ?? text + 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 else { return event } - defer { controlArmed = false } + 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: event.mods.union(.ctrl), + mods: mods, consumedMods: event.consumedMods, unshiftedCodepoint: event.unshiftedCodepoint ) diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift index a0855113..ba3e20a5 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceKeyEvent.swift @@ -66,6 +66,7 @@ struct GhosttySurfaceKeyEvent: Equatable { 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 diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index 394cf60d..c49ea0aa 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -83,6 +83,7 @@ struct GhosttyTerminalCoreView: View { isEnabled: interaction.isInputAvailable, isCompact: false, isControlArmed: terminalInputController.isControlArmed, + isAltArmed: terminalInputController.isAltArmed, windowCount: interaction.windowCount, paneCount: interaction.paneCount, actions: .init( @@ -91,6 +92,7 @@ struct GhosttyTerminalCoreView: View { showPanes: showPanes, toggleKeyboard: toggleKeyboard, toggleControl: { terminalInputController.toggleControl() }, + toggleAlt: { terminalInputController.toggleAlt() }, sendKey: sendTerminalKey ) ) @@ -106,12 +108,11 @@ struct GhosttyTerminalCoreView: View { .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)) { _ in completeKeyboardTransition(for: .hidden) } - .onDisappear { cancelPrefixFlush() } + .onDisappear { cancelTransientInput() } .onChange(of: screen.stateTraceLabel) { oldState, newState in - // A session lifecycle change must not let a delayed key reach a - // replacement surface. The input buffer is cleared and its task - // generation fenced before the next state can accept input. - if oldState != newState { cancelPrefixFlush() } + // A session lifecycle change must not let delayed or latched input + // reach a replacement surface. + if oldState != newState { cancelTransientInput() } } .sheet(item: $selectionSheet) { sheet in switch sheet { @@ -224,6 +225,11 @@ struct GhosttyTerminalCoreView: View { sessionGeneration &+= 1 } + private func cancelTransientInput() { + cancelPrefixFlush() + terminalInputController.clearModifiers() + } + private func sendTerminalPaste(_ text: String) -> Bool { terminalInputController.performPaste( text, diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift index 65b2f4a5..f10221db 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift @@ -134,16 +134,16 @@ struct GhosttyTerminalInputController: Equatable { private var modifierState = GhosttyModifierState() private var tmuxPrefixInputBuffer = GhosttyTmuxPrefixInputBuffer() - var isControlArmed: Bool { - modifierState.isControlArmed - } - - mutating func toggleControl() { - modifierState.toggleControl() - } - - mutating func clearControl() { + 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 { diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift index c6b8cd55..49f71a3f 100644 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift @@ -8,7 +8,18 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { XCTAssertTrue(actions.perform(.escape)) XCTAssertTrue(actions.perform(.tab)) - XCTAssertEqual(events.map(\.keyCode), [.escape, .tab]) + XCTAssertTrue(actions.perform(.shiftTab)) + XCTAssertTrue(actions.perform(.arrowLeft)) + XCTAssertTrue(actions.perform(.arrowUp)) + XCTAssertTrue(actions.perform(.arrowDown)) + XCTAssertTrue(actions.perform(.arrowRight)) + XCTAssertTrue(actions.perform(.questionMark)) + XCTAssertTrue(actions.perform(.slash)) + XCTAssertEqual(events.map(\.keyCode), [.escape, .tab, .tab, .arrowLeft, .arrowUp, .arrowDown, .arrowRight, .slash, .slash]) + XCTAssertEqual(events[2].mods, .shift) + XCTAssertEqual(events[7].text, "?") + XCTAssertEqual(events[7].mods, .shift) + XCTAssertEqual(events[8].text, "/") } func testSelectorsAndModifiersInvokeTheirRetainedActions() { @@ -19,6 +30,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { showPanes: { calls.append("panes") }, toggleKeyboard: { calls.append("keyboard") }, toggleControl: { calls.append("control") }, + toggleAlt: { calls.append("alt") }, sendKey: { _ in false } ) @@ -27,7 +39,8 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { XCTAssertTrue(actions.perform(.panes)) XCTAssertTrue(actions.perform(.keyboard)) XCTAssertTrue(actions.perform(.control)) - XCTAssertEqual(calls, ["sessions", "windows", "panes", "keyboard", "control"]) + XCTAssertTrue(actions.perform(.alt)) + XCTAssertEqual(calls, ["sessions", "windows", "panes", "keyboard", "control", "alt"]) } private func makeActions( @@ -35,7 +48,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { ) -> GhosttyKeyboardChromeActions { GhosttyKeyboardChromeActions( showSessions: {}, showWindows: {}, showPanes: {}, - toggleKeyboard: {}, toggleControl: {}, sendKey: sendKey + toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, sendKey: sendKey ) } } diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift index 253c1d10..ecdd2cac 100644 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyModifierStateTests.swift @@ -46,4 +46,34 @@ final class GhosttyModifierStateTests: XCTestCase { 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/UPSTREAM.md b/MoriRemote/UPSTREAM.md index 3c805278..d0e03750 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -60,7 +60,7 @@ transport remains solely a terminal-core test fixture. | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | -| `GhosttyKeyboardChrome.swift` | Retains the actionable Ctrl/Esc/Tab, session/window/pane selectors, and system keyboard controls; removes composer and shortcut-store actions. | Those excluded surfaces require domains explicitly outside Phase 1. | +| `GhosttyKeyboardChrome.swift` | Retains terminal/session controls, expands the local bar with one-shot Alt, arrows, Shift-Tab, `?`, and `/`, pins keyboard visibility at the leading edge, and removes composer/shortcut-store actions. | Essential terminal keys are product controls, while the excluded surfaces require domains explicitly outside the core terminal scope. | | `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, chrome, and picker sheets. | No SSH construction or persistence dependency. | | `ActiveSessionSwitcherView.swift` | Uses `UUID`/title/subtitle DTOs and select/disconnect callbacks. | Prevents profile/repository types from entering terminal core. | | iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | From 09bae5568d1512b5fa56a5f04fdcfba9fd6866da Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 17:31:23 +0800 Subject: [PATCH 05/22] moriremote: give shortcut row full width --- .../Ghostty/GhosttyKeyboardChrome.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift index 30941ccf..7a9c69af 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -121,14 +121,13 @@ struct GhosttyKeyboardChrome: View { key("?", id: "terminal.question-mark") { actions.perform(.questionMark) } key("/", id: "terminal.slash") { actions.perform(.slash) } } + group { + icon("rectangle.stack", id: "terminal.sessions", label: "Sessions") { actions.perform(.sessions) } + icon("rectangle.on.rectangle", id: "terminal.windows", label: "Windows", enabled: windowCount > 0) { actions.perform(.windows) } + icon("square.split.2x1", id: "terminal.panes", label: "Panes", enabled: paneCount > 0) { actions.perform(.panes) } + } } } - - group { - icon("rectangle.stack", id: "terminal.sessions", label: "Sessions") { actions.perform(.sessions) } - icon("rectangle.on.rectangle", id: "terminal.windows", label: "Windows", enabled: windowCount > 0) { actions.perform(.windows) } - icon("square.split.2x1", id: "terminal.panes", label: "Panes", enabled: paneCount > 0) { actions.perform(.panes) } - } } .frame(maxWidth: .infinity) .accessibilityElement(children: .contain) From 564b9491a70d9a68d9e21f98ea991f587d0b15d2 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 17:50:24 +0800 Subject: [PATCH 06/22] moriremote: keep static terminal out of app bundle --- MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift | 1 + MoriRemote/project.yml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift b/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift index 79c1db57..264f25b3 100644 --- a/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift @@ -47,6 +47,7 @@ import Testing 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] { diff --git a/MoriRemote/project.yml b/MoriRemote/project.yml index 36658058..6717315b 100644 --- a/MoriRemote/project.yml +++ b/MoriRemote/project.yml @@ -21,7 +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 From 496fcd06c6fffb3f3c9c549850992d95c130829c Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 18:35:42 +0800 Subject: [PATCH 07/22] moriremote: discover tmux sessions by server --- CHANGELOG.md | 1 + CHANGELOG.zh-Hans.md | 1 + .../MoriRemote.xcodeproj/project.pbxproj | 4 + .../App/MoriRemoteDependencies.swift | 23 ++++ .../MoriRemote/App/RemoteRootModel.swift | 97 +++++++++++++-- .../Resources/en.lproj/Localizable.strings | 6 + .../zh-Hans.lproj/Localizable.strings | 6 + .../Tmux/SSHTmuxSessionDiscovery.swift | 44 +++++++ .../MoriRemote/Tmux/TmuxShellCommand.swift | 20 ++++ .../MoriRemote/Views/RemoteRootView.swift | 113 ++++++++---------- .../Phase2TransportTests.swift | 22 ++++ .../MoriRemoteTests/Phase4ShellTests.swift | 35 +++++- 12 files changed, 292 insertions(+), 80 deletions(-) create mode 100644 MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index efe1e98d..aa0247e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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)**: Expanded the terminal shortcut bar with one-shot Ctrl and Alt, Esc, Tab, Shift-Tab, arrow keys, `?`, and `/`. The shortcut row scrolls horizontally while the keyboard toggle stays pinned at the left edge. +- **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session from the library instead of manually creating workspace records. ### 🐛 Bug Fixes diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 76b9d53d..3c7a130c 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -11,6 +11,7 @@ - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 - **iOS(MoriRemote)**:终端快捷键条新增一次性 Ctrl/Alt、Esc、Tab、Shift-Tab、方向键、`?` 和 `/`。快捷键区域可横向滚动,键盘开关固定在最左侧。 +- **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接从资料库选择,不再需要手动创建工作区记录。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index d3a4a942..df6b8e1c 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 4990AE0712D61323469D3EF4 /* GhosttyTerminalSurfaceInteractionOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = B29878C376F0AD7940205B86 /* GhosttyTerminalSurfaceInteractionOutcome.swift */; }; 4B2ADF9D0C7011A644E1104E /* TmuxShellCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D631082FBB8A05D4BA1F801F /* TmuxShellCommand.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 */; }; @@ -193,6 +194,7 @@ 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySingleViewportView.swift; sourceTree = ""; }; 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurfaceTests.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 = ""; }; 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChromeActionsTests.swift; sourceTree = ""; }; @@ -385,6 +387,7 @@ children = ( 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */, 21CE86B5FC370573653D3245 /* SSHTmuxControlTransport.swift */, + 7D53B10B6CBF18D7DDAF1B27 /* SSHTmuxSessionDiscovery.swift */, D631082FBB8A05D4BA1F801F /* TmuxShellCommand.swift */, ); path = Tmux; @@ -784,6 +787,7 @@ 68C87C51C7B1430199E64AAC /* SSHPrivateKeyInspector.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 */, 4B2ADF9D0C7011A644E1104E /* TmuxShellCommand.swift in Sources */, diff --git a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift index e93f5076..eca6f3df 100644 --- a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift +++ b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift @@ -189,6 +189,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 b0b0cd21..117920ea 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -41,7 +41,7 @@ struct ServerWorkspaceDraft: Identifiable, Sendable { init(server: SavedServer? = nil, workspace: SavedWorkspace? = nil, identity: SSHIdentity? = nil) { id = server?.id ?? UUID() - workspaceID = workspace?.id ?? (server == nil ? UUID() : nil) + workspaceID = workspace?.id serverLastConnectedAt = server?.lastConnectedAt workspaceLastConnectedAt = workspace?.lastConnectedAt serverName = server?.name ?? "" @@ -72,6 +72,18 @@ struct ServerWorkspaceDraft: Identifiable, Sendable { } } +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 @@ -241,12 +253,14 @@ 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 } @@ -255,6 +269,24 @@ 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: UInt64) -> AgentMetadata { runtimes[workspaceID]?.metadata(for: paneID) ?? .unknown } @@ -280,10 +312,55 @@ final class RemoteRootModel { 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) + discoverSessions(serverID: records.0.id) } catch { errorMessage = error.localizedDescription } } } + func discoverSessions(serverID: UUID) { + guard sessionDiscovery[serverID] != .loading else { return } + sessionDiscovery[serverID] = .loading + Task { [weak self] in + guard let self else { return } + do { + let material = try await self.dependencies.library.discoveryMaterial(for: serverID) + let auth = try await self.dependencies.library.resolveAuth(server: material.0, identity: material.1, settings: material.2) + let endpoint = try CanonicalEndpoint(host: material.0.host, port: material.0.port) + let key = SSHRootPool.Key( + serverID: material.0.id, + endpoint: endpoint, + username: material.0.username, + authenticationFingerprint: auth.rootPoolFingerprint + ) + let names = try await SSHTmuxSessionDiscovery( + connector: CitadelSSHRootConnector( + server: material.0, + auth: auth, + trust: SSHHostTrustResolver(store: self.dependencies.trustedHosts) + ), + pool: self.dependencies.roots, + poolKey: key + ).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) + } + } + } + func save(_ draft: WorkspaceDraft) { Task { do { @@ -396,10 +473,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 { @@ -413,18 +490,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 } } } diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index 88f1ec5d..02a79283 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…"; @@ -170,10 +174,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."; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index 8028a1d8..3ed31cd7 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…"; @@ -170,10 +174,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." = "连接已中断。"; diff --git a/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift b/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift new file mode 100644 index 00000000..691cb663 --- /dev/null +++ b/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift @@ -0,0 +1,44 @@ +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 connector: any SSHRootConnecting + let pool: SSHRootPool + let poolKey: SSHRootPool.Key + var tmuxExecutable = "tmux" + + func load() async throws -> [String] { + let lease = try await pool.lease(for: poolKey, connector: connector) + 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/TmuxShellCommand.swift b/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift index fc9b211b..4c3f3ebb 100644 --- a/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift +++ b/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift @@ -78,6 +78,10 @@ enum TmuxCommandBuilder { 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 } @@ -124,3 +128,19 @@ enum TmuxCommandBuilder { 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 f68437ad..ee629be8 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -46,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 } } @@ -60,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: { @@ -113,7 +106,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, @@ -124,9 +118,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 } ) } @@ -148,33 +140,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" } } @@ -182,7 +167,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? @@ -190,9 +176,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 = "" @@ -206,15 +190,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") @@ -233,17 +217,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 { @@ -252,18 +237,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) } } @@ -505,12 +517,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) @@ -535,31 +541,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/MoriRemoteTests/Phase2TransportTests.swift b/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift index 07479e16..087cc7d6 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) diff --git a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift index 296eaecc..114099a5 100644 --- a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift @@ -28,21 +28,44 @@ import Testing #expect(throws: SavedModelValidationError.invalidTmuxSession) { try draft.record() } } - @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() 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.1 == nil) #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() } + } + + @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, workspace: main, identity: identity, credential: nil) + + 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("connection attempt admission is synchronous and stale tokens cannot finish") From d4b42934d6ca2dbd4601be27b1f83565b5f3350f Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 19:02:12 +0800 Subject: [PATCH 08/22] moriremote: restore compact terminal menus --- CHANGELOG.md | 3 +- CHANGELOG.zh-Hans.md | 3 +- .../MoriRemote/App/RemoteRootModel.swift | 4 +- .../Resources/en.lproj/Localizable.strings | 8 + .../zh-Hans.lproj/Localizable.strings | 8 + .../MoriRemote/Views/RemoteRootView.swift | 17 +- .../App/MoriRemoteTerminalFacade.swift | 33 ++- .../Ghostty/GhosttyKeyboardChrome.swift | 188 +++++++++++++----- .../Ghostty/GhosttyTerminalCoreView.swift | 13 +- .../GhosttyKeyboardChromeActionsTests.swift | 41 +++- .../MoriRemoteTerminalFacadeTests.swift | 11 + MoriRemote/UPSTREAM.md | 2 +- 12 files changed, 258 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0247e4..164cdc0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,12 @@ 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)**: Expanded the terminal shortcut bar with one-shot Ctrl and Alt, Esc, Tab, Shift-Tab, arrow keys, `?`, and `/`. The shortcut row scrolls horizontally while the keyboard toggle stays pinned at the left edge. +- **iOS (MoriRemote)**: Restored remux’s compact three-group terminal bar. One-shot Ctrl/Alt, common terminal keys, and shared tmux actions now live in native menus; Sessions/Windows/Panes remain one-tap selectors, with Library and the keyboard toggle at the trailing edge. - **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session from the library instead of manually creating workspace records. ### 🐛 Bug Fixes +- **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 3c7a130c..05e2e846 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -10,11 +10,12 @@ ### ✨ 新功能 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 -- **iOS(MoriRemote)**:终端快捷键条新增一次性 Ctrl/Alt、Esc、Tab、Shift-Tab、方向键、`?` 和 `/`。快捷键区域可横向滚动,键盘开关固定在最左侧。 +- **iOS(MoriRemote)**:恢复 remux 紧凑的三组终端栏。一次性 Ctrl/Alt、常用终端键和共享 tmux 操作收进原生菜单;Sessions/Windows/Panes 保留一击入口,资料库和键盘开关位于最右侧。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接从资料库选择,不再需要手动创建工作区记录。 ### 🐛 问题修复 +- **iOS(MoriRemote)**:修复终端已可用后,延迟到达的同步回调仍会让标题一直显示“正在连接”的问题。 - **iOS(MoriRemote)**:修复远端 tmux 控制客户端已经连接,但界面仍一直停在“正在等待活动的 tmux pane”的问题。 - **iOS(MoriRemote)**:凭证改为仅限本设备、仅在解锁时可用的 Keychain 存储;Ghostty 必须在终端 surface 拆除后才释放;后台期间延后重连;低内存时优先释放非活动运行时。 diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index 117920ea..fba18552 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -221,7 +221,9 @@ final class ActiveWorkspaceRuntime { private func receive(_ state: MoriRemoteTerminalConnectionState) { switch state { case .connecting: - status = .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: diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index 02a79283..ab25ec06 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -115,6 +115,14 @@ "Function Keys" = "Function Keys"; "Home key" = "Home key"; "Modifiers" = "Modifiers"; +"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 (⌘])"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index 3ed31cd7..8f85d352 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -115,6 +115,14 @@ "Function Keys" = "功能键"; "Home key" = "Home 键"; "Modifiers" = "修饰键"; +"Terminal keys" = "终端按键"; +"tmux actions" = "tmux 操作"; +"New window" = "新建窗口"; +"Split right" = "向右分屏"; +"Split down" = "向下分屏"; +"Close window" = "关闭窗口"; +"Page Up" = "向上翻页"; +"Page Down" = "向下翻页"; "Navigation" = "导航"; "Next Pane" = "下一个面板"; "Next pane (⌘])" = "下一个面板(⌘])"; diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index ee629be8..db80edb9 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -333,7 +333,12 @@ private struct RemoteTerminalDetailView: View { var body: some View { VStack(spacing: 0) { header - MoriRemoteTerminalView(session: runtime.session, onShowSessions: { showsSessions = true }) + MoriRemoteTerminalView( + session: runtime.session, + onShowSessions: { showsSessions = true }, + onShowLibrary: showLibrary, + onSharedMutationRequest: { pendingSharedMutation = RemoteSharedMutation($0) } + ) .id(WorkspaceTerminalPresentation.identity(for: runtime.session.instanceID)) .background(Color.black) } @@ -472,6 +477,16 @@ private struct RemoteTerminalDetailView: View { 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 { diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift index b1f60f3f..aae011f6 100644 --- a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -42,6 +42,12 @@ 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 @@ -160,7 +166,9 @@ public final class MoriRemoteTerminalSession: ObservableObject { lastError = nil publishConnectionState(.ready) case .attaching, .syncing: - publishConnectionState(.connecting) + // 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) } @@ -186,10 +194,27 @@ public final class MoriRemoteTerminalSession: ObservableObject { public struct MoriRemoteTerminalView: View { @ObservedObject private var session: MoriRemoteTerminalSession private let onShowSessions: () -> Void - public init(session: MoriRemoteTerminalSession, onShowSessions: @escaping () -> Void = {}) { - self.session = session; self.onShowSessions = onShowSessions + private let onShowLibrary: () -> Void + private let onSharedMutationRequest: (MoriRemoteTerminalSharedMutation) -> Void + + public init( + session: MoriRemoteTerminalSession, + onShowSessions: @escaping () -> Void = {}, + onShowLibrary: @escaping () -> Void = {}, + onSharedMutationRequest: @escaping (MoriRemoteTerminalSharedMutation) -> Void = { _ in } + ) { + self.session = session + self.onShowSessions = onShowSessions + self.onShowLibrary = onShowLibrary + self.onSharedMutationRequest = onSharedMutationRequest } + public var body: some View { - GhosttyTerminalCoreView(screen: session.screen.screenAdapter, onShowSessions: onShowSessions) + GhosttyTerminalCoreView( + screen: session.screen.screenAdapter, + onShowSessions: onShowSessions, + onShowLibrary: onShowLibrary, + onSharedMutationRequest: onSharedMutationRequest + ) } } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift index 7a9c69af..f6fb69f0 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -1,7 +1,5 @@ import SwiftUI -/// Upstream-compatible keyboard intent. The Phase-1 bar intentionally omits -/// composer and shortcut-marketplace actions, not terminal controls. enum GhosttyKeyboardChromeMode: Equatable { case hidden case system @@ -45,25 +43,34 @@ extension EnvironmentValues { enum GhosttyPhoneChromePalette { static let dock = Color.black } -/// The semantic, testable action boundary behind the retained upstream dock. -/// It has no account, composer, or shortcut-store dependency. +/// 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 showSessions: () -> Void + let showLibrary: () -> Void let showWindows: () -> Void let showPanes: () -> Void let toggleKeyboard: () -> Void let toggleControl: () -> Void let toggleAlt: () -> Void + let requestSharedMutation: (MoriRemoteTerminalSharedMutation) -> Void let sendKey: (GhosttySurfaceKeyEvent) -> Bool func perform(_ action: Action) -> Bool { switch action { case .sessions: showSessions(); return true + case .library: showLibrary(); return true case .windows: showWindows(); return true case .panes: showPanes(); 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 .escape: return sendKey(.init(keyCode: .escape)) case .tab: return sendKey(.init(keyCode: .tab)) case .shiftTab: return sendKey(.init(keyCode: .tab, mods: .shift)) @@ -71,6 +78,10 @@ struct GhosttyKeyboardChromeActions { 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: @@ -79,14 +90,19 @@ struct GhosttyKeyboardChromeActions { } enum Action { - case sessions, windows, panes, keyboard, control, alt, escape, tab, shiftTab - case arrowLeft, arrowUp, arrowDown, arrowRight, questionMark, slash + case sessions, library, windows, panes, keyboard, control, alt + case escape, tab, shiftTab, arrowLeft, arrowUp, arrowDown, arrowRight + case home, end, pageUp, pageDown, questionMark, slash + case newWindow, splitHorizontal, splitVertical, closePane, closeWindow } } -/// The retained terminal portion of remux's keyboard chrome. It keeps Ctrl, -/// Esc, Tab, session/window/pane selectors, and system-keyboard control. +/// Remux's compact three-group dock with Mori's terminal keys folded into +/// native menus. Keeping the keyboard at the upstream trailing position makes +/// its location stable while avoiding a horizontally scrolling toolbar. struct GhosttyKeyboardChrome: View { + @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + let keyboardMode: GhosttyKeyboardChromeMode let isEnabled: Bool let isCompact: Bool @@ -98,79 +114,141 @@ struct GhosttyKeyboardChrome: View { var body: some View { HStack(spacing: isCompact ? 6 : 10) { - group { - icon("keyboard", id: "terminal.keyboard", label: keyboardMode == .hidden ? "Show keyboard" : "Hide keyboard") { actions.perform(.keyboard) } + controlGroup { menuControls } + controlGroup { navigationControls } + controlGroup { inputControls } + } + .frame(maxWidth: .infinity, alignment: .center) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .contain) + } + + private var menuControls: some View { + HStack(spacing: isCompact ? 1 : 2) { + Menu { + Button { _ = actions.perform(.control) } label: { + Label("Ctrl", systemImage: isControlArmed ? "checkmark" : "control") + } + Button { _ = actions.perform(.alt) } label: { + Label("Alt", systemImage: isAltArmed ? "checkmark" : "option") + } + } label: { + menuLabel("control", active: isControlArmed || isAltArmed) } + .accessibilityLabel(String(localized: "Modifiers")) + .accessibilityIdentifier("terminal.modifiers") - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: isCompact ? 6 : 10) { - group { - key("esc", id: "terminal.esc") { actions.perform(.escape) } - key("tab", id: "terminal.tab") { actions.perform(.tab) } - key("ctrl", id: "terminal.ctrl", active: isControlArmed) { actions.perform(.control) } - key("alt", id: "terminal.alt", active: isAltArmed) { actions.perform(.alt) } - } - group { - key("←", id: "terminal.left", label: "Left arrow") { actions.perform(.arrowLeft) } - key("↑", id: "terminal.up", label: "Up arrow") { actions.perform(.arrowUp) } - key("↓", id: "terminal.down", label: "Down arrow") { actions.perform(.arrowDown) } - key("→", id: "terminal.right", label: "Right arrow") { actions.perform(.arrowRight) } - } - group { - key("⇧tab", id: "terminal.shift-tab", label: "Shift Tab", width: 54) { actions.perform(.shiftTab) } - key("?", id: "terminal.question-mark") { actions.perform(.questionMark) } - key("/", id: "terminal.slash") { actions.perform(.slash) } - } - group { - icon("rectangle.stack", id: "terminal.sessions", label: "Sessions") { actions.perform(.sessions) } - icon("rectangle.on.rectangle", id: "terminal.windows", label: "Windows", enabled: windowCount > 0) { actions.perform(.windows) } - icon("square.split.2x1", id: "terminal.panes", label: "Panes", enabled: paneCount > 0) { actions.perform(.panes) } - } + Menu { + Section { + Button("Esc") { _ = actions.perform(.escape) } + Button("Tab") { _ = actions.perform(.tab) } + Button("Shift-Tab") { _ = actions.perform(.shiftTab) } } + Section { + Button("← Left") { _ = actions.perform(.arrowLeft) } + Button("↑ Up") { _ = actions.perform(.arrowUp) } + Button("↓ Down") { _ = actions.perform(.arrowDown) } + Button("→ Right") { _ = actions.perform(.arrowRight) } + } + Section { + Button("Home") { _ = actions.perform(.home) } + Button("End") { _ = actions.perform(.end) } + Button("Page Up") { _ = actions.perform(.pageUp) } + Button("Page Down") { _ = actions.perform(.pageDown) } + } + Section { + Button("?") { _ = actions.perform(.questionMark) } + Button("/") { _ = actions.perform(.slash) } + } + } label: { + menuLabel("command") } + .accessibilityLabel(String(localized: "Terminal keys")) + .accessibilityIdentifier("terminal.keys") + + 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: { + menuLabel("terminal") + } + .accessibilityLabel(String(localized: "tmux actions")) + .accessibilityIdentifier("terminal.tmux-actions") } - .frame(maxWidth: .infinity) - .accessibilityElement(children: .contain) + .disabled(!isEnabled) + } + + private var navigationControls: some View { + HStack(spacing: isCompact ? 1 : 2) { + icon("rectangle.stack", id: "terminal.sessions", label: String(localized: "Sessions")) { actions.perform(.sessions) } + icon("rectangle.on.rectangle", id: "terminal.windows", label: String(localized: "Windows"), enabled: windowCount > 0) { actions.perform(.windows) } + icon("square.split.2x1", id: "terminal.panes", label: String(localized: "Panes"), enabled: paneCount > 0) { actions.perform(.panes) } + } + } + + private var inputControls: some View { + HStack(spacing: isCompact ? 1 : 2) { + icon("house", id: "terminal.home", label: String(localized: "Library"), enabled: true) { actions.perform(.library) } + icon("keyboard", id: "terminal.keyboard", label: keyboardMode == .hidden ? String(localized: "Show keyboard") : String(localized: "Hide keyboard"), enabled: true, active: keyboardMode == .system) { actions.perform(.keyboard) } + } + } + + private func menuLabel(_ systemName: String, active: Bool = false) -> some View { + Image(systemName: systemName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight) + .foregroundStyle(active ? chromeStyle.accent : Color.primary) + .background(active ? chromeStyle.accent.opacity(0.16) : Color.clear, in: RoundedRectangle(cornerRadius: GhosttyKeyboardChromeSizing.dockButtonCornerRadius, style: .continuous)) + .contentShape(Rectangle()) } - private func group(@ViewBuilder _ content: () -> Content) -> some View { - HStack(spacing: 2, content: content) - .padding(4) + private func controlGroup(@ViewBuilder _ content: () -> Content) -> some View { + content() + .padding(.horizontal, isCompact ? 3 : 5) + .padding(.vertical, GhosttyKeyboardChromeSizing.controlGroupVerticalPadding) .background(.thinMaterial, in: Capsule()) + .overlay { Capsule().strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.75) } } - private func key( - _ title: String, + private func icon( + _ name: String, id: String, - label: String? = nil, + label: String, + enabled: Bool = true, active: Bool = false, - width: CGFloat = GhosttyKeyboardChromeSizing.dockButtonWidth, action: @escaping () -> Bool ) -> some View { - Button { _ = action() } label: { Text(title).font(.system(size: 12, weight: .semibold)) } - .buttonStyle(ChromeButtonStyle(active: active, width: width)) - .accessibilityLabel(label ?? title) - .accessibilityIdentifier(id) - .disabled(!isEnabled) + Button { _ = action() } label: { + Image(systemName: name).font(.system(size: 16.5, weight: .semibold)) + } + .buttonStyle(ChromeButtonStyle(active: active, width: dockButtonWidth)) + .accessibilityLabel(label) + .accessibilityIdentifier(id) + .disabled((!isEnabled && id != "terminal.home") || !enabled) } - private func icon(_ name: String, id: String, label: String, enabled: Bool = true, action: @escaping () -> Bool) -> some View { - Button { _ = action() } label: { Image(systemName: name).font(.system(size: 16, weight: .semibold)) } - .buttonStyle(ChromeButtonStyle(active: id == "terminal.keyboard" && keyboardMode == .system, width: GhosttyKeyboardChromeSizing.dockButtonWidth)) - .accessibilityLabel(label) - .accessibilityIdentifier(id) - .disabled(!isEnabled || !enabled) + private var dockButtonWidth: CGFloat { + isCompact ? GhosttyKeyboardChromeSizing.compactDockButtonWidth : GhosttyKeyboardChromeSizing.dockButtonWidth } } private struct ChromeButtonStyle: ButtonStyle { let active: Bool let width: CGFloat + func makeBody(configuration: Configuration) -> some View { configuration.label .frame(width: width, height: GhosttyKeyboardChromeSizing.dockButtonHeight) .foregroundStyle(active ? Color.accentColor : Color.primary) - .background(active ? Color.accentColor.opacity(0.18) : Color.clear, in: RoundedRectangle(cornerRadius: GhosttyKeyboardChromeSizing.dockButtonCornerRadius, style: .continuous)) + .background(active ? Color.accentColor.opacity(0.16) : Color.clear, in: RoundedRectangle(cornerRadius: GhosttyKeyboardChromeSizing.dockButtonCornerRadius, style: .continuous)) + .scaleEffect(configuration.isPressed ? 0.96 : 1) .opacity(configuration.isPressed ? 0.65 : 1) } } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index c49ea0aa..725e6845 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -9,8 +9,11 @@ import UIKit /// sheets, 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 onShowSessions: () -> Void + private let onShowLibrary: () -> Void + private let onSharedMutationRequest: (MoriRemoteTerminalSharedMutation) -> Void @State private var terminalInputController = GhosttyTerminalInputController() @State private var responderHandoff = GhosttyKeyboardResponderHandoff() @State private var trackpadDriver = GhosttyKeyboardCursorTrackpadDriver() @@ -22,10 +25,14 @@ struct GhosttyTerminalCoreView: View { init( screen: TmuxTerminalScreenAdapter, - onShowSessions: @escaping () -> Void = {} + onShowSessions: @escaping () -> Void = {}, + onShowLibrary: @escaping () -> Void = {}, + onSharedMutationRequest: @escaping (MoriRemoteTerminalSharedMutation) -> Void = { _ in } ) { self.screen = screen self.onShowSessions = onShowSessions + self.onShowLibrary = onShowLibrary + self.onSharedMutationRequest = onSharedMutationRequest } var body: some View { @@ -81,18 +88,20 @@ struct GhosttyTerminalCoreView: View { GhosttyKeyboardChrome( keyboardMode: compositionState.inputCoordinator.keyboardMode, isEnabled: interaction.isInputAvailable, - isCompact: false, + isCompact: horizontalSizeClass == .compact, isControlArmed: terminalInputController.isControlArmed, isAltArmed: terminalInputController.isAltArmed, windowCount: interaction.windowCount, paneCount: interaction.paneCount, actions: .init( showSessions: onShowSessions, + showLibrary: onShowLibrary, showWindows: showWindows, showPanes: showPanes, toggleKeyboard: toggleKeyboard, toggleControl: { terminalInputController.toggleControl() }, toggleAlt: { terminalInputController.toggleAlt() }, + requestSharedMutation: onSharedMutationRequest, sendKey: sendTerminalKey ) ) diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift index 49f71a3f..074a0523 100644 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift @@ -13,42 +13,69 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { 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, .slash, .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[7].text, "?") - XCTAssertEqual(events[7].mods, .shift) - XCTAssertEqual(events[8].text, "/") + XCTAssertEqual(events[11].text, "?") + XCTAssertEqual(events[11].mods, .shift) + XCTAssertEqual(events[12].text, "/") } func testSelectorsAndModifiersInvokeTheirRetainedActions() { var calls: [String] = [] let actions = GhosttyKeyboardChromeActions( showSessions: { calls.append("sessions") }, + showLibrary: { calls.append("library") }, showWindows: { calls.append("windows") }, showPanes: { calls.append("panes") }, 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") + } + }, sendKey: { _ in false } ) XCTAssertTrue(actions.perform(.sessions)) + XCTAssertTrue(actions.perform(.library)) XCTAssertTrue(actions.perform(.windows)) XCTAssertTrue(actions.perform(.panes)) XCTAssertTrue(actions.perform(.keyboard)) XCTAssertTrue(actions.perform(.control)) XCTAssertTrue(actions.perform(.alt)) - XCTAssertEqual(calls, ["sessions", "windows", "panes", "keyboard", "control", "alt"]) + XCTAssertTrue(actions.perform(.newWindow)) + XCTAssertTrue(actions.perform(.splitHorizontal)) + XCTAssertTrue(actions.perform(.splitVertical)) + XCTAssertTrue(actions.perform(.closePane)) + XCTAssertTrue(actions.perform(.closeWindow)) + XCTAssertEqual(calls, [ + "sessions", "library", "windows", "panes", "keyboard", "control", "alt", + "new-window", "split-horizontal", "split-vertical", "close-pane", "close-window", + ]) } private func makeActions( sendKey: @escaping (GhosttySurfaceKeyEvent) -> Bool ) -> GhosttyKeyboardChromeActions { GhosttyKeyboardChromeActions( - showSessions: {}, showWindows: {}, showPanes: {}, - toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, sendKey: sendKey + showSessions: {}, showLibrary: {}, showWindows: {}, showPanes: {}, + toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, + requestSharedMutation: { _ in }, sendKey: sendKey ) } } diff --git a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift index ea41e419..6a5dcd6d 100644 --- a/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/MoriRemoteTerminalFacadeTests.swift @@ -17,6 +17,17 @@ final class MoriRemoteTerminalFacadeTests: XCTestCase { 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() diff --git a/MoriRemote/UPSTREAM.md b/MoriRemote/UPSTREAM.md index d0e03750..778b9ba3 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -60,7 +60,7 @@ transport remains solely a terminal-core test fixture. | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | -| `GhosttyKeyboardChrome.swift` | Retains terminal/session controls, expands the local bar with one-shot Alt, arrows, Shift-Tab, `?`, and `/`, pins keyboard visibility at the leading edge, and removes composer/shortcut-store actions. | Essential terminal keys are product controls, while the excluded surfaces require domains explicitly outside the core terminal scope. | +| `GhosttyKeyboardChrome.swift` | Restores remux's compact three-group dock, keeps Sessions/Windows/Panes and trailing keyboard placement, and folds one-shot modifiers, common keys, and Mori shared tmux actions into native menus. Composer and shortcut-store actions remain excluded. | Native menus preserve remux density while essential Mori terminal operations stay reachable without importing deferred product domains. | | `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, chrome, and picker sheets. | No SSH construction or persistence dependency. | | `ActiveSessionSwitcherView.swift` | Uses `UUID`/title/subtitle DTOs and select/disconnect callbacks. | Prevents profile/repository types from entering terminal core. | | iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | From 32399453a9cc3ac539371b130a24fb425b1e3464 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 19:09:23 +0800 Subject: [PATCH 09/22] moriremote: expand shortcut menu and terminal viewport --- CHANGELOG.md | 2 +- CHANGELOG.zh-Hans.md | 2 +- .../Resources/en.lproj/Localizable.strings | 15 ++++ .../zh-Hans.lproj/Localizable.strings | 15 ++++ .../MoriRemote/Views/RemoteRootView.swift | 86 +------------------ .../Ghostty/GhosttyKeyboardChrome.swift | 34 +++++++- .../Ghostty/GhosttyTerminalCoreView.swift | 14 +++ .../GhosttyKeyboardChromeActionsTests.swift | 25 +++++- MoriRemote/UPSTREAM.md | 2 +- 9 files changed, 104 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 164cdc0a..644cbe9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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)**: Restored remux’s compact three-group terminal bar. One-shot Ctrl/Alt, common terminal keys, and shared tmux actions now live in native menus; Sessions/Windows/Panes remain one-tap selectors, with Library and the keyboard toggle at the trailing edge. +- **iOS (MoriRemote)**: Restored remux’s compact three-group terminal bar and removed the redundant terminal header. The leading Shortcuts menu includes one-shot Ctrl/Alt plus common shell and line-editing combinations; terminal keys and shared tmux actions have dedicated menus, while Sessions/Windows/Panes remain one-tap selectors with Library and keyboard at the trailing edge. - **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session from the library instead of manually creating workspace records. ### 🐛 Bug Fixes diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 05e2e846..a8490bca 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -10,7 +10,7 @@ ### ✨ 新功能 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 -- **iOS(MoriRemote)**:恢复 remux 紧凑的三组终端栏。一次性 Ctrl/Alt、常用终端键和共享 tmux 操作收进原生菜单;Sessions/Windows/Panes 保留一击入口,资料库和键盘开关位于最右侧。 +- **iOS(MoriRemote)**:恢复 remux 紧凑的三组终端栏,并移除重复的终端顶部栏。最左侧快捷键菜单包含一次性 Ctrl/Alt、常用 shell 与行编辑组合键;终端按键和共享 tmux 操作使用独立菜单,Sessions/Windows/Panes 保留一击入口,资料库和键盘位于最右侧。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接从资料库选择,不再需要手动创建工作区记录。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index ab25ec06..fa44fba7 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -115,6 +115,21 @@ "Function Keys" = "Function Keys"; "Home key" = "Home key"; "Modifiers" = "Modifiers"; +"Shortcuts" = "Shortcuts"; +"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"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index 8f85d352..3f0aeff8 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -115,6 +115,21 @@ "Function Keys" = "功能键"; "Home key" = "Home 键"; "Modifiers" = "修饰键"; +"Shortcuts" = "快捷键"; +"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" = "新建窗口"; diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index db80edb9..82606e64 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -91,7 +91,7 @@ struct RemoteRootView: View { @ViewBuilder private var terminalDetail: some View { if let runtime = root.activeRuntime { - RemoteTerminalDetailView(root: root, runtime: runtime, compact: sizeClass == .compact, showLibrary: { sheet = .library }) + RemoteTerminalDetailView(root: root, runtime: runtime, showLibrary: { sheet = .library }) } else if sizeClass == .compact { NavigationStack { library } } else { @@ -324,15 +324,12 @@ private struct AgentMetadataBadge: View { private struct RemoteTerminalDetailView: View { let root: RemoteRootModel let runtime: ActiveWorkspaceRuntime - let compact: Bool let showLibrary: () -> Void @State private var showsSessions = false - @State private var showsPanes = false @State private var pendingSharedMutation: RemoteSharedMutation? var body: some View { VStack(spacing: 0) { - header MoriRemoteTerminalView( session: runtime.session, onShowSessions: { showsSessions = true }, @@ -344,7 +341,6 @@ private struct RemoteTerminalDetailView: View { } .background(Color.black.ignoresSafeArea()) .sheet(isPresented: $showsSessions) { sessionSwitcher } - .sheet(isPresented: $showsPanes) { panePicker } .confirmationDialog( String(localized: "Confirm shared workspace change"), isPresented: sharedMutationConfirmationBinding, @@ -361,38 +357,6 @@ private struct RemoteTerminalDetailView: View { } } - private var header: some View { - HStack(spacing: 12) { - if compact { - Button(action: showLibrary) { Image(systemName: "sidebar.left") } - .accessibilityLabel(String(localized: "Show library")) - } - Button { showsPanes = true } label: { - VStack(alignment: .leading, spacing: 1) { - Text(verbatim: runtime.workspace.name).lineLimit(1) - HStack(spacing: 6) { - Text(runtime.status.title).font(.caption).foregroundStyle(.secondary) - AgentMetadataBadge(metadata: runtime.metadata(for: runtime.focusedPaneID ?? 0)) - } - } - } - Spacer() - Menu { - Button(String(localized: "Split right (shared)")) { pendingSharedMutation = .splitHorizontal } - Button(String(localized: "Split down (shared)")) { pendingSharedMutation = .splitVertical } - Button(String(localized: "New window (shared)")) { pendingSharedMutation = .newWindow } - Button(String(localized: "Close pane (shared)"), role: .destructive) { pendingSharedMutation = .closePane } - Button(String(localized: "Close window (shared)"), role: .destructive) { pendingSharedMutation = .closeWindow } - } label: { Image(systemName: "rectangle.3.group") } - 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 sharedMutationConfirmationBinding: Binding { .init(get: { pendingSharedMutation != nil }, set: { if !$0 { pendingSharedMutation = nil } }) } @@ -424,54 +388,6 @@ private struct RemoteTerminalDetailView: View { } } - private var panePicker: some View { - NavigationStack { - List { - Section(String(localized: "Windows")) { - ForEach(runtime.topology?.windows ?? []) { window in - Button { root.selectWindow(window.id) } label: { - HStack { - Label { - Text(verbatim: window.title) - } icon: { - Image(systemName: window.active ? "rectangle.inset.filled" : "rectangle") - } - Spacer() - AgentMetadataBadge(metadata: windowMetadata(window)) - } - } - } - } - Section(String(localized: "Panes")) { - ForEach(runtime.topology?.panes ?? []) { pane in - Button { - root.selectPane(pane.id) - showsPanes = false - } label: { - HStack { - Text(verbatim: "%\(pane.id)") - .font(.body.monospaced()) - AgentMetadataBadge(metadata: runtime.metadata(for: pane.id)) - Spacer() - Text(verbatim: "\(pane.columns)×\(pane.rows)") - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } - } - } - } - } - .navigationTitle(String(localized: "Workspace controls")) - .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(String(localized: "Done")) { showsPanes = false } } } - } - } - - 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 enum RemoteSharedMutation: Identifiable, Equatable { diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift index f6fb69f0..e9dec2d6 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -55,6 +55,7 @@ struct GhosttyKeyboardChromeActions { let toggleControl: () -> Void let toggleAlt: () -> Void let requestSharedMutation: (MoriRemoteTerminalSharedMutation) -> Void + let sendShortcut: (String) -> Bool let sendKey: (GhosttySurfaceKeyEvent) -> Bool func perform(_ action: Action) -> Bool { @@ -71,6 +72,18 @@ struct GhosttyKeyboardChromeActions { 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)) @@ -93,6 +106,7 @@ struct GhosttyKeyboardChromeActions { case sessions, library, windows, panes, 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 } } @@ -132,11 +146,27 @@ struct GhosttyKeyboardChrome: View { Button { _ = actions.perform(.alt) } label: { Label("Alt", systemImage: isAltArmed ? "checkmark" : "option") } + Section("Common shortcuts") { + Button("Ctrl-C · Interrupt") { _ = actions.perform(.ctrlC) } + Button("Ctrl-D · End input") { _ = actions.perform(.ctrlD) } + Button("Ctrl-Z · Suspend") { _ = actions.perform(.ctrlZ) } + Button("Ctrl-L · Clear") { _ = actions.perform(.ctrlL) } + Button("Ctrl-R · History search") { _ = actions.perform(.ctrlR) } + } + Section("Line editing") { + Button("Ctrl-A · Line start") { _ = actions.perform(.ctrlA) } + Button("Ctrl-E · Line end") { _ = actions.perform(.ctrlE) } + Button("Ctrl-U · Delete to start") { _ = actions.perform(.ctrlU) } + Button("Ctrl-K · Delete to end") { _ = actions.perform(.ctrlK) } + Button("Ctrl-W · Delete word") { _ = actions.perform(.ctrlW) } + Button("Alt-B · Previous word") { _ = actions.perform(.altB) } + Button("Alt-F · Next word") { _ = actions.perform(.altF) } + } } label: { menuLabel("control", active: isControlArmed || isAltArmed) } - .accessibilityLabel(String(localized: "Modifiers")) - .accessibilityIdentifier("terminal.modifiers") + .accessibilityLabel(String(localized: "Shortcuts")) + .accessibilityIdentifier("terminal.shortcuts") Menu { Section { diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index 725e6845..f908189f 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -102,6 +102,7 @@ struct GhosttyTerminalCoreView: View { toggleControl: { terminalInputController.toggleControl() }, toggleAlt: { terminalInputController.toggleAlt() }, requestSharedMutation: onSharedMutationRequest, + sendShortcut: sendTerminalShortcut, sendKey: sendTerminalKey ) ) @@ -239,6 +240,19 @@ struct GhosttyTerminalCoreView: View { terminalInputController.clearModifiers() } + private func sendTerminalShortcut(_ text: String) -> Bool { + // 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 { terminalInputController.performPaste( text, diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift index 074a0523..dfbead21 100644 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift @@ -48,6 +48,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { case .closeWindow: calls.append("close-window") } }, + sendShortcut: { value in calls.append(value); return true }, sendKey: { _ in false } ) @@ -69,13 +70,35 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { ]) } + func testCommonShortcutsSendExactTerminalSequences() { + var sequences: [String] = [] + let actions = GhosttyKeyboardChromeActions( + showSessions: {}, showLibrary: {}, showWindows: {}, showPanes: {}, + 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( showSessions: {}, showLibrary: {}, showWindows: {}, showPanes: {}, toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, - requestSharedMutation: { _ in }, sendKey: sendKey + requestSharedMutation: { _ in }, sendShortcut: { _ in false }, sendKey: sendKey ) } } diff --git a/MoriRemote/UPSTREAM.md b/MoriRemote/UPSTREAM.md index 778b9ba3..c027977f 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -60,7 +60,7 @@ transport remains solely a terminal-core test fixture. | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | -| `GhosttyKeyboardChrome.swift` | Restores remux's compact three-group dock, keeps Sessions/Windows/Panes and trailing keyboard placement, and folds one-shot modifiers, common keys, and Mori shared tmux actions into native menus. Composer and shortcut-store actions remain excluded. | Native menus preserve remux density while essential Mori terminal operations stay reachable without importing deferred product domains. | +| `GhosttyKeyboardChrome.swift` | Restores remux's compact three-group dock, keeps Sessions/Windows/Panes and trailing keyboard placement, and folds one-shot modifiers, common shell/line-editing shortcuts, terminal keys, and Mori shared tmux actions into native menus. Composer and shortcut-store actions remain excluded. | Native menus preserve remux density while essential Mori terminal operations stay reachable without importing deferred product domains. | | `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, chrome, and picker sheets. | No SSH construction or persistence dependency. | | `ActiveSessionSwitcherView.swift` | Uses `UUID`/title/subtitle DTOs and select/disconnect callbacks. | Prevents profile/repository types from entering terminal core. | | iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | From e1f503f1904b9f98a3ebc3525f40c39d72aa8732 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 19:22:00 +0800 Subject: [PATCH 10/22] moriremote: switch among host tmux sessions --- CHANGELOG.md | 2 +- CHANGELOG.zh-Hans.md | 2 +- .../Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../MoriRemote/Views/RemoteRootView.swift | 17 ++++++++++---- .../App/ActiveSessionSwitcherView.swift | 23 +++++++++++++++---- ...ActiveSessionSwitcherProjectionTests.swift | 6 +++++ 7 files changed, 42 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 644cbe9d..9f48f325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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)**: Restored remux’s compact three-group terminal bar and removed the redundant terminal header. The leading Shortcuts menu includes one-shot Ctrl/Alt plus common shell and line-editing combinations; terminal keys and shared tmux actions have dedicated menus, while Sessions/Windows/Panes remain one-tap selectors with Library and keyboard at the trailing edge. -- **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session from the library instead of manually creating workspace records. +- **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 diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index a8490bca..089c91dd 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -11,7 +11,7 @@ - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 - **iOS(MoriRemote)**:恢复 remux 紧凑的三组终端栏,并移除重复的终端顶部栏。最左侧快捷键菜单包含一次性 Ctrl/Alt、常用 shell 与行编辑组合键;终端按键和共享 tmux 操作使用独立菜单,Sessions/Windows/Panes 保留一击入口,资料库和键盘位于最右侧。 -- **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接从资料库选择,不再需要手动创建工作区记录。 +- **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index fa44fba7..f86915c8 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -230,6 +230,7 @@ "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 3f0aeff8..f31300f4 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -230,6 +230,7 @@ "Windows" = "窗口"; "Panes" = "面板"; "Active workspaces" = "活动工作区"; +"Sessions on %@" = "%@ 上的会话"; "Workspace controls" = "工作区控制"; "Server" = "服务器"; "Name" = "名称"; diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 82606e64..782ac4d4 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -332,7 +332,10 @@ private struct RemoteTerminalDetailView: View { VStack(spacing: 0) { MoriRemoteTerminalView( session: runtime.session, - onShowSessions: { showsSessions = true }, + onShowSessions: { + root.discoverSessions(serverID: runtime.workspace.serverID) + showsSessions = true + }, onShowLibrary: showLibrary, onSharedMutationRequest: { pendingSharedMutation = RemoteSharedMutation($0) } ) @@ -364,22 +367,24 @@ private struct RemoteTerminalDetailView: View { private var sessionSwitcher: some View { NavigationStack { ActiveSessionSwitcherView( - sessions: root.activeWorkspaces.map { workspace in + sessions: root.visibleWorkspaces(for: runtime.workspace.serverID).map { workspace in let activeRuntime = root.runtimes[workspace.id] return ActiveSessionSwitcherItem( id: workspace.id, - sessionName: workspace.name, + sessionName: workspace.tmuxSession, subtitle: activeRuntime?.status.title ?? String(localized: "Disconnected"), isSelected: workspace.id == root.activeWorkspaceID, + isConnected: activeRuntime != nil, lastOpenedAt: workspace.lastConnectedAt ?? .distantPast ) }, + isRefreshing: root.sessionDiscovery[runtime.workspace.serverID] == .loading, onSelectSession: { root.connect(workspaceID: $0) }, onDisconnectSession: { workspaceID in Task { await root.disconnect(workspaceID: workspaceID) } } ) - .navigationTitle(String(localized: "Active workspaces")) + .navigationTitle(sessionSwitcherTitle) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(String(localized: "Done")) { showsSessions = false } @@ -388,6 +393,10 @@ private struct RemoteTerminalDetailView: View { } } + private var sessionSwitcherTitle: String { + let serverName = root.servers.first(where: { $0.id == runtime.workspace.serverID })?.name ?? runtime.workspace.name + return String(format: String(localized: "Sessions on %@"), serverName) + } } private enum RemoteSharedMutation: Identifiable, Equatable { diff --git a/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift b/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift index cab2d11f..a6e85256 100644 --- a/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift +++ b/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift @@ -7,13 +7,15 @@ public struct ActiveSessionSwitcherItem: Identifiable, Equatable { public let sessionName: String public let subtitle: String public let isSelected: Bool + public let isConnected: Bool public let lastOpenedAt: Date - public init(id: UUID, sessionName: String, subtitle: String, isSelected: Bool, lastOpenedAt: Date) { + public init(id: UUID, sessionName: String, subtitle: String, isSelected: Bool, isConnected: Bool = false, lastOpenedAt: Date) { self.id = id self.sessionName = sessionName self.subtitle = subtitle self.isSelected = isSelected + self.isConnected = isConnected self.lastOpenedAt = lastOpenedAt } } @@ -22,7 +24,8 @@ enum ActiveSessionSwitcherProjection { static func items(_ sessions: [ActiveSessionSwitcherItem]) -> [ActiveSessionSwitcherItem] { sessions.sorted { if $0.isSelected != $1.isSelected { return $0.isSelected } - return $0.lastOpenedAt > $1.lastOpenedAt + if $0.lastOpenedAt != $1.lastOpenedAt { return $0.lastOpenedAt > $1.lastOpenedAt } + return $0.sessionName.localizedStandardCompare($1.sessionName) == .orderedAscending } } } @@ -30,15 +33,18 @@ enum ActiveSessionSwitcherProjection { public struct ActiveSessionSwitcherView: View { @Environment(\.dismiss) private var dismiss let sessions: [ActiveSessionSwitcherItem] + let isRefreshing: Bool let onSelectSession: (UUID) -> Void let onDisconnectSession: (UUID) -> Void public init( sessions: [ActiveSessionSwitcherItem], + isRefreshing: Bool = false, onSelectSession: @escaping (UUID) -> Void, onDisconnectSession: @escaping (UUID) -> Void ) { self.sessions = sessions + self.isRefreshing = isRefreshing self.onSelectSession = onSelectSession self.onDisconnectSession = onDisconnectSession } @@ -55,10 +61,19 @@ public struct ActiveSessionSwitcherView: View { } } .swipeActions { - Button(role: .destructive) { onDisconnectSession(session.id) } label: { - Label(String(localized: "Disconnect"), systemImage: "bolt.slash") + if session.isConnected { + Button(role: .destructive) { onDisconnectSession(session.id) } label: { + Label(String(localized: "Disconnect"), systemImage: "bolt.slash") + } } } } + .overlay { + if sessions.isEmpty, isRefreshing { + ProgressView(String(localized: "Loading sessions…")) + } else if sessions.isEmpty { + ContentUnavailableView(String(localized: "No tmux sessions"), systemImage: "terminal") + } + } } } diff --git a/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift b/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift index ba9fd8f8..1d2c669b 100644 --- a/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift @@ -14,6 +14,12 @@ final class ActiveSessionSwitcherProjectionTests: XCTestCase { ) } + func testItemsSortUnopenedHostSessionsByName() { + let zeta = item(name: "zeta", selected: false, opened: 0) + let alpha = item(name: "alpha", selected: false, opened: 0) + XCTAssertEqual(ActiveSessionSwitcherProjection.items([zeta, alpha]).map(\.sessionName), ["alpha", "zeta"]) + } + private func item(name: String, selected: Bool, opened: TimeInterval) -> ActiveSessionSwitcherItem { .init( id: UUID(), From 361a26eee7234ce9208610b8cb41e1389b9902ad Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 19:44:37 +0800 Subject: [PATCH 11/22] moriremote: unify terminal navigation and keypad --- CHANGELOG.md | 2 +- CHANGELOG.zh-Hans.md | 2 +- .../MoriRemote.xcodeproj/project.pbxproj | 12 +- .../MoriRemote/App/RemoteRootModel.swift | 21 ++ .../Resources/en.lproj/Localizable.strings | 21 ++ .../zh-Hans.lproj/Localizable.strings | 21 ++ .../MoriRemote/Views/RemoteRootView.swift | 202 +++++++++++++++--- .../App/ActiveSessionSwitcherView.swift | 79 ------- .../App/MoriRemoteTerminalFacade.swift | 18 +- .../Ghostty/GhosttyKeyboardChrome.swift | 117 ++++------ .../Ghostty/GhosttyKeypadSheet.swift | 160 ++++++++++++++ .../Ghostty/GhosttyTerminalCoreView.swift | 59 +---- ...ActiveSessionSwitcherProjectionTests.swift | 32 --- .../GhosttyKeyboardChromeActionsTests.swift | 16 +- .../MoriRemoteTests/Phase4ShellTests.swift | 23 ++ MoriRemote/UPSTREAM.md | 18 +- 16 files changed, 490 insertions(+), 313 deletions(-) delete mode 100644 MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift delete mode 100644 MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f48f325..febcbdc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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)**: Restored remux’s compact three-group terminal bar and removed the redundant terminal header. The leading Shortcuts menu includes one-shot Ctrl/Alt plus common shell and line-editing combinations; terminal keys and shared tmux actions have dedicated menus, while Sessions/Windows/Panes remain one-tap selectors with Library and keyboard at the trailing edge. +- **iOS (MoriRemote)**: Reworked the compact terminal bar around four non-overlapping controls and removed the redundant header. Keypad combines one-shot modifiers, terminal keys, and categorized shell/line-editing shortcuts; tmux owns shared mutations; a searchable Navigator unifies Sessions/Windows/Panes and server access; Keyboard remains trailing. - **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 diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 089c91dd..27a0d0d6 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -10,7 +10,7 @@ ### ✨ 新功能 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 -- **iOS(MoriRemote)**:恢复 remux 紧凑的三组终端栏,并移除重复的终端顶部栏。最左侧快捷键菜单包含一次性 Ctrl/Alt、常用 shell 与行编辑组合键;终端按键和共享 tmux 操作使用独立菜单,Sessions/Windows/Panes 保留一击入口,资料库和键盘位于最右侧。 +- **iOS(MoriRemote)**:将紧凑终端栏重构为四个互不重叠的入口,并移除重复顶部栏。Keypad 合并一次性修饰键、终端按键和分类的 shell/行编辑快捷键;tmux 负责共享变更;可搜索 Navigator 统一 Sessions/Windows/Panes 与服务器入口;键盘保持在最右侧。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index df6b8e1c..ce73aa39 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 09212452679FFE001555BFCB /* GhosttyTerminalScreenModeling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */; }; 0A3E5C8D6D19481EDBA77835 /* GhosttyKitControlSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */; }; 0D9936CC7BE5A981314D56F0 /* GhosttyTopLevelSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.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 */; }; @@ -79,7 +80,6 @@ 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 */; }; - 9A408E6D89845AF1D2306836 /* ActiveSessionSwitcherProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB11F550ECE58BA0965DFC7 /* ActiveSessionSwitcherProjectionTests.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 */; }; @@ -115,7 +115,6 @@ 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 */; }; - FCE41412A438AF5DEC891B2E /* ActiveSessionSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36EE2B381CC5804E01C1CD73 /* ActiveSessionSwitcherView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -155,6 +154,7 @@ 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; }; 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxActionTargetResolver.swift; sourceTree = ""; }; + 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 = ""; }; @@ -165,7 +165,6 @@ 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 = ""; }; - 36EE2B381CC5804E01C1CD73 /* ActiveSessionSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherView.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 = ""; }; @@ -188,7 +187,6 @@ 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 = ""; }; - 6CB11F550ECE58BA0965DFC7 /* ActiveSessionSwitcherProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherProjectionTests.swift; sourceTree = ""; }; 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurface.swift; sourceTree = ""; }; 71772BE1F5108FFC309BCC46 /* GhosttyPanePreviewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPanePreviewSession.swift; sourceTree = ""; }; 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; @@ -346,7 +344,6 @@ 5C295A7834704597EC4619C0 /* MoriRemoteTerminalTests */ = { isa = PBXGroup; children = ( - 6CB11F550ECE58BA0965DFC7 /* ActiveSessionSwitcherProjectionTests.swift */, 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */, BE2FE3FEDF880BE280656A2D /* GhosttyKeyboardChromeModeTests.swift */, EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */, @@ -460,6 +457,7 @@ D5A15305D227B6DD4A6C8CBE /* GhosttyKeyboardCursorTrackpad.swift */, 1839CAA9C61F3104CB8231C6 /* GhosttyKeyboardCursorTrackpadHUD.swift */, C010669BD6677489C74BEEFA /* GhosttyKeyboardVisibilityProjection.swift */, + 13347B90704FC1FF2D28C63A /* GhosttyKeypadSheet.swift */, 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */, 0F28E32D6C407ED02A977516 /* GhosttyKitRuntime.swift */, 20D69DE7D59B2C5BA8D15339 /* GhosttyManagedSurface.swift */, @@ -527,7 +525,6 @@ FEF282EBB6D60331E0FEA772 /* App */ = { isa = PBXGroup; children = ( - 36EE2B381CC5804E01C1CD73 /* ActiveSessionSwitcherView.swift */, F7595020B1AEF0AE384FF639 /* Haptic.swift */, FCE453EA5ED8C3C00E2EFF00 /* MoriRemoteTerminalFacade.swift */, D80AB026D4A388B7B0DCEBD4 /* MoriRemoteTerminalProbe.swift */, @@ -701,13 +698,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FCE41412A438AF5DEC891B2E /* ActiveSessionSwitcherView.swift in Sources */, 055D7CE8194A8EB00C6AB48F /* DeterministicTmuxControlTransport.swift in Sources */, EA704DB81EBCED68696937A6 /* GhosttyIOSurfaceFrame.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 */, @@ -798,7 +795,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9A408E6D89845AF1D2306836 /* ActiveSessionSwitcherProjectionTests.swift in Sources */, 656BC1E3C7AC26BC7C6FC1C6 /* GhosttyKeyboardChromeActionsTests.swift in Sources */, 3A2DB1541BA69E76A0E29F66 /* GhosttyKeyboardChromeModeTests.swift in Sources */, A17B820D2070516F1E059C96 /* GhosttyKeyboardVisibilityProjectionTests.swift in Sources */, diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index fba18552..cb185e28 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -72,6 +72,27 @@ struct ServerWorkspaceDraft: Identifiable, Sendable { } } +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 diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index f86915c8..a21f55e7 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -116,6 +116,27 @@ "Home key" = "Home key"; "Modifiers" = "Modifiers"; "Shortcuts" = "Shortcuts"; +"Keypad" = "Keypad"; +"Navigator" = "Navigator"; +"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"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index f31300f4..0111ed25 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -116,6 +116,27 @@ "Home key" = "Home 键"; "Modifiers" = "修饰键"; "Shortcuts" = "快捷键"; +"Keypad" = "按键面板"; +"Navigator" = "导航器"; +"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 · 中断"; diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 782ac4d4..59aca69d 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -325,25 +325,34 @@ private struct RemoteTerminalDetailView: View { let root: RemoteRootModel let runtime: ActiveWorkspaceRuntime let showLibrary: () -> Void - @State private var showsSessions = false + @State private var showsNavigator = false @State private var pendingSharedMutation: RemoteSharedMutation? var body: some View { VStack(spacing: 0) { MoriRemoteTerminalView( session: runtime.session, - onShowSessions: { + onShowNavigator: { root.discoverSessions(serverID: runtime.workspace.serverID) - showsSessions = true + showsNavigator = true }, - onShowLibrary: showLibrary, onSharedMutationRequest: { pendingSharedMutation = RemoteSharedMutation($0) } ) .id(WorkspaceTerminalPresentation.identity(for: runtime.session.instanceID)) .background(Color.black) } .background(Color.black.ignoresSafeArea()) - .sheet(isPresented: $showsSessions) { sessionSwitcher } + .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, @@ -364,38 +373,173 @@ private struct RemoteTerminalDetailView: View { .init(get: { pendingSharedMutation != nil }, set: { if !$0 { pendingSharedMutation = nil } }) } - private var sessionSwitcher: some View { +} + +@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") + } + } + } + + @Environment(\.dismiss) private var dismiss + let root: RemoteRootModel + let runtime: ActiveWorkspaceRuntime + let showLibrary: () -> Void + @State private var scope = Scope.sessions + @State private var filter = "" + + var body: some View { NavigationStack { - ActiveSessionSwitcherView( - sessions: root.visibleWorkspaces(for: runtime.workspace.serverID).map { workspace in - let activeRuntime = root.runtimes[workspace.id] - return ActiveSessionSwitcherItem( - id: workspace.id, - sessionName: workspace.tmuxSession, - subtitle: activeRuntime?.status.title ?? String(localized: "Disconnected"), - isSelected: workspace.id == root.activeWorkspaceID, - isConnected: activeRuntime != nil, - lastOpenedAt: workspace.lastConnectedAt ?? .distantPast - ) - }, - isRefreshing: root.sessionDiscovery[runtime.workspace.serverID] == .loading, - onSelectSession: { root.connect(workspaceID: $0) }, - onDisconnectSession: { workspaceID in - Task { await root.disconnect(workspaceID: workspaceID) } + VStack(spacing: 0) { + Picker(String(localized: "Navigator"), selection: $scope) { + ForEach(Scope.allCases) { Text($0.title).tag($0) } } - ) - .navigationTitle(sessionSwitcherTitle) + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.vertical, 8) + + List { content } + .listStyle(.plain) + .overlay { emptyState } + } + .navigationTitle(serverName) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $filter, prompt: String(localized: "Filter sessions, windows, and panes")) .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "Done")) { showsSessions = false } + ToolbarItem(placement: .topBarLeading) { + Button(String(localized: "Servers"), systemImage: "server.rack", action: showLibrary) + } + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + root.discoverSessions(serverID: runtime.workspace.serverID) + } label: { Image(systemName: "arrow.clockwise") } + .accessibilityLabel(String(localized: "Refresh sessions")) + Button(String(localized: "Done")) { dismiss() } + } + } + } + .onChange(of: scope) { _, _ in filter = "" } + } + + @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") } + } + } + } + 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") } + } } } } } - private var sessionSwitcherTitle: String { - let serverName = root.servers.first(where: { $0.id == runtime.workspace.serverID })?.name ?? runtime.workspace.name - return String(format: String(localized: "Sessions on %@"), serverName) + @ViewBuilder private var emptyState: some View { + if scope == .sessions, root.sessionDiscovery[runtime.workspace.serverID] == .loading, filteredSessions.isEmpty { + ProgressView(String(localized: "Loading sessions…")) + } else if visibleItemCount == 0 { + ContentUnavailableView(emptyTitle, systemImage: "magnifyingglass") + } + } + + private var emptyTitle: String { + filter.isEmpty ? String(localized: "Nothing here") : String(localized: "No matching results") + } + private var serverName: String { + root.servers.first(where: { $0.id == runtime.workspace.serverID })?.name ?? runtime.workspace.name + } + private var filteredSessions: [SavedWorkspace] { + RemoteNavigatorProjection.sessions(root.visibleWorkspaces(for: runtime.workspace.serverID), matching: filter) + } + private var filteredWindows: [MoriRemoteTerminalWindow] { + RemoteNavigatorProjection.windows(runtime.topology?.windows ?? [], matching: filter) + } + private var filteredPanes: [MoriRemoteTerminalPane] { + 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 } } diff --git a/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift b/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift deleted file mode 100644 index a6e85256..00000000 --- a/MoriRemote/MoriRemoteTerminal/App/ActiveSessionSwitcherView.swift +++ /dev/null @@ -1,79 +0,0 @@ -import SwiftUI - -/// Account-free projection used by the terminal shell. Mori's profile model is -/// intentionally adapted at the Phase-2 composition boundary, not imported. -public struct ActiveSessionSwitcherItem: Identifiable, Equatable { - public let id: UUID - public let sessionName: String - public let subtitle: String - public let isSelected: Bool - public let isConnected: Bool - public let lastOpenedAt: Date - - public init(id: UUID, sessionName: String, subtitle: String, isSelected: Bool, isConnected: Bool = false, lastOpenedAt: Date) { - self.id = id - self.sessionName = sessionName - self.subtitle = subtitle - self.isSelected = isSelected - self.isConnected = isConnected - self.lastOpenedAt = lastOpenedAt - } -} - -enum ActiveSessionSwitcherProjection { - static func items(_ sessions: [ActiveSessionSwitcherItem]) -> [ActiveSessionSwitcherItem] { - sessions.sorted { - if $0.isSelected != $1.isSelected { return $0.isSelected } - if $0.lastOpenedAt != $1.lastOpenedAt { return $0.lastOpenedAt > $1.lastOpenedAt } - return $0.sessionName.localizedStandardCompare($1.sessionName) == .orderedAscending - } - } -} - -public struct ActiveSessionSwitcherView: View { - @Environment(\.dismiss) private var dismiss - let sessions: [ActiveSessionSwitcherItem] - let isRefreshing: Bool - let onSelectSession: (UUID) -> Void - let onDisconnectSession: (UUID) -> Void - - public init( - sessions: [ActiveSessionSwitcherItem], - isRefreshing: Bool = false, - onSelectSession: @escaping (UUID) -> Void, - onDisconnectSession: @escaping (UUID) -> Void - ) { - self.sessions = sessions - self.isRefreshing = isRefreshing - self.onSelectSession = onSelectSession - self.onDisconnectSession = onDisconnectSession - } - - public var body: some View { - List(ActiveSessionSwitcherProjection.items(sessions)) { session in - Button { - onSelectSession(session.id) - dismiss() - } label: { - VStack(alignment: .leading) { - Text(session.sessionName) - Text(session.subtitle).font(.footnote).foregroundStyle(.secondary) - } - } - .swipeActions { - if session.isConnected { - Button(role: .destructive) { onDisconnectSession(session.id) } label: { - Label(String(localized: "Disconnect"), systemImage: "bolt.slash") - } - } - } - } - .overlay { - if sessions.isEmpty, isRefreshing { - ProgressView(String(localized: "Loading sessions…")) - } else if sessions.isEmpty { - ContentUnavailableView(String(localized: "No tmux sessions"), systemImage: "terminal") - } - } - } -} diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift index aae011f6..89fa0788 100644 --- a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -53,6 +53,9 @@ public struct MoriRemoteTerminalPane: Identifiable, Equatable, Sendable { 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 { @@ -60,6 +63,9 @@ public struct MoriRemoteTerminalWindow: Identifiable, Equatable, Sendable { 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 { @@ -193,27 +199,23 @@ public final class MoriRemoteTerminalSession: ObservableObject { public struct MoriRemoteTerminalView: View { @ObservedObject private var session: MoriRemoteTerminalSession - private let onShowSessions: () -> Void - private let onShowLibrary: () -> Void + private let onShowNavigator: () -> Void private let onSharedMutationRequest: (MoriRemoteTerminalSharedMutation) -> Void public init( session: MoriRemoteTerminalSession, - onShowSessions: @escaping () -> Void = {}, - onShowLibrary: @escaping () -> Void = {}, + onShowNavigator: @escaping () -> Void = {}, onSharedMutationRequest: @escaping (MoriRemoteTerminalSharedMutation) -> Void = { _ in } ) { self.session = session - self.onShowSessions = onShowSessions - self.onShowLibrary = onShowLibrary + self.onShowNavigator = onShowNavigator self.onSharedMutationRequest = onSharedMutationRequest } public var body: some View { GhosttyTerminalCoreView( screen: session.screen.screenAdapter, - onShowSessions: onShowSessions, - onShowLibrary: onShowLibrary, + onShowNavigator: onShowNavigator, onSharedMutationRequest: onSharedMutationRequest ) } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift index e9dec2d6..59ac32e3 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -47,10 +47,7 @@ enum GhosttyPhoneChromePalette { static let dock = Color.black } /// categories and shared-mutation requests without importing account, /// shortcut-store, or composer dependencies into the terminal module. struct GhosttyKeyboardChromeActions { - let showSessions: () -> Void - let showLibrary: () -> Void - let showWindows: () -> Void - let showPanes: () -> Void + let showNavigator: () -> Void let toggleKeyboard: () -> Void let toggleControl: () -> Void let toggleAlt: () -> Void @@ -60,10 +57,7 @@ struct GhosttyKeyboardChromeActions { func perform(_ action: Action) -> Bool { switch action { - case .sessions: showSessions(); return true - case .library: showLibrary(); return true - case .windows: showWindows(); return true - case .panes: showPanes(); return true + case .navigator: showNavigator(); return true case .keyboard: toggleKeyboard(); return true case .control: toggleControl(); return true case .alt: toggleAlt(); return true @@ -103,7 +97,7 @@ struct GhosttyKeyboardChromeActions { } enum Action { - case sessions, library, windows, panes, keyboard, control, alt + 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 @@ -116,85 +110,43 @@ struct GhosttyKeyboardChromeActions { /// its location stable while avoiding a horizontally scrolling toolbar. struct GhosttyKeyboardChrome: View { @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + @State private var showsKeypad = false let keyboardMode: GhosttyKeyboardChromeMode let isEnabled: Bool let isCompact: Bool let isControlArmed: Bool let isAltArmed: Bool - let windowCount: Int - let paneCount: Int let actions: GhosttyKeyboardChromeActions var body: some View { HStack(spacing: isCompact ? 6 : 10) { controlGroup { menuControls } - controlGroup { navigationControls } - controlGroup { inputControls } + controlGroup { navigatorControl } + controlGroup { keyboardControl } } .frame(maxWidth: .infinity, alignment: .center) .fixedSize(horizontal: false, vertical: true) .accessibilityElement(children: .contain) + .sheet(isPresented: $showsKeypad) { + GhosttyKeypadSheet( + isControlArmed: isControlArmed, + isAltArmed: isAltArmed, + actions: actions + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } } private var menuControls: some View { HStack(spacing: isCompact ? 1 : 2) { - Menu { - Button { _ = actions.perform(.control) } label: { - Label("Ctrl", systemImage: isControlArmed ? "checkmark" : "control") - } - Button { _ = actions.perform(.alt) } label: { - Label("Alt", systemImage: isAltArmed ? "checkmark" : "option") - } - Section("Common shortcuts") { - Button("Ctrl-C · Interrupt") { _ = actions.perform(.ctrlC) } - Button("Ctrl-D · End input") { _ = actions.perform(.ctrlD) } - Button("Ctrl-Z · Suspend") { _ = actions.perform(.ctrlZ) } - Button("Ctrl-L · Clear") { _ = actions.perform(.ctrlL) } - Button("Ctrl-R · History search") { _ = actions.perform(.ctrlR) } - } - Section("Line editing") { - Button("Ctrl-A · Line start") { _ = actions.perform(.ctrlA) } - Button("Ctrl-E · Line end") { _ = actions.perform(.ctrlE) } - Button("Ctrl-U · Delete to start") { _ = actions.perform(.ctrlU) } - Button("Ctrl-K · Delete to end") { _ = actions.perform(.ctrlK) } - Button("Ctrl-W · Delete word") { _ = actions.perform(.ctrlW) } - Button("Alt-B · Previous word") { _ = actions.perform(.altB) } - Button("Alt-F · Next word") { _ = actions.perform(.altF) } - } - } label: { - menuLabel("control", active: isControlArmed || isAltArmed) + Button { showsKeypad = true } label: { + menuLabel("keyboard.badge.ellipsis", active: isControlArmed || isAltArmed) } - .accessibilityLabel(String(localized: "Shortcuts")) - .accessibilityIdentifier("terminal.shortcuts") - - Menu { - Section { - Button("Esc") { _ = actions.perform(.escape) } - Button("Tab") { _ = actions.perform(.tab) } - Button("Shift-Tab") { _ = actions.perform(.shiftTab) } - } - Section { - Button("← Left") { _ = actions.perform(.arrowLeft) } - Button("↑ Up") { _ = actions.perform(.arrowUp) } - Button("↓ Down") { _ = actions.perform(.arrowDown) } - Button("→ Right") { _ = actions.perform(.arrowRight) } - } - Section { - Button("Home") { _ = actions.perform(.home) } - Button("End") { _ = actions.perform(.end) } - Button("Page Up") { _ = actions.perform(.pageUp) } - Button("Page Down") { _ = actions.perform(.pageDown) } - } - Section { - Button("?") { _ = actions.perform(.questionMark) } - Button("/") { _ = actions.perform(.slash) } - } - } label: { - menuLabel("command") - } - .accessibilityLabel(String(localized: "Terminal keys")) - .accessibilityIdentifier("terminal.keys") + .accessibilityLabel(String(localized: "Keypad")) + .accessibilityIdentifier("terminal.keypad") + .disabled(!isEnabled) Menu { Section { @@ -211,23 +163,30 @@ struct GhosttyKeyboardChrome: View { } .accessibilityLabel(String(localized: "tmux actions")) .accessibilityIdentifier("terminal.tmux-actions") + .disabled(!isEnabled) } - .disabled(!isEnabled) } - private var navigationControls: some View { - HStack(spacing: isCompact ? 1 : 2) { - icon("rectangle.stack", id: "terminal.sessions", label: String(localized: "Sessions")) { actions.perform(.sessions) } - icon("rectangle.on.rectangle", id: "terminal.windows", label: String(localized: "Windows"), enabled: windowCount > 0) { actions.perform(.windows) } - icon("square.split.2x1", id: "terminal.panes", label: String(localized: "Panes"), enabled: paneCount > 0) { actions.perform(.panes) } + private var navigatorControl: some View { + Button { _ = actions.perform(.navigator) } label: { + HStack(spacing: 6) { + Image(systemName: "rectangle.stack") + Text(String(localized: "Navigator")) + .font(.system(size: 12, weight: .semibold)) + } } + .buttonStyle(ChromeButtonStyle(active: false, width: isCompact ? 100 : 116)) + .accessibilityIdentifier("terminal.navigator") } - private var inputControls: some View { - HStack(spacing: isCompact ? 1 : 2) { - icon("house", id: "terminal.home", label: String(localized: "Library"), enabled: true) { actions.perform(.library) } - icon("keyboard", id: "terminal.keyboard", label: keyboardMode == .hidden ? String(localized: "Show keyboard") : String(localized: "Hide keyboard"), enabled: true, active: keyboardMode == .system) { actions.perform(.keyboard) } - } + private var keyboardControl: some View { + icon( + "keyboard", + id: "terminal.keyboard", + label: keyboardMode == .hidden ? String(localized: "Show keyboard") : String(localized: "Hide keyboard"), + enabled: true, + active: keyboardMode == .system + ) { actions.perform(.keyboard) } } private func menuLabel(_ systemName: String, active: Bool = false) -> some View { diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift new file mode 100644 index 00000000..d59473ad --- /dev/null +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift @@ -0,0 +1,160 @@ +import SwiftUI + +struct GhosttyKeypadSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + + let isControlArmed: Bool + let isAltArmed: Bool + 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 + 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/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index f908189f..1778dca6 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -11,27 +11,23 @@ import UIKit struct GhosttyTerminalCoreView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @ObservedObject private var screen: TmuxTerminalScreenAdapter - private let onShowSessions: () -> Void - private let onShowLibrary: () -> Void + private let onShowNavigator: () -> Void private let onSharedMutationRequest: (MoriRemoteTerminalSharedMutation) -> Void @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 selectionSheet: GhosttySurfaceSelectionSheet? @State private var compositionState = GhosttyTerminalCompositionState() @State private var prefixFlushTask: Task? @State private var sessionGeneration: UInt64 = 0 init( screen: TmuxTerminalScreenAdapter, - onShowSessions: @escaping () -> Void = {}, - onShowLibrary: @escaping () -> Void = {}, + onShowNavigator: @escaping () -> Void = {}, onSharedMutationRequest: @escaping (MoriRemoteTerminalSharedMutation) -> Void = { _ in } ) { self.screen = screen - self.onShowSessions = onShowSessions - self.onShowLibrary = onShowLibrary + self.onShowNavigator = onShowNavigator self.onSharedMutationRequest = onSharedMutationRequest } @@ -91,13 +87,8 @@ struct GhosttyTerminalCoreView: View { isCompact: horizontalSizeClass == .compact, isControlArmed: terminalInputController.isControlArmed, isAltArmed: terminalInputController.isAltArmed, - windowCount: interaction.windowCount, - paneCount: interaction.paneCount, actions: .init( - showSessions: onShowSessions, - showLibrary: onShowLibrary, - showWindows: showWindows, - showPanes: showPanes, + showNavigator: onShowNavigator, toggleKeyboard: toggleKeyboard, toggleControl: { terminalInputController.toggleControl() }, toggleAlt: { terminalInputController.toggleAlt() }, @@ -124,28 +115,6 @@ struct GhosttyTerminalCoreView: View { // reach a replacement surface. if oldState != newState { cancelTransientInput() } } - .sheet(item: $selectionSheet) { sheet in - switch sheet { - case .windows(let previews): - GhosttyWindowSelectionSheet( - session: previews, - projection: screen.windowSelectionSheetRenderProjection(), - sessionName: "tmux", - onCreateWindow: nil, - onSelect: { _ = screen.focusTmuxTopLevel($0) }, - onRemoveWindow: { _ in } - ) - case .panes(let topLevelID, let previews): - GhosttyPaneSelectionSheet( - session: previews, - projection: screen.paneSelectionSheetRenderProjection(topLevelID: topLevelID), - onSplitPane: nil, - onStackPane: nil, - onSelect: { _ = screen.focusTmuxPane($0) }, - onRemovePane: { _ in } - ) - } - } } private func toggleKeyboard() { @@ -196,7 +165,7 @@ struct GhosttyTerminalCoreView: View { keyboardMode: compositionState.inputCoordinator.keyboardMode, isDismissSystemKeyboardRequested: compositionState.inputCoordinator.isDismissSystemKeyboardRequested, isInputAvailable: screen.terminalInteractionProjection.isInputAvailable, - isSelectionSheetPresented: selectionSheet != nil, + isSelectionSheetPresented: false, isAwaitingSystemKeyboardPresentation: compositionState.keyboardTransitionCoordinator.isAwaitingSystemKeyboardPresentation, isSceneActive: true ) @@ -269,22 +238,4 @@ struct GhosttyTerminalCoreView: View { ) } - private func showWindows() { - guard let projection = screen.windowSheetPresentationProjection() else { return } - selectionSheet = .windows(screen.makePanePreviewSession( - leafIDs: projection.previewLeafIDs, - previewSizing: .windowGridForCurrentScreen - )) - } - - private func showPanes() { - guard let projection = screen.selectedPaneSheetPresentationProjection() else { return } - selectionSheet = .panes( - topLevelID: projection.topLevelID, - previews: screen.makePanePreviewSession( - leafIDs: projection.previewLeafIDs, - previewSizing: .paneGridForCurrentScreen - ) - ) - } } diff --git a/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift b/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift deleted file mode 100644 index 1d2c669b..00000000 --- a/MoriRemote/MoriRemoteTerminalTests/ActiveSessionSwitcherProjectionTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import XCTest -@testable import MoriRemoteTerminal - -final class ActiveSessionSwitcherProjectionTests: XCTestCase { - func testItemsPlaceSelectedSessionBeforeRecencyOrder() { - let selected = item(name: "codex", selected: true, opened: 100) - let recent = item(name: "api", selected: false, opened: 200) - let older = item(name: "web", selected: false, opened: 50) - - XCTAssertEqual( - ActiveSessionSwitcherProjection.items([recent, older, selected]).map(\.id), - [selected.id, recent.id, older.id] - ) - } - - func testItemsSortUnopenedHostSessionsByName() { - let zeta = item(name: "zeta", selected: false, opened: 0) - let alpha = item(name: "alpha", selected: false, opened: 0) - XCTAssertEqual(ActiveSessionSwitcherProjection.items([zeta, alpha]).map(\.sessionName), ["alpha", "zeta"]) - } - - private func item(name: String, selected: Bool, opened: TimeInterval) -> ActiveSessionSwitcherItem { - .init( - id: UUID(), - sessionName: name, - subtitle: "Mori", - isSelected: selected, - lastOpenedAt: Date(timeIntervalSince1970: opened) - ) - } -} diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift index dfbead21..068b468e 100644 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyKeyboardChromeActionsTests.swift @@ -32,10 +32,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { func testSelectorsAndModifiersInvokeTheirRetainedActions() { var calls: [String] = [] let actions = GhosttyKeyboardChromeActions( - showSessions: { calls.append("sessions") }, - showLibrary: { calls.append("library") }, - showWindows: { calls.append("windows") }, - showPanes: { calls.append("panes") }, + showNavigator: { calls.append("navigator") }, toggleKeyboard: { calls.append("keyboard") }, toggleControl: { calls.append("control") }, toggleAlt: { calls.append("alt") }, @@ -52,10 +49,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { sendKey: { _ in false } ) - XCTAssertTrue(actions.perform(.sessions)) - XCTAssertTrue(actions.perform(.library)) - XCTAssertTrue(actions.perform(.windows)) - XCTAssertTrue(actions.perform(.panes)) + XCTAssertTrue(actions.perform(.navigator)) XCTAssertTrue(actions.perform(.keyboard)) XCTAssertTrue(actions.perform(.control)) XCTAssertTrue(actions.perform(.alt)) @@ -65,7 +59,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { XCTAssertTrue(actions.perform(.closePane)) XCTAssertTrue(actions.perform(.closeWindow)) XCTAssertEqual(calls, [ - "sessions", "library", "windows", "panes", "keyboard", "control", "alt", + "navigator", "keyboard", "control", "alt", "new-window", "split-horizontal", "split-vertical", "close-pane", "close-window", ]) } @@ -73,7 +67,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { func testCommonShortcutsSendExactTerminalSequences() { var sequences: [String] = [] let actions = GhosttyKeyboardChromeActions( - showSessions: {}, showLibrary: {}, showWindows: {}, showPanes: {}, + showNavigator: {}, toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, requestSharedMutation: { _ in }, sendShortcut: { sequences.append($0); return true }, @@ -96,7 +90,7 @@ final class GhosttyKeyboardChromeActionsTests: XCTestCase { sendKey: @escaping (GhosttySurfaceKeyEvent) -> Bool ) -> GhosttyKeyboardChromeActions { GhosttyKeyboardChromeActions( - showSessions: {}, showLibrary: {}, showWindows: {}, showPanes: {}, + showNavigator: {}, toggleKeyboard: {}, toggleControl: {}, toggleAlt: {}, requestSharedMutation: { _ in }, sendShortcut: { _ in false }, sendKey: sendKey ) diff --git a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift index 114099a5..443af5d9 100644 --- a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift @@ -1,6 +1,7 @@ import Foundation import Security import Testing +import MoriRemoteTerminal @testable import MoriRemote @Suite("Phase 4 app shell contracts") struct Phase4ShellTests { @@ -68,6 +69,28 @@ import Testing #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") func connectionAttemptLedger() { var attempts = WorkspaceConnectionAttemptLedger() diff --git a/MoriRemote/UPSTREAM.md b/MoriRemote/UPSTREAM.md index c027977f..f884d61b 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -24,18 +24,15 @@ core: runtime trace, and deterministic test transport. - `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, the retained - Ctrl/Esc/Tab/session/window/pane/system-keyboard chrome, preview layout, - topology/selection projections, selection sheets, and - `GhosttyTerminalCoreView` (the minimal upstream-derived composition root). -- `App/ActiveSessionSwitcherView.swift`: an account-free active-session - switcher projection and view. + coordination, modifier state, keyboard visibility/trackpad, compact keypad + and system-keyboard chrome, preview layout, topology/selection projections, + selection sheets, and `GhosttyTerminalCoreView` (the minimal + upstream-derived composition root). - `Domain/TerminalSettings.swift`: terminal appearance only. The paired `MoriRemoteTerminalTests` target ports the matching upstream tests for controller/session/link/adapter teardown, scrolling and viewport state, -responder and keyboard input, modifier state, selection projections, and the -active-session switcher. +responder and keyboard input, modifier state, and selection projections. ## Explicit Phase-1 exclusions @@ -60,9 +57,8 @@ transport remains solely a terminal-core test fixture. | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | -| `GhosttyKeyboardChrome.swift` | Restores remux's compact three-group dock, keeps Sessions/Windows/Panes and trailing keyboard placement, and folds one-shot modifiers, common shell/line-editing shortcuts, terminal keys, and Mori shared tmux actions into native menus. Composer and shortcut-store actions remain excluded. | Native menus preserve remux density while essential Mori terminal operations stay reachable without importing deferred product domains. | -| `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, chrome, and picker sheets. | No SSH construction or persistence dependency. | -| `ActiveSessionSwitcherView.swift` | Uses `UUID`/title/subtitle DTOs and select/disconnect callbacks. | Prevents profile/repository types from entering terminal core. | +| `GhosttyKeyboardChrome.swift` + `GhosttyKeypadSheet.swift` | Keeps remux's compact dock and trailing keyboard placement, combines modifiers, terminal keys, and common shell/line-editing shortcuts in one categorized keypad, and retains a separate Mori shared-tmux menu. | One keypad removes overlapping shortcut categories while keeping exact terminal input local to the terminal module. Composer 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. | | iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | ## Test provenance From a4282672a56f3756175878889c0234d478f2b020 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 20:26:58 +0800 Subject: [PATCH 12/22] moriremote: clarify host navigation and input --- CHANGELOG.md | 2 +- CHANGELOG.zh-Hans.md | 2 +- .../Resources/en.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 5 +- .../MoriRemote/Views/RemoteRootView.swift | 124 ++++++++++++-- .../App/MoriRemoteTerminalFacade.swift | 4 + .../Ghostty/GhosttyKeyboardChrome.swift | 151 +++++++----------- .../Ghostty/GhosttyTerminalCoreView.swift | 76 ++++++--- .../GhosttyTerminalCoreViewTests.swift | 15 ++ MoriRemote/UPSTREAM.md | 2 +- 10 files changed, 254 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index febcbdc2..393b0fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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 the compact terminal bar around four non-overlapping controls and removed the redundant header. Keypad combines one-shot modifiers, terminal keys, and categorized shell/line-editing shortcuts; tmux owns shared mutations; a searchable Navigator unifies Sessions/Windows/Panes and server access; Keyboard remains trailing. +- **iOS (MoriRemote)**: Reworked terminal navigation and input chrome: a slim four-icon keyboard accessory replaces the oversized capsule dock; Navigator now has an explicit host selector and switches terminals only after choosing a session; modal forms suspend the terminal responder so server and credential fields accept input normally. Keypad still combines modifiers, terminal keys, and categorized shell/line-editing shortcuts, while tmux owns shared mutations. - **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 diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 27a0d0d6..40afe3c2 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -10,7 +10,7 @@ ### ✨ 新功能 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 -- **iOS(MoriRemote)**:将紧凑终端栏重构为四个互不重叠的入口,并移除重复顶部栏。Keypad 合并一次性修饰键、终端按键和分类的 shell/行编辑快捷键;tmux 负责共享变更;可搜索 Navigator 统一 Sessions/Windows/Panes 与服务器入口;键盘保持在最右侧。 +- **iOS(MoriRemote)**:重做终端导航与输入栏:纤薄的四图标键盘附件取代笨重的胶囊 Dock;Navigator 新增明确的主机选择器,只有选择会话后才切换终端;模态表单会暂停终端响应器,服务器与凭据字段可正常输入。Keypad 仍统一修饰键、终端按键和分类的 shell/行编辑快捷键,tmux 继续独占共享变更。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index a21f55e7..d1d51397 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -118,6 +118,9 @@ "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"; "Essential" = "Essential"; "Process" = "Process"; "One shot" = "One Shot"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index 0111ed25..ac3c2358 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -117,7 +117,10 @@ "Modifiers" = "修饰键"; "Shortcuts" = "快捷键"; "Keypad" = "按键面板"; -"Navigator" = "导航器"; +"Navigator" = "导航"; +"Current host" = "当前主机"; +"Manage servers" = "管理服务器"; +"Choose a session to switch terminals" = "选择会话后才会切换终端"; "Essential" = "基础按键"; "Process" = "进程控制"; "One shot" = "一次性"; diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 59aca69d..4740642e 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -91,7 +91,12 @@ struct RemoteRootView: View { @ViewBuilder private var terminalDetail: some View { if let runtime = root.activeRuntime { - RemoteTerminalDetailView(root: root, runtime: runtime, showLibrary: { sheet = .library }) + RemoteTerminalDetailView( + root: root, + runtime: runtime, + isInputSuspended: sheet != nil, + showLibrary: { sheet = .library } + ) } else if sizeClass == .compact { NavigationStack { library } } else { @@ -324,6 +329,7 @@ private struct AgentMetadataBadge: View { private struct RemoteTerminalDetailView: View { let root: RemoteRootModel let runtime: ActiveWorkspaceRuntime + let isInputSuspended: Bool let showLibrary: () -> Void @State private var showsNavigator = false @State private var pendingSharedMutation: RemoteSharedMutation? @@ -332,6 +338,7 @@ private struct RemoteTerminalDetailView: View { VStack(spacing: 0) { MoriRemoteTerminalView( session: runtime.session, + isInputSuspended: isInputSuspended || showsNavigator, onShowNavigator: { root.discoverSessions(serverID: runtime.workspace.serverID) showsNavigator = true @@ -393,40 +400,124 @@ private struct RemoteNavigatorView: View { 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) { Text($0.title).tag($0) } + ForEach(Scope.allCases) { item in + Text(item.title) + .tag(item) + .disabled(item != .sessions && !isBrowsingActiveHost) + } } .pickerStyle(.segmented) .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.bottom, 10) List { content } .listStyle(.plain) .overlay { emptyState } } - .navigationTitle(serverName) + .navigationTitle(String(localized: "Navigator")) .navigationBarTitleDisplayMode(.inline) .searchable(text: $filter, prompt: String(localized: "Filter sessions, windows, and panes")) .toolbar { ToolbarItem(placement: .topBarLeading) { - Button(String(localized: "Servers"), systemImage: "server.rack", action: showLibrary) + Button(action: showLibrary) { Image(systemName: "server.rack") } + .accessibilityLabel(String(localized: "Manage servers")) } ToolbarItemGroup(placement: .topBarTrailing) { Button { - root.discoverSessions(serverID: runtime.workspace.serverID) + root.discoverSessions(serverID: selectedServerID) } label: { Image(systemName: "arrow.clockwise") } .accessibilityLabel(String(localized: "Refresh sessions")) Button(String(localized: "Done")) { dismiss() } } } } - .onChange(of: scope) { _, _ in filter = "" } + .onChange(of: scope) { _, newScope in + if newScope != .sessions, !isBrowsingActiveHost { + scope = .sessions + } + 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)) + } + } + } + } 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) + } + } + } + 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 { @@ -503,27 +594,32 @@ private struct RemoteNavigatorView: View { } @ViewBuilder private var emptyState: some View { - if scope == .sessions, root.sessionDiscovery[runtime.workspace.serverID] == .loading, filteredSessions.isEmpty { + if scope == .sessions, root.sessionDiscovery[selectedServerID] == .loading, filteredSessions.isEmpty { ProgressView(String(localized: "Loading sessions…")) } else if visibleItemCount == 0 { - ContentUnavailableView(emptyTitle, systemImage: "magnifyingglass") + ContentUnavailableView(emptyTitle, systemImage: filter.isEmpty ? "rectangle.stack.badge.minus" : "magnifyingglass") } } private var emptyTitle: String { filter.isEmpty ? String(localized: "Nothing here") : String(localized: "No matching results") } - private var serverName: String { - root.servers.first(where: { $0.id == runtime.workspace.serverID })?.name ?? runtime.workspace.name + 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: runtime.workspace.serverID), matching: filter) + RemoteNavigatorProjection.sessions(root.visibleWorkspaces(for: selectedServerID), matching: filter) } private var filteredWindows: [MoriRemoteTerminalWindow] { - RemoteNavigatorProjection.windows(runtime.topology?.windows ?? [], matching: filter) + guard isBrowsingActiveHost else { return [] } + return RemoteNavigatorProjection.windows(runtime.topology?.windows ?? [], matching: filter) } private var filteredPanes: [MoriRemoteTerminalPane] { - RemoteNavigatorProjection.panes( + guard isBrowsingActiveHost else { return [] } + return RemoteNavigatorProjection.panes( runtime.topology?.panes ?? [], windows: runtime.topology?.windows ?? [], matching: filter diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift index 89fa0788..9a4b0c94 100644 --- a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -199,15 +199,18 @@ public final class MoriRemoteTerminalSession: ObservableObject { public struct MoriRemoteTerminalView: View { @ObservedObject private var session: MoriRemoteTerminalSession + private let isInputSuspended: Bool private let onShowNavigator: () -> Void private let onSharedMutationRequest: (MoriRemoteTerminalSharedMutation) -> Void public init( session: MoriRemoteTerminalSession, + isInputSuspended: Bool = false, onShowNavigator: @escaping () -> Void = {}, onSharedMutationRequest: @escaping (MoriRemoteTerminalSharedMutation) -> Void = { _ in } ) { self.session = session + self.isInputSuspended = isInputSuspended self.onShowNavigator = onShowNavigator self.onSharedMutationRequest = onSharedMutationRequest } @@ -215,6 +218,7 @@ public struct MoriRemoteTerminalView: View { public var body: some View { GhosttyTerminalCoreView( screen: session.screen.screenAdapter, + isInputSuspended: isInputSuspended, onShowNavigator: onShowNavigator, onSharedMutationRequest: onSharedMutationRequest ) diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift index 59ac32e3..3a820f8c 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -105,9 +105,8 @@ struct GhosttyKeyboardChromeActions { } } -/// Remux's compact three-group dock with Mori's terminal keys folded into -/// native menus. Keeping the keyboard at the upstream trailing position makes -/// its location stable while avoiding a horizontally scrolling toolbar. +/// A single input accessory strip: four stable targets, no localized label +/// can distort the terminal viewport or move the keyboard control. struct GhosttyKeyboardChrome: View { @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle @State private var showsKeypad = false @@ -120,33 +119,13 @@ struct GhosttyKeyboardChrome: View { let actions: GhosttyKeyboardChromeActions var body: some View { - HStack(spacing: isCompact ? 6 : 10) { - controlGroup { menuControls } - controlGroup { navigatorControl } - controlGroup { keyboardControl } - } - .frame(maxWidth: .infinity, alignment: .center) - .fixedSize(horizontal: false, vertical: true) - .accessibilityElement(children: .contain) - .sheet(isPresented: $showsKeypad) { - GhosttyKeypadSheet( - isControlArmed: isControlArmed, - isAltArmed: isAltArmed, - actions: actions - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - } - - private var menuControls: some View { - HStack(spacing: isCompact ? 1 : 2) { - Button { showsKeypad = true } label: { - menuLabel("keyboard.badge.ellipsis", active: isControlArmed || isAltArmed) - } - .accessibilityLabel(String(localized: "Keypad")) - .accessibilityIdentifier("terminal.keypad") - .disabled(!isEnabled) + HStack(spacing: 0) { + toolbarButton( + "keyboard.badge.ellipsis", + id: "terminal.keypad", + label: String(localized: "Keypad"), + active: isControlArmed || isAltArmed + ) { showsKeypad = true } Menu { Section { @@ -159,86 +138,72 @@ struct GhosttyKeyboardChrome: View { Button("Close window", role: .destructive) { _ = actions.perform(.closeWindow) } } } label: { - menuLabel("terminal") + toolbarIcon("terminal", active: false) } + .frame(maxWidth: .infinity) .accessibilityLabel(String(localized: "tmux actions")) .accessibilityIdentifier("terminal.tmux-actions") .disabled(!isEnabled) - } - } - private var navigatorControl: some View { - Button { _ = actions.perform(.navigator) } label: { - HStack(spacing: 6) { - Image(systemName: "rectangle.stack") - Text(String(localized: "Navigator")) - .font(.system(size: 12, weight: .semibold)) - } + 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) + .sheet(isPresented: $showsKeypad) { + GhosttyKeypadSheet( + isControlArmed: isControlArmed, + isAltArmed: isAltArmed, + actions: actions + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } - .buttonStyle(ChromeButtonStyle(active: false, width: isCompact ? 100 : 116)) - .accessibilityIdentifier("terminal.navigator") - } - - private var keyboardControl: some View { - icon( - "keyboard", - id: "terminal.keyboard", - label: keyboardMode == .hidden ? String(localized: "Show keyboard") : String(localized: "Hide keyboard"), - enabled: true, - active: keyboardMode == .system - ) { actions.perform(.keyboard) } - } - - private func menuLabel(_ systemName: String, active: Bool = false) -> some View { - Image(systemName: systemName) - .font(.system(size: 16, weight: .semibold)) - .frame(width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight) - .foregroundStyle(active ? chromeStyle.accent : Color.primary) - .background(active ? chromeStyle.accent.opacity(0.16) : Color.clear, in: RoundedRectangle(cornerRadius: GhosttyKeyboardChromeSizing.dockButtonCornerRadius, style: .continuous)) - .contentShape(Rectangle()) - } - - private func controlGroup(@ViewBuilder _ content: () -> Content) -> some View { - content() - .padding(.horizontal, isCompact ? 3 : 5) - .padding(.vertical, GhosttyKeyboardChromeSizing.controlGroupVerticalPadding) - .background(.thinMaterial, in: Capsule()) - .overlay { Capsule().strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.75) } } - private func icon( - _ name: String, + private func toolbarButton( + _ systemName: String, id: String, label: String, - enabled: Bool = true, + enabled: Bool? = nil, active: Bool = false, - action: @escaping () -> Bool + action: @escaping () -> Void ) -> some View { - Button { _ = action() } label: { - Image(systemName: name).font(.system(size: 16.5, weight: .semibold)) + Button(action: action) { + toolbarIcon(systemName, active: active) } - .buttonStyle(ChromeButtonStyle(active: active, width: dockButtonWidth)) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) .accessibilityLabel(label) .accessibilityIdentifier(id) - .disabled((!isEnabled && id != "terminal.home") || !enabled) + .disabled(!(enabled ?? isEnabled)) } - private var dockButtonWidth: CGFloat { - isCompact ? GhosttyKeyboardChromeSizing.compactDockButtonWidth : GhosttyKeyboardChromeSizing.dockButtonWidth - } -} - -private struct ChromeButtonStyle: ButtonStyle { - let active: Bool - let width: CGFloat - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .frame(width: width, height: GhosttyKeyboardChromeSizing.dockButtonHeight) - .foregroundStyle(active ? Color.accentColor : Color.primary) - .background(active ? Color.accentColor.opacity(0.16) : Color.clear, in: RoundedRectangle(cornerRadius: GhosttyKeyboardChromeSizing.dockButtonCornerRadius, style: .continuous)) - .scaleEffect(configuration.isPressed ? 0.96 : 1) - .opacity(configuration.isPressed ? 0.65 : 1) + 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()) } } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index 1778dca6..88312b16 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -13,6 +13,7 @@ struct GhosttyTerminalCoreView: View { @ObservedObject private var screen: TmuxTerminalScreenAdapter private let onShowNavigator: () -> Void private let onSharedMutationRequest: (MoriRemoteTerminalSharedMutation) -> Void + private let isInputSuspended: Bool @State private var terminalInputController = GhosttyTerminalInputController() @State private var responderHandoff = GhosttyKeyboardResponderHandoff() @State private var trackpadDriver = GhosttyKeyboardCursorTrackpadDriver() @@ -23,10 +24,12 @@ struct GhosttyTerminalCoreView: View { init( screen: TmuxTerminalScreenAdapter, + isInputSuspended: Bool = false, onShowNavigator: @escaping () -> Void = {}, onSharedMutationRequest: @escaping (MoriRemoteTerminalSharedMutation) -> Void = { _ in } ) { self.screen = screen + self.isInputSuspended = isInputSuspended self.onShowNavigator = onShowNavigator self.onSharedMutationRequest = onSharedMutationRequest } @@ -34,6 +37,10 @@ struct GhosttyTerminalCoreView: View { var body: some View { let projection = screen.terminalScreenPresentationProjection let interaction = projection.interaction + let isInputAvailable = GhosttyTerminalInputAvailabilityProjection( + isTerminalReady: interaction.isInputAvailable, + isSuspended: isInputSuspended + ).isInputAvailable ZStack(alignment: .bottom) { Color.black.ignoresSafeArea() GeometryReader { geometry in @@ -45,13 +52,13 @@ struct GhosttyTerminalCoreView: View { terminalTheme: .ghosttyDefault, trackpadDriver: trackpadDriver, onSurfaceTap: { _ in activateTerminalInput() }, - onWindowSwipe: { _ = screen.focusAdjacentTmuxTopLevel($0) }, + onWindowSwipe: { guard isInputAvailable else { return }; _ = screen.focusAdjacentTmuxTopLevel($0) }, sendKeyEvent: sendTerminalKey, onTrackpadFeedbackChange: { trackpadFeedback = $0 }, - isMouseCaptured: { screen.isMouseCaptured(for: $0) }, - submitMouseButton: { screen.sendMouseButton(to: $0, $1) }, - submitMousePosition: { screen.sendMousePosition(to: $0, $1, mods: $2) }, - submitMouseScroll: { screen.sendMouseScroll(to: $0, $1) } + 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) } @@ -60,8 +67,8 @@ struct GhosttyTerminalCoreView: View { } GhosttyTerminalResponderRepresentable( - isEnabled: interaction.isInputAvailable, - wantsFirstResponder: compositionState.inputCoordinator.keyboardMode == .system, + isEnabled: isInputAvailable, + wantsFirstResponder: isInputAvailable && compositionState.inputCoordinator.keyboardMode == .system, activationToken: compositionState.inputCoordinator.terminalActivationToken, responderHandoff: responderHandoff, trackpadDriver: trackpadDriver, @@ -73,7 +80,7 @@ struct GhosttyTerminalCoreView: View { onFirstResponderChange: { isFirstResponder in if !isFirstResponder, compositionState.inputCoordinator.keyboardMode == .system, !compositionState.inputCoordinator.isDismissSystemKeyboardRequested { - compositionState.inputCoordinator.refocusSystemKeyboardIfActive(isInputAvailable: screen.terminalInteractionProjection.isInputAvailable) + compositionState.inputCoordinator.refocusSystemKeyboardIfActive(isInputAvailable: isInputAvailable) } } ) @@ -83,7 +90,7 @@ struct GhosttyTerminalCoreView: View { .safeAreaInset(edge: .bottom, spacing: 0) { GhosttyKeyboardChrome( keyboardMode: compositionState.inputCoordinator.keyboardMode, - isEnabled: interaction.isInputAvailable, + isEnabled: isInputAvailable, isCompact: horizontalSizeClass == .compact, isControlArmed: terminalInputController.isControlArmed, isAltArmed: terminalInputController.isAltArmed, @@ -97,19 +104,23 @@ struct GhosttyTerminalCoreView: View { sendKey: sendTerminalKey ) ) - .padding(.horizontal, 12) - .padding(.vertical, 4) } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { + guard !isInputSuspended else { return } updateKeyboardVisibility(with: $0) } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + guard !isInputSuspended else { return } completeKeyboardTransition(for: .shown) } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)) { _ in + guard !isInputSuspended else { return } completeKeyboardTransition(for: .hidden) } .onDisappear { cancelTransientInput() } + .onChange(of: isInputSuspended) { _, 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. @@ -118,19 +129,20 @@ struct GhosttyTerminalCoreView: View { } private func toggleKeyboard() { + guard isTerminalInputAvailable else { return } let projection = GhosttyKeyboardToggleProjection( keyboardMode: compositionState.inputCoordinator.keyboardMode, - isInputAvailable: screen.terminalInteractionProjection.isInputAvailable + isInputAvailable: isTerminalInputAvailable ) if let request = compositionState.keyboardTransitionCoordinator.transitionRequest(forToggle: projection) { beginKeyboardTransition(request) } - compositionState.inputCoordinator.toggleKeyboard(isInputAvailable: screen.terminalInteractionProjection.isInputAvailable) + compositionState.inputCoordinator.toggleKeyboard(isInputAvailable: isTerminalInputAvailable) if compositionState.inputCoordinator.keyboardMode == .hidden { _ = responderHandoff.transfer(to: .terminal) } } private func activateTerminalInput() { - guard screen.terminalInteractionProjection.isInputAvailable else { return } + guard isTerminalInputAvailable else { return } compositionState.inputCoordinator.showSystemKeyboard(isInputAvailable: true) } @@ -164,8 +176,8 @@ struct GhosttyTerminalCoreView: View { activeTransitionTarget: compositionState.viewportCoordinator.keyboardTransitionTarget, keyboardMode: compositionState.inputCoordinator.keyboardMode, isDismissSystemKeyboardRequested: compositionState.inputCoordinator.isDismissSystemKeyboardRequested, - isInputAvailable: screen.terminalInteractionProjection.isInputAvailable, - isSelectionSheetPresented: false, + isInputAvailable: isTerminalInputAvailable, + isSelectionSheetPresented: isInputSuspended, isAwaitingSystemKeyboardPresentation: compositionState.keyboardTransitionCoordinator.isAwaitingSystemKeyboardPresentation, isSceneActive: true ) @@ -174,7 +186,8 @@ struct GhosttyTerminalCoreView: View { } private func sendTerminalText(_ text: String) -> Bool { - terminalInputController.performTextInput( + guard isTerminalInputAvailable else { return false } + return terminalInputController.performTextInput( text, submit: { screen.sendInputToFocusedSurface($0).isAccepted }, schedulePrefixFlush: schedulePrefixFlush(token:), @@ -191,6 +204,7 @@ struct GhosttyTerminalCoreView: View { 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) @@ -210,6 +224,7 @@ struct GhosttyTerminalCoreView: View { } 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. @@ -223,7 +238,8 @@ struct GhosttyTerminalCoreView: View { } private func sendTerminalPaste(_ text: String) -> Bool { - terminalInputController.performPaste( + guard isTerminalInputAvailable else { return false } + return terminalInputController.performPaste( text, submitPendingPrefix: { screen.sendInputToFocusedSurface($0).isAccepted }, sendPaste: { screen.sendPasteToFocusedSurface($0).isAccepted } @@ -231,11 +247,33 @@ struct GhosttyTerminalCoreView: View { } private func sendTerminalKey(_ event: GhosttySurfaceKeyEvent) -> Bool { - terminalInputController.performKeyEvent( + guard isTerminalInputAvailable else { return false } + return terminalInputController.performKeyEvent( event, submitPendingPrefix: { screen.sendInputToFocusedSurface($0).isAccepted }, sendKey: { screen.sendKeyEventToFocusedSurface($0).isAccepted } ) } + private var isTerminalInputAvailable: Bool { + GhosttyTerminalInputAvailabilityProjection( + isTerminalReady: screen.terminalInteractionProjection.isInputAvailable, + isSuspended: isInputSuspended + ).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/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift index 0b6c8fab..f942330e 100644 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalCoreViewTests.swift @@ -13,4 +13,19 @@ final class GhosttyTerminalCoreViewTests: XCTestCase { 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/UPSTREAM.md b/MoriRemote/UPSTREAM.md index f884d61b..c24edb4c 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -57,7 +57,7 @@ transport remains solely a terminal-core test fixture. | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | -| `GhosttyKeyboardChrome.swift` + `GhosttyKeypadSheet.swift` | Keeps remux's compact dock and trailing keyboard placement, combines modifiers, terminal keys, and common shell/line-editing shortcuts in one categorized keypad, and retains a separate Mori shared-tmux menu. | One keypad removes overlapping shortcut categories while keeping exact terminal input local to the terminal module. Composer and shortcut-store domains remain excluded. | +| `GhosttyKeyboardChrome.swift` + `GhosttyKeypadSheet.swift` | Keeps remux's trailing keyboard placement in a slim four-icon input accessory, combines modifiers, terminal keys, and common shell/line-editing shortcuts in one categorized keypad, and retains a separate Mori shared-tmux menu. App modal presentation explicitly suspends the hidden terminal responder. | Stable equal-width controls avoid localized-label layout drift; responder suspension prevents Ghostty from reclaiming first responder from app-owned text fields. Composer 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. | | iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | From f04fe386c8e3c30c43bad0247f858cf19b8f428b Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 21:05:36 +0800 Subject: [PATCH 13/22] moriremote: stabilize metadata projection tests --- .../MoriRemoteTests/Phase5AgentMetadataTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift index 486d034f..f242b0db 100644 --- a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift @@ -56,13 +56,13 @@ import Testing await relay.waitUntilRequested() relay.complete(.init(succeeded: true, body: "%1\tworking\tclaude\n")) - await Task.yield() + await eventually { projector.metadata[1] == .init(state: .working, name: "claude") } #expect(projector.metadata[1] == .init(state: .working, name: "claude")) projector.foregrounded() await relay.waitUntilRequested() relay.complete(.init(succeeded: true, body: "%1\twaiting\tclaude\n")) - await Task.yield() + await eventually { projector.metadata[1] == .init(state: .waiting, name: "claude") } #expect(projector.metadata[1] == .init(state: .waiting, name: "claude")) projector.stop() } @@ -75,7 +75,7 @@ import Testing projector.setVisible(true) await relay.waitUntilRequested() relay.complete(.init(succeeded: false, body: "transport closed")) - await Task.yield() + await eventually { projector.lastFailure == "transport closed" } #expect(projector.metadata[1] == .unknown) #expect(projector.lastFailure == "transport closed") @@ -95,7 +95,7 @@ import Testing projector.setVisible(true) await relay.waitUntilRequested() relay.complete(.init(succeeded: true, body: "%1\tworking\tclaude\n")) - await Task.yield() + await eventually { projector.metadata[1] == .init(state: .working, name: "claude") } projector.foregrounded() await relay.waitUntilRequested() @@ -107,7 +107,7 @@ import Testing await Task.yield() #expect(projector.metadata.isEmpty) relay.complete(.init(succeeded: true, body: "%1\twaiting\tclaude\n")) - await Task.yield() + await eventually { projector.metadata[1] == .init(state: .waiting, name: "claude") } #expect(projector.metadata[1] == .init(state: .waiting, name: "claude")) projector.stop() } @@ -137,9 +137,9 @@ import Testing @MainActor private func eventually(_ condition: @escaping @MainActor () -> Bool) async { - for _ in 0..<40 { + for _ in 0..<100 { if condition() { return } - await Task.yield() + try? await Task.sleep(for: .milliseconds(1)) } Issue.record("condition did not become true") } From 2dfb411724727ec57bff9b440f86db1f2d206357 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 21:05:36 +0800 Subject: [PATCH 14/22] moriremote: restore IME and image input --- CHANGELOG.md | 2 +- CHANGELOG.zh-Hans.md | 2 +- .../MoriRemote.xcodeproj/project.pbxproj | 16 + .../MoriRemote/App/RemoteRootModel.swift | 8 + .../Resources/en.lproj/Localizable.strings | 18 ++ .../zh-Hans.lproj/Localizable.strings | 18 ++ .../MoriRemote/SSH/CitadelSSHTransport.swift | 149 +++++++++ .../MoriRemote/SSH/SSHImageUpload.swift | 270 ++++++++++++++++ MoriRemote/MoriRemote/SSH/SSHRootPool.swift | 29 ++ .../MoriRemote/Views/RemoteRootView.swift | 1 + .../App/MoriRemoteTerminalFacade.swift | 25 ++ .../Ghostty/GhosttyImageAttachmentSheet.swift | 293 ++++++++++++++++++ .../Ghostty/GhosttyKeyboardChrome.swift | 45 ++- .../Ghostty/GhosttyKeypadSheet.swift | 10 + .../Ghostty/GhosttyTerminalCoreView.swift | 37 ++- ...hosttyTerminalResponderTextInputShim.swift | 50 ++- .../GhosttyTerminalResponderView.swift | 9 + .../GhosttyImageAttachmentTests.swift | 20 ++ .../GhosttyTerminalResponderViewTests.swift | 51 +++ .../MoriRemoteTests/ImageInputTests.swift | 93 ++++++ MoriRemote/UPSTREAM.md | 28 +- README.md | 4 +- README.zh-Hans.md | 2 +- 23 files changed, 1136 insertions(+), 44 deletions(-) create mode 100644 MoriRemote/MoriRemote/SSH/SSHImageUpload.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyImageAttachmentSheet.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyImageAttachmentTests.swift create mode 100644 MoriRemote/MoriRemoteTests/ImageInputTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 393b0fb6..532c261f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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 now has an explicit host selector and switches terminals only after choosing a session; modal forms suspend the terminal responder so server and credential fields accept input normally. Keypad still combines modifiers, terminal keys, and categorized shell/line-editing shortcuts, while tmux owns shared mutations. +- **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 diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 40afe3c2..d77003de 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -10,7 +10,7 @@ ### ✨ 新功能 - **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 -- **iOS(MoriRemote)**:重做终端导航与输入栏:纤薄的四图标键盘附件取代笨重的胶囊 Dock;Navigator 新增明确的主机选择器,只有选择会话后才切换终端;模态表单会暂停终端响应器,服务器与凭据字段可正常输入。Keypad 仍统一修饰键、终端按键和分类的 shell/行编辑快捷键,tmux 继续独占共享变更。 +- **iOS(MoriRemote)**:重做终端导航与输入栏:纤薄的四图标键盘附件取代笨重的胶囊 Dock;Navigator 提供明确的主机选择器,只有选择会话后才切换终端;模态表单会暂停终端响应器;中文等输入法的组合文本只会提交一次。Keypad 还新增照片与剪贴板图片输入:图片经当前已认证 SSH 根连接预览并原子上传,随后仅插入经过 shell 转义的远程路径,不会自动按回车。 - **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。 ### 🐛 问题修复 diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index ce73aa39..a7056f9b 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 055D7CE8194A8EB00C6AB48F /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AF4F5E129749B9F473BA1D /* DeterministicTmuxControlTransport.swift */; }; 09212452679FFE001555BFCB /* GhosttyTerminalScreenModeling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */; }; 0A3E5C8D6D19481EDBA77835 /* GhosttyKitControlSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */; }; + 0CD0FDE83F290E70D2B82BAB /* SSHImageUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AE8D2B54D6CB377400488B /* SSHImageUpload.swift */; }; 0D9936CC7BE5A981314D56F0 /* GhosttyTopLevelSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */; }; 0E1ABB2DB77400492C766DB7 /* GhosttyKeypadSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13347B90704FC1FF2D28C63A /* GhosttyKeypadSheet.swift */; }; 109E4551800EDCA43A760F80 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9A1E86AC034B64B37F68A846 /* Localizable.strings */; }; @@ -22,6 +23,7 @@ 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 */; }; @@ -38,6 +40,7 @@ 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 */; }; + 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 */; }; 51CDD881F49D4124117A3D7D /* HostTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4A9E8D51098FB9FE41A085 /* HostTrust.swift */; }; @@ -80,6 +83,7 @@ 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 */; }; @@ -142,6 +146,7 @@ /* 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 = ""; }; @@ -179,6 +184,7 @@ 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRuntimeSurfaceTopologySnapshot.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 = ""; }; 618CC6C670DB54BA190E5827 /* GhosttySurfaceSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceSelectionSheet.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; }; @@ -206,6 +212,7 @@ 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 = ""; }; A597D49CF7A1A57D33B9A7E2 /* GhosttyTerminalInputCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalInputCoordinator.swift; sourceTree = ""; }; A7D76C58B99537D89078D58B /* GhosttyTerminalPresentationProjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalPresentationProjector.swift; sourceTree = ""; }; @@ -213,6 +220,7 @@ A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalDisconnectReasonClassifier.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 = ""; }; @@ -298,6 +306,7 @@ D77CF33F94CEBE45B61807FB /* CitadelSSHTransport.swift */, 2E4A9E8D51098FB9FE41A085 /* HostTrust.swift */, 46D3CED1154E19B734CC9AA8 /* SSHAuth.swift */, + 01AE8D2B54D6CB377400488B /* SSHImageUpload.swift */, 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */, DC5CE2DEDF2FDD48461599ED /* SSHRootPool.swift */, ); @@ -344,6 +353,7 @@ 5C295A7834704597EC4619C0 /* MoriRemoteTerminalTests */ = { isa = PBXGroup; children = ( + B2CE71956CBE6B24690CB0E7 /* GhosttyImageAttachmentTests.swift */, 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */, BE2FE3FEDF880BE280656A2D /* GhosttyKeyboardChromeModeTests.swift */, EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */, @@ -452,6 +462,7 @@ D08B8BC473350934D03F8E02 /* Ghostty */ = { isa = PBXGroup; children = ( + 9F7ADE8147E90699397F2846 /* GhosttyImageAttachmentSheet.swift */, F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.swift */, 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */, D5A15305D227B6DD4A6C8CBE /* GhosttyKeyboardCursorTrackpad.swift */, @@ -512,6 +523,7 @@ EC1D22246639767E9C4475A3 /* MoriRemoteTests */ = { isa = PBXGroup; children = ( + 5EF2A6B22DB0665EE8DE530E /* ImageInputTests.swift */, 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */, 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */, 0B2C6DECBA8C1C75B1A429CD /* Phase4ShellTests.swift */, @@ -700,6 +712,7 @@ files = ( 055D7CE8194A8EB00C6AB48F /* DeterministicTmuxControlTransport.swift in Sources */, EA704DB81EBCED68696937A6 /* GhosttyIOSurfaceFrame.swift in Sources */, + 26B09ED95683BE1B0C6484A6 /* GhosttyImageAttachmentSheet.swift in Sources */, 25683F5BC20807C3F9ADE249 /* GhosttyKeyboardChrome.swift in Sources */, F1D09D202AF835D9ED07031C /* GhosttyKeyboardCursorTrackpad.swift in Sources */, F4684D11FED84F66904D7C0D /* GhosttyKeyboardCursorTrackpadHUD.swift in Sources */, @@ -759,6 +772,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9F04137A8480985A7635FE52 /* ImageInputTests.swift in Sources */, 54F48944A9E87F22A490B8D4 /* LegacyMigrationTests.swift in Sources */, 78FE94F2F5065228E0F8B259 /* Phase2TransportTests.swift in Sources */, 3FAC41CC848F16DDB123A9F6 /* Phase4ShellTests.swift in Sources */, @@ -781,6 +795,7 @@ 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 */, 553D7FA2654275C5B609DB67 /* SSHRootPool.swift in Sources */, 64772A470A3EB7EE2CD92BA8 /* SSHTmuxControlTransport.swift in Sources */, @@ -795,6 +810,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 40753F5ADF7AF6E9BDB498D2 /* GhosttyImageAttachmentTests.swift in Sources */, 656BC1E3C7AC26BC7C6FC1C6 /* GhosttyKeyboardChromeActionsTests.swift in Sources */, 3A2DB1541BA69E76A0E29F66 /* GhosttyKeyboardChromeModeTests.swift in Sources */, A17B820D2070516F1E059C96 /* GhosttyKeyboardVisibilityProjectionTests.swift in Sources */, diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index cb185e28..f4ff2294 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -548,6 +548,14 @@ final class RemoteRootModel { func performSharedMutation(_ mutation: MoriRemoteTerminalSharedMutation) { activeRuntime?.performSharedMutation(mutation) } + + func imageUploader(for workspaceID: UUID) -> MoriRemoteTerminalImageUploader { + SSHImageUploadService( + library: dependencies.library, + roots: dependencies.roots, + trustedHosts: dependencies.trustedHosts + ).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. diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index d1d51397..14caa030 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -121,6 +121,24 @@ "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"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index ac3c2358..08c2a2fd 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -121,6 +121,24 @@ "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" = "一次性"; 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..5ff0f24c --- /dev/null +++ b/MoriRemote/MoriRemote/SSH/SSHImageUpload.swift @@ -0,0 +1,270 @@ +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 library: RemoteLibrary + let roots: SSHRootPool + let trustedHosts: TrustedHostStore + + 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 material = try await library.connectionMaterial(for: workspaceID) + let auth = try await library.resolveAuth(server: material.1, identity: material.2, settings: material.3) + 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 connector = CitadelSSHRootConnector( + server: material.1, + auth: auth, + trust: SSHHostTrustResolver(store: trustedHosts) + ) + let lease = try await roots.lease(for: key, connector: connector) + 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/SSHRootPool.swift b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift index 2c1b0757..02bf557b 100644 --- a/MoriRemote/MoriRemote/SSH/SSHRootPool.swift +++ b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift @@ -9,11 +9,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 } diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 4740642e..73a7fc78 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -339,6 +339,7 @@ private struct RemoteTerminalDetailView: View { MoriRemoteTerminalView( session: runtime.session, isInputSuspended: isInputSuspended || showsNavigator, + imageUploader: root.imageUploader(for: runtime.workspace.id), onShowNavigator: { root.discoverSessions(serverID: runtime.workspace.serverID) showsNavigator = true diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift index 9a4b0c94..42fa0195 100644 --- a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -87,6 +87,27 @@ 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. @@ -200,17 +221,20 @@ public final class MoriRemoteTerminalSession: ObservableObject { public struct MoriRemoteTerminalView: View { @ObservedObject private var 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 } @@ -219,6 +243,7 @@ public struct MoriRemoteTerminalView: View { GhosttyTerminalCoreView( screen: session.screen.screenAdapter, isInputSuspended: isInputSuspended, + imageUploader: imageUploader, onShowNavigator: onShowNavigator, onSharedMutationRequest: onSharedMutationRequest ) 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 index 3a820f8c..37ce22c8 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeyboardChrome.swift @@ -108,14 +108,22 @@ struct GhosttyKeyboardChromeActions { /// 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 showsKeypad = false + @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 { @@ -125,7 +133,7 @@ struct GhosttyKeyboardChrome: View { id: "terminal.keypad", label: String(localized: "Keypad"), active: isControlArmed || isAltArmed - ) { showsKeypad = true } + ) { presentedSheet = .keypad } Menu { Section { @@ -165,14 +173,31 @@ struct GhosttyKeyboardChrome: View { .overlay(alignment: .top) { Divider().opacity(0.7) } .preferredColorScheme(.dark) .accessibilityElement(children: .contain) - .sheet(isPresented: $showsKeypad) { - GhosttyKeypadSheet( - isControlArmed: isControlArmed, - isAltArmed: isAltArmed, - actions: actions - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) + .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) + } + } } } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift index d59473ad..ed3111d1 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyKeypadSheet.swift @@ -6,6 +6,7 @@ struct GhosttyKeypadSheet: View { let isControlArmed: Bool let isAltArmed: Bool + let onAddImage: (() -> Void)? let actions: GhosttyKeyboardChromeActions private let compactColumns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 4) @@ -16,6 +17,15 @@ struct GhosttyKeypadSheet: View { 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) diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index 88312b16..0c54c982 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -14,6 +14,7 @@ struct GhosttyTerminalCoreView: View { 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() @@ -21,15 +22,18 @@ struct GhosttyTerminalCoreView: View { @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 } @@ -39,7 +43,7 @@ struct GhosttyTerminalCoreView: View { let interaction = projection.interaction let isInputAvailable = GhosttyTerminalInputAvailabilityProjection( isTerminalReady: interaction.isInputAvailable, - isSuspended: isInputSuspended + isSuspended: isInputSuspended || isImageAttachmentPresented ).isInputAvailable ZStack(alignment: .bottom) { Color.black.ignoresSafeArea() @@ -94,6 +98,9 @@ struct GhosttyTerminalCoreView: View { isCompact: horizontalSizeClass == .compact, isControlArmed: terminalInputController.isControlArmed, isAltArmed: terminalInputController.isAltArmed, + imageUploader: imageUploader, + insertImagePath: insertUploadedImagePath, + onImagePresentationChange: { isImageAttachmentPresented = $0 }, actions: .init( showNavigator: onShowNavigator, toggleKeyboard: toggleKeyboard, @@ -106,19 +113,19 @@ struct GhosttyTerminalCoreView: View { ) } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { - guard !isInputSuspended else { return } + guard !isTerminalInputSuspended else { return } updateKeyboardVisibility(with: $0) } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in - guard !isInputSuspended else { return } + guard !isTerminalInputSuspended else { return } completeKeyboardTransition(for: .shown) } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)) { _ in - guard !isInputSuspended else { return } + guard !isTerminalInputSuspended else { return } completeKeyboardTransition(for: .hidden) } .onDisappear { cancelTransientInput() } - .onChange(of: isInputSuspended) { _, isSuspended in + .onChange(of: isTerminalInputSuspended) { _, isSuspended in if isSuspended { cancelTransientInput() } } .onChange(of: screen.stateTraceLabel) { oldState, newState in @@ -177,7 +184,7 @@ struct GhosttyTerminalCoreView: View { keyboardMode: compositionState.inputCoordinator.keyboardMode, isDismissSystemKeyboardRequested: compositionState.inputCoordinator.isDismissSystemKeyboardRequested, isInputAvailable: isTerminalInputAvailable, - isSelectionSheetPresented: isInputSuspended, + isSelectionSheetPresented: isTerminalInputSuspended, isAwaitingSystemKeyboardPresentation: compositionState.keyboardTransitionCoordinator.isAwaitingSystemKeyboardPresentation, isSceneActive: true ) @@ -239,7 +246,17 @@ struct GhosttyTerminalCoreView: View { private func sendTerminalPaste(_ text: String) -> Bool { guard isTerminalInputAvailable else { return false } - return terminalInputController.performPaste( + 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 } @@ -255,10 +272,14 @@ struct GhosttyTerminalCoreView: View { ) } + private var isTerminalInputSuspended: Bool { + isInputSuspended || isImageAttachmentPresented + } + private var isTerminalInputAvailable: Bool { GhosttyTerminalInputAvailabilityProjection( isTerminalReady: screen.terminalInteractionProjection.isInputAvailable, - isSuspended: isInputSuspended + isSuspended: isTerminalInputSuspended ).isInputAvailable } } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift index 2a3788d6..c7d675d3 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderTextInputShim.swift @@ -4,11 +4,10 @@ import UIKit // 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. Everything here exists solely to keep UIKit's protocol-required -// calls happy without surfacing autocorrect, marked text, an edit menu, or -// other text-input behaviors. UIKit may still deliver committed software -// keyboard input through `replace(_:withText:)`, so committed replacement text -// is forwarded to the same terminal path as `insertText`. +// 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. @@ -44,7 +43,13 @@ extension GhosttyTerminalResponderUIView: UITextInput { set { _ = newValue } } - var markedTextRange: UITextRange? { nil } + 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 } @@ -68,21 +73,44 @@ extension GhosttyTerminalResponderUIView: UITextInput { return nil } - // Keep the virtual document coherent so UIKit sees one deletable - // character and drives its native Backspace repeat behavior. - return range.isEmpty ? "" : " " + 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) { - _ = (markedText, selectedRange) + _ = selectedRange + updateMarkedText(markedText ?? "") + } + + func unmarkText() { + let committed = markedTextStorage + clearMarkedText() + guard !committed.isEmpty else { return } + submitTextInput(committed, source: "unmarkText") } - func 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 diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift index 2f41e2c2..c412b2d5 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift @@ -134,6 +134,9 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait 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 = @@ -199,6 +202,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait } if !isEnabled { + clearMarkedText() cancelTrackpadGestureIfActive(reason: "disabled") pendingFirstResponderRequest = false self.activationToken = activationToken @@ -225,6 +229,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait } func insertText(_ text: String) { + clearMarkedText() submitTextInput(text, source: "insertText") } @@ -367,6 +372,10 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait func deleteBackward() { guard isInputEnabled else { return } + if !markedTextStorage.isEmpty { + clearMarkedText() + return + } GhosttyRuntimeTrace.diagnostics( "responder.deleteBackward firstResponder=\(isFirstResponder) token=\(activationToken)" ) 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/GhosttyTerminalResponderViewTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderViewTests.swift index 35a90c2d..4fe74e91 100644 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderViewTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/GhosttyTerminalResponderViewTests.swift @@ -771,6 +771,57 @@ final class GhosttyTerminalResponderViewTests: XCTestCase { 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()) 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/UPSTREAM.md b/MoriRemote/UPSTREAM.md index c24edb4c..92ab7fef 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -34,16 +34,22 @@ The paired `MoriRemoteTerminalTests` target ports the matching upstream tests for controller/session/link/adapter teardown, scrolling and viewport state, responder and keyboard input, modifier state, and selection projections. -## Explicit Phase-1 exclusions +## Explicit exclusions No files from remux account/profile repositories, SSH services/transports, -SFTP/live forwarding, terminal preview, attachments, composer/voice, or -shortcut marketplace/editor are linked into `MoriRemoteTerminal`. - -`TmuxControlTransport` is a terminal-internal protocol-only seam. The app +live forwarding, terminal preview, 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` facade delegates the authenticated SFTP +upload to Mori's app-owned SSH root pool. + +`TmuxControlTransport` remains a terminal-internal protocol-only seam. The app crosses it only through `MoriRemoteTerminalTransport`, whose byte lifecycle -closures are adapted by `SSHTmuxControlTransport.asTerminalTransport()`. This -omits remux SFTP and live-forward provider protocols. The deterministic +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 @@ -53,11 +59,11 @@ transport remains solely a terminal-core test fixture. | `TmuxScreenModel.swift` | Reduced to injected `ghostty_app_t` + `TmuxControlTransport` composition. | Upstream constructs account targets, runtime status reporting, and preview services; those are Phase 2+ concerns. | | `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 owns `GhosttyKitRuntime` + screen model, exposes state/topology, fixed metadata results, labeled shared mutations, presentation lifecycle, and type-erased SSH byte lifecycle closures. | App code retains Citadel/trust/persistence without importing GhosttyKit or terminal controller/surface types. | +| `App/MoriRemoteTerminalFacade.swift` | Public deep facade owns `GhosttyKitRuntime` + screen model, 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. | | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | -| `GhosttyKeyboardChrome.swift` + `GhosttyKeypadSheet.swift` | Keeps remux's trailing keyboard placement in a slim four-icon input accessory, combines modifiers, terminal keys, and common shell/line-editing shortcuts in one categorized keypad, and retains a separate Mori shared-tmux menu. App modal presentation explicitly suspends the hidden terminal responder. | Stable equal-width controls avoid localized-label layout drift; responder suspension prevents Ghostty from reclaiming first responder from app-owned text fields. Composer and shortcut-store domains remain excluded. | +| `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. | | iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | @@ -70,12 +76,12 @@ made deterministic or detached from excluded remux domains. | Production area | Pinned remux tests reviewed | MoriRemoteTerminalTests | Untranslated gap | | --- | --- | --- | --- | | 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 | Simulator-global `UIPasteboard` integration replaced by injected deterministic source; routing remains tested. | +| 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` | Composer, attachments, shortcut UI and account actions deliberately excluded. | +| 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 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 共享。 From 3bab847762905d62ad153a4a0a78cdf04a8a8007 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 21:34:18 +0800 Subject: [PATCH 15/22] moriremote: refresh scrollback after rendered frames --- CHANGELOG.md | 1 + CHANGELOG.zh-Hans.md | 1 + .../MoriRemote.xcodeproj/project.pbxproj | 8 ++++ .../GhosttyPublishedFrameObserver.swift | 39 +++++++++++++++ .../Tmux/TmuxPaneSurface.swift | 15 ++++++ .../GhosttyPublishedFrameObserverTests.swift | 47 +++++++++++++++++++ MoriRemote/UPSTREAM.md | 1 + 7 files changed, 112 insertions(+) create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPublishedFrameObserver.swift create mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyPublishedFrameObserverTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 532c261f..09dc77a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 🐛 Bug Fixes +- **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 d77003de..5b1ea876 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -15,6 +15,7 @@ ### 🐛 问题修复 +- **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 a7056f9b..a339b6b1 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 6E8368DBBA57DB44BDC8E1E5 /* LegacyMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88BDAAB702E98FDD084041C /* LegacyMigration.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 */; }; @@ -106,6 +107,7 @@ 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 */; }; E75088D081F5457454778A3D /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E9E52C78F643675B58F0BA /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */; }; E84DA64184225212B72BE0EA /* GhosttyScrollDeltaBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523D89AB51C587C880ABC74F /* GhosttyScrollDeltaBudgetTests.swift */; }; EA704DB81EBCED68696937A6 /* GhosttyIOSurfaceFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.swift */; }; @@ -214,6 +216,7 @@ 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 = ""; }; @@ -253,6 +256,7 @@ EC055273524821D3351EF36A /* GhosttyTerminalCoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalCoreView.swift; sourceTree = ""; }; F127EC360B82F2E804AF82D3 /* TmuxTerminalScreenAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalScreenAdapterTests.swift; sourceTree = ""; }; F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIOSurfaceFrame.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 = ""; }; @@ -359,6 +363,7 @@ EBCBF6802FFEFC86BF1AAE4F /* GhosttyKeyboardVisibilityProjectionTests.swift */, 991EF1D262BD8AA86A113A21 /* GhosttyKitControlSurfaceTests.swift */, C87D3C5A05813B59CE76559C /* GhosttyModifierStateTests.swift */, + F4572DCA39CA3C26E446A98D /* GhosttyPublishedFrameObserverTests.swift */, 523D89AB51C587C880ABC74F /* GhosttyScrollDeltaBudgetTests.swift */, C8CC6B54296A1389D419EFDB /* GhosttySurfaceScrollGestureTests.swift */, 0B36247C24B0EFA6F0FAF9F3 /* GhosttyTerminalCompositionStateTests.swift */, @@ -476,6 +481,7 @@ DB587FE0CD381E9401F1040A /* GhosttyModifierState.swift */, 71772BE1F5108FFC309BCC46 /* GhosttyPanePreviewSession.swift */, 04577A2C3BBC8458C5A60A76 /* GhosttyPaneScrollContainerView.swift */, + A49304A7038A93C9A55D7F2C /* GhosttyPublishedFrameObserver.swift */, 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.swift */, 3C290B472A5F07F07F8B1DA7 /* GhosttyScrollPhysicsView.swift */, 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */, @@ -725,6 +731,7 @@ EDAC14212B0E6E2F7CD8B3CA /* GhosttyModifierState.swift in Sources */, 560C8AF64E8C5031E37DA781 /* GhosttyPanePreviewSession.swift in Sources */, D59928A947468A35FBE0FA53 /* GhosttyPaneScrollContainerView.swift in Sources */, + E4C3B69390643F74AAE51D48 /* GhosttyPublishedFrameObserver.swift in Sources */, F6EB8B544E101B7B7FE4ED5F /* GhosttyRuntimeSurfaceTopologySnapshot.swift in Sources */, AFE0787AFAB5605F12537763 /* GhosttyRuntimeTrace.swift in Sources */, C5CA42EC2B7413DB3A326D73 /* GhosttyScrollPhysicsView.swift in Sources */, @@ -816,6 +823,7 @@ 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 */, 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/Tmux/TmuxPaneSurface.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift index 6ec55dcd..9077e9a2 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift @@ -46,6 +46,7 @@ final class TmuxPaneSurface { private var presentationTask: Task? private var presentationGeneration: UInt64 = 0 private let previewRelay = PreviewRelay() + private let publishedFrameObserver = GhosttyPublishedFrameObserver() private var canonicalViewportMetrics: GhosttySurfaceDisplayMetrics private var appliedDisplayMetrics: GhosttySurfaceDisplayMetrics @@ -348,6 +349,7 @@ final class TmuxPaneSurface { ) managed.onDisplayUpdate = onDisplayUpdate managedSurface = managed + installPublishedFrameInteractionObservation() applyPresentationActivity() return managed } @@ -356,6 +358,7 @@ final class TmuxPaneSurface { 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. @@ -488,6 +491,7 @@ final class TmuxPaneSurface { 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. @@ -571,6 +575,7 @@ final class TmuxPaneSurface { lifecycle = .active rendererFailureReported = false managedSurface?.replaceControlSurface(wrapper) + installPublishedFrameInteractionObservation() completion(.replaced) } } @@ -606,6 +611,7 @@ final class TmuxPaneSurface { guard lifecycle != .closing else { return } let wasReplacing = lifecycle == .replacing lifecycle = .closing + publishedFrameObserver.invalidate() cancelPresentationPreparation() previewRelay.pane = nil failureRelay.pane = nil @@ -737,6 +743,14 @@ final class TmuxPaneSurface { return .init(image: image, source: source) } + private func installPublishedFrameInteractionObservation() { + guard lifecycle == .active, + let managedSurface, + let rendererLayer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer) + else { return } + publishedFrameObserver.observe(rendererLayer, target: managedSurface) + } + private func rendererDidFail() { guard lifecycle == .active, !rendererFailureReported else { return } rendererFailureReported = true @@ -756,6 +770,7 @@ final class TmuxPaneSurface { private func destroyUnregisteredRenderer() { lifecycle = .closed + publishedFrameObserver.invalidate() cancelPresentationPreparation() previewRelay.pane = nil failureRelay.pane = nil 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/UPSTREAM.md b/MoriRemote/UPSTREAM.md index 92ab7fef..1d87ffa9 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -63,6 +63,7 @@ transport remains solely a terminal-core test fixture. | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | +| `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. | | iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | From 9bbc11ffc042ecc8819a91ddcd6948afdda180ba Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 22:52:42 +0800 Subject: [PATCH 16/22] moriremote: remove superseded pane previews The app-owned searchable Navigator is now the sole window/pane browser. Delete the unreachable upstream selection sheets, pixel capture/cache, picker-grid resizing, and their tests while retaining renderer-frame publication for safe presentation and scroll-state refresh. --- .../MoriRemote.xcodeproj/project.pbxproj | 28 +- .../Ghostty/GhosttyIOSurfaceFrame.swift | 129 ---- .../Ghostty/GhosttyPanePreviewSession.swift | 206 ----- .../Ghostty/GhosttyRendererLayer.swift | 28 + .../Ghostty/GhosttySingleViewportView.swift | 4 +- .../GhosttySurfaceSelectionSheet.swift | 709 ------------------ .../Ghostty/GhosttyTerminalCoreView.swift | 4 +- ...GhosttyTerminalPresentationProjector.swift | 162 +--- .../GhosttyTerminalScreenModeling.swift | 30 +- .../Ghostty/GhosttyTopLevelSurface.swift | 3 - .../Ghostty/PanePreviewLayout.swift | 207 ----- .../Ghostty/TerminalSelectionSheetStyle.swift | 243 ------ .../Tmux/TmuxPanePreviewImageCache.swift | 87 --- .../Tmux/TmuxPaneSurface.swift | 368 +-------- .../Tmux/TmuxTerminalScreenAdapter.swift | 205 +---- .../Tmux/TmuxTerminalSession.swift | 24 - .../TmuxTerminalScreenAdapterTests.swift | 244 ++---- MoriRemote/UPSTREAM.md | 22 +- 18 files changed, 135 insertions(+), 2568 deletions(-) delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyIOSurfaceFrame.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPanePreviewSession.swift create mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRendererLayer.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceSelectionSheet.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/PanePreviewLayout.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/TerminalSelectionSheetStyle.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxPanePreviewImageCache.swift diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index a339b6b1..441418be 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -30,7 +30,6 @@ 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 */; }; - 32D5675A41236D9050F9D6FC /* TerminalSelectionSheetStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFD021A4570F40A100E02F4 /* TerminalSelectionSheetStyle.swift */; }; 3465552A378696C80FABA606 /* TmuxSessionLinkWriteFailureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F848DC8CEFD90068DE779866 /* TmuxSessionLinkWriteFailureTests.swift */; }; 376EEC1B30EE8565C4B085D1 /* MoriRemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F3182F8D251430A10B298E /* MoriRemoteApp.swift */; }; 3A1491877954A342656AEBF9 /* GhosttyTerminalViewportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9659C2FCA72254982C81D686 /* GhosttyTerminalViewportCoordinator.swift */; }; @@ -50,7 +49,6 @@ 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 */; }; - 560C8AF64E8C5031E37DA781 /* GhosttyPanePreviewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71772BE1F5108FFC309BCC46 /* GhosttyPanePreviewSession.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 */; }; @@ -72,12 +70,11 @@ 7EB9D2C181E73D43B40C9485 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E788C063B75F299CBC1D0165 /* PrivacyInfo.xcprivacy */; }; 83049E3D188D4C2A9784F9FB /* GhosttyTerminalDisconnectReasonClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */; }; 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 */; }; - 91626D0DA7669135F90E633C /* GhosttySurfaceSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618CC6C670DB54BA190E5827 /* GhosttySurfaceSelectionSheet.swift */; }; 9213AD0FB635C3806A8C0010 /* MoriRemoteTerminalProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80AB026D4A388B7B0DCEBD4 /* MoriRemoteTerminalProbe.swift */; }; - 94A3CF3A125B7DE1A0C08E11 /* PanePreviewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66685A19C08961A3417917DE /* PanePreviewLayout.swift */; }; 950C33367217BF06FA564D2B /* NIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6712048F2C2EC6961F582380 /* NIO */; }; 954E0187B8822478AC53A4A6 /* MoriRemoteTerminal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CDAD6C595773E27C2302A0E2 /* MoriRemoteTerminal.framework */; }; 968FC5B380206254527E3718 /* TmuxTerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DC599E9268D13F97F75BC /* TmuxTerminalSession.swift */; }; @@ -98,7 +95,6 @@ B4BB4188870E6A3BD8A84509 /* TerminalRuntimeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */; }; C07777BEE0CE5B8012A02C74 /* GhosttyTerminalResponderFocusPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62BCDBA618379FE5456A1C57 /* GhosttyTerminalResponderFocusPolicyTests.swift */; }; C15730869E5A9CF770E3D2CC /* GhosttyTmuxPrefixInputBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */; }; - C22FAD380B109CB1806083A6 /* TmuxPanePreviewImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D155521D2475C24D89AE1677 /* TmuxPanePreviewImageCache.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 */; }; @@ -110,7 +106,6 @@ E4C3B69390643F74AAE51D48 /* GhosttyPublishedFrameObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A49304A7038A93C9A55D7F2C /* GhosttyPublishedFrameObserver.swift */; }; E75088D081F5457454778A3D /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E9E52C78F643675B58F0BA /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */; }; E84DA64184225212B72BE0EA /* GhosttyScrollDeltaBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523D89AB51C587C880ABC74F /* GhosttyScrollDeltaBudgetTests.swift */; }; - EA704DB81EBCED68696937A6 /* GhosttyIOSurfaceFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.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 */; }; @@ -187,16 +182,13 @@ 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 = ""; }; - 618CC6C670DB54BA190E5827 /* GhosttySurfaceSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceSelectionSheet.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; }; - 66685A19C08961A3417917DE /* PanePreviewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanePreviewLayout.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 = ""; }; 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurface.swift; sourceTree = ""; }; - 71772BE1F5108FFC309BCC46 /* GhosttyPanePreviewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPanePreviewSession.swift; sourceTree = ""; }; 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySingleViewportView.swift; sourceTree = ""; }; 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurfaceTests.swift; sourceTree = ""; }; @@ -205,7 +197,6 @@ 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMetadataProjector.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 = ""; }; - 8BFD021A4570F40A100E02F4 /* TerminalSelectionSheetStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSelectionSheetStyle.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 = ""; }; 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalScreenModeling.swift; sourceTree = ""; }; @@ -240,7 +231,6 @@ 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 = ""; }; - D155521D2475C24D89AE1677 /* TmuxPanePreviewImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxPanePreviewImageCache.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 = ""; }; @@ -255,13 +245,13 @@ 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 = ""; }; - F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIOSurfaceFrame.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 */ @@ -325,7 +315,6 @@ C6CD869EC2CF2DD3481DE8E9 /* TmuxControlTransport.swift */, 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */, 8C2B40FFD54FA5E83ED47253 /* TmuxIdentity.swift */, - D155521D2475C24D89AE1677 /* TmuxPanePreviewImageCache.swift */, A7F69592AA4A0788015A2A51 /* TmuxPaneSurface.swift */, 4D20C034CF2CE559BF155AD4 /* TmuxScreenModel.swift */, F6FCBEFA092CA08F879265D1 /* TmuxSessionController.swift */, @@ -468,7 +457,6 @@ isa = PBXGroup; children = ( 9F7ADE8147E90699397F2846 /* GhosttyImageAttachmentSheet.swift */, - F13A6169DA7B5D6833F20F92 /* GhosttyIOSurfaceFrame.swift */, 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */, D5A15305D227B6DD4A6C8CBE /* GhosttyKeyboardCursorTrackpad.swift */, 1839CAA9C61F3104CB8231C6 /* GhosttyKeyboardCursorTrackpadHUD.swift */, @@ -479,16 +467,15 @@ 20D69DE7D59B2C5BA8D15339 /* GhosttyManagedSurface.swift */, B3EBA5074382AC1C70BEB15E /* GhosttyManagedSurfaceLookup.swift */, DB587FE0CD381E9401F1040A /* GhosttyModifierState.swift */, - 71772BE1F5108FFC309BCC46 /* GhosttyPanePreviewSession.swift */, 04577A2C3BBC8458C5A60A76 /* GhosttyPaneScrollContainerView.swift */, A49304A7038A93C9A55D7F2C /* GhosttyPublishedFrameObserver.swift */, + FFF190110E6756726243B4B5 /* GhosttyRendererLayer.swift */, 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.swift */, 3C290B472A5F07F07F8B1DA7 /* GhosttyScrollPhysicsView.swift */, 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */, BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */, 3DF6A6CC81A7E40BF7BB825E /* GhosttySurfaceMouseEvent.swift */, 44A50387A301B14D8E30A167 /* GhosttySurfaceScrollGesture.swift */, - 618CC6C670DB54BA190E5827 /* GhosttySurfaceSelectionSheet.swift */, 9BD23FE8E78A324650AA77CC /* GhosttyTerminalCompositionState.swift */, EC055273524821D3351EF36A /* GhosttyTerminalCoreView.swift */, A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */, @@ -504,8 +491,6 @@ 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */, 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */, 676FDCFD6BAB360A6FDE7151 /* GhosttyViewportSizing.swift */, - 66685A19C08961A3417917DE /* PanePreviewLayout.swift */, - 8BFD021A4570F40A100E02F4 /* TerminalSelectionSheetStyle.swift */, ); path = Ghostty; sourceTree = ""; @@ -717,7 +702,6 @@ buildActionMask = 2147483647; files = ( 055D7CE8194A8EB00C6AB48F /* DeterministicTmuxControlTransport.swift in Sources */, - EA704DB81EBCED68696937A6 /* GhosttyIOSurfaceFrame.swift in Sources */, 26B09ED95683BE1B0C6484A6 /* GhosttyImageAttachmentSheet.swift in Sources */, 25683F5BC20807C3F9ADE249 /* GhosttyKeyboardChrome.swift in Sources */, F1D09D202AF835D9ED07031C /* GhosttyKeyboardCursorTrackpad.swift in Sources */, @@ -729,9 +713,9 @@ 3CC26EB85FA319E85F2D36BC /* GhosttyManagedSurface.swift in Sources */, 57EF884059F194983540CBCB /* GhosttyManagedSurfaceLookup.swift in Sources */, EDAC14212B0E6E2F7CD8B3CA /* GhosttyModifierState.swift in Sources */, - 560C8AF64E8C5031E37DA781 /* GhosttyPanePreviewSession.swift in Sources */, D59928A947468A35FBE0FA53 /* GhosttyPaneScrollContainerView.swift in Sources */, E4C3B69390643F74AAE51D48 /* GhosttyPublishedFrameObserver.swift in Sources */, + 8811DFD8D63BC3F6263EFCF1 /* GhosttyRendererLayer.swift in Sources */, F6EB8B544E101B7B7FE4ED5F /* GhosttyRuntimeSurfaceTopologySnapshot.swift in Sources */, AFE0787AFAB5605F12537763 /* GhosttyRuntimeTrace.swift in Sources */, C5CA42EC2B7413DB3A326D73 /* GhosttyScrollPhysicsView.swift in Sources */, @@ -739,7 +723,6 @@ 914D5C34DBB457D970A90C25 /* GhosttySurfaceKeyEvent.swift in Sources */, 1B4ABC9EE1AAD05752C0DDDE /* GhosttySurfaceMouseEvent.swift in Sources */, FBCC61A7E5D38B8184FF864E /* GhosttySurfaceScrollGesture.swift in Sources */, - 91626D0DA7669135F90E633C /* GhosttySurfaceSelectionSheet.swift in Sources */, 2302A7B4A772047379C73067 /* GhosttyTerminalCompositionState.swift in Sources */, 6D55ADE98CE693CA6802197D /* GhosttyTerminalCoreView.swift in Sources */, 83049E3D188D4C2A9784F9FB /* GhosttyTerminalDisconnectReasonClassifier.swift in Sources */, @@ -758,14 +741,11 @@ 31097F77B38B348F9E7E0BB3 /* Haptic.swift in Sources */, C3F4EADB4D52F73F75D6E750 /* MoriRemoteTerminalFacade.swift in Sources */, 9213AD0FB635C3806A8C0010 /* MoriRemoteTerminalProbe.swift in Sources */, - 94A3CF3A125B7DE1A0C08E11 /* PanePreviewLayout.swift in Sources */, B4BB4188870E6A3BD8A84509 /* TerminalRuntimeTypes.swift in Sources */, - 32D5675A41236D9050F9D6FC /* TerminalSelectionSheetStyle.swift in Sources */, ACE22CEBB527F6CDA6C44470 /* TerminalSettings.swift in Sources */, AE2B7491776C8FC853899464 /* TmuxControlTransport.swift in Sources */, C34E3D3F89FF5F790D22D0C0 /* TmuxControlViewport.swift in Sources */, 7ACD00781983EB3E3052D10F /* TmuxIdentity.swift in Sources */, - C22FAD380B109CB1806083A6 /* TmuxPanePreviewImageCache.swift in Sources */, 7B1B93EEB1DE05CA1966D556 /* TmuxPaneSurface.swift in Sources */, 160AABFF71D3C5A31ECC918F /* TmuxScreenModel.swift in Sources */, 7608ABD730F2113B6100141F /* TmuxSessionController.swift in Sources */, diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyIOSurfaceFrame.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyIOSurfaceFrame.swift deleted file mode 100644 index 7e0ee2d3..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyIOSurfaceFrame.swift +++ /dev/null @@ -1,129 +0,0 @@ -import CoreGraphics -import Foundation -import IOSurface -import QuartzCore - -/// Swift-owned pixels copied from one frame already published by Ghostty's -/// IOSurface renderer layer. Reading never asks the renderer to draw. -struct GhosttyIOSurfaceFrame: Sendable { - enum ReadError: Error { - case lockFailed - case invalidGeometry - case unsupportedPixelFormat(UInt32) - case imageCreationFailed - } - - private static let bgraPixelFormat: UInt32 = 0x4247_5241 - - let width: Int - let height: Int - let bytesPerRow: Int - let bytes: Data - - static func rendererLayer(in viewLayer: CALayer) -> CALayer? { - let layers = viewLayer.sublayers ?? [] - if let published = layers.first(where: { iosurface(from: $0) != nil }) { - return published - } - // Ghostty's iOS Metal renderer installs exactly one direct sublayer. - // Before its first frame that layer has no contents yet. - 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)) - } - - /// Copies the currently published IOSurface while the caller still owns - /// the layer publication boundary. Retaining the IOSurface is not enough: - /// Metal reuses its fixed frame targets, so later draws may overwrite the - /// same allocation even while another owner holds a CF reference. - static func read(from layer: CALayer) throws -> Self { - guard let surface = iosurface(from: layer) else { - throw ReadError.invalidGeometry - } - guard IOSurfaceLock(surface, .readOnly, nil) == 0 else { - throw ReadError.lockFailed - } - defer { IOSurfaceUnlock(surface, .readOnly, nil) } - - let width = IOSurfaceGetWidth(surface) - let height = IOSurfaceGetHeight(surface) - let bytesPerRow = IOSurfaceGetBytesPerRow(surface) - guard width > 0, height > 0, bytesPerRow >= width * 4 else { - throw ReadError.invalidGeometry - } - let optionalBase: UnsafeMutableRawPointer? = IOSurfaceGetBaseAddress(surface) - guard let base = optionalBase else { - throw ReadError.invalidGeometry - } - let pixelFormat = IOSurfaceGetPixelFormat(surface) - guard pixelFormat == bgraPixelFormat else { - throw ReadError.unsupportedPixelFormat(pixelFormat) - } - let (byteCount, overflow) = bytesPerRow.multipliedReportingOverflow(by: height) - guard !overflow else { throw ReadError.invalidGeometry } - return Self( - width: width, - height: height, - bytesPerRow: bytesPerRow, - bytes: Data(bytes: base, count: byteCount) - ) - } - - func image(maxWidth: UInt32, maxHeight: UInt32) throws -> CGImage { - guard maxWidth > 0, maxHeight > 0, - let provider = CGDataProvider(data: bytes as CFData), - let source = CGImage( - width: width, - height: height, - bitsPerComponent: 8, - bitsPerPixel: 32, - bytesPerRow: bytesPerRow, - space: Self.colorSpace, - bitmapInfo: Self.bitmapInfo, - provider: provider, - decode: nil, - shouldInterpolate: false, - intent: .defaultIntent - ) - else { throw ReadError.imageCreationFailed } - - let scale = min( - 1, - min(Double(maxWidth) / Double(width), Double(maxHeight) / Double(height)) - ) - guard scale < 1 else { return source } - - let targetWidth = max(1, Int((Double(width) * scale).rounded(.down))) - let targetHeight = max(1, Int((Double(height) * scale).rounded(.down))) - let targetBytesPerRow = targetWidth * 4 - guard let context = CGContext( - data: nil, - width: targetWidth, - height: targetHeight, - bitsPerComponent: 8, - bytesPerRow: targetBytesPerRow, - space: Self.colorSpace, - bitmapInfo: Self.bitmapInfo.rawValue - ) else { throw ReadError.imageCreationFailed } - context.interpolationQuality = .medium - context.draw(source, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)) - guard let image = context.makeImage() else { throw ReadError.imageCreationFailed } - return image - } - - private static let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) - ?? CGColorSpaceCreateDeviceRGB() - private static let bitmapInfo = CGBitmapInfo.byteOrder32Little.union( - CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipFirst.rawValue) - ) - - 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/GhosttyPanePreviewSession.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPanePreviewSession.swift deleted file mode 100644 index 980d6e94..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyPanePreviewSession.swift +++ /dev/null @@ -1,206 +0,0 @@ -import CoreGraphics -import Foundation - -/// Picker-scoped cached image state plus one sequential asynchronous capture -/// task. Native renderer and terminal lifetime remain owned by the tmux -/// session; this type owns no handles, callbacks, retries, or render queue. -@MainActor -final class GhosttyPanePreviewSession: ObservableObject { - struct FullViewportProvenance: Equatable { - let surfaceID: UUID - let pixelWidth: UInt32 - let pixelHeight: UInt32 - } - - struct PaneGeometryProvenance: Equatable { - let surfaceID: UUID - let columns: UInt32 - let rows: UInt32 - } - - enum PreviewSource: Equatable { - case paneGeometry(PaneGeometryProvenance) - case fullViewport(FullViewportProvenance) - } - - struct RenderedPreview { - let image: CGImage - let source: PreviewSource - } - - struct PixelBudget: Equatable, Sendable { - let width: UInt32 - let height: UInt32 - } - - struct PreviewClient { - let capture: @MainActor (UUID, PixelBudget) async -> RenderedPreview? - let cancelCapture: @MainActor (UUID) -> Void - let cachedPreview: @MainActor (UUID) -> RenderedPreview? - let shouldRefreshCachedImage: @MainActor (UUID) -> Bool - let cacheRenderedPreview: @MainActor (UUID, RenderedPreview) -> Void - - init( - capture: @escaping @MainActor (UUID, PixelBudget) async -> RenderedPreview?, - cancelCapture: @escaping @MainActor (UUID) -> Void = { _ in }, - cachedPreview: @escaping @MainActor (UUID) -> RenderedPreview? = { _ in nil }, - shouldRefreshCachedImage: @escaping @MainActor (UUID) -> Bool = { _ in true }, - cacheRenderedPreview: @escaping @MainActor (UUID, RenderedPreview) -> Void = { _, _ in } - ) { - self.capture = capture - self.cancelCapture = cancelCapture - self.cachedPreview = cachedPreview - self.shouldRefreshCachedImage = shouldRefreshCachedImage - self.cacheRenderedPreview = cacheRenderedPreview - } - } - - enum PreviewSizing: Equatable { - case paneGrid(availableWidth: CGFloat) - case windowGrid(availableWidth: CGFloat) - - @MainActor - static var paneGridForCurrentScreen: PreviewSizing { - .paneGrid(availableWidth: PanePreviewLayout.currentSheetContentWidth()) - } - - @MainActor - static var windowGridForCurrentScreen: PreviewSizing { - .windowGrid(availableWidth: PanePreviewLayout.currentSheetContentWidth()) - } - } - - enum PreviewState { - case pending - case ready(RenderedPreview) - case failed - } - - let id = UUID() - @Published private(set) var imagesByPaneID: [UUID: PreviewState] = [:] - - private let displayScale: CGFloat - private let previewSizing: PreviewSizing - private let client: PreviewClient - private var trackedLeafIDs: [UUID] - private var refreshTask: Task? - private var didStartRefreshing = false - private var cancelled = false - private var generation: UInt64 = 0 - private var activeCaptureLeafID: UUID? - - init( - leafIDs: [UUID], - scale: CGFloat = PanePreviewLayout.currentScale(), - previewSizing: PreviewSizing? = nil, - client: PreviewClient - ) { - displayScale = scale - self.previewSizing = previewSizing ?? .paneGridForCurrentScreen - self.client = client - trackedLeafIDs = Self.unique(leafIDs) - seedCachedImages(for: trackedLeafIDs) - } - - func startRefreshing() { - guard !didStartRefreshing, !cancelled else { return } - didStartRefreshing = true - restartRefresh() - } - - func reconcile(leafIDs: [UUID]) { - let next = Self.unique(leafIDs) - let nextSet = Set(next) - for removed in imagesByPaneID.keys where !nextSet.contains(removed) { - imagesByPaneID.removeValue(forKey: removed) - } - trackedLeafIDs = next - seedCachedImages(for: next) - guard didStartRefreshing, !cancelled else { return } - restartRefresh() - } - - func cancelAll() { - guard !cancelled else { return } - cancelled = true - generation &+= 1 - cancelActiveCapture() - refreshTask?.cancel() - refreshTask = nil - } - - private func restartRefresh() { - generation &+= 1 - let currentGeneration = generation - let leafIDs = trackedLeafIDs - let budget = pixelBudget(itemCount: max(leafIDs.count, 1)) - cancelActiveCapture() - refreshTask?.cancel() - refreshTask = Task { @MainActor [weak self] in - guard let self else { return } - for leafID in leafIDs { - guard !Task.isCancelled, - !cancelled, - generation == currentGeneration, - trackedLeafIDs.contains(leafID) - else { return } - - let cached = client.cachedPreview(leafID) - if let cached { imagesByPaneID[leafID] = .ready(cached) } - guard cached == nil || client.shouldRefreshCachedImage(leafID) else { continue } - if cached == nil { imagesByPaneID[leafID] = .pending } - - activeCaptureLeafID = leafID - let preview = await client.capture(leafID, budget) - if activeCaptureLeafID == leafID { activeCaptureLeafID = nil } - guard !Task.isCancelled, - !cancelled, - generation == currentGeneration, - trackedLeafIDs.contains(leafID) - else { return } - if let preview { - client.cacheRenderedPreview(leafID, preview) - imagesByPaneID[leafID] = .ready(preview) - } else if cached == nil { - imagesByPaneID[leafID] = .failed - } - } - } - } - - private func cancelActiveCapture() { - guard let leafID = activeCaptureLeafID else { return } - activeCaptureLeafID = nil - client.cancelCapture(leafID) - } - - private func seedCachedImages(for leafIDs: [UUID]) { - for leafID in leafIDs where imagesByPaneID[leafID] == nil { - if let cached = client.cachedPreview(leafID) { - imagesByPaneID[leafID] = .ready(cached) - } - } - } - - private func pixelBudget(itemCount: Int) -> PixelBudget { - let dimensions: (width: UInt32, height: UInt32) = switch previewSizing { - case .paneGrid(let availableWidth): - PanePreviewLayout.physicalPixelBudget( - paneCount: itemCount, - availableWidth: availableWidth, - scale: displayScale - ) - case .windowGrid(let availableWidth): - PanePreviewLayout.windowPhysicalPixelBudget( - availableWidth: availableWidth, - scale: displayScale - ) - } - return PixelBudget(width: dimensions.width, height: dimensions.height) - } - - private static func unique(_ leafIDs: [UUID]) -> [UUID] { - var seen: Set = [] - return leafIDs.filter { seen.insert($0).inserted } - } -} 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/GhosttySingleViewportView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift index 29caa4e1..cda1e409 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift @@ -2,8 +2,8 @@ import GhosttyKit import SwiftUI import UIKit -/// Hosts the single terminal surface presented by Remux. Pane/window topology belongs to -/// the picker model; it never participates in viewport layout. +/// 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 diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceSelectionSheet.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceSelectionSheet.swift deleted file mode 100644 index c42b5cff..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySurfaceSelectionSheet.swift +++ /dev/null @@ -1,709 +0,0 @@ -import SwiftUI - -enum GhosttySurfaceSelectionSheet: Identifiable { - case windows(GhosttyPanePreviewSession) - case panes(topLevelID: UUID, previews: GhosttyPanePreviewSession) - - var id: String { - switch self { - case .windows(_): - "windows" - case .panes(let topLevelID, let previews): - "panes-\(topLevelID.uuidString)-\(previews.id.uuidString)" - } - } - - var paneTopLevelIDForTopologyValidation: UUID? { - switch self { - case .windows(_): - nil - case .panes(let topLevelID, _): - topLevelID - } - } -} - -struct GhosttyWindowSelectionSheet: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - @ObservedObject var session: GhosttyPanePreviewSession - @State private var pendingRemoval: GhosttyWindowRemovalRequest? - @State private var pendingContextAction: GhosttyWindowRemovalRequest? - - let projection: GhosttyWindowSelectionSheetRenderProjection - let sessionName: String - let onCreateWindow: (() -> Void)? - let onSelect: (UUID) -> Void - let onRemoveWindow: (UUID) -> Void - - var body: some View { - let layout = PanePreviewLayout.windowMetricsForCurrentScreen() - - TerminalSelectionSheetScaffold( - title: "Windows", - context: "\(sessionName) · \(projection.windows.count) \(projection.windows.count == 1 ? "window" : "windows")", - closeAccessibilityIdentifier: "terminal.windows.close" - ) { - ScrollView(showsIndicators: false) { - windowGrid( - windows: projection.windows, - layout: layout - ) - } - .accessibilityIdentifier("terminal.windows.scroll") - .contentMargins(.horizontal, 16, for: .scrollContent) - } actions: { - TerminalSelectionSheetActionButton( - title: "New Window", - systemName: "plus", - accessibilityIdentifier: "terminal.window.new", - action: onCreateWindow - ) - } - .task(id: session.id) { - session.reconcile(leafIDs: projection.previewLeafIDs) - await Task.yield() - guard !Task.isCancelled else { return } - GhosttyRuntimeTrace.perf("panePreview.presentation activate kind=windows") - session.startRefreshing() - } - .onChange(of: projection.previewLeafIDs) { _, newValue in - session.reconcile(leafIDs: newValue) - } - .overlayPreferenceValue(GhosttySelectionTileBoundsPreferenceKey.self) { bounds in - GhosttySelectionContextActionOverlay( - bounds: bounds, - action: pendingContextAction.map { - GhosttySelectionContextActionPresentation( - id: $0.id, - title: "Remove Window \($0.displayIndex)", - accessibilityIdentifier: "terminal.window.remove.\($0.displayIndex)" - ) - }, - perform: confirmPendingContextAction, - dismiss: dismissPendingContextAction - ) - } - .confirmationDialog( - "Remove Window?", - isPresented: pendingRemovalBinding, - titleVisibility: .visible, - presenting: pendingRemoval - ) { request in - Button("Remove Window \(request.displayIndex)", role: .destructive) { - onRemoveWindow(request.id) - pendingRemoval = nil - } - .accessibilityIdentifier("terminal.window.remove.confirm.\(request.displayIndex)") - } message: { request in - Text(windowRemovalMessage(for: request)) - } - .accessibilityElement(children: .contain) - .accessibilityIdentifier("terminal.windows.sheet") - } - - private func windowGrid( - windows: [GhosttyWindowSelectionSheetRenderProjection.Window], - layout: PanePreviewLayout.Metrics - ) -> some View { - LazyVGrid( - columns: Array( - repeating: GridItem(.fixed(layout.tilePointSize.width), spacing: layout.gridSpacing), - count: layout.columnCount - ), - alignment: .center, - spacing: layout.gridSpacing - ) { - ForEach(windows) { window in - Button { - Haptic.selection() - onSelect(window.id) - } label: { - GhosttyWindowSelectionTile( - displayIndex: window.displayIndex, - displayName: window.displayName, - totalCount: window.totalCount, - paneCount: window.paneCount, - isSelected: window.isSelected, - previewState: window.focusedPreviewPaneID - .flatMap { session.imagesByPaneID[$0] }, - chromeStyle: chromeStyle, - layout: layout - ) - } - .buttonStyle(.plain) - .accessibilityIdentifier("terminal.window.tile.\(window.displayIndex)") - .anchorPreference(key: GhosttySelectionTileBoundsPreferenceKey.self, value: .bounds) { - [window.id: $0] - } - .highPriorityGesture( - LongPressGesture(minimumDuration: 0.42, maximumDistance: 18) - .onEnded { _ in - Haptic.warning() - pendingContextAction = GhosttyWindowRemovalRequest( - id: window.id, - displayIndex: window.displayIndex, - paneCount: window.paneCount - ) - } - ) - .accessibilityAction(named: Text("Remove Window \(window.displayIndex)")) { - Haptic.warning() - pendingRemoval = GhosttyWindowRemovalRequest( - id: window.id, - displayIndex: window.displayIndex, - paneCount: window.paneCount - ) - } - } - - } - .frame(maxWidth: .infinity, alignment: .top) - } - - private var pendingRemovalBinding: Binding { - Binding( - get: { pendingRemoval != nil }, - set: { isPresented in - if !isPresented { - pendingRemoval = nil - pendingContextAction = nil - } - } - ) - } - - private func confirmPendingContextAction() { - pendingRemoval = pendingContextAction - pendingContextAction = nil - } - - private func dismissPendingContextAction() { - pendingContextAction = nil - } - - private func windowRemovalMessage(for request: GhosttyWindowRemovalRequest) -> String { - "This will close Window \(request.displayIndex) and \(request.paneCount) \(request.paneCount == 1 ? "pane" : "panes")." - } -} - -struct GhosttyPaneSelectionSheet: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - @ObservedObject var session: GhosttyPanePreviewSession - @State private var pendingRemoval: GhosttyPaneRemovalRequest? - @State private var pendingContextAction: GhosttyPaneRemovalRequest? - - let projection: GhosttyPaneSelectionSheetRenderProjection - let onSplitPane: (() -> Void)? - let onStackPane: (() -> Void)? - let onSelect: (UUID) -> Void - let onRemovePane: (UUID) -> Void - - var body: some View { - let layout = PanePreviewLayout.metricsForCurrentScreen(for: projection.paneCount) - - TerminalSelectionSheetScaffold( - title: "Panes", - context: "\(projection.paneCount) \(projection.paneCount == 1 ? "pane" : "panes")", - closeAccessibilityIdentifier: "terminal.panes.close" - ) { - ScrollView(showsIndicators: false) { - paneLayout( - panes: projection.panes, - layout: layout, - onRemove: { pane in - pendingContextAction = GhosttyPaneRemovalRequest( - id: pane.id, - displayIndex: pane.displayIndex, - isOnlyPane: projection.paneCount == 1 - ) - } - ) - } - .accessibilityIdentifier("terminal.panes.scroll") - .contentMargins(.horizontal, 16, for: .scrollContent) - } actions: { - HStack(spacing: 10) { - TerminalSelectionSheetActionButton( - title: "Split", - systemName: "square.split.2x1", - accessibilityIdentifier: "terminal.pane.split", - action: onSplitPane - ) - - TerminalSelectionSheetActionButton( - title: "Stack", - systemName: "square.split.1x2", - accessibilityIdentifier: "terminal.pane.stack", - action: onStackPane - ) - } - } - .task(id: session.id) { - // First-render reconcile closes the gap between tap-time session - // creation and the sheet's initial body render. If pane - // membership changed during presentation, the session must align - // immediately with the leaf IDs the sheet is actually showing. - session.reconcile(leafIDs: projection.previewLeafIDs) - await Task.yield() - guard !Task.isCancelled else { return } - GhosttyRuntimeTrace.perf("panePreview.presentation activate kind=panes") - session.startRefreshing() - } - .onChange(of: projection.previewLeafIDs) { _, newValue in - session.reconcile(leafIDs: newValue) - } - .overlayPreferenceValue(GhosttySelectionTileBoundsPreferenceKey.self) { bounds in - GhosttySelectionContextActionOverlay( - bounds: bounds, - action: pendingContextAction.map { - GhosttySelectionContextActionPresentation( - id: $0.id, - title: "Remove Pane \($0.displayIndex)", - accessibilityIdentifier: "terminal.pane.remove.\($0.displayIndex)" - ) - }, - perform: confirmPendingContextAction, - dismiss: dismissPendingContextAction - ) - } - .confirmationDialog( - "Remove Pane?", - isPresented: pendingRemovalBinding, - titleVisibility: .visible, - presenting: pendingRemoval - ) { request in - Button("Remove Pane \(request.displayIndex)", role: .destructive) { - onRemovePane(request.id) - pendingRemoval = nil - } - .accessibilityIdentifier("terminal.pane.remove.confirm.\(request.displayIndex)") - } message: { request in - Text(paneRemovalMessage(for: request)) - } - .accessibilityElement(children: .contain) - .accessibilityIdentifier("terminal.panes.sheet") - } - - private func paneLayout( - panes: [GhosttyPaneSelectionSheetRenderProjection.Pane], - layout: PanePreviewLayout.Metrics, - onRemove: @escaping (GhosttyPaneSelectionSheetRenderProjection.Pane) -> Void - ) -> some View { - LazyVGrid( - columns: Array( - repeating: GridItem(.fixed(layout.tilePointSize.width), spacing: layout.gridSpacing), - count: layout.columnCount - ), - alignment: .center, - spacing: layout.gridSpacing - ) { - ForEach(panes) { pane in - Button { - Haptic.selection() - onSelect(pane.id) - } label: { - GhosttyPaneSelectionTile( - displayIndex: pane.displayIndex, - totalCount: pane.totalCount, - isSelected: pane.isSelected, - state: session.imagesByPaneID[pane.id], - chromeStyle: chromeStyle, - layout: layout - ) - } - .buttonStyle(.plain) - .accessibilityIdentifier("terminal.pane.tile.\(pane.displayIndex)") - .anchorPreference(key: GhosttySelectionTileBoundsPreferenceKey.self, value: .bounds) { - [pane.id: $0] - } - .highPriorityGesture( - LongPressGesture(minimumDuration: 0.42, maximumDistance: 18) - .onEnded { _ in - Haptic.warning() - onRemove(pane) - } - ) - .accessibilityAction(named: Text("Remove Pane \(pane.displayIndex)")) { - Haptic.warning() - pendingRemoval = GhosttyPaneRemovalRequest( - id: pane.id, - displayIndex: pane.displayIndex, - isOnlyPane: pane.totalCount == 1 - ) - } - } - } - .frame(maxWidth: .infinity, alignment: .top) - } - - private var pendingRemovalBinding: Binding { - Binding( - get: { pendingRemoval != nil }, - set: { isPresented in - if !isPresented { - pendingRemoval = nil - pendingContextAction = nil - } - } - ) - } - - private func confirmPendingContextAction() { - pendingRemoval = pendingContextAction - pendingContextAction = nil - } - - private func dismissPendingContextAction() { - pendingContextAction = nil - } - - private func paneRemovalMessage(for request: GhosttyPaneRemovalRequest) -> String { - if request.isOnlyPane { - return "This is the only pane in the window, so removing it can close the window too." - } - return "This will close Pane \(request.displayIndex)." - } -} - -private struct GhosttyWindowRemovalRequest: Identifiable { - let id: UUID - let displayIndex: Int - let paneCount: Int -} - -private struct GhosttyPaneRemovalRequest: Identifiable { - let id: UUID - let displayIndex: Int - let isOnlyPane: Bool -} - -private struct GhosttySelectionContextActionPresentation: Identifiable, Equatable { - let id: UUID - let title: String - let accessibilityIdentifier: String -} - -private struct GhosttySelectionTileBoundsPreferenceKey: PreferenceKey { - static let defaultValue: [UUID: Anchor] = [:] - - static func reduce(value: inout [UUID: Anchor], nextValue: () -> [UUID: Anchor]) { - value.merge(nextValue(), uniquingKeysWith: { _, newValue in newValue }) - } -} - -private struct GhosttySelectionContextActionOverlay: View { - let bounds: [UUID: Anchor] - let action: GhosttySelectionContextActionPresentation? - let perform: () -> Void - let dismiss: () -> Void - - var body: some View { - GeometryReader { proxy in - if let action, let anchor = bounds[action.id] { - let tileFrame = proxy[anchor] - - ZStack { - Color.black.opacity(0.001) - .ignoresSafeArea() - .contentShape(Rectangle()) - .onTapGesture(perform: dismiss) - - GhosttySelectionContextActionButton( - title: action.title, - accessibilityIdentifier: action.accessibilityIdentifier, - action: perform - ) - .position(actionPosition(for: tileFrame, in: proxy.size)) - .transition(.scale(scale: 0.94).combined(with: .opacity)) - } - .animation(.spring(response: 0.24, dampingFraction: 0.82), value: action) - } - } - } - - private func actionPosition(for tileFrame: CGRect, in containerSize: CGSize) -> CGPoint { - let actionSize = GhosttySelectionContextActionButton.metrics.size - let edgeMargin: CGFloat = 10 - let cornerInset: CGFloat = 18 - let x = min( - max(tileFrame.maxX - cornerInset, actionSize.width / 2 + edgeMargin), - containerSize.width - actionSize.width / 2 - edgeMargin - ) - let y = min( - max(tileFrame.minY + cornerInset, actionSize.height / 2 + edgeMargin), - containerSize.height - actionSize.height / 2 - edgeMargin - ) - return CGPoint(x: x, y: y) - } -} - -private struct GhosttySelectionContextActionButton: View { - struct Metrics { - let size = CGSize(width: 44, height: 44) - } - - static let metrics = Metrics() - - let title: String - let accessibilityIdentifier: String - let action: () -> Void - - var body: some View { - Button { - Haptic.tap() - action() - } label: { - Image(systemName: "trash") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(GhosttySelectionContextActionPalette.destructiveText) - .frame(width: Self.metrics.size.width, height: Self.metrics.size.height) - .ghosttySelectionContextActionSurface() - } - .buttonStyle(GhosttySelectionContextActionButtonStyle()) - .accessibilityIdentifier(accessibilityIdentifier) - .accessibilityLabel(title) - } -} - -private struct GhosttySelectionContextActionButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.975 : 1) - .animation(.easeOut(duration: 0.12), value: configuration.isPressed) - } -} - -private enum GhosttySelectionContextActionPalette { - static let fallbackFill = Color(uiColor: .secondarySystemBackground).opacity(0.92) - static let glassTint = Color.primary.opacity(0.055) - static let destructiveText = Color(uiColor: .systemRed) - static let stroke = Color.primary.opacity(0.11) - static let shadow = Color.black.opacity(0.20) -} - -private struct GhosttyRenderedPreviewSurface: View { - let preview: GhosttyPanePreviewSession.RenderedPreview - let size: CGSize - - var body: some View { - Image(decorative: preview.image, scale: PanePreviewLayout.currentScale()) - .resizable() - .aspectRatio(contentMode: contentMode) - .frame(width: size.width, height: size.height) - .background(Color.black.opacity(0.30)) - .clipped() - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - } - - private var contentMode: ContentMode { - switch preview.source { - case .fullViewport: .fill - case .paneGeometry: .fit - } - } -} - -private struct GhosttyWindowSelectionTile: View { - let displayIndex: Int - let displayName: String - let totalCount: Int - let paneCount: Int - let isSelected: Bool - let previewState: GhosttyPanePreviewSession.PreviewState? - let chromeStyle: GhosttyTerminalChromeStyle - let layout: PanePreviewLayout.Metrics - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - previewSurface - caption - } - .padding(layout.tilePadding) - .frame( - width: layout.tilePointSize.width, - height: layout.tilePointSize.height, - alignment: .topLeading - ) - .terminalSelectionTileChrome(isSelected: isSelected, chromeStyle: chromeStyle) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - .accessibilityValue(previewState.accessibilityValue) - .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) - } - - @ViewBuilder - private var previewSurface: some View { - switch previewState { - case .ready(let preview): - GhosttyRenderedPreviewSurface( - preview: preview, - size: layout.previewPointSize - ) - - case .pending, .none, .failed: - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.black.opacity(0.30)) - .frame( - width: layout.previewPointSize.width, - height: layout.previewPointSize.height - ) - } - } - - private var caption: some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text("\(displayIndex)") - .font(.system(size: 11, weight: .semibold)) - .monospacedDigit() - .foregroundStyle(TerminalSelectionSheetPalette.tertiary) - - if !displayName.isEmpty { - Text(displayName) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(TerminalSelectionSheetPalette.primary) - .lineLimit(1) - .truncationMode(.tail) - } - - Spacer(minLength: 0) - } - - if paneCount > 1 { - HStack(spacing: 6) { - Text("\(paneCount)") - .monospacedDigit() - - Text("panes") - } - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(TerminalSelectionSheetPalette.secondary) - .lineLimit(1) - } - } - .padding(.horizontal, 2) - } - - private var accessibilityLabel: String { - let paneText = "\(paneCount) \(paneCount == 1 ? "pane" : "panes")" - let positional = "Window \(displayIndex) of \(totalCount)" - let named = displayName.isEmpty ? positional : "\(positional), \(displayName)" - if isSelected { - return "\(named), \(paneText), active" - } - return "\(named), \(paneText)" - } -} - -private struct GhosttyPaneSelectionTile: View { - let displayIndex: Int - let totalCount: Int - let isSelected: Bool - let state: GhosttyPanePreviewSession.PreviewState? - let chromeStyle: GhosttyTerminalChromeStyle - let layout: PanePreviewLayout.Metrics - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - previewSurface - captionRow - } - .padding(layout.tilePadding) - .frame( - width: layout.tilePointSize.width, - height: layout.tilePointSize.height, - alignment: .topLeading - ) - .terminalSelectionTileChrome(isSelected: isSelected, chromeStyle: chromeStyle) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - .accessibilityValue(state.accessibilityValue) - .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) - } - - private var accessibilityLabel: String { - let positional = "Pane \(displayIndex) of \(totalCount)" - return isSelected ? "\(positional), active" : positional - } - - @ViewBuilder - private var previewSurface: some View { - switch state { - case .ready(let preview): - GhosttyRenderedPreviewSurface( - preview: preview, - size: layout.previewPointSize - ) - - case .pending, .none: - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.black.opacity(0.30)) - .frame( - width: layout.previewPointSize.width, - height: layout.previewPointSize.height - ) - - case .failed: - // Failed state still shows a neutral placeholder; we don't - // surface different copy per status reason in v1. - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.black.opacity(0.30)) - .frame( - width: layout.previewPointSize.width, - height: layout.previewPointSize.height - ) - } - } - - private var captionRow: some View { - HStack(spacing: 6) { - Text("\(displayIndex)") - .font(.system(size: 11, weight: .semibold)) - .monospacedDigit() - .foregroundStyle(TerminalSelectionSheetPalette.tertiary) - - Spacer(minLength: 0) - } - .padding(.horizontal, 2) - } -} - -private extension Optional where Wrapped == GhosttyPanePreviewSession.PreviewState { - var accessibilityValue: String { - switch self { - case .ready: - "Preview ready" - case .failed: - "Preview unavailable" - case .pending, .none: - "Preview loading" - } - } -} - -private extension View { - @ViewBuilder - func ghosttySelectionContextActionSurface() -> some View { - let shape = Circle() - - if #available(iOS 26.0, *) { - self - .glassEffect(.regular.tint(GhosttySelectionContextActionPalette.glassTint).interactive(), in: shape) - .overlay { - shape.strokeBorder(GhosttySelectionContextActionPalette.stroke, lineWidth: 0.75) - } - .shadow(color: GhosttySelectionContextActionPalette.shadow, radius: 18, y: 9) - } else { - self - .background(.regularMaterial, in: shape) - .background { - shape.fill(GhosttySelectionContextActionPalette.fallbackFill) - } - .overlay { - shape.strokeBorder(GhosttySelectionContextActionPalette.stroke, lineWidth: 1) - } - .shadow(color: GhosttySelectionContextActionPalette.shadow, radius: 18, y: 10) - } - } - -} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index 0c54c982..fc7b7e51 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -5,8 +5,8 @@ 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, upstream selector -/// sheets, and terminal keyboard chrome. Its only construction input is the +/// 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 diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift index fde1dd74..b9cb20d6 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift @@ -197,9 +197,8 @@ struct GhosttyTerminalScreenPresentationProjection: Equatable { let statusOverlay: GhosttyTerminalStatusOverlayProjection } -/// Remux presents exactly one tmux pane per app viewport on every supported -/// device class. Topology identities remain stable for picker actions; this -/// projection identifies the one native surface instance currently hosted. +/// MoriRemote presents exactly one tmux pane per app viewport. This projection +/// identifies the one native surface instance currently hosted. struct GhosttyTerminalViewportPresentationProjection: Equatable { static let empty = GhosttyTerminalViewportPresentationProjection( surfaceID: nil, @@ -233,51 +232,6 @@ enum GhosttyTmuxTopologyActionInteractionEffect: Equatable, Sendable { } } -struct GhosttyWindowSheetPresentationProjection: Equatable, Sendable { - let previewLeafIDs: [UUID] -} - -struct GhosttyPaneSheetPresentationProjection: Equatable, Sendable { - let topLevelID: UUID - let previewLeafIDs: [UUID] -} - -struct GhosttyPaneSelectionSheetTopologyProjection: Equatable, Sendable { - let topLevelID: UUID? - let shouldDismissPaneSheet: Bool -} - -struct GhosttyWindowSelectionSheetRenderProjection: Equatable, Sendable { - struct Window: Identifiable, Equatable, Sendable { - let id: UUID - let displayName: String - let displayIndex: Int - let totalCount: Int - let paneCount: Int - let isSelected: Bool - let focusedPreviewPaneID: UUID? - } - - let windows: [Window] - let selectedWindowID: UUID? - let previewLeafIDs: [UUID] -} - -struct GhosttyPaneSelectionSheetRenderProjection: Equatable, Sendable { - struct Pane: Identifiable, Equatable, Sendable { - let id: UUID - let displayIndex: Int - let totalCount: Int - let isSelected: Bool - } - - let topLevelID: UUID - let panes: [Pane] - let selectedPaneID: UUID? - let previewLeafIDs: [UUID] - let paneCount: Int -} - @MainActor enum GhosttyTerminalPresentationProjector { static func terminalScreenPresentationProjection( @@ -411,116 +365,4 @@ enum GhosttyTerminalPresentationProjector { return topLevel.leafIDs.count == 1 ? .refocusOnly : .none } - - static func windowSheetPresentationProjection( - snapshot: GhosttyRuntimeSurfaceTopologySnapshot - ) -> GhosttyWindowSheetPresentationProjection? { - guard !snapshot.topLevels.isEmpty else { return nil } - - return GhosttyWindowSheetPresentationProjection( - previewLeafIDs: snapshot.topLevels.compactMap(\.resolvedFocusedLeafID) - ) - } - - static func selectedPaneSheetPresentationProjection( - snapshot: GhosttyRuntimeSurfaceTopologySnapshot - ) -> GhosttyPaneSheetPresentationProjection? { - guard let topLevel = snapshot.selectedTopLevel else { return nil } - - return GhosttyPaneSheetPresentationProjection( - topLevelID: topLevel.id, - previewLeafIDs: topLevel.leafIDs - ) - } - - static func paneCount( - topLevelID: UUID, - snapshot: GhosttyRuntimeSurfaceTopologySnapshot - ) -> Int { - snapshot.topLevels.first(where: { $0.id == topLevelID })?.leafIDs.count ?? 0 - } - - static func paneSelectionSheetTopologyProjection( - topLevelID: UUID?, - snapshot: GhosttyRuntimeSurfaceTopologySnapshot - ) -> GhosttyPaneSelectionSheetTopologyProjection { - guard let topLevelID else { - return GhosttyPaneSelectionSheetTopologyProjection( - topLevelID: nil, - shouldDismissPaneSheet: false - ) - } - - let topLevelExists = snapshot.topLevels.contains { $0.id == topLevelID } - return GhosttyPaneSelectionSheetTopologyProjection( - topLevelID: topLevelID, - shouldDismissPaneSheet: !topLevelExists - ) - } - - static func windowSelectionSheetRenderProjection( - snapshot: GhosttyRuntimeSurfaceTopologySnapshot - ) -> GhosttyWindowSelectionSheetRenderProjection { - let topLevels = snapshot.topLevels - let selectedWindowID = snapshot.selectedTopLevel?.id - let totalCount = topLevels.count - let windows = topLevels.enumerated().map { index, topLevel in - GhosttyWindowSelectionSheetRenderProjection.Window( - id: topLevel.id, - displayName: displaySafeWindowName(topLevel.name), - displayIndex: index + 1, - totalCount: totalCount, - paneCount: topLevel.leafIDs.count, - isSelected: topLevel.id == selectedWindowID, - focusedPreviewPaneID: topLevel.resolvedFocusedLeafID - ) - } - - return GhosttyWindowSelectionSheetRenderProjection( - windows: windows, - selectedWindowID: selectedWindowID, - previewLeafIDs: windows.compactMap(\.focusedPreviewPaneID) - ) - } - - private static func displaySafeWindowName(_ name: String) -> String { - name.unicodeScalars.reduce(into: "") { result, scalar in - guard scalar.properties.generalCategory != .control else { return } - result.unicodeScalars.append(scalar) - } - } - - static func paneSelectionSheetRenderProjection( - topLevelID: UUID, - snapshot: GhosttyRuntimeSurfaceTopologySnapshot - ) -> GhosttyPaneSelectionSheetRenderProjection { - guard let topLevel = snapshot.topLevels.first(where: { $0.id == topLevelID }) else { - return GhosttyPaneSelectionSheetRenderProjection( - topLevelID: topLevelID, - panes: [], - selectedPaneID: nil, - previewLeafIDs: [], - paneCount: 0 - ) - } - - let selectedPaneID = topLevel.resolvedFocusedLeafID - let totalCount = topLevel.leafIDs.count - let panes = topLevel.leafIDs.enumerated().map { index, paneID in - GhosttyPaneSelectionSheetRenderProjection.Pane( - id: paneID, - displayIndex: index + 1, - totalCount: totalCount, - isSelected: paneID == selectedPaneID - ) - } - - return GhosttyPaneSelectionSheetRenderProjection( - topLevelID: topLevelID, - panes: panes, - selectedPaneID: selectedPaneID, - previewLeafIDs: topLevel.leafIDs, - paneCount: totalCount - ) - } } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift index a431ee2f..139cbe96 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift @@ -2,9 +2,9 @@ import CoreGraphics import Foundation import GhosttyKit -/// The model surface `GhosttySurfaceScreen` renders against: projections of -/// terminal readiness/topology, focused-surface input routing, tmux topology -/// actions, and the selection-sheet/preview plumbing. +/// The model surface `GhosttyTerminalCoreView` renders against: projections +/// of terminal readiness/topology, focused-surface input routing, and tmux +/// topology actions. /// /// The tmux session stack implements it (`TmuxTerminalScreenAdapter`). The /// screen owns presentation behavior only; everything engine-specific flows @@ -124,31 +124,9 @@ protocol GhosttyTmuxActionModeling: ObservableObject { ) -> GhosttyTmuxModelActionOutcome } -@MainActor -protocol GhosttyTmuxSelectionModeling: ObservableObject { - func makePanePreviewSession( - leafIDs: [UUID], - previewSizing: GhosttyPanePreviewSession.PreviewSizing - ) -> GhosttyPanePreviewSession - - // MARK: Selection sheets - - func windowSheetPresentationProjection() -> GhosttyWindowSheetPresentationProjection? - func selectedPaneSheetPresentationProjection() -> GhosttyPaneSheetPresentationProjection? - func paneCount(topLevelID: UUID) -> Int - func paneSelectionSheetTopologyProjection( - topLevelID: UUID? - ) -> GhosttyPaneSelectionSheetTopologyProjection - func windowSelectionSheetRenderProjection() -> GhosttyWindowSelectionSheetRenderProjection - func paneSelectionSheetRenderProjection( - topLevelID: UUID - ) -> GhosttyPaneSelectionSheetRenderProjection -} - @MainActor protocol GhosttyTerminalScreenModeling: GhosttyTerminalRenderingModeling, GhosttyTerminalInputModeling, - GhosttyTmuxActionModeling, - GhosttyTmuxSelectionModeling + GhosttyTmuxActionModeling {} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift index a1e24a68..ebe7d01c 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift @@ -2,18 +2,15 @@ import Foundation struct GhosttyTopLevelSurface: Identifiable, Equatable { let id: UUID - let name: String let leafIDs: [UUID] let focusedLeafID: UUID? init( id: UUID = UUID(), - name: String = "", leafIDs: [UUID], focusedLeafID: UUID? = nil ) { self.id = id - self.name = name self.leafIDs = leafIDs self.focusedLeafID = focusedLeafID.flatMap { leafIDs.contains($0) ? $0 : nil } } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/PanePreviewLayout.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/PanePreviewLayout.swift deleted file mode 100644 index d7c660ab..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/PanePreviewLayout.swift +++ /dev/null @@ -1,207 +0,0 @@ -import CoreGraphics -import UIKit - -/// Single source of truth for pane-preview tile geometry and the physical -/// pixel budget used when downscaling local renderer frames. -/// -/// Used by: -/// - `GhosttyPanePreviewSession` for the local image budget -/// - `GhosttyPaneSelectionTile` for fixed tile sizing -/// -/// Capture once per session at session-init time. Rotation while the panes -/// sheet is open does not justify reissuing previews; we keep the originally -/// requested image regardless. -enum PanePreviewLayout { - struct Metrics: Equatable { - let columnCount: Int - let tilePointSize: CGSize - let previewPointSize: CGSize - let gridSpacing: CGFloat - let tilePadding: CGFloat - - func gridHeight(itemCount: Int) -> CGFloat { - guard itemCount > 0 else { return 0 } - let rows = (itemCount + columnCount - 1) / columnCount - return CGFloat(rows) * tilePointSize.height - + CGFloat(rows - 1) * gridSpacing - } - } - - /// Height for a selector sheet's scrollable grid. The whole grid shows - /// exactly whenever it fits within the height budget — the sheet grows - /// rather than hiding part of the final row. Only grids larger than the - /// budget scroll, showing complete rows plus half of the next tile so - /// the cut is an unmistakable scroll affordance. - @MainActor - static func gridIdealHeight(itemCount: Int, metrics: Metrics) -> CGFloat { - let fullHeight = metrics.gridHeight(itemCount: itemCount) - let budget = UIScreen.main.bounds.height * 0.72 - guard fullHeight > budget else { return fullHeight } - - let tile = metrics.tilePointSize.height - let spacing = metrics.gridSpacing - let peek = tile * 0.5 - - func height(fullRows: Int) -> CGFloat { - CGFloat(fullRows) * tile + CGFloat(fullRows - 1) * spacing + spacing + peek - } - - var rows = 1 - while height(fullRows: rows + 1) <= budget { - rows += 1 - } - return min(height(fullRows: rows), fullHeight) - } - - private static let defaultSheetContentWidth: CGFloat = 361 - private static let sheetHorizontalPadding: CGFloat = 32 - private static let defaultPreviewAspectRatio: CGFloat = 4.0 / 3.0 - private static let tilePadding: CGFloat = 8 - private static let captionHeight: CGFloat = 14 - private static let windowCaptionHeight: CGFloat = 30 - private static let tileCaptionSpacing: CGFloat = 6 - private static let maxSingleTileWidth: CGFloat = 390 - - /// Window grid uses a fixed two-column layout. The "New Window" affordance - /// is a fixed sheet action, not a trailing grid cell, so dense sessions can - /// scroll windows without hiding the create command. - private static let windowGridColumnCount: Int = 2 - private static let windowGridSpacing: CGFloat = 10 - - static func metrics(for paneCount: Int) -> Metrics { - metrics(for: paneCount, availableWidth: defaultSheetContentWidth) - } - - static func metrics( - for paneCount: Int, - availableWidth: CGFloat - ) -> Metrics { - let paneCount = max(paneCount, 1) - let columnCount = paneCount == 1 ? 1 : 2 - let gridSpacing: CGFloat = paneCount == 1 ? 12 : 10 - let safeAvailableWidth = max(availableWidth, 1) - let contentWidth = paneCount == 1 - ? min(safeAvailableWidth, maxSingleTileWidth) - : safeAvailableWidth - let totalGridSpacing = CGFloat(columnCount - 1) * gridSpacing - let tileWidth = max( - 1, - floor((contentWidth - totalGridSpacing) / CGFloat(columnCount)) - ) - let previewWidth = max(1, tileWidth - tilePadding * 2) - let previewHeight = ceil(previewWidth / defaultPreviewAspectRatio) - let tileHeight = previewHeight + tileCaptionSpacing + captionHeight + tilePadding * 2 - return .init( - columnCount: columnCount, - tilePointSize: CGSize(width: tileWidth, height: tileHeight), - previewPointSize: CGSize(width: previewWidth, height: previewHeight), - gridSpacing: gridSpacing, - tilePadding: tilePadding - ) - } - - /// Display scale captured once at session init. Avoids touching - /// UIScreen.main during request construction or rendering. - @MainActor - static func currentScale() -> CGFloat { - let scale = UIScreen.main.scale - return scale.isFinite && scale > 0 ? scale : 1 - } - - @MainActor - static func currentSheetContentWidth() -> CGFloat { - let width = UIScreen.main.bounds.width - sheetHorizontalPadding - return width.isFinite && width > 0 ? width : defaultSheetContentWidth - } - - @MainActor - static func metricsForCurrentScreen(for paneCount: Int) -> Metrics { - metrics(for: paneCount, availableWidth: currentSheetContentWidth()) - } - - @MainActor - static func windowMetricsForCurrentScreen() -> Metrics { - windowMetrics(availableWidth: currentSheetContentWidth()) - } - - static func windowMetrics( - availableWidth: CGFloat - ) -> Metrics { - let safeAvailableWidth = max(availableWidth, 1) - let columnCount = windowGridColumnCount - let totalGridSpacing = CGFloat(columnCount - 1) * windowGridSpacing - let tileWidth = max( - 1, - floor((safeAvailableWidth - totalGridSpacing) / CGFloat(columnCount)) - ) - let previewWidth = max(1, tileWidth - tilePadding * 2) - let previewHeight = ceil(previewWidth / defaultPreviewAspectRatio) - let tileHeight = previewHeight + tileCaptionSpacing + windowCaptionHeight + tilePadding * 2 - return .init( - columnCount: columnCount, - tilePointSize: CGSize(width: tileWidth, height: tileHeight), - previewPointSize: CGSize(width: previewWidth, height: previewHeight), - gridSpacing: windowGridSpacing, - tilePadding: tilePadding - ) - } - - /// Physical pixel budget for local picker images at the given display - /// scale. Returned dimensions are clamped to UInt32. - @MainActor - static func physicalPixelBudget( - paneCount: Int, - scale: CGFloat - ) -> (width: UInt32, height: UInt32) { - physicalPixelBudget( - paneCount: paneCount, - availableWidth: currentSheetContentWidth(), - scale: scale - ) - } - - static func physicalPixelBudget( - paneCount: Int, - availableWidth: CGFloat, - scale: CGFloat - ) -> (width: UInt32, height: UInt32) { - let metrics = metrics(for: paneCount, availableWidth: availableWidth) - let safeScale = max(scale, 1) - let widthPx = (metrics.previewPointSize.width * safeScale).rounded(.up) - let heightPx = (metrics.previewPointSize.height * safeScale).rounded(.up) - return ( - clampUInt32(widthPx), - clampUInt32(heightPx) - ) - } - - @MainActor - static func windowPhysicalPixelBudget( - scale: CGFloat - ) -> (width: UInt32, height: UInt32) { - windowPhysicalPixelBudget( - availableWidth: currentSheetContentWidth(), - scale: scale - ) - } - - static func windowPhysicalPixelBudget( - availableWidth: CGFloat, - scale: CGFloat - ) -> (width: UInt32, height: UInt32) { - let metrics = windowMetrics(availableWidth: availableWidth) - let safeScale = max(scale, 1) - let widthPx = (metrics.previewPointSize.width * safeScale).rounded(.up) - let heightPx = (metrics.previewPointSize.height * safeScale).rounded(.up) - return ( - clampUInt32(widthPx), - clampUInt32(heightPx) - ) - } - - private static func clampUInt32(_ value: CGFloat) -> UInt32 { - guard value.isFinite, value > 0 else { return 1 } - let clamped = min(value, CGFloat(UInt32.max)) - return max(1, UInt32(clamped)) - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/TerminalSelectionSheetStyle.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/TerminalSelectionSheetStyle.swift deleted file mode 100644 index 3d487103..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/TerminalSelectionSheetStyle.swift +++ /dev/null @@ -1,243 +0,0 @@ -import SwiftUI -import UIKit - -enum TerminalSelectionSheetPalette { - static let row = Color(uiColor: .secondarySystemFill) - static let stroke = Color.primary.opacity(0.12) - static let controlFill = Color(uiColor: .secondarySystemFill) - static let primary = Color.primary.opacity(0.92) - static let secondary = Color.secondary.opacity(0.78) - static let tertiary = Color.secondary.opacity(0.56) - - static func selectedStroke(_ chromeStyle: GhosttyTerminalChromeStyle) -> Color { - chromeStyle.selectedStroke - } -} - -/// Single source of truth for selector-sheet chrome heights. The scaffold -/// lays its rows out from these tokens and `sheetHeight` sums the same -/// tokens for the presentation detent, so the sheet always cleanly fits -/// its content: the views and the height math cannot drift apart. -enum TerminalSelectionSheetLayout { - static let headerTopPadding: CGFloat = 14 - static let headerHeight: CGFloat = 36 - static let headerBottomPadding: CGFloat = 12 - static let contextHeight: CGFloat = 16 - static let contextToContentSpacing: CGFloat = 12 - static let contentToActionsSpacing: CGFloat = 16 - static let actionBarHeight: CGFloat = 44 - static let actionsBottomPadding: CGFloat = 8 - - /// The `.height()` detent excludes the bottom safe area (verified by - /// measurement: adding it produced exactly one safe-area of slack), so - /// the sum covers only the content rows the scaffold lays out. - static func sheetHeight(gridHeight: CGFloat) -> CGFloat { - headerTopPadding + headerHeight + headerBottomPadding - + contextHeight + contextToContentSpacing - + gridHeight - + contentToActionsSpacing + actionBarHeight + actionsBottomPadding - } -} - -// Existing non-selector sheets keep their established palette name and styling. -typealias GhosttySheetPalette = TerminalSelectionSheetPalette - -struct TerminalSelectionSheetContextLabel: View { - let text: String - - var body: some View { - Text(text) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(TerminalSelectionSheetPalette.secondary) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: TerminalSelectionSheetLayout.contextHeight) - } -} - -struct TerminalSelectionSheetCloseButton: View { - let title: String - let accessibilityIdentifier: String - let action: () -> Void - - var body: some View { - Button { - Haptic.tap() - action() - } label: { - Image(systemName: "xmark") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(TerminalSelectionSheetPalette.primary) - .frame(width: 36, height: 36) - .background(TerminalSelectionSheetPalette.controlFill, in: Circle()) - } - .accessibilityLabel("Close \(title)") - .accessibilityIdentifier(accessibilityIdentifier) - } -} - -struct TerminalSelectionTileCheckmark: View { - let chromeStyle: GhosttyTerminalChromeStyle - - var body: some View { - Image(systemName: "checkmark") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(chromeStyle.accentForeground) - .frame(width: 22, height: 22) - .background(chromeStyle.accent, in: Circle()) - .overlay { - Circle().strokeBorder(Color.white.opacity(0.24), lineWidth: 0.5) - } - .accessibilityHidden(true) - } -} - -/// Shared anatomy for the terminal selector sheets. Owns its header (title -/// and close button) as plain content — no navigation bar — so the sheet's -/// natural height is fully defined by views the app controls, which is what -/// lets fitted presentation size the sheet to its content. -struct TerminalSelectionSheetScaffold: View { - @Environment(\.dismiss) private var dismiss - - let title: String - let context: String - let closeAccessibilityIdentifier: String - let content: Content - let actions: Actions - - init( - title: String, - context: String, - closeAccessibilityIdentifier: String, - @ViewBuilder content: () -> Content, - @ViewBuilder actions: () -> Actions - ) { - self.title = title - self.context = context - self.closeAccessibilityIdentifier = closeAccessibilityIdentifier - self.content = content() - self.actions = actions() - } - - var body: some View { - VStack(spacing: 0) { - HStack { - TerminalSelectionSheetCloseButton( - title: title, - accessibilityIdentifier: closeAccessibilityIdentifier, - action: dismiss.callAsFunction - ) - - Spacer(minLength: 0) - } - .overlay { - Text(title) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(TerminalSelectionSheetPalette.primary) - .lineLimit(1) - } - .padding(.horizontal, 16) - .frame(height: TerminalSelectionSheetLayout.headerHeight) - .padding(.top, TerminalSelectionSheetLayout.headerTopPadding) - .padding(.bottom, TerminalSelectionSheetLayout.headerBottomPadding) - - VStack(alignment: .leading, spacing: TerminalSelectionSheetLayout.contextToContentSpacing) { - TerminalSelectionSheetContextLabel(text: context) - .padding(.horizontal, 16) - - content - .frame(maxWidth: .infinity, alignment: .top) - } - .frame(maxWidth: .infinity, alignment: .top) - - actions - .padding(.horizontal, 16) - .frame(maxWidth: .infinity) - .frame(height: TerminalSelectionSheetLayout.actionBarHeight) - .padding(.top, TerminalSelectionSheetLayout.contentToActionsSpacing) - .padding(.bottom, TerminalSelectionSheetLayout.actionsBottomPadding) - } - } -} - -struct TerminalSelectionSheetActionButton: View { - let title: String - let systemName: String - let accessibilityIdentifier: String - let action: (() -> Void)? - - var body: some View { - let button = Button { - Haptic.tap() - action?() - } label: { - Label(title, systemImage: systemName) - .font(.body.weight(.semibold)) - .foregroundStyle(TerminalSelectionSheetPalette.primary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .accessibilityIdentifier(accessibilityIdentifier) - .disabled(action == nil) - - if #available(iOS 26.0, *) { - button - .buttonStyle(.glass) - .buttonSizing(.flexible) - .controlSize(.regular) - } else { - button - .buttonStyle(.bordered) - .controlSize(.regular) - } - } -} - -extension View { - func terminalSelectionTileChrome( - isSelected: Bool, - chromeStyle: GhosttyTerminalChromeStyle - ) -> some View { - background(TerminalSelectionSheetPalette.row) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder( - isSelected - ? TerminalSelectionSheetPalette.selectedStroke(chromeStyle) - : TerminalSelectionSheetPalette.stroke, - lineWidth: isSelected ? 1.25 : 1 - ) - } - .overlay(alignment: .topTrailing) { - if isSelected { - TerminalSelectionTileCheckmark(chromeStyle: chromeStyle) - .padding(6) - } - } - } - - func terminalSelectionSheetPresentation( - colorScheme: ColorScheme, - chromeStyle: GhosttyTerminalChromeStyle - ) -> some View { - presentationDetents([.medium]) - .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.hidden) - .terminalSelectionSheetPresentationBackground() - .ghosttyTerminalChromePresentation( - colorScheme, - chromeStyle: chromeStyle - ) - } - - @ViewBuilder - func terminalSelectionSheetPresentationBackground() -> some View { - if #available(iOS 26.0, *) { - self - } else { - self.presentationBackground(.regularMaterial) - } - } - -} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPanePreviewImageCache.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPanePreviewImageCache.swift deleted file mode 100644 index 611ef797..00000000 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPanePreviewImageCache.swift +++ /dev/null @@ -1,87 +0,0 @@ -import CoreGraphics -import Foundation - -/// Byte-bounded last-known pane thumbnails. Cold panes retain their first -/// local pane-geometry render; visiting one replaces it with the pane's latest -/// full-viewport image. The cache is deliberately a Remux concern: libghostty -/// renders surfaces but does not know that this client presents one tmux pane -/// per phone viewport. -struct TmuxPanePreviewImageCache { - struct Entry { - let preview: GhosttyPanePreviewSession.RenderedPreview - let byteCost: Int - var lastAccess: UInt64 - } - - let byteLimit: Int - private(set) var entries: [TmuxPaneID: Entry] = [:] - private(set) var totalByteCost = 0 - private var accessSequence: UInt64 = 0 - - init(byteLimit: Int) { - precondition(byteLimit > 0) - self.byteLimit = byteLimit - } - - mutating func preview( - for paneID: TmuxPaneID - ) -> GhosttyPanePreviewSession.RenderedPreview? { - guard var entry = entries[paneID] else { return nil } - accessSequence &+= 1 - entry.lastAccess = accessSequence - entries[paneID] = entry - return entry.preview - } - - @discardableResult - mutating func store( - _ preview: GhosttyPanePreviewSession.RenderedPreview, - for paneID: TmuxPaneID - ) -> [TmuxPaneID] { - let image = preview.image - let (byteCost, overflow) = image.bytesPerRow.multipliedReportingOverflow(by: image.height) - guard !overflow, byteCost > 0, byteCost <= byteLimit else { return [] } - - if let replaced = entries.removeValue(forKey: paneID) { - totalByteCost -= replaced.byteCost - } - accessSequence &+= 1 - entries[paneID] = Entry( - preview: preview, - byteCost: byteCost, - lastAccess: accessSequence - ) - totalByteCost += byteCost - - var evictedPaneIDs: [TmuxPaneID] = [] - while totalByteCost > byteLimit, - let oldest = entries.min(by: { $0.value.lastAccess < $1.value.lastAccess }) { - entries.removeValue(forKey: oldest.key) - totalByteCost -= oldest.value.byteCost - evictedPaneIDs.append(oldest.key) - } - return evictedPaneIDs - } - - @discardableResult - mutating func retainOnly(_ paneIDs: Set) -> [TmuxPaneID] { - let removedPaneIDs = entries.keys.filter { !paneIDs.contains($0) } - for paneID in removedPaneIDs { - if let removed = entries.removeValue(forKey: paneID) { - totalByteCost -= removed.byteCost - } - } - return removedPaneIDs - } - - mutating func remove(_ paneID: TmuxPaneID) { - guard let removed = entries.removeValue(forKey: paneID) else { return } - totalByteCost -= removed.byteCost - } - - mutating func removeAll() { - entries.removeAll() - totalByteCost = 0 - accessSequence = 0 - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift index 9077e9a2..2f996c90 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift @@ -34,9 +34,7 @@ final class TmuxPaneSurface { private var renderer: Renderer? private(set) var managedSurface: GhosttyManagedSurface? - private(set) var lastFullViewportProvenance: - GhosttyPanePreviewSession.FullViewportProvenance? - private var fullViewportFrameNeedsRefresh = false + private var lastPublishedViewportMetrics: GhosttySurfaceDisplayMetrics? private var presented = false private var sceneActive = true private var lifecycle = Lifecycle.active @@ -45,7 +43,7 @@ final class TmuxPaneSurface { private var framePublicationWait: FramePublicationWait? private var presentationTask: Task? private var presentationGeneration: UInt64 = 0 - private let previewRelay = PreviewRelay() + private let frameRelay = FrameRelay() private let publishedFrameObserver = GhosttyPublishedFrameObserver() private var canonicalViewportMetrics: GhosttySurfaceDisplayMetrics private var appliedDisplayMetrics: GhosttySurfaceDisplayMetrics @@ -65,36 +63,19 @@ final class TmuxPaneSurface { weak var pane: TmuxPaneSurface? } - private enum FramePublication { - case ready - case captured(GhosttyIOSurfaceFrame) - } - private final class FramePublicationWait: @unchecked Sendable { - let transientVisibility: Bool - let keepVisibleAfterSuccess: Bool - let captureOwnedPixels: Bool let expectedWidth: UInt32 let expectedHeight: UInt32 var observation: NSKeyValueObservation? - var continuation: CheckedContinuation? + var continuation: CheckedContinuation? - init( - transientVisibility: Bool, - keepVisibleAfterSuccess: Bool, - captureOwnedPixels: Bool, - expectedWidth: UInt32, - expectedHeight: UInt32 - ) { - self.transientVisibility = transientVisibility - self.keepVisibleAfterSuccess = keepVisibleAfterSuccess - self.captureOwnedPixels = captureOwnedPixels + init(expectedWidth: UInt32, expectedHeight: UInt32) { self.expectedWidth = expectedWidth self.expectedHeight = expectedHeight } } - private final class PreviewRelay: @unchecked Sendable { + private final class FrameRelay: @unchecked Sendable { weak var pane: TmuxPaneSurface? } @@ -294,7 +275,7 @@ final class TmuxPaneSurface { } ) renderer = Renderer(handle: surface, control: control) - previewRelay.pane = self + frameRelay.pane = self } var rawSurface: ghostty_terminal_surface_t? { renderer?.handle } @@ -399,13 +380,13 @@ final class TmuxPaneSurface { presentationGeneration &+= 1 let generation = presentationGeneration - if hasCurrentFullViewportFrame() { + if hasCurrentViewportFrame() { completion(true) return } guard applyDisplayMetrics(canonicalViewportMetrics), - let rendererLayer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer) + let rendererLayer = GhosttyRendererLayer.find(in: view.layer) else { completion(false) return @@ -414,11 +395,8 @@ final class TmuxPaneSurface { let expected = canonicalViewportMetrics presentationTask = Task { @MainActor [weak self] in guard let self else { return } - let publication = await matchingPublication( + let didPublish = await waitForViewportFrame( on: rendererLayer, - transientVisibility: true, - keepVisibleAfterSuccess: true, - captureOwnedPixels: false, expectedWidth: expected.pixelWidth, expectedHeight: expected.pixelHeight ) @@ -426,17 +404,12 @@ final class TmuxPaneSurface { presentationTask = nil guard !Task.isCancelled, expected == canonicalViewportMetrics, - publication != nil + didPublish else { completion(false) return } - lastFullViewportProvenance = .init( - surfaceID: instanceID.rawValue, - pixelWidth: expected.pixelWidth, - pixelHeight: expected.pixelHeight - ) - fullViewportFrameNeedsRefresh = false + lastPublishedViewportMetrics = expected completion(true) } } @@ -469,9 +442,7 @@ final class TmuxPaneSurface { } view.applyTerminalTheme(theme) managedSurface?.notifyLocalSelectionGeometryChanged() - if !presented, lastFullViewportProvenance != nil { - fullViewportFrameNeedsRefresh = true - } + if !presented { lastPublishedViewportMetrics = nil } return true } @@ -497,8 +468,7 @@ final class TmuxPaneSurface { // completion; they must not recursively schedule another attempt. rendererFailureReported = true cancelPresentationPreparation() - lastFullViewportProvenance = nil - fullViewportFrameNeedsRefresh = false + lastPublishedViewportMetrics = nil let installReplacement = { [self] in guard lifecycle == .replacing else { @@ -613,7 +583,7 @@ final class TmuxPaneSurface { lifecycle = .closing publishedFrameObserver.invalidate() cancelPresentationPreparation() - previewRelay.pane = nil + frameRelay.pane = nil failureRelay.pane = nil managedSurface?.prepareForPermanentRemoval() guard !wasReplacing else { return } @@ -632,121 +602,10 @@ final class TmuxPaneSurface { } } - /// Cancel any transient detached render before pane selection owns the - /// surface. Cancellation hides immediately and invalidates KVO, so no - /// delayed completion can hide the newly selected pane. - func cancelPickerCaptureForPresentation() { - guard framePublicationWait?.keepVisibleAfterSuccess == false else { return } - cancelFramePublicationWait() - } - - func capturePickerPreview( - columns: UInt32, - rows: UInt32, - budget: GhosttyPanePreviewSession.PixelBudget - ) async -> GhosttyPanePreviewSession.RenderedPreview? { - guard lifecycle == .active, - framePublicationWait == nil, - presentationTask == nil, - columns > 0, rows > 0, - let rendererLayer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer) - else { return nil } - - if let provenance = lastFullViewportProvenance { - if fullViewportFrameNeedsRefresh { - guard !presented, - applyDisplayMetrics(canonicalViewportMetrics), - let publication = await matchingPublication( - on: rendererLayer, - transientVisibility: true, - keepVisibleAfterSuccess: false, - captureOwnedPixels: true, - expectedWidth: canonicalViewportMetrics.pixelWidth, - expectedHeight: canonicalViewportMetrics.pixelHeight - ), - case .captured(let frame) = publication, - let image = await makePreviewImage(from: frame, budget: budget) - else { return nil } - let refreshedProvenance = GhosttyPanePreviewSession.FullViewportProvenance( - surfaceID: instanceID.rawValue, - pixelWidth: canonicalViewportMetrics.pixelWidth, - pixelHeight: canonicalViewportMetrics.pixelHeight - ) - lastFullViewportProvenance = refreshedProvenance - fullViewportFrameNeedsRefresh = false - return .init(image: image, source: .fullViewport(refreshedProvenance)) - } - - if let frame = retainedFullViewportFrame( - in: rendererLayer, - provenance: provenance - ), - let image = await makePreviewImage(from: frame, budget: budget) { - return .init(image: image, source: .fullViewport(provenance)) - } - - guard presented, - let current = renderer?.control.currentSize(), - isViewportSized(current), - let dimensions = GhosttyIOSurfaceFrame.dimensions(in: rendererLayer), - dimensions.width == Int(canonicalViewportMetrics.pixelWidth), - dimensions.height == Int(canonicalViewportMetrics.pixelHeight), - let frame = try? GhosttyIOSurfaceFrame.read(from: rendererLayer) - else { return nil } - let currentProvenance = GhosttyPanePreviewSession.FullViewportProvenance( - surfaceID: instanceID.rawValue, - pixelWidth: current.width_px, - pixelHeight: current.height_px - ) - lastFullViewportProvenance = currentProvenance - fullViewportFrameNeedsRefresh = false - guard let image = await makePreviewImage(from: frame, budget: budget) else { - return nil - } - return .init(image: image, source: .fullViewport(currentProvenance)) - } - - guard !presented, - resizeForPickerGrid(columns: columns, rows: rows), - let current = renderer?.control.currentSize(), - current.columns == columns, - current.rows == rows, - let publication = await matchingPublication( - on: rendererLayer, - transientVisibility: true, - keepVisibleAfterSuccess: false, - captureOwnedPixels: true, - expectedWidth: current.width_px, - expectedHeight: current.height_px - ), - case .captured(let frame) = publication, - let image = await makePreviewImage(from: frame, budget: budget) - else { return nil } - - let source: GhosttyPanePreviewSession.PreviewSource - if isViewportSized(current) { - let provenance = GhosttyPanePreviewSession.FullViewportProvenance( - surfaceID: instanceID.rawValue, - pixelWidth: current.width_px, - pixelHeight: current.height_px - ) - lastFullViewportProvenance = provenance - fullViewportFrameNeedsRefresh = false - source = .fullViewport(provenance) - } else { - source = .paneGeometry(.init( - surfaceID: instanceID.rawValue, - columns: columns, - rows: rows - )) - } - return .init(image: image, source: source) - } - private func installPublishedFrameInteractionObservation() { guard lifecycle == .active, let managedSurface, - let rendererLayer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer) + let rendererLayer = GhosttyRendererLayer.find(in: view.layer) else { return } publishedFrameObserver.observe(rendererLayer, target: managedSurface) } @@ -772,7 +631,7 @@ final class TmuxPaneSurface { lifecycle = .closed publishedFrameObserver.invalidate() cancelPresentationPreparation() - previewRelay.pane = nil + frameRelay.pane = nil failureRelay.pane = nil renderer?.control.invalidate() if let renderer { ghostty_terminal_surface_free(renderer.handle) } @@ -811,32 +670,25 @@ final class TmuxPaneSurface { return config } - private func matchingPublication( + private func waitForViewportFrame( on layer: CALayer, - transientVisibility: Bool, - keepVisibleAfterSuccess: Bool, - captureOwnedPixels: Bool, expectedWidth: UInt32, expectedHeight: UInt32 - ) async -> FramePublication? { - // Drain any stale display invalidation before observing. The visibility - // mailbox below is ordered after resize and is what requests the real - // updateFrame/draw whose IOSurface publication we accept. + ) 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: nil) + continuation.resume(returning: false) return } let wait = FramePublicationWait( - transientVisibility: transientVisibility, - keepVisibleAfterSuccess: keepVisibleAfterSuccess, - captureOwnedPixels: captureOwnedPixels, expectedWidth: expectedWidth, expectedHeight: expectedHeight ) let layerReference = LayerReference(layer) - let relay = previewRelay + let relay = frameRelay wait.continuation = continuation framePublicationWait = wait wait.observation = layer.observe(\.contents, options: [.new]) { [weak wait] _, _ in @@ -850,12 +702,10 @@ final class TmuxPaneSurface { } } } - if transientVisibility { - _ = renderer?.control.setFocused(keepVisibleAfterSuccess) - guard renderer?.control.setVisible(true) == true else { - finishFramePublicationWait(wait, publication: nil) - return - } + _ = renderer?.control.setFocused(true) + guard renderer?.control.setVisible(true) == true else { + finishFramePublicationWait(wait, didPublish: false) + return } } } @@ -865,155 +715,41 @@ final class TmuxPaneSurface { layer: CALayer ) { guard framePublicationWait === wait, - let dimensions = GhosttyIOSurfaceFrame.dimensions(in: layer) - else { return } - guard dimensions.width == Int(wait.expectedWidth), + let dimensions = GhosttyRendererLayer.dimensions(in: layer), + dimensions.width == Int(wait.expectedWidth), dimensions.height == Int(wait.expectedHeight) else { return } - guard wait.captureOwnedPixels else { - finishFramePublicationWait(wait, publication: .ready) - return - } - let frame: GhosttyIOSurfaceFrame - do { - frame = try GhosttyIOSurfaceFrame.read(from: layer) - } catch { - GhosttyRuntimeTrace.diagnostics( - "tmuxPane.frameRead failed pane=\(paneID) error=\(String(describing: error))" - ) - finishFramePublicationWait(wait, publication: nil) - return - } - finishFramePublicationWait(wait, publication: .captured(frame)) + finishFramePublicationWait(wait, didPublish: true) } private func finishFramePublicationWait( _ wait: FramePublicationWait, - publication: FramePublication? + didPublish: Bool ) { guard framePublicationWait === wait else { return } wait.observation?.invalidate() wait.observation = nil framePublicationWait = nil - if wait.transientVisibility, - (publication == nil || !wait.keepVisibleAfterSuccess), - !presented { + if !didPublish, !presented { _ = renderer?.control.setVisible(false) } let continuation = wait.continuation wait.continuation = nil - continuation?.resume(returning: publication) + continuation?.resume(returning: didPublish) } private func cancelFramePublicationWait() { guard let wait = framePublicationWait else { return } - finishFramePublicationWait(wait, publication: nil) + finishFramePublicationWait(wait, didPublish: false) } - private func resizeForPickerGrid(columns: UInt32, rows: UInt32) -> Bool { - guard let renderer else { return false } - let current = renderer.control.currentSize() - guard current.columns > 0, current.rows > 0, - current.cell_width_px > 0, current.cell_height_px > 0, - let width = Self.pixelDimension( - targetCells: columns, - currentCells: UInt32(current.columns), - cellPixels: current.cell_width_px, - currentPixels: current.width_px - ), - let height = Self.pixelDimension( - targetCells: rows, - currentCells: UInt32(current.rows), - cellPixels: current.cell_height_px, - currentPixels: current.height_px - ), - applyPickerSize(width: width, height: height) - else { return false } - - let measured = renderer.control.currentSize() - if measured.columns == columns, measured.rows == rows { return true } - - let correctedWidth = Self.correctedPixelDimension( - currentPixels: measured.width_px, - actualCells: UInt32(measured.columns), - targetCells: columns, - cellPixels: measured.cell_width_px - ) - let correctedHeight = Self.correctedPixelDimension( - currentPixels: measured.height_px, - actualCells: UInt32(measured.rows), - targetCells: rows, - cellPixels: measured.cell_height_px - ) - guard let correctedWidth, let correctedHeight, - applyPickerSize(width: correctedWidth, height: correctedHeight) + private func hasCurrentViewportFrame() -> Bool { + guard lastPublishedViewportMetrics == canonicalViewportMetrics, + let layer = GhosttyRendererLayer.find(in: view.layer), + let dimensions = GhosttyRendererLayer.dimensions(in: layer) else { return false } - let verified = renderer.control.currentSize() - return verified.columns == columns && verified.rows == rows - } - - private func applyPickerSize(width: UInt32, height: UInt32) -> Bool { - applyDisplayMetrics(.init( - contentScale: canonicalViewportMetrics.contentScale, - pixelWidth: width, - pixelHeight: height - )) - } - - private func makePreviewImage( - from frame: GhosttyIOSurfaceFrame, - budget: GhosttyPanePreviewSession.PixelBudget - ) async -> CGImage? { - let paneID = paneID - return await Task.detached(priority: .userInitiated) { - do { - return try frame.image( - maxWidth: budget.width, - maxHeight: budget.height - ) - } catch { - GhosttyRuntimeTrace.diagnostics( - "tmuxPane.previewRead failed pane=\(paneID) error=\(String(describing: error))" - ) - return nil - } - }.value - } - - private func isViewportSized(_ size: ghostty_surface_size_s) -> Bool { - size.width_px == canonicalViewportMetrics.pixelWidth - && size.height_px == canonicalViewportMetrics.pixelHeight - } - - private func hasCurrentFullViewportFrame() -> Bool { - guard !fullViewportFrameNeedsRefresh, - let provenance = lastFullViewportProvenance, - provenance.pixelWidth == canonicalViewportMetrics.pixelWidth, - provenance.pixelHeight == canonicalViewportMetrics.pixelHeight, - let layer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer) - else { return false } - return publishedFrameMatches(in: layer, provenance: provenance) - } - - private func retainedFullViewportFrame( - in layer: CALayer, - provenance: GhosttyPanePreviewSession.FullViewportProvenance - ) -> GhosttyIOSurfaceFrame? { - guard publishedFrameMatches(in: layer, provenance: provenance) else { - return nil - } - return try? GhosttyIOSurfaceFrame.read(from: layer) - } - - private func publishedFrameMatches( - in layer: CALayer, - provenance: GhosttyPanePreviewSession.FullViewportProvenance - ) -> Bool { - guard provenance.surfaceID == instanceID.rawValue, - let dimensions = GhosttyIOSurfaceFrame.dimensions(in: layer) - else { return false } - return dimensions.width == Int(provenance.pixelWidth) - && dimensions.height == Int(provenance.pixelHeight) + return dimensions.width == Int(canonicalViewportMetrics.pixelWidth) + && dimensions.height == Int(canonicalViewportMetrics.pixelHeight) } private func applyDisplayMetrics( @@ -1036,32 +772,6 @@ final class TmuxPaneSurface { return true } - private static func pixelDimension( - targetCells: UInt32, - currentCells: UInt32, - cellPixels: UInt32, - currentPixels: UInt32 - ) -> UInt32? { - let currentGridPixels = UInt64(currentCells) * UInt64(cellPixels) - guard UInt64(currentPixels) >= currentGridPixels else { return nil } - let padding = UInt64(currentPixels) - currentGridPixels - let target = UInt64(targetCells) * UInt64(cellPixels) + padding - return UInt32(exactly: target) - } - - private static func correctedPixelDimension( - currentPixels: UInt32, - actualCells: UInt32, - targetCells: UInt32, - cellPixels: UInt32 - ) -> UInt32? { - guard cellPixels > 0 else { return nil } - let correction = (Int64(targetCells) - Int64(actualCells)) * Int64(cellPixels) - let corrected = Int64(currentPixels) + correction - guard corrected > 0 else { return nil } - return UInt32(exactly: corrected) - } - deinit { let finalLifecycle = lifecycle assert(finalLifecycle == .closed, "TmuxPaneSurface deinit without close()") diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift index a74cd794..e8247692 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift @@ -14,8 +14,6 @@ import GhosttyKit /// `GhosttyManagedSurface` to the phone viewport. @MainActor final class TmuxTerminalScreenAdapter: ObservableObject { - private static let panePreviewCacheByteLimit = 8 * 1024 * 1024 - private weak var session: TmuxTerminalSession? private var controller: TmuxSessionController? @@ -31,9 +29,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { private var initialViewportHandler: ((CGSize, CGFloat) -> Void)? private var viewportStabilityHandler: ((Bool) -> Void)? private var cachedTopologySnapshot = GhosttyRuntimeSurfaceTopologySnapshot.empty - private var panePreviewCache = TmuxPanePreviewImageCache( - byteLimit: TmuxTerminalScreenAdapter.panePreviewCacheByteLimit - ) private var commandFailureMessage: String? private(set) var commandFailureEvent: GhosttyTmuxCommandFailureEvent? @@ -54,15 +49,7 @@ final class TmuxTerminalScreenAdapter: ObservableObject { self.viewportStabilityHandler = viewportStabilityHandler session.$state - .sink { [weak self] state in - guard let self else { return } - if case .detached = state { - self.clearPanePreviewCache(reason: "detached") - } else if case .closed = state { - self.clearPanePreviewCache(reason: "closed") - } - self.objectWillChange.send() - } + .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. @@ -70,11 +57,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { .sink { [weak self] topology in guard let self else { return } self.latestTopology = topology - if let topology { - self.reconcilePanePreviewCache(with: topology) - } else { - self.clearPanePreviewCache(reason: "topology-unavailable") - } self.rebuildTopologySnapshot() self.objectWillChange.send() } @@ -100,7 +82,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { subscriptions.removeAll() activeManagedSurface = nil activeManagedPaneID = nil - clearPanePreviewCache(reason: "invalidate") session = nil controller = nil initialViewportHandler = nil @@ -109,10 +90,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { cachedTopologySnapshot = Self.emptyTopologySnapshot } - func terminalConfigurationDidChange() { - clearPanePreviewCache(reason: "appearance-change") - } - func tmuxPaneID(for surfaceID: UUID) -> TmuxPaneID? { let paneID = activeManagedSurface?.id == surfaceID ? activeManagedPaneID @@ -148,7 +125,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { .map { identities.surfaceID(for: $0.id) } return GhosttyTopLevelSurface( id: identities.surfaceID(for: window.id), - name: window.name, leafIDs: paneIDs, focusedLeafID: window.activePaneID.map { identities.surfaceID(for: $0) } ) @@ -199,9 +175,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { guard let paneSurface else { return } let paneID = paneSurface.paneID - if case .paneGeometry? = panePreviewCache.entries[paneID]?.preview.source { - panePreviewCache.remove(paneID) - } let wasAlreadyWrapped = paneSurface.managedSurface != nil let managed = paneSurface.screenSurface { [weak paneSurface] managed, size, _ in guard size.width > 1, size.height > 1 else { return } @@ -322,129 +295,6 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { viewportStabilityHandler?(stable) } - func makePanePreviewSession( - leafIDs: [UUID], - previewSizing: GhosttyPanePreviewSession.PreviewSizing - ) -> GhosttyPanePreviewSession { - return newPanePreviewSession( - leafIDs: leafIDs, - previewSizing: previewSizing - ) - } - - private func newPanePreviewSession( - leafIDs: [UUID], - previewSizing: GhosttyPanePreviewSession.PreviewSizing - ) -> GhosttyPanePreviewSession { - GhosttyPanePreviewSession( - leafIDs: leafIDs, - previewSizing: previewSizing, - client: GhosttyPanePreviewSession.PreviewClient( - capture: { [weak self] leafID, budget in - guard let self, - let session = self.session, - let paneID = self.identities.paneID(for: leafID), - let pane = self.latestTopology?.panes.first(where: { $0.id == paneID }) - else { return nil } - return await session.capturePickerPreview( - paneID: paneID, - columns: pane.width, - rows: pane.height, - budget: budget - ) - }, - cancelCapture: { [weak self] leafID in - guard let self, - let paneID = self.identities.paneID(for: leafID) - else { return } - self.session?.cancelPickerPreview(paneID: paneID) - }, - cachedPreview: { [weak self] leafID in - guard let self, - let paneID = self.identities.paneID(for: leafID) - else { return nil } - guard let preview = self.panePreviewCache.preview(for: paneID) else { - GhosttyRuntimeTrace.perf( - "tmuxPane.preview.cache pane=\(paneID) result=miss" - ) - return nil - } - if case .paneGeometry(let provenance) = preview.source, - self.latestTopology?.panes.first(where: { $0.id == paneID }).map({ - $0.width != provenance.columns || $0.height != provenance.rows - }) != false { - self.panePreviewCache.remove(paneID) - return nil - } - GhosttyRuntimeTrace.perf( - "tmuxPane.preview.cache pane=\(paneID) result=hit source=\(Self.previewSourceLabel(preview.source)) bytes=\(preview.image.bytesPerRow * preview.image.height)" - ) - return preview - }, - shouldRefreshCachedImage: { [weak self] leafID in - guard let self, - let paneID = self.identities.paneID(for: leafID) - else { return false } - return self.activeManagedPaneID == paneID - }, - cacheRenderedPreview: { [weak self] leafID, preview in - guard let self, - self.session?.state == .ready, - let paneID = self.identities.paneID(for: leafID), - self.latestTopology?.panes.contains(where: { $0.id == paneID }) == true - else { return } - let evictedPaneIDs = self.panePreviewCache.store( - preview, - for: paneID - ) - guard self.panePreviewCache.entries[paneID]?.preview.image === preview.image else { - GhosttyRuntimeTrace.perf( - "tmuxPane.preview.cache pane=\(paneID) result=reject-oversize bytes=\(preview.image.bytesPerRow * preview.image.height) limit=\(self.panePreviewCache.byteLimit)" - ) - return - } - GhosttyRuntimeTrace.perf( - "tmuxPane.preview.cache pane=\(paneID) result=store source=\(Self.previewSourceLabel(preview.source)) bytes=\(preview.image.bytesPerRow * preview.image.height) total=\(self.panePreviewCache.totalByteCost)" - ) - if !evictedPaneIDs.isEmpty { - GhosttyRuntimeTrace.perf( - "tmuxPane.preview.cache result=evict panes=\(evictedPaneIDs) total=\(self.panePreviewCache.totalByteCost)" - ) - } - } - ) - ) - } - - private static func previewSourceLabel( - _ source: GhosttyPanePreviewSession.PreviewSource - ) -> String { - switch source { - case .paneGeometry(let provenance): - return "pane-geometry-\(provenance.columns)x\(provenance.rows)" - case .fullViewport(let provenance): - return "full-viewport-\(provenance.pixelWidth)x\(provenance.pixelHeight)" - } - } - - private func reconcilePanePreviewCache( - with topology: TmuxSessionController.TopologySnapshot - ) { - let removedPaneIDs = panePreviewCache.retainOnly(Set(topology.panes.map(\.id))) - guard !removedPaneIDs.isEmpty else { return } - GhosttyRuntimeTrace.perf( - "tmuxPane.preview.cache result=topology-remove panes=\(removedPaneIDs) total=\(panePreviewCache.totalByteCost)" - ) - } - - private func clearPanePreviewCache(reason: String) { - guard !panePreviewCache.entries.isEmpty else { return } - panePreviewCache.removeAll() - GhosttyRuntimeTrace.perf( - "tmuxPane.preview.cache result=clear reason=\(reason)" - ) - } - // MARK: Input routing private func preflightFocusedInput() -> FocusedTerminalInputSubmissionResult? { @@ -621,59 +471,6 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { ) } - - - - - - // MARK: Selection sheet projections - - - - - - func windowSheetPresentationProjection() -> GhosttyWindowSheetPresentationProjection? { - GhosttyTerminalPresentationProjector.windowSheetPresentationProjection( - snapshot: topologySnapshot - ) - } - - func selectedPaneSheetPresentationProjection() -> GhosttyPaneSheetPresentationProjection? { - GhosttyTerminalPresentationProjector.selectedPaneSheetPresentationProjection( - snapshot: topologySnapshot - ) - } - - func paneCount(topLevelID: UUID) -> Int { - GhosttyTerminalPresentationProjector.paneCount( - topLevelID: topLevelID, - snapshot: topologySnapshot - ) - } - - func paneSelectionSheetTopologyProjection( - topLevelID: UUID? - ) -> GhosttyPaneSelectionSheetTopologyProjection { - GhosttyTerminalPresentationProjector.paneSelectionSheetTopologyProjection( - topLevelID: topLevelID, - snapshot: topologySnapshot - ) - } - - func windowSelectionSheetRenderProjection() -> GhosttyWindowSelectionSheetRenderProjection { - GhosttyTerminalPresentationProjector.windowSelectionSheetRenderProjection( - snapshot: topologySnapshot - ) - } - - func paneSelectionSheetRenderProjection( - topLevelID: UUID - ) -> GhosttyPaneSelectionSheetRenderProjection { - GhosttyTerminalPresentationProjector.paneSelectionSheetRenderProjection( - topLevelID: topLevelID, - snapshot: topologySnapshot - ) - } } // MARK: - Shared reason mapping diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift index 23a66a59..9b65126b 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift @@ -401,35 +401,11 @@ final class TmuxTerminalSession: ObservableObject { return } } - surfacesByPaneID[paneID]?.cancelPickerCaptureForPresentation() cancelPendingPresentation() pendingPaneID = paneID unpublishPane() } - func capturePickerPreview( - paneID: TmuxPaneID, - columns: UInt32, - rows: UInt32, - budget: GhosttyPanePreviewSession.PixelBudget - ) async -> GhosttyPanePreviewSession.RenderedPreview? { - guard !isShutDown, - state == .ready, - livePaneIDs.contains(paneID), - let surface = surfacesByPaneID[paneID], - !surface.isClosing - else { return nil } - return await surface.capturePickerPreview( - columns: columns, - rows: rows, - budget: budget - ) - } - - func cancelPickerPreview(paneID: TmuxPaneID) { - surfacesByPaneID[paneID]?.cancelPickerCaptureForPresentation() - } - private func presentActivePane(from snapshot: TmuxSessionController.TopologySnapshot) { guard !isShutDown, isAppActive, state == .ready, let paneID = activePaneID(in: snapshot) diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift index ded9a8a1..4e79bb9c 100644 --- a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift @@ -27,6 +27,45 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { XCTAssertNil(registry.windowID(for: UUID())) } + func testTopologyProjectionReflectsEmittedTopologyImmediately() async throws { + let runtime = try GhosttyKitRuntime() + let session = makeSession(runtime: runtime) + let adapter = TmuxTerminalScreenAdapter() + adapter.activate( + session: session, + initialViewportHandler: { _, _ in }, + viewportStabilityHandler: { _ 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.terminalInteractionProjection + XCTAssertEqual(first.windowCount, 2) + XCTAssertEqual(first.selectedWindowIndex, 0) + XCTAssertEqual(first.paneCount, 1) + + 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.terminalInteractionProjection + XCTAssertEqual(second.windowCount, 1) + XCTAssertEqual(second.selectedWindowIndex, 0) + + await session.shutdown() + } + private func makeSession(runtime: GhosttyKitRuntime) -> TmuxTerminalSession { TmuxTerminalSession( app: runtime.appHandleForTesting, @@ -44,15 +83,13 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { private func window( id: TmuxWindowID, active: Bool, - paneID: TmuxPaneID?, - name: String = "", - zoomed: Bool = true + paneID: TmuxPaneID? ) -> TmuxSessionController.WindowInfo { TmuxSessionController.WindowInfo( id: id, - name: name, + name: "", active: active, - zoomed: zoomed, + zoomed: true, width: 80, height: 24, activePaneID: paneID @@ -73,201 +110,4 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { phase: .live ) } - - func testWindowProjectionReflectsEmittedTopologyImmediately() async throws { - let runtime = try GhosttyKitRuntime() - let session = makeSession(runtime: runtime) - let adapter = TmuxTerminalScreenAdapter() - adapter.activate( - session: session, - initialViewportHandler: { _, _ in }, - viewportStabilityHandler: { _ in } - ) - - let twoWindows = TmuxSessionController.TopologySnapshot( - sessionName: "fresh-test", - windows: [ - window(id: 1, active: true, paneID: 10, name: "editor"), - window(id: 2, active: false, paneID: 20, name: "logs") - ], - panes: [pane(id: 10, windowID: 1), pane(id: 20, windowID: 2)], - activeWindowID: 1 - ) - session.handleTopology(twoWindows) - - let first = adapter.windowSelectionSheetRenderProjection() - XCTAssertEqual( - first.windows.count, 2, - "the first emitted topology must project immediately, not lag one update behind" - ) - XCTAssertEqual(first.windows.map(\.displayName), ["editor", "logs"]) - let firstPaneSurfaceID = try XCTUnwrap(first.previewLeafIDs.first) - XCTAssertEqual(adapter.tmuxPaneID(for: firstPaneSurfaceID), 10) - - let oneWindow = TmuxSessionController.TopologySnapshot( - sessionName: "fresh-test", - windows: [window(id: 1, active: true, paneID: 10, name: "renamed")], - panes: [pane(id: 10, windowID: 1)], - activeWindowID: 1 - ) - session.handleTopology(oneWindow) - - let second = adapter.windowSelectionSheetRenderProjection() - XCTAssertEqual( - second.windows.count, 1, - "removing a non-current window must drop its tile on the same topology update" - ) - XCTAssertEqual(second.windows.first?.totalCount, 1) - XCTAssertEqual(second.windows.first?.displayName, "renamed") - - await session.shutdown() - } - - func testNameOnlyTopologyUpdatePreservesSurfaceIdentityAndPanePreview() async throws { - let runtime = try GhosttyKitRuntime() - let session = makeSession(runtime: runtime) - let adapter = TmuxTerminalScreenAdapter() - adapter.activate( - session: session, - initialViewportHandler: { _, _ in }, - viewportStabilityHandler: { _ in } - ) - - let initial = TmuxSessionController.TopologySnapshot( - sessionName: "rename-test", - windows: [window(id: 1, active: true, paneID: 10, name: "editor")], - panes: [pane(id: 10, windowID: 1)], - activeWindowID: 1 - ) - session.handleTopology(initial) - let before = adapter.windowSelectionSheetRenderProjection() - let beforeWindowID = try XCTUnwrap(before.windows.first?.id) - let beforePaneID = try XCTUnwrap(before.previewLeafIDs.first) - - let image = try makeImage(width: 4, height: 4) - var cache = TmuxPanePreviewImageCache(byteLimit: 1_024) - cache.store(preview(image), for: 10) - let initialByteCost = cache.totalByteCost - - let renamed = TmuxSessionController.TopologySnapshot( - sessionName: "rename-test", - windows: [window(id: 1, active: true, paneID: 10, name: "déploy-漢字")], - panes: [pane(id: 10, windowID: 1)], - activeWindowID: 1 - ) - session.handleTopology(renamed) - let after = adapter.windowSelectionSheetRenderProjection() - - XCTAssertEqual(after.windows.first?.displayName, "déploy-漢字") - XCTAssertEqual(after.windows.first?.id, beforeWindowID) - XCTAssertEqual(after.previewLeafIDs.first, beforePaneID) - XCTAssertEqual( - cache.retainOnly(Set(renamed.panes.map(\.id))), - [], - "a name-only topology update must not evict any pane preview" - ) - XCTAssertTrue(cache.preview(for: 10)?.image === image) - XCTAssertEqual(cache.totalByteCost, initialByteCost) - - await session.shutdown() - } - - func testPanePreviewCacheEvictsLeastRecentlyUsedImageWithinByteLimit() throws { - let first = try makeImage(width: 4, height: 4) - let second = try makeImage(width: 4, height: 4) - let third = try makeImage(width: 4, height: 4) - let imageCost = first.bytesPerRow * first.height - var cache = TmuxPanePreviewImageCache(byteLimit: imageCost * 2) - - XCTAssertEqual(cache.store(preview(first), for: 1), []) - XCTAssertEqual(cache.store(preview(second), for: 2), []) - XCTAssertNotNil(cache.preview(for: 1), "reading pane 1 must refresh its LRU age") - XCTAssertEqual(cache.store(preview(third), for: 3), [2]) - XCTAssertNotNil(cache.preview(for: 1)) - XCTAssertNil(cache.preview(for: 2)) - XCTAssertNotNil(cache.preview(for: 3)) - XCTAssertEqual(cache.totalByteCost, imageCost * 2) - } - - func testPanePreviewCacheDropsRemovedTopologyPanes() throws { - let image = try makeImage(width: 4, height: 4) - var cache = TmuxPanePreviewImageCache(byteLimit: 1024) - cache.store(preview(image), for: 1) - cache.store(preview(image), for: 2) - - XCTAssertEqual(Set(cache.retainOnly(Set([2]))), Set([1])) - XCTAssertNil(cache.preview(for: 1)) - XCTAssertNotNil(cache.preview(for: 2)) - } - - func testPanePreviewCacheRejectsImageLargerThanByteLimit() throws { - let image = try makeImage(width: 4, height: 4) - var cache = TmuxPanePreviewImageCache( - byteLimit: image.bytesPerRow * image.height - 1 - ) - - XCTAssertEqual(cache.store(preview(image), for: 1), []) - XCTAssertNil(cache.preview(for: 1)) - XCTAssertEqual(cache.totalByteCost, 0) - } - - func testPanePreviewCacheRetainsFullViewportProvenance() throws { - let image = try makeImage(width: 4, height: 4) - let expected = provenance() - var cache = TmuxPanePreviewImageCache(byteLimit: 1024) - - cache.store( - .init(image: image, source: .fullViewport(expected)), - for: 1 - ) - - XCTAssertEqual(cache.entries[1]?.preview.source, .fullViewport(expected)) - } - - func testPanePreviewCacheRetainsPaneGeometrySource() throws { - let image = try makeImage(width: 4, height: 4) - var cache = TmuxPanePreviewImageCache(byteLimit: 1024) - - cache.store(preview(image), for: 1) - - guard case .paneGeometry(let provenance)? = cache.preview(for: 1)?.source else { - return XCTFail("expected pane geometry provenance") - } - XCTAssertEqual(provenance.columns, 80) - XCTAssertEqual(provenance.rows, 24) - } - - private func makeImage(width: Int, height: Int) throws -> CGImage { - let context = try XCTUnwrap(CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - )) - return try XCTUnwrap(context.makeImage()) - } - - private func provenance() -> GhosttyPanePreviewSession.FullViewportProvenance { - GhosttyPanePreviewSession.FullViewportProvenance( - surfaceID: UUID(), - pixelWidth: 390, - pixelHeight: 709 - ) - } - - private func preview( - _ image: CGImage - ) -> GhosttyPanePreviewSession.RenderedPreview { - .init( - image: image, - source: .paneGeometry(.init( - surfaceID: UUID(), - columns: 80, - rows: 24 - )) - ) - } } diff --git a/MoriRemote/UPSTREAM.md b/MoriRemote/UPSTREAM.md index 1d87ffa9..e8569dee 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -20,25 +20,24 @@ 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/model, pane preview cache, - runtime trace, and deterministic test transport. + terminal session, pane surface, screen adapter/model, runtime trace, and + deterministic test transport. - `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 - and system-keyboard chrome, preview layout, topology/selection projections, - selection sheets, and `GhosttyTerminalCoreView` (the minimal - upstream-derived composition root). + 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 ports the matching upstream tests for controller/session/link/adapter teardown, scrolling and viewport state, -responder and keyboard input, modifier state, and selection projections. +responder and keyboard input, modifier state, and local text selection. ## Explicit exclusions No files from remux account/profile repositories, SSH services/transports, -live forwarding, terminal preview, full composer/voice, generic file -attachments, or shortcut marketplace/editor are linked into +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` facade delegates the authenticated SFTP @@ -62,11 +61,12 @@ transport remains solely a terminal-core test fixture. | `App/MoriRemoteTerminalFacade.swift` | Public deep facade owns `GhosttyKitRuntime` + screen model, 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. | | `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` | 850-line subtractive adaptation of upstream's 883-line 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. Picker sheets are topology UI, not text selection. | +| `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. | -| iOS 17 | Keeps `#available(iOS 26, *)` styling fallback in upstream selection UI; terminal framework deployment target is `17.0`. | Upstream source uses no required iOS 18 API in this closed slice. | +| 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. | +| iOS 17 | Terminal framework deployment target remains `17.0`. | The closed source slice uses no required iOS 18 API. | ## Test provenance From 2f486d97c9376d726893a1377678dd00a894cdb8 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 23:09:06 +0800 Subject: [PATCH 17/22] moriremote: collapse terminal transplant scaffolding Let the public facade directly own the terminal session and concrete screen adapter. Remove one-adapter modeling protocols, dead topology identities/refocus policies, excluded tracked-composer input, test fixtures from the production target, and tracing flows that had no producer. --- .../MoriRemote.xcodeproj/project.pbxproj | 32 +- .../App/MoriRemoteTerminalFacade.swift | 41 +- .../Ghostty/GhosttyManagedSurface.swift | 16 - ...hosttyRuntimeSurfaceTopologySnapshot.swift | 36 -- .../Ghostty/GhosttySingleViewportView.swift | 9 +- .../Ghostty/GhosttyTerminalCoreView.swift | 2 +- .../GhosttyTerminalInputCoordinator.swift | 151 ----- ...GhosttyTerminalPresentationProjector.swift | 93 +-- .../GhosttyTerminalResponderView.swift | 119 +--- .../GhosttyTerminalScreenModeling.swift | 132 ----- .../GhosttyTmuxActionTargetResolver.swift | 12 - .../Ghostty/GhosttyTopLevelSurface.swift | 21 - .../Tmux/GhosttyRuntimeTrace.swift | 555 +----------------- .../Tmux/TmuxIdentity.swift | 39 -- .../Tmux/TmuxPaneSurface.swift | 80 +-- .../Tmux/TmuxScreenModel.swift | 48 -- .../Tmux/TmuxSessionController.swift | 114 ---- .../Tmux/TmuxSessionLink.swift | 2 +- .../Tmux/TmuxTerminalScreenAdapter.swift | 201 +------ .../DeterministicTmuxControlTransport.swift | 1 + .../GhosttyTopLevelSurfaceTests.swift | 48 -- .../TmuxTerminalScreenAdapterTests.swift | 36 +- MoriRemote/UPSTREAM.md | 11 +- 23 files changed, 89 insertions(+), 1710 deletions(-) delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRuntimeSurfaceTopologySnapshot.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxActionTargetResolver.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift rename MoriRemote/{MoriRemoteTerminal/Tmux => MoriRemoteTerminalTests}/DeterministicTmuxControlTransport.swift (97%) delete mode 100644 MoriRemote/MoriRemoteTerminalTests/GhosttyTopLevelSurfaceTests.swift diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index 441418be..912a3eee 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -7,18 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 01D6EAEBDB1ECC8192C0CA11 /* GhosttyTopLevelSurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */; }; 04BF2F5618DC52CA420109BF /* GhosttyTerminalResponderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 678146824749C1C540C8D179 /* GhosttyTerminalResponderViewTests.swift */; }; - 055D7CE8194A8EB00C6AB48F /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AF4F5E129749B9F473BA1D /* DeterministicTmuxControlTransport.swift */; }; - 09212452679FFE001555BFCB /* GhosttyTerminalScreenModeling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */; }; 0A3E5C8D6D19481EDBA77835 /* GhosttyKitControlSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511048C0833352ACA220DEE8 /* GhosttyKitControlSurface.swift */; }; 0CD0FDE83F290E70D2B82BAB /* SSHImageUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AE8D2B54D6CB377400488B /* SSHImageUpload.swift */; }; - 0D9936CC7BE5A981314D56F0 /* GhosttyTopLevelSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.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 */; }; - 160AABFF71D3C5A31ECC918F /* TmuxScreenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20C034CF2CE559BF155AD4 /* TmuxScreenModel.swift */; }; 16DF0B72EA0BA73D616F61D5 /* CitadelSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77CF33F94CEBE45B61807FB /* CitadelSSHTransport.swift */; }; 1B4ABC9EE1AAD05752C0DDDE /* GhosttySurfaceMouseEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF6A6CC81A7E40BF7BB825E /* GhosttySurfaceMouseEvent.swift */; }; 2302A7B4A772047379C73067 /* GhosttyTerminalCompositionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD23FE8E78A324650AA77CC /* GhosttyTerminalCompositionState.swift */; }; @@ -57,7 +52,6 @@ 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 */; }; - 6BE7A55C31DA48C075ED4886 /* GhosttyTmuxActionTargetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.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 */; }; @@ -104,6 +98,7 @@ 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 */; }; @@ -112,7 +107,6 @@ 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 */; }; - F6EB8B544E101B7B7FE4ED5F /* GhosttyRuntimeSurfaceTopologySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.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 */; }; @@ -155,7 +149,6 @@ 0EBBE7E60EEF6A3363F73F0A /* MoriRemoteDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteDependencies.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; }; - 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxActionTargetResolver.swift; sourceTree = ""; }; 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 = ""; }; @@ -170,15 +163,14 @@ 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 = ""; }; 47B29BE2E10455066FD1CE70 /* GhosttyKeyboardChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardChrome.swift; sourceTree = ""; }; 495654252F6CE455BE0201B3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 4D20C034CF2CE559BF155AD4 /* TmuxScreenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxScreenModel.swift; sourceTree = ""; }; 4E8CA8956F700B0020F44AED /* GhosttyTerminalViewportCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewportCoordinatorTests.swift; sourceTree = ""; }; - 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRuntimeSurfaceTopologySnapshot.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 = ""; }; @@ -188,10 +180,8 @@ 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 = ""; }; - 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurface.swift; sourceTree = ""; }; 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySingleViewportView.swift; sourceTree = ""; }; - 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurfaceTests.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 = ""; }; @@ -199,7 +189,6 @@ 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 = ""; }; - 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalScreenModeling.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 = ""; }; @@ -218,7 +207,6 @@ 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 = ""; }; - B6AF4F5E129749B9F473BA1D /* DeterministicTmuxControlTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterministicTmuxControlTransport.swift; sourceTree = ""; }; B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRuntimeTypes.swift; sourceTree = ""; }; B88BDAAB702E98FDD084041C /* LegacyMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMigration.swift; sourceTree = ""; }; BDD098C91CB7C0CA340592E1 /* MoriTmuxNativeStartupIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriTmuxNativeStartupIsolationTests.swift; sourceTree = ""; }; @@ -310,13 +298,11 @@ 30DDCCC0FF189CEABC730FB5 /* Tmux */ = { isa = PBXGroup; children = ( - B6AF4F5E129749B9F473BA1D /* DeterministicTmuxControlTransport.swift */, CF39528D1FAB67FE7906605F /* GhosttyRuntimeTrace.swift */, C6CD869EC2CF2DD3481DE8E9 /* TmuxControlTransport.swift */, 88FA2BFA5CD2056F8BF4C10F /* TmuxControlViewport.swift */, 8C2B40FFD54FA5E83ED47253 /* TmuxIdentity.swift */, A7F69592AA4A0788015A2A51 /* TmuxPaneSurface.swift */, - 4D20C034CF2CE559BF155AD4 /* TmuxScreenModel.swift */, F6FCBEFA092CA08F879265D1 /* TmuxSessionController.swift */, 076F9259ECBB8E9DE2737FB2 /* TmuxSessionLink.swift */, E68FBB36ECEC4F7C77BA31B4 /* TmuxTerminalScreenAdapter.swift */, @@ -346,6 +332,7 @@ 5C295A7834704597EC4619C0 /* MoriRemoteTerminalTests */ = { isa = PBXGroup; children = ( + 443669BFE66D88CAC8DCCEE2 /* DeterministicTmuxControlTransport.swift */, B2CE71956CBE6B24690CB0E7 /* GhosttyImageAttachmentTests.swift */, 815558772B7C99B76036C473 /* GhosttyKeyboardChromeActionsTests.swift */, BE2FE3FEDF880BE280656A2D /* GhosttyKeyboardChromeModeTests.swift */, @@ -363,7 +350,6 @@ 22E9E52C78F643675B58F0BA /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift */, 4E8CA8956F700B0020F44AED /* GhosttyTerminalViewportCoordinatorTests.swift */, 0544BADF4E27E64B50E2BBC9 /* GhosttyTmuxPrefixInputBufferTests.swift */, - 7B544416E20689DB6B198A0D /* GhosttyTopLevelSurfaceTests.swift */, 9DAD61A087944EDE21D81BDA /* MoriRemoteTerminalFacadeTests.swift */, 6973C2936B36B0BFE904B022 /* MoriTmuxIsolationTests.swift */, BDD098C91CB7C0CA340592E1 /* MoriTmuxNativeStartupIsolationTests.swift */, @@ -470,7 +456,6 @@ 04577A2C3BBC8458C5A60A76 /* GhosttyPaneScrollContainerView.swift */, A49304A7038A93C9A55D7F2C /* GhosttyPublishedFrameObserver.swift */, FFF190110E6756726243B4B5 /* GhosttyRendererLayer.swift */, - 4FBA0086A51DAD3C25A60BE7 /* GhosttyRuntimeSurfaceTopologySnapshot.swift */, 3C290B472A5F07F07F8B1DA7 /* GhosttyScrollPhysicsView.swift */, 7639439F69A16AF8882F2391 /* GhosttySingleViewportView.swift */, BF46159144201B1AD4C95944 /* GhosttySurfaceKeyEvent.swift */, @@ -484,12 +469,9 @@ E137716C1A4146436A28685D /* GhosttyTerminalResponderFocusPolicy.swift */, 1B49983C9FB3783CBF224C23 /* GhosttyTerminalResponderTextInputShim.swift */, 24733F909F325E7D558F8E31 /* GhosttyTerminalResponderView.swift */, - 95C62EC1607B686139F91C60 /* GhosttyTerminalScreenModeling.swift */, B29878C376F0AD7940205B86 /* GhosttyTerminalSurfaceInteractionOutcome.swift */, 9659C2FCA72254982C81D686 /* GhosttyTerminalViewportCoordinator.swift */, - 13079498941B156A3187CBC0 /* GhosttyTmuxActionTargetResolver.swift */, 8D2D395101C23EF27C68EAA9 /* GhosttyTmuxPrefixInputBuffer.swift */, - 712272E0D9E8DFA5283FDBE1 /* GhosttyTopLevelSurface.swift */, 676FDCFD6BAB360A6FDE7151 /* GhosttyViewportSizing.swift */, ); path = Ghostty; @@ -701,7 +683,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 055D7CE8194A8EB00C6AB48F /* DeterministicTmuxControlTransport.swift in Sources */, 26B09ED95683BE1B0C6484A6 /* GhosttyImageAttachmentSheet.swift in Sources */, 25683F5BC20807C3F9ADE249 /* GhosttyKeyboardChrome.swift in Sources */, F1D09D202AF835D9ED07031C /* GhosttyKeyboardCursorTrackpad.swift in Sources */, @@ -716,7 +697,6 @@ D59928A947468A35FBE0FA53 /* GhosttyPaneScrollContainerView.swift in Sources */, E4C3B69390643F74AAE51D48 /* GhosttyPublishedFrameObserver.swift in Sources */, 8811DFD8D63BC3F6263EFCF1 /* GhosttyRendererLayer.swift in Sources */, - F6EB8B544E101B7B7FE4ED5F /* GhosttyRuntimeSurfaceTopologySnapshot.swift in Sources */, AFE0787AFAB5605F12537763 /* GhosttyRuntimeTrace.swift in Sources */, C5CA42EC2B7413DB3A326D73 /* GhosttyScrollPhysicsView.swift in Sources */, AABCC196B2F7F17A6BA540BE /* GhosttySingleViewportView.swift in Sources */, @@ -731,12 +711,9 @@ F04B667AE1CBE572A7553EEE /* GhosttyTerminalResponderFocusPolicy.swift in Sources */, AAF3070C7F3F444471211195 /* GhosttyTerminalResponderTextInputShim.swift in Sources */, 3E7D9500BA676CB7BF115D4E /* GhosttyTerminalResponderView.swift in Sources */, - 09212452679FFE001555BFCB /* GhosttyTerminalScreenModeling.swift in Sources */, 4990AE0712D61323469D3EF4 /* GhosttyTerminalSurfaceInteractionOutcome.swift in Sources */, 3A1491877954A342656AEBF9 /* GhosttyTerminalViewportCoordinator.swift in Sources */, - 6BE7A55C31DA48C075ED4886 /* GhosttyTmuxActionTargetResolver.swift in Sources */, C15730869E5A9CF770E3D2CC /* GhosttyTmuxPrefixInputBuffer.swift in Sources */, - 0D9936CC7BE5A981314D56F0 /* GhosttyTopLevelSurface.swift in Sources */, B191529CCEFA8B8A6161B20E /* GhosttyViewportSizing.swift in Sources */, 31097F77B38B348F9E7E0BB3 /* Haptic.swift in Sources */, C3F4EADB4D52F73F75D6E750 /* MoriRemoteTerminalFacade.swift in Sources */, @@ -747,7 +724,6 @@ C34E3D3F89FF5F790D22D0C0 /* TmuxControlViewport.swift in Sources */, 7ACD00781983EB3E3052D10F /* TmuxIdentity.swift in Sources */, 7B1B93EEB1DE05CA1966D556 /* TmuxPaneSurface.swift in Sources */, - 160AABFF71D3C5A31ECC918F /* TmuxScreenModel.swift in Sources */, 7608ABD730F2113B6100141F /* TmuxSessionController.swift in Sources */, 3D1C909CBEFA2B7BEC0D33B6 /* TmuxSessionLink.swift in Sources */, 977DBF133BDD92FAFED6FAAB /* TmuxTerminalScreenAdapter.swift in Sources */, @@ -797,6 +773,7 @@ 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 */, @@ -814,7 +791,6 @@ E75088D081F5457454778A3D /* GhosttyTerminalSurfaceInteractionOutcomeTests.swift in Sources */, DACDAB6BF863B2DE4F81F8A9 /* GhosttyTerminalViewportCoordinatorTests.swift in Sources */, 29F224E2CCC19FD837908D80 /* GhosttyTmuxPrefixInputBufferTests.swift in Sources */, - 01D6EAEBDB1ECC8192C0CA11 /* GhosttyTopLevelSurfaceTests.swift in Sources */, 545CF67F733324F87DE9CBEB /* MoriRemoteTerminalFacadeTests.swift in Sources */, C598300440F9F8D28664A7A2 /* MoriTmuxIsolationTests.swift in Sources */, 7D9CB9C5D0700BA450BD9954 /* MoriTmuxNativeStartupIsolationTests.swift in Sources */, diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift index 42fa0195..bbf3a21d 100644 --- a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -123,7 +123,8 @@ public final class MoriRemoteTerminalSession: ObservableObject { public var onTopologyChange: (@MainActor (MoriRemoteTerminalTopology) -> Void)? private let runtime: GhosttyKitRuntime - fileprivate let screen: TmuxScreenModel + private let terminalSession: TmuxTerminalSession + fileprivate let screenAdapter: TmuxTerminalScreenAdapter public init( transport: MoriRemoteTerminalTransport, @@ -133,21 +134,30 @@ public final class MoriRemoteTerminalSession: ObservableObject { self.instanceID = instanceID let runtime = try GhosttyKitRuntime() self.runtime = runtime - screen = TmuxScreenModel( + let terminalSession = TmuxTerminalSession( app: runtime.appHandle, transport: ClosureTmuxControlTransport(base: transport), historyLineLimit: initialScrollbackLines, baseSurfaceConfig: { runtime.makeTmuxBaseSurfaceConfig() }, paneViewTheme: { .ghosttyDefault } ) - screen.session?.onStateChange = { [weak self] state in self?.receive(state) } - screen.session?.onTopologyChange = { [weak self] snapshot in self?.receive(snapshot) } - screen.session?.onPresentationChange = { [weak self] ready in self?.isPresentationReady = ready } + 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) } + terminalSession.onPresentationChange = { [weak self] ready in self?.isPresentationReady = ready } } public func start() async throws { do { - try await screen.connect() + try await terminalSession.connect() } catch { lastError = error.localizedDescription publishConnectionState(.disconnected) @@ -156,26 +166,27 @@ public final class MoriRemoteTerminalSession: ObservableObject { } public func stop() async { - await screen.stop() + screenAdapter.invalidate() + await terminalSession.shutdown() publishConnectionState(.disconnected) } public func setPresentationActive(_ active: Bool) { - screen.session?.setAppActive(active) + terminalSession.setAppActive(active) } - public func isControlChannelActive() async -> Bool { await screen.session?.controlChannelIsActive() ?? false } + public func isControlChannelActive() async -> Bool { await terminalSession.controlChannelIsActive() } - public func selectWindow(_ id: UInt64) { screen.session?.controller.requestSelectWindow(windowID: .init(id)) } - public func selectPane(_ id: UInt64) { screen.session?.controller.requestSelectPane(paneID: .init(id)) } + 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 - screen.session?.controller.queryAgentMetadata { result 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)) - } ?? continuation.resume(returning: .init(status: .failed, body: "")) + } } } @@ -184,7 +195,7 @@ public final class MoriRemoteTerminalSession: ObservableObject { case .newWindow: .newWindow; case .splitHorizontal: .splitHorizontal case .splitVertical: .splitVertical; case .closePane: .closePane; case .closeWindow: .closeWindow } - screen.session?.controller.requestSharedMutation(value) + terminalSession.controller.requestSharedMutation(value) } private func receive(_ state: TmuxSessionController.SessionState) { @@ -241,7 +252,7 @@ public struct MoriRemoteTerminalView: View { public var body: some View { GhosttyTerminalCoreView( - screen: session.screen.screenAdapter, + screen: session.screenAdapter, isInputSuspended: isInputSuspended, imageUploader: imageUploader, onShowNavigator: onShowNavigator, diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift index bd5a5207..d25a10a3 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyManagedSurface.swift @@ -92,11 +92,6 @@ final class GhosttyManagedSurface { return controlSurface.sendPaste(text) ? .accepted : .surfaceRejected } - func sendPasteAwaitingCommandCompletion(_ text: String) async -> Bool { - guard !text.isEmpty, let paneOwner else { return false } - return await paneOwner.sendPasteAwaitingCommandCompletion(text) - } - @discardableResult func sendKeyEvent(_ event: GhosttySurfaceKeyEvent) -> FocusedTerminalInputSubmissionResult { guard controlSurface.sendKeyEvent(event) else { return .surfaceRejected } @@ -104,17 +99,6 @@ final class GhosttyManagedSurface { return .accepted } - func sendKeyEventAwaitingCommandCompletion( - _ event: GhosttySurfaceKeyEvent - ) async -> Bool { - guard let paneOwner else { return false } - let delivered = await paneOwner.sendKeyEventAwaitingCommandCompletion(event) - if delivered { - onLocalSelectionGeometryChange?() - } - return delivered - } - func setVisible(_ visible: Bool) { guard visible != isVisible else { return } isVisible = visible diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRuntimeSurfaceTopologySnapshot.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRuntimeSurfaceTopologySnapshot.swift deleted file mode 100644 index f6c40677..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyRuntimeSurfaceTopologySnapshot.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -struct GhosttyRuntimeSurfaceTopologySnapshot: Equatable { - static var empty: GhosttyRuntimeSurfaceTopologySnapshot { - GhosttyRuntimeSurfaceTopologySnapshot( - topLevels: [], - selectedTopLevelID: nil - ) - } - - let topLevels: [GhosttyTopLevelSurface] - let selectedTopLevelID: UUID? - let selectedTopLevel: GhosttyTopLevelSurface? - let selectedTopLevelIndex: Int? - - init( - topLevels: [GhosttyTopLevelSurface], - selectedTopLevelID: UUID? - ) { - self.topLevels = topLevels - self.selectedTopLevelID = selectedTopLevelID - - guard let selectedTopLevelID, - let selectedTopLevelIndex = topLevels.firstIndex(where: { $0.id == selectedTopLevelID }) - else { - self.selectedTopLevel = nil - self.selectedTopLevelIndex = nil - return - } - - let selectedTopLevel = topLevels[selectedTopLevelIndex] - - self.selectedTopLevel = selectedTopLevel - self.selectedTopLevelIndex = selectedTopLevelIndex - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift index cda1e409..a5deedec 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttySingleViewportView.swift @@ -358,14 +358,7 @@ private final class GhosttySingleViewportContainerView: UIView, if changedFrame || changedContainer { container.layoutIfNeeded() } - GhosttyRuntimeTrace.flowEndIfActive( - GhosttyRuntimeTrace.paneSwitchFlow, - event: "presentation.reveal.ready", - fields: [ - "surface_uuid": surface.id.uuidString, - "wall_ns": "\(GhosttyRuntimeTrace.wallNanos())", - ] - ) + if let startedAt { GhosttyRuntimeTrace.perf( "viewport.layout bounds=\(ghosttyDiagnosticRect(bounds)) changed=\(changedFrame || changedContainer) elapsed_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: startedAt))" diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index fc7b7e51..3f55cdd4 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -56,7 +56,7 @@ struct GhosttyTerminalCoreView: View { terminalTheme: .ghosttyDefault, trackpadDriver: trackpadDriver, onSurfaceTap: { _ in activateTerminalInput() }, - onWindowSwipe: { guard isInputAvailable else { return }; _ = screen.focusAdjacentTmuxTopLevel($0) }, + onWindowSwipe: { guard isInputAvailable else { return }; screen.focusAdjacentTmuxTopLevel($0) }, sendKeyEvent: sendTerminalKey, onTrackpadFeedbackChange: { trackpadFeedback = $0 }, isMouseCaptured: { isInputAvailable && screen.isMouseCaptured(for: $0) }, diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift index f10221db..992f8687 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalInputCoordinator.swift @@ -224,154 +224,3 @@ struct GhosttyTerminalInputController: Equatable { tmuxPrefixInputBuffer.flushPendingInput(matching: token) } } - -struct GhosttyPendingTopologyInputRefocus: Equatable { - private var isPending = false - private var sourceActiveLeafID: UUID? - private(set) var ownsKeyboardTransition = false - - var isActive: Bool { - isPending - } - - @discardableResult - mutating func request( - from activeLeafID: UUID?, - keyboardMode: GhosttyKeyboardChromeMode, - keyboardOwner: GhosttyKeyboardOwner = .terminal - ) -> Bool { - guard keyboardMode == .system, keyboardOwner == .terminal else { return false } - isPending = true - sourceActiveLeafID = activeLeafID - ownsKeyboardTransition = false - return true - } - - mutating func markKeyboardTransitionOwned() { - guard isActive else { return } - ownsKeyboardTransition = true - } - - mutating func consumeIfActiveLeafChanged(to activeLeafID: UUID?) -> Bool { - guard isPending else { return false } - guard activeLeafID != sourceActiveLeafID else { return false } - - isPending = false - self.sourceActiveLeafID = nil - ownsKeyboardTransition = false - return true - } - - mutating func cancel() { - isPending = false - sourceActiveLeafID = nil - ownsKeyboardTransition = false - } -} - -struct GhosttyTopologyActionInputRefocusCoordinator: Equatable { - enum Effect: Equatable { - case requestRefocus - case dismissSelectionSheet - case cancelRefocus(ownsKeyboardTransition: Bool) - case completeRefocus - } - - enum EffectApplicationFeedback: Equatable { - case none - case refocusKeyboardTransitionStarted - } - - private var pendingRefocus = GhosttyPendingTopologyInputRefocus() - - var isActive: Bool { - pendingRefocus.isActive - } - - mutating func prepare( - actionEffect: GhosttyTmuxTopologyActionInteractionEffect, - activeLeafID: UUID?, - keyboardMode: GhosttyKeyboardChromeMode, - keyboardOwner: GhosttyKeyboardOwner = .terminal - ) -> Effect? { - guard actionEffect.requestsInputRefocus else { return nil } - guard pendingRefocus.request( - from: activeLeafID, - keyboardMode: keyboardMode, - keyboardOwner: keyboardOwner - ) else { - return nil - } - return .requestRefocus - } - - mutating func complete( - actionEffect: GhosttyTmuxTopologyActionInteractionEffect, - outcome: GhosttyTmuxModelActionOutcome - ) -> Effect? { - guard outcome.isQueued else { - guard actionEffect.requestsInputRefocus else { return nil } - guard pendingRefocus.isActive else { return nil } - - let ownsKeyboardTransition = pendingRefocus.ownsKeyboardTransition - pendingRefocus.cancel() - return .cancelRefocus(ownsKeyboardTransition: ownsKeyboardTransition) - } - - guard actionEffect.dismissesSelectionSheetOnQueued else { return nil } - return .dismissSelectionSheet - } - - mutating func consumeActiveLeafChange(to activeLeafID: UUID?) -> Effect? { - guard pendingRefocus.consumeIfActiveLeafChanged(to: activeLeafID) else { - return nil - } - return .completeRefocus - } - - mutating func cancelForCommandFailure() -> Effect? { - guard pendingRefocus.isActive else { return nil } - - let ownsKeyboardTransition = pendingRefocus.ownsKeyboardTransition - pendingRefocus.cancel() - return .cancelRefocus(ownsKeyboardTransition: ownsKeyboardTransition) - } - - @discardableResult - mutating func perform( - actionEffect: GhosttyTmuxTopologyActionInteractionEffect, - activeLeafID: UUID?, - keyboardMode: GhosttyKeyboardChromeMode, - keyboardOwner: GhosttyKeyboardOwner = .terminal, - apply: (Effect) -> EffectApplicationFeedback, - action: () -> GhosttyTmuxModelActionOutcome - ) -> GhosttyTmuxModelActionOutcome { - if let effect = prepare( - actionEffect: actionEffect, - activeLeafID: activeLeafID, - keyboardMode: keyboardMode, - keyboardOwner: keyboardOwner - ) { - applyEffect(effect, using: apply) - } - - let outcome = action() - - if let effect = complete(actionEffect: actionEffect, outcome: outcome) { - applyEffect(effect, using: apply) - } - - return outcome - } - - private mutating func applyEffect( - _ effect: Effect, - using apply: (Effect) -> EffectApplicationFeedback - ) { - let feedback = apply(effect) - guard case .requestRefocus = effect else { return } - guard feedback == .refocusKeyboardTransitionStarted else { return } - - pendingRefocus.markKeyboardTransitionOwned() - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift index b9cb20d6..3e7f9e55 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift @@ -173,13 +173,6 @@ enum TerminalReadinessProjector { struct GhosttyTerminalInteractionProjection: Equatable, Sendable { let isInputAvailable: Bool - let hasFocusedSurface: Bool - let selectedActiveLeafID: UUID? - let selectedWindowIndex: Int? - let windowCount: Int - let selectedPaneIndex: Int? - let paneCount: Int - let isWaitingForPanes: Bool } enum GhosttyTerminalStatusOverlayProjection: Equatable, Sendable { @@ -213,25 +206,6 @@ struct GhosttyTerminalViewportPresentationProjection: Equatable { } } -enum GhosttyTmuxTopologyActionInteractionEffect: Equatable, Sendable { - case none - case refocusOnly - case refocusAndDismissOnQueued - - var requestsInputRefocus: Bool { - switch self { - case .none: - false - case .refocusOnly, .refocusAndDismissOnQueued: - true - } - } - - var dismissesSelectionSheetOnQueued: Bool { - self == .refocusAndDismissOnQueued - } -} - @MainActor enum GhosttyTerminalPresentationProjector { static func terminalScreenPresentationProjection( @@ -241,12 +215,12 @@ enum GhosttyTerminalPresentationProjector { debugStatus: String, registryDebugSummary: String, presentedSurfaceID: UUID?, - snapshot: GhosttyRuntimeSurfaceTopologySnapshot + topLevelCount: Int ) -> GhosttyTerminalScreenPresentationProjection { let readiness = TerminalReadinessProjector.snapshot( phase: phase, transportWritable: transportWritable, - topLevelCount: snapshot.topLevels.count, + topLevelCount: topLevelCount, selectedActiveLeafID: presentedSurfaceID ) @@ -254,12 +228,11 @@ enum GhosttyTerminalPresentationProjector { readiness: readiness, interaction: terminalInteractionProjection( phase: phase, - presentedSurfaceID: presentedSurfaceID, - snapshot: snapshot + presentedSurfaceID: presentedSurfaceID ), viewport: GhosttyTerminalViewportPresentationProjection( surfaceID: presentedSurfaceID, - windowCount: snapshot.topLevels.count + windowCount: topLevelCount ), statusOverlay: terminalStatusOverlayProjection( readiness: readiness, @@ -304,65 +277,13 @@ enum GhosttyTerminalPresentationProjector { static func terminalInteractionProjection( phase: GhosttyTerminalRuntimePhase, - presentedSurfaceID: UUID?, - snapshot: GhosttyRuntimeSurfaceTopologySnapshot + presentedSurfaceID: UUID? ) -> GhosttyTerminalInteractionProjection { - let selectedTopLevel = snapshot.selectedTopLevel - let selectedPaneIndex = selectedTopLevel.flatMap { topLevel -> Int? in - guard let focusedLeafID = topLevel.resolvedFocusedLeafID else { return nil } - return topLevel.leafIDs.firstIndex(of: focusedLeafID) - } - let hasFocusedSurface = presentedSurfaceID != nil - - return GhosttyTerminalInteractionProjection( + GhosttyTerminalInteractionProjection( isInputAvailable: TerminalReadinessProjector.isInputAvailable( phase: phase, - hasFocusedSurface: hasFocusedSurface - ), - hasFocusedSurface: hasFocusedSurface, - selectedActiveLeafID: presentedSurfaceID, - selectedWindowIndex: snapshot.selectedTopLevelIndex, - windowCount: snapshot.topLevels.count, - selectedPaneIndex: selectedPaneIndex, - paneCount: selectedTopLevel?.leafIDs.count ?? 0, - isWaitingForPanes: TerminalReadinessProjector.isWaitingForPanes( - phase: phase, - topLevelCount: snapshot.topLevels.count + hasFocusedSurface: presentedSurfaceID != nil ) ) } - - static func createTmuxWindowInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect { - .refocusAndDismissOnQueued - } - - static func splitFocusedTmuxPaneInteractionEffect() -> GhosttyTmuxTopologyActionInteractionEffect { - .refocusAndDismissOnQueued - } - - static func closeTmuxWindowInteractionEffect( - _ id: UUID, - snapshot: GhosttyRuntimeSurfaceTopologySnapshot - ) -> GhosttyTmuxTopologyActionInteractionEffect { - guard snapshot.topLevels.contains(where: { $0.id == id }) else { - return .none - } - - return snapshot.topLevels.count <= 1 ? .refocusAndDismissOnQueued : .none - } - - static func closeTmuxPaneInteractionEffect( - _ id: UUID, - inTopLevel topLevelID: UUID, - snapshot: GhosttyRuntimeSurfaceTopologySnapshot - ) -> GhosttyTmuxTopologyActionInteractionEffect { - guard - let topLevel = snapshot.topLevels.first(where: { $0.id == topLevelID }), - topLevel.leafIDs.contains(id) - else { - return .none - } - - return topLevel.leafIDs.count == 1 ? .refocusOnly : .none - } } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift index c412b2d5..8144a596 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalResponderView.swift @@ -175,19 +175,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait GhosttyRuntimeTrace.diagnostics( "responder.update enabled=\(isEnabled) wasEnabled=\(wasInputEnabled) wantsFirstResponder=\(wantsFirstResponder) previousWantsFirstResponder=\(previouslyWantedFirstResponder) token=\(activationToken) previousToken=\(previousActivationToken) firstResponder=\(isFirstResponder) hasWindow=\(window != nil)" ) - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.update", - fields: [ - "enabled": "\(isEnabled)", - "firstResponder": "\(isFirstResponder)", - "hasWindow": "\(window != nil)", - "token": "\(activationToken)", - "wasEnabled": "\(wasInputEnabled)", - "wantsFirstResponder": "\(wantsFirstResponder)", - "previousWantsFirstResponder": "\(previouslyWantedFirstResponder)", - ] - ) + self.isInputEnabled = isEnabled self.wantsFirstResponder = wantsFirstResponder self.keyboardAppearance = keyboardAppearance @@ -239,15 +227,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait GhosttyRuntimeTrace.diagnostics( "responder.\(source) bytes=\(text.lengthOfBytes(using: .utf8)) firstResponder=\(isFirstResponder) token=\(activationToken)" ) - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.\(source)", - fields: [ - "bytes": "\(text.lengthOfBytes(using: .utf8))", - "firstResponder": "\(isFirstResponder)", - "token": "\(activationToken)", - ], - ) + _ = sendTextHandler?(text) } @@ -268,15 +248,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait override func becomeFirstResponder() -> Bool { let didBecomeFirstResponder = super.becomeFirstResponder() reportFirstResponderStateIfChanged() - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.becomeFirstResponder.result", - fields: [ - "firstResponder": "\(isFirstResponder)", - "result": "\(didBecomeFirstResponder)", - "token": "\(activationToken)", - ] - ) + return didBecomeFirstResponder } @@ -284,15 +256,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait cancelTrackpadGestureIfActive(reason: "resignFirstResponder") let didResignFirstResponder = super.resignFirstResponder() reportFirstResponderStateIfChanged() - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.resignFirstResponder.result", - fields: [ - "firstResponder": "\(isFirstResponder)", - "result": "\(didResignFirstResponder)", - "token": "\(activationToken)", - ] - ) + return didResignFirstResponder } @@ -331,14 +295,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait self?.trackpadFeedbackHandler?(state) } ) - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.trackpad.begin", - fields: [ - "firstResponder": "\(isFirstResponder)", - "token": "\(activationToken)", - ] - ) + } func updateFloatingCursor(at point: CGPoint) { @@ -348,26 +305,12 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait func endFloatingCursor() { guard trackpadDriver.end(owner: self) != nil else { return } - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.trackpad.end", - fields: [ - "firstResponder": "\(isFirstResponder)", - "token": "\(activationToken)", - ] - ) + } func cancelTrackpadGestureIfActive(reason: String) { guard trackpadDriver.cancel(owner: self) else { return } - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.trackpad.cancel", - fields: [ - "reason": reason, - "token": "\(activationToken)", - ] - ) + } func deleteBackward() { @@ -464,14 +407,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait guard !responderReconciliationScheduled else { return } responderReconciliationScheduled = true - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.reconcile.scheduled", - fields: [ - "reason": reason, - "token": "\(activationToken)", - ] - ) + DispatchQueue.main.async { [weak self] in guard let self else { return } self.reconcileResponderState() @@ -514,14 +450,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait GhosttyRuntimeTrace.perf( "responder.requestFirstResponder deferred token=\(activationToken) firstResponder=\(isFirstResponder)" ) - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.becomeFirstResponder.scheduled", - fields: [ - "route": "reconcile", - "token": "\(activationToken)", - ] - ) + _ = attemptFirstResponderRequest(route: "reconcile") } @@ -533,27 +462,12 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait GhosttyRuntimeTrace.perf( "responder.requestFirstResponder result=true route=\(route) token=\(activationToken) firstResponder=true" ) - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.becomeFirstResponder.already", - fields: [ - "route": route, - "token": "\(activationToken)", - ] - ) + return true } let traceStart = GhosttyRuntimeTrace.nowNanos() - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.becomeFirstResponder.begin", - fields: [ - "route": route, - "token": "\(activationToken)", - ], - at: traceStart - ) + let didBecomeFirstResponder = becomeFirstResponder() let elapsedMilliseconds = GhosttyRuntimeTrace.elapsedMilliseconds(from: traceStart) GhosttyRuntimeTrace.perf( @@ -562,16 +476,7 @@ final class GhosttyTerminalResponderUIView: UIView, UIKeyInput, UITextInputTrait GhosttyRuntimeTrace.diagnostics( "responder.requestFirstResponder result=\(didBecomeFirstResponder) route=\(route) token=\(activationToken) firstResponder=\(isFirstResponder)" ) - GhosttyRuntimeTrace.flowEventIfActive( - "terminal.input", - event: "responder.becomeFirstResponder.end", - fields: [ - "elapsed_ms": elapsedMilliseconds, - "result": "\(didBecomeFirstResponder)", - "route": route, - "token": "\(activationToken)", - ] - ) + if didBecomeFirstResponder { pendingFirstResponderRequest = false } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift deleted file mode 100644 index 139cbe96..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalScreenModeling.swift +++ /dev/null @@ -1,132 +0,0 @@ -import CoreGraphics -import Foundation -import GhosttyKit - -/// The model surface `GhosttyTerminalCoreView` renders against: projections -/// of terminal readiness/topology, focused-surface input routing, and tmux -/// topology actions. -/// -/// The tmux session stack implements it (`TmuxTerminalScreenAdapter`). The -/// screen owns presentation behavior only; everything engine-specific flows -/// through this boundary. -enum GhosttyTmuxModelActionOutcome: Equatable, Sendable { - case queued - case missingTarget(GhosttyTmuxActionMissingTarget) - - var isHandled: Bool { - switch self { - case .queued: - true - case .missingTarget: - false - } - } - - var isQueued: Bool { - self == .queued - } -} - -struct GhosttyTmuxCommandFailureEvent: Equatable { - let token: UInt64 - let message: String -} - -/// App-level scene lifecycle phases forwarded into terminal screen models. -enum GhosttyAppLifecyclePhase: Equatable { - case active - case inactive - case background -} - -@MainActor -protocol GhosttyTerminalRenderingModeling: ObservableObject { - var terminalScreenPresentationProjection: GhosttyTerminalScreenPresentationProjection { get } - var terminalInteractionProjection: GhosttyTerminalInteractionProjection { get } - var terminalManagedSurfaceLookup: GhosttyManagedSurfaceLookup { get } - var commandFailureEvent: GhosttyTmuxCommandFailureEvent? { get } - var stateTraceLabel: String { get } - - func prepareInitialViewport(size: CGSize, scale: CGFloat) - - /// Host hint that the terminal viewport is (not) in its settled - /// shape — false while a transient overlay (software keyboard) is - /// changing the layout. Engines use it to decide which reported - /// viewport is safe to carry into a reconnect. - func setViewportStabilityHint(stable: Bool) -} - -@MainActor -protocol GhosttyTerminalInputModeling: ObservableObject { - // MARK: Focused/targeted input routing - - @discardableResult - func sendInputToFocusedSurface(_ text: String) -> FocusedTerminalInputSubmissionResult - - @discardableResult - func sendPasteToFocusedSurface(_ text: String) -> FocusedTerminalInputSubmissionResult - - @discardableResult - func sendPaste(_ text: String, to surfaceID: UUID) -> FocusedTerminalInputSubmissionResult - - func sendPasteAwaitingCommandCompletion(_ text: String, to surfaceID: UUID) async -> Bool - - @discardableResult - func sendKeyEvent( - _ event: GhosttySurfaceKeyEvent, - to surfaceID: UUID - ) -> FocusedTerminalInputSubmissionResult - - func sendKeyEventAwaitingCommandCompletion( - _ event: GhosttySurfaceKeyEvent, - to surfaceID: UUID - ) async -> Bool - - @discardableResult - func sendKeyEventToFocusedSurface(_ event: GhosttySurfaceKeyEvent) -> FocusedTerminalInputSubmissionResult - - func isMouseCaptured(for surfaceID: UUID) -> Bool - - @discardableResult - func sendMouseButton( - to surfaceID: UUID, - _ event: GhosttySurfaceMouseButtonEvent - ) -> GhosttyMouseInputSubmissionOutcome - - @discardableResult - func sendMousePosition( - to surfaceID: UUID, - _ position: CGPoint, - mods: GhosttySurfaceKeyEvent.Mods - ) -> GhosttyMouseInputSubmissionOutcome - - @discardableResult - func sendMouseScroll( - to surfaceID: UUID, - _ event: GhosttySurfaceMouseScrollEvent - ) -> GhosttyMouseInputSubmissionOutcome - -} - -@MainActor -protocol GhosttyTmuxActionModeling: ObservableObject { - // MARK: tmux topology actions - - @discardableResult - func focusTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome - - @discardableResult - func focusTmuxTopLevel(_ id: UUID) -> GhosttyTmuxModelActionOutcome - - @discardableResult - func focusAdjacentTmuxTopLevel( - _ direction: GhosttyRuntimeSelectionDirection - ) -> GhosttyTmuxModelActionOutcome -} - -@MainActor -protocol GhosttyTerminalScreenModeling: - GhosttyTerminalRenderingModeling, - GhosttyTerminalInputModeling, - GhosttyTmuxActionModeling -{} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxActionTargetResolver.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxActionTargetResolver.swift deleted file mode 100644 index 7c56d437..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTmuxActionTargetResolver.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -enum GhosttyTmuxActionMissingTarget: Equatable, Sendable { - case host - case pane(UUID) - case focusedPane - case window(UUID) - case windowPane(UUID) - case selectedWindow - case adjacentWindow -} - diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift deleted file mode 100644 index ebe7d01c..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTopLevelSurface.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -struct GhosttyTopLevelSurface: Identifiable, Equatable { - let id: UUID - let leafIDs: [UUID] - let focusedLeafID: UUID? - - init( - id: UUID = UUID(), - leafIDs: [UUID], - focusedLeafID: UUID? = nil - ) { - self.id = id - self.leafIDs = leafIDs - self.focusedLeafID = focusedLeafID.flatMap { leafIDs.contains($0) ? $0 : nil } - } - - var resolvedFocusedLeafID: UUID? { - focusedLeafID ?? leafIDs.first - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift b/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift index 51c22086..e340b038 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/GhosttyRuntimeTrace.swift @@ -1,564 +1,37 @@ import Foundation enum GhosttyRuntimeTrace { - static let paneSwitchFlow = "tmux.paneSwitch" - static let isEnabled = ProcessInfo.processInfo.environment["REMUX_TRACE_GHOSTTY_IO"] == "1" - private static let latencyMode = ProcessInfo.processInfo.environment["REMUX_TRACE_LATENCY"] - static let latencyEnabled = latencyMode == "1" || latencyMode == "minimal" - private static let verboseLatencyEnabled = latencyMode == "1" - static let diagnosticsEnabled = isEnabled || - ProcessInfo.processInfo.environment["REMUX_TRACE_GHOSTTY_DIAGNOSTICS"] == "1" + 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" - static let tmuxViewportEnabled = ProcessInfo.processInfo.environment["REMUX_TRACE_TMUX_VIEWPORT"] == "1" - - private static let latencyProbeStore = GhosttyLatencyProbeStore() - private static let latencyMarkerAccumulator = GhosttyLatencyMarkerAccumulator() - private static let flowTraceStore = GhosttyFlowTraceStore() + private static let tmuxViewportEnabled = + ProcessInfo.processInfo.environment["REMUX_TRACE_TMUX_VIEWPORT"] == "1" static func nowNanos() -> UInt64 { DispatchTime.now().uptimeNanoseconds } - /// Wall-clock nanoseconds used only to correlate Remux milestones with - /// renderer completion timestamps emitted inside libghostty. Durations - /// continue to use the monotonic clock above. - static func wallNanos() -> UInt64 { - var value = timespec() - clock_gettime(CLOCK_REALTIME, &value) - return UInt64(value.tv_sec) * 1_000_000_000 + UInt64(value.tv_nsec) + 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("Remux diag %@", message()) + NSLog("MoriRemote diag %@", message()) } - /// Lightweight perf signpost. Gated on REMUX_TRACE_PERF=1 so it's a true - /// no-op (and the message autoclosure is not evaluated) in normal builds. - /// `thread` is captured because some Ghostty callbacks fire off-main and - /// we want to see which queue is actually doing the work. static func perf(_ message: @autoclosure () -> String) { guard perfEnabled else { return } - let threadLabel = Thread.isMainThread ? "main" : (Thread.current.name ?? "bg") - NSLog("Remux perf t=%llu thread=%@ %@", nowNanos(), threadLabel, message()) - } - - /// Wraps a block, recording its entry thread and elapsed duration when - /// REMUX_TRACE_PERF=1. Always cheap when disabled; the only cost is one - /// `nowNanos()` call before invoking the body. - static func perfMeasure(_ label: @autoclosure () -> String, _ body: () -> T) -> T { - guard perfEnabled else { return body() } - let entryThread = Thread.isMainThread ? "main" : (Thread.current.name ?? "bg") - let start = nowNanos() - let result = body() - NSLog( - "Remux perf t=%llu thread=%@ %@ elapsed_ms=%@", - start, - entryThread, - label(), - elapsedMilliseconds(from: start) - ) - return result - } - - static func latency(_ message: @autoclosure () -> String) { - guard latencyEnabled else { return } - let resolvedMessage = message() - guard verboseLatencyEnabled || isMinimalLatencyMessage(resolvedMessage) else { return } - - NSLog("Remux latency t=%llu %@", nowNanos(), resolvedMessage) + 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("Remux tmuxViewport t=%llu %@", nowNanos(), message()) - } - - static func viewportDescription(_ viewport: TmuxControlViewport) -> String { - "\(viewport.columns)x\(viewport.rows) px=\(viewport.pixelWidth)x\(viewport.pixelHeight)" - } - - static func formatTraceFields(_ fields: [String: String]) -> String { - fields.keys.sorted().map { key in - "\(key)=\(sanitizeTraceValue(fields[key] ?? ""))" - }.joined(separator: " ") - } - - private static func sanitizeTraceValue(_ value: String) -> String { - value - .replacingOccurrences(of: " ", with: "_") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - } - - static func flowBegin( - _ flow: String, - event: String, - fields: @autoclosure () -> [String: String] = [:], - startedAt: UInt64? = nil - ) { - guard flowTraceEnabled else { return } - let timestamp = startedAt ?? nowNanos() - flowTraceStore.begin(flow: flow, at: timestamp) - logFlow(flow, event: event, startedAt: timestamp, at: timestamp, fields: fields()) - } - - static func flowEvent( - _ flow: String, - event: String, - fields: @autoclosure () -> [String: String] = [:], - at timestamp: UInt64? = nil - ) { - guard flowTraceEnabled else { return } - let eventTimestamp = timestamp ?? nowNanos() - let start = flowTraceStore.start(for: flow) ?? eventTimestamp - logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) - } - - static func flowEventIfActive( - _ flow: String, - event: String, - fields: @autoclosure () -> [String: String] = [:], - at timestamp: UInt64? = nil - ) { - guard flowTraceEnabled else { return } - guard let start = flowTraceStore.start(for: flow) else { return } - let eventTimestamp = timestamp ?? nowNanos() - logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) - } - - static func flowEventSince( - _ flow: String, - event: String, - startedAt: UInt64, - fields: @autoclosure () -> [String: String] = [:], - at timestamp: UInt64? = nil - ) { - guard flowTraceEnabled else { return } - let eventTimestamp = timestamp ?? nowNanos() - logFlow(flow, event: event, startedAt: startedAt, at: eventTimestamp, fields: fields()) - } - - /// Like `flowEventIfActive`, but logs only the first occurrence of - /// `event` per flow lifetime — for emission sites that fire - /// repeatedly (SwiftUI view init, layout passes) where only the - /// first occurrence is the milestone. - static func flowEventOnce( - _ flow: String, - event: String, - fields: @autoclosure () -> [String: String] = [:], - at timestamp: UInt64? = nil - ) { - guard flowTraceEnabled else { return } - guard let start = flowTraceStore.markOnce(flow: flow, event: event) else { return } - let eventTimestamp = timestamp ?? nowNanos() - logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) - } - - static func flowEnd( - _ flow: String, - event: String, - fields: @autoclosure () -> [String: String] = [:], - at timestamp: UInt64? = nil - ) { - guard flowTraceEnabled else { return } - let eventTimestamp = timestamp ?? nowNanos() - let start = flowTraceStore.end(flow: flow) ?? eventTimestamp - logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) - } - - static func flowEndIfActive( - _ flow: String, - event: String, - fields: @autoclosure () -> [String: String] = [:], - at timestamp: UInt64? = nil - ) { - guard flowTraceEnabled else { return } - guard let start = flowTraceStore.end(flow: flow) else { return } - let eventTimestamp = timestamp ?? nowNanos() - logFlow(flow, event: event, startedAt: start, at: eventTimestamp, fields: fields()) - } - - static func isFlowActive(_ flow: String) -> Bool { - guard flowTraceEnabled else { return false } - return flowTraceStore.start(for: flow) != nil - } - - static func flowStartIfActive(_ flow: String) -> UInt64? { - guard flowTraceEnabled else { return nil } - return flowTraceStore.start(for: flow) - } - - static func elapsedMilliseconds(from start: UInt64, to end: UInt64 = nowNanos()) -> String { - String(format: "%.3f", Double(end &- start) / 1_000_000) - } - - static func registerLatencyProbe(marker: String, label: String, submittedAt: UInt64? = nil) { - guard latencyEnabled else { return } - let timestamp = submittedAt ?? nowNanos() - latencyProbeStore.register(marker: marker, label: label, submittedAt: timestamp) - latency("probe_register label=\(label) marker=\(marker)") - } - - static func registerLatencyMarkers(in text: String, label: String, submittedAt: UInt64? = nil) { - guard latencyEnabled else { return } - let timestamp = submittedAt ?? nowNanos() - - for marker in latencyMarkerAccumulator.append(text) { - registerLatencyProbe(marker: marker, label: label, submittedAt: timestamp) - } - } - - static func observeInboundData(_ data: Data, source: String) { - guard latencyEnabled, !data.isEmpty else { return } - - let now = nowNanos() - let hits = latencyProbeStore.recordHits(in: data) - for hit in hits { - latency( - "probe_hit label=\(hit.label) marker=\(hit.marker) hit=\(hit.hitCount) source=\(source) bytes=\(data.count) offset=\(hit.offset) delta_ms=\(elapsedMilliseconds(from: hit.submittedAt, to: now)) preview=\(preview(data, limit: 160))" - ) - flowEventIfActive( - "terminal.input", - event: "probe.hit", - fields: [ - "label": hit.label, - "marker": hit.marker, - "source": source, - "delta_ms": elapsedMilliseconds(from: hit.submittedAt, to: now), - ], - at: now - ) - } - } - - static func preview(_ data: Data, limit: Int = 48) -> String { - data - .prefix(limit) - .map { byte in - if byte >= 0x20, byte <= 0x7E { - return String(UnicodeScalar(byte)) - } - - return String(format: "\\x%02X", byte) - } - .joined() - } - - private static func isMinimalLatencyMessage(_ message: String) -> Bool { - message.hasPrefix("probe_") || - message.hasPrefix("debugLatencyProbe") - } - - static let flowTraceEnabled = perfEnabled || latencyEnabled || - ProcessInfo.processInfo.environment["REMUX_TRACE_FLOWS"] == "1" - - private static func logFlow( - _ flow: String, - event: String, - startedAt: UInt64, - at timestamp: UInt64, - fields: [String: String] - ) { - var parts = [ - "flow=\(flow)", - "event=\(event)", - "since_ms=\(elapsedMilliseconds(from: startedAt, to: timestamp))", - ] - for (key, value) in fields.sorted(by: { $0.key < $1.key }) { - parts.append("\(key)=\(normalizeFieldValue(value))") - } - NSLog("Remux flow t=%llu %@", timestamp, parts.joined(separator: " ")) - } - - private static func normalizeFieldValue(_ value: String) -> String { - value - .replacingOccurrences(of: " ", with: "_") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - } -} - -struct RemuxTransportStartupTrace: Sendable { - private let flowID: String? - private let startedAt: UInt64 - - init(flowID: String?, startedAt: UInt64 = GhosttyRuntimeTrace.nowNanos()) { - self.flowID = flowID - self.startedAt = startedAt - } - - func event( - _ name: String, - fields: [String: String] = [:], - at timestamp: UInt64 = GhosttyRuntimeTrace.nowNanos() - ) { - GhosttyRuntimeTrace.latency( - "transport.startup.\(name) since_ms=\(GhosttyRuntimeTrace.elapsedMilliseconds(from: startedAt, to: timestamp))\(latencyFields(fields))" - ) - - if let flowID { - GhosttyRuntimeTrace.flowEventIfActive( - flowID, - event: "transport.startup.\(name)", - fields: fields, - at: timestamp - ) - } - } - - func stage( - _ name: String, - fields: [String: String] = [:], - operation: () async throws -> T - ) async throws -> T { - let stageStart = GhosttyRuntimeTrace.nowNanos() - event("\(name).begin", fields: fields, at: stageStart) - - do { - let result = try await operation() - let finishedAt = GhosttyRuntimeTrace.nowNanos() - event( - "\(name).end", - fields: stageFields(fields, stageStart: stageStart, finishedAt: finishedAt), - at: finishedAt - ) - return result - } catch { - let failedAt = GhosttyRuntimeTrace.nowNanos() - var failureFields = stageFields(fields, stageStart: stageStart, finishedAt: failedAt) - failureFields["error"] = String(describing: error) - event("\(name).failed", fields: failureFields, at: failedAt) - throw error - } - } - - private func stageFields( - _ fields: [String: String], - stageStart: UInt64, - finishedAt: UInt64 - ) -> [String: String] { - var stageFields = fields - stageFields["elapsed_ms"] = GhosttyRuntimeTrace.elapsedMilliseconds(from: stageStart, to: finishedAt) - return stageFields - } - - private func latencyFields(_ fields: [String: String]) -> String { - guard !fields.isEmpty else { return "" } - - return " " + fields - .sorted(by: { $0.key < $1.key }) - .map { key, value in "\(key)=\(sanitizeLatencyField(value))" } - .joined(separator: " ") - } - - private func sanitizeLatencyField(_ value: String) -> String { - value - .replacingOccurrences(of: " ", with: "_") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - } -} - -enum GhosttyTmuxActionTrace { - enum Action: Equatable, Sendable { - case newWindow - case splitPane - - var flow: String { - switch self { - case .newWindow: - "tmux.newWindow" - case .splitPane: - "tmux.splitPane" - } - } - - } - - static func traceActiveTopologyFlows( - event: String, - fields: @autoclosure () -> [String: String] = [:], - at timestamp: UInt64? = nil - ) { - guard GhosttyRuntimeTrace.flowTraceEnabled else { return } - - var resolvedFields: [String: String]? - for action in [Action.newWindow, .splitPane] where GhosttyRuntimeTrace.isFlowActive(action.flow) { - if resolvedFields == nil { - resolvedFields = fields() - } - GhosttyRuntimeTrace.flowEventIfActive( - action.flow, - event: event, - fields: resolvedFields ?? [:], - at: timestamp - ) - } - } -} - -final class GhosttyFlowTraceStore: @unchecked Sendable { - private let lock = NSLock() - private var starts: [String: UInt64] = [:] - private var onceEvents: Set = [] - - func begin(flow: String, at timestamp: UInt64) { - lock.withLock { - starts[flow] = timestamp - clearOnceEventsLocked(flow: flow) - } - } - - func start(for flow: String) -> UInt64? { - lock.withLock { - starts[flow] - } - } - - func end(flow: String) -> UInt64? { - lock.withLock { - clearOnceEventsLocked(flow: flow) - return starts.removeValue(forKey: flow) - } - } - - /// First occurrence of `event` for an active flow: returns the - /// flow's start time exactly once per flow lifetime, nil after - /// (and always nil for inactive flows). A new `begin` re-arms. - func markOnce(flow: String, event: String) -> UInt64? { - lock.withLock { - guard let start = starts[flow] else { return nil } - guard onceEvents.insert("\(flow)#\(event)").inserted else { return nil } - return start - } - } - - private func clearOnceEventsLocked(flow: String) { - let prefix = "\(flow)#" - onceEvents = onceEvents.filter { !$0.hasPrefix(prefix) } - } -} - -final class GhosttyLatencyMarkerAccumulator: @unchecked Sendable { - private let lock = NSLock() - private let prefix = "__REMUX_LATENCY_" - private let maxBufferedCharacters: Int - private var buffer = "" - - init(maxBufferedCharacters: Int = 256) { - self.maxBufferedCharacters = max(32, maxBufferedCharacters) - } - - func append(_ text: String) -> [String] { - lock.withLock { - appendLocked(text) - } - } - - private func appendLocked(_ text: String) -> [String] { - guard !text.isEmpty else { return [] } - - buffer.append(text) - var markers: [String] = [] - - while true { - guard let prefixRange = buffer.range(of: prefix) else { - preservePossiblePrefixSuffix() - return markers - } - - if prefixRange.lowerBound > buffer.startIndex { - buffer.removeSubrange(buffer.startIndex.. maxBufferedCharacters else { return } - buffer = String(buffer.suffix(maxBufferedCharacters)) - } -} - -final class GhosttyLatencyProbeStore: @unchecked Sendable { - struct Hit { - let marker: String - let label: String - let submittedAt: UInt64 - let hitCount: Int - let offset: Int - } - - private struct Probe { - let marker: String - let markerData: Data - let label: String - let submittedAt: UInt64 - var hitCount: Int - } - - private let lock = NSLock() - private var probes: [String: Probe] = [:] - private var recentData = Data() - - func register(marker: String, label: String, submittedAt: UInt64) { - lock.withLock { - probes[marker] = Probe( - marker: marker, - markerData: Data(marker.utf8), - label: label, - submittedAt: submittedAt, - hitCount: 0 - ) - } - } - - func recordHits(in data: Data) -> [Hit] { - lock.withLock { - var hits: [Hit] = [] - var searchableData = recentData - let previousByteCount = searchableData.count - searchableData.append(data) - - for marker in probes.keys.sorted() { - guard var probe = probes[marker] else { continue } - guard let range = searchableData.range(of: probe.markerData) else { continue } - guard range.upperBound > previousByteCount else { continue } - - probe.hitCount += 1 - probes[marker] = probe - hits.append( - Hit( - marker: probe.marker, - label: probe.label, - submittedAt: probe.submittedAt, - hitCount: probe.hitCount, - offset: max(0, range.lowerBound - previousByteCount) - ) - ) - } - - if let maxMarkerLength = probes.values.map(\.markerData.count).max(), maxMarkerLength > 1 { - recentData = searchableData.suffix(maxMarkerLength - 1) - } else { - recentData.removeAll(keepingCapacity: true) - } - - return hits - } + NSLog("MoriRemote tmuxViewport t=%llu %@", nowNanos(), message()) } } diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift index 7d18fb2e..8bf3bf9b 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxIdentity.swift @@ -69,42 +69,3 @@ struct TerminalSurfaceInstanceID: RawRepresentable, Hashable, Sendable { self.rawValue = UUID() } } - -/// Owns the reversible identity boundary between tmux's typed numeric IDs and -/// the UUIDs used by terminal presentation projections. -struct TmuxTerminalIdentityRegistry { - private var paneSurfaceIDsByTmuxID: [TmuxPaneID: UUID] = [:] - private var paneTmuxIDsBySurfaceID: [UUID: TmuxPaneID] = [:] - private var windowSurfaceIDsByTmuxID: [TmuxWindowID: UUID] = [:] - private var windowTmuxIDsBySurfaceID: [UUID: TmuxWindowID] = [:] - - mutating func surfaceID(for paneID: TmuxPaneID) -> UUID { - if let existing = paneSurfaceIDsByTmuxID[paneID] { - return existing - } - - let surfaceID = UUID() - paneSurfaceIDsByTmuxID[paneID] = surfaceID - paneTmuxIDsBySurfaceID[surfaceID] = paneID - return surfaceID - } - - func paneID(for surfaceID: UUID) -> TmuxPaneID? { - paneTmuxIDsBySurfaceID[surfaceID] - } - - mutating func surfaceID(for windowID: TmuxWindowID) -> UUID { - if let existing = windowSurfaceIDsByTmuxID[windowID] { - return existing - } - - let surfaceID = UUID() - windowSurfaceIDsByTmuxID[windowID] = surfaceID - windowTmuxIDsBySurfaceID[surfaceID] = windowID - return surfaceID - } - - func windowID(for surfaceID: UUID) -> TmuxWindowID? { - windowTmuxIDsBySurfaceID[surfaceID] - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift index 2f996c90..4143c6b0 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxPaneSurface.swift @@ -88,20 +88,9 @@ final class TmuxPaneSurface { } private final class CallbackBox: @unchecked Sendable { - enum TrackedWriteTransport { - case exact - case literal - } - - private struct TrackedWrite { - let transport: TrackedWriteTransport - let completion: @Sendable (Bool) -> Void - } - let controller: TmuxSessionController let paneID: TmuxPaneID let failureRelay: FailureRelay - private var trackedWrite: TrackedWrite? init( controller: TmuxSessionController, @@ -115,56 +104,18 @@ final class TmuxPaneSurface { 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 from the - // output feed. `trackedWrite` is single-threaded because of - // this contract. + // 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 } - if let trackedWrite = box.trackedWrite { - box.trackedWrite = nil - let bytes = Data(bytes: pointer, count: count) - let admitted = switch trackedWrite.transport { - case .exact: - box.controller.sendTrackedInput( - paneID: box.paneID, - bytes, - completion: trackedWrite.completion - ) - case .literal: - box.controller.sendTrackedLiteralInput( - paneID: box.paneID, - bytes, - completion: trackedWrite.completion - ) - } - if !admitted { - trackedWrite.completion(false) - } - return admitted - } return box.controller.sendInput( paneID: box.paneID, Data(bytes: pointer, count: count) ) } - func performTrackedWrite( - transport: TrackedWriteTransport, - completion: @escaping @Sendable (Bool) -> Void, - _ operation: () -> Bool - ) { - MainActor.preconditionIsolated() - precondition(trackedWrite == nil) - trackedWrite = TrackedWrite(transport: transport, completion: completion) - _ = operation() - guard trackedWrite != nil else { return } - trackedWrite = nil - completion(false) - } - 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() @@ -280,35 +231,6 @@ final class TmuxPaneSurface { var rawSurface: ghostty_terminal_surface_t? { renderer?.handle } - func sendPasteAwaitingCommandCompletion(_ text: String) async -> Bool { - guard !text.isEmpty, lifecycle == .active, let renderer else { return false } - return await performInputAwaitingCommandCompletion(transport: .literal) { - renderer.control.sendPaste(text) - } - } - - func sendKeyEventAwaitingCommandCompletion( - _ event: GhosttySurfaceKeyEvent - ) async -> Bool { - guard lifecycle == .active, let renderer else { return false } - return await performInputAwaitingCommandCompletion(transport: .exact) { - renderer.control.sendKeyEvent(event) - } - } - - private func performInputAwaitingCommandCompletion( - transport: CallbackBox.TrackedWriteTransport, - _ operation: () -> Bool - ) async -> Bool { - await withCheckedContinuation { continuation in - callbackBox.performTrackedWrite( - transport: transport, - completion: { continuation.resume(returning: $0) }, - operation - ) - } - } - func screenSurface( onDisplayUpdate: @escaping (GhosttyManagedSurface, CGSize, CGFloat) -> Void ) -> GhosttyManagedSurface { diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift deleted file mode 100644 index 698c15ce..00000000 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxScreenModel.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import GhosttyKit - -/// Phase-1 composition seam. It owns an upstream session and screen adapter, -/// but deliberately does not construct a transport or touch Mori persistence. -@MainActor -final class TmuxScreenModel: ObservableObject { - let terminalScreenAdapter = TmuxTerminalScreenAdapter() - @Published private(set) var session: TmuxTerminalSession? - @Published private(set) var startupFailure: String? - - init( - app: ghostty_app_t, - transport: any TmuxControlTransport, - historyLineLimit: Int, - baseSurfaceConfig: @escaping () -> ghostty_terminal_surface_config_s, - paneViewTheme: @escaping () -> TerminalTheme - ) { - let session = TmuxTerminalSession( - app: app, - transport: transport, - historyLineLimit: historyLineLimit, - baseSurfaceConfig: baseSurfaceConfig, - paneViewTheme: paneViewTheme - ) - self.session = session - terminalScreenAdapter.activate( - session: session, - initialViewportHandler: { [weak session] size, scale in - session?.updateViewportMetrics(size: size, scale: scale) - }, - viewportStabilityHandler: { _ in } - ) - } - - func connect() async throws { - try await session?.connect() - } - - var screenAdapter: TmuxTerminalScreenAdapter { terminalScreenAdapter } - - func stop() async { - terminalScreenAdapter.invalidate() - guard let session else { return } - await session.shutdown() - self.session = nil - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift index 38c1a03e..19857eb7 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift @@ -191,7 +191,6 @@ final class TmuxSessionController: @unchecked Sendable { @Sendable (Result) -> Void ) case agentMetadata(@Sendable (AgentMetadataQueryResult) -> Void) - case trackedInput(@Sendable (Bool) -> Void) } private struct DesiredPaneRefresh { @@ -285,7 +284,6 @@ final class TmuxSessionController: @unchecked Sendable { guard !shuttingDown else { return } failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) failOutstandingAgentMetadataQueries() - failOutstandingTrackedInput() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil guard case .closed = state else { @@ -303,7 +301,6 @@ final class TmuxSessionController: @unchecked Sendable { guard !shuttingDown else { return } failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) failOutstandingAgentMetadataQueries() - failOutstandingTrackedInput() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil guard case .closed = state else { @@ -319,11 +316,9 @@ final class TmuxSessionController: @unchecked Sendable { outboundSink = nil let directoryQueries = outstandingPaneDirectoryQueries() let agentMetadataQueries = outstandingAgentMetadataQueries() - let trackedInputCompletions = outstandingTrackedInputCompletions() requestsByToken.removeAll() directoryQueries.forEach { $0(.failure(.sessionUnavailable)) } agentMetadataQueries.forEach { $0(.init(status: .failed, body: "")) } - trackedInputCompletions.forEach { $0(false) } deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil topology = nil @@ -401,7 +396,6 @@ final class TmuxSessionController: @unchecked Sendable { case GHOSTTY_TMUX_ACTION_EXIT: failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) failOutstandingAgentMetadataQueries() - failOutstandingTrackedInput() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil let exit = action.value.exit @@ -563,13 +557,6 @@ final class TmuxSessionController: @unchecked Sendable { preconditionOnWriterQueue() guard let outstanding = requestsByToken.removeValue(forKey: completion.token) else { return } switch outstanding { - case .trackedInput(let completionHandler): - let succeeded = completion.status == GHOSTTY_TMUX_COMMAND_SUCCESS - if !succeeded { - reportRequestFailure(.sendInput) - } - completionHandler(succeeded) - return case .paneCurrentDirectory(let completionHandler): completionHandler(paneCurrentDirectoryResult(for: completion)) return @@ -696,89 +683,6 @@ final class TmuxSessionController: @unchecked Sendable { return true } - /// Sends one terminal payload and completes only when tmux answers for - /// that exact send-keys command (or the session can no longer do so). - func sendTrackedInput( - paneID: TmuxPaneID, - _ bytes: Data, - completion: @escaping @Sendable (Bool) -> Void - ) -> Bool { - sendTrackedInput( - paneID: paneID, - bytes, - transport: .exact, - completion: completion - ) - } - - func sendTrackedLiteralInput( - paneID: TmuxPaneID, - _ bytes: Data, - completion: @escaping @Sendable (Bool) -> Void - ) -> Bool { - sendTrackedInput( - paneID: paneID, - bytes, - transport: .literal, - completion: completion - ) - } - - private enum TrackedInputTransport { - case exact - case literal - } - - private func sendTrackedInput( - paneID: TmuxPaneID, - _ bytes: Data, - transport: TrackedInputTransport, - completion: @escaping @Sendable (Bool) -> Void - ) -> Bool { - guard !bytes.isEmpty else { return false } - queue.async { [self, bytes] in - guard let client, outboundSink != nil, !shuttingDown else { - reportRequestFailure(.sendInput) - completion(false) - return - } - guard admitCommandOnWriter( - command: Self.cancelStaleSharedInputMode, - request: .sendInput - ) else { completion(false); return } - var token: UInt64 = 0 - let result = bytes.withUnsafeBytes { buffer in - let pointer = buffer.bindMemory(to: UInt8.self).baseAddress - return switch transport { - case .exact: - ghostty_tmux_client_send_pane_input_tracked( - client, - paneID.rawValue, - pointer, - buffer.count, - &token - ) - case .literal: - ghostty_tmux_client_send_pane_literal_input_tracked( - client, - paneID.rawValue, - pointer, - buffer.count, - &token - ) - } - } - guard result == GHOSTTY_TMUX_RESULT_OK else { - reportImmediateFailure(result, request: .sendInput) - completion(false) - return - } - requestsByToken[token] = .trackedInput(completion) - _ = drainOutbound() - } - return true - } - /// Selectors target Mori's grouped shadow client only; they never resize /// panes, toggle zoom, or mutate shared layout. func requestSelectWindow( @@ -1248,7 +1152,6 @@ final class TmuxSessionController: @unchecked Sendable { guard !shuttingDown else { return } failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) failOutstandingAgentMetadataQueries() - failOutstandingTrackedInput() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil switch result { @@ -1338,23 +1241,6 @@ final class TmuxSessionController: @unchecked Sendable { completions.forEach { $0(.init(status: .failed, body: "")) } } - private func outstandingTrackedInputCompletions() -> [@Sendable (Bool) -> Void] { - requestsByToken.values.compactMap { - guard case .trackedInput(let completion) = $0 else { return nil } - return completion - } - } - - private func failOutstandingTrackedInput() { - preconditionOnWriterQueue() - let completions = outstandingTrackedInputCompletions() - requestsByToken = requestsByToken.filter { - guard case .trackedInput = $0.value else { return true } - return false - } - completions.forEach { $0(false) } - } - private func preconditionOnWriterQueue() { dispatchPrecondition(condition: .onQueue(queue)) } diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift index 0c5d0ba6..a840727c 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionLink.swift @@ -7,7 +7,7 @@ import GhosttyKit /// single consumer task drains an ordered stream fed from the writer /// queue), and transport loss closes this attachment promptly. /// -/// Viewport ownership stays in the screen model. The control client is +/// Viewport ownership stays in `TmuxTerminalSession`. The control client is /// deliberately unsized; all local viewport metrics stay renderer-only. actor TmuxSessionLink { let controller: TmuxSessionController diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift index e8247692..0ded764a 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift @@ -4,9 +4,8 @@ import Foundation import GhosttyKit -/// Presents the new tmux session stack (`TmuxTerminalSession`) through the -/// `GhosttyTerminalScreenModeling` boundary so `GhosttySurfaceScreen` — the -/// full terminal UX — renders it unchanged. +/// 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 @@ -22,16 +21,11 @@ final class TmuxTerminalScreenAdapter: ObservableObject { /// `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 identities = TmuxTerminalIdentityRegistry() private var activeManagedSurface: GhosttyManagedSurface? - private var activeManagedPaneID: TmuxPaneID? private var initialViewportHandler: ((CGSize, CGFloat) -> Void)? - private var viewportStabilityHandler: ((Bool) -> Void)? - private var cachedTopologySnapshot = GhosttyRuntimeSurfaceTopologySnapshot.empty private var commandFailureMessage: String? - private(set) var commandFailureEvent: GhosttyTmuxCommandFailureEvent? private var commandFailureToken: UInt64 = 0 private var subscriptions: [AnyCancellable] = [] @@ -40,13 +34,11 @@ final class TmuxTerminalScreenAdapter: ObservableObject { /// session is created. func activate( session: TmuxTerminalSession, - initialViewportHandler: @escaping (CGSize, CGFloat) -> Void, - viewportStabilityHandler: @escaping (Bool) -> Void + initialViewportHandler: @escaping (CGSize, CGFloat) -> Void ) { self.session = session self.controller = session.controller self.initialViewportHandler = initialViewportHandler - self.viewportStabilityHandler = viewportStabilityHandler session.$state .sink { [weak self] _ in self?.objectWillChange.send() } @@ -57,7 +49,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { .sink { [weak self] topology in guard let self else { return } self.latestTopology = topology - self.rebuildTopologySnapshot() self.objectWillChange.send() } .store(in: &subscriptions) @@ -81,59 +72,10 @@ final class TmuxTerminalScreenAdapter: ObservableObject { func invalidate() { subscriptions.removeAll() activeManagedSurface = nil - activeManagedPaneID = nil session = nil controller = nil initialViewportHandler = nil - viewportStabilityHandler = nil latestTopology = nil - cachedTopologySnapshot = Self.emptyTopologySnapshot - } - - func tmuxPaneID(for surfaceID: UUID) -> TmuxPaneID? { - let paneID = activeManagedSurface?.id == surfaceID - ? activeManagedPaneID - : identities.paneID(for: surfaceID) - guard let paneID, - latestTopology?.panes.contains(where: { $0.id == paneID }) == true - else { return nil } - return paneID - } - - // MARK: Topology synthesis - - private static var emptyTopologySnapshot: GhosttyRuntimeSurfaceTopologySnapshot { - GhosttyRuntimeSurfaceTopologySnapshot.empty - } - - private var topologySnapshot: GhosttyRuntimeSurfaceTopologySnapshot { - cachedTopologySnapshot - } - - private func rebuildTopologySnapshot() { - guard let topology = latestTopology else { - cachedTopologySnapshot = Self.emptyTopologySnapshot - return - } - - let topLevels = topology.windows.map { window in - let paneIDs = topology.panes - .filter { $0.windowID == window.id } - .sorted { lhs, rhs in - (lhs.y, lhs.x, lhs.id) < (rhs.y, rhs.x, rhs.id) - } - .map { identities.surfaceID(for: $0.id) } - return GhosttyTopLevelSurface( - id: identities.surfaceID(for: window.id), - leafIDs: paneIDs, - focusedLeafID: window.activePaneID.map { identities.surfaceID(for: $0) } - ) - } - - cachedTopologySnapshot = GhosttyRuntimeSurfaceTopologySnapshot( - topLevels: topLevels, - selectedTopLevelID: topology.activeWindowID.map { identities.surfaceID(for: $0) } - ) } private var runtimePhase: GhosttyTerminalRuntimePhase { @@ -167,41 +109,7 @@ final class TmuxTerminalScreenAdapter: ObservableObject { // MARK: Managed surface lifecycle private func rebuildActiveManagedSurface(for paneSurface: TmuxPaneSurface?) { - if activeManagedSurface != nil { - activeManagedSurface = nil - activeManagedPaneID = nil - } - - guard let paneSurface else { return } - - let paneID = paneSurface.paneID - let wasAlreadyWrapped = paneSurface.managedSurface != nil - let managed = paneSurface.screenSurface { [weak paneSurface] managed, size, _ in - guard size.width > 1, size.height > 1 else { return } - GhosttyRuntimeTrace.flowEventOnce( - GhosttyRuntimeTrace.paneSwitchFlow, - event: "presentation.layout.ready", - fields: [ - "height": "\(size.height)", - "pane": "\(paneID)", - "surface": paneSurface.map { String(describing: $0.rawSurface) } ?? "released", - "width": "\(size.width)", - ] - ) - } - activeManagedSurface = managed - activeManagedPaneID = paneID - if !wasAlreadyWrapped { - GhosttyRuntimeTrace.flowEventIfActive( - GhosttyRuntimeTrace.paneSwitchFlow, - event: "presentation.managedSurface.ready", - fields: [ - "pane": "\(paneID)", - "surface": String(describing: paneSurface.rawSurface), - "surface_uuid": managed.id.uuidString, - ] - ) - } + activeManagedSurface = paneSurface?.screenSurface { _, _, _ in } } private func managedSurface(for id: UUID) -> GhosttyManagedSurface? { @@ -222,10 +130,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { commandFailureToken &+= 1 let message = "tmux: \(Self.failureLabel(for: request)) failed" commandFailureMessage = message - commandFailureEvent = GhosttyTmuxCommandFailureEvent( - token: commandFailureToken, - message: message - ) objectWillChange.send() let token = commandFailureToken @@ -247,9 +151,7 @@ final class TmuxTerminalScreenAdapter: ObservableObject { } } -// MARK: - GhosttyTerminalScreenModeling - -extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { +extension TmuxTerminalScreenAdapter { func prepareInitialViewport(size: CGSize, scale: CGFloat) { initialViewportHandler?(size, scale) } @@ -262,15 +164,14 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { debugStatus: stateTraceLabel, registryDebugSummary: "tmux session stack", presentedSurfaceID: activeManagedSurface?.id, - snapshot: topologySnapshot + topLevelCount: latestTopology?.windows.count ?? 0 ) } var terminalInteractionProjection: GhosttyTerminalInteractionProjection { GhosttyTerminalPresentationProjector.terminalInteractionProjection( phase: runtimePhase, - presentedSurfaceID: activeManagedSurface?.id, - snapshot: topologySnapshot + presentedSurfaceID: activeManagedSurface?.id ) } @@ -291,10 +192,6 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { } } - func setViewportStabilityHint(stable: Bool) { - viewportStabilityHandler?(stable) - } - // MARK: Input routing private func preflightFocusedInput() -> FocusedTerminalInputSubmissionResult? { @@ -313,38 +210,6 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { return focusedManagedSurface?.sendPaste(text) ?? .noFocusedSurface } - func sendPaste(_ text: String, to surfaceID: UUID) -> FocusedTerminalInputSubmissionResult { - guard isTransportWritable else { return .transportUnavailable } - guard let managed = managedSurface(for: surfaceID) else { return .noFocusedSurface } - return managed.sendPaste(text) - } - - func sendPasteAwaitingCommandCompletion(_ text: String, to surfaceID: UUID) async -> Bool { - guard isTransportWritable, - let managed = managedSurface(for: surfaceID) - else { return false } - return await managed.sendPasteAwaitingCommandCompletion(text) - } - - func sendKeyEvent( - _ event: GhosttySurfaceKeyEvent, - to surfaceID: UUID - ) -> FocusedTerminalInputSubmissionResult { - guard isTransportWritable else { return .transportUnavailable } - guard let managed = managedSurface(for: surfaceID) else { return .noFocusedSurface } - return managed.sendKeyEvent(event) - } - - func sendKeyEventAwaitingCommandCompletion( - _ event: GhosttySurfaceKeyEvent, - to surfaceID: UUID - ) async -> Bool { - guard isTransportWritable, - let managed = managedSurface(for: surfaceID) - else { return false } - return await managed.sendKeyEventAwaitingCommandCompletion(event) - } - func sendKeyEventToFocusedSurface(_ event: GhosttySurfaceKeyEvent) -> FocusedTerminalInputSubmissionResult { if let preflight = preflightFocusedInput() { return preflight } return focusedManagedSurface?.sendKeyEvent(event) ?? .noFocusedSurface @@ -389,70 +254,24 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { // MARK: tmux topology actions - func performSharedMutation(_ mutation: TmuxSessionController.SharedMutation) -> GhosttyTmuxModelActionOutcome { - guard let controller else { return .missingTarget(.host) } - controller.requestSharedMutation(mutation) - return .queued - } - - func focusTmuxPane(_ id: UUID) -> GhosttyTmuxModelActionOutcome { - guard let paneID = identities.paneID(for: id), let controller else { - GhosttyRuntimeTrace.flowEventIfActive( - GhosttyRuntimeTrace.paneSwitchFlow, - event: "adapter.resolve.failed", - fields: ["target_uuid": id.uuidString] - ) - return .missingTarget(.pane(id)) - } - GhosttyRuntimeTrace.flowEventIfActive( - GhosttyRuntimeTrace.paneSwitchFlow, - event: "adapter.resolve.ready", - fields: [ - "pane": "\(paneID)", - "target_uuid": id.uuidString, - ] - ) - session?.prepareForPaneSelection(paneID: paneID) - controller.requestSelectPane(paneID: paneID) - return .queued - } - - func focusTmuxTopLevel(_ id: UUID) -> GhosttyTmuxModelActionOutcome { - guard let windowID = identities.windowID(for: id), let controller else { - return .missingTarget(.window(id)) - } - if let topology = latestTopology, - let targetWindow = topology.windows.first(where: { $0.id == windowID }) { - requestWindowSelection(targetWindow, in: topology, controller: controller) - } else { - controller.requestSelectWindow(windowID: windowID) - } - return .queued - } - func focusAdjacentTmuxTopLevel( _ direction: GhosttyRuntimeSelectionDirection - ) -> GhosttyTmuxModelActionOutcome { + ) { guard let controller, let topology = latestTopology, !topology.windows.isEmpty, let activeWindowID = topology.activeWindowID, let activeIndex = topology.windows.firstIndex(where: { $0.id == activeWindowID }) - else { - return .missingTarget(.adjacentWindow) - } + else { return } let targetIndex = direction.advancedIndex( from: activeIndex, count: topology.windows.count ) - guard targetIndex != activeIndex else { - return .missingTarget(.adjacentWindow) - } + guard targetIndex != activeIndex else { return } let targetWindow = topology.windows[targetIndex] requestWindowSelection(targetWindow, in: topology, controller: controller) - return .queued } private func requestWindowSelection( diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/DeterministicTmuxControlTransport.swift b/MoriRemote/MoriRemoteTerminalTests/DeterministicTmuxControlTransport.swift similarity index 97% rename from MoriRemote/MoriRemoteTerminal/Tmux/DeterministicTmuxControlTransport.swift rename to MoriRemote/MoriRemoteTerminalTests/DeterministicTmuxControlTransport.swift index 454caee8..079a8cf4 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/DeterministicTmuxControlTransport.swift +++ b/MoriRemote/MoriRemoteTerminalTests/DeterministicTmuxControlTransport.swift @@ -1,4 +1,5 @@ import Foundation +@testable import MoriRemoteTerminal actor DeterministicTmuxControlTransport: TmuxControlTransport { nonisolated let receivedBytes: AsyncThrowingStream diff --git a/MoriRemote/MoriRemoteTerminalTests/GhosttyTopLevelSurfaceTests.swift b/MoriRemote/MoriRemoteTerminalTests/GhosttyTopLevelSurfaceTests.swift deleted file mode 100644 index 1ea20a2e..00000000 --- a/MoriRemote/MoriRemoteTerminalTests/GhosttyTopLevelSurfaceTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import XCTest -@testable import MoriRemoteTerminal - -final class GhosttyTopLevelSurfaceTests: XCTestCase { - func testPreservesOrderedLeafIDsAndValidFocus() { - let first = UUID() - let second = UUID() - let third = UUID() - - let topLevel = GhosttyTopLevelSurface( - leafIDs: [first, second, third], - focusedLeafID: third - ) - - XCTAssertEqual(topLevel.leafIDs, [first, second, third]) - XCTAssertEqual(topLevel.focusedLeafID, third) - XCTAssertEqual(topLevel.resolvedFocusedLeafID, third) - } - - func testResolvedFocusFallsBackToFirstLeaf() { - let first = UUID() - let second = UUID() - let topLevel = GhosttyTopLevelSurface(leafIDs: [first, second]) - - XCTAssertNil(topLevel.focusedLeafID) - XCTAssertEqual(topLevel.resolvedFocusedLeafID, first) - } - - func testInitializerNormalizesMissingFocus() { - let first = UUID() - let missing = UUID() - - let topLevel = GhosttyTopLevelSurface( - leafIDs: [first], - focusedLeafID: missing - ) - - XCTAssertNil(topLevel.focusedLeafID) - XCTAssertEqual(topLevel.resolvedFocusedLeafID, first) - } - - func testEmptyTopLevelHasNoResolvedFocus() { - let topLevel = GhosttyTopLevelSurface(leafIDs: []) - - XCTAssertNil(topLevel.focusedLeafID) - XCTAssertNil(topLevel.resolvedFocusedLeafID) - } -} diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift index 4e79bb9c..240382c8 100644 --- a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift @@ -5,36 +5,13 @@ import XCTest @MainActor final class TmuxTerminalScreenAdapterTests: XCTestCase { - func testIdentityRegistryKeepsPaneRoundTripStable() { - var registry = TmuxTerminalIdentityRegistry() - let paneID = TmuxPaneID(41) - - let surfaceID = registry.surfaceID(for: paneID) - - XCTAssertEqual(registry.surfaceID(for: paneID), surfaceID) - XCTAssertEqual(registry.paneID(for: surfaceID), paneID) - XCTAssertNil(registry.paneID(for: UUID())) - } - - func testIdentityRegistryKeepsWindowRoundTripStable() { - var registry = TmuxTerminalIdentityRegistry() - let windowID = TmuxWindowID(17) - - let surfaceID = registry.surfaceID(for: windowID) - - XCTAssertEqual(registry.surfaceID(for: windowID), surfaceID) - XCTAssertEqual(registry.windowID(for: surfaceID), windowID) - XCTAssertNil(registry.windowID(for: UUID())) - } - func testTopologyProjectionReflectsEmittedTopologyImmediately() async throws { let runtime = try GhosttyKitRuntime() let session = makeSession(runtime: runtime) let adapter = TmuxTerminalScreenAdapter() adapter.activate( session: session, - initialViewportHandler: { _, _ in }, - viewportStabilityHandler: { _ in } + initialViewportHandler: { _, _ in } ) session.handleTopology(.init( @@ -47,10 +24,8 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { activeWindowID: 1 )) - let first = adapter.terminalInteractionProjection - XCTAssertEqual(first.windowCount, 2) - XCTAssertEqual(first.selectedWindowIndex, 0) - XCTAssertEqual(first.paneCount, 1) + let first = adapter.terminalScreenPresentationProjection + XCTAssertEqual(first.viewport.windowCount, 2) session.handleTopology(.init( sessionName: "fresh-test", @@ -59,9 +34,8 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { activeWindowID: 1 )) - let second = adapter.terminalInteractionProjection - XCTAssertEqual(second.windowCount, 1) - XCTAssertEqual(second.selectedWindowIndex, 0) + let second = adapter.terminalScreenPresentationProjection + XCTAssertEqual(second.viewport.windowCount, 1) await session.shutdown() } diff --git a/MoriRemote/UPSTREAM.md b/MoriRemote/UPSTREAM.md index e8569dee..ae456d9c 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -20,8 +20,7 @@ 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/model, runtime trace, and - deterministic test transport. + 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, @@ -29,7 +28,8 @@ core: (the minimal upstream-derived composition root). - `Domain/TerminalSettings.swift`: terminal appearance only. -The paired `MoriRemoteTerminalTests` target ports the matching upstream tests +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. @@ -55,10 +55,9 @@ transport remains solely a terminal-core test fixture. | Area | Change | Why | | --- | --- | --- | -| `TmuxScreenModel.swift` | Reduced to injected `ghostty_app_t` + `TmuxControlTransport` composition. | Upstream constructs account targets, runtime status reporting, and preview services; those are Phase 2+ concerns. | | `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 owns `GhosttyKitRuntime` + screen model, 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. | +| `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. | @@ -66,6 +65,8 @@ transport remains solely a terminal-core test fixture. | `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 tracked composer input | Removed with the excluded composer/submission path. Ordinary terminal input retains local routing through the sole controller queue but does not allocate command-completion tokens that no caller observes. | The copied await-completion chain crossed surface, managed-surface, adapter, and controller modules solely for an excluded feature. | +| `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 From 86cd3a867f66cbf417d343ac53cd24a0b1341640 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 23:14:30 +0800 Subject: [PATCH 18/22] moriremote: observe workspace runtime state directly Make the retained runtime the observable authority for status, topology, and metadata. Remove the unread revision counter, manual change relay, dead reconnecting state, and identity passthrough; cover direct observation through runtime shutdown. --- CHANGELOG.md | 1 + CHANGELOG.zh-Hans.md | 1 + .../MoriRemote/App/RemoteRootModel.swift | 27 ++++-------- .../MoriRemote/Views/RemoteRootView.swift | 2 +- .../MoriRemoteTests/Phase4ShellTests.swift | 41 +++++++++++++++++++ .../Phase6TerminalOwnershipTests.swift | 8 ---- 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09dc77a6..0fca9069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 🐛 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. diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 5b1ea876..25ea7ee0 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -15,6 +15,7 @@ ### 🐛 问题修复 +- **iOS(MoriRemote)**:让工作区运行状态、拓扑和 agent 元数据可被直接观察,避免 Navigator 与资源库徽章只写入无人读取的 revision 而不更新。 - **iOS(MoriRemote)**:在 Ghostty 发布每个完整渲染帧后刷新本地回滚区几何,避免预渲染终端变更回调读到旧状态,导致滚动在真实底部之前被硬性截断。 - **iOS(MoriRemote)**:修复终端已可用后,延迟到达的同步回调仍会让标题一直显示“正在连接”的问题。 - **iOS(MoriRemote)**:修复远端 tmux 控制客户端已经连接,但界面仍一直停在“正在等待活动的 tmux pane”的问题。 diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index f4ff2294..0152e1e2 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -108,14 +108,12 @@ enum PendingSSHTrustAction: Equatable, Sendable { 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") } } @@ -142,10 +140,6 @@ 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. -enum WorkspaceTerminalPresentation: Sendable { - static func identity(for sessionInstanceID: UUID) -> UUID { sessionInstanceID } -} - /// 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 { @@ -175,21 +169,24 @@ struct WorkspaceConnectionAttemptLedger: Sendable { mutating func cancel(workspaceID: UUID) { tokens[workspaceID] = nil } } -@MainActor +@MainActor @Observable final class ActiveWorkspaceRuntime { let workspace: SavedWorkspace let instanceID: UUID let session: MoriRemoteTerminalSession private let metadataProjector: AgentMetadataProjector + private var metadataRevision: UInt64 = 0 private(set) var topology: MoriRemoteTerminalTopology? - var agentMetadata: [UInt64: AgentMetadata] { metadataProjector.metadata } + 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, transport: MoriRemoteTerminalTransport, instanceID: UUID = UUID()) throws { self.workspace = workspace @@ -203,13 +200,12 @@ final class ActiveWorkspaceRuntime { let result = await session.queryAgentMetadata() return .init(succeeded: result.status == .success, body: result.body) } - metadataProjector.onChange = { [weak self] in self?.onChange?() } + metadataProjector.onChange = { [weak self] in self?.metadataRevision &+= 1 } session.onTopologyChange = { [weak self] topology in guard let self else { return } self.topology = topology self.status = .ready self.metadataProjector.topologyDidChange(paneIDs: topology.panes.map(\.id)) - self.onChange?() } session.onConnectionStateChange = { [weak self] state in self?.receive(state) } session.setPresentationActive(false) @@ -225,7 +221,6 @@ final class ActiveWorkspaceRuntime { func confirmTransportAfterForeground() async { guard await session.isControlChannelActive() else { status = .disconnected(String(localized: "Connection lost.")) - onChange?() onTransportLoss?(instanceID) return } @@ -236,7 +231,7 @@ final class ActiveWorkspaceRuntime { agentMetadata.values.max { lhs, rhs in lhs.state.priority < rhs.state.priority } ?? .unknown } func selectWindow(_ id: UInt64) { session.selectWindow(id) } - func selectPane(_ id: UInt64) { session.selectPane(id); onChange?() } + func selectPane(_ id: UInt64) { session.selectPane(id) } func performSharedMutation(_ mutation: MoriRemoteTerminalSharedMutation) { session.performSharedMutation(mutation) } private func receive(_ state: MoriRemoteTerminalConnectionState) { @@ -255,7 +250,6 @@ final class ActiveWorkspaceRuntime { // post-start transport transition earns the bounded reconnect. if !wasDisconnected, session.lastError == nil { onTransportLoss?(instanceID) } } - onChange?() } } @@ -282,7 +276,6 @@ final class RemoteRootModel { var pendingTrust: SSHHostTrustChallenge? var errorMessage: String? var migrationReport: LegacyMigrationReport? - var runtimeRevision = 0 private var pendingTrustAction: PendingSSHTrustAction? private(set) var isLoaded = false var libraryLoadError: String? { bootstrapFailure } @@ -470,10 +463,6 @@ final class RemoteRootModel { 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, diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 73a7fc78..155dc0d5 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -346,7 +346,7 @@ private struct RemoteTerminalDetailView: View { }, onSharedMutationRequest: { pendingSharedMutation = RemoteSharedMutation($0) } ) - .id(WorkspaceTerminalPresentation.identity(for: runtime.session.instanceID)) + .id(runtime.instanceID) .background(Color.black) } .background(Color.black.ignoresSafeArea()) diff --git a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift index 443af5d9..0b38b3d8 100644 --- a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift @@ -1,4 +1,5 @@ import Foundation +import Observation import Security import Testing import MoriRemoteTerminal @@ -16,6 +17,38 @@ import MoriRemoteTerminal #expect(!policy.mayReconnect(status: .connecting, attempts: 0)) } + @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("workspace draft owns a distinct record and rejects unsafe sessions") func workspaceDraftValidation() throws { let serverID = UUID() @@ -212,6 +245,14 @@ import MoriRemoteTerminal } +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 { private var values: [UUID: String] = [:] func password(for identityID: UUID) throws -> String? { values[identityID] } diff --git a/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift b/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift index 264f25b3..ae983d13 100644 --- a/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase6TerminalOwnershipTests.swift @@ -3,14 +3,6 @@ import Testing @testable import MoriRemote @Suite("Terminal facade ownership") struct Phase6TerminalOwnershipTests { - @Test("adaptive workspace chrome retains its terminal session identity") - func workspaceSessionIdentity() { - let session = UUID() - #expect(WorkspaceTerminalPresentation.identity(for: session) == session) - #expect(WorkspaceTerminalPresentation.identity(for: session) == session) - #expect(WorkspaceTerminalPresentation.identity(for: UUID()) != session) - } - @Test("app target has no direct native terminal owner or Ghostty link") func oneOwnerInvariant() throws { let remoteRoot = URL(fileURLWithPath: #filePath) From fe4632cbab3d5307939135c24e9e52ef5b71f345 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 23:22:38 +0800 Subject: [PATCH 19/22] moriremote: centralize authenticated SSH roots Resolve library material, credentials, canonical endpoints, pool identity, Citadel connection, and host trust in one concrete app-owned module. Discovery, terminal control, and image upload now consume the same lazy root source while retaining operation-owned TOFU retries. --- .../MoriRemote.xcodeproj/project.pbxproj | 4 + .../App/MoriRemoteDependencies.swift | 12 +- .../MoriRemote/App/RemoteRootModel.swift | 42 ++----- .../MoriRemote/SSH/SSHImageUpload.swift | 21 +--- MoriRemote/MoriRemote/SSH/SSHRootAccess.swift | 110 ++++++++++++++++++ .../Tmux/SSHTmuxControlTransport.swift | 14 +-- .../Tmux/SSHTmuxSessionDiscovery.swift | 6 +- .../Phase2TransportTests.swift | 40 +++++++ MoriRemote/UPSTREAM.md | 5 +- 9 files changed, 182 insertions(+), 72 deletions(-) create mode 100644 MoriRemote/MoriRemote/SSH/SSHRootAccess.swift diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index 912a3eee..bdae92ec 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 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 */; }; + 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 */; }; @@ -169,6 +170,7 @@ 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMigrationTests.swift; sourceTree = ""; }; 46F3182F8D251430A10B298E /* MoriRemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteApp.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 = ""; }; @@ -290,6 +292,7 @@ 46D3CED1154E19B734CC9AA8 /* SSHAuth.swift */, 01AE8D2B54D6CB377400488B /* SSHImageUpload.swift */, 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */, + 49212AC593537B7DD8346F22 /* SSHRootAccess.swift */, DC5CE2DEDF2FDD48461599ED /* SSHRootPool.swift */, ); path = SSH; @@ -760,6 +763,7 @@ 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 */, diff --git a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift index eca6f3df..839aa287 100644 --- a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift +++ b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift @@ -6,13 +6,17 @@ import Foundation @MainActor final class MoriRemoteDependencies { let library: RemoteLibrary - let trustedHosts: TrustedHostStore - let roots = SSHRootPool() + 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 { diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index 0152e1e2..a7a602b6 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -339,24 +339,8 @@ final class RemoteRootModel { Task { [weak self] in guard let self else { return } do { - let material = try await self.dependencies.library.discoveryMaterial(for: serverID) - let auth = try await self.dependencies.library.resolveAuth(server: material.0, identity: material.1, settings: material.2) - let endpoint = try CanonicalEndpoint(host: material.0.host, port: material.0.port) - let key = SSHRootPool.Key( - serverID: material.0.id, - endpoint: endpoint, - username: material.0.username, - authenticationFingerprint: auth.rootPoolFingerprint - ) - let names = try await SSHTmuxSessionDiscovery( - connector: CitadelSSHRootConnector( - server: material.0, - auth: auth, - trust: SSHHostTrustResolver(store: self.dependencies.trustedHosts) - ), - pool: self.dependencies.roots, - poolKey: key - ).load() + 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) @@ -440,23 +424,17 @@ final class RemoteRootModel { guard let self else { return } var runtime: ActiveWorkspaceRuntime? do { - let material = try await self.dependencies.library.connectionMaterial(for: 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) + let access = try await self.dependencies.sshRoots.workspace(workspaceID) 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 = try ActiveWorkspaceRuntime( - workspace: material.0, - settings: material.3, + workspace: access.workspace, + settings: access.settings, transport: transport.asTerminalTransport(), instanceID: instanceID ) @@ -539,11 +517,7 @@ final class RemoteRootModel { } func imageUploader(for workspaceID: UUID) -> MoriRemoteTerminalImageUploader { - SSHImageUploadService( - library: dependencies.library, - roots: dependencies.roots, - trustedHosts: dependencies.trustedHosts - ).uploader(for: workspaceID) + 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 diff --git a/MoriRemote/MoriRemote/SSH/SSHImageUpload.swift b/MoriRemote/MoriRemote/SSH/SSHImageUpload.swift index 5ff0f24c..9db22c8f 100644 --- a/MoriRemote/MoriRemote/SSH/SSHImageUpload.swift +++ b/MoriRemote/MoriRemote/SSH/SSHImageUpload.swift @@ -187,9 +187,7 @@ enum SSHImageUploadTransfer { } struct SSHImageUploadService: Sendable { - let library: RemoteLibrary - let roots: SSHRootPool - let trustedHosts: TrustedHostStore + let sshRoots: SSHRootAccess func uploader(for workspaceID: UUID) -> MoriRemoteTerminalImageUploader { MoriRemoteTerminalImageUploader { localURL, filename, progress in @@ -213,21 +211,8 @@ struct SSHImageUploadService: Sendable { throw SSHFileUploadError.localFileUnavailable } let totalBytes = (try FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? NSNumber)?.int64Value ?? 0 - let material = try await library.connectionMaterial(for: workspaceID) - let auth = try await library.resolveAuth(server: material.1, identity: material.2, settings: material.3) - 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 connector = CitadelSSHRootConnector( - server: material.1, - auth: auth, - trust: SSHHostTrustResolver(store: trustedHosts) - ) - let lease = try await roots.lease(for: key, connector: connector) + 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() 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/Tmux/SSHTmuxControlTransport.swift b/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift index 889d21bf..637eeb0f 100644 --- a/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift +++ b/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift @@ -9,9 +9,7 @@ actor SSHTmuxControlTransport { 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 @@ -27,16 +25,12 @@ actor SSHTmuxControlTransport { 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 @@ -63,7 +57,7 @@ actor SSHTmuxControlTransport { 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. diff --git a/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift b/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift index 691cb663..1546dbd2 100644 --- a/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift +++ b/MoriRemote/MoriRemote/Tmux/SSHTmuxSessionDiscovery.swift @@ -3,13 +3,11 @@ 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 connector: any SSHRootConnecting - let pool: SSHRootPool - let poolKey: SSHRootPool.Key + let rootSource: AuthenticatedSSHRootSource var tmuxExecutable = "tmux" func load() async throws -> [String] { - let lease = try await pool.lease(for: poolKey, connector: connector) + let lease = try await rootSource.lease() do { let version = try await run( command: TmuxCommandBuilder.preflight(executable: tmuxExecutable), diff --git a/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift b/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift index 087cc7d6..dabf0e72 100644 --- a/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift @@ -261,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/UPSTREAM.md b/MoriRemote/UPSTREAM.md index ae456d9c..a872fcad 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -40,8 +40,9 @@ 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` facade delegates the authenticated SFTP -upload to Mori's app-owned SSH root pool. +`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 From 9c657e807b94282a93eb075e97f3b84f57216cb7 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 23:26:24 +0800 Subject: [PATCH 20/22] moriremote: remove manual workspace persistence paths Server profiles now model only server and SSH identity input. Remove the unused workspace draft, profile-workspace optional branch, direct workspace save/delete APIs, and tautological tests while preserving migrated/discovered workspace IDs and recency. --- .../App/MoriRemoteDependencies.swift | 43 +------------ .../MoriRemote/App/RemoteRootModel.swift | 61 ++----------------- .../MoriRemote/Views/RemoteRootView.swift | 6 +- .../MoriRemoteTests/Phase4ShellTests.swift | 58 +++++++----------- 4 files changed, 33 insertions(+), 135 deletions(-) diff --git a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift index 839aa287..dcb60c08 100644 --- a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift +++ b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift @@ -71,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. @@ -108,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 { @@ -148,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 } diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift index a7a602b6..b69e6561 100644 --- a/MoriRemote/MoriRemote/App/RemoteRootModel.swift +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -3,64 +3,35 @@ 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 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 { @@ -68,7 +39,7 @@ 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) } } @@ -321,12 +292,12 @@ 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) + 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 } @@ -361,26 +332,6 @@ final class RemoteRootModel { } } - func save(_ draft: WorkspaceDraft) { - Task { - do { - let snapshot = try await dependencies.library.save(workspace: draft.record()) - apply(snapshot) - } catch { errorMessage = error.localizedDescription } - } - } - - func delete(workspace: SavedWorkspace) { - connectionAttempts.cancel(workspaceID: workspace.id) - Task { - do { - await disconnect(workspaceID: workspace.id) - let snapshot = try await dependencies.library.delete(workspaceID: workspace.id) - apply(snapshot) - } catch { errorMessage = error.localizedDescription } - } - } - func delete(_ server: SavedServer) { let serverWorkspaces = workspaces.filter { $0.serverID == server.id } serverWorkspaces.forEach { connectionAttempts.cancel(workspaceID: $0.id) } diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift index 155dc0d5..30784326 100644 --- a/MoriRemote/MoriRemote/Views/RemoteRootView.swift +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -677,11 +677,11 @@ private enum RemoteSharedMutation: Identifiable, Equatable { 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 diff --git a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift index 0b38b3d8..4aaa7ed3 100644 --- a/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift +++ b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift @@ -49,30 +49,16 @@ import MoriRemoteTerminal #expect(observation.didChange) } - @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("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" let records = try draft.records() - #expect(records.1 == nil) - #expect(records.0.identityID == records.2.id) - #expect(records.2.serverID == records.0.id) + #expect(records.0.identityID == records.1.id) + #expect(records.1.serverID == records.0.id) } @Test("tmux discovery lists source sessions and hides MoriRemote shadows") @@ -93,7 +79,8 @@ import MoriRemoteTerminal 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, workspace: main, identity: identity, credential: nil) + _ = 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) @@ -140,19 +127,19 @@ import MoriRemoteTerminal #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") @@ -166,17 +153,13 @@ import MoriRemoteTerminal 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") @@ -233,7 +216,8 @@ import MoriRemoteTerminal 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)) ?? "" } From 92043ac29a35899dba002b5704b36f7100ec3029 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 23:28:28 +0800 Subject: [PATCH 21/22] moriremote: keep terminal closure policy at transport boundary Give generic SSH leases their own reusable/invalidated disposition and map the terminal facade vocabulary only inside SSHTmuxControlTransport. SSH pooling no longer imports the terminal module. --- MoriRemote/MoriRemote/SSH/SSHRootPool.swift | 10 +++++++--- .../MoriRemote/Tmux/SSHTmuxControlTransport.swift | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/MoriRemote/MoriRemote/SSH/SSHRootPool.swift b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift index 02bf557b..f2f88c4c 100644 --- a/MoriRemote/MoriRemote/SSH/SSHRootPool.swift +++ b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift @@ -1,5 +1,4 @@ import Foundation -import MoriRemoteTerminal protocol SSHChildChannel: AnyObject, Sendable { var receivedBytes: AsyncThrowingStream { get } @@ -51,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 { @@ -121,7 +125,7 @@ actor SSHRootPool { } } - fileprivate func release(_ lease: SSHRootLease, disposition: MoriRemoteTerminalCloseDisposition) async { + fileprivate func release(_ lease: SSHRootLease, disposition: SSHRootLeaseDisposition) async { guard let key = lease.key, let token = lease.token else { await lease.root.close() return @@ -226,7 +230,7 @@ struct SSHRootLease: Sendable { self.token = token } - func release(_ disposition: MoriRemoteTerminalCloseDisposition) 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/SSHTmuxControlTransport.swift b/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift index 637eeb0f..873f95af 100644 --- a/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift +++ b/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift @@ -232,7 +232,7 @@ actor SSHTmuxControlTransport { 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) From c5a5d0a33f683e966b6beb09010a1f82d6e28961 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Sat, 1 Aug 2026 23:50:55 +0800 Subject: [PATCH 22/22] moriremote: remove unused terminal projection domains Delete the composer-only pane current-directory query, unrendered readiness/status projections, dead disconnect state, and redundant facade observation/presentation channels. Keep only the active viewport and input state consumed by MoriRemote. --- .../MoriRemote.xcodeproj/project.pbxproj | 8 - .../App/MoriRemoteTerminalFacade.swift | 12 +- .../App/MoriRemoteTerminalProbe.swift | 5 +- .../App/TerminalRuntimeTypes.swift | 22 -- .../Ghostty/GhosttyTerminalCoreView.swift | 9 +- ...tyTerminalDisconnectReasonClassifier.swift | 13 - ...GhosttyTerminalPresentationProjector.swift | 276 +----------------- .../Tmux/TmuxSessionController.swift | 123 -------- .../Tmux/TmuxTerminalScreenAdapter.swift | 124 +------- .../Tmux/TmuxTerminalSession.swift | 22 +- .../TmuxTerminalScreenAdapterTests.swift | 8 +- MoriRemote/UPSTREAM.md | 3 +- 12 files changed, 25 insertions(+), 600 deletions(-) delete mode 100644 MoriRemote/MoriRemoteTerminal/App/TerminalRuntimeTypes.swift delete mode 100644 MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalDisconnectReasonClassifier.swift diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index bdae92ec..8011ab8e 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -63,7 +63,6 @@ 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 */; }; - 83049E3D188D4C2A9784F9FB /* GhosttyTerminalDisconnectReasonClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */; }; 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 */; }; @@ -87,7 +86,6 @@ 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 */; }; - B4BB4188870E6A3BD8A84509 /* TerminalRuntimeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.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 */; }; @@ -202,14 +200,12 @@ 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 = ""; }; - A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalDisconnectReasonClassifier.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 = ""; }; - B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRuntimeTypes.swift; sourceTree = ""; }; B88BDAAB702E98FDD084041C /* LegacyMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMigration.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 = ""; }; @@ -466,7 +462,6 @@ 44A50387A301B14D8E30A167 /* GhosttySurfaceScrollGesture.swift */, 9BD23FE8E78A324650AA77CC /* GhosttyTerminalCompositionState.swift */, EC055273524821D3351EF36A /* GhosttyTerminalCoreView.swift */, - A80B2B2D1F8EBE1D675AF2A7 /* GhosttyTerminalDisconnectReasonClassifier.swift */, A597D49CF7A1A57D33B9A7E2 /* GhosttyTerminalInputCoordinator.swift */, A7D76C58B99537D89078D58B /* GhosttyTerminalPresentationProjector.swift */, E137716C1A4146436A28685D /* GhosttyTerminalResponderFocusPolicy.swift */, @@ -516,7 +511,6 @@ F7595020B1AEF0AE384FF639 /* Haptic.swift */, FCE453EA5ED8C3C00E2EFF00 /* MoriRemoteTerminalFacade.swift */, D80AB026D4A388B7B0DCEBD4 /* MoriRemoteTerminalProbe.swift */, - B78101A70AE9524CA337B32C /* TerminalRuntimeTypes.swift */, ); path = App; sourceTree = ""; @@ -708,7 +702,6 @@ FBCC61A7E5D38B8184FF864E /* GhosttySurfaceScrollGesture.swift in Sources */, 2302A7B4A772047379C73067 /* GhosttyTerminalCompositionState.swift in Sources */, 6D55ADE98CE693CA6802197D /* GhosttyTerminalCoreView.swift in Sources */, - 83049E3D188D4C2A9784F9FB /* GhosttyTerminalDisconnectReasonClassifier.swift in Sources */, FA8F3FA0C8EE6BB056279187 /* GhosttyTerminalInputCoordinator.swift in Sources */, 2E5B1E954FE1011C8C057D50 /* GhosttyTerminalPresentationProjector.swift in Sources */, F04B667AE1CBE572A7553EEE /* GhosttyTerminalResponderFocusPolicy.swift in Sources */, @@ -721,7 +714,6 @@ 31097F77B38B348F9E7E0BB3 /* Haptic.swift in Sources */, C3F4EADB4D52F73F75D6E750 /* MoriRemoteTerminalFacade.swift in Sources */, 9213AD0FB635C3806A8C0010 /* MoriRemoteTerminalProbe.swift in Sources */, - B4BB4188870E6A3BD8A84509 /* TerminalRuntimeTypes.swift in Sources */, ACE22CEBB527F6CDA6C44470 /* TerminalSettings.swift in Sources */, AE2B7491776C8FC853899464 /* TmuxControlTransport.swift in Sources */, C34E3D3F89FF5F790D22D0C0 /* TmuxControlViewport.swift in Sources */, diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift index bbf3a21d..77a02696 100644 --- a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalFacade.swift @@ -112,12 +112,11 @@ public struct MoriRemoteTerminalImageUploader: Sendable { /// 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: ObservableObject { +public final class MoriRemoteTerminalSession { public let instanceID: UUID - @Published public private(set) var connectionState: MoriRemoteTerminalConnectionState = .connecting - @Published public private(set) var topology: MoriRemoteTerminalTopology? - @Published public private(set) var isPresentationReady = false - @Published public private(set) var lastError: String? + 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)? @@ -152,7 +151,6 @@ public final class MoriRemoteTerminalSession: ObservableObject { ) terminalSession.onStateChange = { [weak self] state in self?.receive(state) } terminalSession.onTopologyChange = { [weak self] snapshot in self?.receive(snapshot) } - terminalSession.onPresentationChange = { [weak self] ready in self?.isPresentationReady = ready } } public func start() async throws { @@ -230,7 +228,7 @@ public final class MoriRemoteTerminalSession: ObservableObject { } public struct MoriRemoteTerminalView: View { - @ObservedObject private var session: MoriRemoteTerminalSession + private let session: MoriRemoteTerminalSession private let isInputSuspended: Bool private let imageUploader: MoriRemoteTerminalImageUploader? private let onShowNavigator: () -> Void diff --git a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift index 5a44a997..0c53ccfa 100644 --- a/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift +++ b/MoriRemote/MoriRemoteTerminal/App/MoriRemoteTerminalProbe.swift @@ -27,9 +27,6 @@ public struct MoriRemoteTerminalProbe: View { .padding() .accessibilityIdentifier("ghostty-terminal-probe") .task { await model.start() } - .onChange(of: model.session?.isPresentationReady) { _, ready in - if ready == true { model.recordSuccess() } - } .onDisappear { Task { await model.stop() } } } } @@ -66,7 +63,7 @@ public struct MoriRemoteTerminalProbe: View { 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?.isPresentationReady != true else { return } + 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") diff --git a/MoriRemote/MoriRemoteTerminal/App/TerminalRuntimeTypes.swift b/MoriRemote/MoriRemoteTerminal/App/TerminalRuntimeTypes.swift deleted file mode 100644 index 5e77a297..00000000 --- a/MoriRemote/MoriRemoteTerminal/App/TerminalRuntimeTypes.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -/// Terminal-only status vocabulary. Transport/account policy intentionally stays -/// outside the transplant until Phase 2 supplies a Mori-owned composition root. -struct TerminalDisconnectReason: Equatable, Sendable { - enum Kind: Equatable, Sendable { case transportIO, remoteExit, runtime, unknown } - let kind: Kind - let message: String -} - -enum TerminalRuntimeState: Equatable, Sendable { - case connecting - case connected - case disconnected(TerminalDisconnectReason) -} - -enum GhosttyTerminalRuntimePhase: Equatable, Sendable { - case idle - case starting - case running - case failed(message: String, reason: TerminalDisconnectReason?) -} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift index 3f55cdd4..6695673d 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalCoreView.swift @@ -39,10 +39,9 @@ struct GhosttyTerminalCoreView: View { } var body: some View { - let projection = screen.terminalScreenPresentationProjection - let interaction = projection.interaction + let viewportPresentation = screen.terminalViewportPresentationProjection let isInputAvailable = GhosttyTerminalInputAvailabilityProjection( - isTerminalReady: interaction.isInputAvailable, + isTerminalReady: screen.isInputAvailable, isSuspended: isInputSuspended || isImageAttachmentPresented ).isInputAvailable ZStack(alignment: .bottom) { @@ -52,7 +51,7 @@ struct GhosttyTerminalCoreView: View { let effectiveSize = compositionState.viewportCoordinator.effectiveSize(liveSize: liveSize) GhosttySingleViewportView( surfaceLookup: screen.terminalManagedSurfaceLookup, - projection: projection.viewport, + projection: viewportPresentation, terminalTheme: .ghosttyDefault, trackpadDriver: trackpadDriver, onSurfaceTap: { _ in activateTerminalInput() }, @@ -278,7 +277,7 @@ struct GhosttyTerminalCoreView: View { private var isTerminalInputAvailable: Bool { GhosttyTerminalInputAvailabilityProjection( - isTerminalReady: screen.terminalInteractionProjection.isInputAvailable, + isTerminalReady: screen.isInputAvailable, isSuspended: isTerminalInputSuspended ).isInputAvailable } diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalDisconnectReasonClassifier.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalDisconnectReasonClassifier.swift deleted file mode 100644 index d6d1c36c..00000000 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalDisconnectReasonClassifier.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -/// Classification is intentionally transport-agnostic until Phase 2 supplies -/// Mori's SSH adapter. No SSH/NIO type is referenced by the core target. -enum GhosttyTerminalDisconnectReasonClassifier { - static func transportStartFailure(_ error: any Error) -> TerminalDisconnectReason { - .init(kind: .unknown, message: String(describing: error)) - } - - static func foregroundMissingHost() -> TerminalDisconnectReason { - .init(kind: .transportIO, message: "tmux transport unavailable after foreground") - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift index 3e7f9e55..81ec7271 100644 --- a/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift +++ b/MoriRemote/MoriRemoteTerminal/Ghostty/GhosttyTerminalPresentationProjector.swift @@ -1,197 +1,7 @@ import Foundation -struct TerminalReadinessSnapshot: Equatable, Sendable { - let phase: GhosttyTerminalRuntimePhase - let transportWritable: Bool - let topLevelCount: Int - let selectedActiveLeafID: UUID? - - init( - phase: GhosttyTerminalRuntimePhase, - transportWritable: Bool, - topLevelCount: Int, - selectedActiveLeafID: UUID? - ) { - precondition(topLevelCount >= 0, "topLevelCount must be non-negative") - self.phase = phase - self.transportWritable = transportWritable - self.topLevelCount = topLevelCount - self.selectedActiveLeafID = selectedActiveLeafID - } - - var hasFocusedSurface: Bool { - selectedActiveLeafID != nil - } -} - -enum TerminalReadinessProjector { - static func snapshot( - phase: GhosttyTerminalRuntimePhase, - transportWritable: Bool, - topLevelCount: Int, - selectedActiveLeafID: UUID? - ) -> TerminalReadinessSnapshot { - TerminalReadinessSnapshot( - phase: phase, - transportWritable: transportWritable, - topLevelCount: topLevelCount, - selectedActiveLeafID: selectedActiveLeafID - ) - } - - static func runtimeState(_ snapshot: TerminalReadinessSnapshot) -> TerminalRuntimeState { - runtimeState( - phase: snapshot.phase, - hasFocusedSurface: snapshot.hasFocusedSurface - ) - } - - static func runtimeState( - phase: GhosttyTerminalRuntimePhase, - hasFocusedSurface: Bool - ) -> TerminalRuntimeState { - if phase == .running, hasFocusedSurface { - return .connected - } - - switch phase { - case .idle, .starting, .running: - return .connecting - case .failed(let message, let reason): - return .disconnected( - reason ?? TerminalDisconnectReason( - kind: .unknown, - message: message - ) - ) - } - } - - static func isInputAvailable(_ snapshot: TerminalReadinessSnapshot) -> Bool { - isInputAvailable( - phase: snapshot.phase, - hasFocusedSurface: snapshot.hasFocusedSurface - ) - } - - static func isInputAvailable( - phase: GhosttyTerminalRuntimePhase, - hasFocusedSurface: Bool - ) -> Bool { - phase == .running && hasFocusedSurface - } - - static func isTransportAvailableForInput(_ snapshot: TerminalReadinessSnapshot) -> Bool { - isTransportAvailableForInput( - phase: snapshot.phase, - transportWritable: snapshot.transportWritable - ) - } - - static func isTransportAvailableForInput( - phase: GhosttyTerminalRuntimePhase, - transportWritable: Bool - ) -> Bool { - phase == .running && transportWritable - } - - static func canSubmitInput(_ snapshot: TerminalReadinessSnapshot) -> Bool { - canSubmitInput( - phase: snapshot.phase, - transportWritable: snapshot.transportWritable, - hasFocusedSurface: snapshot.hasFocusedSurface - ) - } - - static func uiTestInputReady(_ snapshot: TerminalReadinessSnapshot) -> Bool { - canSubmitInput(snapshot) - } - - static func canSubmitInput( - phase: GhosttyTerminalRuntimePhase, - transportWritable: Bool, - hasFocusedSurface: Bool - ) -> Bool { - isInputAvailable(phase: phase, hasFocusedSurface: hasFocusedSurface) - && isTransportAvailableForInput(phase: phase, transportWritable: transportWritable) - } - - static func isWaitingForPanes(_ snapshot: TerminalReadinessSnapshot) -> Bool { - isWaitingForPanes(phase: snapshot.phase, topLevelCount: snapshot.topLevelCount) - } - - static func isWaitingForPanes( - phase: GhosttyTerminalRuntimePhase, - topLevelCount: Int - ) -> Bool { - precondition(topLevelCount >= 0, "topLevelCount must be non-negative") - return phase == .running && topLevelCount == 0 - } - - static func isTerminalStatusReady( - _ snapshot: TerminalReadinessSnapshot, - commandFailureMessage: String? - ) -> Bool { - snapshot.phase == .running - && snapshot.topLevelCount > 0 - && commandFailureMessage == nil - } - - static func shouldTraceTerminalReady(_ snapshot: TerminalReadinessSnapshot) -> Bool { - snapshot.phase == .running && snapshot.topLevelCount > 0 - } - - static func terminalReadyTraceFields( - _ snapshot: TerminalReadinessSnapshot, - managedSurfaceCount: Int, - workspaceID: UUID - ) -> [String: String] { - precondition(managedSurfaceCount >= 0, "managedSurfaceCount must be non-negative") - return [ - "topLevels": "\(snapshot.topLevelCount)", - "managedSurfaces": "\(managedSurfaceCount)", - "workspaceID": workspaceID.uuidString, - "phase": traceValue(for: snapshot.phase), - "transportWritable": "\(snapshot.transportWritable)", - "selectedActiveLeafID": ghosttyDiagnosticShortID(snapshot.selectedActiveLeafID), - ] - } - - private static func traceValue(for phase: GhosttyTerminalRuntimePhase) -> String { - switch phase { - case .idle: - "idle" - case .starting: - "starting" - case .running: - "running" - case .failed: - "failed" - } - } -} - -struct GhosttyTerminalInteractionProjection: Equatable, Sendable { - let isInputAvailable: Bool -} - -enum GhosttyTerminalStatusOverlayProjection: Equatable, Sendable { - case starting - case commandFailure(String) - case waitingForPanes(debugStatus: String, registryDebugSummary: String) - case ready - case failed(message: String, reason: TerminalDisconnectReason?) -} - -struct GhosttyTerminalScreenPresentationProjection: Equatable { - let readiness: TerminalReadinessSnapshot - let interaction: GhosttyTerminalInteractionProjection - let viewport: GhosttyTerminalViewportPresentationProjection - let statusOverlay: GhosttyTerminalStatusOverlayProjection -} - -/// MoriRemote presents exactly one tmux pane per app viewport. This projection -/// identifies the one native surface instance currently hosted. +/// 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, @@ -205,85 +15,3 @@ struct GhosttyTerminalViewportPresentationProjection: Equatable { windowCount > 1 } } - -@MainActor -enum GhosttyTerminalPresentationProjector { - static func terminalScreenPresentationProjection( - phase: GhosttyTerminalRuntimePhase, - transportWritable: Bool, - commandFailureMessage: String?, - debugStatus: String, - registryDebugSummary: String, - presentedSurfaceID: UUID?, - topLevelCount: Int - ) -> GhosttyTerminalScreenPresentationProjection { - let readiness = TerminalReadinessProjector.snapshot( - phase: phase, - transportWritable: transportWritable, - topLevelCount: topLevelCount, - selectedActiveLeafID: presentedSurfaceID - ) - - return GhosttyTerminalScreenPresentationProjection( - readiness: readiness, - interaction: terminalInteractionProjection( - phase: phase, - presentedSurfaceID: presentedSurfaceID - ), - viewport: GhosttyTerminalViewportPresentationProjection( - surfaceID: presentedSurfaceID, - windowCount: topLevelCount - ), - statusOverlay: terminalStatusOverlayProjection( - readiness: readiness, - commandFailureMessage: commandFailureMessage, - debugStatus: debugStatus, - registryDebugSummary: registryDebugSummary - ) - ) - } - - static func terminalStatusOverlayProjection( - readiness: TerminalReadinessSnapshot, - commandFailureMessage: String?, - debugStatus: String, - registryDebugSummary: String - ) -> GhosttyTerminalStatusOverlayProjection { - switch readiness.phase { - case .idle, .starting: - return .starting - case .failed(let message, let reason): - return .failed(message: message, reason: reason) - case .running: - if let commandFailureMessage { - return .commandFailure(commandFailureMessage) - } - let waitingProjection = GhosttyTerminalStatusOverlayProjection.waitingForPanes( - debugStatus: debugStatus, - registryDebugSummary: registryDebugSummary - ) - if TerminalReadinessProjector.isWaitingForPanes(readiness) { - return waitingProjection - } - if TerminalReadinessProjector.isTerminalStatusReady( - readiness, - commandFailureMessage: nil - ) { - return .ready - } - return waitingProjection - } - } - - static func terminalInteractionProjection( - phase: GhosttyTerminalRuntimePhase, - presentedSurfaceID: UUID? - ) -> GhosttyTerminalInteractionProjection { - GhosttyTerminalInteractionProjection( - isInputAvailable: TerminalReadinessProjector.isInputAvailable( - phase: phase, - hasFocusedSurface: presentedSurfaceID != nil - ) - ) - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift index 19857eb7..4474def9 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxSessionController.swift @@ -119,31 +119,6 @@ final class TmuxSessionController: @unchecked Sendable { let body: String } - enum PaneCurrentDirectoryError: LocalizedError, Equatable, Sendable { - case sessionUnavailable - case paneUnavailable - case commandSkipped - case commandFailed(String) - case invalidResponse - - var errorDescription: String? { - switch self { - case .sessionUnavailable: - return "The terminal session is no longer available." - case .paneUnavailable: - return "The originating terminal pane is no longer available." - case .commandSkipped: - return "tmux did not execute the current-directory query." - case .commandFailed(let detail): - return detail.isEmpty - ? "tmux could not resolve the terminal's current directory." - : detail - case .invalidResponse: - return "tmux returned an invalid current directory." - } - } - } - /// One retained reference to ControlClient's canonical pane terminal. /// Ownership transfers from the writer queue to MainActor exactly once. final class RetainedPaneTerminal: @unchecked Sendable { @@ -187,9 +162,6 @@ final class TmuxSessionController: @unchecked Sendable { private enum OutstandingRequest { case action(Request, topologyRevisionAtSubmission: UInt64) - case paneCurrentDirectory( - @Sendable (Result) -> Void - ) case agentMetadata(@Sendable (AgentMetadataQueryResult) -> Void) } @@ -282,7 +254,6 @@ final class TmuxSessionController: @unchecked Sendable { func transportClosed() { queue.async { [self] in guard !shuttingDown else { return } - failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) failOutstandingAgentMetadataQueries() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -299,7 +270,6 @@ final class TmuxSessionController: @unchecked Sendable { func attachmentStopped() { queue.async { [self] in guard !shuttingDown else { return } - failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) failOutstandingAgentMetadataQueries() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -314,10 +284,8 @@ final class TmuxSessionController: @unchecked Sendable { queue.async { [self] in shuttingDown = true outboundSink = nil - let directoryQueries = outstandingPaneDirectoryQueries() let agentMetadataQueries = outstandingAgentMetadataQueries() requestsByToken.removeAll() - directoryQueries.forEach { $0(.failure(.sessionUnavailable)) } agentMetadataQueries.forEach { $0(.init(status: .failed, body: "")) } deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -394,7 +362,6 @@ final class TmuxSessionController: @unchecked Sendable { preconditionOnWriterQueue() switch action.tag { case GHOSTTY_TMUX_ACTION_EXIT: - failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) failOutstandingAgentMetadataQueries() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -557,9 +524,6 @@ final class TmuxSessionController: @unchecked Sendable { preconditionOnWriterQueue() guard let outstanding = requestsByToken.removeValue(forKey: completion.token) else { return } switch outstanding { - case .paneCurrentDirectory(let completionHandler): - completionHandler(paneCurrentDirectoryResult(for: completion)) - return case .agentMetadata(let completionHandler): let status: AgentMetadataQueryResult.Status = switch completion.status { case GHOSTTY_TMUX_COMMAND_SUCCESS: .success @@ -731,17 +695,6 @@ final class TmuxSessionController: @unchecked Sendable { } } - func paneCurrentDirectory(for paneID: TmuxPaneID) async throws -> String { - try await withCheckedThrowingContinuation { continuation in - queue.async { [self] in - submitPaneCurrentDirectoryQueryOnWriter( - paneID: paneID, - completion: { continuation.resume(with: $0) } - ) - } - } - } - private func submitNavigation(_ intent: NavigationIntent) { preconditionOnWriterQueue() guard !navigationAdmissionBlocked else { @@ -909,36 +862,6 @@ final class TmuxSessionController: @unchecked Sendable { return true } - private func submitPaneCurrentDirectoryQueryOnWriter( - paneID: TmuxPaneID, - completion: @escaping @Sendable ( - Result - ) -> Void - ) { - preconditionOnWriterQueue() - guard let client, !shuttingDown else { - completion(.failure(.sessionUnavailable)) - return - } - guard retainedPaneIDs.contains(paneID) else { - completion(.failure(.paneUnavailable)) - return - } - - let command = "display-message -p -t %\(paneID.rawValue) '#{pane_current_path}'" - let (result, token) = enqueueCommandTokenOnWriter(command, client: client) - guard result == GHOSTTY_TMUX_RESULT_OK else { - completion(.failure(.commandFailed(String(describing: result)))) - if result == GHOSTTY_TMUX_RESULT_CLIENT_FAILED - || result == GHOSTTY_TMUX_RESULT_CLOSED { - handleClientFailure(result) - } - return - } - requestsByToken[token] = .paneCurrentDirectory(completion) - _ = drainOutbound() - } - private func enqueueCommandTokenOnWriter( _ command: String, client: ghostty_tmux_client_t @@ -1150,7 +1073,6 @@ final class TmuxSessionController: @unchecked Sendable { private func handleClientFailure(_ result: ghostty_tmux_result_e) { preconditionOnWriterQueue() guard !shuttingDown else { return } - failOutstandingPaneDirectoryQueries(with: .sessionUnavailable) failOutstandingAgentMetadataQueries() deferredNavigationIntent = nil successfulMutationRequiredAfterRevision = nil @@ -1179,51 +1101,6 @@ final class TmuxSessionController: @unchecked Sendable { DispatchQueue.main.async { self.callbacks.onRequestFailed(request) } } - private func paneCurrentDirectoryResult( - for completion: ghostty_tmux_command_completion_s - ) -> Result { - switch completion.status { - case GHOSTTY_TMUX_COMMAND_SUCCESS: - let path = decodeTmuxString(completion.body) - .trimmingCharacters(in: .newlines) - guard path.hasPrefix("/"), - !path.contains("\0"), - !path.contains("\n"), - !path.contains("\r") - else { return .failure(.invalidResponse) } - return .success(path) - case GHOSTTY_TMUX_COMMAND_SKIPPED: - return .failure(.commandSkipped) - case GHOSTTY_TMUX_COMMAND_ERROR_BLOCK: - let detail = decodeTmuxString(completion.body) - .trimmingCharacters(in: .newlines) - return .failure(.commandFailed(detail)) - default: - return .failure(.invalidResponse) - } - } - - private func outstandingPaneDirectoryQueries() -> [ - @Sendable (Result) -> Void - ] { - requestsByToken.values.compactMap { - guard case .paneCurrentDirectory(let completion) = $0 else { return nil } - return completion - } - } - - private func failOutstandingPaneDirectoryQueries( - with error: PaneCurrentDirectoryError - ) { - preconditionOnWriterQueue() - let completions = outstandingPaneDirectoryQueries() - requestsByToken = requestsByToken.filter { - guard case .paneCurrentDirectory = $0.value else { return true } - return false - } - completions.forEach { $0(.failure(error)) } - } - private func outstandingAgentMetadataQueries() -> [@Sendable (AgentMetadataQueryResult) -> Void] { requestsByToken.values.compactMap { guard case .agentMetadata(let completion) = $0 else { return nil } diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift index 0ded764a..469716bb 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalScreenAdapter.swift @@ -25,9 +25,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { private var activeManagedSurface: GhosttyManagedSurface? private var initialViewportHandler: ((CGSize, CGFloat) -> Void)? - private var commandFailureMessage: String? - private var commandFailureToken: UInt64 = 0 - private var subscriptions: [AnyCancellable] = [] /// Connects the adapter to a live session. Called once, right after the @@ -58,15 +55,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { self?.objectWillChange.send() } .store(in: &subscriptions) - session.$lastFailedRequest - .sink { [weak self] request in - guard let request else { return } - self?.presentCommandFailure(for: request) - } - .store(in: &subscriptions) - session.$transportFailure - .sink { [weak self] _ in self?.objectWillChange.send() } - .store(in: &subscriptions) } func invalidate() { @@ -78,30 +66,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { latestTopology = nil } - private var runtimePhase: GhosttyTerminalRuntimePhase { - guard let session else { - return .failed(message: "terminal session unavailable", reason: nil) - } - switch session.state { - case .attaching, .syncing: - return .starting - case .ready: - return .running - case .detached(nil): - if let failure = session.transportFailure { - return .failed(message: failure.message, reason: failure) - } - // Pre-connect; the first connect is imminent. - return .starting - case .detached(.some(let reason)): - let mapped = reason.terminalDisconnectReason - return .failed(message: mapped.message, reason: mapped) - case .closed(let reason): - let mapped = reason.terminalDisconnectReason - return .failed(message: mapped.message, reason: mapped) - } - } - private var isTransportWritable: Bool { session?.state == .ready } @@ -122,33 +86,6 @@ final class TmuxTerminalScreenAdapter: ObservableObject { private var focusedManagedSurface: GhosttyManagedSurface? { activeManagedSurface } - - - // MARK: Command failures - - private func presentCommandFailure(for request: TmuxSessionController.Request) { - commandFailureToken &+= 1 - let message = "tmux: \(Self.failureLabel(for: request)) failed" - commandFailureMessage = message - objectWillChange.send() - - let token = commandFailureToken - Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(4)) - guard let self, self.commandFailureToken == token else { return } - self.commandFailureMessage = nil - self.objectWillChange.send() - } - } - - private static func failureLabel(for request: TmuxSessionController.Request) -> String { - switch request { - case .selectWindow: "select window" - case .selectPane: "select pane" - case .sharedMutation: "shared workspace action" - case .sendInput: "input" - } - } } extension TmuxTerminalScreenAdapter { @@ -156,23 +93,15 @@ extension TmuxTerminalScreenAdapter { initialViewportHandler?(size, scale) } - var terminalScreenPresentationProjection: GhosttyTerminalScreenPresentationProjection { - GhosttyTerminalPresentationProjector.terminalScreenPresentationProjection( - phase: runtimePhase, - transportWritable: isTransportWritable, - commandFailureMessage: commandFailureMessage, - debugStatus: stateTraceLabel, - registryDebugSummary: "tmux session stack", - presentedSurfaceID: activeManagedSurface?.id, - topLevelCount: latestTopology?.windows.count ?? 0 + var terminalViewportPresentationProjection: GhosttyTerminalViewportPresentationProjection { + GhosttyTerminalViewportPresentationProjection( + surfaceID: activeManagedSurface?.id, + windowCount: latestTopology?.windows.count ?? 0 ) } - var terminalInteractionProjection: GhosttyTerminalInteractionProjection { - GhosttyTerminalPresentationProjector.terminalInteractionProjection( - phase: runtimePhase, - presentedSurfaceID: activeManagedSurface?.id - ) + var isInputAvailable: Bool { + isTransportWritable && activeManagedSurface != nil } var terminalManagedSurfaceLookup: GhosttyManagedSurfaceLookup { @@ -291,44 +220,3 @@ extension TmuxTerminalScreenAdapter { } } - -// MARK: - Shared reason mapping - -extension TmuxSessionController.DetachReason { - var terminalDisconnectReason: TerminalDisconnectReason { - switch self { - case .serverExited(let message): - TerminalDisconnectReason( - kind: .remoteExit, - message: message ?? "tmux server exited" - ) - case .transportClosed: - TerminalDisconnectReason( - kind: .transportIO, - message: "connection lost" - ) - case .channelAborted: - TerminalDisconnectReason( - kind: .runtime, - message: "tmux control protocol error" - ) - case .outOfMemory: - TerminalDisconnectReason( - kind: .runtime, - message: "tmux session sync failed" - ) - } - } -} - -extension TmuxSessionController.CloseReason { - var terminalDisconnectReason: TerminalDisconnectReason { - switch self { - case .unsupportedVersion(let version): - TerminalDisconnectReason( - kind: .runtime, - message: "unsupported tmux version \(version) (requires 3.2+)" - ) - } - } -} diff --git a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift index 9b65126b..6d3e5f5c 100644 --- a/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift +++ b/MoriRemote/MoriRemoteTerminal/Tmux/TmuxTerminalSession.swift @@ -10,11 +10,8 @@ final class TmuxTerminalSession: ObservableObject { @Published private(set) var topology: TmuxSessionController.TopologySnapshot? @Published private(set) var paneSurface: TmuxPaneSurface? @Published private(set) var livePaneIDs: Set = [] - @Published private(set) var lastFailedRequest: TmuxSessionController.Request? - @Published private(set) var transportFailure: TerminalDisconnectReason? var onStateChange: (@MainActor (TmuxSessionController.SessionState) -> Void)? var onTopologyChange: (@MainActor (TmuxSessionController.TopologySnapshot) -> Void)? - var onPresentationChange: (@MainActor (Bool) -> Void)? private let app: ghostty_app_t private(set) var controller: TmuxSessionController! @@ -106,7 +103,6 @@ final class TmuxTerminalSession: ObservableObject { guard !isShutDown, !didStartLink else { return } didStartLink = true linkIsActive = true - transportFailure = nil let link = self.link do { try await link.start() @@ -116,11 +112,10 @@ final class TmuxTerminalSession: ObservableObject { } } - private func connectFailed(link failed: TmuxSessionLink, error: any Error) async { + private func connectFailed(link failed: TmuxSessionLink, error _: any Error) async { await failed.stop() guard !isShutDown, link === failed, linkIsActive else { return } linkIsActive = false - transportFailure = GhosttyTerminalDisconnectReasonClassifier.transportStartFailure(error) state = .detached(nil) } @@ -135,18 +130,6 @@ final class TmuxTerminalSession: ObservableObject { await link.controlChannelIsActive() ?? false } - func invalidateInactiveTransportOnForeground( - willInvalidate: (TerminalDisconnectReason) -> Void - ) async -> TerminalDisconnectReason? { - guard linkIsActive else { return nil } - guard let isActive = await link.controlChannelIsActive(), !isActive else { return nil } - guard linkIsActive, !isShutDown else { return nil } - let reason = GhosttyTerminalDisconnectReasonClassifier.foregroundMissingHost() - willInvalidate(reason) - await link.invalidateTransport() - return reason - } - func shutdown() async { guard !isShutDown else { return } isShutDown = true @@ -309,7 +292,6 @@ final class TmuxTerminalSession: ObservableObject { } private func handleRequestFailed(_ request: TmuxSessionController.Request) { - lastFailedRequest = request if request == .selectPane || request == .selectWindow { pendingPaneID = nil cancelPendingPresentation() @@ -458,7 +440,6 @@ final class TmuxTerminalSession: ObservableObject { return } paneSurface = surface - onPresentationChange?(true) pendingPaneID = nil surface.setSceneActive(isAppActive) surface.setPresented(true) @@ -475,7 +456,6 @@ final class TmuxTerminalSession: ObservableObject { guard let surface = paneSurface else { return } surface.setPresented(false) paneSurface = nil - onPresentationChange?(false) } private func relinquishPresentationOwnership(of surface: TmuxPaneSurface) { diff --git a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift index 240382c8..50222733 100644 --- a/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift +++ b/MoriRemote/MoriRemoteTerminalTests/TmuxTerminalScreenAdapterTests.swift @@ -24,8 +24,8 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { activeWindowID: 1 )) - let first = adapter.terminalScreenPresentationProjection - XCTAssertEqual(first.viewport.windowCount, 2) + let first = adapter.terminalViewportPresentationProjection + XCTAssertEqual(first.windowCount, 2) session.handleTopology(.init( sessionName: "fresh-test", @@ -34,8 +34,8 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { activeWindowID: 1 )) - let second = adapter.terminalScreenPresentationProjection - XCTAssertEqual(second.viewport.windowCount, 1) + let second = adapter.terminalViewportPresentationProjection + XCTAssertEqual(second.windowCount, 1) await session.shutdown() } diff --git a/MoriRemote/UPSTREAM.md b/MoriRemote/UPSTREAM.md index a872fcad..96a75e7f 100644 --- a/MoriRemote/UPSTREAM.md +++ b/MoriRemote/UPSTREAM.md @@ -66,7 +66,8 @@ transport remains solely a terminal-core test fixture. | `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 tracked composer input | Removed with the excluded composer/submission path. Ordinary terminal input retains local routing through the sole controller queue but does not allocate command-completion tokens that no caller observes. | The copied await-completion chain crossed surface, managed-surface, adapter, and controller modules solely for an excluded feature. | +| 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. |