diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 315e13af..b567ddd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,65 +57,75 @@ jobs: run: cd Packages/MoriIPC && swift run MoriIPCTests ios-build: + needs: ghosttykit runs-on: macos-26 - timeout-minutes: 20 + timeout-minutes: 30 + env: + DERIVED_DATA: .derived-data-ios steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Download Mori-built GhosttyKit + uses: actions/download-artifact@v7 + with: + name: GhosttyKit + path: Frameworks + + - name: Verify universal GhosttyKit + run: bash scripts/verify-ghosttykit.sh - name: Cache SPM packages uses: actions/cache@v5 with: - path: MoriRemote/.derived-data-sim/SourcePackages - key: spm-ios-${{ hashFiles('MoriRemote/MoriRemote.xcodeproj/project.pbxproj', '**/Package.resolved') }} + path: ${{ env.DERIVED_DATA }}/SourcePackages + key: spm-ios-${{ hashFiles('MoriRemote/project.yml', '**/Package.resolved') }} restore-keys: spm-ios- - name: Install tools - run: | - brew install xcodegen xcbeautify - - - name: Stub GhosttyKit for SPM resolution - run: | - mkdir -p Frameworks/GhosttyKit.xcframework/macos-arm64_x86_64/GhosttyKit.framework/Headers - touch Frameworks/GhosttyKit.xcframework/macos-arm64_x86_64/GhosttyKit.framework/GhosttyKit - cat > Frameworks/GhosttyKit.xcframework/Info.plist << 'PLIST' - - - - - AvailableLibraries - - - LibraryIdentifier - macos-arm64_x86_64 - LibraryPath - GhosttyKit.framework - SupportedArchitectures - arm64x86_64 - SupportedPlatform - macos - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - - PLIST + run: brew install xcodegen xcbeautify - name: Generate Xcode project run: cd MoriRemote && xcodegen generate - - name: Build for iOS Simulator + - name: Run MoriRemote Swift Testing suite run: | set -euo pipefail + SIMULATOR="$(xcrun simctl list devices available | grep 'iPhone' | grep -oE '[A-F0-9-]{36}' | head -1)" xcodebuild \ -project MoriRemote/MoriRemote.xcodeproj \ -scheme MoriRemote \ - -destination 'generic/platform=iOS Simulator' \ - -derivedDataPath .derived-data-ios \ - build 2>&1 | xcbeautify + -destination "platform=iOS Simulator,id=$SIMULATOR" \ + -derivedDataPath "$DERIVED_DATA" \ + test 2>&1 | xcbeautify + + - name: Build iPhone and iPad Release simulator products + run: | + set -euo pipefail + for family in iPhone iPad; do + SIMULATOR="$(xcrun simctl list devices available | grep "$family" | grep -oE '[A-F0-9-]{36}' | head -1)" + xcodebuild \ + -project MoriRemote/MoriRemote.xcodeproj \ + -scheme MoriRemote \ + -configuration Release \ + -destination "platform=iOS Simulator,id=$SIMULATOR" \ + -derivedDataPath "$DERIVED_DATA" \ + build 2>&1 | xcbeautify + done + + - name: Smoke library and deterministic Ghostty terminal + run: MORI_IOS_SMOKE_OUTPUT="$RUNNER_TEMP/moriremote-smoke" bash scripts/smoke-moriremote-simulator.sh + + - name: Upload simulator smoke evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: MoriRemote-simulator-smoke + path: ${{ runner.temp }}/moriremote-smoke + if-no-files-found: warn + retention-days: 14 bundle: needs: [ghosttykit, build-and-test] diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml index 235eb7c2..b7d414d9 100644 --- a/.github/workflows/release-ios.yml +++ b/.github/workflows/release-ios.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: version: - description: "Version (e.g. 0.1.0)" + description: "Version (must be 0.3.5)" required: true build_number: description: "Optional build number override (defaults to UTC timestamp, e.g. 202604071105)" @@ -21,22 +21,33 @@ concurrency: cancel-in-progress: false jobs: + ghosttykit: + uses: ./.github/workflows/build-ghosttykit.yml + release-ios: + needs: ghosttykit runs-on: macos-26 timeout-minutes: 30 env: DERIVED_DATA: .derived-data-ios steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 + with: + submodules: recursive - - name: Resolve version + - name: Resolve fixed marketing version and build number id: version + env: + EVENT_NAME: ${{ github.event_name }} + VERSION_INPUT: ${{ inputs.version }} + BUILD_NUMBER_INPUT: ${{ inputs.build_number }} run: | + set -euo pipefail DEFAULT_BUILD_NUMBER="$(date -u +%Y%m%d%H%M)" - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - VERSION="${{ github.event.inputs.version }}" - BUILD_NUMBER="${{ github.event.inputs.build_number }}" + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + VERSION="$VERSION_INPUT" + BUILD_NUMBER="$BUILD_NUMBER_INPUT" if [[ -z "$BUILD_NUMBER" ]]; then BUILD_NUMBER="$DEFAULT_BUILD_NUMBER" fi @@ -44,19 +55,29 @@ jobs: VERSION="${GITHUB_REF_NAME#ios-v}" BUILD_NUMBER="$DEFAULT_BUILD_NUMBER" fi + [[ "$VERSION" == "0.3.5" ]] || { echo "MoriRemote TestFlight marketing version is pinned to 0.3.5." >&2; exit 1; } + [[ "$BUILD_NUMBER" =~ ^[0-9]+$ ]] || { echo "Build number must contain only digits." >&2; exit 1; } echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "build_number=${BUILD_NUMBER}" >> "$GITHUB_OUTPUT" echo "📦 Version: ${VERSION} (${BUILD_NUMBER})" + - name: Download Mori-built GhosttyKit + uses: actions/download-artifact@v7 + with: + name: GhosttyKit + path: Frameworks + + - name: Verify universal GhosttyKit + run: bash scripts/verify-ghosttykit.sh + - name: Install tools - run: | - brew install xcodegen xcbeautify + run: brew install xcodegen xcbeautify - name: Cache SPM packages uses: actions/cache@v5 with: path: ${{ env.DERIVED_DATA }}/SourcePackages - key: spm-ios-${{ hashFiles('MoriRemote/MoriRemote.xcodeproj/project.pbxproj', '**/Package.resolved') }} + key: spm-ios-${{ hashFiles('MoriRemote/project.yml', '**/Package.resolved') }} restore-keys: spm-ios- - name: Generate Xcode project @@ -64,36 +85,6 @@ jobs: DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }} run: cd MoriRemote && xcodegen generate - - name: Stub GhosttyKit for SPM resolution - run: | - mkdir -p Frameworks/GhosttyKit.xcframework/macos-arm64_x86_64/GhosttyKit.framework/Headers - touch Frameworks/GhosttyKit.xcframework/macos-arm64_x86_64/GhosttyKit.framework/GhosttyKit - cat > Frameworks/GhosttyKit.xcframework/Info.plist << 'PLIST' - - - - - AvailableLibraries - - - LibraryIdentifier - macos-arm64_x86_64 - LibraryPath - GhosttyKit.framework - SupportedArchitectures - arm64x86_64 - SupportedPlatform - macos - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - - PLIST - - name: Import signing certificate env: APPLE_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} @@ -146,7 +137,8 @@ jobs: MARKETING_VERSION="$VERSION" \ CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ 2>&1 | xcbeautify - echo "✅ Archive created" + bash scripts/verify-moriremote-archive.sh "$DERIVED_DATA/MoriRemote.xcarchive" "$VERSION" "$BUILD_NUMBER" + echo "✅ Archive created and inspected" - name: Export IPA run: | @@ -164,11 +156,8 @@ jobs: APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} run: | set -euo pipefail - IPA_PATH=$(find "$DERIVED_DATA/export" -name "*.ipa" | head -1) - if [ -z "$IPA_PATH" ]; then - echo "❌ No IPA found" - exit 1 - fi + IPA_PATH=$(find "$DERIVED_DATA/export" -name "*.ipa" -print -quit) + [[ -n "$IPA_PATH" ]] || { echo "❌ No IPA found"; exit 1; } xcrun altool --upload-app \ --type ios \ --file "$IPA_PATH" \ @@ -177,7 +166,7 @@ jobs: echo "✅ Uploaded to TestFlight" - name: Upload IPA artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: MoriRemote-${{ steps.version.outputs.version }}.ipa path: ${{ env.DERIVED_DATA }}/export/*.ipa diff --git a/AGENTS.md b/AGENTS.md index 9fa0dc59..8be76361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ mise run test:ui # MoriUI tests only mise run clean # Remove .build and .derived-data ``` -Tests are executable targets (not XCTest), run via `swift run ` from each package directory. +Tests are executable targets (not XCTest), run via `swift run ` from each package directory. MoriRemote is the narrow exception: `mise run ios:test` runs its Swift Testing suite and bootstraps the universal GhosttyKit artifact with `mise run build:ghostty-universal`. `mise run ios:run` / `mise run ios:smoke` build, install, verify process liveness and crash diagnostics, and save library/terminal screenshots; a returned simulator PID alone is not success. Before an iOS release, `bash scripts/verify-moriremote-archive.sh 0.3.5` verifies the archive contract. ## Pre-Push Verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd010c8..b20995db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### ✨ 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. + +### 🐛 Bug Fixes + +- **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. + ### 🔧 CI/CD - **GhosttyKit**: Build one universal macOS + iOS framework from the pinned remux Ghostty source and verify its provenance, platform slices, build mode, and tmux ABI before reuse. +- **iOS (MoriRemote)**: CI consumes that same-run verified universal framework for app tests, simulator smoke, and archives; TestFlight marketing version remains pinned to `0.3.5`. + + ## [0.7.0] - 2026-07-31 diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 80836df6..a5f737b3 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -7,9 +7,21 @@ ## [Unreleased] +### ✨ 新功能 + +- **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。 + +### 🐛 问题修复 + +- **iOS(MoriRemote)**:修复远端 tmux 控制客户端已经连接,但界面仍一直停在“正在等待活动的 tmux pane”的问题。 +- **iOS(MoriRemote)**:凭证改为仅限本设备、仅在解锁时可用的 Keychain 存储;Ghostty 必须在终端 surface 拆除后才释放;后台期间延后重连;低内存时优先释放非活动运行时。 + ### 🔧 CI/CD - **GhosttyKit**:从固定的 remux Ghostty 源码构建一份通用 macOS + iOS framework,并在复用前校验其来源、平台 slice、构建模式和 tmux ABI。 +- **iOS(MoriRemote)**:CI 在应用测试、模拟器 smoke 和 archive 中消费同一次运行内已验证的通用 framework;TestFlight 营销版本仍固定为 `0.3.5`。 + + ## [0.7.0] - 2026-07-31 diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index 37a322f7..d60fadd4 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -8,60 +8,92 @@ /* Begin PBXBuildFile section */ 109E4551800EDCA43A760F80 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9A1E86AC034B64B37F68A846 /* Localizable.strings */; }; - 229F7BB580720A05A8DFF1AE /* TmuxSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15134BC85B9E6519FF7F2A9 /* TmuxSidebarView.swift */; }; + 14EC21ABAE7D514B7F68225A /* Stores.swift in Sources */ = {isa = PBXBuildFile; fileRef = B05E4FE02E3C3962771154D7 /* Stores.swift */; }; + 16DF0B72EA0BA73D616F61D5 /* CitadelSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77CF33F94CEBE45B61807FB /* CitadelSSHTransport.swift */; }; + 29E5C06C8FB1718904533EEE /* TmuxControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */; }; + 2EA225F941CDB8FE36CA7A8F /* SavedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210BF0AD6F094B3D96D0A36D /* SavedModels.swift */; }; + 30694D81E01EF77122136322 /* MoriRemoteDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBBE7E60EEF6A3363F73F0A /* MoriRemoteDependencies.swift */; }; + 3207EDDDF0FDBE441D981A8B /* SSHTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B402FCCB191BE4C43EE332CF /* SSHTransportTests.swift */; }; 376EEC1B30EE8565C4B085D1 /* MoriRemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F3182F8D251430A10B298E /* MoriRemoteApp.swift */; }; - 3E10DC5EF41F3B0DBD7E18E0 /* ServerFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C9BF50025C03B0BCF8E77A /* ServerFormView.swift */; }; - 511FAAE001FB06AACCFC7E2C /* KeyBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAEB9A39277AB28A5B3718E3 /* KeyBarView.swift */; }; - 57768C47189EF8BE03AD3106 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = CB7E8BE74D0419BD18CEA5E3 /* SwiftTerm */; }; + 3D5ABC739FD3170547500C7E /* GhosttyKitABIProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.swift */; }; + 3FAC41CC848F16DDB123A9F6 /* Phase4ShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B2C6DECBA8C1C75B1A429CD /* Phase4ShellTests.swift */; }; + 40234B3C274CDF5CBDF2746B /* GhosttyTerminalProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.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 */; }; + 54F38E40C11EB8E48022762A /* AgentMetadataProjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */; }; + 54F48944A9E87F22A490B8D4 /* LegacyMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */; }; + 553D7FA2654275C5B609DB67 /* SSHRootPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5CE2DEDF2FDD48461599ED /* SSHRootPool.swift */; }; 57A0B148D1B8D23CA120481C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 495654252F6CE455BE0201B3 /* Assets.xcassets */; }; - 58C6389AD3DF6D861F49D1DE /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6666C37E7773D0C60B3D16BA /* ServerListView.swift */; }; - 593DE6D8BD420A29CCDB02CD /* KeyAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F0FEA59D35705F0BCD383 /* KeyAction.swift */; }; - 5ACA156F8A500931E29BAD3E /* TerminalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EBAEEBBFE6A6FF22BFC722 /* TerminalScreen.swift */; }; - 5D0A706E5CC15CA815D2205C /* MoriSSH in Frameworks */ = {isa = PBXBuildFile; productRef = E4A46D15917AB2D06DABB5BF /* MoriSSH */; }; - 6AB5F68B40467923E1395F97 /* ShellCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A4540E9036A99292A5A06A /* ShellCoordinator.swift */; }; - 7C0AE4FDB467B008BCA2E721 /* KeyBarCustomizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405D186A2E98097FEB5F37A9 /* KeyBarCustomizeView.swift */; }; - 84BCE7CB7E779A71350C7FB7 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977760861C89855B90308D6B /* Theme.swift */; }; - 85ADFC9BAA17017ECA067C5B /* MoriCore in Frameworks */ = {isa = PBXBuildFile; productRef = CB00391E626D4853B15135EF /* MoriCore */; }; - 949356F283B52C6F3AFC7B06 /* ServerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C351C1B33D101446ECAAE1 /* ServerStore.swift */; }; - 950C33367217BF06FA564D2B /* MoriTmux in Frameworks */ = {isa = PBXBuildFile; productRef = 8AFD415F38753179608A985F /* MoriTmux */; }; - 96C8DDDEA3B929ED016AF50D /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 293AE41146DC5F2BB4900EA4 /* TerminalView.swift */; }; - 97B5FB0C7E80E60AF0D59B0E /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = 493C27506159F574E156F96C /* Server.swift */; }; - C7B874D9B7973D33D6D7E5D6 /* MoriTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 35EA6C6FBCAE1CE488930F9F /* MoriTerminal */; }; - C8B49020D82B4405EE6CFD7C /* RegularWidthServerBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC39E01BAFBE24446B2B9AF /* RegularWidthServerBrowserView.swift */; }; - D2F9125D254E9980272FA1F7 /* TmuxBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879D7ABF80A185D8F5020407 /* TmuxBarView.swift */; }; - F005EC1ECFC968DF06655D7E /* SidebarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348894A19970D2C348B4A692 /* SidebarContainer.swift */; }; - F49D0FFF75E55EA9446E6816 /* TerminalSessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FE89B4C66C6973B7997A6B /* TerminalSessionHost.swift */; }; - F8BC0A57AF5F0A266F023AB0 /* TerminalAccessoryBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3314AE88EB123344D196346D /* TerminalAccessoryBar.swift */; }; + 5D0A706E5CC15CA815D2205C /* NIOPosix in Frameworks */ = {isa = PBXBuildFile; productRef = 82712771B627666368A3F09C /* NIOPosix */; }; + 61910C0D99CE3C03CDCAA824 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AC557BE4951B52EE25ED7B /* GhosttyKit.xcframework */; }; + 64772A470A3EB7EE2CD92BA8 /* SSHTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CE86B5FC370573653D3245 /* SSHTmuxControlTransport.swift */; }; + 68C87C51C7B1430199E64AAC /* SSHPrivateKeyInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */; }; + 6C61BF2005608B1366F0CE31 /* RemoteRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA4083A9512502482A6CECA /* RemoteRootView.swift */; }; + 6E8368DBBA57DB44BDC8E1E5 /* LegacyMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88BDAAB702E98FDD084041C /* LegacyMigration.swift */; }; + 7847C4893AE9B6481BD63CEB /* Phase3RuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */; }; + 78FE94F2F5065228E0F8B259 /* Phase2TransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */; }; + 7EB9D2C181E73D43B40C9485 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E788C063B75F299CBC1D0165 /* PrivacyInfo.xcprivacy */; }; + 85ADFC9BAA17017ECA067C5B /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = F391794B759D1B5CD2C36000 /* Citadel */; }; + 8A1DC8FFA1F8B93734D4E0E3 /* SSHAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D3CED1154E19B734CC9AA8 /* SSHAuth.swift */; }; + 950C33367217BF06FA564D2B /* NIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6712048F2C2EC6961F582380 /* NIO */; }; + A64AA54831409250D2D7AC3A /* GhosttyPaneSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */; }; + BE15F82CE37D35B536E662ED /* GhosttyTmuxRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */; }; + C7B874D9B7973D33D6D7E5D6 /* NIOSSH in Frameworks */ = {isa = PBXBuildFile; productRef = 2B047F037703450AD7431F98 /* NIOSSH */; }; + DE5D4372E774A67A9B411667 /* DeterministicTmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */; }; + EDD8C4E66770445F478F5BA5 /* RemoteRootModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1E077AE28FE61640CCBFBC /* RemoteRootModel.swift */; }; + EF60EB78984BBF1E9A3FFC35 /* GhosttyKitRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */; }; + F80DC80D0F8E3E17AD450B98 /* Phase5AgentMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0323091B8EC347B211B0AF /* Phase5AgentMetadataTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + F94EA73662EAB769336ACE5F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 04D4F671C0A85A6697E3F07E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 219FEED5B577C565EBA81436; + remoteInfo = MoriRemote; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ - 0D01ED3247C1344CC2910A0C /* MoriTerminal */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MoriTerminal; path = ../Packages/MoriTerminal; sourceTree = SOURCE_ROOT; }; - 11EBAEEBBFE6A6FF22BFC722 /* TerminalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScreen.swift; sourceTree = ""; }; - 293AE41146DC5F2BB4900EA4 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; - 2B45537B3FC70BA7F912DE66 /* MoriSSH */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MoriSSH; path = ../Packages/MoriSSH; sourceTree = SOURCE_ROOT; }; - 3314AE88EB123344D196346D /* TerminalAccessoryBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAccessoryBar.swift; sourceTree = ""; }; - 348894A19970D2C348B4A692 /* SidebarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarContainer.swift; sourceTree = ""; }; - 35C6D3B4BCCF583DFF3FE192 /* MoriCore */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MoriCore; path = ../Packages/MoriCore; sourceTree = SOURCE_ROOT; }; - 377F0FEA59D35705F0BCD383 /* KeyAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyAction.swift; sourceTree = ""; }; + 02AC557BE4951B52EE25ED7B /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GhosttyKit.xcframework; path = ../Frameworks/GhosttyKit.xcframework; 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 = ""; }; + 0EBBE7E60EEF6A3363F73F0A /* MoriRemoteDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoriRemoteDependencies.swift; sourceTree = ""; }; + 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalProbe.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 = ""; }; + 2E4A9E8D51098FB9FE41A085 /* HostTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTrust.swift; sourceTree = ""; }; 405397F8D3FACA71D62B7717 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - 405D186A2E98097FEB5F37A9 /* KeyBarCustomizeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBarCustomizeView.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 = ""; }; - 493C27506159F574E156F96C /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.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 = ""; }; - 6666C37E7773D0C60B3D16BA /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; + 64BCCA48638A9FE1582D7514 /* MoriRemoteTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MoriRemoteTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControl.swift; sourceTree = ""; }; 6C511C1314958A8D89FC53C8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 879D7ABF80A185D8F5020407 /* TmuxBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxBarView.swift; sourceTree = ""; }; - 89FE89B4C66C6973B7997A6B /* TerminalSessionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSessionHost.swift; sourceTree = ""; }; - 93C9BF50025C03B0BCF8E77A /* ServerFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFormView.swift; sourceTree = ""; }; - 94A4540E9036A99292A5A06A /* ShellCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellCoordinator.swift; sourceTree = ""; }; - 977760861C89855B90308D6B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; - A15134BC85B9E6519FF7F2A9 /* TmuxSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSidebarView.swift; sourceTree = ""; }; + 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPrivateKeyInspector.swift; sourceTree = ""; }; + 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitRuntime.swift; sourceTree = ""; }; + 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Phase2TransportTests.swift; sourceTree = ""; }; + 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMetadataProjector.swift; sourceTree = ""; }; + 879AFAFA203BE5A903FC8375 /* GhosttyKitABIProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKitABIProbe.swift; sourceTree = ""; }; + 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPaneSurface.swift; sourceTree = ""; }; + 9B1E077AE28FE61640CCBFBC /* RemoteRootModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRootModel.swift; sourceTree = ""; }; + B05E4FE02E3C3962771154D7 /* Stores.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stores.swift; sourceTree = ""; }; + B402FCCB191BE4C43EE332CF /* SSHTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHTransportTests.swift; sourceTree = ""; }; + B88BDAAB702E98FDD084041C /* LegacyMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMigration.swift; sourceTree = ""; }; + BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxRuntime.swift; sourceTree = ""; }; BEC31B48C129D1E076933F45 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - C5C351C1B33D101446ECAAE1 /* ServerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerStore.swift; sourceTree = ""; }; - CAEB9A39277AB28A5B3718E3 /* KeyBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBarView.swift; sourceTree = ""; }; - D65F64A88926274343CB4D23 /* MoriTmux */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MoriTmux; path = ../Packages/MoriTmux; sourceTree = SOURCE_ROOT; }; + C1C4381824F6C5E7604AAF7A /* TmuxSessionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionController.swift; sourceTree = ""; }; + C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Phase3RuntimeTests.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 = ""; }; + DC5CE2DEDF2FDD48461599ED /* SSHRootPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHRootPool.swift; sourceTree = ""; }; + DDA4083A9512502482A6CECA /* RemoteRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRootView.swift; sourceTree = ""; }; E6317A1B56EED0B86D2F4D5D /* MoriRemote.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = MoriRemote.app; sourceTree = BUILT_PRODUCTS_DIR; }; - FEC39E01BAFBE24446B2B9AF /* RegularWidthServerBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularWidthServerBrowserView.swift; sourceTree = ""; }; + E788C063B75F299CBC1D0165 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -69,11 +101,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 85ADFC9BAA17017ECA067C5B /* MoriCore in Frameworks */, - 950C33367217BF06FA564D2B /* MoriTmux in Frameworks */, - 5D0A706E5CC15CA815D2205C /* MoriSSH in Frameworks */, - C7B874D9B7973D33D6D7E5D6 /* MoriTerminal in Frameworks */, - 57768C47189EF8BE03AD3106 /* SwiftTerm 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; }; @@ -88,74 +120,102 @@ path = Resources; sourceTree = ""; }; + 30D66DA26907E44CBF500E44 /* SSH */ = { + isa = PBXGroup; + children = ( + D77CF33F94CEBE45B61807FB /* CitadelSSHTransport.swift */, + 2E4A9E8D51098FB9FE41A085 /* HostTrust.swift */, + 46D3CED1154E19B734CC9AA8 /* SSHAuth.swift */, + 73D1DDD00FB01982F6F52E2B /* SSHPrivateKeyInspector.swift */, + DC5CE2DEDF2FDD48461599ED /* SSHRootPool.swift */, + ); + path = SSH; + sourceTree = ""; + }; 3AA0698F729DC6B09848C13A /* MoriRemote */ = { isa = PBXGroup; children = ( - A41EA73D264E6AFC829AE2AE /* Accessories */, - 51D726D874821968DFE9EA85 /* Models */, + 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 */, - 94A4540E9036A99292A5A06A /* ShellCoordinator.swift */, - 293AE41146DC5F2BB4900EA4 /* TerminalView.swift */, - 977760861C89855B90308D6B /* Theme.swift */, + E788C063B75F299CBC1D0165 /* PrivacyInfo.xcprivacy */, ); path = MoriRemote; sourceTree = ""; }; - 51D726D874821968DFE9EA85 /* Models */ = { + 4951FA280BE48E9B2C0D8D06 /* Ghostty */ = { isa = PBXGroup; children = ( - 493C27506159F574E156F96C /* Server.swift */, - C5C351C1B33D101446ECAAE1 /* ServerStore.swift */, + 7C56BC03B97609DD98C3EF2C /* GhosttyKitRuntime.swift */, + 959028AD3B554BF795BA1E1D /* GhosttyPaneSurface.swift */, + 19D5EB975098390AD7F38424 /* GhosttyTerminalProbe.swift */, + BC12A8D26738364541B655A5 /* GhosttyTmuxRuntime.swift */, ); - path = Models; + path = Ghostty; sourceTree = ""; }; - 7545784AFF05234220C217D0 /* Packages */ = { + 63BE1FDACD0B6E72C3E5E7E5 /* App */ = { isa = PBXGroup; children = ( - 35C6D3B4BCCF583DFF3FE192 /* MoriCore */, - 2B45537B3FC70BA7F912DE66 /* MoriSSH */, - 0D01ED3247C1344CC2910A0C /* MoriTerminal */, - D65F64A88926274343CB4D23 /* MoriTmux */, + 0EBBE7E60EEF6A3363F73F0A /* MoriRemoteDependencies.swift */, + 9B1E077AE28FE61640CCBFBC /* RemoteRootModel.swift */, ); - name = Packages; + path = App; sourceTree = ""; }; - A41EA73D264E6AFC829AE2AE /* Accessories */ = { + 85F3C6650D4ABEA622250660 /* Tmux */ = { isa = PBXGroup; children = ( - 377F0FEA59D35705F0BCD383 /* KeyAction.swift */, - 405D186A2E98097FEB5F37A9 /* KeyBarCustomizeView.swift */, - CAEB9A39277AB28A5B3718E3 /* KeyBarView.swift */, - 3314AE88EB123344D196346D /* TerminalAccessoryBar.swift */, - 879D7ABF80A185D8F5020407 /* TmuxBarView.swift */, + 806F0C378B5C9E2D51B6E1A3 /* AgentMetadataProjector.swift */, + 4890358DA9214454F2A47677 /* DeterministicTmuxControlTransport.swift */, + 21CE86B5FC370573653D3245 /* SSHTmuxControlTransport.swift */, + 6A8E335F20D27CE6353A85E4 /* TmuxControl.swift */, + C1C4381824F6C5E7604AAF7A /* TmuxSessionController.swift */, + D631082FBB8A05D4BA1F801F /* TmuxShellCommand.swift */, ); - path = Accessories; + path = Tmux; + sourceTree = ""; + }; + B063AA8B3750F1CA282E879F /* Domain */ = { + isa = PBXGroup; + children = ( + 210BF0AD6F094B3D96D0A36D /* SavedModels.swift */, + ); + path = Domain; sourceTree = ""; }; B723E13FED944C47F0F5BB37 /* Views */ = { isa = PBXGroup; children = ( - FEC39E01BAFBE24446B2B9AF /* RegularWidthServerBrowserView.swift */, - 93C9BF50025C03B0BCF8E77A /* ServerFormView.swift */, - 6666C37E7773D0C60B3D16BA /* ServerListView.swift */, - 348894A19970D2C348B4A692 /* SidebarContainer.swift */, - 11EBAEEBBFE6A6FF22BFC722 /* TerminalScreen.swift */, - 89FE89B4C66C6973B7997A6B /* TerminalSessionHost.swift */, - A15134BC85B9E6519FF7F2A9 /* TmuxSidebarView.swift */, + DDA4083A9512502482A6CECA /* RemoteRootView.swift */, ); path = Views; sourceTree = ""; }; + BB548A1B5870D127A31579E5 /* Persistence */ = { + isa = PBXGroup; + children = ( + B88BDAAB702E98FDD084041C /* LegacyMigration.swift */, + B05E4FE02E3C3962771154D7 /* Stores.swift */, + ); + path = Persistence; + sourceTree = ""; + }; CD5421D0DDCEBB050BBBD629 = { isa = PBXGroup; children = ( 3AA0698F729DC6B09848C13A /* MoriRemote */, - 7545784AFF05234220C217D0 /* Packages */, + EC1D22246639767E9C4475A3 /* MoriRemoteTests */, + E6A9207E06A0AA3FF84EEAD4 /* Frameworks */, CF688A13389F8A9D4F04A9A0 /* Products */, ); sourceTree = ""; @@ -164,10 +224,32 @@ isa = PBXGroup; children = ( E6317A1B56EED0B86D2F4D5D /* MoriRemote.app */, + 64BCCA48638A9FE1582D7514 /* MoriRemoteTests.xctest */, ); name = Products; sourceTree = ""; }; + E6A9207E06A0AA3FF84EEAD4 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 02AC557BE4951B52EE25ED7B /* GhosttyKit.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; + EC1D22246639767E9C4475A3 /* MoriRemoteTests */ = { + isa = PBXGroup; + children = ( + 46F1405B1589426A03FA7530 /* LegacyMigrationTests.swift */, + 7E8BD2004DFD8216875267B3 /* Phase2TransportTests.swift */, + C489F3E1E08779C85038ED0E /* Phase3RuntimeTests.swift */, + 0B2C6DECBA8C1C75B1A429CD /* Phase4ShellTests.swift */, + 0A0323091B8EC347B211B0AF /* Phase5AgentMetadataTests.swift */, + B402FCCB191BE4C43EE332CF /* SSHTransportTests.swift */, + ); + path = MoriRemoteTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -178,6 +260,7 @@ 9D6A69F5E0C209F46BFBA798 /* Sources */, D55D2784B20EE0BE03FC4ED5 /* Resources */, F7510CFC46875E2AE7CF2B7A /* Frameworks */, + 71A46B35119AD087A2D74C49 /* Embed MoriRemote third-party notices */, ); buildRules = ( ); @@ -185,16 +268,33 @@ ); name = MoriRemote; packageProductDependencies = ( - CB00391E626D4853B15135EF /* MoriCore */, - 8AFD415F38753179608A985F /* MoriTmux */, - E4A46D15917AB2D06DABB5BF /* MoriSSH */, - 35EA6C6FBCAE1CE488930F9F /* MoriTerminal */, - CB7E8BE74D0419BD18CEA5E3 /* SwiftTerm */, + F391794B759D1B5CD2C36000 /* Citadel */, + 6712048F2C2EC6961F582380 /* NIO */, + 82712771B627666368A3F09C /* NIOPosix */, + 2B047F037703450AD7431F98 /* NIOSSH */, ); productName = MoriRemote; productReference = E6317A1B56EED0B86D2F4D5D /* MoriRemote.app */; productType = "com.apple.product-type.application"; }; + B6AEABE71E27F93510CA50A8 /* MoriRemoteTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AC164D6FBF79B9F2E0C42756 /* Build configuration list for PBXNativeTarget "MoriRemoteTests" */; + buildPhases = ( + 76A5C3E337C059514C36B29A /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 47DA555F8AF746D6D8E85EEB /* PBXTargetDependency */, + ); + name = MoriRemoteTests; + packageProductDependencies = ( + ); + productName = MoriRemoteTests; + productReference = 64BCCA48638A9FE1582D7514 /* MoriRemoteTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -220,11 +320,9 @@ mainGroup = CD5421D0DDCEBB050BBBD629; minimizedProjectReferenceProxies = 1; packageReferences = ( - 14A83BF7D6D793F1467946A7 /* XCRemoteSwiftPackageReference "SwiftTerm" */, - 22EC689C4EBC0381BBF9502E /* XCLocalSwiftPackageReference "../Packages/MoriCore" */, - DC4E78377E3F41EF4AB7AFEC /* XCLocalSwiftPackageReference "../Packages/MoriSSH" */, - 63BB9A1CF4A54F50FFC9115F /* XCLocalSwiftPackageReference "../Packages/MoriTerminal" */, - 8A1BCAFD03938FC3421C63BE /* XCLocalSwiftPackageReference "../Packages/MoriTmux" */, + 7C6A572E5B9BA196192B1D40 /* XCRemoteSwiftPackageReference "Citadel" */, + 44DC4C8F745B8FAC8527796A /* XCRemoteSwiftPackageReference "swift-nio" */, + CBA32E44E2A2F728B4317FDB /* XCRemoteSwiftPackageReference "swift-nio-ssh" */, ); preferredProjectObjectVersion = 77; productRefGroup = CF688A13389F8A9D4F04A9A0 /* Products */; @@ -232,6 +330,7 @@ projectRoot = ""; targets = ( 219FEED5B577C565EBA81436 /* MoriRemote */, + B6AEABE71E27F93510CA50A8 /* MoriRemoteTests */, ); }; /* End PBXProject section */ @@ -243,39 +342,88 @@ files = ( 57A0B148D1B8D23CA120481C /* Assets.xcassets in Resources */, 109E4551800EDCA43A760F80 /* Localizable.strings in Resources */, + 7EB9D2C181E73D43B40C9485 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 71A46B35119AD087A2D74C49 /* Embed MoriRemote third-party notices */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Embed MoriRemote third-party notices"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -euo pipefail\ndestination=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\"\nditto \"$SRCROOT/../THIRD_PARTY_NOTICES.md\" \"$destination/THIRD_PARTY_NOTICES.md\"\nrm -rf \"$destination/THIRD_PARTY_LICENSES\"\nditto \"$SRCROOT/../THIRD_PARTY_LICENSES\" \"$destination/THIRD_PARTY_LICENSES\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ + 76A5C3E337C059514C36B29A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + 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 */, + 3207EDDDF0FDBE441D981A8B /* SSHTransportTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9D6A69F5E0C209F46BFBA798 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 593DE6D8BD420A29CCDB02CD /* KeyAction.swift in Sources */, - 7C0AE4FDB467B008BCA2E721 /* KeyBarCustomizeView.swift in Sources */, - 511FAAE001FB06AACCFC7E2C /* KeyBarView.swift in Sources */, + 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 */, - C8B49020D82B4405EE6CFD7C /* RegularWidthServerBrowserView.swift in Sources */, - 97B5FB0C7E80E60AF0D59B0E /* Server.swift in Sources */, - 3E10DC5EF41F3B0DBD7E18E0 /* ServerFormView.swift in Sources */, - 58C6389AD3DF6D861F49D1DE /* ServerListView.swift in Sources */, - 949356F283B52C6F3AFC7B06 /* ServerStore.swift in Sources */, - 6AB5F68B40467923E1395F97 /* ShellCoordinator.swift in Sources */, - F005EC1ECFC968DF06655D7E /* SidebarContainer.swift in Sources */, - F8BC0A57AF5F0A266F023AB0 /* TerminalAccessoryBar.swift in Sources */, - 5ACA156F8A500931E29BAD3E /* TerminalScreen.swift in Sources */, - F49D0FFF75E55EA9446E6816 /* TerminalSessionHost.swift in Sources */, - 96C8DDDEA3B929ED016AF50D /* TerminalView.swift in Sources */, - 84BCE7CB7E779A71350C7FB7 /* Theme.swift in Sources */, - D2F9125D254E9980272FA1F7 /* TmuxBarView.swift in Sources */, - 229F7BB580720A05A8DFF1AE /* TmuxSidebarView.swift in Sources */, + 30694D81E01EF77122136322 /* MoriRemoteDependencies.swift in Sources */, + EDD8C4E66770445F478F5BA5 /* RemoteRootModel.swift in Sources */, + 6C61BF2005608B1366F0CE31 /* RemoteRootView.swift in Sources */, + 8A1DC8FFA1F8B93734D4E0E3 /* SSHAuth.swift in Sources */, + 68C87C51C7B1430199E64AAC /* SSHPrivateKeyInspector.swift in Sources */, + 553D7FA2654275C5B609DB67 /* SSHRootPool.swift in Sources */, + 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; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 47DA555F8AF746D6D8E85EEB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 219FEED5B577C565EBA81436 /* MoriRemote */; + targetProxy = F94EA73662EAB769336ACE5F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 9A1E86AC034B64B37F68A846 /* Localizable.strings */ = { isa = PBXVariantGroup; @@ -289,6 +437,25 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 1BBAE16F791C607971EBAA18 /* 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", + ); + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MoriRemote.app/MoriRemote"; + }; + name = Release; + }; 69426152C9AAF1A96AC6E225 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -298,9 +465,13 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = "${CURRENT_PROJECT_VERSION:1}"; + 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; @@ -308,7 +479,12 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "${MARKETING_VERSION:0.1.0}"; + MARKETING_VERSION = 0.3.5; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-Wl,-u,_ghostty_tmux_client_config_new", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.vaayne.mori-remote"; PRODUCT_NAME = MoriRemote; PROVISIONING_PROFILE_SPECIFIER = "MoriRemote App Store"; @@ -375,6 +551,25 @@ }; name = Release; }; + B92CA5DD46DFAC00DB97415E /* 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", + ); + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MoriRemote.app/MoriRemote"; + }; + name = Debug; + }; C7934FFE7BF74048CAD0AB5D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -447,9 +642,13 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = "${CURRENT_PROJECT_VERSION:1}"; + 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; @@ -457,7 +656,12 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = "${MARKETING_VERSION:0.1.0}"; + MARKETING_VERSION = 0.3.5; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-Wl,-u,_ghostty_tmux_client_config_new", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.vaayne.mori-remote"; PRODUCT_NAME = MoriRemote; SDKROOT = iphoneos; @@ -488,59 +692,64 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + AC164D6FBF79B9F2E0C42756 /* Build configuration list for PBXNativeTarget "MoriRemoteTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B92CA5DD46DFAC00DB97415E /* Debug */, + 1BBAE16F791C607971EBAA18 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - 22EC689C4EBC0381BBF9502E /* XCLocalSwiftPackageReference "../Packages/MoriCore" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../Packages/MoriCore; - }; - 63BB9A1CF4A54F50FFC9115F /* XCLocalSwiftPackageReference "../Packages/MoriTerminal" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../Packages/MoriTerminal; - }; - 8A1BCAFD03938FC3421C63BE /* XCLocalSwiftPackageReference "../Packages/MoriTmux" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../Packages/MoriTmux; +/* Begin XCRemoteSwiftPackageReference section */ + 44DC4C8F745B8FAC8527796A /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = exactVersion; + version = 2.97.1; + }; }; - DC4E78377E3F41EF4AB7AFEC /* XCLocalSwiftPackageReference "../Packages/MoriSSH" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../Packages/MoriSSH; + 7C6A572E5B9BA196192B1D40 /* XCRemoteSwiftPackageReference "Citadel" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/h3nock/Citadel.git"; + requirement = { + kind = revision; + revision = 1d0eadd81d0a521b00ede6663c8b3301f5fc252e; + }; }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 14A83BF7D6D793F1467946A7 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { + CBA32E44E2A2F728B4317FDB /* XCRemoteSwiftPackageReference "swift-nio-ssh" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; + repositoryURL = "https://github.com/h3nock/swift-nio-ssh.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.13.0; + kind = revision; + revision = 7588777b8f6439efa1a33117f86cb2729abd864c; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 35EA6C6FBCAE1CE488930F9F /* MoriTerminal */ = { - isa = XCSwiftPackageProductDependency; - productName = MoriTerminal; - }; - 8AFD415F38753179608A985F /* MoriTmux */ = { + 2B047F037703450AD7431F98 /* NIOSSH */ = { isa = XCSwiftPackageProductDependency; - productName = MoriTmux; + package = CBA32E44E2A2F728B4317FDB /* XCRemoteSwiftPackageReference "swift-nio-ssh" */; + productName = NIOSSH; }; - CB00391E626D4853B15135EF /* MoriCore */ = { + 6712048F2C2EC6961F582380 /* NIO */ = { isa = XCSwiftPackageProductDependency; - productName = MoriCore; + package = 44DC4C8F745B8FAC8527796A /* XCRemoteSwiftPackageReference "swift-nio" */; + productName = NIO; }; - CB7E8BE74D0419BD18CEA5E3 /* SwiftTerm */ = { + 82712771B627666368A3F09C /* NIOPosix */ = { isa = XCSwiftPackageProductDependency; - package = 14A83BF7D6D793F1467946A7 /* XCRemoteSwiftPackageReference "SwiftTerm" */; - productName = SwiftTerm; + package = 44DC4C8F745B8FAC8527796A /* XCRemoteSwiftPackageReference "swift-nio" */; + productName = NIOPosix; }; - E4A46D15917AB2D06DABB5BF /* MoriSSH */ = { + F391794B759D1B5CD2C36000 /* Citadel */ = { isa = XCSwiftPackageProductDependency; - productName = MoriSSH; + package = 7C6A572E5B9BA196192B1D40 /* XCRemoteSwiftPackageReference "Citadel" */; + productName = Citadel; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/MoriRemote/MoriRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MoriRemote/MoriRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7304fbdf..d0fcdbe0 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/MoriRemote/MoriRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,21 @@ { - "originHash" : "f62bb908e2cd6f3bd56901c05b0610de8d021e996cc0a4da7d3d964c2d4be226", + "originHash" : "d2c468b19049b912dff0b54f1d822a2678c673ee6a73143f330b964f7c5645d8", "pins" : [ { - "identity" : "swift-argument-parser", + "identity" : "bigint", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/attaswift/BigInt.git", "state" : { - "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", - "version" : "1.7.1" + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "citadel", + "kind" : "remoteSourceControl", + "location" : "https://github.com/h3nock/Citadel.git", + "state" : { + "revision" : "1d0eadd81d0a521b00ede6663c8b3301f5fc252e" } }, { @@ -46,6 +54,15 @@ "version" : "3.15.1" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", @@ -58,10 +75,9 @@ { "identity" : "swift-nio-ssh", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssh.git", + "location" : "https://github.com/h3nock/swift-nio-ssh.git", "state" : { - "revision" : "8f33cac67309a13aecc0a4d95044543549b20ffb", - "version" : "0.12.0" + "revision" : "7588777b8f6439efa1a33117f86cb2729abd864c" } }, { @@ -72,15 +88,6 @@ "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", "version" : "1.6.4" } - }, - { - "identity" : "swiftterm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/migueldeicaza/SwiftTerm.git", - "state" : { - "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", - "version" : "1.13.0" - } } ], "version" : 3 diff --git a/MoriRemote/MoriRemote.xcodeproj/xcshareddata/xcschemes/MoriRemote.xcscheme b/MoriRemote/MoriRemote.xcodeproj/xcshareddata/xcschemes/MoriRemote.xcscheme index 4e510793..2009d7d9 100644 --- a/MoriRemote/MoriRemote.xcodeproj/xcshareddata/xcschemes/MoriRemote.xcscheme +++ b/MoriRemote/MoriRemote.xcodeproj/xcshareddata/xcschemes/MoriRemote.xcscheme @@ -39,7 +39,20 @@ + + + + + + - case semicolon // ; - case singleQ // ' - case doubleQ // " - case colon // : - - // Navigation - case left - case down - case up - case right - case home - case end - case pageUp - case pageDown - - // Function keys - case f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12 - - // Tmux shortcuts — mirrors Mori macOS menu actions - case tmuxPrefix // Ctrl+B (raw prefix) - case tmuxNewTab // Ctrl+B c — new window/tab - case tmuxClosePane // Ctrl+B x — close pane (last pane closes tab) - case tmuxNextTab // Ctrl+B n — next window - case tmuxPrevTab // Ctrl+B p — previous window - case tmuxSplitH // Ctrl+B % — split right - case tmuxSplitV // Ctrl+B " — split down - case tmuxNextPane // Ctrl+B o — next pane - case tmuxPrevPane // Ctrl+B ; — previous pane - case tmuxZoom // Ctrl+B z — toggle pane zoom - case tmuxDetach // Ctrl+B d — detach session - - // Special - case divider // visual separator, not a real key - - // MARK: - Display - - var label: String { - switch self { - case .esc: return "esc" - case .ctrl: return "ctrl" - case .alt: return "alt" - case .tab: return "tab" - case .tilde: return "~" - case .pipe: return "|" - case .slash: return "/" - case .dash: return "-" - case .underscore: return "_" - case .equals: return "=" - case .backtick: return "`" - case .backslash: return "\\" - case .bracketL: return "[" - case .bracketR: return "]" - case .braceL: return "{" - case .braceR: return "}" - case .angleL: return "<" - case .angleR: return ">" - case .semicolon: return ";" - case .singleQ: return "'" - case .doubleQ: return "\"" - case .colon: return ":" - case .left: return "←" - case .down: return "↓" - case .up: return "↑" - case .right: return "→" - case .home: return "Home" - case .end: return "End" - case .pageUp: return "PgUp" - case .pageDown: return "PgDn" - case .f1: return "F1" - case .f2: return "F2" - case .f3: return "F3" - case .f4: return "F4" - case .f5: return "F5" - case .f6: return "F6" - case .f7: return "F7" - case .f8: return "F8" - case .f9: return "F9" - case .f10: return "F10" - case .f11: return "F11" - case .f12: return "F12" - case .tmuxPrefix: return "C-b" - case .tmuxNewTab: return "+tab" - case .tmuxClosePane: return "close" - case .tmuxNextTab: return "tab›" - case .tmuxPrevTab: return "‹tab" - case .tmuxSplitH: return "split→" - case .tmuxSplitV: return "split↓" - case .tmuxNextPane: return "pane›" - case .tmuxPrevPane: return "‹pane" - case .tmuxZoom: return "zoom" - case .tmuxDetach: return "detach" - case .divider: return "" - } - } - - /// SF Symbol name, if the key uses an icon instead of text. - var iconName: String? { - switch self { - case .left: return "arrow.left" - case .down: return "arrow.down" - case .up: return "arrow.up" - case .right: return "arrow.right" - default: return nil - } - } - - /// Whether this key is a modifier/special key (darker background). - var isSpecial: Bool { - switch self { - case .esc, .ctrl, .alt, .tab: return true - default: return false - } - } - - /// Whether this key is a tmux shortcut (accent-tinted). - var isTmux: Bool { - switch self { - case .tmuxPrefix, .tmuxNewTab, .tmuxClosePane, .tmuxNextTab, .tmuxPrevTab, - .tmuxSplitH, .tmuxSplitV, .tmuxNextPane, .tmuxPrevPane, .tmuxZoom, .tmuxDetach: - return true - default: return false - } - } - - /// Whether this key supports auto-repeat on long press. - var supportsAutoRepeat: Bool { - switch self { - case .left, .down, .up, .right: return true - default: return false - } - } - - /// Whether this is a toggle (sticky) key. - var isToggle: Bool { - self == .ctrl || self == .alt - } - - // MARK: - Execution - - /// Send this key action to the terminal view. - /// Returns `true` if the action was fully handled (non-toggle keys). - /// Returns `false` for toggle keys that just flip state. - @MainActor @discardableResult - func execute(on terminalView: SwiftTerm.TerminalView) -> Bool { - let terminal = terminalView.getTerminal() - let appCursor = terminal.applicationCursor - - switch self { - // Modifiers - case .esc: - terminalView.send(EscapeSequences.cmdEsc) - case .ctrl: - terminalView.controlModifier.toggle() - return false - case .alt: - // Send ESC prefix for the next keystroke (meta key behavior) - terminalView.send([0x1b]) - case .tab: - terminalView.send(EscapeSequences.cmdTab) - - // Symbols — send as text - case .tilde: terminalView.send(txt: "~") - case .pipe: terminalView.send(txt: "|") - case .slash: terminalView.send(txt: "/") - case .dash: terminalView.send(txt: "-") - case .underscore: terminalView.send(txt: "_") - case .equals: terminalView.send(txt: "=") - case .backtick: terminalView.send(txt: "`") - case .backslash: terminalView.send(txt: "\\") - case .bracketL: terminalView.send(txt: "[") - case .bracketR: terminalView.send(txt: "]") - case .braceL: terminalView.send(txt: "{") - case .braceR: terminalView.send(txt: "}") - case .angleL: terminalView.send(txt: "<") - case .angleR: terminalView.send(txt: ">") - case .semicolon: terminalView.send(txt: ";") - case .singleQ: terminalView.send(txt: "'") - case .doubleQ: terminalView.send(txt: "\"") - case .colon: terminalView.send(txt: ":") - - // Navigation - case .left: - terminalView.send(appCursor ? EscapeSequences.moveLeftApp : EscapeSequences.moveLeftNormal) - case .down: - terminalView.send(appCursor ? EscapeSequences.moveDownApp : EscapeSequences.moveDownNormal) - case .up: - terminalView.send(appCursor ? EscapeSequences.moveUpApp : EscapeSequences.moveUpNormal) - case .right: - terminalView.send(appCursor ? EscapeSequences.moveRightApp : EscapeSequences.moveRightNormal) - case .home: - terminalView.send(appCursor ? EscapeSequences.moveHomeApp : EscapeSequences.moveHomeNormal) - case .end: - terminalView.send(appCursor ? EscapeSequences.moveEndApp : EscapeSequences.moveEndNormal) - case .pageUp: - terminalView.send(EscapeSequences.cmdPageUp) - case .pageDown: - terminalView.send(EscapeSequences.cmdPageDown) - - // Function keys - case .f1: terminalView.send(EscapeSequences.cmdF[0]) - case .f2: terminalView.send(EscapeSequences.cmdF[1]) - case .f3: terminalView.send(EscapeSequences.cmdF[2]) - case .f4: terminalView.send(EscapeSequences.cmdF[3]) - case .f5: terminalView.send(EscapeSequences.cmdF[4]) - case .f6: terminalView.send(EscapeSequences.cmdF[5]) - case .f7: terminalView.send(EscapeSequences.cmdF[6]) - case .f8: terminalView.send(EscapeSequences.cmdF[7]) - case .f9: terminalView.send(EscapeSequences.cmdF[8]) - case .f10: terminalView.send(EscapeSequences.cmdF[9]) - case .f11: terminalView.send(EscapeSequences.cmdF[10]) - case .f12: terminalView.send(EscapeSequences.cmdF[11]) - - // Tmux key actions — these are available in the customizable key bar. - // The tmux popup menu uses real CLI commands instead (via TmuxCommand). - // These remain for users who add individual tmux keys to their bar. - case .tmuxPrefix: terminalView.send([0x02]) - case .tmuxNewTab: terminalView.send([0x02]); terminalView.send(txt: "c") - case .tmuxClosePane: terminalView.send([0x02]); terminalView.send(txt: "x") - case .tmuxNextTab: terminalView.send([0x02]); terminalView.send(txt: "n") - case .tmuxPrevTab: terminalView.send([0x02]); terminalView.send(txt: "p") - case .tmuxSplitH: terminalView.send([0x02]); terminalView.send(txt: "%") - case .tmuxSplitV: terminalView.send([0x02]); terminalView.send(txt: "\"") - case .tmuxNextPane: terminalView.send([0x02]); terminalView.send(txt: "o") - case .tmuxPrevPane: terminalView.send([0x02]); terminalView.send(txt: ";") - case .tmuxZoom: terminalView.send([0x02]); terminalView.send(txt: "z") - case .tmuxDetach: terminalView.send([0x02]); terminalView.send(txt: "d") - - case .divider: - break - } - return true - } - - // MARK: - Categories (for palette UI) - - enum Category: String, CaseIterable { - case modifiers = "Modifiers" - case symbols = "Symbols" - case navigation = "Navigation" - case functionKeys = "Function Keys" - case tmux = "Tmux Shortcuts" - } - - var category: Category { - switch self { - case .esc, .ctrl, .alt, .tab: - return .modifiers - case .tilde, .pipe, .slash, .dash, .underscore, .equals, .backtick, - .backslash, .bracketL, .bracketR, .braceL, .braceR, - .angleL, .angleR, .semicolon, .singleQ, .doubleQ, .colon: - return .symbols - case .left, .down, .up, .right, .home, .end, .pageUp, .pageDown: - return .navigation - case .f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12: - return .functionKeys - case .tmuxPrefix, .tmuxNewTab, .tmuxClosePane, .tmuxNextTab, .tmuxPrevTab, - .tmuxSplitH, .tmuxSplitV, .tmuxNextPane, .tmuxPrevPane, .tmuxZoom, .tmuxDetach: - return .tmux - case .divider: - return .symbols - } - } - - static func actions(for category: Category) -> [KeyAction] { - allCases.filter { $0.category == category && $0 != .divider } - } - - // MARK: - Default Layout - - // `.tmuxMenu` is a virtual key — it shows a popup with all tmux actions. - // It's not a real KeyAction case but handled specially by KeyBarView. - static let tmuxMenuPlaceholder = KeyAction.tmuxPrefix - - static let defaultLayout: [KeyAction] = [ - .esc, .ctrl, .tab, - .divider, - .tilde, .pipe, .slash, .dash, - .divider, - .left, .down, .up, .right, - ] - -} - -// MARK: - Persistence - -enum KeyBarLayout { - private static let storageKey = "keybar_layout" - private static let versionKey = "keybar_version" - /// Bump this to force reset to new defaults on app update. - private static let currentVersion = 3 - - static func load() -> [KeyAction] { - let savedVersion = UserDefaults.standard.integer(forKey: versionKey) - if savedVersion < currentVersion { - // New version — reset to updated defaults - UserDefaults.standard.removeObject(forKey: storageKey) - UserDefaults.standard.set(currentVersion, forKey: versionKey) - return KeyAction.defaultLayout - } - guard let data = UserDefaults.standard.data(forKey: storageKey), - let actions = try? JSONDecoder().decode([KeyAction].self, from: data) - else { - return KeyAction.defaultLayout - } - return actions - } - - static func save(_ actions: [KeyAction]) { - guard let data = try? JSONEncoder().encode(actions) else { return } - UserDefaults.standard.set(data, forKey: storageKey) - } - - static func reset() { - UserDefaults.standard.removeObject(forKey: storageKey) - } -} - -#endif diff --git a/MoriRemote/MoriRemote/Accessories/KeyBarCustomizeView.swift b/MoriRemote/MoriRemote/Accessories/KeyBarCustomizeView.swift deleted file mode 100644 index 3edea1b9..00000000 --- a/MoriRemote/MoriRemote/Accessories/KeyBarCustomizeView.swift +++ /dev/null @@ -1,207 +0,0 @@ -#if os(iOS) -import SwiftUI - -/// Bottom sheet for customizing the keyboard accessory key bar. -struct KeyBarCustomizeView: View { - let keyBar: KeyBarView - @Environment(\.dismiss) private var dismiss - - @State private var layout: [KeyAction] = [] - - var body: some View { - NavigationStack { - List { - Section { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "Customize Keys")) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(String(localized: "Pick the keys you want in the terminal accessory bar. Reorder active keys to keep your most-used actions close.")) - .font(.system(size: 13)) - .foregroundStyle(Theme.textSecondary) - } - .listRowBackground(Color.clear) - } - - Section(String(localized: "Active Keys")) { - let activeKeys = layout.filter { $0 != .divider } - if activeKeys.isEmpty { - Text(String(localized: "No keys added")) - .foregroundStyle(Theme.textSecondary) - .font(.system(size: 13)) - } else { - ForEach(activeKeys, id: \.self) { action in - activeKeyRow(action) - } - .onMove { from, to in - var keys = layout.filter { $0 != .divider } - keys.move(fromOffsets: from, toOffset: to) - applyLayout(keys) - } - .onDelete { offsets in - var keys = layout.filter { $0 != .divider } - keys.remove(atOffsets: offsets) - applyLayout(keys) - } - } - } - - ForEach(KeyAction.Category.allCases, id: \.self) { category in - Section(category.localizedTitle) { - let actions = KeyAction.actions(for: category) - ForEach(actions, id: \.self) { action in - keyToggleRow(action) - } - } - } - } - .scrollContentBackground(.hidden) - .background(Theme.bg) - .navigationTitle(String(localized: "Customize Keys")) - .navigationBarTitleDisplayMode(.inline) - .toolbarColorScheme(.dark, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - EditButton() - } - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "Reset")) { - applyLayout(KeyAction.defaultLayout) - } - .foregroundStyle(Theme.destructive) - } - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "Done")) { dismiss() } - .fontWeight(.semibold) - } - } - } - .preferredColorScheme(.dark) - .onAppear { - layout = keyBar.layout - } - } - - private func applyLayout(_ newLayout: [KeyAction]) { - layout = newLayout - keyBar.layout = newLayout - KeyBarLayout.save(newLayout) - } - - private func activeKeyRow(_ action: KeyAction) -> some View { - HStack(spacing: 12) { - keyPreview(action, width: 52, height: 28) - - Text(action.localizedDescription) - .font(.system(size: 13)) - .foregroundStyle(Theme.textSecondary) - - Spacer() - } - .padding(.vertical, 2) - .listRowBackground(Theme.mutedSurface) - } - - private func keyToggleRow(_ action: KeyAction) -> some View { - let isInBar = layout.contains(action) - return Button { - var newLayout = layout.filter { $0 != .divider } - if isInBar { - newLayout.removeAll { $0 == action } - } else { - newLayout.append(action) - } - applyLayout(newLayout) - } label: { - HStack(spacing: 12) { - keyPreview(action, width: 62, height: 30) - - Text(action.localizedDescription) - .font(.system(size: 13)) - .foregroundStyle(Theme.textSecondary) - - Spacer() - - Image(systemName: isInBar ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isInBar ? Theme.accent : Theme.textTertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .listRowBackground(Theme.mutedSurface) - } - - private func keyPreview(_ action: KeyAction, width: CGFloat, height: CGFloat) -> some View { - Text(action.label) - .font(.system(size: action.isSpecial ? 10 : 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(keyColor(action)) - .frame(width: width, height: height) - .background(keyBackground(action), in: RoundedRectangle(cornerRadius: 7)) - .overlay( - RoundedRectangle(cornerRadius: 7) - .strokeBorder(keyBorder(action), lineWidth: 1) - ) - } - - private func keyColor(_ action: KeyAction) -> Color { - if action.isTmux { return Theme.accent } - if action.isSpecial { return Theme.textSecondary } - return Theme.textPrimary - } - - private func keyBackground(_ action: KeyAction) -> Color { - if action.isTmux { return Theme.accentSoft } - if action.isSpecial { return Theme.elevatedBg } - return Theme.mutedSurface - } - - private func keyBorder(_ action: KeyAction) -> Color { - if action.isTmux { return Theme.accentBorder } - return Theme.cardBorder - } -} - -private extension KeyAction.Category { - var localizedTitle: LocalizedStringKey { - switch self { - case .modifiers: return "Modifiers" - case .symbols: return "Symbols" - case .navigation: return "Navigation" - case .functionKeys: return "Function Keys" - case .tmux: return "Tmux Shortcuts" - } - } -} - -private extension KeyAction { - var localizedDescription: String { - switch self { - case .esc: return String(localized: "Escape key") - case .ctrl: return String(localized: "Control modifier (sticky)") - case .alt: return String(localized: "Alt/Meta modifier") - case .tab: return String(localized: "Tab key") - case .tmuxPrefix: return String(localized: "Tmux prefix (Ctrl+B)") - case .tmuxNewTab: return String(localized: "New tab (⌘T)") - case .tmuxClosePane: return String(localized: "Close pane (⌘W)") - case .tmuxNextTab: return String(localized: "Next tab (⌘⇧])") - case .tmuxPrevTab: return String(localized: "Previous tab (⌘⇧[)") - case .tmuxSplitH: return String(localized: "Split right (⌘D)") - case .tmuxSplitV: return String(localized: "Split down (⌘⇧D)") - case .tmuxNextPane: return String(localized: "Next pane (⌘])") - case .tmuxPrevPane: return String(localized: "Previous pane (⌘[)") - case .tmuxZoom: return String(localized: "Toggle zoom (⌘⇧↩)") - case .tmuxDetach: return String(localized: "Detach session") - case .left: return String(localized: "Arrow left (auto-repeat)") - case .down: return String(localized: "Arrow down (auto-repeat)") - case .up: return String(localized: "Arrow up (auto-repeat)") - case .right: return String(localized: "Arrow right (auto-repeat)") - case .home: return String(localized: "Home key") - case .end: return String(localized: "End key") - case .pageUp: return String(localized: "Page up") - case .pageDown: return String(localized: "Page down") - default: return String(localized: "Send key") + " ‘\(label)’" - } - } -} -#endif diff --git a/MoriRemote/MoriRemote/Accessories/KeyBarView.swift b/MoriRemote/MoriRemote/Accessories/KeyBarView.swift deleted file mode 100644 index 47632601..00000000 --- a/MoriRemote/MoriRemote/Accessories/KeyBarView.swift +++ /dev/null @@ -1,664 +0,0 @@ -#if os(iOS) -import SwiftTerm -import UIKit - -/// Customizable horizontal key bar for terminal accessory actions. -@MainActor -final class KeyBarView: UIView { - - weak var terminalView: SwiftTerm.TerminalView? { - didSet { resetSelectionMode() } - } - var onBackTapped: (() -> Void)? - var onSidebarTapped: (() -> Void)? - var onCustomizeTapped: (() -> Void)? - var onTmuxMenuTapped: (() -> Void)? - var onTmuxAction: ((TmuxCommand) -> Void)? - - private let scrollView = UIScrollView() - private let stackView = UIStackView() - private var keyButtons: [UIView] = [] - - var layout: [KeyAction] = KeyBarLayout.load() { - didSet { rebuildKeys() } - } - - private var ctrlActive = false - private var selectionModeActive = false - private weak var selectionButton: UIButton? - private var repeatingButton: UIButton? - private var repeatAction: KeyAction? - private var repeatTask: Task? - private var repeatTimer: Timer? - - private let barBg = UIColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1) - private let keyBg = UIColor.white.withAlphaComponent(0.05) - private let keySpecialBg = UIColor.white.withAlphaComponent(0.035) - private let keyActiveBg = UIColor.tintColor.withAlphaComponent(0.16) - private let accentColor = UIColor.tintColor - private let textColor = UIColor.white.withAlphaComponent(0.96) - private let textDim = UIColor.white.withAlphaComponent(0.62) - private let dividerColor = UIColor.white.withAlphaComponent(0.08) - private let tmuxKeyBg = UIColor.tintColor.withAlphaComponent(0.12) - private let tmuxBorder = UIColor.tintColor.withAlphaComponent(0.28) - private let keyBorder = UIColor.white.withAlphaComponent(0.08) - - private let fadeView = UIView() - private let fadeGradient = CAGradientLayer() - - override init(frame: CGRect) { - super.init(frame: frame) - setup() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - deinit { - MainActor.assumeIsolated { - cancelAutoRepeat() - NotificationCenter.default.removeObserver(self) - } - } - - private func setup() { - backgroundColor = barBg - - // The dismiss-keyboard button lives outside the scroll view, pinned at - // the far left so it stays one tap away at any scroll position — - // previously it sat at the scrollable row's right end, forcing a swipe - // to the very end just to put the keyboard away. - let dismissButton = makeKeyboardDismissButton() - addSubview(dismissButton) - - let dismissDivider = UIView() - dismissDivider.backgroundColor = dividerColor - dismissDivider.translatesAutoresizingMaskIntoConstraints = false - addSubview(dismissDivider) - - scrollView.showsHorizontalScrollIndicator = false - scrollView.alwaysBounceHorizontal = true - scrollView.delaysContentTouches = false - scrollView.delegate = self - scrollView.contentInsetAdjustmentBehavior = .never - scrollView.translatesAutoresizingMaskIntoConstraints = false - addSubview(scrollView) - - stackView.axis = .horizontal - stackView.spacing = 4 - stackView.alignment = .center - stackView.translatesAutoresizingMaskIntoConstraints = false - scrollView.addSubview(stackView) - - fadeView.isUserInteractionEnabled = false - fadeView.translatesAutoresizingMaskIntoConstraints = false - fadeGradient.colors = [barBg.withAlphaComponent(0).cgColor, barBg.cgColor] - fadeGradient.startPoint = CGPoint(x: 0, y: 0.5) - fadeGradient.endPoint = CGPoint(x: 1, y: 0.5) - fadeView.layer.addSublayer(fadeGradient) - addSubview(fadeView) - - NSLayoutConstraint.activate([ - dismissButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), - dismissButton.centerYAnchor.constraint(equalTo: centerYAnchor), - - dismissDivider.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 6), - dismissDivider.centerYAnchor.constraint(equalTo: centerYAnchor), - dismissDivider.widthAnchor.constraint(equalToConstant: 1), - dismissDivider.heightAnchor.constraint(equalToConstant: 20), - - scrollView.topAnchor.constraint(equalTo: topAnchor), - scrollView.leadingAnchor.constraint(equalTo: dismissDivider.trailingAnchor), - scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - - stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 6), - stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -6), - stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), - - fadeView.trailingAnchor.constraint(equalTo: trailingAnchor), - fadeView.topAnchor.constraint(equalTo: topAnchor), - fadeView.bottomAnchor.constraint(equalTo: bottomAnchor), - fadeView.widthAnchor.constraint(equalToConstant: 28), - ]) - - NotificationCenter.default.addObserver( - self, - selector: #selector(ctrlModifierReset), - name: .terminalViewControlModifierReset, - object: nil - ) - - rebuildKeys() - } - - @objc private func ctrlModifierReset() { - ctrlActive = false - updateCtrlButton() - } - - private func rebuildKeys() { - stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - keyButtons = [] - - // Leading cluster: low-frequency app chrome folds into one overflow - // menu; the two high-frequency context actions (sessions, tmux) stay - // pinned so they're always one tap away. - let menu = makeMenuButton() - stackView.addArrangedSubview(menu) - keyButtons.append(menu) - - let sidebar = makeSidebarButton() - stackView.addArrangedSubview(sidebar) - keyButtons.append(sidebar) - - let tmux = makeTmuxMenuButton() - stackView.addArrangedSubview(tmux) - keyButtons.append(tmux) - - let div0 = makeDivider() - stackView.addArrangedSubview(div0) - keyButtons.append(div0) - - for action in layout { - if action == .divider { - let div = makeDivider() - stackView.addArrangedSubview(div) - keyButtons.append(div) - } else if action.isTmux { - continue - } else { - let btn = makeKeyButton(for: action) - stackView.addArrangedSubview(btn) - keyButtons.append(btn) - } - } - - let divEnd = makeDivider() - stackView.addArrangedSubview(divEnd) - keyButtons.append(divEnd) - - let selectionButton = makeSelectionModeButton() - stackView.addArrangedSubview(selectionButton) - keyButtons.append(selectionButton) - self.selectionButton = selectionButton - } - - private func makeDivider() -> UIView { - let view = UIView() - view.backgroundColor = dividerColor - view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - view.widthAnchor.constraint(equalToConstant: 1), - view.heightAnchor.constraint(equalToConstant: 20), - ]) - return view - } - - private func configurePanScrolling(_ button: KeyBarButton) { - button.onHorizontalPan = { [weak self] deltaX in - guard let self else { return } - let newOffset = self.scrollView.contentOffset.x + deltaX - let maxOffset = max(0, self.scrollView.contentSize.width - self.scrollView.bounds.width) - self.scrollView.contentOffset.x = max(0, min(newOffset, maxOffset)) - self.updateFadeVisibility() - } - button.onPanBegan = { [weak self] sender in - self?.cancelAutoRepeat() - if let action = self?.action(for: sender), (!action.isToggle || self?.ctrlActive != true) { - self?.applyStyle(to: sender, action: action, active: false) - } - } - } - - private func makeKeyButton(for action: KeyAction) -> UIButton { - let button = KeyBarButton() - button.tag = action.hashValue - button.layer.cornerRadius = 7 - button.layer.borderWidth = 1 - button.layer.borderColor = keyBorder.cgColor - button.clipsToBounds = true - - let isArrow = action.iconName != nil - let minWidth: CGFloat = isArrow ? 28 : 34 - button.translatesAutoresizingMaskIntoConstraints = false - button.horizontalContentPadding = 7 - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 30), - button.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth), - ]) - - if let iconName = action.iconName { - let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) - let image = UIImage(systemName: iconName, withConfiguration: config) - button.setImage(image, for: .normal) - // Same image for .highlighted so UIKit doesn't dim the icon on - // touch (touchDown already applies the active style). - button.setImage(image, for: .highlighted) - button.tintColor = textColor - } else { - button.setTitle(action.label, for: .normal) - button.titleLabel?.font = action.isSpecial || action.isTmux - ? .monospacedSystemFont(ofSize: 10, weight: .semibold) - : .monospacedSystemFont(ofSize: 11, weight: .medium) - } - - applyStyle(to: button, action: action, active: false) - objc_setAssociatedObject(button, &KeyBarView.actionKey, action, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - - button.addTarget(self, action: #selector(buttonTouchDown(_:)), for: .touchDown) - button.addTarget(self, action: #selector(buttonTouchUpInside(_:)), for: .touchUpInside) - button.addTarget(self, action: #selector(buttonTouchUpOutside(_:)), for: .touchUpOutside) - button.addTarget(self, action: #selector(buttonTouchUpOutside(_:)), for: .touchCancel) - - configurePanScrolling(button) - - if action.supportsAutoRepeat { - let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) - longPress.minimumPressDuration = 0.45 - longPress.cancelsTouchesInView = false - button.addGestureRecognizer(longPress) - } - - return button - } - - private func applyStyle(to button: UIButton, action: KeyAction, active: Bool) { - if action.isTmux { - button.backgroundColor = active ? accentColor.withAlphaComponent(0.18) : tmuxKeyBg - button.setTitleColor(accentColor, for: .normal) - button.tintColor = accentColor - button.layer.borderColor = tmuxBorder.cgColor - } else if action.isSpecial { - button.backgroundColor = active ? keyActiveBg : keySpecialBg - button.setTitleColor(active ? accentColor : textDim, for: .normal) - button.tintColor = active ? accentColor : textDim - button.layer.borderColor = (active ? tmuxBorder : keyBorder).cgColor - } else { - button.backgroundColor = active ? keyActiveBg : keyBg - button.setTitleColor(textColor, for: .normal) - button.tintColor = textColor - button.layer.borderColor = (active ? tmuxBorder : keyBorder).cgColor - } - } - - private static var actionKey: UInt8 = 0 - - private func action(for button: UIButton) -> KeyAction? { - objc_getAssociatedObject(button, &KeyBarView.actionKey) as? KeyAction - } - - /// Overflow menu for low-frequency app chrome (switch host, customize keys, - /// detach) — kept out of the typing row so it can't be fat-fingered while - /// reaching for `ctrl`/`esc`. - private func makeMenuButton() -> UIButton { - let button = KeyBarButton() - configurePanScrolling(button) - let config = UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold) - button.setImage(UIImage(systemName: "ellipsis.circle", withConfiguration: config), for: .normal) - button.tintColor = textDim - button.backgroundColor = keySpecialBg - button.layer.cornerRadius = 7 - button.layer.borderWidth = 1 - button.layer.borderColor = keyBorder.cgColor - button.clipsToBounds = true - button.translatesAutoresizingMaskIntoConstraints = false - button.showsMenuAsPrimaryAction = true - button.menu = makeChromeMenu() - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 30), - button.widthAnchor.constraint(equalToConstant: 34), - ]) - return button - } - - private func makeChromeMenu() -> UIMenu { - let switchHost = UIAction( - title: String(localized: "Switch Host"), - image: UIImage(systemName: "arrow.left.arrow.right") - ) { [weak self] _ in - self?.dismissKeyboardForDeferredUITransition() - DispatchQueue.main.async { self?.onBackTapped?() } - } - let customize = UIAction( - title: String(localized: "Customize Keys"), - image: UIImage(systemName: "slider.horizontal.3") - ) { [weak self] _ in - self?.dismissKeyboardForDeferredUITransition() - DispatchQueue.main.async { self?.onCustomizeTapped?() } - } - let detach = UIAction( - title: String(localized: "Detach"), - image: UIImage(systemName: "rectangle.portrait.and.arrow.right"), - attributes: .destructive - ) { [weak self] _ in - self?.dismissKeyboardForDeferredUITransition() - DispatchQueue.main.async { self?.onTmuxAction?(.detach) } - } - return UIMenu(children: [switchHost, customize, detach]) - } - - private func makeSidebarButton() -> UIButton { - let button = KeyBarButton() - configurePanScrolling(button) - let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) - button.setImage(UIImage(systemName: "sidebar.left", withConfiguration: config), for: .normal) - button.tintColor = textDim - button.backgroundColor = keySpecialBg - button.layer.cornerRadius = 7 - button.layer.borderWidth = 1 - button.layer.borderColor = keyBorder.cgColor - button.clipsToBounds = true - button.addTarget(self, action: #selector(sidebarTapped), for: .touchUpInside) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 30), - button.widthAnchor.constraint(equalToConstant: 30), - ]) - return button - } - - @objc private func sidebarTapped() { - UIDevice.current.playInputClick() - dismissKeyboardForDeferredUITransition() - DispatchQueue.main.async { [weak self] in - self?.onSidebarTapped?() - } - } - - private func makeTmuxMenuButton() -> UIButton { - let button = KeyBarButton() - configurePanScrolling(button) - button.setTitle(String(localized: "tmux"), for: .normal) - button.titleLabel?.font = .monospacedSystemFont(ofSize: 10, weight: .bold) - button.setTitleColor(accentColor, for: .normal) - button.backgroundColor = tmuxKeyBg - button.layer.cornerRadius = 7 - button.layer.borderWidth = 1 - button.layer.borderColor = tmuxBorder.cgColor - button.clipsToBounds = true - button.horizontalContentPadding = 9 - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 30), - ]) - button.addTarget(self, action: #selector(tmuxMenuTapped), for: .touchUpInside) - return button - } - - @objc private func tmuxMenuTapped() { - UIDevice.current.playInputClick() - dismissKeyboardForDeferredUITransition() - DispatchQueue.main.async { [weak self] in - self?.onTmuxMenuTapped?() - } - } - - private func makeSelectionModeButton() -> UIButton { - let button = KeyBarButton() - configurePanScrolling(button) - let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) - button.setImage(UIImage(systemName: "text.cursor", withConfiguration: config), for: .normal) - button.layer.cornerRadius = 7 - button.layer.borderWidth = 1 - button.clipsToBounds = true - button.addTarget(self, action: #selector(selectionModeTapped), for: .touchUpInside) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 30), - button.widthAnchor.constraint(equalToConstant: 30), - ]) - updateSelectionButton(button) - return button - } - - @objc private func selectionModeTapped() { - UIDevice.current.playInputClick() - selectionModeActive.toggle() - terminalView?.allowMouseReporting = !selectionModeActive - updateSelectionButton() - } - - private func resetSelectionMode() { - selectionModeActive = false - terminalView?.allowMouseReporting = true - updateSelectionButton() - } - - private func updateSelectionButton(_ button: UIButton? = nil) { - guard let button = button ?? selectionButton else { return } - button.backgroundColor = selectionModeActive ? keyActiveBg : keySpecialBg - button.tintColor = selectionModeActive ? accentColor : textDim - button.layer.borderColor = (selectionModeActive ? tmuxBorder : keyBorder).cgColor - } - - private func makeKeyboardDismissButton() -> UIButton { - let button = KeyBarButton() - let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) - button.setImage(UIImage(systemName: "keyboard.chevron.compact.down", withConfiguration: config), for: .normal) - button.tintColor = textDim - button.backgroundColor = keySpecialBg - button.layer.cornerRadius = 7 - button.layer.borderWidth = 1 - button.layer.borderColor = keyBorder.cgColor - button.clipsToBounds = true - button.addTarget(self, action: #selector(keyboardDismissTapped), for: .touchUpInside) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 30), - button.widthAnchor.constraint(equalToConstant: 34), - ]) - return button - } - - @objc private func keyboardDismissTapped() { - UIDevice.current.playInputClick() - _ = terminalView?.resignFirstResponder() - } - - private func dismissKeyboardForDeferredUITransition() { - _ = terminalView?.resignFirstResponder() - } - - @objc private func buttonTouchDown(_ sender: UIButton) { - guard let action = action(for: sender) else { return } - applyStyle(to: sender, action: action, active: true) - } - - @objc private func buttonTouchUpInside(_ sender: UIButton) { - guard let action = action(for: sender) else { return } - if sender === repeatingButton { - repeatingButton = nil - if !action.isToggle || !ctrlActive { - applyStyle(to: sender, action: action, active: false) - } - return - } - UIDevice.current.playInputClick() - executeAction(action, button: sender) - if !action.isToggle || !ctrlActive { - applyStyle(to: sender, action: action, active: false) - } - } - - @objc private func buttonTouchUpOutside(_ sender: UIButton) { - if sender === repeatingButton { - repeatingButton = nil - } - if let action = action(for: sender), (!action.isToggle || !ctrlActive) { - applyStyle(to: sender, action: action, active: false) - } - cancelAutoRepeat() - } - - @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { - guard let button = gesture.view as? UIButton, - let action = action(for: button), - action.supportsAutoRepeat else { return } - switch gesture.state { - case .began: - repeatingButton = button - UIDevice.current.playInputClick() - startAutoRepeat(action) - case .ended, .cancelled: - cancelAutoRepeat() - if !action.isToggle || !ctrlActive { - applyStyle(to: button, action: action, active: false) - } - default: - break - } - } - - private func executeAction(_ action: KeyAction, button: UIButton? = nil) { - guard let terminalView else { return } - let handled = action.execute(on: terminalView) - if !handled && action == .ctrl { - ctrlActive = terminalView.controlModifier - updateCtrlButton() - } - } - - private func updateCtrlButton() { - for view in stackView.arrangedSubviews { - guard let button = view as? UIButton, - let action = action(for: button), - action == .ctrl else { continue } - applyStyle(to: button, action: action, active: ctrlActive) - } - } - - private func startAutoRepeat(_ action: KeyAction) { - cancelAutoRepeat() - repeatAction = action - executeAction(action) - - // The long-press gesture already enforces a 0.45s hold before calling - // startAutoRepeat, so we can begin repeating immediately. - repeatTimer = Timer.scheduledTimer(withTimeInterval: 0.075, repeats: true) { [weak self] _ in - MainActor.assumeIsolated { - self?.executeAction(action) - } - } - } - - private func cancelAutoRepeat() { - repeatTimer?.invalidate() - repeatTimer = nil - repeatAction = nil - } - - override func layoutSubviews() { - super.layoutSubviews() - fadeGradient.frame = fadeView.bounds - updateFadeVisibility() - } - - private func updateFadeVisibility() { - let maxOffset = scrollView.contentSize.width - scrollView.bounds.width - fadeView.isHidden = maxOffset <= 0 || scrollView.contentOffset.x >= maxOffset - 4 - } - - override var intrinsicContentSize: CGSize { - CGSize(width: UIView.noIntrinsicMetric, height: 44) - } -} - -extension KeyBarView: UIScrollViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - updateFadeVisibility() - } -} - -// MARK: - KeyBarButton - -/// Custom button (type == .custom) that detects horizontal pans and forwards -/// deltaX to the parent key bar so scrolling works even when -/// `delaysContentTouches` is false. Uses `.custom` to avoid a UIKit crash -/// (`_delayTouchesForEvent:inPhase:`) that can occur with `.system` buttons -/// inside a `UIScrollView`. -final class KeyBarButton: UIButton { - var onHorizontalPan: ((CGFloat) -> Void)? - var onPanBegan: ((UIButton) -> Void)? - - /// Horizontal padding added around centered content, replacing the - /// deprecated `contentEdgeInsets` (these buttons never adopt - /// UIButtonConfiguration). - var horizontalContentPadding: CGFloat = 0 { - didSet { invalidateIntrinsicContentSize() } - } - - override var intrinsicContentSize: CGSize { - var size = super.intrinsicContentSize - size.width += horizontalContentPadding * 2 - return size - } - - private var beganPoint: CGPoint = .zero - private let panThreshold: CGFloat = 6.0 - private var didCancelForPan = false - private var lastPanX: CGFloat = 0 - - override init(frame: CGRect) { - super.init(frame: frame) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - didCancelForPan = false - beganPoint = touches.first?.location(in: nil) ?? .zero - lastPanX = beganPoint.x - super.touchesBegan(touches, with: event) - } - - override func touchesMoved(_ touches: Set, with event: UIEvent?) { - guard let touch = touches.first else { - super.touchesMoved(touches, with: event) - return - } - - if !didCancelForPan { - let point = touch.location(in: nil) - let dx = abs(point.x - beganPoint.x) - let dy = abs(point.y - beganPoint.y) - - if dx > panThreshold && dx > dy { - didCancelForPan = true - cancelTracking(with: event) - onPanBegan?(self) - lastPanX = point.x - return - } - - super.touchesMoved(touches, with: event) - } else { - let currentX = touch.location(in: nil).x - let delta = lastPanX - currentX - lastPanX = currentX - onHorizontalPan?(delta) - } - } - - override func touchesEnded(_ touches: Set, with event: UIEvent?) { - if didCancelForPan { - didCancelForPan = false - } else { - super.touchesEnded(touches, with: event) - } - } - - override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - if didCancelForPan { - didCancelForPan = false - } else { - super.touchesCancelled(touches, with: event) - } - } -} -#endif diff --git a/MoriRemote/MoriRemote/Accessories/TerminalAccessoryBar.swift b/MoriRemote/MoriRemote/Accessories/TerminalAccessoryBar.swift deleted file mode 100644 index 5880f763..00000000 --- a/MoriRemote/MoriRemote/Accessories/TerminalAccessoryBar.swift +++ /dev/null @@ -1,111 +0,0 @@ -#if os(iOS) -import SwiftTerm -import UIKit - -/// Single-row input accessory view for the terminal keyboard. -/// Contains the compact Mori-style quick key bar. -@MainActor -final class TerminalAccessoryBar: UIInputView, UIInputViewAudioFeedback { - - let keyBar = KeyBarView() - - weak var terminalView: SwiftTerm.TerminalView? { - didSet { keyBar.terminalView = terminalView } - } - - /// Callback for tmux commands from the key bar. - var onTmuxCommand: ((TmuxCommand) -> Void)? - - /// Called when the user taps the back button. - var onBackTapped: (() -> Void)? - - /// Called when the user taps the sidebar button. - var onSidebarTapped: (() -> Void)? - - /// Called when the user taps the tmux menu button. - var onTmuxMenuTapped: (() -> Void)? - - /// Called when the user taps the gear button to customize the key bar. - var onCustomizeTapped: (() -> Void)? - - var enableInputClicksWhenVisible: Bool { true } - - private let barBg = UIColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1) - private let topBorder = UIView() - - init() { - super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 45), inputViewStyle: .keyboard) - allowsSelfSizing = true - backgroundColor = barBg - setup() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - private func setup() { - topBorder.backgroundColor = UIColor.white.withAlphaComponent(0.08) - topBorder.translatesAutoresizingMaskIntoConstraints = false - - keyBar.translatesAutoresizingMaskIntoConstraints = false - - addSubview(topBorder) - addSubview(keyBar) - - keyBar.onBackTapped = { [weak self] in - self?.onBackTapped?() - } - keyBar.onSidebarTapped = { [weak self] in - self?.onSidebarTapped?() - } - keyBar.onCustomizeTapped = { [weak self] in - self?.onCustomizeTapped?() - } - keyBar.onTmuxMenuTapped = { [weak self] in - self?.onTmuxMenuTapped?() - } - keyBar.onTmuxAction = { [weak self] cmd in - self?.onTmuxCommand?(cmd) - } - - NSLayoutConstraint.activate([ - topBorder.topAnchor.constraint(equalTo: topAnchor), - topBorder.leadingAnchor.constraint(equalTo: leadingAnchor), - topBorder.trailingAnchor.constraint(equalTo: trailingAnchor), - topBorder.heightAnchor.constraint(equalToConstant: 1), - - keyBar.topAnchor.constraint(equalTo: topBorder.bottomAnchor), - keyBar.leadingAnchor.constraint(equalTo: leadingAnchor), - keyBar.trailingAnchor.constraint(equalTo: trailingAnchor), - keyBar.heightAnchor.constraint(equalToConstant: 44), - keyBar.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - func updateTmux(session: TmuxSession?, windows: [TmuxWindow]) { - // Kept for ShellCoordinator compatibility. - } - - override var intrinsicContentSize: CGSize { - CGSize(width: UIView.noIntrinsicMetric, height: 45) - } -} - -// MARK: - Tmux Command - -enum TmuxCommand: Sendable { - case selectWindow(Int) - case newWindow - case nextWindow - case prevWindow - case splitRight - case splitDown - case nextPane - case prevPane - case toggleZoom - case closePane - case showSessionPicker - case switchSession(String) - case detach -} -#endif diff --git a/MoriRemote/MoriRemote/Accessories/TmuxBarView.swift b/MoriRemote/MoriRemote/Accessories/TmuxBarView.swift deleted file mode 100644 index a3ffe5d7..00000000 --- a/MoriRemote/MoriRemote/Accessories/TmuxBarView.swift +++ /dev/null @@ -1,169 +0,0 @@ -#if os(iOS) -import UIKit - -// MARK: - Tmux Data Types - -struct TmuxSession: Equatable, Sendable, Identifiable { - let name: String - let windowCount: Int - let isAttached: Bool - var windows: [TmuxWindow] = [] - - var id: String { name } -} - -struct TmuxPane: Equatable, Sendable, Identifiable { - let paneId: String // tmux global id, e.g. "%5" - let isActive: Bool - let command: String // pane_current_command, e.g. "claude", "zsh" - let title: String - let path: String - let agentState: String? // @mori-agent-state, e.g. "working", "done" - let agentName: String? // @mori-agent-name, e.g. "claude", "codex" - - var id: String { paneId } - - /// Best label for the pane row: agent name, command, or pane id. - var displayLabel: String { - if let agentName, !agentName.isEmpty { return agentName } - if !command.isEmpty { return command } - return paneId - } -} - -struct TmuxWindow: Equatable, Sendable, Identifiable { - let index: Int - let name: String - let isActive: Bool - let sessionName: String - let path: String - var panes: [TmuxPane] = [] - - var id: String { "\(sessionName):\(index)" } - - var shortPath: String { - guard !path.isEmpty else { return "" } - let display = path.contains("/Users/") || path.contains("/home/") - ? "~" + path.split(separator: "/").dropFirst(2).map { "/" + $0 }.joined() - : path - let parts = display.split(separator: "/") - if parts.count <= 2 { return display } - return "…/" + parts.suffix(2).joined(separator: "/") - } - - init(index: Int, name: String, isActive: Bool, sessionName: String = "", path: String = "", panes: [TmuxPane] = []) { - self.index = index - self.name = name - self.isActive = isActive - self.sessionName = sessionName - self.path = path - self.panes = panes - } -} - -@MainActor -protocol TmuxBarDelegate: AnyObject { - func tmuxBarDidTap() -} - -/// Compact status pill showing the active tmux session and window. -@MainActor -final class TmuxBarView: UIView { - - weak var delegate: TmuxBarDelegate? - - private let pillButton = UIButton(type: .system) - private(set) var currentSession: TmuxSession? - private(set) var windows: [TmuxWindow] = [] - - private let accentColor = UIColor.tintColor - private let pillBg = UIColor.tintColor.withAlphaComponent(0.12) - private let borderColor = UIColor.tintColor.withAlphaComponent(0.28) - - override init(frame: CGRect) { - super.init(frame: frame) - setup() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - private func setup() { - pillButton.backgroundColor = pillBg - pillButton.layer.cornerRadius = 7 - pillButton.layer.borderWidth = 1 - pillButton.layer.borderColor = borderColor.cgColor - pillButton.clipsToBounds = true - pillButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 9, bottom: 4, right: 9) - pillButton.addTarget(self, action: #selector(pillTapped), for: .touchUpInside) - pillButton.translatesAutoresizingMaskIntoConstraints = false - addSubview(pillButton) - - NSLayoutConstraint.activate([ - pillButton.centerYAnchor.constraint(equalTo: centerYAnchor), - pillButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - pillButton.heightAnchor.constraint(equalToConstant: 28), - ]) - } - - func update(session: TmuxSession?, windows: [TmuxWindow]) { - self.currentSession = session - self.windows = windows - rebuild() - } - - private func rebuild() { - guard let session = currentSession else { - isHidden = true - return - } - isHidden = false - - let activeWindow = windows.first(where: { $0.isActive }) - let text = NSMutableAttributedString() - - text.append(NSAttributedString( - string: "⬡ ", - attributes: [ - .font: UIFont.systemFont(ofSize: 9, weight: .bold), - .foregroundColor: accentColor, - ] - )) - - text.append(NSAttributedString( - string: session.name, - attributes: [ - .font: UIFont.monospacedSystemFont(ofSize: 10, weight: .semibold), - .foregroundColor: accentColor, - ] - )) - - if let window = activeWindow { - text.append(NSAttributedString( - string: " › ", - attributes: [ - .font: UIFont.systemFont(ofSize: 9, weight: .semibold), - .foregroundColor: accentColor.withAlphaComponent(0.5), - ] - )) - text.append(NSAttributedString( - string: window.name, - attributes: [ - .font: UIFont.systemFont(ofSize: 10, weight: .medium), - .foregroundColor: UIColor.white.withAlphaComponent(0.92), - ] - )) - } - - pillButton.setAttributedTitle(text, for: .normal) - } - - @objc private func pillTapped() { - delegate?.tmuxBarDidTap() - } - - override var intrinsicContentSize: CGSize { - CGSize(width: UIView.noIntrinsicMetric, height: 34) - } -} -#endif diff --git a/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift new file mode 100644 index 00000000..bfef0879 --- /dev/null +++ b/MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift @@ -0,0 +1,220 @@ +import Foundation + +/// The app composes durable state once. `RemoteLibrary` is the only writer for +/// profile JSON, trust, and credentials, so foreground/UI tasks cannot race a +/// migration or each other through the whole-file atomic stores. +@MainActor +final class MoriRemoteDependencies { + let library: RemoteLibrary + let trustedHosts: TrustedHostStore + let roots = SSHRootPool() + private var ghosttyRuntime: GhosttyKitRuntime? + + init(storage: MoriRemoteStorage, legacyServersURL: URL) { + trustedHosts = storage.trustedHosts + let migrator = LegacyServerMigrator(storage: storage, legacyServersURL: legacyServersURL) + library = RemoteLibrary(storage: storage, migrator: migrator) + } + + static func live() -> MoriRemoteDependencies { + do { + let storage = try MoriRemoteStorage.applicationSupport() + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return MoriRemoteDependencies(storage: storage, legacyServersURL: documents.appendingPathComponent("servers.json")) + } catch { + fatalError("MoriRemote cannot prepare Application Support: \(error)") + } + } + + func terminalRuntime() throws -> GhosttyKitRuntime { + if let ghosttyRuntime { return ghosttyRuntime } + let runtime = try GhosttyKitRuntime() + ghosttyRuntime = runtime + return runtime + } +} + +struct RemoteLibrarySnapshot: Sendable { + var servers: [SavedServer] + var workspaces: [SavedWorkspace] + var identities: [SSHIdentity] + var settings: RemoteSettings + var migration: LegacyMigrationReport? +} + +enum ProfileCredential: Sendable { + case password(String) + case privateKey(SSHPrivateKeyCredential) +} + +/// Actor ownership is intentional: `UUIDJSONRepository` is an atomic-file +/// primitive, not a multi-writer database. All app mutations pass here. +actor RemoteLibrary { + private let storage: MoriRemoteStorage + private let migrator: LegacyServerMigrator + private let passwords: any CredentialStoring + private let credentials: any SSHCredentialStoring + + init( + storage: MoriRemoteStorage, + migrator: LegacyServerMigrator, + passwords: any CredentialStoring = KeychainCredentialStore(), + secretData: any SecretDataStore = SecuritySecretDataStore() + ) { + self.storage = storage + self.migrator = migrator + self.passwords = passwords + credentials = KeychainSSHCredentialStore(passwords: passwords, secrets: secretData) + } + + func bootstrap() throws -> RemoteLibrarySnapshot { + let report = try migrator.migrateIfNeeded() + return try snapshot(migration: report) + } + + func reload() throws -> RemoteLibrarySnapshot { try snapshot(migration: nil) } + + func save(server: SavedServer, workspace: SavedWorkspace?, 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 { + 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. + 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. + throw SSHAuthResolverError.missingCredential(identity.id) + } + if let credential { + switch credential { + case let .password(password): + guard !password.isEmpty else { throw SSHAuthResolverError.missingCredential(identity.id) } + case let .privateKey(key): + _ = try SSHPrivateKeyInspector.inspect(key.privateKeyPEM) + } + } + + if existingServers.contains(where: { $0.id == server.id }) { + try storage.servers.replace(server) + } 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 { + _ = try storage.identities.insertIfAbsent(identity) + } + + if let credential { + switch credential { + case let .password(password): + try passwords.setPassword(password, for: identity.id) + try credentials.deletePrivateKey(for: identity.id) + case let .privateKey(key): + try credentials.savePrivateKey(key, for: identity.id) + try passwords.deletePassword(for: identity.id) + } + } + return try snapshot(migration: nil) + } + + func markConnected(workspaceID: UUID, now: Date = .now) throws -> RemoteLibrarySnapshot { + guard var workspace = try storage.workspaces.all().first(where: { $0.id == workspaceID }) else { + throw PersistenceError.notFound(workspaceID) + } + guard var server = try storage.servers.all().first(where: { $0.id == workspace.serverID }) else { + throw PersistenceError.notFound(workspace.serverID) + } + workspace.lastConnectedAt = now + server.lastConnectedAt = now + try storage.workspaces.replace(workspace) + try storage.servers.replace(server) + 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 } + for workspace in workspaces { try storage.workspaces.remove(workspace.id) } + for identity in identities { + try storage.identities.remove(identity.id) + try passwords.deletePassword(for: identity.id) + try credentials.deletePrivateKey(for: identity.id) + } + try storage.servers.remove(serverID) + return try snapshot(migration: nil) + } + + func save(settings: RemoteSettings) throws -> RemoteLibrarySnapshot { + _ = try settings.validated() + try storage.settings.save(settings) + return try snapshot(migration: nil) + } + + func trust(_ challenge: SSHHostTrustChallenge, replaceChanged: Bool) throws { + try SSHHostTrustResolver(store: storage.trustedHosts).explicitlyTrust(challenge, replaceChanged: replaceChanged) + } + + 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) } + let servers = try storage.servers.all() + guard let server = servers.first(where: { $0.id == workspace.serverID }) else { throw PersistenceError.notFound(workspace.serverID) } + let identities = try storage.identities.all() + guard let identity = identities.first(where: { $0.id == server.identityID }) else { throw SSHAuthResolverError.missingIdentity(server.identityID) } + return (workspace, server, identity, try storage.settings.load(or: .default)) + } + + func resolveAuth(server: SavedServer, identity: SSHIdentity, settings: RemoteSettings) throws -> ResolvedSSHAuth { + try SSHAuthResolver(credentials: credentials).resolve(server: server, identity: identity, settings: settings) + } + + private func snapshot(migration: LegacyMigrationReport?) throws -> RemoteLibrarySnapshot { + let servers = try storage.servers.all().sorted { ($0.lastConnectedAt ?? .distantPast, $0.name) > ($1.lastConnectedAt ?? .distantPast, $1.name) } + let serverIDs = Set(servers.map(\.id)) + let workspaces = try storage.workspaces.all().filter { serverIDs.contains($0.serverID) }.sorted { ($0.lastConnectedAt ?? .distantPast, $0.name) > ($1.lastConnectedAt ?? .distantPast, $1.name) } + let identities = try storage.identities.all().filter { serverIDs.contains($0.serverID) } + return .init(servers: servers, workspaces: workspaces, identities: identities, settings: try storage.settings.load(or: .default), migration: migration) + } +} diff --git a/MoriRemote/MoriRemote/App/RemoteRootModel.swift b/MoriRemote/MoriRemote/App/RemoteRootModel.swift new file mode 100644 index 00000000..26444d78 --- /dev/null +++ b/MoriRemote/MoriRemote/App/RemoteRootModel.swift @@ -0,0 +1,529 @@ +import Foundation +import Observation +import SwiftUI + +struct WorkspaceDraft: 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) { + id = server?.id ?? UUID() + workspaceID = workspace?.id ?? (server == nil ? UUID() : nil) + serverLastConnectedAt = server?.lastConnectedAt + workspaceLastConnectedAt = workspace?.lastConnectedAt + serverName = server?.name ?? "" + host = server?.host ?? "" + port = String(server?.port ?? 22) + username = server?.username ?? "" + workspaceName = workspace?.name ?? "main" + tmuxSession = workspace?.tmuxSession ?? "main" + identityKind = identity?.kind ?? .password + password = "" + privateKey = "" + passphrase = "" + } + + func records(existingIdentityID: UUID? = nil) throws -> (SavedServer, SavedWorkspace?, SSHIdentity, ProfileCredential?) { + 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 { + case .password: credential = password.isEmpty ? nil : .password(password) + case .privateKey: + credential = privateKey.isEmpty ? nil : .privateKey(.init(privateKeyPEM: privateKey, passphrase: passphrase.isEmpty ? nil : passphrase)) + } + return (try server.validated(), workspace, try identity.validated(), credential) + } +} + +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") + } + } +} + +/// The small pure policy prevents credential/trust/profile failures from becoming +/// noisy background retries. Only a live transport loss gets one bounded retry. +struct WorkspaceReconnectPolicy: Sendable { + static let automaticAttempts = 1 + func mayReconnect(status: WorkspaceRuntimeStatus, attempts: Int) -> Bool { + if case .disconnected = status { return attempts < Self.automaticAttempts } + return false + } +} + +/// iOS memory warnings are advisory, not an excuse to tear down the terminal a +/// user is actively using. Evict dormant workspaces first; their one-shot +/// runtime fences release native surfaces before the root lease returns to pool. +struct WorkspaceMemoryPressurePolicy: Sendable { + func workspaceIDsToDisconnect(active: UUID?, all: some Collection) -> [UUID] { + all.filter { $0 != active }.sorted { $0.uuidString < $1.uuidString } + } +} + +/// 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 { + case challenge(SSHHostTrustChallenge) + case error(String) + + static func resolve(_ error: SSHHostTrustError) -> Self { + switch error { + case let .trustRequired(challenge), let .changedKey(challenge): .challenge(challenge) + case .staleChallenge: .error(error.localizedDescription) + } + } +} + +struct WorkspaceConnectionAttemptLedger: Sendable { + private var tokens: [UUID: UUID] = [:] + + mutating func begin(workspaceID: UUID) -> UUID? { + guard tokens[workspaceID] == nil else { return nil } + let token = UUID() + tokens[workspaceID] = token + return token + } + + func isCurrent(_ token: UUID, for workspaceID: UUID) -> Bool { tokens[workspaceID] == token } + mutating func end(_ token: UUID, for workspaceID: UUID) { guard isCurrent(token, for: workspaceID) else { return }; tokens[workspaceID] = nil } + mutating func cancel(workspaceID: UUID) { tokens[workspaceID] = nil } +} + +@MainActor +final class ActiveWorkspaceRuntime { + let workspace: SavedWorkspace + let instanceID: UUID + private let runtime: GhosttyTmuxRuntime + 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 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()) { + 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) + } + metadataProjector.onChange = { [weak self] in self?.onChange?() } + runtime.onTopology = { [weak self] topology in + guard let self else { return } + self.topology = topology + if let focused = self.focusedPaneID, topology.panes.contains(where: { $0.id == focused }) { + // Keep client-local focus stable through topology updates. + } else { + self.focusedPaneID = topology.activePaneID + } + self.status = .ready + self.metadataProjector.topologyDidChange(topology) + self.onChange?() + } + 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?() } + } + + 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 foregrounded() { metadataProjector.foregrounded() } + func confirmTransportAfterForeground() async { + guard !(await runtime.isActive()) else { + foregrounded() + return + } + status = .disconnected(String(localized: "Connection lost.")) + onChange?() + onTransportLoss?(instanceID) + } + func surface() -> TmuxPaneSurface? { focusedPaneID.flatMap(runtime.surface(for:)) } + func metadata(for paneID: TmuxPaneID) -> 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) } +} + +@MainActor @Observable +final class RemoteRootModel { + private let dependencies: MoriRemoteDependencies + private let reconnectPolicy = WorkspaceReconnectPolicy() + private let memoryPressurePolicy = WorkspaceMemoryPressurePolicy() + private var reconnectAttempts: [UUID: Int] = [:] + private var deferredReconnects = Set() + private var connectionAttempts = WorkspaceConnectionAttemptLedger() + private var loadingTask: Task? + private var bootstrapFailure: String? + private var sceneIsActive = true + + private(set) var servers: [SavedServer] = [] + private(set) var workspaces: [SavedWorkspace] = [] + private(set) var identities: [SSHIdentity] = [] + private(set) var settings = RemoteSettings.default + private(set) var runtimes: [UUID: ActiveWorkspaceRuntime] = [:] + var activeWorkspaceID: UUID? + var pendingTrust: SSHHostTrustChallenge? + var errorMessage: String? + var migrationReport: LegacyMigrationReport? + var runtimeRevision = 0 + private var pendingTrustWorkspaceID: UUID? + private(set) var isLoaded = false + var libraryLoadError: String? { bootstrapFailure } + var isBootstrapping: Bool { loadingTask != nil } + + init(dependencies: MoriRemoteDependencies = .live()) { self.dependencies = dependencies } + + 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 bootstrap() { + guard !isLoaded, loadingTask == nil else { return } + bootstrapFailure = nil + loadingTask = Task { [weak self] in + guard let self else { return } + defer { self.loadingTask = nil } + do { + let snapshot = try await self.dependencies.library.bootstrap() + self.apply(snapshot) + } catch { + self.bootstrapFailure = error.localizedDescription + } + } + } + + func save(_ draft: ServerWorkspaceDraft, existingServer: SavedServer? = nil) { + Task { + do { + let currentIdentity = existingServer.flatMap { server in identities.first { $0.id == server.identityID } } + let records = try draft.records(existingIdentityID: currentIdentity?.id) + let snapshot = try await dependencies.library.save(server: records.0, workspace: records.1, identity: records.2, credential: records.3) + apply(snapshot) + } catch { errorMessage = error.localizedDescription } + } + } + + func save(_ draft: WorkspaceDraft) { + Task { + do { + let snapshot = try await dependencies.library.save(workspace: draft.record()) + 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) } + Task { + do { + for workspace in serverWorkspaces { await disconnect(workspaceID: workspace.id) } + let snapshot = try await dependencies.library.delete(serverID: server.id) + apply(snapshot) + } catch { errorMessage = error.localizedDescription } + } + } + + func save(settings: RemoteSettings) { + Task { + do { + let snapshot = try await dependencies.library.save(settings: settings) + apply(snapshot) + } catch { errorMessage = error.localizedDescription } + } + } + + func connect(workspaceID: UUID, automatic: Bool = false, activating: Bool? = nil) { + let shouldActivate = activating ?? (!automatic || activeWorkspaceID == nil || activeWorkspaceID == workspaceID) + deferredReconnects.remove(workspaceID) + if let runtime = runtimes[workspaceID] { + if case .disconnected = runtime.status { + // A background loss leaves its one-shot runtime intact until + // foregrounding or an explicit tap. Never "activate" a dead + // surface and strand the user without a reconnect path. + Task { [weak self] in + await self?.disconnect(workspaceID: workspaceID) + self?.connect(workspaceID: workspaceID, automatic: automatic) + } + } else if shouldActivate { + activate(workspaceID: workspaceID) + } + return + } + guard let attempt = connectionAttempts.begin(workspaceID: workspaceID) else { return } + Task { [weak self] in + 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) + 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, + runtimeID: instanceID + ) + let created = ActiveWorkspaceRuntime(workspace: material.0, settings: material.3, app: try self.dependencies.terminalRuntime(), transport: transport, instanceID: instanceID) + runtime = created + guard self.attemptIsCurrent(attempt, workspaceID: workspaceID) else { await created.stop(); return } + created.onTransportLoss = { [weak self] id in self?.lost(workspaceID: workspaceID, instanceID: id) } + created.onChange = { [weak self, weak created] in + guard let self, self.runtimes[workspaceID] === created else { return } + self.runtimeRevision &+= 1 + } + self.runtimes[workspaceID] = created + // An automatic reconnect must not steal focus from another + // healthy workspace. If the lost workspace was focused, + // disconnect() cleared the active ID and it is restored here. + if shouldActivate { + self.activate(workspaceID: workspaceID) + } + try await created.start() + guard self.attemptIsCurrent(attempt, workspaceID: workspaceID), self.runtimes[workspaceID] === created else { await self.stop(created, workspaceID: workspaceID); return } + let snapshot = try await self.dependencies.library.markConnected(workspaceID: workspaceID) + guard self.attemptIsCurrent(attempt, workspaceID: workspaceID), self.runtimes[workspaceID] === created else { await self.stop(created, workspaceID: workspaceID); return } + self.apply(snapshot) + self.reconnectAttempts[workspaceID] = 0 + self.connectionAttempts.end(attempt, for: workspaceID) + } catch let error as SSHHostTrustError { + guard self.attemptIsCurrent(attempt, workspaceID: workspaceID) else { if let runtime { await self.stop(runtime, workspaceID: workspaceID) }; return } + if let runtime { await self.stop(runtime, workspaceID: workspaceID) } + self.connectionAttempts.end(attempt, for: workspaceID) + switch SSHTrustPresentation.resolve(error) { + case let .challenge(challenge): + self.errorMessage = nil + self.pendingTrust = challenge + self.pendingTrustWorkspaceID = workspaceID + case let .error(message): + self.pendingTrust = nil + self.pendingTrustWorkspaceID = nil + self.errorMessage = message + } + } catch { + guard self.attemptIsCurrent(attempt, workspaceID: workspaceID) else { if let runtime { await self.stop(runtime, workspaceID: workspaceID) }; return } + if let runtime { await self.stop(runtime, workspaceID: workspaceID) } + self.connectionAttempts.end(attempt, for: workspaceID) + if !automatic { self.errorMessage = error.localizedDescription } + } + } + } + + func dismissTrust() { + pendingTrust = nil + pendingTrustWorkspaceID = nil + } + + func confirmTrust(_ challenge: SSHHostTrustChallenge, replaceChanged: Bool) { + Task { + do { + try await dependencies.library.trust(challenge, replaceChanged: replaceChanged) + let workspaceID = pendingTrustWorkspaceID + pendingTrust = nil + pendingTrustWorkspaceID = nil + errorMessage = nil + if let workspaceID { connect(workspaceID: workspaceID) } + } catch { errorMessage = error.localizedDescription } + } + } + + func disconnect(workspaceID: UUID) async { + connectionAttempts.cancel(workspaceID: workspaceID) + deferredReconnects.remove(workspaceID) + guard let runtime = runtimes.removeValue(forKey: workspaceID) else { return } + if activeWorkspaceID == workspaceID { activeWorkspaceID = nil } + runtime.setMetadataRefreshVisible(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() } + /// 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) + 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 + // inactive runtimes retain a dead native surface forever. + let deferred = deferredReconnects.sorted { $0.uuidString < $1.uuidString } + let activeBeforeReconnect = activeWorkspaceID + deferredReconnects.removeAll() + for workspaceID in deferred { + let shouldActivate = activeBeforeReconnect == nil || activeBeforeReconnect == workspaceID + if let runtime = runtimes[workspaceID] { + reconnectAfterTransportLoss(workspaceID: workspaceID, instanceID: runtime.instanceID, activating: shouldActivate) + } else { + connect(workspaceID: workspaceID, automatic: true, activating: shouldActivate) + } + } + guard let workspaceID = activeWorkspaceID, let runtime = runtimes[workspaceID] else { return } + Task { [weak self, weak runtime] in + guard let self, self.runtimes[workspaceID] === runtime else { return } + await runtime?.confirmTransportAfterForeground() + } + } + + func handleMemoryWarning() { + let dormant = memoryPressurePolicy.workspaceIDsToDisconnect(active: activeWorkspaceID, all: runtimes.keys) + guard !dormant.isEmpty else { return } + Task { [weak self] in + for workspaceID in dormant { await self?.disconnect(workspaceID: workspaceID) } + } + } + + private func activate(workspaceID: UUID) { + guard activeWorkspaceID != workspaceID else { + runtimes[workspaceID]?.setMetadataRefreshVisible(sceneIsActive) + return + } + if let activeWorkspaceID { runtimes[activeWorkspaceID]?.setMetadataRefreshVisible(false) } + activeWorkspaceID = workspaceID + runtimes[workspaceID]?.setMetadataRefreshVisible(sceneIsActive) + } + + private func attemptIsCurrent(_ attempt: UUID, workspaceID: UUID) -> Bool { + connectionAttempts.isCurrent(attempt, for: workspaceID) + } + + private func stop(_ runtime: ActiveWorkspaceRuntime, workspaceID: UUID) async { + if runtimes[workspaceID] === runtime { + runtimes[workspaceID] = nil + if activeWorkspaceID == workspaceID { activeWorkspaceID = nil } + } + runtime.setMetadataRefreshVisible(false) + await runtime.stop() + } + + private func lost(workspaceID: UUID, instanceID: UUID) { + guard runtimes[workspaceID]?.instanceID == instanceID else { return } + let attempts = reconnectAttempts[workspaceID, default: 0] + guard reconnectPolicy.mayReconnect(status: runtimes[workspaceID]?.status ?? .disconnected(""), attempts: attempts) else { return } + reconnectAttempts[workspaceID] = attempts + 1 + guard sceneIsActive else { + deferredReconnects.insert(workspaceID) + return + } + reconnectAfterTransportLoss(workspaceID: workspaceID, instanceID: instanceID, activating: activeWorkspaceID == nil || activeWorkspaceID == workspaceID) + } + + private func reconnectAfterTransportLoss(workspaceID: UUID, instanceID: UUID, activating: Bool) { + Task { [weak self] in + guard let self, self.runtimes[workspaceID]?.instanceID == instanceID else { return } + await disconnect(workspaceID: workspaceID) + try? await Task.sleep(for: .seconds(1)) + guard self.sceneIsActive else { + self.deferredReconnects.insert(workspaceID) + return + } + self.connect(workspaceID: workspaceID, automatic: true, activating: activating) + } + } + + private func apply(_ snapshot: RemoteLibrarySnapshot) { + servers = snapshot.servers + workspaces = snapshot.workspaces + identities = snapshot.identities + settings = snapshot.settings + migrationReport = snapshot.migration + isLoaded = true + } +} diff --git a/MoriRemote/MoriRemote/Domain/SavedModels.swift b/MoriRemote/MoriRemote/Domain/SavedModels.swift new file mode 100644 index 00000000..128fd61c --- /dev/null +++ b/MoriRemote/MoriRemote/Domain/SavedModels.swift @@ -0,0 +1,181 @@ +import Foundation + +/// A saved SSH endpoint. Credentials deliberately live outside this JSON model. +struct SavedServer: Identifiable, Codable, Equatable, Sendable { + let id: UUID + var name: String + var host: String + var port: Int + var username: String + var identityID: UUID + var lastConnectedAt: Date? + + init( + id: UUID = UUID(), + name: String, + host: String, + port: Int = 22, + username: String, + identityID: UUID? = nil, + lastConnectedAt: Date? = nil + ) { + self.id = id + self.name = name + self.host = host + self.port = port + self.username = username + self.identityID = identityID ?? id + self.lastConnectedAt = lastConnectedAt + } + + var endpoint: CanonicalEndpoint? { try? CanonicalEndpoint(host: host, port: port) } + + func validated() throws -> SavedServer { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SavedModelValidationError.emptyName + } + guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SavedModelValidationError.emptyUsername + } + _ = try CanonicalEndpoint(host: host, port: port) + return self + } +} + +struct SavedWorkspace: Identifiable, Codable, Equatable, Sendable { + let id: UUID + var serverID: UUID + var name: String + var tmuxSession: String + var lastConnectedAt: Date? + + init(id: UUID = UUID(), serverID: UUID, name: String, tmuxSession: String, lastConnectedAt: Date? = nil) { + self.id = id + self.serverID = serverID + self.name = name + self.tmuxSession = tmuxSession + self.lastConnectedAt = lastConnectedAt + } + + func validated() throws -> SavedWorkspace { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SavedModelValidationError.emptyName + } + guard !tmuxSession.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !tmuxSession.contains(where: { $0.isNewline || $0 == "\0" }) else { + throw SavedModelValidationError.invalidTmuxSession + } + return self + } +} + +enum SSHIdentityKind: String, Codable, Sendable { + case password + case privateKey +} + +struct SSHIdentity: Identifiable, Codable, Equatable, Sendable { + let id: UUID + var serverID: UUID + var kind: SSHIdentityKind + var label: String + + init(id: UUID = UUID(), serverID: UUID, kind: SSHIdentityKind, label: String = "") { + self.id = id + self.serverID = serverID + self.kind = kind + self.label = label + } + + func validated() throws -> SSHIdentity { + guard !label.contains(where: { $0.isNewline || $0 == "\0" }) else { + throw SavedModelValidationError.invalidIdentityLabel + } + return self + } +} + +struct RemoteSettings: Codable, Equatable, Sendable { + static let `default` = RemoteSettings() + static let minimumInitialScrollbackLines = 2_000 + static let maximumScrollbackLines = 10_000 + + var initialScrollbackLines: Int + var maximumScrollbackLines: Int + /// Legacy RSA/SHA-1 is disabled unless a user explicitly enables it for an old host. + var allowLegacyRSA: Bool + + init(initialScrollbackLines: Int = 2_000, maximumScrollbackLines: Int = 10_000, allowLegacyRSA: Bool = false) { + self.initialScrollbackLines = initialScrollbackLines + self.maximumScrollbackLines = maximumScrollbackLines + self.allowLegacyRSA = allowLegacyRSA + } + + /// Settings from older builds may hold a smaller value. New connections clamp + /// it to Ghostty's documented 2k/10k local-history contract. + var effectiveInitialScrollbackLines: Int { + min(max(initialScrollbackLines, Self.minimumInitialScrollbackLines), Self.maximumScrollbackLines) + } + + func validated() throws -> RemoteSettings { + guard initialScrollbackLines > 0, + maximumScrollbackLines >= initialScrollbackLines, + maximumScrollbackLines <= Self.maximumScrollbackLines else { + throw SavedModelValidationError.invalidScrollbackLimit + } + return self + } +} + +enum SavedModelValidationError: Error, Equatable, Sendable { + case emptyName + case emptyUsername + case invalidHost + case invalidPort + case invalidTmuxSession + case invalidIdentityLabel + case invalidScrollbackLimit +} + +struct CanonicalEndpoint: Codable, Equatable, Hashable, Sendable { + let host: String + let port: Int + + init(host: String, port: Int) throws { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let unbracketed = trimmed.hasPrefix("[") && trimmed.hasSuffix("]") + ? String(trimmed.dropFirst().dropLast()) + : trimmed + let canonical = unbracketed.hasSuffix(".") ? String(unbracketed.dropLast()) : unbracketed + + guard !canonical.isEmpty, + !canonical.contains(where: { $0.isWhitespace || $0.isNewline || $0 == "\0" }) else { + throw SavedModelValidationError.invalidHost + } + guard (1...65_535).contains(port) else { throw SavedModelValidationError.invalidPort } + self.host = canonical.lowercased() + self.port = port + } +} + +struct TrustedHost: Codable, Equatable, Sendable { + let serverID: UUID + let endpoint: CanonicalEndpoint + let algorithm: String + let fingerprint: String + let trustedAt: Date +} + +protocol CredentialReading: Sendable { + func password(for identityID: UUID) throws -> String? +} + +enum CredentialRequirement: Equatable, Sendable { + case available + /// Presentation localizes this state in the future connection flow. + case credentialRequired +} + +func credentialRequirement(identityID: UUID, credentials: any CredentialReading) throws -> CredentialRequirement { + try credentials.password(for: identityID) == nil ? .credentialRequired : .available +} diff --git a/MoriRemote/MoriRemote/Ghostty/GhosttyKitRuntime.swift b/MoriRemote/MoriRemote/Ghostty/GhosttyKitRuntime.swift new file mode 100644 index 00000000..80308a11 --- /dev/null +++ b/MoriRemote/MoriRemote/Ghostty/GhosttyKitRuntime.swift @@ -0,0 +1,155 @@ +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 new file mode 100644 index 00000000..e9cfb648 --- /dev/null +++ b/MoriRemote/MoriRemote/Ghostty/GhosttyPaneSurface.swift @@ -0,0 +1,526 @@ +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 new file mode 100644 index 00000000..f3e7c3c5 --- /dev/null +++ b/MoriRemote/MoriRemote/Ghostty/GhosttyTerminalProbe.swift @@ -0,0 +1,111 @@ +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 new file mode 100644 index 00000000..52bfc8c8 --- /dev/null +++ b/MoriRemote/MoriRemote/Ghostty/GhosttyTmuxRuntime.swift @@ -0,0 +1,116 @@ +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 new file mode 100644 index 00000000..9dfae993 --- /dev/null +++ b/MoriRemote/MoriRemote/GhosttyKitABIProbe.swift @@ -0,0 +1,9 @@ +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/Models/Server.swift b/MoriRemote/MoriRemote/Models/Server.swift deleted file mode 100644 index 9b2c8389..00000000 --- a/MoriRemote/MoriRemote/Models/Server.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation -import Security - -struct Server: Identifiable, Codable, Equatable, Sendable { - var id: UUID - var name: String - var host: String - var port: Int - var username: String - var defaultSession: String - var lastConnectedAt: Date? - - /// Transient — not persisted to JSON. Loaded from / saved to Keychain. - var password: String - - private enum CodingKeys: String, CodingKey { - case id, name, host, port, username, defaultSession, lastConnectedAt - } - - init( - id: UUID = UUID(), - name: String = "", - host: String = "", - port: Int = 22, - username: String = "", - password: String = "", - defaultSession: String = "main", - lastConnectedAt: Date? = nil - ) { - self.id = id - self.name = name - self.host = host - self.port = port - self.username = username - self.password = password - self.defaultSession = defaultSession - self.lastConnectedAt = lastConnectedAt - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(UUID.self, forKey: .id) - name = try c.decode(String.self, forKey: .name) - host = try c.decode(String.self, forKey: .host) - port = try c.decode(Int.self, forKey: .port) - username = try c.decode(String.self, forKey: .username) - defaultSession = try c.decode(String.self, forKey: .defaultSession) - lastConnectedAt = try c.decodeIfPresent(Date.self, forKey: .lastConnectedAt) - password = KeychainHelper.load(account: id.uuidString) ?? "" - } - - func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - try c.encode(id, forKey: .id) - try c.encode(name, forKey: .name) - try c.encode(host, forKey: .host) - try c.encode(port, forKey: .port) - try c.encode(username, forKey: .username) - try c.encode(defaultSession, forKey: .defaultSession) - try c.encodeIfPresent(lastConnectedAt, forKey: .lastConnectedAt) - } - - /// Persist the password to Keychain (call after add/update). - func savePasswordToKeychain() { - KeychainHelper.save(password, account: id.uuidString) - } - - /// Remove the password from Keychain (call on delete). - func deletePasswordFromKeychain() { - KeychainHelper.delete(account: id.uuidString) - } - - private var address: String { - port != 22 ? "\(host):\(port)" : host - } - - var displayName: String { - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - return !trimmed.isEmpty ? trimmed : "\(username)@\(address)" - } - - var subtitle: String { - let session = defaultSession.trimmingCharacters(in: .whitespacesAndNewlines) - guard !session.isEmpty else { return "\(username)@\(address)" } - return "\(username)@\(address) · \(session)" - } - - var isValid: Bool { - !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !password.isEmpty && - port > 0 && port <= 65535 - } -} - -// MARK: - Keychain Helper - -enum KeychainHelper { - private static let service = "com.vaayne.mori-remote.servers" - - static func save(_ password: String, account: String) { - guard let data = password.data(using: .utf8) else { return } - delete(account: account) // remove old entry first - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecValueData as String: data, - ] - SecItemAdd(query as CFDictionary, nil) - } - - static func load(account: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - static func delete(account: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - SecItemDelete(query as CFDictionary) - } -} diff --git a/MoriRemote/MoriRemote/Models/ServerStore.swift b/MoriRemote/MoriRemote/Models/ServerStore.swift deleted file mode 100644 index 08acc971..00000000 --- a/MoriRemote/MoriRemote/Models/ServerStore.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation -import Observation -import os.log - -private let log = Logger(subsystem: "com.vaayne.mori-remote", category: "ServerStore") - -@MainActor -@Observable -final class ServerStore { - private(set) var servers: [Server] = [] - - private static let fileName = "servers.json" - - var sortedServers: [Server] { - servers.enumerated() - .sorted { lhs, rhs in - switch (lhs.element.lastConnectedAt, rhs.element.lastConnectedAt) { - case let (left?, right?) where left != right: - return left > right - case (.some, .none): - return true - case (.none, .some): - return false - default: - return lhs.offset < rhs.offset - } - } - .map(\.element) - } - - init() { - servers = Self.load() - } - - func add(_ server: Server) { - server.savePasswordToKeychain() - servers.append(server) - save() - } - - func update(_ server: Server) { - guard let index = servers.firstIndex(where: { $0.id == server.id }) else { return } - server.savePasswordToKeychain() - servers[index] = server - save() - } - - func markConnected(_ serverID: Server.ID, at date: Date = Date()) { - guard let index = servers.firstIndex(where: { $0.id == serverID }) else { return } - servers[index].lastConnectedAt = date - save() - } - - func delete(_ server: Server) { - server.deletePasswordFromKeychain() - servers.removeAll { $0.id == server.id } - save() - } - - func delete(at offsets: IndexSet) { - for index in offsets { - servers[index].deletePasswordFromKeychain() - } - servers.remove(atOffsets: offsets) - save() - } - - func move(from source: IndexSet, to destination: Int) { - servers.move(fromOffsets: source, toOffset: destination) - save() - } - - // MARK: - Persistence - - private func save() { - do { - let data = try JSONEncoder().encode(servers) - try data.write(to: Self.fileURL, options: .atomic) - } catch { - log.error("Save failed: \(error.localizedDescription)") - } - } - - private static func load() -> [Server] { - guard let data = try? Data(contentsOf: fileURL), - let servers = try? JSONDecoder().decode([Server].self, from: data) - else { - return [] - } - return servers - } - - private static var fileURL: URL { - guard let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - // Fallback to temp directory — should never happen on iOS - return FileManager.default.temporaryDirectory.appendingPathComponent(fileName) - } - return dir.appendingPathComponent(fileName) - } -} diff --git a/MoriRemote/MoriRemote/MoriRemoteApp.swift b/MoriRemote/MoriRemote/MoriRemoteApp.swift index fb3f6952..72b2219d 100644 --- a/MoriRemote/MoriRemote/MoriRemoteApp.swift +++ b/MoriRemote/MoriRemote/MoriRemoteApp.swift @@ -1,191 +1,26 @@ import SwiftUI +import UIKit @main struct MoriRemoteApp: App { - @State private var coordinator = ShellCoordinator() - @State private var store = ServerStore() - @State private var regularWidthSelection = RegularWidthServerSelection() - @State private var terminalSessionHost = TerminalSessionHost() + @State private var root = RemoteRootModel() var body: some Scene { WindowGroup { - RootView( - regularWidthSelection: regularWidthSelection, - terminalSessionHost: terminalSessionHost - ) - .environment(coordinator) - .environment(store) - } - } -} - -private struct RootView: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(ShellCoordinator.self) private var coordinator - @Environment(ServerStore.self) private var store - @State private var showsCompactTerminal = false - @State private var renameTarget: TmuxSession? - @State private var renameText = "" - - let regularWidthSelection: RegularWidthServerSelection - let terminalSessionHost: TerminalSessionHost - - var body: some View { - Group { - if horizontalSizeClass == .regular { - regularWidthContent - } else { - compactContent - } - } - .animation(.easeInOut(duration: 0.25), value: coordinator.state) - .onAppear { - terminalSessionHost.handleCoordinatorStateChange( - coordinator.state, - activeServerID: coordinator.activeServer?.id - ) - } - .onChange(of: coordinator.state) { _, newState in - if newState == .disconnected || newState == .connecting { - showsCompactTerminal = false - } else if newState == .shell, let serverID = coordinator.activeServer?.id { - store.markConnected(serverID) - } - terminalSessionHost.handleCoordinatorStateChange( - newState, - activeServerID: coordinator.activeServer?.id - ) - } - .onChange(of: coordinator.activeServer?.id) { _, newServerID in - regularWidthSelection.remember(coordinator.activeServer) - terminalSessionHost.handleCoordinatorStateChange( - coordinator.state, - activeServerID: newServerID - ) - } - } - - @ViewBuilder - private var compactContent: some View { - switch coordinator.state { - case .disconnected, .connecting: - ServerListView() - - case .connected: - terminalContent - - case .shell: - compactWorkspace - } - } - - @ViewBuilder - private var regularWidthContent: some View { - switch coordinator.state { - case .disconnected, .connecting: - RegularWidthServerBrowserView(selection: regularWidthSelection) - - case .connected, .shell: - terminalContent - } - } - - private var compactWorkspace: some View { - NavigationStack { - if let server = coordinator.activeServer { - WorkspaceView( - serverName: server.displayName, - sessions: coordinator.tmuxSessions, - activeSessionName: coordinator.tmuxActiveSession?.name, - activeWindowID: coordinator.tmuxActiveSession?.windows.first(where: { $0.isActive })?.id, - showsDismissButton: false, - onSelectWindow: { session, windowIndex in - coordinator.selectTmuxWindow(session: session, windowIndex: windowIndex) - showsCompactTerminal = true - }, - onSelectPane: { session, windowIndex, paneId in - coordinator.selectTmuxPane(session: session, windowIndex: windowIndex, paneId: paneId) - showsCompactTerminal = true - }, - onSwitchSession: { session in coordinator.switchTmuxSession(session) }, - onRenameSession: { session in - renameTarget = session - renameText = session.name - }, - onKillSession: { session in coordinator.closeTmuxSession(session) }, - onNewWindowAfter: { session, windowIndex in - coordinator.newTmuxWindowAfter(session: session, windowIndex: windowIndex) - }, - onCloseWindow: { session, windowIndex in - coordinator.closeTmuxWindow(session: session, windowIndex: windowIndex) - }, - onNewWindow: { coordinator.newTmuxWindow() }, - onNewSession: { coordinator.newTmuxSession() }, - onSwitchHost: returnToDisconnectedBrowser, - onDisconnect: returnToDisconnectedBrowser, - onDismiss: nil, - onRefresh: { coordinator.refreshTmuxState() } - ) - .navigationBarHidden(true) - .alert(String(localized: "Rename Session"), isPresented: showRenameAlert) { - TextField(String(localized: "Session name"), text: $renameText) - Button(String(localized: "Cancel"), role: .cancel) { } - Button(String(localized: "Rename")) { - if let session = renameTarget, !renameText.isEmpty { - coordinator.renameTmuxSession(session.name, to: renameText) - } - } + Group { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--ghostty-terminal-probe") { + GhosttyTerminalProbe() + } else { + RemoteRootView(root: root) } - .navigationDestination(isPresented: $showsCompactTerminal) { - TerminalScreen( - sessionHost: terminalSessionHost, - serverName: server.displayName, - onDisconnect: returnToDisconnectedBrowser, - onSwitchHost: returnToDisconnectedBrowser, - onBackToWorkspace: { showsCompactTerminal = false } - ) - .navigationBarHidden(true) - } - } else { - ServerListView() + #else + RemoteRootView(root: root) + #endif + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in + root.handleMemoryWarning() } } } - - @ViewBuilder - private var terminalContent: some View { - if let server = coordinator.activeServer { - TerminalScreen( - sessionHost: terminalSessionHost, - serverName: server.displayName, - onDisconnect: returnToDisconnectedBrowser, - onSwitchHost: returnToDisconnectedBrowser, - onBackToWorkspace: { showsCompactTerminal = false } - ) - } else if horizontalSizeClass == .regular { - RegularWidthServerBrowserView(selection: regularWidthSelection) - } else { - ServerListView() - } - } - - private var showRenameAlert: Binding { - Binding( - get: { renameTarget != nil }, - set: { if !$0 { renameTarget = nil } } - ) - } - - private func returnToDisconnectedBrowser() { - let activeServer = coordinator.activeServer - regularWidthSelection.remember(activeServer) - regularWidthSelection.select(activeServer) - Task { await coordinator.disconnect() } - } -} - -extension String { - static func localized(_ key: String.LocalizationValue) -> String { - String(localized: key) - } } diff --git a/MoriRemote/MoriRemote/Persistence/LegacyMigration.swift b/MoriRemote/MoriRemote/Persistence/LegacyMigration.swift new file mode 100644 index 00000000..904dc9c8 --- /dev/null +++ b/MoriRemote/MoriRemote/Persistence/LegacyMigration.swift @@ -0,0 +1,198 @@ +import Foundation + +struct LegacyServerRecord: Codable, Sendable { + let id: UUID + let name: String + let host: String + let port: Int + let username: String + let defaultSession: String + let lastConnectedAt: Date? +} + +enum LegacyMigrationDisposition: String, Codable, Equatable, Sendable { + case migrated + case migratedWithoutCredential + case skippedInvalid +} + +struct LegacyMigrationRecord: Codable, Equatable, Sendable { + let legacyID: String + let disposition: LegacyMigrationDisposition +} + +struct LegacyMigrationMarker: Codable, Equatable, Sendable { + static let schemaVersion = 1 + let schemaVersion: Int + let completedAt: Date + let records: [LegacyMigrationRecord] +} + +struct LegacyMigrationReport: Equatable, Sendable { + let completed: Bool + let records: [LegacyMigrationRecord] +} + +/// One-way, retry-safe importer. It never writes legacy Documents or the old Keychain service. +struct LegacyServerMigrator: Sendable { + let storage: MoriRemoteStorage + let legacyServersURL: URL + let legacyCredentials: any CredentialReading + let destinationCredentials: any CredentialStoring + let now: @Sendable () -> Date + + init( + storage: MoriRemoteStorage, + legacyServersURL: URL, + legacyCredentials: any CredentialReading = KeychainCredentialStore.legacyReader(), + destinationCredentials: any CredentialStoring = KeychainCredentialStore(), + now: @escaping @Sendable () -> Date = Date.init + ) { + self.storage = storage + self.legacyServersURL = legacyServersURL + self.legacyCredentials = legacyCredentials + self.destinationCredentials = destinationCredentials + self.now = now + } + + func migrateIfNeeded() throws -> LegacyMigrationReport { + if let marker = try storage.migration.loadIfPresent() { + return LegacyMigrationReport(completed: true, records: marker.records) + } + + let records = try decodeLegacyRecords() + var dispositions: [LegacyMigrationRecord] = [] + var seen = Set() + + for record in records.enumerated().sorted(by: { migrationOrderKey($0) < migrationOrderKey($1) }).map(\.element) { + switch record { + case .invalid(let index): + dispositions.append(LegacyMigrationRecord(legacyID: "invalid-\(index)", disposition: .skippedInvalid)) + case .server(let legacy): + guard seen.insert(legacy.id).inserted else { + dispositions.append(LegacyMigrationRecord(legacyID: legacy.id.uuidString, disposition: .skippedInvalid)) + continue + } + dispositions.append(try migrate(legacy)) + } + } + + try validateTerminalSnapshot(dispositions) + + // This is intentionally last: any persistence/keychain failure above leaves migration retryable. + let marker = LegacyMigrationMarker(schemaVersion: LegacyMigrationMarker.schemaVersion, completedAt: now(), records: dispositions) + try storage.migration.save(marker) + return LegacyMigrationReport(completed: true, records: dispositions) + } + + private func migrate(_ legacy: LegacyServerRecord) throws -> LegacyMigrationRecord { + let server = SavedServer( + id: legacy.id, + name: legacy.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? legacy.username + "@" + legacy.host : legacy.name, + host: legacy.host, + port: legacy.port, + username: legacy.username, + identityID: legacy.id, + lastConnectedAt: legacy.lastConnectedAt + ) + let session = legacy.defaultSession + let normalizedSession = session.contains(where: { $0.isNewline || $0 == "\0" }) + ? session + : (session.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "main" : session) + let workspace = SavedWorkspace( + id: legacy.id, + serverID: legacy.id, + name: normalizedSession, + tmuxSession: normalizedSession, + lastConnectedAt: legacy.lastConnectedAt + ) + let identity = SSHIdentity(id: legacy.id, serverID: legacy.id, kind: .password) + + do { + _ = try server.validated() + _ = try workspace.validated() + _ = try identity.validated() + } catch { + return LegacyMigrationRecord(legacyID: legacy.id.uuidString, disposition: .skippedInvalid) + } + + // Insert-only retries preserve any profile edits made after an interrupted first attempt. + _ = try storage.servers.insertIfAbsent(server) + _ = try storage.identities.insertIfAbsent(identity) + _ = try storage.workspaces.insertIfAbsent(workspace) + + guard let password = try legacyCredentials.password(for: legacy.id), !password.isEmpty else { + return LegacyMigrationRecord(legacyID: legacy.id.uuidString, disposition: .migratedWithoutCredential) + } + // Add-only retries may complete a missing destination secret, never replace a user-edited one. + _ = try destinationCredentials.createPasswordIfAbsent(password, for: legacy.id) + return LegacyMigrationRecord(legacyID: legacy.id.uuidString, disposition: .migrated) + } + + private func migrationOrderKey(_ entry: (offset: Int, element: DecodedRecord)) -> MigrationOrderKey { + switch entry.element { + case let .server(server) where server.lastConnectedAt != nil: + return MigrationOrderKey(kind: 0, date: server.lastConnectedAt, sourceIndex: entry.offset) + case .server: + return MigrationOrderKey(kind: 1, date: nil, sourceIndex: entry.offset) + case .invalid: + return MigrationOrderKey(kind: 2, date: nil, sourceIndex: entry.offset) + } + } + + /// Strict total order: dated profiles (newest first), undated profiles, invalid source records; source index breaks ties. + private struct MigrationOrderKey: Comparable { + let kind: Int + let date: Date? + let sourceIndex: Int + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.kind != rhs.kind { return lhs.kind < rhs.kind } + if let left = lhs.date, let right = rhs.date, left != right { return left > right } + return lhs.sourceIndex < rhs.sourceIndex + } + } + + private func validateTerminalSnapshot(_ dispositions: [LegacyMigrationRecord]) throws { + let servers = try storage.servers.all() + let identities = try storage.identities.all() + let workspaces = try storage.workspaces.all() + + for record in dispositions where record.disposition != .skippedInvalid { + guard let id = UUID(uuidString: record.legacyID), + let server = servers.first(where: { $0.id == id }), + let identity = identities.first(where: { $0.id == id }), identity.serverID == server.id, + let workspace = workspaces.first(where: { $0.id == id }), workspace.serverID == server.id else { + throw PersistenceError.corruptStore("migration referential integrity") + } + if record.disposition == .migrated, + try destinationCredentials.password(for: id) == nil { + throw PersistenceError.corruptStore("migration credential disposition") + } + } + } + + private enum DecodedRecord: Sendable { + case server(LegacyServerRecord) + case invalid(Int) + } + + private func decodeLegacyRecords() throws -> [DecodedRecord] { + guard FileManager.default.fileExists(atPath: legacyServersURL.path) else { return [] } + let data = try Data(contentsOf: legacyServersURL) + guard let raw = try? JSONSerialization.jsonObject(with: data) as? [Any] else { + // Malformed source is terminal: there is no safe record to retry. Preserve the bytes untouched. + return [.invalid(0)] + } + // Legacy ServerStore used JSONEncoder's default Date representation. + let decoder = JSONDecoder() + return raw.enumerated().map { index, object in + guard JSONSerialization.isValidJSONObject(object), + let itemData = try? JSONSerialization.data(withJSONObject: object), + let server = try? decoder.decode(LegacyServerRecord.self, from: itemData) else { + return .invalid(index) + } + return .server(server) + } + } +} diff --git a/MoriRemote/MoriRemote/Persistence/Stores.swift b/MoriRemote/MoriRemote/Persistence/Stores.swift new file mode 100644 index 00000000..0b47adf0 --- /dev/null +++ b/MoriRemote/MoriRemote/Persistence/Stores.swift @@ -0,0 +1,267 @@ +import Foundation +import Security + +/// Credentials are usable only while this device is unlocked and never migrate +/// through an encrypted backup. Keep this policy central so passwords and +/// imported private keys cannot silently diverge. +enum MoriRemoteKeychainProtection { + static func accessibility() -> CFString { kSecAttrAccessibleWhenUnlockedThisDeviceOnly } + + static func item(service: String, account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + static func writeAttributes() -> [String: Any] { + [kSecAttrAccessible as String: accessibility()] + } +} + +protocol AtomicDataWriting: Sendable { + func write(_ data: Data, to url: URL) throws +} + +struct FoundationAtomicDataWriter: AtomicDataWriting { + func write(_ data: Data, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } +} + +enum PersistenceError: Error, Equatable, Sendable { + case notFound(UUID) + case corruptStore(String) + case keychain(OSStatus) +} + +/// A small persistence boundary: encode whole collections atomically, never expose filesystem details to callers. +struct AtomicJSONStore: Sendable { + let url: URL + private let writer: any AtomicDataWriting + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(url: URL, writer: any AtomicDataWriting = FoundationAtomicDataWriter()) { + self.url = url + self.writer = writer + let encoder = JSONEncoder() + // Preserve legacy Date's fractional seconds exactly during migration. + encoder.outputFormatting = [.sortedKeys] + self.encoder = encoder + self.decoder = JSONDecoder() + } + + func load(or defaultValue: @autoclosure () -> Value) throws -> Value { + try loadIfPresent() ?? defaultValue() + } + + func loadIfPresent() throws -> Value? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + do { + return try decoder.decode(Value.self, from: Data(contentsOf: url)) + } catch { + throw PersistenceError.corruptStore(url.lastPathComponent) + } + } + + func save(_ value: Value) throws { + try writer.write(try encoder.encode(value), to: url) + } +} + +protocol UUIDRecord: Identifiable, Codable, Sendable where ID == UUID {} +extension SavedServer: UUIDRecord {} +extension SavedWorkspace: UUIDRecord {} +extension SSHIdentity: UUIDRecord {} + +struct UUIDJSONRepository: Sendable { + private let store: AtomicJSONStore<[Record]> + + init(url: URL, writer: any AtomicDataWriting = FoundationAtomicDataWriter()) { + store = AtomicJSONStore(url: url, writer: writer) + } + + func all() throws -> [Record] { try store.load(or: []) } + + /// Migration uses this instead of upsert: an interrupted retry must not clobber later user edits. + func insertIfAbsent(_ record: Record) throws -> Bool { + var records = try all() + guard !records.contains(where: { $0.id == record.id }) else { return false } + records.append(record) + try store.save(records) + return true + } + + func replace(_ record: Record) throws { + var records = try all() + guard let index = records.firstIndex(where: { $0.id == record.id }) else { + throw PersistenceError.notFound(record.id) + } + records[index] = record + try store.save(records) + } + + func remove(_ id: UUID) throws { + var records = try all() + guard records.contains(where: { $0.id == id }) else { throw PersistenceError.notFound(id) } + records.removeAll { $0.id == id } + try store.save(records) + } +} + +/// Server updates own the host-trust invalidation rule so callers cannot accidentally carry trust to a new endpoint. +struct SavedServerRepository: Sendable { + private let records: UUIDJSONRepository + private let trustedHosts: TrustedHostStore + + init(url: URL, trustedHosts: TrustedHostStore, writer: any AtomicDataWriting = FoundationAtomicDataWriter()) { + records = UUIDJSONRepository(url: url, writer: writer) + self.trustedHosts = trustedHosts + } + + func all() throws -> [SavedServer] { try records.all() } + func insertIfAbsent(_ server: SavedServer) throws -> Bool { try records.insertIfAbsent(server) } + func remove(_ id: UUID) throws { try records.remove(id) } + + func replace(_ server: SavedServer) throws { + _ = try server.validated() + let previous = try all().first { $0.id == server.id } + if let previous, let oldEndpoint = previous.endpoint, let newEndpoint = server.endpoint, oldEndpoint != newEndpoint { + // Remove trust first: a failed invalidation must leave the old endpoint authoritative. + try trustedHosts.invalidateTrust(for: server.id, ifEndpointChangedFrom: newEndpoint) + } + try records.replace(server) + } +} + +protocol CredentialStoring: CredentialReading { + func password(for identityID: UUID) throws -> String? + /// Returns false when a new-app credential already exists; it is never replaced. + func createPasswordIfAbsent(_ password: String, for identityID: UUID) throws -> Bool + func setPassword(_ password: String, for identityID: UUID) throws + func deletePassword(for identityID: UUID) throws +} + +struct KeychainCredentialStore: CredentialStoring { + static let service = "com.vaayne.mori-remote.credentials" + static let legacyService = "com.vaayne.mori-remote.servers" + + let service: String + /// Legacy reads must not alter the old service: migration is explicitly + /// one-way and read-only at its source. + private let allowsProtectionUpgradeOnRead: Bool + /// Only the new destination service is eligible, even if a caller passes + /// the legacy service to the general initializer by mistake. + var upgradesProtectionOnRead: Bool { + allowsProtectionUpgradeOnRead && service == Self.service + } + + init(service: String = Self.service, upgradesProtectionOnRead: Bool = true) { + self.service = service + allowsProtectionUpgradeOnRead = upgradesProtectionOnRead + } + + static func legacyReader() -> Self { + .init(service: legacyService, upgradesProtectionOnRead: false) + } + + func password(for identityID: UUID) throws -> String? { + var query = MoriRemoteKeychainProtection.item(service: service, account: identityID.uuidString) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { throw PersistenceError.keychain(status) } + // Upgrade destination entries on read. A failure here must not discard + // a valid credential; the current authenticated operation can continue. + // Never mutate the old migration source service. + if upgradesProtectionOnRead { + _ = SecItemUpdate(MoriRemoteKeychainProtection.item(service: service, account: identityID.uuidString) as CFDictionary, MoriRemoteKeychainProtection.writeAttributes() as CFDictionary) + } + return String(data: data, encoding: .utf8) + } + + func createPasswordIfAbsent(_ password: String, for identityID: UUID) throws -> Bool { + var query = MoriRemoteKeychainProtection.item(service: service, account: identityID.uuidString) + query[kSecValueData as String] = Data(password.utf8) + query.merge(MoriRemoteKeychainProtection.writeAttributes(), uniquingKeysWith: { _, replacement in replacement }) + let status = SecItemAdd(query as CFDictionary, nil) + if status == errSecDuplicateItem { return false } + guard status == errSecSuccess else { throw PersistenceError.keychain(status) } + return true + } + + func setPassword(_ password: String, for identityID: UUID) throws { + var attributes = MoriRemoteKeychainProtection.writeAttributes() + attributes[kSecValueData as String] = Data(password.utf8) + let query = MoriRemoteKeychainProtection.item(service: service, account: identityID.uuidString) + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + _ = try createPasswordIfAbsent(password, for: identityID) + } else if status != errSecSuccess { + throw PersistenceError.keychain(status) + } + } + + func deletePassword(for identityID: UUID) throws { + let status = SecItemDelete(MoriRemoteKeychainProtection.item(service: service, account: identityID.uuidString) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw PersistenceError.keychain(status) } + } +} + +struct TrustedHostStore: Sendable { + private let store: AtomicJSONStore<[TrustedHost]> + + init(url: URL, writer: any AtomicDataWriting = FoundationAtomicDataWriter()) { + store = AtomicJSONStore(url: url, writer: writer) + } + + func trustedHost(for serverID: UUID, endpoint: CanonicalEndpoint) throws -> TrustedHost? { + try store.load(or: []).first { $0.serverID == serverID && $0.endpoint == endpoint } + } + + /// A changed host/port cannot inherit trust; all prior endpoint entries for this server are removed. + func trust(_ host: TrustedHost) throws { + var hosts = try store.load(or: []) + hosts.removeAll { $0.serverID == host.serverID } + hosts.append(host) + try store.save(hosts) + } + + func invalidateTrust(for serverID: UUID, ifEndpointChangedFrom endpoint: CanonicalEndpoint) throws { + var hosts = try store.load(or: []) + let originalCount = hosts.count + hosts.removeAll { $0.serverID == serverID && $0.endpoint != endpoint } + if hosts.count != originalCount { try store.save(hosts) } + } +} + +struct MoriRemoteStorage: Sendable { + let root: URL + let servers: SavedServerRepository + let workspaces: UUIDJSONRepository + let identities: UUIDJSONRepository + let settings: AtomicJSONStore + let trustedHosts: TrustedHostStore + let migration: AtomicJSONStore + + init(root: URL, writer: any AtomicDataWriting = FoundationAtomicDataWriter()) { + self.root = root + let trustedHosts = TrustedHostStore(url: root.appendingPathComponent("trusted-hosts.json"), writer: writer) + self.trustedHosts = trustedHosts + servers = SavedServerRepository(url: root.appendingPathComponent("servers.json"), trustedHosts: trustedHosts, writer: writer) + workspaces = UUIDJSONRepository(url: root.appendingPathComponent("workspaces.json"), writer: writer) + identities = UUIDJSONRepository(url: root.appendingPathComponent("identities.json"), writer: writer) + settings = AtomicJSONStore(url: root.appendingPathComponent("settings.json"), writer: writer) + migration = AtomicJSONStore(url: root.appendingPathComponent("migration.json"), writer: writer) + } + + static func applicationSupport(fileManager: FileManager = .default) throws -> MoriRemoteStorage { + let base = try fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + return MoriRemoteStorage(root: base.appendingPathComponent("MoriRemote", isDirectory: true)) + } +} diff --git a/MoriRemote/MoriRemote/PrivacyInfo.xcprivacy b/MoriRemote/MoriRemote/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..beab0b74 --- /dev/null +++ b/MoriRemote/MoriRemote/PrivacyInfo.xcprivacy @@ -0,0 +1,24 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index 14a0b9a5..2ee28cc7 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -10,6 +10,16 @@ "Choose a server from the sidebar to review its connection details before connecting." = "Choose a server from the sidebar to review its connection details before connecting."; "Connect" = "Connect"; "Connected" = "Connected"; +"Ghostty terminal probe loading" = "Ghostty terminal probe loading"; +"Starting Ghostty tmux transcript…" = "Starting Ghostty tmux transcript…"; +"Ghostty rendered deterministic tmux transcript" = "Ghostty rendered deterministic tmux transcript"; +"Ghostty probe failed: %@" = "Ghostty probe failed: %@"; +"Ghostty tmux: %@" = "Ghostty tmux: %@"; +"Ghostty renderer failed" = "Ghostty renderer failed"; +"Ghostty transcript fed; waiting for native draw…" = "Ghostty transcript fed; waiting for native draw…"; +"Ghostty renderer did not draw transcript: %@" = "Ghostty renderer did not draw transcript: %@"; +"No live Ghostty terminal surface arrived within 5 seconds." = "No live Ghostty terminal surface arrived within 5 seconds."; +"Ghostty renderer timed out: %@" = "Ghostty renderer timed out: %@"; "Connecting..." = "Connecting..."; "Connecting…" = "Connecting…"; "Connecting to Server" = "Connecting to Server"; @@ -75,6 +85,8 @@ "Terminal renderer is not ready yet." = "Terminal renderer is not ready yet."; "Window" = "Window"; "Working" = "Working"; +"Waiting" = "Waiting"; +"Unknown" = "Unknown"; "Needs input" = "Needs input"; "TMUX SESSION" = "TMUX SESSION"; "tmux did not report a pane ID." = "tmux did not report a pane ID."; @@ -128,3 +140,79 @@ "End key" = "End key"; "Next tab (⌘⇧])" = "Next tab (⌘⇧])"; "Previous tab (⌘⇧[)" = "Previous tab (⌘⇧[)"; +"SSH identity is missing." = "SSH identity is missing."; +"SSH credential is required." = "SSH credential is required."; +"The SSH host key is unknown. Review and trust it before connecting." = "The SSH host key is unknown. Review and trust it before connecting."; +"The SSH host key changed. Connection refused." = "The SSH host key changed. Connection refused."; +"tmux 3.2 or later is required." = "tmux 3.2 or later is required."; +"The tmux version response is invalid." = "The tmux version response is invalid."; +"The tmux executable must be an absolute path or tmux." = "The tmux executable must be an absolute path or tmux."; +"The saved SSH credential does not match its identity." = "The saved SSH credential does not match its identity."; +"Legacy RSA/SHA-1 authentication is disabled." = "Legacy RSA/SHA-1 authentication is disabled."; +"The SSH host-key confirmation is no longer valid. Try again." = "The SSH host-key confirmation is no longer valid. Try again."; +"The tmux command contains an unsupported control character." = "The tmux command contains an unsupported control character."; +"The temporary tmux session could not be verified safely." = "The temporary tmux session could not be verified safely."; +"The temporary tmux session is not grouped with the requested workspace." = "The temporary tmux session is not grouped with the requested workspace."; +"The tmux control connection is closed." = "The tmux control connection is closed."; +"The tmux control connection has already started." = "The tmux control connection has already started."; +"Private key is required." = "Private key is required."; +"Private key file is too large." = "Private key file is too large."; +"Import an OpenSSH private key." = "Import an OpenSSH private key."; +"SSH private key type “%@” is not supported." = "SSH private key type “%@” is not supported."; +"Loading library…" = "Loading library…"; +"Host key confirmation" = "Host Key Confirmation"; +"Replace trusted key" = "Replace Trusted Key"; +"Trust host key" = "Trust Host Key"; +"Previously trusted:" = "Previously trusted:"; +"OK" = "OK"; +"Library" = "Library"; +"Select a workspace" = "Select a Workspace"; +"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"; +"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"; +"Settings" = "Settings"; +"Add server" = "Add Server"; +"Connection lost." = "Connection lost."; +"Reconnecting…" = "Reconnecting…"; +"Disconnected" = "Disconnected"; +"Dismiss keyboard" = "Dismiss Keyboard"; +"Show library" = "Show Library"; +"Split right (shared)" = "Split Right (shared)"; +"Split down (shared)" = "Split Down (shared)"; +"New window (shared)" = "New Window (shared)"; +"Close pane (shared)" = "Close Pane (shared)"; +"Close pane" = "Close Pane"; +"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"; +"Deleting this server also deletes %lld workspaces and their saved credentials." = "Deleting this server also deletes %lld workspaces and their saved credentials."; +"Deleting this workspace disconnects it and cannot be undone." = "Deleting this workspace disconnects it and cannot be undone."; +"Library unavailable" = "Library Unavailable"; +"Copy selection" = "Copy Selection"; +"Waiting for the active tmux pane." = "Waiting for the active tmux pane."; +"Windows" = "Windows"; +"Panes" = "Panes"; +"Workspace controls" = "Workspace Controls"; +"Server" = "Server"; +"Name" = "Name"; +"Workspace" = "Workspace"; +"Workspace name" = "Workspace Name"; +"tmux session" = "tmux session"; +"Authentication" = "Authentication"; +"Identity" = "Identity"; +"Private key" = "Private Key"; +"Private key passphrase (optional)" = "Private Key Passphrase (optional)"; +"Save" = "Save"; +"Initial scrollback: %lld lines" = "Initial scrollback: %lld lines"; +"Local history is limited to 10,000 lines; server copy-mode browsing stays disabled." = "Local history is limited to 10,000 lines; server copy-mode browsing stays disabled."; +"Security" = "Security"; +"Allow legacy RSA/SHA-1 authentication" = "Allow legacy RSA/SHA-1 authentication"; +"Migration" = "Migration"; +"%lld saved profiles migrated" = "%lld saved profiles migrated"; +"Add workspace" = "Add Workspace"; +"Edit workspace" = "Edit Workspace"; +"Delete workspace" = "Delete Workspace"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index 049e5ea7..32c41a6e 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -10,6 +10,16 @@ "Choose a server from the sidebar to review its connection details before connecting." = "从侧边栏选择一个服务器,先查看连接详情,再决定是否连接。"; "Connect" = "连接"; "Connected" = "已连接"; +"Ghostty terminal probe loading" = "正在载入 Ghostty 终端探针"; +"Starting Ghostty tmux transcript…" = "正在启动 Ghostty tmux 记录…"; +"Ghostty rendered deterministic tmux transcript" = "Ghostty 已渲染确定性 tmux 记录"; +"Ghostty probe failed: %@" = "Ghostty 探针失败:%@"; +"Ghostty tmux: %@" = "Ghostty tmux:%@"; +"Ghostty renderer failed" = "Ghostty 渲染器失败"; +"Ghostty transcript fed; waiting for native draw…" = "Ghostty 记录已送入,正在等待原生绘制…"; +"Ghostty renderer did not draw transcript: %@" = "Ghostty 渲染器未绘制记录:%@"; +"No live Ghostty terminal surface arrived within 5 seconds." = "5 秒内未出现可用的 Ghostty 终端 surface。"; +"Ghostty renderer timed out: %@" = "Ghostty 渲染器超时:%@"; "Connecting..." = "连接中..."; "Connecting…" = "正在连接…"; "Connecting to Server" = "正在连接服务器"; @@ -75,6 +85,8 @@ "Terminal renderer is not ready yet." = "终端渲染器尚未就绪。"; "Window" = "窗口"; "Working" = "运行中"; +"Waiting" = "等待中"; +"Unknown" = "未知"; "Needs input" = "等待输入"; "TMUX SESSION" = "TMUX 会话"; "tmux did not report a pane ID." = "tmux 没有返回 pane ID。"; @@ -128,3 +140,79 @@ "End key" = "End 键"; "Next tab (⌘⇧])" = "下一个标签(⌘⇧])"; "Previous tab (⌘⇧[)" = "上一个标签(⌘⇧[)"; +"SSH identity is missing." = "缺少 SSH 身份。"; +"SSH credential is required." = "需要 SSH 凭据。"; +"The SSH host key is unknown. Review and trust it before connecting." = "SSH 主机密钥未知。请先检查并信任后再连接。"; +"The SSH host key changed. Connection refused." = "SSH 主机密钥已变更,已拒绝连接。"; +"tmux 3.2 or later is required." = "需要 tmux 3.2 或更高版本。"; +"The tmux version response is invalid." = "tmux 版本响应无效。"; +"The tmux executable must be an absolute path or tmux." = "tmux 可执行文件必须是绝对路径或 tmux。"; +"The saved SSH credential does not match its identity." = "已保存的 SSH 凭据与其身份不匹配。"; +"Legacy RSA/SHA-1 authentication is disabled." = "已禁用旧版 RSA/SHA-1 身份验证。"; +"The SSH host-key confirmation is no longer valid. Try again." = "SSH 主机密钥确认已失效,请重试。"; +"The tmux command contains an unsupported control character." = "tmux 命令包含不受支持的控制字符。"; +"The temporary tmux session could not be verified safely." = "无法安全验证临时 tmux 会话。"; +"The temporary tmux session is not grouped with the requested workspace." = "临时 tmux 会话未与请求的工作区分组。"; +"The tmux control connection is closed." = "tmux 控制连接已关闭。"; +"The tmux control connection has already started." = "tmux 控制连接已经启动。"; +"Private key is required." = "需要私钥。"; +"Private key file is too large." = "私钥文件过大。"; +"Import an OpenSSH private key." = "请导入 OpenSSH 私钥。"; +"SSH private key type “%@” is not supported." = "不支持 SSH 私钥类型“%@”。"; +"Loading library…" = "正在加载资料库…"; +"Host key confirmation" = "确认主机密钥"; +"Replace trusted key" = "替换已信任密钥"; +"Trust host key" = "信任主机密钥"; +"Previously trusted:" = "此前信任:"; +"OK" = "好"; +"Library" = "资料库"; +"Select a workspace" = "选择工作区"; +"Choose a saved workspace to open its terminal." = "选择一个已保存的工作区以打开终端。"; +"No saved servers" = "没有已保存的服务器"; +"No matching workspaces" = "没有匹配的工作区"; +"Add a server and workspace to begin." = "添加服务器和工作区后即可开始。"; +"Edit server" = "编辑服务器"; +"Delete server" = "删除服务器"; +"Filter servers and workspaces" = "筛选服务器和工作区"; +"Settings" = "设置"; +"Add server" = "添加服务器"; +"Connection lost." = "连接已中断。"; +"Reconnecting…" = "正在重连…"; +"Disconnected" = "已断开"; +"Dismiss keyboard" = "收起键盘"; +"Show library" = "显示资料库"; +"Split right (shared)" = "向右分屏(共享)"; +"Split down (shared)" = "向下分屏(共享)"; +"New window (shared)" = "新建窗口(共享)"; +"Close pane (shared)" = "关闭面板(共享)"; +"Close pane" = "关闭面板"; +"Close shared pane?" = "关闭共享面板?"; +"Closing this shared pane affects every tmux client." = "关闭此共享面板会影响所有 tmux 客户端。"; +"Confirm destructive action" = "确认破坏性操作"; +"Deleting this server also deletes %lld workspaces and their saved credentials." = "删除此服务器还会删除 %lld 个工作区及其保存的凭据。"; +"Deleting this workspace disconnects it and cannot be undone." = "删除此工作区会断开连接,且无法撤销。"; +"Library unavailable" = "资料库不可用"; +"Copy selection" = "复制所选内容"; +"Waiting for the active tmux pane." = "正在等待活动的 tmux 面板。"; +"Windows" = "窗口"; +"Panes" = "面板"; +"Workspace controls" = "工作区控制"; +"Server" = "服务器"; +"Name" = "名称"; +"Workspace" = "工作区"; +"Workspace name" = "工作区名称"; +"tmux session" = "tmux 会话"; +"Authentication" = "身份验证"; +"Identity" = "身份"; +"Private key" = "私钥"; +"Private key passphrase (optional)" = "私钥密码(可选)"; +"Save" = "保存"; +"Initial scrollback: %lld lines" = "初始回滚:%lld 行"; +"Local history is limited to 10,000 lines; server copy-mode browsing stays disabled." = "本地历史最多保留 10,000 行;已禁用服务器 copy-mode 浏览。"; +"Security" = "安全"; +"Allow legacy RSA/SHA-1 authentication" = "允许旧版 RSA/SHA-1 身份验证"; +"Migration" = "迁移"; +"%lld saved profiles migrated" = "已迁移 %lld 个保存的配置"; +"Add workspace" = "添加工作区"; +"Edit workspace" = "编辑工作区"; +"Delete workspace" = "删除工作区"; diff --git a/MoriRemote/MoriRemote/SSH/CitadelSSHTransport.swift b/MoriRemote/MoriRemote/SSH/CitadelSSHTransport.swift new file mode 100644 index 00000000..802d39c3 --- /dev/null +++ b/MoriRemote/MoriRemote/SSH/CitadelSSHTransport.swift @@ -0,0 +1,274 @@ +@preconcurrency import Citadel +@preconcurrency import Crypto +import Foundation +import NIO +import NIOPosix +@preconcurrency import NIOSSH + +/// Citadel supplies key decoding/authentication; the public NIOSSH APIs provide the +/// multiplexed authenticated root and its bare exec children. +struct CitadelSSHRootConnector: SSHRootConnecting, Sendable { + let server: SavedServer + let auth: ResolvedSSHAuth + let trust: SSHHostTrustResolver + + func connect() async throws -> any SSHRootConnection { + let validator = CitadelHostKeyValidator(server: server, trust: trust) + let bootstrap = ClientBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .channelInitializer { channel in + do { + let ssh = NIOSSHHandler( + role: .client(.init( + userAuthDelegate: try self.authenticationMethod(), + serverAuthDelegate: validator + )), + allocator: channel.allocator, + inboundChildChannelInitializer: { child, _ in + child.eventLoop.makeFailedFuture(CitadelTransportError.unexpectedInboundChannel) + } + ) + let authentication = SSHAuthenticationGate(eventLoop: channel.eventLoop) + return channel.pipeline.addHandler(ssh).flatMap { + channel.pipeline.addHandler(authentication) + } + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + } + .connectTimeout(.seconds(30)) + .channelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1) + + var root: Channel? + do { + let channel = try await bootstrap.connect(host: server.host, port: server.port).get() + root = channel + let gate = try await channel.pipeline.handler(type: SSHAuthenticationGate.self).get() + try await gate.authenticated.get() + let ssh = try await channel.pipeline.handler(type: NIOSSHHandler.self).get() + return CitadelSSHRootConnection(channel: channel, handler: ssh) + } catch { + if let root { try? await root.close() } + throw error + } + } + + private func authenticationMethod() throws -> SSHAuthenticationMethod { + switch auth { + case let .password(username, password, _, _): + return .passwordBased(username: username, password: password) + case let .privateKey(username, credential, _, _): + let passphrase = credential.passphrase.map { Data($0.utf8) } + switch try SSHPrivateKeyInspector.inspect(credential.privateKeyPEM).keyType { + case .ed25519: + return .ed25519(username: username, privateKey: try .init(sshEd25519: credential.privateKeyPEM, decryptionKey: passphrase)) + case .rsa: + return .rsa(username: username, privateKey: try .init(sshRsa: credential.privateKeyPEM, decryptionKey: passphrase)) + case .ecdsaP256: + return .p256(username: username, privateKey: try .init(sshEcdsaP256: credential.privateKeyPEM, decryptionKey: passphrase)) + case .ecdsaP384: + return .p384(username: username, privateKey: try .init(sshEcdsaP384: credential.privateKeyPEM, decryptionKey: passphrase)) + case .ecdsaP521: + return .p521(username: username, privateKey: try .init(sshEcdsaP521: credential.privateKeyPEM, decryptionKey: passphrase)) + } + } + } +} + +enum SSHAuthenticationCompletion: Equatable, Sendable { + case succeed + case fail(CitadelTransportError) +} + +/// One-shot gate state is separate so terminal-event ordering is testable without +/// constructing an NIO pipeline. +final class SSHAuthenticationCompletionState: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + func claim(_ completion: SSHAuthenticationCompletion) -> SSHAuthenticationCompletion? { + lock.withLock { + guard !completed else { return nil } + completed = true + return completion + } + } +} + +private final class SSHAuthenticationGate: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = Any + let authenticated: EventLoopFuture + private let promise: EventLoopPromise + private let completionState = SSHAuthenticationCompletionState() + + init(eventLoop: EventLoop) { + promise = eventLoop.makePromise(of: Void.self) + authenticated = promise.futureResult + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if event is UserAuthSuccessEvent { + complete(.succeed) + } + context.fireUserInboundEventTriggered(event) + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + complete(.fail(.closed), underlyingError: error) + context.fireErrorCaught(error) + } + + func channelInactive(context: ChannelHandlerContext) { + complete(.fail(.closed)) + context.fireChannelInactive() + } + + private func complete(_ completion: SSHAuthenticationCompletion, underlyingError: Error? = nil) { + guard let completion = completionState.claim(completion) else { return } + switch completion { + case .succeed: + promise.succeed(()) + case .fail: + promise.fail(underlyingError ?? CitadelTransportError.closed) + } + } +} + +private final class CitadelHostKeyValidator: NIOSSHClientServerAuthenticationDelegate, @unchecked Sendable { + let server: SavedServer + let trust: SSHHostTrustResolver + + init(server: SavedServer, trust: SSHHostTrustResolver) { + self.server = server + self.trust = trust + } + + func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise) { + do { + let fields = String(openSSHPublicKey: hostKey).split(separator: " ") + guard fields.count >= 2, let blob = Data(base64Encoded: String(fields[1])) else { + throw SSHHostTrustError.staleChallenge + } + let digest = Data(SHA256.hash(data: blob)).base64EncodedString().replacingOccurrences(of: "=", with: "") + try trust.verify(server: server, algorithm: String(fields[0]), fingerprint: "SHA256:\(digest)") + validationCompletePromise.succeed(()) + } catch { + // NIOSSH will not send user authentication until this promise succeeds. + validationCompletePromise.fail(error) + } + } +} + +enum CitadelTransportError: Error, Equatable, Sendable { + case unexpectedInboundChannel + case closed + case execRejected +} + +final class CitadelSSHRootConnection: SSHRootConnection, @unchecked Sendable { + private let channel: Channel + private let handler: NIOSSHHandler + + init(channel: Channel, handler: NIOSSHHandler) { + self.channel = channel + self.handler = handler + } + + func openSessionChannel() async throws -> any SSHChildChannel { + let child = try await channel.eventLoop.flatSubmit { [channel, handler] in + let promise = channel.eventLoop.makePromise(of: Channel.self) + handler.createChannel(promise, channelType: .session) { child, type in + guard type == .session else { + return child.eventLoop.makeFailedFuture(CitadelTransportError.unexpectedInboundChannel) + } + return child.eventLoop.makeSucceededFuture(()) + } + return promise.futureResult + }.get() + return CitadelControlChannel(child) + } + + func close() async { + try? await channel.close() + } +} + +final class CitadelControlChannel: SSHChildChannel, @unchecked Sendable { + nonisolated let receivedBytes: AsyncThrowingStream + private let channel: Channel + private let continuation: AsyncThrowingStream.Continuation + private let completion = CitadelControlCompletion() + + init(_ channel: Channel) { + self.channel = channel + var continuation: AsyncThrowingStream.Continuation! + receivedBytes = AsyncThrowingStream { continuation = $0 } + self.continuation = continuation + } + + func execute(_ command: String) async throws { + try await channel.pipeline.addHandler(CitadelControlReadHandler(continuation: continuation, completion: completion)).get() + try await channel.triggerUserOutboundEvent( + SSHChannelRequestEvent.ExecRequest(command: command, wantReply: true) + ).get() + } + + func write(_ data: Data) async throws { + guard channel.isActive else { throw CitadelTransportError.closed } + var buffer = channel.allocator.buffer(capacity: data.count) + buffer.writeBytes(data) + try await channel.writeAndFlush(SSHChannelData(type: .channel, data: .byteBuffer(buffer))) + } + + func isActive() async -> Bool { + channel.isActive && !completion.finished + } + + func close() async throws { + completion.finish(nil, continuation: continuation) + try await channel.close() + } +} + +private final class CitadelControlCompletion: @unchecked Sendable { + private let lock = NSLock() + private var value = false + var finished: Bool { lock.withLock { value } } + func finish(_ error: Error?, continuation: AsyncThrowingStream.Continuation) { + let shouldFinish = lock.withLock { guard !value else { return false }; value = true; return true } + guard shouldFinish else { return } + continuation.finish(throwing: error) + } +} + +private final class CitadelControlReadHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = SSHChannelData + let continuation: AsyncThrowingStream.Continuation + let completion: CitadelControlCompletion + + init(continuation: AsyncThrowingStream.Continuation, completion: CitadelControlCompletion) { + self.continuation = continuation + self.completion = completion + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let packet = unwrapInboundIn(data) + guard case .byteBuffer(var buffer) = packet.data, + let bytes = buffer.readBytes(length: buffer.readableBytes) else { return } + if packet.type == .channel { continuation.yield(Data(bytes)) } + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if event is NIOSSH.ChannelFailureEvent { completion.finish(CitadelTransportError.execRejected, continuation: continuation) } + context.fireUserInboundEventTriggered(event) + } + + func channelInactive(context: ChannelHandlerContext) { + completion.finish(nil, continuation: continuation) + context.fireChannelInactive() + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + completion.finish(error, continuation: continuation) + context.close(promise: nil) + } +} diff --git a/MoriRemote/MoriRemote/SSH/HostTrust.swift b/MoriRemote/MoriRemote/SSH/HostTrust.swift new file mode 100644 index 00000000..191532e0 --- /dev/null +++ b/MoriRemote/MoriRemote/SSH/HostTrust.swift @@ -0,0 +1,46 @@ +import Foundation + +enum SSHHostTrustKind: Equatable, Sendable { case unknown, changed } +struct SSHHostTrustChallenge: Equatable, Sendable { + let kind: SSHHostTrustKind; let serverID: UUID; let endpoint: CanonicalEndpoint + let algorithm: String; let receivedFingerprint: String; let trustedFingerprint: String? +} +enum SSHHostTrustError: Error, Equatable, Sendable, LocalizedError { + case trustRequired(SSHHostTrustChallenge) + case changedKey(SSHHostTrustChallenge) + case staleChallenge + + var errorDescription: String? { + switch self { + case .trustRequired: + String(localized: "The SSH host key is unknown. Review and trust it before connecting.") + case .changedKey: + String(localized: "The SSH host key changed. Connection refused.") + case .staleChallenge: + String(localized: "The SSH host-key confirmation is no longer valid. Try again.") + } + } +} + +/// Pure, fail-closed trust gate. A network adapter must call this before opening any child channel. +struct SSHHostTrustResolver: Sendable { + let store: TrustedHostStore + func verify(server: SavedServer, algorithm: String, fingerprint: String) throws { + guard let endpoint = server.endpoint else { throw SavedModelValidationError.invalidHost } + guard let trusted = try store.trustedHost(for: server.id, endpoint: endpoint) else { + throw SSHHostTrustError.trustRequired(.init(kind: .unknown, serverID: server.id, endpoint: endpoint, algorithm: algorithm, receivedFingerprint: fingerprint, trustedFingerprint: nil)) + } + guard trusted.algorithm == algorithm, trusted.fingerprint == fingerprint else { + let challenge = SSHHostTrustChallenge(kind: .changed, serverID: server.id, endpoint: endpoint, algorithm: algorithm, receivedFingerprint: fingerprint, trustedFingerprint: trusted.fingerprint) + throw SSHHostTrustError.changedKey(challenge) + } + } + func explicitlyTrust(_ challenge: SSHHostTrustChallenge, replaceChanged: Bool = false, now: Date = .now) throws { + let current = try store.trustedHost(for: challenge.serverID, endpoint: challenge.endpoint) + switch challenge.kind { + case .unknown: guard current == nil else { throw SSHHostTrustError.staleChallenge } + case .changed: guard replaceChanged, current?.fingerprint == challenge.trustedFingerprint else { throw SSHHostTrustError.staleChallenge } + } + try store.trust(.init(serverID: challenge.serverID, endpoint: challenge.endpoint, algorithm: challenge.algorithm, fingerprint: challenge.receivedFingerprint, trustedAt: now)) + } +} diff --git a/MoriRemote/MoriRemote/SSH/SSHAuth.swift b/MoriRemote/MoriRemote/SSH/SSHAuth.swift new file mode 100644 index 00000000..e9068fdd --- /dev/null +++ b/MoriRemote/MoriRemote/SSH/SSHAuth.swift @@ -0,0 +1,215 @@ +@preconcurrency import Crypto +import Foundation +import Security + +struct SSHPrivateKeyCredential: Equatable, Sendable { + let privateKeyPEM: String + let passphrase: String? +} + +enum SSHCredential: Equatable, Sendable { + case password(String) + case privateKey(SSHPrivateKeyCredential) + + var kind: SSHIdentityKind { + switch self { + case .password: .password + case .privateKey: .privateKey + } + } +} + +enum ResolvedSSHAuth: Equatable, Sendable { + case password(username: String, password: String, identityID: UUID, label: String) + case privateKey(username: String, credential: SSHPrivateKeyCredential, identityID: UUID, label: String) + + var identityID: UUID { + switch self { + case let .password(_, _, id, _), let .privateKey(_, _, id, _): id + } + } + + /// In-memory-only pool partitioning. This digest is never persisted, logged, + /// or shown to the user; changing any secret forces a fresh authenticated root. + var rootPoolFingerprint: String { + var material = Data("mori-remote.root-pool.v1".utf8) + func append(_ value: String) { + let bytes = Data(value.utf8) + var length = UInt64(bytes.count).bigEndian + withUnsafeBytes(of: &length) { material.append(contentsOf: $0) } + material.append(bytes) + } + switch self { + case let .password(username, password, identityID, _): + append("password") + append(identityID.uuidString) + append(username) + append(password) + case let .privateKey(username, credential, identityID, _): + append("private-key") + append(identityID.uuidString) + append(username) + append(credential.privateKeyPEM) + append(credential.passphrase ?? "") + } + return SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined() + } +} + +enum SSHAuthResolverError: Error, Equatable, Sendable, LocalizedError { + case missingIdentity(UUID) + case missingCredential(UUID) + case credentialKindMismatch(UUID) + case unsupportedLegacyRSA + + var errorDescription: String? { + switch self { + case .missingIdentity: + String(localized: "SSH identity is missing.") + case .missingCredential: + String(localized: "SSH credential is required.") + case .credentialKindMismatch: + String(localized: "The saved SSH credential does not match its identity.") + case .unsupportedLegacyRSA: + String(localized: "Legacy RSA/SHA-1 authentication is disabled.") + } + } +} + +struct SSHAuthResolver: Sendable { + let credentials: any SSHCredentialReading + + func resolve(server: SavedServer, identity: SSHIdentity, settings: RemoteSettings = .default) throws -> ResolvedSSHAuth { + guard identity.serverID == server.id, identity.id == server.identityID else { + throw SSHAuthResolverError.missingIdentity(server.identityID) + } + guard let credential = try credentials.credential(for: identity.id) else { + throw SSHAuthResolverError.missingCredential(identity.id) + } + guard credential.kind == identity.kind else { + throw SSHAuthResolverError.credentialKindMismatch(identity.id) + } + switch credential { + case .password(let password): + return .password(username: server.username, password: password, identityID: identity.id, label: identity.label) + case .privateKey(let key): + let inspection = try SSHPrivateKeyInspector.inspect(key.privateKeyPEM) + guard !(inspection.keyType == .rsa && !settings.allowLegacyRSA) else { + throw SSHAuthResolverError.unsupportedLegacyRSA + } + return .privateKey(username: server.username, credential: key, identityID: identity.id, label: identity.label) + } + } +} + +protocol SSHCredentialReading: Sendable { + func credential(for identityID: UUID) throws -> SSHCredential? +} + +protocol SSHCredentialStoring: SSHCredentialReading { + func savePrivateKey(_ credential: SSHPrivateKeyCredential, for identityID: UUID) throws + func deletePrivateKey(for identityID: UUID) throws +} + +/// Minimal secret boundary shared by the device Keychain and deterministic tests. +protocol SecretDataStore: Sendable { + func read(service: String, account: String) throws -> Data? + func createOrUpdate(_ data: Data, service: String, account: String) throws + func delete(service: String, account: String) throws +} + +struct SecuritySecretDataStore: SecretDataStore { + func read(service: String, account: String) throws -> Data? { + var query = MoriRemoteKeychainProtection.item(service: service, account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw PersistenceError.keychain(status) + } + // Keep existing private-key records device-bound when an older app + // created them without an explicit Keychain accessibility class. + _ = SecItemUpdate(MoriRemoteKeychainProtection.item(service: service, account: account) as CFDictionary, MoriRemoteKeychainProtection.writeAttributes() as CFDictionary) + return data + } + + func createOrUpdate(_ data: Data, service: String, account: String) throws { + let query = MoriRemoteKeychainProtection.item(service: service, account: account) + var attributes = MoriRemoteKeychainProtection.writeAttributes() + attributes[kSecValueData as String] = data + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var insert = query + insert.merge(attributes, uniquingKeysWith: { _, replacement in replacement }) + let insertStatus = SecItemAdd(insert as CFDictionary, nil) + guard insertStatus == errSecSuccess else { throw PersistenceError.keychain(insertStatus) } + } else if status != errSecSuccess { + throw PersistenceError.keychain(status) + } + } + + func delete(service: String, account: String) throws { + let status = SecItemDelete(MoriRemoteKeychainProtection.item(service: service, account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw PersistenceError.keychain(status) + } + } +} + +enum SSHCredentialStoreError: Error, Equatable, Sendable { + case corruptPrivateKey(UUID) +} + +/// Secrets stay in the Keychain; no key material/passphrase enters profile JSON. +struct KeychainSSHCredentialStore: SSHCredentialStoring, Sendable { + static let privateKeyService = "com.vaayne.mori-remote.private-keys" + + let passwords: any CredentialReading + let secrets: any SecretDataStore + let service: String + + init( + passwords: any CredentialReading = KeychainCredentialStore(), + secrets: any SecretDataStore = SecuritySecretDataStore(), + service: String = Self.privateKeyService + ) { + self.passwords = passwords + self.secrets = secrets + self.service = service + } + + func credential(for identityID: UUID) throws -> SSHCredential? { + let account = identityID.uuidString + if let data = try secrets.read(service: service, account: account) { + do { + let key = try JSONDecoder().decode(StoredPrivateKey.self, from: data) + return .privateKey(.init(privateKeyPEM: key.pem, passphrase: key.passphrase)) + } catch { + // A present but corrupt private-key secret is not a missing key. + // Falling through to a password would silently authenticate differently. + throw SSHCredentialStoreError.corruptPrivateKey(identityID) + } + } + return try passwords.password(for: identityID).map(SSHCredential.password) + } + + func savePrivateKey(_ credential: SSHPrivateKeyCredential, for identityID: UUID) throws { + _ = try SSHPrivateKeyInspector.inspect(credential.privateKeyPEM) + try secrets.createOrUpdate( + JSONEncoder().encode(StoredPrivateKey(pem: credential.privateKeyPEM, passphrase: credential.passphrase)), + service: service, + account: identityID.uuidString + ) + } + + func deletePrivateKey(for identityID: UUID) throws { + try secrets.delete(service: service, account: identityID.uuidString) + } + + private struct StoredPrivateKey: Codable { + let pem: String + let passphrase: String? + } +} diff --git a/MoriRemote/MoriRemote/SSH/SSHPrivateKeyInspector.swift b/MoriRemote/MoriRemote/SSH/SSHPrivateKeyInspector.swift new file mode 100644 index 00000000..a841e905 --- /dev/null +++ b/MoriRemote/MoriRemote/SSH/SSHPrivateKeyInspector.swift @@ -0,0 +1,446 @@ + import Crypto +import Foundation + +enum SSHPrivateKeyType: String, Codable, Equatable, Sendable { + case ed25519 = "ssh-ed25519" + case rsa = "ssh-rsa" + case ecdsaP256 = "ecdsa-sha2-nistp256" + case ecdsaP384 = "ecdsa-sha2-nistp384" + case ecdsaP521 = "ecdsa-sha2-nistp521" + + var displayName: String { + switch self { + case .ed25519: + "ED25519" + case .rsa: + "RSA" + case .ecdsaP256: + "ECDSA P-256" + case .ecdsaP384: + "ECDSA P-384" + case .ecdsaP521: + "ECDSA P-521" + } + } + + var ecdsaCurveName: String? { + switch self { + case .ed25519, .rsa: + nil + case .ecdsaP256: + "nistp256" + case .ecdsaP384: + "nistp384" + case .ecdsaP521: + "nistp521" + } + } + + var ecdsaPointByteCount: Int? { + switch self { + case .ed25519, .rsa: + nil + case .ecdsaP256: + 65 + case .ecdsaP384: + 97 + case .ecdsaP521: + 133 + } + } +} + +struct SSHPrivateKeyInspection: Equatable, Sendable { + let keyType: SSHPrivateKeyType + let publicFingerprint: String + let publicKeyLine: String + let normalizedPEM: String + let isEncrypted: Bool +} + +struct SSHGeneratedPrivateKey: Equatable, Sendable { + let privateKeyPEM: String + let publicKeyLine: String + let publicFingerprint: String +} + +enum SSHPrivateKeyInspectionError: Error, Equatable, LocalizedError, Sendable { + case empty + case tooLarge + case invalidOpenSSHPrivateKey + case unsupportedKeyType(String) + + var errorDescription: String? { + switch self { + case .empty: + String(localized: "Private key is required.") + case .tooLarge: + String(localized: "Private key file is too large.") + case .invalidOpenSSHPrivateKey: + String(localized: "Import an OpenSSH private key.") + case .unsupportedKeyType(let keyType): + String( + format: String(localized: "SSH private key type “%@” is not supported."), + keyType + ) + } + } +} + +enum SSHPrivateKeyInspector { + static let maxByteCount = 256 * 1024 + + static func inspect(_ pem: String) throws -> SSHPrivateKeyInspection { + let normalizedPEM = pem.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedPEM.isEmpty else { + throw SSHPrivateKeyInspectionError.empty + } + + guard normalizedPEM.utf8.count <= maxByteCount else { + throw SSHPrivateKeyInspectionError.tooLarge + } + + let payload = try openSSHPrivateKeyPayload(from: normalizedPEM) + var reader = SSHPrivateKeyPayloadReader(data: payload) + + guard + try reader.readBytes(count: "openssh-key-v1\0".utf8.count) == Data("openssh-key-v1\0".utf8) + else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + let cipherName = try reader.readSSHString() + let kdfName = try reader.readSSHString() + let kdfOptions = try reader.readSSHStringData() + + guard try reader.readUInt32() == 1 else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + let publicKeyBlob = try reader.readSSHStringData() + var publicKeyReader = SSHPrivateKeyPayloadReader(data: publicKeyBlob) + let rawKeyType = try publicKeyReader.readSSHString() + + guard let keyType = SSHPrivateKeyType(rawValue: rawKeyType) else { + throw SSHPrivateKeyInspectionError.unsupportedKeyType(rawKeyType) + } + try validatePublicKeyBlob(for: keyType, reader: &publicKeyReader) + guard publicKeyReader.isAtEnd else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + let privateKeyBlock = try reader.readSSHStringData() + guard reader.isAtEnd else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + if cipherName == "none" { + guard kdfName == "none", kdfOptions.isEmpty else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + try validateUnencryptedPrivateKeyBlock( + privateKeyBlock, + keyType: keyType, + publicKeyBlob: publicKeyBlob + ) + } else { + guard !privateKeyBlock.isEmpty else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + } + + let fingerprint = Data(SHA256.hash(data: publicKeyBlob)) + .base64EncodedString() + .replacingOccurrences(of: "=", with: "") + + return SSHPrivateKeyInspection( + keyType: keyType, + publicFingerprint: "SHA256:\(fingerprint)", + publicKeyLine: "\(rawKeyType) \(publicKeyBlob.base64EncodedString())", + normalizedPEM: normalizedPEM, + isEncrypted: cipherName != "none" + ) + } + + private static func validatePublicKeyBlob( + for keyType: SSHPrivateKeyType, + reader: inout SSHPrivateKeyPayloadReader + ) throws { + switch keyType { + case .ed25519: + guard try reader.readSSHStringData().count == 32 else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + case .rsa: + let exponent = try reader.readSSHStringData() + let modulus = try reader.readSSHStringData() + guard + !exponent.isEmpty, + !modulus.isEmpty + else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + case .ecdsaP256, .ecdsaP384, .ecdsaP521: + guard + let expectedCurveName = keyType.ecdsaCurveName, + let expectedPointByteCount = keyType.ecdsaPointByteCount, + try reader.readSSHString() == expectedCurveName + else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + let point = try reader.readSSHStringData() + guard point.count == expectedPointByteCount, point.first == 0x04 else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + } + } + + private static func validateUnencryptedPrivateKeyBlock( + _ data: Data, + keyType: SSHPrivateKeyType, + publicKeyBlob: Data + ) throws { + guard data.count.isMultiple(of: 8) else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + var reader = SSHPrivateKeyPayloadReader(data: data) + var publicKeyReader = SSHPrivateKeyPayloadReader(data: publicKeyBlob) + + guard try publicKeyReader.readSSHString() == keyType.rawValue else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + let firstCheck = try reader.readUInt32() + guard try reader.readUInt32() == firstCheck else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + guard try reader.readSSHString() == keyType.rawValue else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + switch keyType { + case .ed25519: + let publicKey = try publicKeyReader.readSSHStringData() + let privateBlockPublicKey = try reader.readSSHStringData() + let privateMaterial = try reader.readSSHStringData() + guard + privateBlockPublicKey == publicKey, + publicKey.count == 32, + privateMaterial.count == 64, + Data(privateMaterial.suffix(32)) == publicKey + else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + case .rsa: + let publicExponent = try publicKeyReader.readSSHStringData() + let publicModulus = try publicKeyReader.readSSHStringData() + let privateBlockModulus = try reader.readSSHStringData() + let privateBlockExponent = try reader.readSSHStringData() + guard + privateBlockModulus == publicModulus, + privateBlockExponent == publicExponent + else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + for _ in 0..<4 { + let component = try reader.readSSHStringData() + guard !component.isEmpty else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + } + case .ecdsaP256, .ecdsaP384, .ecdsaP521: + guard + let expectedCurveName = keyType.ecdsaCurveName, + let expectedPointByteCount = keyType.ecdsaPointByteCount, + try publicKeyReader.readSSHString() == expectedCurveName, + try reader.readSSHString() == expectedCurveName + else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + let publicPoint = try publicKeyReader.readSSHStringData() + let point = try reader.readSSHStringData() + let privateScalar = try reader.readSSHStringData() + guard + point == publicPoint, + point.count == expectedPointByteCount, + point.first == 0x04, + !privateScalar.isEmpty + else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + } + + _ = try reader.readSSHString() + let padding = try reader.readRemainingBytes() + guard padding.count <= 8 else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + for (index, byte) in padding.enumerated() { + guard byte == UInt8(index + 1) else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + } + + guard publicKeyReader.isAtEnd else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + } + + static func generateEd25519(comment: String = "remux") -> SSHGeneratedPrivateKey { + let privateKey = Curve25519.Signing.PrivateKey() + let publicKey = privateKey.publicKey.rawRepresentation + let privateSeed = privateKey.rawRepresentation + let keyType = SSHPrivateKeyType.ed25519.rawValue + + var publicBlob = SSHPrivateKeyPayloadWriter() + publicBlob.writeSSHString(keyType) + publicBlob.writeSSHString(publicKey) + let publicKeyBlob = publicBlob.data + + let check = UInt32.random(in: UInt32.min...UInt32.max) + var privateBlock = SSHPrivateKeyPayloadWriter() + privateBlock.writeUInt32(check) + privateBlock.writeUInt32(check) + privateBlock.writeSSHString(keyType) + privateBlock.writeSSHString(publicKey) + privateBlock.writeSSHString(privateSeed + publicKey) + privateBlock.writeSSHString(comment) + privateBlock.writePadding(blockSize: 8) + + var payload = SSHPrivateKeyPayloadWriter() + payload.writeBytes(Data("openssh-key-v1\0".utf8)) + payload.writeSSHString("none") + payload.writeSSHString("none") + payload.writeSSHString(Data()) + payload.writeUInt32(1) + payload.writeSSHString(publicKeyBlob) + payload.writeSSHString(privateBlock.data) + + let base64 = payload.data.base64EncodedString() + let wrapped = stride(from: 0, to: base64.count, by: 70).map { offset in + let start = base64.index(base64.startIndex, offsetBy: offset) + let end = base64.index(start, offsetBy: min(70, base64.distance(from: start, to: base64.endIndex))) + return String(base64[start.. Data { + let lines = pem + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard + lines.first == "-----BEGIN OPENSSH PRIVATE KEY-----", + lines.last == "-----END OPENSSH PRIVATE KEY-----" + else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + let base64 = lines.dropFirst().dropLast().joined() + guard let payload = Data(base64Encoded: base64) else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + return payload + } +} + +private struct SSHPrivateKeyPayloadReader { + private let data: Data + private var offset = 0 + + init(data: Data) { + self.data = data + } + + mutating func readUInt32() throws -> UInt32 { + guard offset + 4 <= data.count else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + let value = data[offset..<(offset + 4)].reduce(UInt32(0)) { result, byte in + (result << 8) | UInt32(byte) + } + offset += 4 + return value + } + + mutating func readSSHString() throws -> String { + let stringData = try readSSHStringData() + guard let string = String(data: stringData, encoding: .utf8) else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + return string + } + + mutating func readSSHStringData() throws -> Data { + let length = Int(try readUInt32()) + return try readBytes(count: length) + } + + mutating func readBytes(count: Int) throws -> Data { + guard count >= 0, offset + count <= data.count else { + throw SSHPrivateKeyInspectionError.invalidOpenSSHPrivateKey + } + + let bytes = data[offset..<(offset + count)] + offset += count + return Data(bytes) + } + + mutating func readRemainingBytes() throws -> Data { + try readBytes(count: data.count - offset) + } + + var isAtEnd: Bool { + offset == data.count + } +} + +private struct SSHPrivateKeyPayloadWriter { + private(set) var data = Data() + + mutating func writeUInt32(_ value: UInt32) { + data.append(UInt8((value >> 24) & 0xff)) + data.append(UInt8((value >> 16) & 0xff)) + data.append(UInt8((value >> 8) & 0xff)) + data.append(UInt8(value & 0xff)) + } + + mutating func writeSSHString(_ string: String) { + writeSSHString(Data(string.utf8)) + } + + mutating func writeSSHString(_ bytes: Data) { + writeUInt32(UInt32(bytes.count)) + data.append(bytes) + } + + mutating func writeBytes(_ bytes: Data) { + data.append(bytes) + } + + mutating func writePadding(blockSize: Int) { + var paddingByte: UInt8 = 1 + repeat { + data.append(paddingByte) + paddingByte &+= 1 + } while data.count % blockSize != 0 + } +} diff --git a/MoriRemote/MoriRemote/SSH/SSHRootPool.swift b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift new file mode 100644 index 00000000..1560967d --- /dev/null +++ b/MoriRemote/MoriRemote/SSH/SSHRootPool.swift @@ -0,0 +1,204 @@ +import Foundation + +protocol SSHChildChannel: AnyObject, Sendable { + var receivedBytes: AsyncThrowingStream { get } + func execute(_ command: String) async throws + func write(_ data: Data) async throws + func isActive() async -> Bool + func close() async throws +} + +protocol SSHRootConnection: Sendable { + func openSessionChannel() async throws -> any SSHChildChannel + func close() async +} + +protocol SSHRootConnecting: Sendable { + func connect() async throws -> any SSHRootConnection +} + +enum SSHRootPoolError: Error, Equatable, Sendable { + case staleLease +} + +/// 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 { + static let maximumChildren = 4 + + struct Key: Hashable, Sendable { + let serverID: UUID + let endpoint: CanonicalEndpoint + let username: String + let authenticationFingerprint: String + } + + private struct Entry { + let token: UUID + let rootTask: Task + var leases: Int + var reservations: Int + var idleClose: Task? + } + + private struct RetiredEntry { + let rootTask: Task + var leases: Int + } + + private let idleTimeout: Duration + private var entries: [Key: Entry] = [:] + private var retired: [UUID: RetiredEntry] = [:] + + init(idleTimeout: Duration = .seconds(120)) { + self.idleTimeout = idleTimeout + } + + func lease(for key: Key, connector: any SSHRootConnecting) async throws -> SSHRootLease { + let entry: Entry + if var existing = entries[key], existing.leases + existing.reservations < Self.maximumChildren { + existing.idleClose?.cancel() + existing.idleClose = nil + existing.reservations += 1 + entries[key] = existing + entry = existing + } else if entries[key] == nil { + let token = UUID() + let task = Task { try await connector.connect() } + entry = Entry(token: token, rootTask: task, leases: 0, reservations: 1, idleClose: nil) + entries[key] = entry + observeConnection(task, key: key, token: token) + } else { + // Four consumers is the sharing ceiling, not a connection limit. A + // dedicated root avoids one busy workspace blocking another. + let root = try await connector.connect() + return SSHRootLease(key: nil, pool: self, root: root, token: nil) + } + + do { + let root = try await entry.rootTask.value + guard var current = entries[key], current.token == entry.token else { + await root.close() + throw SSHRootPoolError.staleLease + } + current.reservations -= 1 + current.leases += 1 + entries[key] = current + return SSHRootLease(key: key, pool: self, root: root, token: entry.token) + } catch { + releaseReservation(key: key, token: entry.token) + throw error + } + } + + fileprivate func release(_ lease: SSHRootLease, disposition: TmuxControlTransportCloseDisposition) async { + guard let key = lease.key, let token = lease.token else { + await lease.root.close() + return + } + guard var entry = entries[key], entry.token == token else { + if var old = retired[token] { + old.leases -= 1 + if old.leases == 0 { + retired[token] = nil + await lease.root.close() + } else { + retired[token] = old + } + } + return + } + + entry.leases = max(0, entry.leases - 1) + if disposition == .invalidated { + entry.idleClose?.cancel() + entries[key] = nil + if entry.leases == 0 { + await lease.root.close() + } else { + retired[token] = RetiredEntry(rootTask: entry.rootTask, leases: entry.leases) + } + return + } + + entries[key] = entry + scheduleIdleClose(for: key, token: token) + } + + private func releaseReservation(key: Key, token: UUID) { + guard var entry = entries[key], entry.token == token else { return } + entry.reservations = max(0, entry.reservations - 1) + entries[key] = entry + scheduleIdleClose(for: key, token: token) + } + + private func observeConnection(_ task: Task, key: Key, token: UUID) { + Task { + do { + _ = try await task.value + } catch { + guard let entry = self.entries[key], entry.token == token else { return } + entry.idleClose?.cancel() + self.entries[key] = nil + } + } + } + + private func scheduleIdleClose(for key: Key, token: UUID) { + guard var entry = entries[key], entry.token == token, + entry.leases == 0, entry.reservations == 0 else { return } + entry.idleClose?.cancel() + entry.idleClose = Task { [weak self, idleTimeout] in + do { + try await Task.sleep(for: idleTimeout) + await self?.closeIdle(key: key, token: token) + } catch { + return + } + } + entries[key] = entry + } + + private func closeIdle(key: Key, token: UUID) async { + guard let entry = entries[key], entry.token == token, + entry.leases == 0, entry.reservations == 0 else { return } + entries[key] = nil + if let root = try? await entry.rootTask.value { + await root.close() + } + } +} + +private final class SSHRootLeaseReleaseState: @unchecked Sendable { + private let lock = NSLock() + private var released = false + + func claim() -> Bool { + lock.withLock { + guard !released else { return false } + released = true + return true + } + } +} + +struct SSHRootLease: Sendable { + fileprivate let key: SSHRootPool.Key? + fileprivate let pool: SSHRootPool + let root: any SSHRootConnection + fileprivate let token: UUID? + private let releaseState = SSHRootLeaseReleaseState() + + fileprivate init(key: SSHRootPool.Key?, pool: SSHRootPool, root: any SSHRootConnection, token: UUID?) { + self.key = key + self.pool = pool + self.root = root + self.token = token + } + + func release(_ disposition: TmuxControlTransportCloseDisposition) async { + let shouldRelease = releaseState.claim() + guard shouldRelease else { return } + await pool.release(self, disposition: disposition) + } +} diff --git a/MoriRemote/MoriRemote/ShellCoordinator.swift b/MoriRemote/MoriRemote/ShellCoordinator.swift deleted file mode 100644 index 97840e8a..00000000 --- a/MoriRemote/MoriRemote/ShellCoordinator.swift +++ /dev/null @@ -1,708 +0,0 @@ -import Foundation -import MoriSSH -import MoriTerminal -import Observation -import os.log - -private let log = Logger(subsystem: "com.vaayne.mori-remote", category: "Shell") - -enum ShellState: Equatable, Sendable { - case disconnected - case connecting - case connected - case shell - - nonisolated static func == (lhs: ShellState, rhs: ShellState) -> Bool { - switch (lhs, rhs) { - case (.disconnected, .disconnected), - (.connecting, .connecting), - (.connected, .connected), - (.shell, .shell): - return true - default: - return false - } - } -} - -enum ShellError: LocalizedError { - case notConnected - case missingCredentials - case connectionTimedOut - case shellFailed(String) - - var errorDescription: String? { - switch self { - case .notConnected: - return String.localized("SSH connection is not available.") - case .missingCredentials: - return String.localized("Saved server credentials are incomplete. Edit the server and enter the password again.") - case .connectionTimedOut: - return String.localized("SSH connection timed out. Check the host, password, and network, then try again.") - case .shellFailed(let reason): - return String.localized("Shell failed: \(reason)") - } - } -} - -@MainActor -@Observable -final class ShellCoordinator { - var state: ShellState = .disconnected - var lastError: Error? - var activeServer: Server? - - private var connectionGeneration: UInt64 = 0 - - // Tmux state (observable for sidebar) - var tmuxSessions: [TmuxSession] = [] - var tmuxActiveSession: TmuxSession? - var tmuxWindows: [TmuxWindow] = [] - var isTmuxActive: Bool { tmuxActiveSession != nil } - - /// The tty of the tmux client backing THIS iOS shell channel (e.g. "/dev/pts/3"). - /// Resolved after attach; used to target switch-client at our own client so - /// switching never disturbs a desktop client attached to the same server. - private var iosClientTTY: String? - - /// The session our own client currently views. Seeded from the resolved client, - /// then updated on every switch we issue (the iOS client only moves via us). - private var iosCurrentSession: String? - - var isShellActive: Bool { state == .shell } - - private var sshManager: SSHConnectionManager? - private var shellChannel: SSHChannel? - private weak var renderer: SwiftTermRenderer? - private var outputTask: Task? - - // MARK: - Connect / Disconnect - - func connect(server: Server) async { - let generation = beginConnectionGeneration() - await resetConnection() - - guard isCurrentConnection(generation) else { return } - - activeServer = server - state = .connecting - lastError = nil - - guard !server.password.isEmpty else { - lastError = ShellError.missingCredentials - state = .disconnected - return - } - - let manager = SSHConnectionManager() - do { - try await withTimeout(seconds: 15) { - try await manager.connect( - host: server.host.trimmingCharacters(in: .whitespacesAndNewlines), - port: server.port, - user: server.username.trimmingCharacters(in: .whitespacesAndNewlines), - auth: .password(server.password) - ) - } - - guard isCurrentConnection(generation) else { - await manager.disconnect() - return - } - - sshManager = manager - state = .connected - } catch { - await manager.disconnect() - guard isCurrentConnection(generation) else { return } - lastError = error - state = .disconnected - } - } - - func disconnect() async { - let generation = beginConnectionGeneration() - await resetConnection() - guard isCurrentConnection(generation) else { return } - state = .disconnected - } - - // MARK: - Shell - - func openShell(renderer: SwiftTermRenderer) async { - let generation = connectionGeneration - - guard case .connected = state else { - if case .shell = state, isCurrentConnection(generation) { - wireRenderer(renderer) - renderer.activateKeyboard() - } - return - } - guard let sshManager else { - lastError = ShellError.notConnected - state = .disconnected - return - } - - wireRenderer(renderer) - - let size = renderer.gridSize() - let cols = size.cols > 0 ? Int(size.cols) : 80 - let rows = size.rows > 0 ? Int(size.rows) : 24 - - do { - let channel = try await sshManager.openShellChannel(cols: cols, rows: rows) - - guard isCurrentConnection(generation), activeServer != nil else { - await channel.close() - return - } - - shellChannel = channel - - outputTask = Task { [weak self] in - do { - for try await chunk in channel.inbound { - guard let self else { return } - guard self.isCurrentConnection(generation) else { return } - self.renderer?.feedBytes(chunk) - } - } catch { - log.error("Shell inbound error: \(error)") - } - guard let self, !Task.isCancelled else { return } - guard self.isCurrentConnection(generation) else { return } - await self.handleShellClosed(generation: generation) - } - - state = .shell - renderer.activateKeyboard() - attachDefaultSession() - startTmuxPolling(generation: generation) - } catch { - guard isCurrentConnection(generation) else { return } - await resetConnection() - lastError = error - state = .disconnected - } - } - - // MARK: - Private - - /// The custom keyboard accessory bar (set by TerminalScreen). - var accessoryBar: TerminalAccessoryBar? - - private func wireRenderer(_ renderer: SwiftTermRenderer) { - if let previousRenderer = self.renderer, previousRenderer !== renderer { - detachAccessoryBar(from: previousRenderer) - } - - self.renderer = renderer - renderer.inputHandler = { [weak self] data in - self?.sendInput(data) - } - renderer.sizeChangeHandler = { [weak self] newCols, newRows in - self?.sendResize(Int(newCols), Int(newRows)) - } - - // Wire the custom accessory bar — must set before activateKeyboard. - // Reusing the same accessory view across terminal responders is fine, - // but UIKit stays happier if we explicitly detach it from the previous - // responder before reassigning it during host switches and reconnects. - if let bar = accessoryBar { - let tv = renderer.swiftTermView - bar.terminalView = tv - if tv.inputAccessoryView !== bar { - tv.inputAccessoryView = bar - } - if tv.window != nil || tv.isFirstResponder { - tv.reloadInputViews() - } - bar.onTmuxCommand = { [weak self] cmd in - self?.handleTmuxCommand(cmd) - } - } - } - - private func detachAccessoryBar(from renderer: SwiftTermRenderer?) { - guard let accessoryBar else { return } - - if let renderer { - let terminalView = renderer.swiftTermView - if terminalView.inputAccessoryView != nil { - terminalView.inputAccessoryView = nil - if terminalView.window != nil || terminalView.isFirstResponder { - terminalView.reloadInputViews() - } - } - - if accessoryBar.terminalView === terminalView { - accessoryBar.terminalView = nil - } - } else { - accessoryBar.terminalView = nil - } - - accessoryBar.onTmuxCommand = nil - } - - private func sendInput(_ data: Data) { - guard let shellChannel else { return } - Task { - do { - try await shellChannel.write(data) - } catch { - log.error("sendInput error: \(error)") - } - } - } - - private func sendResize(_ cols: Int, _ rows: Int) { - guard cols > 0, rows > 0, let shellChannel else { return } - Task { - try? await shellChannel.resize(cols: cols, rows: rows) - } - } - - // MARK: - Tmux - - private var tmuxPollTask: Task? - - func handleTmuxCommand(_ command: TmuxCommand) { - // Route through the exec channel (like the sidebar actions) rather than - // typing the command as text into the shell channel. The shell channel - // only reaches tmux when it's sitting at a bare prompt; in practice the - // pane is almost always running an agent/program, which would swallow - // the keystrokes. Exec talks to the tmux server directly, targeting our - // own client's session, so it works regardless of the foreground app. - guard let args = tmuxArgs(for: command) else { - log.debug("Ignoring tmux command: no current session/client context") - return - } - runTmuxCommand(tmuxCmd(args)) - } - - /// Translate a `TmuxCommand` into exec-safe tmux args targeting our client's - /// current session. Relative window/pane navigation resolves against the - /// session's active window/pane (`:.+` etc.), so no `$TMUX` context - /// is required. Returns nil when the needed session/client tty is unknown. - private func tmuxArgs(for command: TmuxCommand) -> String? { - let session = iosCurrentSession - func s(_ build: (String) -> String) -> String? { session.map(build) } - - switch command { - case .selectWindow(let idx): - return s { "select-window -t '\($0):\(idx)'" } - case .newWindow: - return s { "new-window -t '\($0)'" } ?? "new-window" - case .nextWindow: - return s { "next-window -t '\($0)'" } - case .prevWindow: - return s { "previous-window -t '\($0)'" } - case .splitRight: - return s { "split-window -h -t '\($0)'" } - case .splitDown: - return s { "split-window -v -t '\($0)'" } - case .nextPane: - return s { "select-pane -t '\($0):.+'" } - case .prevPane: - return s { "select-pane -t '\($0):.-'" } - case .toggleZoom: - return s { "resize-pane -Z -t '\($0)'" } - case .closePane: - return s { "kill-pane -t '\($0)'" } - case .showSessionPicker: - return "switch-client \(clientFlag)-n" - case .switchSession(let name): - return "switch-client \(clientFlag)-t '\(name)'" - case .detach: - return iosClientTTY.map { "detach-client -t '\($0)'" } ?? "detach-client" - } - } - - /// Attach this shell channel to the server's default tmux session, creating it - /// if needed. Only attaches when the shell is not already inside tmux, so a - /// user whose login shell auto-attaches keeps their existing session. - private func attachDefaultSession() { - guard let session = activeServer?.defaultSession.trimmingCharacters(in: .whitespacesAndNewlines), - !session.isEmpty else { return } - iosCurrentSession = session - let cmd = "[ -z \"$TMUX\" ] && exec tmux new-session -A -s '\(session)'" - let sequence = "\u{15}\(cmd)\n" - sendInput(Data(sequence.utf8)) - } - - /// Resolve the tty of the tmux client backing this shell channel, so that - /// switch-client can target our own client by `-c `. - private func resolveClientTTY(generation: UInt64) async { - guard isCurrentConnection(generation), let sshManager, await sshManager.isConnected else { return } - let target = activeServer?.defaultSession.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - do { - let raw = try await sshManager.runCommandNoPTY( - tmuxCmd("list-clients -F '#{client_tty}\(fs)#{client_session}' 2>/dev/null") - ) - guard isCurrentConnection(generation) else { return } - let clients = raw.split(separator: "\n").compactMap { line -> (tty: String, session: String)? in - let p = line.components(separatedBy: fs) - guard p.count >= 2, !p[0].isEmpty else { return nil } - return (p[0], p[1]) - } - guard !clients.isEmpty else { return } - // Prefer the client attached to our default session; else the sole client. - let match = clients.first(where: { $0.session == target }) ?? (clients.count == 1 ? clients.first : nil) - if let match { - iosClientTTY = match.tty - iosCurrentSession = match.session - log.info("Resolved iOS tmux client tty: \(match.tty) (session \(match.session))") - } - } catch { - log.debug("resolveClientTTY failed: \(error)") - } - } - - func startTmuxPolling(generation: UInt64) { - tmuxPollTask?.cancel() - tmuxPollTask = Task { [weak self] in - guard let self else { return } - guard self.isCurrentConnection(generation) else { return } - - // Give the attach a moment to settle, then resolve our client tty. - try? await Task.sleep(nanoseconds: 1_500_000_000) - guard !Task.isCancelled, self.isCurrentConnection(generation) else { return } - await self.resolveClientTTY(generation: generation) - guard !Task.isCancelled, self.isCurrentConnection(generation) else { return } - await self.pollTmuxState(generation: generation) - - // Poll every 5 seconds using NoPTY exec channels - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 5_000_000_000) - guard !Task.isCancelled, self.isCurrentConnection(generation) else { return } - await self.pollTmuxState(generation: generation) - } - } - } - - - /// Build a tmux invocation with common bin locations on PATH. Exec channels - /// don't source the login profile, so Homebrew's tmux is off PATH — prepend - /// the usual locations so a bare `tmux` resolves on any standard install. - /// This avoids depending on a separately-detected path, which login-shell - /// banner noise can corrupt. - private func tmuxCmd(_ args: String) -> String { - "PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH\" tmux \(args)" - } - - /// Field separator for tmux `-F` queries. tmux sanitizes non-printable and - /// non-ASCII bytes in format output to `_` (both a raw tab and `§` came back - /// as `_`), so use a printable-ASCII token that survives verbatim and is - /// vanishingly unlikely to appear in a session/window name, path, or title. - private let fs = "~|~" - - /// Run a tmux query via NoPTY exec, falling back to PTY exec on failure. - private func tmuxQuery(_ command: String) async throws -> String { - guard let sshManager else { throw ShellError.notConnected } - do { - return try await sshManager.runCommandNoPTY(command) - } catch { - return try await sshManager.runCommand(command) - } - } - - private func pollTmuxState(generation: UInt64? = nil) async { - if let generation, !isCurrentConnection(generation) { - return - } - - guard let sshManager, await sshManager.isConnected else { - log.debug("tmux poll: no SSH manager or disconnected") - return - } - - func clearState() { - accessoryBar?.updateTmux(session: nil, windows: []) - self.tmuxSessions = [] - self.tmuxActiveSession = nil - self.tmuxWindows = [] - } - - do { - // Three bulk queries (fs-delimited): sessions, all windows, all panes. - let sessionsRaw = try await tmuxQuery( - tmuxCmd("list-sessions -F '#{session_name}\(fs)#{session_windows}\(fs)#{session_attached}' 2>/dev/null") - ) - if let generation, !isCurrentConnection(generation) { return } - guard !sessionsRaw.isEmpty else { clearState(); return } - - // Windows/panes are best-effort: a failure here must not wipe the - // session list, so fall back to empty instead of throwing out. - let windowsRaw = (try? await tmuxQuery( - tmuxCmd("list-windows -a -F '#{session_name}\(fs)#{window_index}\(fs)#{window_name}\(fs)#{window_active}\(fs)#{pane_current_path}' 2>/dev/null") - )) ?? "" - if let generation, !isCurrentConnection(generation) { return } - - let panesRaw = (try? await tmuxQuery( - tmuxCmd("list-panes -a -F '#{session_name}\(fs)#{window_index}\(fs)#{pane_id}\(fs)#{pane_active}\(fs)#{pane_current_command}\(fs)#{pane_title}\(fs)#{pane_current_path}\(fs)#{@mori-agent-state}\(fs)#{@mori-agent-name}' 2>/dev/null") - )) ?? "" - if let generation, !isCurrentConnection(generation) { return } - - // Panes grouped by "session\twindowIndex". - var panesByWindow: [String: [TmuxPane]] = [:] - for line in panesRaw.split(separator: "\n") { - let p = line.components(separatedBy: fs) - guard p.count >= 4 else { continue } - let key = "\(p[0])\t\(p[1])" - let pane = TmuxPane( - paneId: p[2], - isActive: p[3] == "1", - command: p.count > 4 ? p[4] : "", - title: p.count > 5 ? p[5] : "", - path: p.count > 6 ? p[6] : "", - agentState: p.count > 7 && !p[7].isEmpty ? p[7] : nil, - agentName: p.count > 8 && !p[8].isEmpty ? p[8] : nil - ) - panesByWindow[key, default: []].append(pane) - } - - // Windows grouped by session name, panes attached. - var windowsBySession: [String: [TmuxWindow]] = [:] - for line in windowsRaw.split(separator: "\n") { - let p = line.components(separatedBy: fs) - guard p.count >= 4 else { continue } - let sessionName = p[0] - let index = Int(p[1]) ?? 0 - let window = TmuxWindow( - index: index, - name: p[2], - isActive: p[3] == "1", - sessionName: sessionName, - path: p.count > 4 ? p[4] : "", - panes: panesByWindow["\(sessionName)\t\(index)"] ?? [] - ) - windowsBySession[sessionName, default: []].append(window) - } - - let sessions = sessionsRaw.split(separator: "\n").compactMap { line -> TmuxSession? in - let p = line.components(separatedBy: fs) - guard p.count >= 3 else { return nil } - let name = p[0] - return TmuxSession( - name: name, - windowCount: Int(p[1]) ?? 0, - isAttached: p[2] == "1", - windows: (windowsBySession[name] ?? []).sorted { $0.index < $1.index } - ) - } - - // The session our own client views (by tty), else first attached, else first. - let activeSession = resolveActiveSession(among: sessions) - guard let activeSession else { clearState(); return } - if let generation, !isCurrentConnection(generation) { return } - - accessoryBar?.updateTmux(session: activeSession, windows: activeSession.windows) - self.tmuxSessions = sessions - self.tmuxActiveSession = activeSession - self.tmuxWindows = activeSession.windows - } catch { - // tmux not running — hide the bar - log.info("tmux poll error: \(error)") - clearState() - } - } - - /// Pick the session this iOS client is viewing: our tracked current session - /// when it still exists, otherwise the first attached/first session. - private func resolveActiveSession(among sessions: [TmuxSession]) -> TmuxSession? { - if let current = iosCurrentSession, - let match = sessions.first(where: { $0.name == current }) { - return match - } - return sessions.first(where: { $0.isAttached }) ?? sessions.first - } - - /// `-c '' ` targeting our own client, or empty when the tty is unknown. - private var clientFlag: String { - iosClientTTY.map { "-c '\($0)' " } ?? "" - } - - /// tmux args (joined after a switch with `\;`) that leave copy-mode on the - /// target pane. A pane scrolled into copy-mode stays there across window - /// switches, where stray digits hit the default `(repeat)` command-prompt - /// binding and swallow input — cancel it so the pane is typeable on - /// arrival. `copy-mode -q` (tmux 3.2+) is a no-op outside copy-mode; on - /// older tmux it errors harmlessly after the switch has already happened. - private func cancelCopyModeArgs(target: String) -> String { - "copy-mode -q -t '\(target)'" - } - - /// Switch to a specific tmux window by index in the given session. - /// `switch-client -t 'session:index'` moves our client to that session AND - /// selects the window in one step; the attached client repaints to it. - func selectTmuxWindow(session: String, windowIndex: Int) { - iosCurrentSession = session - let target = "\(session):\(windowIndex)" - runTmuxCommand(tmuxCmd("switch-client \(clientFlag)-t '\(target)' \\; \(cancelCopyModeArgs(target: target))")) - } - - /// Switch to a different tmux session. - func switchTmuxSession(_ sessionName: String) { - iosCurrentSession = sessionName - runTmuxCommand(tmuxCmd("switch-client \(clientFlag)-t '\(sessionName)' \\; \(cancelCopyModeArgs(target: sessionName))")) - } - - /// Switch to a specific pane: move our client to the owning window, then - /// select the pane (pane ids like `%5` are unique across the server). - func selectTmuxPane(session: String, windowIndex: Int, paneId: String) { - iosCurrentSession = session - runTmuxCommand(tmuxCmd("switch-client \(clientFlag)-t '\(session):\(windowIndex)' \\; select-pane -t '\(paneId)' \\; \(cancelCopyModeArgs(target: paneId))")) - } - - /// Close (kill) a tmux window. - func closeTmuxWindow(session: String, windowIndex: Int) { - runTmuxCommand(tmuxCmd("kill-window -t '\(session):\(windowIndex)'")) - } - - /// Create a new tmux window in the active session. - func newTmuxWindow() { - if let session = iosCurrentSession { - runTmuxCommand(tmuxCmd("new-window -t '\(session)'")) - } else { - runTmuxCommand(tmuxCmd("new-window")) - } - } - - /// Create a new tmux session. - func newTmuxSession() { - runTmuxCommand(tmuxCmd("new-session -d")) - } - - /// Kill (close) a tmux session. - func closeTmuxSession(_ sessionName: String) { - runTmuxCommand(tmuxCmd("kill-session -t '\(sessionName)'")) - } - - /// Rename a tmux session. - func renameTmuxSession(_ oldName: String, to newName: String) { - runTmuxCommand(tmuxCmd("rename-session -t '\(oldName)' '\(newName)'")) - } - - /// Create a new tmux window after a specific window index. - func newTmuxWindowAfter(session: String, windowIndex: Int) { - runTmuxCommand(tmuxCmd("new-window -a -t '\(session):\(windowIndex)'")) - } - - /// Force a tmux state refresh. - func refreshTmuxState() { - let generation = connectionGeneration - Task { [weak self] in - guard let self, self.isCurrentConnection(generation) else { return } - await self.pollTmuxState(generation: generation) - } - } - - /// Run a tmux command via NoPTY exec channel and refresh state. - private func runTmuxCommand(_ cmd: String) { - guard state == .shell else { - log.debug("Ignoring tmux exec while shell is inactive") - return - } - guard let sshManager else { - log.error("runTmuxCommand: no SSH manager") - return - } - - let generation = connectionGeneration - log.info("Tmux exec: \(cmd)") - Task { [weak self] in - do { - _ = try await sshManager.runCommandNoPTY(cmd) - } catch { - log.error("Tmux exec failed: \(error)") - } - try? await Task.sleep(nanoseconds: 300_000_000) - guard let self, self.isCurrentConnection(generation) else { return } - await self.pollTmuxState(generation: generation) - } - } - - /// Send a tmux command through the shell channel and refresh state. - private func sendTmuxShellCommand(_ cmd: String) { - guard state == .shell, shellChannel != nil else { - log.debug("Ignoring tmux shell command while shell is inactive") - return - } - - let generation = connectionGeneration - log.info("Tmux shell command: \(cmd)") - let sequence = "\u{15}\(cmd)\n" - sendInput(Data(sequence.utf8)) - Task { [weak self] in - try? await Task.sleep(nanoseconds: 500_000_000) - guard let self, self.isCurrentConnection(generation) else { return } - await self.pollTmuxState(generation: generation) - } - } - - private func handleShellClosed(generation: UInt64) async { - guard isCurrentConnection(generation) else { return } - await resetConnection() - guard isCurrentConnection(generation) else { return } - lastError = ShellError.shellFailed("Shell session ended.") - state = .disconnected - } - - private func resetConnection() async { - tmuxPollTask?.cancel() - tmuxPollTask = nil - outputTask?.cancel() - outputTask = nil - - renderer?.inputHandler = nil - renderer?.sizeChangeHandler = nil - detachAccessoryBar(from: renderer) - renderer?.deactivateKeyboard() - renderer = nil - - if let shellChannel { - self.shellChannel = nil - await shellChannel.close() - } - - if let sshManager { - self.sshManager = nil - await sshManager.disconnect() - } - - activeServer = nil - tmuxSessions = [] - tmuxActiveSession = nil - tmuxWindows = [] - iosClientTTY = nil - iosCurrentSession = nil - } - - private func beginConnectionGeneration() -> UInt64 { - connectionGeneration &+= 1 - return connectionGeneration - } - - private func isCurrentConnection(_ generation: UInt64) -> Bool { - generation == connectionGeneration - } - - private func withTimeout(seconds: Double, operation: @escaping @Sendable () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - group.addTask { - try await Task.sleep(for: .seconds(seconds)) - throw ShellError.connectionTimedOut - } - - let result = try await group.next()! - group.cancelAll() - return result - } - } -} diff --git a/MoriRemote/MoriRemote/TerminalView.swift b/MoriRemote/MoriRemote/TerminalView.swift deleted file mode 100644 index 7348e979..00000000 --- a/MoriRemote/MoriRemote/TerminalView.swift +++ /dev/null @@ -1,21 +0,0 @@ -import MoriTerminal -import SwiftUI -import UIKit - -struct TerminalView: UIViewRepresentable { - let onRendererReady: @MainActor (SwiftTermRenderer) -> Void - - func makeUIView(context: Context) -> SwiftTermRenderer { - let renderer = SwiftTermRenderer() - // Defer callback so SwiftUI layout is settled before the coordinator acts. - // Don't activate keyboard here — let openShell do it after the accessory bar is wired. - DispatchQueue.main.async { - onRendererReady(renderer) - } - return renderer - } - - func updateUIView(_ uiView: SwiftTermRenderer, context: Context) { - // No-op — renderer is long-lived, coordinator holds a weak ref. - } -} diff --git a/MoriRemote/MoriRemote/Theme.swift b/MoriRemote/MoriRemote/Theme.swift deleted file mode 100644 index 7374597f..00000000 --- a/MoriRemote/MoriRemote/Theme.swift +++ /dev/null @@ -1,180 +0,0 @@ -import SwiftUI - -/// MoriRemote UI tokens aligned with the Mac app's quieter, denser workspace language. -enum Theme { - // MARK: - Colors - - static let bg = Color(red: 0.07, green: 0.08, blue: 0.10) - static let sidebarBg = Color(red: 0.08, green: 0.09, blue: 0.11) - static let terminalBg = Color.black - static let cardBg = Color.white.opacity(0.045) - static let elevatedBg = Color.white.opacity(0.07) - static let mutedSurface = Color.white.opacity(0.04) - static let rowHover = Color.white.opacity(0.035) - static let cardBorder = Color.white.opacity(0.08) - static let divider = Color.white.opacity(0.08) - - static let accent = Color.accentColor - static let accentSoft = Theme.accent.opacity(0.12) - static let accentBorder = Theme.accent.opacity(0.28) - - static let success = Color.green.opacity(0.95) - static let warning = Color.yellow.opacity(0.95) - static let destructive = Color.red.opacity(0.9) - - static let agentWorking = Theme.accent - static let agentWaiting = Color.orange.opacity(0.95) - static let agentDone = Color.green.opacity(0.95) - - static let textPrimary = Color.white.opacity(0.96) - static let textSecondary = Color.white.opacity(0.64) - static let textTertiary = Color.white.opacity(0.38) - - // MARK: - Spacing - - static let rowSpacing: CGFloat = 8 - static let sectionSpacing: CGFloat = 10 - static let contentInset: CGFloat = 16 - - // MARK: - Shapes - - static let cardRadius: CGFloat = 10 - static let rowRadius: CGFloat = 7 - static let buttonRadius: CGFloat = 10 - static let sheetRadius: CGFloat = 20 - - // MARK: - Typography - - static let sectionHeaderFont = Font.system(size: 11, weight: .bold) - static let rowTitleFont = Font.system(size: 13.5, weight: .semibold) - static let rowSubtitleFont = Font.system(size: 11) - static let monoCaptionFont = Font.system(size: 10.5, design: .monospaced) - static let monoDetailFont = Font.system(size: 11, design: .monospaced) - static let shortcutFont = Font.system(size: 10, design: .monospaced) - static let chipFont = Font.system(size: 10.5, weight: .semibold) - static let chipHorizontalPadding: CGFloat = 7 - static let chipVerticalPadding: CGFloat = 4 - - // MARK: - View Modifiers - - struct PanelStyle: ViewModifier { - var padding: CGFloat = 16 - - func body(content: Content) -> some View { - content - .padding(padding) - .background(Theme.cardBg, in: RoundedRectangle(cornerRadius: Theme.cardRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.cardRadius) - .strokeBorder(Theme.cardBorder, lineWidth: 1) - ) - } - } - - struct RowSurfaceStyle: ViewModifier { - let isSelected: Bool - - func body(content: Content) -> some View { - content - .background( - isSelected ? Theme.accentSoft : Theme.mutedSurface, - in: RoundedRectangle(cornerRadius: Theme.rowRadius) - ) - .overlay( - RoundedRectangle(cornerRadius: Theme.rowRadius) - .strokeBorder(isSelected ? Theme.accentBorder : Theme.cardBorder, lineWidth: 1) - ) - } - } - - struct PrimaryButtonStyle: ButtonStyle { - let disabled: Bool - - init(disabled: Bool = false) { - self.disabled = disabled - } - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(disabled ? Theme.textTertiary : Theme.textPrimary) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background( - disabled ? Theme.accent.opacity(0.24) : Theme.accent, - in: RoundedRectangle(cornerRadius: Theme.buttonRadius) - ) - .opacity(configuration.isPressed ? 0.92 : 1) - .scaleEffect(configuration.isPressed ? 0.985 : 1) - .animation(.easeOut(duration: 0.14), value: configuration.isPressed) - } - } - - struct SecondaryButtonStyle: ButtonStyle { - let foreground: Color - let background: Color - let border: Color - - init( - foreground: Color = Theme.textPrimary, - background: Color = Theme.mutedSurface, - border: Color = Theme.cardBorder - ) { - self.foreground = foreground - self.background = background - self.border = border - } - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(foreground) - .frame(maxWidth: .infinity) - .frame(height: 40) - .background(background, in: RoundedRectangle(cornerRadius: Theme.buttonRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.buttonRadius) - .strokeBorder(border, lineWidth: 1) - ) - .opacity(configuration.isPressed ? 0.88 : 1) - .animation(.easeOut(duration: 0.12), value: configuration.isPressed) - } - } - - struct DarkFieldStyle: ViewModifier { - func body(content: Content) -> some View { - content - .font(.system(size: 14)) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: Theme.buttonRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.buttonRadius) - .strokeBorder(Theme.cardBorder, lineWidth: 1) - ) - .foregroundStyle(Theme.textPrimary) - } - } -} - -extension View { - func cardStyle(padding: CGFloat = 16) -> some View { - modifier(Theme.PanelStyle(padding: padding)) - } - - func rowSurfaceStyle(selected: Bool = false) -> some View { - modifier(Theme.RowSurfaceStyle(isSelected: selected)) - } - - func darkFieldStyle() -> some View { - modifier(Theme.DarkFieldStyle()) - } - - func moriSectionHeaderStyle() -> some View { - self - .font(Theme.sectionHeaderFont) - .tracking(1.2) - .textCase(.uppercase) - .foregroundStyle(Theme.textTertiary) - } -} diff --git a/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift new file mode 100644 index 00000000..5ea74e02 --- /dev/null +++ b/MoriRemote/MoriRemote/Tmux/AgentMetadataProjector.swift @@ -0,0 +1,197 @@ +import Foundation + +/// Agent state is deliberately smaller than the macOS model: these are the only +/// values emitted by Mori's tmux hooks. Everything else is rendered as unknown +/// rather than being guessed from terminal output. +enum MoriAgentState: String, CaseIterable, Equatable, Sendable { + case unknown + case working + case waiting + case done + + var priority: Int { + switch self { + case .waiting: 3 + case .working: 2 + case .done: 1 + case .unknown: 0 + } + } +} + +struct AgentMetadata: Equatable, Sendable { + let state: MoriAgentState + let name: String? + + static let unknown = Self(state: .unknown, name: nil) +} + +/// 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. +struct AgentMetadataResponseParser: Sendable { + static let maximumResponseBytes = 65_536 + static let maximumRecords = 512 + static let maximumNameLength = 64 + + func parse(_ body: String) -> [TmuxPaneID: 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() + for line in records { + let fields = line.split(separator: "\t", omittingEmptySubsequences: false) + guard let first = fields.first, let paneID = parsePaneID(first) else { continue } + guard seenPaneIDs.insert(paneID).inserted else { return [:] } + guard fields.count == 3 else { continue } + result[paneID] = .init(state: normalizeState(fields[1]), name: normalizeName(fields[2])) + } + 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 normalizeState(_ field: Substring) -> MoriAgentState { + MoriAgentState(rawValue: String(field)) ?? .unknown + } + + private func normalizeName(_ field: Substring) -> String? { + let value = String(field) + guard !value.isEmpty, value.count <= Self.maximumNameLength, + value.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value != 0x7F }) + else { return nil } + return value + } +} + +/// Projects response records only onto panes the active Ghostty 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 [:] } + // 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 + } + 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. +@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 parser = AgentMetadataResponseParser() + private var topology: TmuxSessionController.Topology? + private var refreshTask: Task? + private var visible = false + private var stopped = false + private var queryInFlight = false + /// Hiding has no native command cancellation primitive. This generation + /// fence lets a newly-visible runtime issue a fresh query while dropping an + /// old completion that arrives after presentation changed. + private var queryGeneration: UInt64 = 0 + + private(set) var metadata: [TmuxPaneID: AgentMetadata] = [:] + private(set) var lastFailure: String? + var onChange: (@MainActor () -> Void)? + + init(instanceID: UUID, query: @escaping (@escaping @Sendable (TmuxSessionController.CommandResult) -> Void) -> Void) { + self.instanceID = instanceID + self.query = query + } + + func topologyDidChange(_ topology: TmuxSessionController.Topology) { + guard !stopped else { return } + self.topology = topology + metadata = AgentMetadataProjection.merge(metadata, into: topology) + onChange?() + refreshImmediately() + } + + func setVisible(_ visible: Bool) { + guard !stopped, self.visible != visible else { return } + self.visible = visible + if visible { + refreshImmediately() + refreshTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: Self.refreshInterval) + guard !Task.isCancelled else { return } + self?.refreshImmediately() + } + } + } else { + refreshTask?.cancel() + refreshTask = nil + queryGeneration &+= 1 + queryInFlight = false + metadata = [:] + onChange?() + } + } + + /// Foregrounding only refreshes already-visible metadata. It never creates a + /// transport or invokes the reconnect policy. + func foregrounded() { refreshImmediately() } + + func stop() { + guard !stopped else { return } + stopped = true + queryGeneration &+= 1 + refreshTask?.cancel() + refreshTask = nil + queryInFlight = false + metadata = [:] + } + + private func refreshImmediately() { + guard visible, !stopped, topology != nil, !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) } + } + } + + private func receive(_ result: TmuxSessionController.CommandResult, from responseInstanceID: UUID, generation: UInt64) { + guard !stopped, responseInstanceID == instanceID, generation == queryGeneration, visible else { return } + queryInFlight = false + switch result.status { + case .success: + lastFailure = nil + metadata = AgentMetadataProjection.merge(parser.parse(result.body), into: topology) + case .skipped, .error: + lastFailure = result.body + metadata = AgentMetadataProjection.merge([:], into: topology) + } + onChange?() + } +} diff --git a/MoriRemote/MoriRemote/Tmux/DeterministicTmuxControlTransport.swift b/MoriRemote/MoriRemote/Tmux/DeterministicTmuxControlTransport.swift new file mode 100644 index 00000000..a5ec9373 --- /dev/null +++ b/MoriRemote/MoriRemote/Tmux/DeterministicTmuxControlTransport.swift @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000..dfed9aca --- /dev/null +++ b/MoriRemote/MoriRemote/Tmux/SSHTmuxControlTransport.swift @@ -0,0 +1,247 @@ +import Foundation + +/// 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 { + nonisolated let receivedBytes: AsyncThrowingStream + + private enum Lifecycle: Equatable { case idle, starting, started, closing, closed } + + private let connector: any SSHRootConnecting + private let pool: SSHRootPool + private let poolKey: SSHRootPool.Key + private let tmuxExecutable: String + private let sourceSession: String + private let runtimeID: UUID + private let continuation: AsyncThrowingStream.Continuation + + private var lease: SSHRootLease? + private var control: (any SSHChildChannel)? + private var controlReader: Task? + /// The startup command may be awaiting output when close wins. Retaining it + /// lets close unblock and release that child instead of stranding it on root. + private var startupChild: (any SSHChildChannel)? + private var shadowCreated = false + private var lifecycle: Lifecycle = .idle + + init( + connector: any SSHRootConnecting, + pool: SSHRootPool, + poolKey: SSHRootPool.Key, + tmuxExecutable: String = "tmux", + sourceSession: String, + runtimeID: UUID = UUID() + ) { + self.connector = connector + self.pool = pool + self.poolKey = poolKey + self.tmuxExecutable = tmuxExecutable + self.sourceSession = sourceSession + self.runtimeID = runtimeID + var continuation: AsyncThrowingStream.Continuation! + receivedBytes = AsyncThrowingStream { continuation = $0 } + self.continuation = continuation + } + + func start() async throws { + guard lifecycle != .closed && lifecycle != .closing else { throw SSHTmuxControlTransportError.closed } + guard lifecycle == .idle else { throw SSHTmuxControlTransportError.alreadyStarted } + lifecycle = .starting + + do { + let lease = try await pool.lease(for: poolKey, connector: connector) + 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. + await lease.release(.reusable) + throw SSHTmuxControlTransportError.closed + } + self.lease = lease + + let preflight = try TmuxCommandBuilder.preflight(executable: tmuxExecutable) + let version = try await run(command: preflight, root: lease.root, trackStartup: true) + try requireStarting() + try TmuxCommandBuilder.requireSupportedVersion(version) + + let shadow = try TmuxCommandBuilder.createShadow( + executable: tmuxExecutable, + source: sourceSession, + runtimeID: runtimeID + ) + _ = try await run(command: shadow, root: lease.root, trackStartup: true) + try requireStarting() + shadowCreated = true + + let attach = try TmuxCommandBuilder.attachShadow( + executable: tmuxExecutable, + shadow: try TmuxCommandBuilder.shadowName(source: sourceSession, runtimeID: runtimeID) + ) + let control = try await lease.root.openSessionChannel() + guard lifecycle == .starting else { + try? await control.close() + throw SSHTmuxControlTransportError.closed + } + self.control = control + try await control.execute(attach) + guard lifecycle == .starting else { + // close() claims and clears this property before it awaits. Do + // not double-close an attach channel after the close race won. + if self.control === control { + self.control = nil + try? await control.close() + } + throw SSHTmuxControlTransportError.closed + } + lifecycle = .started + let receivedBytes = control.receivedBytes + controlReader = Task { [weak self] in + do { + for try await bytes in receivedBytes { + await self?.forwardControlBytes(bytes) + } + await self?.controlStreamEnded(error: nil) + } catch { + await self?.controlStreamEnded(error: error) + } + } + } catch { + // Preserve authentication, trust, and tmux startup errors for the + // root model. Only a concurrent explicit close changes the error to + // `.closed`; otherwise TOFU would be unreachable from the UI. + if lifecycle == .starting { + await terminate(disposition: .invalidated, error: error) + throw error + } + throw SSHTmuxControlTransportError.closed + } + } + + private func forwardControlBytes(_ bytes: Data) { + guard lifecycle == .started else { return } + continuation.yield(bytes) + } + + private func controlStreamEnded(error: Error?) async { + guard lifecycle == .started else { return } + controlReader = nil + await terminate(disposition: .invalidated, error: error) + } + + func send(_ data: Data) async throws { + guard lifecycle == .started, let control else { throw SSHTmuxControlTransportError.closed } + do { + try await control.write(data) + } catch { + await terminate(disposition: .invalidated, error: error) + throw error + } + } + + func isActive() async -> Bool { + guard lifecycle == .started, let control else { return false } + return await control.isActive() + } + + func close(disposition: TmuxControlTransportCloseDisposition) async { + guard lifecycle != .closed && lifecycle != .closing else { return } + await terminate(disposition: disposition, error: nil) + } + + private func requireStarting() throws { + guard lifecycle == .starting else { throw SSHTmuxControlTransportError.closed } + } + + private func run(command: String, root: any SSHRootConnection, trackStartup: Bool = false) async throws -> String { + let child = try await root.openSessionChannel() + if trackStartup { + guard lifecycle == .starting else { + try? await child.close() + throw SSHTmuxControlTransportError.closed + } + startupChild = child + } + do { + try await child.execute(command) + if trackStartup { try requireStarting() } + var output = Data() + for try await bytes in child.receivedBytes { + if trackStartup { try requireStarting() } + output.append(bytes) + } + if trackStartup { try requireStarting() } + let ownsChild = !trackStartup || startupChild === child + if ownsChild { try? await child.close() } + if trackStartup, ownsChild { startupChild = nil } + return String(decoding: output, as: UTF8.self) + } catch { + // close() clears startupChild before awaiting child.close(). A resumed + // startup must not close the same newly acquired child a second time. + let ownsChild = !trackStartup || startupChild === child + if ownsChild { try? await child.close() } + if trackStartup, ownsChild { startupChild = nil } + throw error + } + } + + /// 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 { + guard shadowCreated else { return .reusable } + do { + let plan = try TmuxCommandBuilder.cleanupPlan( + executable: tmuxExecutable, + source: sourceSession, + shadow: try TmuxCommandBuilder.shadowName(source: sourceSession, runtimeID: runtimeID), + runtimeID: runtimeID + ) + let output = try await run(command: plan.verifyCommand, root: lease.root) + try TmuxCommandBuilder.verifyCleanup(output, plan: plan) + _ = try await run(command: plan.killCommand, root: lease.root) + return .reusable + } catch { + return .invalidated + } + } + + /// 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 { + guard lifecycle != .closed && lifecycle != .closing else { return } + lifecycle = .closing + let control = self.control + let controlReader = self.controlReader + let startupChild = self.startupChild + let lease = self.lease + self.control = nil + self.controlReader = nil + self.startupChild = nil + self.lease = nil + + controlReader?.cancel() + if let control { try? await control.close() } + if let startupChild { try? await startupChild.close() } + var finalDisposition = disposition + if let lease { + let cleanup = await cleanupShadowIfPossible(using: lease) + if cleanup == .invalidated { finalDisposition = .invalidated } + await lease.release(finalDisposition) + } + lifecycle = .closed + continuation.finish(throwing: error) + } +} + +enum SSHTmuxControlTransportError: Error, Equatable, Sendable, LocalizedError { + case closed + case alreadyStarted + + var errorDescription: String? { + switch self { + case .closed: + String(localized: "The tmux control connection is closed.") + case .alreadyStarted: + String(localized: "The tmux control connection has already started.") + } + } +} diff --git a/MoriRemote/MoriRemote/Tmux/TmuxControl.swift b/MoriRemote/MoriRemote/Tmux/TmuxControl.swift new file mode 100644 index 00000000..a54b328e --- /dev/null +++ b/MoriRemote/MoriRemote/Tmux/TmuxControl.swift @@ -0,0 +1,206 @@ +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 new file mode 100644 index 00000000..7386eee9 --- /dev/null +++ b/MoriRemote/MoriRemote/Tmux/TmuxSessionController.swift @@ -0,0 +1,319 @@ +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 new file mode 100644 index 00000000..68566557 --- /dev/null +++ b/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift @@ -0,0 +1,19 @@ +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. +enum TmuxShellCommand { + static let fallbackPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + + static func command(executable: String, arguments: [String]) -> String { + let executableToken = quote(executable) + let argumentTokens = arguments.map(quote).joined(separator: " ") + return "PATH=\(quote(fallbackPath)):$PATH; export PATH; tmux_path=$(command -v -- \(executableToken)) || exit 127; exec \"$tmux_path\" \(argumentTokens)" + } + + /// In POSIX shell, a single quote inside a single-quoted word must close the + /// word, emit a quoted apostrophe, then reopen it: `'"'"'`. + static func quote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } +} diff --git a/MoriRemote/MoriRemote/Views/RegularWidthServerBrowserView.swift b/MoriRemote/MoriRemote/Views/RegularWidthServerBrowserView.swift deleted file mode 100644 index 587c66fb..00000000 --- a/MoriRemote/MoriRemote/Views/RegularWidthServerBrowserView.swift +++ /dev/null @@ -1,673 +0,0 @@ -import Observation -import SwiftUI - -@MainActor -@Observable -final class RegularWidthServerSelection { - var selectedServerID: Server.ID? - private(set) var lastFocusedServerID: Server.ID? - - func selectedServer(in servers: [Server]) -> Server? { - guard let selectedServerID else { return nil } - return servers.first(where: { $0.id == selectedServerID }) - } - - func select(_ server: Server?) { - selectedServerID = server?.id - if let serverID = server?.id { - lastFocusedServerID = serverID - } - } - - func remember(_ server: Server?) { - guard let serverID = server?.id else { return } - lastFocusedServerID = serverID - if selectedServerID == nil { - selectedServerID = serverID - } - } - - func reconcile(with servers: [Server], preferredServer: Server? = nil) { - if let selectedServerID, - servers.contains(where: { $0.id == selectedServerID }) { - return - } - - if let preferredServer, - servers.contains(where: { $0.id == preferredServer.id }) { - selectedServerID = preferredServer.id - lastFocusedServerID = preferredServer.id - return - } - - if let lastFocusedServerID, - servers.contains(where: { $0.id == lastFocusedServerID }) { - selectedServerID = lastFocusedServerID - return - } - - selectedServerID = nil - } -} - -private enum RegularWidthServerDetailState { - case empty - case placeholder - case selected(Server) - case connecting(Server) - case failure(Server, String) -} - -struct RegularWidthServerBrowserView: View { - @Environment(ServerStore.self) private var store - @Environment(ShellCoordinator.self) private var coordinator - - let selection: RegularWidthServerSelection - - @State private var editingServer: Server? - @State private var showingAddSheet = false - @State private var columnVisibility = NavigationSplitViewVisibility.all - - var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { - sidebar - } detail: { - detail - .background(Theme.bg.ignoresSafeArea()) - } - .navigationSplitViewStyle(.balanced) - .preferredColorScheme(.dark) - .sheet(isPresented: $showingAddSheet) { - ServerFormView(mode: .add) { server in - store.add(server) - selection.select(server) - } - } - .sheet(item: $editingServer) { server in - ServerFormView(mode: .edit(server)) { updated in - store.update(updated) - selection.select(updated) - clearFailureIfShowing(serverID: updated.id) - } - } - .onAppear { - selection.remember(coordinator.activeServer) - syncSelection(preferredServer: coordinator.activeServer) - } - .onChange(of: store.servers) { _, servers in - selection.reconcile(with: servers, preferredServer: coordinator.activeServer) - } - .onChange(of: coordinator.state) { _, state in - if state == .connecting || state == .shell || state == .connected { - selection.remember(coordinator.activeServer) - selection.select(coordinator.activeServer) - } - if state == .disconnected { - selection.remember(coordinator.activeServer) - syncSelection(preferredServer: coordinator.activeServer) - } - } - .onChange(of: coordinator.lastError != nil) { _, _ in - syncSelection(preferredServer: coordinator.activeServer) - } - } - - private var sidebar: some View { - ZStack { - Theme.sidebarBg.ignoresSafeArea() - - ServerListContentView( - servers: store.sortedServers, - selectedServerID: selection.selectedServerID, - connectingServerID: connectingServerID, - onSelect: handleSidebarSelection, - onAdd: { showingAddSheet = true }, - onEdit: { editingServer = $0 }, - onDelete: handleDelete - ) - } - .navigationTitle(String(localized: "Servers")) - .navigationBarTitleDisplayMode(.inline) - .toolbarColorScheme(.dark, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showingAddSheet = true - } label: { - Image(systemName: "plus") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .frame(width: 32, height: 32) - .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.rowRadius) - .strokeBorder(Theme.accentBorder, lineWidth: 1) - ) - } - } - } - } - - @ViewBuilder - private var detail: some View { - switch detailState { - case .empty: - ServerBrowserInfoState( - icon: "server.rack", - title: String(localized: "No Servers"), - message: String(localized: "Add a server to start browsing your remote workspaces on iPad."), - actionTitle: String(localized: "Add Server"), - actionSystemImage: "plus", - action: { showingAddSheet = true } - ) - - case .placeholder: - ServerBrowserInfoState( - icon: "sidebar.left", - title: String(localized: "Select a Server"), - message: String(localized: "Choose a server from the sidebar to review its connection details before connecting."), - actionTitle: nil, - actionSystemImage: nil, - action: nil - ) - - case .selected(let server): - ServerBrowserSelectedDetail( - server: server, - canConnect: coordinator.state == .disconnected, - onConnect: { connect(to: server) }, - onEdit: { editingServer = server } - ) - - case .connecting(let server): - ServerBrowserConnectingDetail(server: server) - - case .failure(let server, let message): - ServerBrowserFailureDetail( - server: server, - message: message, - onRetry: { connect(to: server) }, - onEdit: { editingServer = server } - ) - } - } - - private var connectingServerID: Server.ID? { - coordinator.state == .connecting ? coordinator.activeServer?.id : nil - } - - private var detailState: RegularWidthServerDetailState { - if store.servers.isEmpty { - return .empty - } - - if let connectingServer = connectingServer { - return .connecting(connectingServer) - } - - if let failure = failureContext { - return .failure(failure.server, failure.message) - } - - if let server = selection.selectedServer(in: store.servers) { - return .selected(server) - } - - return .placeholder - } - - private var connectingServer: Server? { - coordinator.state == .connecting ? coordinator.activeServer : nil - } - - private var failureContext: (server: Server, message: String)? { - guard coordinator.state == .disconnected, - let server = coordinator.activeServer, - let error = coordinator.lastError, - selection.selectedServerID == server.id else { - return nil - } - - return (server, error.localizedDescription) - } - - private func handleSidebarSelection(_ server: Server) { - selection.select(server) - - if coordinator.activeServer?.id != server.id { - coordinator.lastError = nil - } - } - - private func handleDelete(_ server: Server) { - if selection.selectedServerID == server.id { - selection.selectedServerID = nil - } - if coordinator.activeServer?.id == server.id { - coordinator.lastError = nil - } - store.delete(server) - syncSelection(preferredServer: coordinator.activeServer) - } - - private func connect(to server: Server) { - guard coordinator.state == .disconnected else { return } - selection.remember(server) - selection.select(server) - coordinator.lastError = nil - Task { await coordinator.connect(server: server) } - } - - private func clearFailureIfShowing(serverID: Server.ID) { - if coordinator.activeServer?.id == serverID { - coordinator.lastError = nil - } - } - - private func syncSelection(preferredServer: Server?) { - selection.reconcile(with: store.servers, preferredServer: preferredServer) - } -} - -private struct ServerBrowserInfoState: View { - let icon: String - let title: String - let message: String - let actionTitle: String? - let actionSystemImage: String? - let action: (() -> Void)? - - var body: some View { - ServerBrowserDetailLayout { - VStack(alignment: .leading, spacing: 14) { - Image(systemName: icon) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(Theme.accent) - - Text(title) - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(message) - .font(.system(size: 14)) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - - if let actionTitle, let actionSystemImage, let action { - Button(action: action) { - Label(actionTitle, systemImage: actionSystemImage) - } - .buttonStyle(Theme.PrimaryButtonStyle()) - .frame(maxWidth: 220) - .padding(.top, 4) - } - } - .cardStyle(padding: 24) - } - } -} - -private struct ServerBrowserSelectedDetail: View { - let server: Server - let canConnect: Bool - let onConnect: () -> Void - let onEdit: () -> Void - - var body: some View { - ServerBrowserDetailLayout { - VStack(alignment: .leading, spacing: 16) { - header - actionRow - connectionSection - sessionSection - } - } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 12) { - Text(String(localized: "Connection")) - .moriSectionHeaderStyle() - - HStack(alignment: .top, spacing: 14) { - RoundedRectangle(cornerRadius: Theme.cardRadius) - .fill(canConnect ? Theme.accentSoft : Theme.mutedSurface) - .frame(width: 42, height: 42) - .overlay { - Image(systemName: "terminal") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(canConnect ? Theme.accent : Theme.textSecondary) - } - - VStack(alignment: .leading, spacing: 4) { - Text(server.displayName) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(server.subtitle) - .font(Theme.monoDetailFont) - .foregroundStyle(Theme.textSecondary) - } - - Spacer(minLength: 12) - - ConnectionBadge( - title: canConnect ? String(localized: "Ready to connect") : String(localized: "Connection busy"), - color: canConnect ? Theme.accent : Theme.textTertiary - ) - } - } - .cardStyle(padding: 20) - } - - private var actionRow: some View { - HStack(spacing: 12) { - Button(action: onConnect) { - Label(String(localized: "Connect"), systemImage: "arrow.up.right.circle.fill") - } - .buttonStyle(Theme.PrimaryButtonStyle(disabled: !canConnect)) - .disabled(!canConnect) - - Button(action: onEdit) { - Label(String(localized: "Edit"), systemImage: "pencil") - } - .buttonStyle(Theme.SecondaryButtonStyle()) - } - } - - private var connectionSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text(String(localized: "CONNECTION")) - .moriSectionHeaderStyle() - - VStack(spacing: 0) { - detailRow(label: String(localized: "Host"), value: server.host) - detailDivider - detailRow(label: String(localized: "Port"), value: String(server.port), useMonospace: true) - detailDivider - detailRow(label: String(localized: "Username"), value: server.username, useMonospace: true) - } - .cardStyle(padding: 0) - } - } - - private var sessionSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text(String(localized: "TMUX SESSION")) - .moriSectionHeaderStyle() - - VStack(spacing: 0) { - detailRow(label: String(localized: "Default Session"), value: server.defaultSession, useMonospace: true) - detailDivider - detailNote(canConnect - ? String(localized: "Review the server settings, then connect when you're ready.") - : String(localized: "A connection is already in progress. Finish or cancel it before starting another one.")) - } - .cardStyle(padding: 0) - } - } - - private var detailDivider: some View { - Rectangle() - .fill(Theme.divider) - .frame(height: 1) - } - - private func detailRow(label: String, value: String, useMonospace: Bool = false) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 16) { - Text(label) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - - Spacer(minLength: 12) - - Text(value) - .font(useMonospace ? Theme.monoDetailFont : .system(size: 14)) - .foregroundStyle(Theme.textPrimary) - .multilineTextAlignment(.trailing) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - } - - private func detailNote(_ text: String) -> some View { - Text(text) - .font(.system(size: 13)) - .foregroundStyle(Theme.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 14) - } -} - -private struct ServerBrowserConnectingDetail: View { - let server: Server - - var body: some View { - ServerBrowserDetailLayout { - VStack(alignment: .leading, spacing: 16) { - Text(String(localized: "Connection")) - .moriSectionHeaderStyle() - - VStack(alignment: .leading, spacing: 18) { - HStack(alignment: .top, spacing: 14) { - RoundedRectangle(cornerRadius: Theme.cardRadius) - .fill(Theme.accentSoft) - .frame(width: 42, height: 42) - .overlay { - ProgressView() - .tint(Theme.accent) - } - - VStack(alignment: .leading, spacing: 6) { - WorkflowStateBadge( - title: String(localized: "Connecting…"), - color: Theme.accent, - background: Theme.accentSoft, - border: Theme.accentBorder - ) - - Text(String(localized: "Connecting to Server")) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(server.displayName) - .font(Theme.rowTitleFont) - .foregroundStyle(Theme.textSecondary) - } - } - - WorkflowMetadataBlock(server: server) - - Text(String(localized: "Checking credentials and opening the SSH session. You can keep browsing servers while this attempt finishes.")) - .font(.system(size: 14)) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .cardStyle(padding: 20) - } - } - } -} - -private struct ServerBrowserFailureDetail: View { - let server: Server - let message: String - let onRetry: () -> Void - let onEdit: () -> Void - - var body: some View { - ServerBrowserDetailLayout { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 18) { - HStack(alignment: .top, spacing: 14) { - RoundedRectangle(cornerRadius: Theme.cardRadius) - .fill(Theme.warning.opacity(0.12)) - .frame(width: 42, height: 42) - .overlay { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Theme.warning) - } - - VStack(alignment: .leading, spacing: 6) { - WorkflowStateBadge( - title: String(localized: "Connection Failed"), - color: Theme.warning, - background: Theme.warning.opacity(0.12), - border: Theme.warning.opacity(0.24) - ) - - Text(server.displayName) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(server.subtitle) - .font(Theme.monoDetailFont) - .foregroundStyle(Theme.textSecondary) - } - } - - WorkflowMetadataBlock(server: server) - } - .cardStyle(padding: 20) - - VStack(alignment: .leading, spacing: 10) { - Label(String(localized: "SSH couldn’t connect with the current settings."), systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Theme.warning) - - Text(message) - .font(.system(size: 14)) - .foregroundStyle(Theme.textPrimary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(16) - .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: Theme.cardRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.cardRadius) - .strokeBorder(Theme.cardBorder, lineWidth: 1) - ) - - HStack(spacing: 12) { - Button(action: onRetry) { - Label(String(localized: "Retry"), systemImage: "arrow.clockwise") - } - .buttonStyle(Theme.PrimaryButtonStyle()) - - Button(action: onEdit) { - Label(String(localized: "Edit Server"), systemImage: "slider.horizontal.3") - } - .buttonStyle(Theme.SecondaryButtonStyle()) - } - } - } - } -} - -private struct WorkflowMetadataBlock: View { - let server: Server - - var body: some View { - VStack(spacing: 0) { - metadataRow(label: String(localized: "Host"), value: server.host) - metadataDivider - metadataRow(label: String(localized: "Username"), value: server.username, monospace: true) - metadataDivider - metadataRow(label: String(localized: "Session"), value: server.defaultSession, monospace: true) - } - .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: Theme.cardRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.cardRadius) - .strokeBorder(Theme.cardBorder, lineWidth: 1) - ) - } - - private var metadataDivider: some View { - Rectangle() - .fill(Theme.divider) - .frame(height: 1) - } - - private func metadataRow(label: String, value: String, monospace: Bool = false) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 16) { - Text(label) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - - Spacer(minLength: 10) - - Text(value) - .font(monospace ? Theme.monoDetailFont : .system(size: 13)) - .foregroundStyle(Theme.textPrimary) - .multilineTextAlignment(.trailing) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - } -} - -private struct WorkflowStateBadge: View { - let title: String - let color: Color - let background: Color - let border: Color - - var body: some View { - Text(title) - .font(Theme.shortcutFont.weight(.semibold)) - .foregroundStyle(color) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(background, in: RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(border, lineWidth: 1) - ) - } -} - -private struct ServerBrowserDetailLayout: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - content - } - .frame(maxWidth: 640, alignment: .leading) - .padding(.horizontal, 28) - .padding(.vertical, 24) - .frame(maxWidth: .infinity, alignment: .leading) - } - .background(Theme.bg.ignoresSafeArea()) - } -} - -private struct ConnectionBadge: View { - let title: String - let color: Color - - var body: some View { - HStack(spacing: 6) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - - Text(title) - .font(Theme.shortcutFont) - .foregroundStyle(color) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Theme.cardBorder, lineWidth: 1) - ) - } -} diff --git a/MoriRemote/MoriRemote/Views/RemoteRootView.swift b/MoriRemote/MoriRemote/Views/RemoteRootView.swift new file mode 100644 index 00000000..0376864f --- /dev/null +++ b/MoriRemote/MoriRemote/Views/RemoteRootView.swift @@ -0,0 +1,537 @@ +import SwiftUI +import UIKit + +@MainActor +struct RemoteRootView: View { + @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.scenePhase) private var scenePhase + let root: RemoteRootModel + @State private var sheet: RemoteSheet? + @State private var pendingConfirmation: RemoteDestructiveAction? + + var body: some View { + Group { + if let error = root.libraryLoadError { + VStack(spacing: 16) { + ContentUnavailableView( + String(localized: "Library unavailable"), + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + Button(String(localized: "Retry"), action: root.bootstrap) + } + } else if !root.isLoaded { + ProgressView(String(localized: "Loading library…")) + } else { + adaptiveLayout + } + } + .tint(.mint) + .task { root.bootstrap() } + .onChange(of: scenePhase) { _, phase in + root.scenePhaseChanged(phase) + } + .sheet(item: $sheet) { route in + switch route { + case .add: + ProfileEditorView(draft: .init()) { root.save($0) } + case let .edit(serverID): + if let server = root.servers.first(where: { $0.id == serverID }) { + ProfileEditorView( + draft: .init(server: server, identity: root.identities.first(where: { $0.id == server.identityID })), + existingServer: server, + onSave: { root.save($0, existingServer: server) } + ) + } + 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 } + } + } + .confirmationDialog(String(localized: "Confirm destructive action"), isPresented: destructiveConfirmationBinding, titleVisibility: .visible) { + if let action = pendingConfirmation { + 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: { + Text(pendingConfirmation?.message ?? "") + } + .alert(String(localized: "Host key confirmation"), isPresented: trustBinding, presenting: root.pendingTrust) { challenge in + Button(challenge.kind == .changed ? String(localized: "Replace trusted key") : String(localized: "Trust host key"), role: challenge.kind == .changed ? .destructive : nil) { + root.confirmTrust(challenge, replaceChanged: challenge.kind == .changed) + } + Button(String(localized: "Cancel"), role: .cancel) { root.dismissTrust() } + } message: { challenge in + Text(challengeMessage(challenge)) + } + .alert(String(localized: "Connection Failed"), isPresented: errorBinding) { + Button(String(localized: "OK"), role: .cancel) { root.errorMessage = nil } + } message: { + Text(root.errorMessage ?? "") + } + } + + /// The terminal is always the trailing child. Size-class changes only add or + /// remove the leading library, preserving the representable's surface, + /// viewport, responder, and its runtime instance. + private var adaptiveLayout: some View { + HStack(spacing: 0) { + if sizeClass == .regular { + NavigationStack { library.navigationTitle(String(localized: "Library")) } + .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) + Divider() + } + terminalDetail + } + } + + @ViewBuilder private var terminalDetail: some View { + if let runtime = root.activeRuntime { + RemoteTerminalView(root: root, runtime: runtime, compact: sizeClass == .compact, showLibrary: { sheet = .library }) + } else if sizeClass == .compact { + NavigationStack { library } + } else { + ContentUnavailableView( + String(localized: "Select a workspace"), + systemImage: "rectangle.split.3x1", + description: Text(String(localized: "Choose a saved workspace to open its terminal.")) + ) + } + } + + private var library: some View { + RemoteLibraryView( + servers: root.servers, + workspaces: root.workspaces, + activeWorkspaceIDs: Set(root.activeWorkspaces.map(\.id)), + agentSummaries: Dictionary(uniqueKeysWithValues: root.activeWorkspaces.map { ($0.id, root.agentSummary(for: $0.id)) }), + migrationReport: root.migrationReport, + onConnect: { + root.connect(workspaceID: $0) + if sizeClass == .compact { sheet = nil } + }, + 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) }, + onSettings: { sheet = .settings } + ) + } + + private var destructiveConfirmationBinding: Binding { + .init(get: { pendingConfirmation != nil }, set: { if !$0 { pendingConfirmation = nil } }) + } + private var trustBinding: Binding { + .init(get: { root.pendingTrust != nil }, set: { if !$0 { root.dismissTrust() } }) + } + private var errorBinding: Binding { + .init(get: { root.errorMessage != nil }, set: { if !$0 { root.errorMessage = nil } }) + } + private func challengeMessage(_ challenge: SSHHostTrustChallenge) -> String { + let prior = challenge.trustedFingerprint.map { "\n\n\(String(localized: "Previously trusted:")) \($0)" } ?? "" + return "\(challenge.endpoint.host):\(challenge.endpoint.port)\n\(challenge.algorithm)\n\(challenge.receivedFingerprint)\(prior)" + } +} + +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 + } + } + + 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 + 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" + } + } +} + +private struct RemoteLibraryView: View { + let servers: [SavedServer] + let workspaces: [SavedWorkspace] + let activeWorkspaceIDs: Set + let agentSummaries: [UUID: AgentMetadata] + let migrationReport: LegacyMigrationReport? + let onConnect: (UUID) -> Void + let onAdd: () -> Void + let onEdit: (SavedServer) -> Void + let onDelete: (SavedServer) -> Void + let onAddWorkspace: (UUID) -> Void + let onEditWorkspace: (SavedWorkspace) -> Void + let onDeleteWorkspace: (SavedWorkspace) -> Void + let onSettings: () -> Void + @State private var filter = "" + + var body: some View { + List { + if let migrationReport, !migrationReport.records.isEmpty { + Section(String(localized: "Migration")) { + Label(String(format: String(localized: "%lld saved profiles migrated"), migrationReport.records.filter { $0.disposition != .skippedInvalid }.count), systemImage: "checkmark.shield") + .foregroundStyle(.secondary) + } + } + if filteredServers.isEmpty { + ContentUnavailableView( + filter.isEmpty ? String(localized: "No saved servers") : String(localized: "No matching workspaces"), + systemImage: "server.rack", + description: Text(String(localized: "Add a server and workspace to begin.")) + ) + .listRowBackground(Color.clear) + } + ForEach(filteredServers) { server in + Section { + ForEach(workspaces.filter { $0.serverID == server.id && matches($0) }) { workspace in + Button { onConnect(workspace.id) } label: { + HStack { + Image(systemName: activeWorkspaceIDs.contains(workspace.id) ? "terminal.fill" : "terminal") + .foregroundStyle(activeWorkspaceIDs.contains(workspace.id) ? .mint : .secondary) + VStack(alignment: .leading) { + Text(verbatim: workspace.name) + Text(verbatim: workspace.tmuxSession) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + if let summary = agentSummaries[workspace.id], summary.state != .unknown { + AgentMetadataBadge(metadata: summary) + } else if activeWorkspaceIDs.contains(workspace.id) { + Image(systemName: "dot.radiowaves.left.and.right") + } + } + } + .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) } + } header: { + HStack { + Text(verbatim: server.name) + Spacer() + Text(verbatim: "\(server.username)@\(server.host)") + } + } + .contextMenu { + Button(String(localized: "Edit server"), action: { onEdit(server) }) + Button(String(localized: "Delete server"), role: .destructive, action: { onDelete(server) }) + } + } + } + .searchable(text: $filter, prompt: String(localized: "Filter servers and workspaces")) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button(String(localized: "Settings"), systemImage: "gear", action: onSettings) } + ToolbarItem(placement: .topBarTrailing) { Button(String(localized: "Add server"), systemImage: "plus", action: onAdd) } + } + } + + private var filteredServers: [SavedServer] { + servers.filter { server in workspaces.contains { $0.serverID == server.id && matches($0) } } + } + 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 + } +} + +private struct AgentMetadataBadge: View { + let metadata: AgentMetadata + + var body: some View { + if metadata.state != .unknown { + HStack(spacing: 4) { + Image(systemName: symbol) + if let name = metadata.name { Text(verbatim: name).lineLimit(1) } + Text(stateTitle).lineLimit(1) + } + .font(.caption.weight(.medium)) + .foregroundStyle(color) + } + } + + private var symbol: String { + switch metadata.state { + case .working: "bolt.fill" + case .waiting: "exclamationmark.circle.fill" + case .done: "checkmark.circle.fill" + case .unknown: "questionmark.circle" + } + } + private var color: Color { + switch metadata.state { + case .working: .mint + case .waiting: .orange + case .done: .green + case .unknown: .secondary + } + } + private var stateTitle: String { + switch metadata.state { + case .working: String(localized: "Working") + case .waiting: String(localized: "Waiting") + case .done: String(localized: "Done") + case .unknown: String(localized: "Unknown") + } + } +} + +@MainActor +private struct RemoteTerminalView: View { + let root: RemoteRootModel + let runtime: ActiveWorkspaceRuntime + let compact: Bool + let showLibrary: () -> Void + @State private var showsPanes = false + @State private var confirmsClosePane = false + + 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) + } + } + .background(Color.black.ignoresSafeArea()) + .sheet(isPresented: $showsPanes) { panePicker } + .onChange(of: root.runtimeRevision) { _, _ in } + .confirmationDialog(String(localized: "Close shared pane?"), isPresented: $confirmsClosePane, titleVisibility: .visible) { + Button(String(localized: "Close pane"), role: .destructive) { root.closePane() } + } message: { + Text(String(localized: "Closing this shared pane 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) + HStack(spacing: 6) { + Text(runtime.status.title).font(.caption).foregroundStyle(.secondary) + AgentMetadataBadge(metadata: runtime.metadata(for: runtime.focusedPaneID ?? TmuxPaneID(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 } + } label: { Image(systemName: "rectangle.3.group") } + Button(action: copySelection) { Image(systemName: "doc.on.doc") } + .accessibilityLabel(String(localized: "Copy selection")) + Button(action: root.disconnectActive) { Image(systemName: "power") } + .accessibilityLabel(String(localized: "Disconnect")) + } + .padding(.horizontal, 12) + .frame(height: 48) + .foregroundStyle(.white) + .background(Color(white: 0.12)) + } + + private var panePicker: some View { + NavigationStack { + List { + Section(String(localized: "Windows")) { + ForEach(runtime.topology?.windows ?? [], id: \.id) { window in + Button { root.selectWindow(window.id) } label: { + HStack { + Label { + Text(verbatim: window.name) + } icon: { + Image(systemName: window.active ? "rectangle.inset.filled" : "rectangle") + } + Spacer() + AgentMetadataBadge(metadata: windowMetadata(window)) + } + } + } + } + Section(String(localized: "Panes")) { + ForEach(runtime.topology?.panes ?? [], id: \.id) { pane in + Button { + root.selectPane(pane.id) + showsPanes = false + } label: { + HStack { + Text(verbatim: "%\(pane.id.rawValue)") + .font(.body.monospaced()) + AgentMetadataBadge(metadata: runtime.metadata(for: pane.id)) + Spacer() + Text(verbatim: "\(pane.width)×\(pane.height)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle(String(localized: "Workspace controls")) + .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(String(localized: "Done")) { showsPanes = false } } } + } + } + + private func windowMetadata(_ window: TmuxSessionController.Window) -> 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 struct ProfileEditorView: View { + @Environment(\.dismiss) private var dismiss + @State private var draft: ServerWorkspaceDraft + let existingServer: SavedServer? + let onSave: (ServerWorkspaceDraft) -> Void + + init(draft: ServerWorkspaceDraft, existingServer: SavedServer? = nil, onSave: @escaping (ServerWorkspaceDraft) -> Void) { + _draft = State(initialValue: draft) + self.existingServer = existingServer + self.onSave = onSave + } + + var body: some View { + NavigationStack { + Form { + Section(String(localized: "Server")) { + TextField(String(localized: "Name"), text: $draft.serverName) + TextField(String(localized: "Host"), text: $draft.host) + .textInputAutocapitalization(.never).autocorrectionDisabled() + TextField(String(localized: "Port"), text: $draft.port).keyboardType(.numberPad) + 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) + Text(String(localized: "Private key")).tag(SSHIdentityKind.privateKey) + } + if draft.identityKind == .password { + SecureField(String(localized: "Password"), text: $draft.password) + } else { + TextEditor(text: $draft.privateKey).frame(minHeight: 110) + SecureField(String(localized: "Private key passphrase (optional)"), text: $draft.passphrase) + } + } + } + .navigationTitle(String(localized: existingServer == nil ? "Add server" : "Edit server")) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button(String(localized: "Cancel"), action: dismiss.callAsFunction) } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "Save")) { onSave(draft); dismiss() } + } + } + } + } +} + +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 + let onSave: (RemoteSettings) -> Void + + init(settings: RemoteSettings, onSave: @escaping (RemoteSettings) -> Void) { + _settings = State(initialValue: settings) + self.onSave = onSave + } + + var body: some View { + NavigationStack { + Form { + Section(String(localized: "Terminal")) { + Stepper(String(format: String(localized: "Initial scrollback: %lld lines"), settings.initialScrollbackLines), value: $settings.initialScrollbackLines, in: RemoteSettings.minimumInitialScrollbackLines...RemoteSettings.maximumScrollbackLines, step: 500) + Text(String(localized: "Local history is limited to 10,000 lines; server copy-mode browsing stays disabled.")) + .font(.footnote).foregroundStyle(.secondary) + } + Section(String(localized: "Security")) { + Toggle(String(localized: "Allow legacy RSA/SHA-1 authentication"), isOn: $settings.allowLegacyRSA) + } + } + .navigationTitle(String(localized: "Settings")) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button(String(localized: "Cancel"), action: dismiss.callAsFunction) } + ToolbarItem(placement: .confirmationAction) { Button(String(localized: "Save")) { onSave(settings); dismiss() } } + } + } + } +} diff --git a/MoriRemote/MoriRemote/Views/ServerFormView.swift b/MoriRemote/MoriRemote/Views/ServerFormView.swift deleted file mode 100644 index 787b505c..00000000 --- a/MoriRemote/MoriRemote/Views/ServerFormView.swift +++ /dev/null @@ -1,234 +0,0 @@ -import SwiftUI - -struct ServerFormView: View { - enum Mode: Identifiable { - case add - case edit(Server) - - var id: String { - switch self { - case .add: return "add" - case .edit(let s): return s.id.uuidString - } - } - } - - let mode: Mode - let onSave: (Server) -> Void - - @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - @State private var name: String - @State private var host: String - @State private var port: String - @State private var username: String - @State private var password: String - @State private var defaultSession: String - - init(mode: Mode, onSave: @escaping (Server) -> Void) { - self.mode = mode - self.onSave = onSave - - switch mode { - case .add: - _name = State(initialValue: "") - _host = State(initialValue: "") - _port = State(initialValue: "22") - _username = State(initialValue: "") - _password = State(initialValue: "") - _defaultSession = State(initialValue: "main") - case .edit(let server): - _name = State(initialValue: server.name) - _host = State(initialValue: server.host) - _port = State(initialValue: String(server.port)) - _username = State(initialValue: server.username) - _password = State(initialValue: server.password) - _defaultSession = State(initialValue: server.defaultSession) - } - } - - private var title: String { - switch mode { - case .add: return String(localized: "Add Server") - case .edit: return String(localized: "Edit Server") - } - } - - private var isValid: Bool { - let p = Int(port) ?? 0 - return !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !password.isEmpty && - p > 0 && p <= 65535 - } - - private var formMaxWidth: CGFloat { - horizontalSizeClass == .regular ? 560 : .infinity - } - - var body: some View { - NavigationStack { - ZStack { - Theme.bg.ignoresSafeArea() - - ScrollView { - VStack(alignment: .leading, spacing: 18) { - formSummary - - fieldSection(String(localized: "LABEL")) { - field(String(localized: "My Server"), text: $name) - } - - fieldSection(String(localized: "CONNECTION")) { - field(String(localized: "hostname or IP"), text: $host) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - - Divider().overlay(Theme.divider) - - HStack(spacing: 12) { - Text(String(localized: "Port")) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - - Spacer() - - TextField(String(localized: "22"), text: $port) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 92) - .font(Theme.monoDetailFont) - .foregroundStyle(Theme.textPrimary) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - } - - fieldSection(String(localized: "AUTHENTICATION")) { - field(String(localized: "username"), text: $username) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Divider().overlay(Theme.divider) - - SecureField(String(localized: "password"), text: $password) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .foregroundStyle(Theme.textPrimary) - } - - fieldSection(String(localized: "TMUX SESSION")) { - field(String(localized: "main"), text: $defaultSession) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - - Button { - save() - } label: { - Text(mode.isAdd ? String(localized: "Add Server") : String(localized: "Save Changes")) - } - .buttonStyle(Theme.PrimaryButtonStyle(disabled: !isValid)) - .disabled(!isValid) - .padding(.top, 2) - } - .frame(maxWidth: formMaxWidth, alignment: .leading) - .padding(.horizontal, Theme.contentInset) - .padding(.top, 16) - .padding(.bottom, 24) - .frame(maxWidth: .infinity) - } - } - .navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - .toolbarColorScheme(.dark, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "Cancel")) { dismiss() } - .foregroundStyle(Theme.textSecondary) - } - } - } - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - .presentationCornerRadius(Theme.sheetRadius) - .presentationBackground(Theme.bg) - .preferredColorScheme(.dark) - } - - private var formSummary: some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(mode.isAdd - ? String(localized: "Add a server to get started.") - : String(localized: "Review the server settings, then connect when you're ready.")) - .font(.system(size: 13)) - .foregroundStyle(Theme.textSecondary) - } - .cardStyle(padding: 18) - } - - @ViewBuilder - private func fieldSection(_ header: String, @ViewBuilder content: () -> some View) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(header) - .moriSectionHeaderStyle() - .padding(.leading, 2) - - VStack(spacing: 0) { - content() - } - .cardStyle(padding: 0) - } - } - - private func field(_ placeholder: String, text: Binding) -> some View { - TextField(placeholder, text: text) - .font(.system(size: 14)) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .foregroundStyle(Theme.textPrimary) - } - - private func save() { - let portValue = Int(port) ?? 22 - let normalizedDefaultSession = { - let trimmed = defaultSession.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "main" : trimmed - }() - - switch mode { - case .add: - let server = Server( - name: name.trimmingCharacters(in: .whitespacesAndNewlines), - host: host.trimmingCharacters(in: .whitespacesAndNewlines), - port: portValue, - username: username.trimmingCharacters(in: .whitespacesAndNewlines), - password: password, - defaultSession: normalizedDefaultSession - ) - onSave(server) - case .edit(var server): - server.name = name.trimmingCharacters(in: .whitespacesAndNewlines) - server.host = host.trimmingCharacters(in: .whitespacesAndNewlines) - server.port = portValue - server.username = username.trimmingCharacters(in: .whitespacesAndNewlines) - server.password = password - server.defaultSession = normalizedDefaultSession - onSave(server) - } - dismiss() - } -} - -extension ServerFormView.Mode { - var isAdd: Bool { - if case .add = self { return true } - return false - } -} diff --git a/MoriRemote/MoriRemote/Views/ServerListView.swift b/MoriRemote/MoriRemote/Views/ServerListView.swift deleted file mode 100644 index 36b04fc9..00000000 --- a/MoriRemote/MoriRemote/Views/ServerListView.swift +++ /dev/null @@ -1,304 +0,0 @@ -import SwiftUI - -struct ServerListView: View { - @Environment(ServerStore.self) private var store - @Environment(ShellCoordinator.self) private var coordinator - - @State private var editingServer: Server? - @State private var showingAddSheet = false - - var body: some View { - NavigationStack { - ZStack { - Theme.bg.ignoresSafeArea() - - ServerListContentView( - servers: store.sortedServers, - selectedServerID: nil, - connectingServerID: connectingServerID, - onSelect: connectToServer, - onAdd: { showingAddSheet = true }, - onEdit: { editingServer = $0 }, - onDelete: deleteServer - ) - } - .navigationTitle(String(localized: "Servers")) - .navigationBarTitleDisplayMode(.inline) - .toolbarColorScheme(.dark, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showingAddSheet = true - } label: { - Image(systemName: "plus") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .frame(width: 32, height: 32) - .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.rowRadius) - .strokeBorder(Theme.accentBorder, lineWidth: 1) - ) - } - } - } - .sheet(isPresented: $showingAddSheet) { - ServerFormView(mode: .add) { server in - addServer(server) - } - } - .sheet(item: $editingServer) { server in - ServerFormView(mode: .edit(server)) { updated in - updateServer(updated) - } - } - .overlay(alignment: .bottom) { - if let error = coordinator.lastError { - ErrorBanner(message: error.localizedDescription) { - coordinator.lastError = nil - } - .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.horizontal, Theme.contentInset) - .padding(.bottom, 8) - } - } - } - .preferredColorScheme(.dark) - } - - private var connectingServerID: Server.ID? { - coordinator.state == .connecting ? coordinator.activeServer?.id : nil - } - - private func connectToServer(_ server: Server) { - guard coordinator.state == .disconnected else { return } - Task { await coordinator.connect(server: server) } - } - - private func addServer(_ server: Server) { - coordinator.lastError = nil - store.add(server) - } - - private func updateServer(_ server: Server) { - if coordinator.activeServer?.id == server.id { - coordinator.lastError = nil - } - store.update(server) - } - - private func deleteServer(_ server: Server) { - if coordinator.activeServer?.id == server.id { - coordinator.lastError = nil - } - store.delete(server) - } -} - -struct ServerListContentView: View { - let servers: [Server] - let selectedServerID: Server.ID? - let connectingServerID: Server.ID? - let onSelect: (Server) -> Void - let onAdd: () -> Void - let onEdit: (Server) -> Void - let onDelete: (Server) -> Void - - var body: some View { - if servers.isEmpty { - ServerListEmptyState(onAdd: onAdd) - } else { - List { - Section { - ForEach(servers) { server in - ServerRow( - server: server, - isSelected: server.id == selectedServerID, - isConnecting: server.id == connectingServerID, - onTap: { onSelect(server) }, - onEdit: { onEdit(server) }, - onDelete: { onDelete(server) } - ) - .listRowInsets(EdgeInsets(top: 4, leading: Theme.contentInset, bottom: 4, trailing: Theme.contentInset)) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - } header: { - Text(String(localized: "Servers")) - .moriSectionHeaderStyle() - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Theme.bg) - } - } -} - -private struct ServerListEmptyState: View { - let onAdd: () -> Void - - var body: some View { - VStack(spacing: 14) { - Spacer(minLength: 40) - - Image(systemName: "server.rack") - .font(.system(size: 28, weight: .semibold)) - .foregroundStyle(Theme.textTertiary) - - Text(String(localized: "No Servers")) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(String(localized: "Add a server to get started.")) - .font(.system(size: 14)) - .foregroundStyle(Theme.textSecondary) - .multilineTextAlignment(.center) - - Button(action: onAdd) { - Label(String(localized: "Add Server"), systemImage: "plus") - } - .buttonStyle(Theme.PrimaryButtonStyle()) - .frame(maxWidth: 220) - .padding(.top, 4) - - Spacer() - } - .padding(.horizontal, 24) - } -} - -private struct ServerRow: View { - let server: Server - let isSelected: Bool - let isConnecting: Bool - let onTap: () -> Void - let onEdit: () -> Void - let onDelete: () -> Void - - @State private var showDeleteConfirm = false - - var body: some View { - HStack(spacing: 0) { - Button(action: onTap) { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: Theme.rowRadius) - .fill(isSelected ? Theme.accentSoft : Theme.mutedSurface) - .frame(width: 36, height: 36) - - if isConnecting { - ProgressView() - .tint(Theme.accent) - .scaleEffect(0.9) - } else { - Image(systemName: "terminal") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(isSelected ? Theme.accent : Theme.textSecondary) - } - } - - VStack(alignment: .leading, spacing: 3) { - Text(server.displayName) - .font(Theme.rowTitleFont) - .foregroundStyle(Theme.textPrimary) - .lineLimit(1) - - Text(server.subtitle) - .font(Theme.monoCaptionFont) - .foregroundStyle(Theme.textSecondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - if isConnecting { - Text(String(localized: "Connecting…")) - .font(Theme.shortcutFont) - .foregroundStyle(Theme.accent) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: 5)) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - Menu { - Button { onEdit() } label: { - Label(String(localized: "Edit"), systemImage: "pencil") - } - Button(role: .destructive) { showDeleteConfirm = true } label: { - Label(String(localized: "Delete"), systemImage: "trash") - } - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Theme.textTertiary) - .frame(width: 36, height: 36) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .rowSurfaceStyle(selected: isSelected) - .contextMenu { - Button { onEdit() } label: { - Label(String(localized: "Edit"), systemImage: "pencil") - } - Button(role: .destructive) { showDeleteConfirm = true } label: { - Label(String(localized: "Delete"), systemImage: "trash") - } - } - .swipeActions(edge: .leading, allowsFullSwipe: false) { - Button { onEdit() } label: { - Label(String(localized: "Edit"), systemImage: "pencil") - } - .tint(Theme.accent) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { showDeleteConfirm = true } label: { - Label(String(localized: "Delete"), systemImage: "trash") - } - } - .confirmationDialog(String( - format: String(localized: "Delete %@?"), - server.displayName - ), isPresented: $showDeleteConfirm) { - Button(String(localized: "Delete"), role: .destructive) { onDelete() } - } - } -} - -struct ErrorBanner: View { - let message: String - let onDismiss: () -> Void - - var body: some View { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Theme.warning) - - Text(message) - .font(.system(size: 13)) - .foregroundStyle(Theme.textPrimary) - .lineLimit(2) - - Spacer(minLength: 8) - - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Theme.textSecondary) - } - } - .padding(12) - .background(Color(red: 0.18, green: 0.14, blue: 0.08), in: RoundedRectangle(cornerRadius: Theme.cardRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.cardRadius) - .strokeBorder(Theme.warning.opacity(0.24), lineWidth: 1) - ) - } -} diff --git a/MoriRemote/MoriRemote/Views/SidebarContainer.swift b/MoriRemote/MoriRemote/Views/SidebarContainer.swift deleted file mode 100644 index ba0e4a97..00000000 --- a/MoriRemote/MoriRemote/Views/SidebarContainer.swift +++ /dev/null @@ -1,130 +0,0 @@ -#if os(iOS) -import SwiftUI - -/// Container that adds a slide-from-left sidebar overlay to terminal content. -struct SidebarContainer: View { - @Binding var isOpen: Bool - let content: Content - - private let sidebarWidth: CGFloat = 300 - - let sidebar: () -> Sidebar - - init(isOpen: Binding, @ViewBuilder sidebar: @escaping () -> Sidebar, @ViewBuilder content: () -> Content) { - self._isOpen = isOpen - self.sidebar = sidebar - self.content = content() - } - - @State private var dragOffset: CGFloat = 0 - @GestureState private var isDragging = false - - var body: some View { - GeometryReader { _ in - ZStack(alignment: .leading) { - content - .frame(maxWidth: .infinity, maxHeight: .infinity) - .gesture(edgeOpenGesture) - - if isOpen || isDragging { - Color.black - .opacity(dimmingOpacity) - .ignoresSafeArea() - .onTapGesture { close() } - .gesture(closeGesture) - .allowsHitTesting(isOpen) - } - - if isOpen || isDragging { - sidebarPanel - .frame(width: sidebarWidth) - .offset(x: sidebarOffset) - .transition(.identity) - } - } - } - } - - private var sidebarPanel: some View { - sidebar() - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: 0, - bottomLeadingRadius: 0, - bottomTrailingRadius: 12, - topTrailingRadius: 12 - ) - ) - .overlay(alignment: .trailing) { - Rectangle() - .fill(Theme.divider) - .frame(width: 1) - } - .shadow(color: .black.opacity(0.28), radius: 18, x: 8) - } - - private var edgeOpenGesture: some Gesture { - DragGesture(minimumDistance: 15, coordinateSpace: .global) - .updating($isDragging) { _, state, _ in - state = true - } - .onChanged { value in - guard !isOpen, value.startLocation.x < 30 else { return } - let translation = max(0, min(sidebarWidth, value.translation.width)) - dragOffset = translation - sidebarWidth - } - .onEnded { value in - guard !isOpen else { return } - let velocity = value.predictedEndTranslation.width - value.translation.width - if value.translation.width > 80 || velocity > 200 { - open() - } else { - close() - } - dragOffset = 0 - } - } - - private var closeGesture: some Gesture { - DragGesture(minimumDistance: 15, coordinateSpace: .global) - .onChanged { value in - guard isOpen else { return } - let translation = min(0, value.translation.width) - dragOffset = translation - } - .onEnded { value in - guard isOpen else { return } - let velocity = value.predictedEndTranslation.width - value.translation.width - if value.translation.width < -60 || velocity < -200 { - close() - } else { - open() - } - dragOffset = 0 - } - } - - private var sidebarOffset: CGFloat { - dragOffset - } - - private var dimmingOpacity: Double { - let progress = 1.0 + Double(dragOffset) / Double(sidebarWidth) - return 0.34 * max(0, min(1, progress)) - } - - private func open() { - withAnimation(.easeOut(duration: 0.18)) { - isOpen = true - dragOffset = 0 - } - } - - private func close() { - withAnimation(.easeOut(duration: 0.16)) { - isOpen = false - dragOffset = 0 - } - } -} -#endif diff --git a/MoriRemote/MoriRemote/Views/TerminalScreen.swift b/MoriRemote/MoriRemote/Views/TerminalScreen.swift deleted file mode 100644 index 4a1699af..00000000 --- a/MoriRemote/MoriRemote/Views/TerminalScreen.swift +++ /dev/null @@ -1,423 +0,0 @@ -import MoriTerminal -import SwiftUI - -struct TerminalScreen: View { - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(ShellCoordinator.self) private var coordinator - - let sessionHost: TerminalSessionHost - let serverName: String - let onDisconnect: () -> Void - let onSwitchHost: () -> Void - let onBackToWorkspace: () -> Void - - @State private var showRegularSidebar = true - - var body: some View { - Group { - if horizontalSizeClass == .regular { - regularWorkspace - } else { - compactWorkspace - } - } - .statusBarHidden(true) - .preferredColorScheme(.dark) - .sheet(isPresented: keyBarCustomizeBinding) { - KeyBarCustomizeView(keyBar: sessionHost.accessoryBar.keyBar) - .presentationDetents([.medium, .large]) - } - .confirmationDialog( - String(localized: "Tmux Shortcuts"), - isPresented: tmuxCommandsBinding, - titleVisibility: .visible - ) { - Button(String(localized: "New Tab")) { coordinator.handleTmuxCommand(.newWindow) } - Button(String(localized: "Next Tab")) { coordinator.handleTmuxCommand(.nextWindow) } - Button(String(localized: "Previous Tab")) { coordinator.handleTmuxCommand(.prevWindow) } - Button(String(localized: "Split Right")) { coordinator.handleTmuxCommand(.splitRight) } - Button(String(localized: "Split Down")) { coordinator.handleTmuxCommand(.splitDown) } - Button(String(localized: "Next Pane")) { coordinator.handleTmuxCommand(.nextPane) } - Button(String(localized: "Previous Pane")) { coordinator.handleTmuxCommand(.prevPane) } - Button(String(localized: "Toggle Zoom")) { coordinator.handleTmuxCommand(.toggleZoom) } - Button(String(localized: "Close Pane"), role: .destructive) { coordinator.handleTmuxCommand(.closePane) } - Button(String(localized: "Detach"), role: .destructive) { coordinator.handleTmuxCommand(.detach) } - Button(String(localized: "Cancel"), role: .cancel) { } - } - .onAppear { - sessionHost.accessoryBar.onBackTapped = onSwitchHost - sessionHost.handleCoordinatorStateChange( - coordinator.state, - activeServerID: coordinator.activeServer?.id - ) - } - .onChange(of: coordinator.state) { _, newState in - sessionHost.handleCoordinatorStateChange( - newState, - activeServerID: coordinator.activeServer?.id - ) - } - .onChange(of: horizontalSizeClass) { _, newSizeClass in - sessionHost.accessoryBar.onBackTapped = onSwitchHost - if newSizeClass == .regular { - sessionHost.showSidebar = false - } - } - } - - private var compactWorkspace: some View { - VStack(spacing: 0) { - compactTopBar - if compactWindows.count > 1 { - compactWindowChipsBar - } - terminalContent(showsCompactChrome: true) - } - .background(Theme.terminalBg.ignoresSafeArea()) - .sheet(isPresented: sidebarBinding) { - sidebarContent(presentation: .overlay, onDismiss: { sessionHost.showSidebar = false }) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - .presentationBackground(Theme.sidebarBg) - } - } - - private var regularWorkspace: some View { - HStack(spacing: 0) { - if showRegularSidebar { - sidebarContent( - presentation: .persistent, - onDismiss: { showRegularSidebar = false } - ) - .frame(width: 304) - .background(Theme.sidebarBg) - - Rectangle() - .fill(Theme.divider) - .frame(width: 1) - } - - terminalContent(showsCompactChrome: false) - } - .safeAreaInset(edge: .top, alignment: .leading) { - if coordinator.state == .shell && !showRegularSidebar { - HStack { - regularSidebarRevealButton - Spacer(minLength: 0) - } - .padding(.top, 6) - .padding(.leading, 12) - .padding(.trailing, 12) - } - } - .background(Theme.terminalBg.ignoresSafeArea()) - } - - private var compactTopBar: some View { - HStack(spacing: 10) { - Button(action: onBackToWorkspace) { - Image(systemName: "chevron.left") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - - Button { sessionHost.showSidebar = true } label: { - VStack(alignment: .leading, spacing: 1) { - Text(currentWindow?.workspaceTitle ?? String(localized: "Terminal")) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .lineLimit(1) - - Text(coordinator.tmuxActiveSession?.name ?? serverName) - .font(Theme.monoCaptionFont) - .foregroundStyle(Theme.textTertiary) - .lineLimit(1) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - Spacer(minLength: 8) - - if let window = currentWindow { - AgentStatusChip(status: window.agentStatus, fallback: window.fallbackCommand) - } - - Button { sessionHost.showTmuxCommands = true } label: { - Image(systemName: "command") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Theme.textSecondary) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - } - .frame(height: 44) - .padding(.horizontal, 8) - .background(Theme.terminalBg) - .overlay(alignment: .bottom) { - Rectangle().fill(Theme.divider).frame(height: 1) - } - } - - private var compactWindowChipsBar: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach(compactWindows) { window in - TerminalWindowChip( - window: window, - isSelected: window.isActive, - onSelect: { - coordinator.selectTmuxWindow(session: window.sessionName, windowIndex: window.index) - } - ) - } - } - .padding(.horizontal, 8) - .frame(height: 34) - } - .background(Theme.terminalBg) - .overlay(alignment: .bottom) { - Rectangle().fill(Theme.divider).frame(height: 1) - } - } - - private var compactWindows: [TmuxWindow] { - coordinator.tmuxActiveSession?.windows ?? [] - } - - private var currentWindow: TmuxWindow? { - coordinator.tmuxActiveSession?.windows.first(where: { $0.isActive }) - } - - private func sidebarContent( - presentation: TmuxSidebarPresentation, - onDismiss: (() -> Void)? - ) -> some View { - TmuxSidebarView( - presentation: presentation, - onDismiss: onDismiss, - onDisconnect: onDisconnect, - onSwitchHost: onSwitchHost - ) - } - - private var keyBarCustomizeBinding: Binding { - Binding( - get: { sessionHost.showKeyBarCustomize }, - set: { sessionHost.showKeyBarCustomize = $0 } - ) - } - - private var tmuxCommandsBinding: Binding { - Binding( - get: { sessionHost.showTmuxCommands }, - set: { sessionHost.showTmuxCommands = $0 } - ) - } - - private var sidebarBinding: Binding { - Binding( - get: { sessionHost.showSidebar }, - set: { sessionHost.showSidebar = $0 } - ) - } - - private func terminalContent(showsCompactChrome: Bool) -> some View { - ZStack { - Theme.terminalBg.ignoresSafeArea() - - TerminalView( - onRendererReady: { renderer in - sessionHost.handleRendererReady(renderer, coordinator: coordinator) - } - ) - .ignoresSafeArea(.container, edges: .bottom) - - if coordinator.state != .shell { - TerminalPreparingOverlay( - serverName: serverName, - subtitle: coordinator.activeServer?.subtitle ?? "", - sessionName: coordinator.activeServer?.defaultSession ?? "" - ) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } - } - } - - private var regularSidebarRevealButton: some View { - Button { - showRegularSidebar = true - } label: { - Image(systemName: "sidebar.left") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .frame(width: 32, height: 32) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) - } - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1) - ) - } -} - -private struct TerminalWindowChip: View { - let window: TmuxWindow - let isSelected: Bool - let onSelect: () -> Void - - @State private var pulse = false - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 5) { - Circle() - .fill(chipColor) - .frame(width: 5, height: 5) - .opacity(window.agentStatus == .working && pulse ? 0.35 : 1) - - Text(window.workspaceTitle) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(isSelected ? Theme.textPrimary : Theme.textSecondary) - .lineLimit(1) - } - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(isSelected ? Theme.accentSoft : Theme.mutedSurface, in: Capsule()) - .overlay( - Capsule() - .strokeBorder(isSelected ? Theme.accentBorder : Theme.cardBorder, lineWidth: 1) - ) - } - .buttonStyle(.plain) - .onAppear { updatePulse(for: window.agentStatus) } - .onChange(of: window.agentStatus) { _, newStatus in - updatePulse(for: newStatus) - } - } - - private var chipColor: Color { - window.agentStatus?.color ?? Theme.textTertiary - } - - private func updatePulse(for status: TmuxAgentStatus?) { - if status == .working { - withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { - pulse = true - } - } else { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - pulse = false - } - } - } -} - -private struct TerminalPreparingOverlay: View { - let serverName: String - let subtitle: String - let sessionName: String - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .top, spacing: 14) { - RoundedRectangle(cornerRadius: Theme.cardRadius) - .fill(Theme.accentSoft) - .frame(width: 42, height: 42) - .overlay { - ProgressView() - .tint(Theme.accent) - } - - VStack(alignment: .leading, spacing: 6) { - TerminalStateBadge(title: String(localized: "SSH Connected")) - - Text(String(localized: "Preparing Terminal")) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(serverName) - .font(Theme.rowTitleFont) - .foregroundStyle(Theme.textSecondary) - - if !subtitle.isEmpty { - Text(subtitle) - .font(Theme.monoDetailFont) - .foregroundStyle(Theme.textSecondary) - } - } - } - - VStack(spacing: 0) { - terminalMetadataRow(label: String(localized: "Session"), value: sessionName, monospace: true) - Rectangle() - .fill(Theme.divider) - .frame(height: 1) - terminalMetadataRow(label: String(localized: "Status"), value: String(localized: "Opening shell…")) - } - .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: Theme.cardRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.cardRadius) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1) - ) - - Text(String(localized: "Opening the interactive shell and checking tmux windows.")) - .font(.system(size: 14)) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(20) - .frame(maxWidth: 360, alignment: .leading) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.18), radius: 18, y: 8) - .padding(.horizontal, 24) - } - - private func terminalMetadataRow(label: String, value: String, monospace: Bool = false) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 16) { - Text(label) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - - Spacer(minLength: 8) - - Text(value) - .font(monospace ? Theme.monoDetailFont : .system(size: 13)) - .foregroundStyle(Theme.textPrimary) - .multilineTextAlignment(.trailing) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - } -} - -private struct TerminalStateBadge: View { - let title: String - - var body: some View { - HStack(spacing: 6) { - Circle() - .fill(Theme.accent) - .frame(width: 6, height: 6) - - Text(title) - .font(Theme.shortcutFont.weight(.semibold)) - .foregroundStyle(Theme.accent) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Theme.accentBorder, lineWidth: 1) - ) - } -} diff --git a/MoriRemote/MoriRemote/Views/TerminalSessionHost.swift b/MoriRemote/MoriRemote/Views/TerminalSessionHost.swift deleted file mode 100644 index 7dec07b4..00000000 --- a/MoriRemote/MoriRemote/Views/TerminalSessionHost.swift +++ /dev/null @@ -1,121 +0,0 @@ -import MoriTerminal -import Observation -import SwiftUI - -@MainActor -@Observable -final class TerminalSessionHost { - var showKeyBarCustomize = false - var showTmuxCommands = false - var showSidebar = false - let accessoryBar = TerminalAccessoryBar() - - private weak var renderer: SwiftTermRenderer? - private var hostedSessionState: HostedSessionState = .idle - private var lastOpenRequest: ShellOpenRequest? - - func handleRendererReady(_ renderer: SwiftTermRenderer, coordinator: ShellCoordinator) { - self.renderer = renderer - coordinator.accessoryBar = accessoryBar - accessoryBar.onCustomizeTapped = { [weak self] in - self?.showKeyBarCustomize = true - } - accessoryBar.onTmuxMenuTapped = { [weak self] in - self?.showTmuxCommands = true - } - accessoryBar.onSidebarTapped = { [weak self] in - self?.showSidebar = true - } - - renderer.initialLayoutHandler = { [weak self, weak renderer] _, _ in - guard let self, let renderer else { return } - self.openShellIfNeeded(with: renderer, coordinator: coordinator) - } - - let size = renderer.gridSize() - if size.cols > 0 && size.rows > 0 { - openShellIfNeeded(with: renderer, coordinator: coordinator) - } - } - - func handleCoordinatorStateChange(_ state: ShellState, activeServerID: Server.ID?) { - switch state { - case .disconnected: - hostedSessionState = .idle - lastOpenRequest = nil - renderer?.initialLayoutHandler = nil - renderer = nil - showKeyBarCustomize = false - showTmuxCommands = false - showSidebar = false - - case .connecting: - if hostedSessionState.serverID != activeServerID { - hostedSessionState = .idle - lastOpenRequest = nil - } - showKeyBarCustomize = false - showTmuxCommands = false - showSidebar = false - - case .connected: - hostedSessionState = activeServerID.map(HostedSessionState.waitingForShell) ?? .idle - if lastOpenRequest?.serverID != activeServerID { - lastOpenRequest = nil - } - showKeyBarCustomize = false - showTmuxCommands = false - showSidebar = false - - case .shell: - hostedSessionState = activeServerID.map(HostedSessionState.shellOpen) ?? .idle - renderer?.activateKeyboard() - } - } - - private func openShellIfNeeded(with renderer: SwiftTermRenderer, coordinator: ShellCoordinator) { - guard let activeServerID = coordinator.activeServer?.id else { return } - - let request = ShellOpenRequest(serverID: activeServerID, rendererID: ObjectIdentifier(renderer)) - guard lastOpenRequest != request else { return } - - switch coordinator.state { - case .connected: - if hostedSessionState.serverID != activeServerID { - hostedSessionState = .waitingForShell(activeServerID) - } - lastOpenRequest = request - renderer.initialLayoutHandler = nil - Task { await coordinator.openShell(renderer: renderer) } - - case .shell: - hostedSessionState = .shellOpen(activeServerID) - lastOpenRequest = request - renderer.initialLayoutHandler = nil - Task { await coordinator.openShell(renderer: renderer) } - - case .connecting, .disconnected: - break - } - } -} - -private struct ShellOpenRequest: Equatable { - let serverID: Server.ID - let rendererID: ObjectIdentifier -} - -private enum HostedSessionState: Equatable { - case idle - case waitingForShell(Server.ID) - case shellOpen(Server.ID) - - var serverID: Server.ID? { - switch self { - case .idle: - nil - case .waitingForShell(let serverID), .shellOpen(let serverID): - serverID - } - } -} diff --git a/MoriRemote/MoriRemote/Views/TmuxSidebarView.swift b/MoriRemote/MoriRemote/Views/TmuxSidebarView.swift deleted file mode 100644 index 5ed9aaf3..00000000 --- a/MoriRemote/MoriRemote/Views/TmuxSidebarView.swift +++ /dev/null @@ -1,748 +0,0 @@ -#if os(iOS) -import SwiftUI - -enum TmuxSidebarPresentation { - case overlay - case persistent - - var showsDismissButton: Bool { self == .overlay } -} - -/// Sidebar wrapper that feeds coordinator state into the pure workspace list. -struct TmuxSidebarView: View { - @Environment(ShellCoordinator.self) private var coordinator - - let presentation: TmuxSidebarPresentation - let onDismiss: (() -> Void)? - let onDisconnect: () -> Void - let onSwitchHost: () -> Void - - @State private var renameTarget: TmuxSession? - @State private var renameText = "" - - var body: some View { - WorkspaceView( - serverName: coordinator.activeServer?.displayName ?? String(localized: "Mori Remote"), - sessions: coordinator.tmuxSessions, - activeSessionName: coordinator.tmuxActiveSession?.name, - activeWindowID: coordinator.tmuxActiveSession?.windows.first(where: { $0.isActive })?.id, - showsDismissButton: presentation.showsDismissButton, - onSelectWindow: { session, windowIndex in - coordinator.selectTmuxWindow(session: session, windowIndex: windowIndex) - onDismiss?() - }, - onSelectPane: { session, windowIndex, paneId in - coordinator.selectTmuxPane(session: session, windowIndex: windowIndex, paneId: paneId) - onDismiss?() - }, - onSwitchSession: { session in - coordinator.switchTmuxSession(session) - onDismiss?() - }, - onRenameSession: { session in - renameTarget = session - renameText = session.name - }, - onKillSession: { session in coordinator.closeTmuxSession(session) }, - onNewWindowAfter: { session, windowIndex in - coordinator.newTmuxWindowAfter(session: session, windowIndex: windowIndex) - }, - onCloseWindow: { session, windowIndex in - coordinator.closeTmuxWindow(session: session, windowIndex: windowIndex) - }, - onNewWindow: { coordinator.newTmuxWindow() }, - onNewSession: { coordinator.newTmuxSession() }, - onSwitchHost: onSwitchHost, - onDisconnect: onDisconnect, - onDismiss: onDismiss, - onRefresh: { coordinator.refreshTmuxState() } - ) - .alert(String(localized: "Rename Session"), isPresented: showRenameAlert) { - TextField(String(localized: "Session name"), text: $renameText) - Button(String(localized: "Cancel"), role: .cancel) { } - Button(String(localized: "Rename")) { - if let session = renameTarget, !renameText.isEmpty { - coordinator.renameTmuxSession(session.name, to: renameText) - } - } - } - } - - private var showRenameAlert: Binding { - Binding( - get: { renameTarget != nil }, - set: { if !$0 { renameTarget = nil } } - ) - } -} - - -enum TmuxAgentStatus: String, Sendable { - case waiting - case working - case done - - init?(_ raw: String?) { - guard let raw else { return nil } - switch raw.lowercased() { - case "waiting": self = .waiting - case "working": self = .working - case "done": self = .done - default: return nil - } - } - - var title: String { - switch self { - case .waiting: String(localized: "Needs input") - case .working: String(localized: "Working") - case .done: String(localized: "Done") - } - } - - var color: Color { - switch self { - case .waiting: Theme.agentWaiting - case .working: Theme.agentWorking - case .done: Theme.agentDone - } - } -} - -struct WorkspaceProjectGroup: Identifiable, Sendable { - let project: String - let sessions: [TmuxSession] - - var id: String { project } -} - -struct WorkspaceView: View { - let serverName: String - let sessions: [TmuxSession] - let activeSessionName: String? - let activeWindowID: String? - let showsDismissButton: Bool - let onSelectWindow: (String, Int) -> Void - let onSelectPane: (String, Int, String) -> Void - let onSwitchSession: (String) -> Void - let onRenameSession: (TmuxSession) -> Void - let onKillSession: (String) -> Void - let onNewWindowAfter: (String, Int) -> Void - let onCloseWindow: (String, Int) -> Void - let onNewWindow: () -> Void - let onNewSession: () -> Void - let onSwitchHost: () -> Void - let onDisconnect: () -> Void - let onDismiss: (() -> Void)? - let onRefresh: () -> Void - - @State private var filterQuery = "" - - private var trimmedQuery: String { - filterQuery.trimmingCharacters(in: .whitespacesAndNewlines) - } - - /// Sessions matching the filter. A hit on the session name (which contains - /// project and branch) keeps the whole session; otherwise only matching - /// windows survive, and windowless sessions drop out. - private var filteredSessions: [TmuxSession] { - let query = trimmedQuery - guard !query.isEmpty else { return sessions } - return sessions.compactMap { session in - if Self.matches(query, session.name) { return session } - let windows = session.windows.filter { window in - Self.matches(query, window.workspaceTitle) - || Self.matches(query, window.name) - || Self.matches(query, window.path) - } - guard !windows.isEmpty else { return nil } - var filtered = session - filtered.windows = windows - return filtered - } - } - - private static func matches(_ query: String, _ haystack: String) -> Bool { - haystack.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil - } - - private var projectGroups: [WorkspaceProjectGroup] { - var order: [String] = [] - var map: [String: [TmuxSession]] = [:] - for session in filteredSessions { - let project = Self.projectName(for: session.name) - if map[project] == nil { order.append(project) } - map[project, default: []].append(session) - } - return order.map { WorkspaceProjectGroup(project: $0, sessions: map[$0] ?? []) } - } - - var body: some View { - VStack(spacing: 0) { - WorkspaceTopBar( - serverName: serverName, - showsDismissButton: showsDismissButton, - onDismiss: onDismiss, - onNewWindow: onNewWindow, - onNewSession: onNewSession, - onSwitchHost: onSwitchHost, - onDisconnect: onDisconnect - ) - - ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { - if sessions.isEmpty { - WorkspaceEmptyState(onNewSession: onNewSession) - } else if projectGroups.isEmpty { - WorkspaceNoMatchesState(query: trimmedQuery) - } else { - ForEach(projectGroups) { group in - WorkspaceProjectSection( - group: group, - activeSessionName: activeSessionName, - activeWindowID: activeWindowID, - onSelectWindow: onSelectWindow, - onSelectPane: onSelectPane, - onSwitchSession: onSwitchSession, - onRenameSession: onRenameSession, - onKillSession: onKillSession, - onNewWindowAfter: onNewWindowAfter, - onCloseWindow: onCloseWindow - ) - } - } - } - .padding(.horizontal, 14) - .padding(.top, 16) - .padding(.bottom, 28) - } - .refreshable { onRefresh() } - .safeAreaInset(edge: .bottom, spacing: 0) { - if !sessions.isEmpty { - WorkspaceFilterBar(text: $filterQuery) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Theme.sidebarBg) - } - - static func projectName(for sessionName: String) -> String { - guard let slash = sessionName.firstIndex(of: "/") else { return sessionName } - return String(sessionName[.. String? { - guard let slash = sessionName.firstIndex(of: "/") else { return nil } - let branch = sessionName[sessionName.index(after: slash)...] - return branch.isEmpty ? nil : String(branch) - } -} - -private struct WorkspaceTopBar: View { - let serverName: String - let showsDismissButton: Bool - let onDismiss: (() -> Void)? - let onNewWindow: () -> Void - let onNewSession: () -> Void - let onSwitchHost: () -> Void - let onDisconnect: () -> Void - - var body: some View { - HStack(spacing: 10) { - if showsDismissButton { - Button { onDismiss?() } label: { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(Theme.textSecondary) - .frame(width: 30, height: 30) - } - .buttonStyle(.plain) - } - - Circle() - .fill(Theme.agentDone) - .frame(width: 7, height: 7) - - Text(serverName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .lineLimit(1) - - Spacer(minLength: 8) - - Menu { - Button { onSwitchHost() } label: { - Label(String(localized: "Switch Host"), systemImage: "arrow.left.arrow.right") - } - Button(role: .destructive) { onDisconnect() } label: { - Label(String(localized: "Disconnect"), systemImage: "power") - } - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Theme.textSecondary) - .frame(width: 32, height: 32) - } - - Menu { - Button { onNewWindow() } label: { - Label(String(localized: "New Window"), systemImage: "plus.rectangle") - } - Button { onNewSession() } label: { - Label(String(localized: "New Session"), systemImage: "plus.square.on.square") - } - } label: { - Image(systemName: "plus") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .frame(width: 32, height: 32) - .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.rowRadius) - .strokeBorder(Theme.accentBorder, lineWidth: 1) - ) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(Theme.sidebarBg) - .overlay(alignment: .bottom) { - Rectangle().fill(Theme.divider).frame(height: 1) - } - } -} - -private struct WorkspaceProjectSection: View { - let group: WorkspaceProjectGroup - let activeSessionName: String? - let activeWindowID: String? - let onSelectWindow: (String, Int) -> Void - let onSelectPane: (String, Int, String) -> Void - let onSwitchSession: (String) -> Void - let onRenameSession: (TmuxSession) -> Void - let onKillSession: (String) -> Void - let onNewWindowAfter: (String, Int) -> Void - let onCloseWindow: (String, Int) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 7) { - Text(group.project) - .moriSectionHeaderStyle() - - if showsAttachedInHeader { - Circle() - .fill(Theme.accent) - .frame(width: 5, height: 5) - } - - Spacer(minLength: 0) - } - .padding(.horizontal, 4) - - VStack(alignment: .leading, spacing: 14) { - ForEach(group.sessions) { session in - WorkspaceSessionBlock( - session: session, - branchLabel: WorkspaceView.branchLabel(for: session.name), - isActiveSession: session.name == activeSessionName, - activeWindowID: activeWindowID, - hidesSessionHeader: hidesSessionHeader(for: session), - onSelectWindow: onSelectWindow, - onSelectPane: onSelectPane, - onSwitchSession: onSwitchSession, - onRenameSession: onRenameSession, - onKillSession: onKillSession, - onNewWindowAfter: onNewWindowAfter, - onCloseWindow: onCloseWindow - ) - } - } - } - } - - private var showsAttachedInHeader: Bool { - group.sessions.count == 1 && WorkspaceView.branchLabel(for: group.sessions[0].name) == nil && group.sessions[0].isAttached - } - - private func hidesSessionHeader(for session: TmuxSession) -> Bool { - group.sessions.count == 1 && WorkspaceView.branchLabel(for: session.name) == nil - } -} - -private struct WorkspaceSessionBlock: View { - let session: TmuxSession - let branchLabel: String? - let isActiveSession: Bool - let activeWindowID: String? - let hidesSessionHeader: Bool - let onSelectWindow: (String, Int) -> Void - let onSelectPane: (String, Int, String) -> Void - let onSwitchSession: (String) -> Void - let onRenameSession: (TmuxSession) -> Void - let onKillSession: (String) -> Void - let onNewWindowAfter: (String, Int) -> Void - let onCloseWindow: (String, Int) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - if !hidesSessionHeader { - sessionHeader - } - - VStack(spacing: 2) { - ForEach(session.windows) { window in - let isSelected = isActiveSession && window.id == activeWindowID - WorkspaceWindowRow( - window: window, - isSelected: isSelected, - onSelect: { onSelectWindow(session.name, window.index) }, - onNewAfter: { onNewWindowAfter(session.name, window.index) }, - onClose: { onCloseWindow(session.name, window.index) } - ) - - if window.panes.count > 1 { - VStack(spacing: 2) { - ForEach(window.panes) { pane in - WorkspacePaneRow( - pane: pane, - isSelected: isSelected && pane.isActive, - onSelect: { onSelectPane(session.name, window.index, pane.paneId) } - ) - } - } - .padding(.leading, 28) - } - } - } - } - } - - private var sessionHeader: some View { - HStack(spacing: 7) { - Text(branchLabel ?? session.name) - .font(.system(size: 12, weight: .bold)) - .tracking(0.8) - .foregroundStyle(isActiveSession ? Theme.textPrimary : Theme.textSecondary) - .lineLimit(1) - - if session.isAttached { - Circle() - .fill(Theme.accent) - .frame(width: 5, height: 5) - } - - Spacer(minLength: 0) - } - .padding(.horizontal, 4) - .contentShape(Rectangle()) - .contextMenu { - Button { onSwitchSession(session.name) } label: { - Label(String(localized: "Switch to Session"), systemImage: "arrow.right.square") - } - - Divider() - - Button { onRenameSession(session) } label: { - Label(String(localized: "Rename Session"), systemImage: "pencil") - } - - Divider() - - Button(role: .destructive) { onKillSession(session.name) } label: { - Label(String(localized: "Kill Session"), systemImage: "xmark.circle") - } - } - } -} - -private struct WorkspaceWindowRow: View { - let window: TmuxWindow - let isSelected: Bool - let onSelect: () -> Void - let onNewAfter: () -> Void - let onClose: () -> Void - - private var title: String { - window.workspaceTitle - } - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 10) { - Image(systemName: isSelected ? "rectangle.inset.filled" : "rectangle") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(isSelected ? Theme.accent : Theme.textTertiary) - .frame(width: 18) - - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(Theme.rowTitleFont) - .foregroundStyle(Theme.textPrimary) - .lineLimit(1) - - if !window.shortPath.isEmpty { - Text(window.shortPath) - .font(Theme.monoCaptionFont) - .foregroundStyle(Theme.textTertiary) - .lineLimit(1) - } - } - - Spacer(minLength: 8) - - AgentStatusChip(status: window.agentStatus, fallback: window.fallbackCommand) - } - .padding(.horizontal, 10) - .padding(.vertical, 9) - .background(isSelected ? Theme.accentSoft : Color.clear, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { - Button { onSelect() } label: { - Label(String(localized: "Switch to Window"), systemImage: "arrow.up.right.square") - } - - Divider() - - Button { onNewAfter() } label: { - Label(String(localized: "New Window After"), systemImage: "plus.rectangle") - } - - Divider() - - Button(role: .destructive) { onClose() } label: { - Label(String(localized: "Close Window"), systemImage: "xmark.circle") - } - } - } -} - -private struct WorkspacePaneRow: View { - let pane: TmuxPane - let isSelected: Bool - let onSelect: () -> Void - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 8) { - Image(systemName: pane.isActive ? "rectangle.split.2x1.fill" : "rectangle.split.2x1") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(isSelected ? Theme.accent : Theme.textTertiary) - .frame(width: 16) - - VStack(alignment: .leading, spacing: 2) { - Text(pane.displayLabel) - .font(.system(size: 12, weight: isSelected ? .semibold : .medium)) - .foregroundStyle(isSelected ? Theme.textPrimary : Theme.textSecondary) - .lineLimit(1) - - if !pane.shortPath.isEmpty { - Text(pane.shortPath) - .font(Theme.monoCaptionFont) - .foregroundStyle(Theme.textTertiary) - .lineLimit(1) - } - } - - Spacer(minLength: 6) - - AgentStatusChip(status: TmuxAgentStatus(pane.agentState), fallback: pane.command) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(isSelected ? Theme.accentSoft : Color.clear, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -struct AgentStatusChip: View { - let status: TmuxAgentStatus? - let fallback: String? - @State private var pulse = false - - var body: some View { - HStack(spacing: 5) { - Circle() - .fill(chipColor) - .frame(width: 6, height: 6) - .opacity(status == .working && pulse ? 0.35 : 1) - - Text(label) - .font(Theme.chipFont) - .foregroundStyle(chipColor) - .lineLimit(1) - } - .padding(.horizontal, Theme.chipHorizontalPadding) - .padding(.vertical, Theme.chipVerticalPadding) - .background(chipColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(chipColor.opacity(0.24), lineWidth: 1) - ) - .onAppear { updatePulse(for: status) } - .onChange(of: status) { _, newStatus in - updatePulse(for: newStatus) - } - } - - private func updatePulse(for status: TmuxAgentStatus?) { - if status == .working { - withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { - pulse = true - } - } else { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - pulse = false - } - } - } - - private var label: String { - if let status { return status.title } - let value = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return value.isEmpty ? String(localized: "tmux") : value - } - - private var chipColor: Color { - status?.color ?? Theme.textTertiary - } -} - -/// Bottom-anchored quick filter, cmd+p style: thumb-reachable, rides above the -/// keyboard via the safe-area inset. -private struct WorkspaceFilterBar: View { - @Binding var text: String - - var body: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Theme.textTertiary) - - TextField(String(localized: "Filter sessions"), text: $text) - .font(.system(size: 14)) - .foregroundStyle(Theme.textPrimary) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.done) - - if !text.isEmpty { - Button { text = "" } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) - .foregroundStyle(Theme.textTertiary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.rowRadius) - .strokeBorder(Theme.cardBorder, lineWidth: 1) - ) - .padding(.horizontal, 14) - .padding(.top, 8) - .padding(.bottom, 10) - .background(Theme.sidebarBg) - .overlay(alignment: .top) { - Rectangle().fill(Theme.divider).frame(height: 1) - } - } -} - -private struct WorkspaceNoMatchesState: View { - let query: String - - var body: some View { - VStack(alignment: .center, spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(Theme.textTertiary) - - Text(String(localized: "No matches for “\(query)”")) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 34) - } -} - -private struct WorkspaceEmptyState: View { - let onNewSession: () -> Void - - var body: some View { - VStack(alignment: .center, spacing: 10) { - Image(systemName: "square.grid.2x2") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(Theme.textTertiary) - - Text(String(localized: "No tmux sessions")) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - - Text(String(localized: "Start tmux to manage\nwindows from here.")) - .font(.system(size: 12)) - .foregroundStyle(Theme.textSecondary) - .multilineTextAlignment(.center) - - Button(action: onNewSession) { - Label(String(localized: "New Session"), systemImage: "plus") - } - .buttonStyle(Theme.SecondaryButtonStyle( - foreground: Theme.accent, - background: Theme.accentSoft, - border: Theme.accentBorder - )) - .frame(maxWidth: 180) - .padding(.top, 4) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 34) - } -} - -extension TmuxPane { - var shortPath: String { - guard !path.isEmpty else { return "" } - let display = path.contains("/Users/") || path.contains("/home/") - ? "~" + path.split(separator: "/").dropFirst(2).map { "/" + $0 }.joined() - : path - let parts = display.split(separator: "/") - if parts.count <= 2 { return display } - return "…/" + parts.suffix(2).joined(separator: "/") - } -} - -extension TmuxWindow { - var workspaceTitle: String { - let pane = panes.first(where: { $0.agentName?.isEmpty == false }) ?? panes.first - if let agentName = pane?.agentName, !agentName.isEmpty { return agentName } - if !name.isEmpty && name != "[tmux]" { return name } - if let command = fallbackCommand, !command.isEmpty { return command } - return name - } - - var agentStatus: TmuxAgentStatus? { - let statuses = panes.compactMap { TmuxAgentStatus($0.agentState) } - if statuses.contains(.waiting) { return .waiting } - if statuses.contains(.working) { return .working } - if statuses.contains(.done) { return .done } - return nil - } - - var fallbackCommand: String? { - panes.first(where: { !$0.command.isEmpty })?.command - } -} -#endif diff --git a/MoriRemote/MoriRemoteTests/LegacyMigrationTests.swift b/MoriRemote/MoriRemoteTests/LegacyMigrationTests.swift new file mode 100644 index 00000000..3dc47198 --- /dev/null +++ b/MoriRemote/MoriRemoteTests/LegacyMigrationTests.swift @@ -0,0 +1,270 @@ +import Foundation +import Testing +@testable import MoriRemote + +@Suite("Legacy profile migration") +struct LegacyMigrationTests { + @Test("empty install completes once") + func emptyInstall() throws { + let fixture = try Fixture() + let report = try fixture.migrator().migrateIfNeeded() + #expect(report.completed) + #expect(report.records.isEmpty) + #expect(try fixture.storage.servers.all().isEmpty) + #expect(try fixture.storage.migration.loadIfPresent() != nil) + } + + @Test("migrates profiles, identities, workspaces and credentials without mutating legacy JSON") + func successfulMigration() throws { + let fixture = try Fixture() + let first = fixture.legacyServer(name: "First", session: "dev", date: Date(timeIntervalSince1970: 20.123_456)) + let second = fixture.legacyServer(name: "Second", session: "ops", date: Date(timeIntervalSince1970: 10.654_321)) + let legacyBytes = try fixture.writeLegacy([first, second]) + fixture.legacyCredentials.values[first.id] = "first-password" + fixture.legacyCredentials.values[second.id] = "second-password" + + let report = try fixture.migrator().migrateIfNeeded() + #expect(report.records.map(\.disposition) == [.migrated, .migrated]) + let servers = try fixture.storage.servers.all() + let identities = try fixture.storage.identities.all() + let workspaces = try fixture.storage.workspaces.all() + #expect(servers.map(\.id) == [first.id, second.id]) + #expect(identities.map(\.id) == [first.id, second.id]) + #expect(workspaces.map(\.serverID) == [first.id, second.id]) + #expect(workspaces.map(\.tmuxSession) == ["dev", "ops"]) + #expect(servers.map(\.lastConnectedAt) == [first.lastConnectedAt, second.lastConnectedAt]) + #expect(workspaces.map(\.lastConnectedAt) == [first.lastConnectedAt, second.lastConnectedAt]) + #expect(try fixture.destinationCredentials.password(for: first.id) == "first-password") + #expect(try Data(contentsOf: fixture.legacyURL) == legacyBytes) + #expect(try fixture.legacyCredentials.password(for: first.id) == "first-password") + } + + @Test("missing password is terminal and second launch is idempotent") + func missingPasswordAndSecondLaunch() throws { + let fixture = try Fixture() + let server = fixture.legacyServer() + _ = try fixture.writeLegacy([server]) + fixture.legacyCredentials.values[server.id] = "" + + let first = try fixture.migrator().migrateIfNeeded() + #expect(first.records == [LegacyMigrationRecord(legacyID: server.id.uuidString, disposition: .migratedWithoutCredential)]) + #expect(try fixture.destinationCredentials.password(for: server.id) == nil) + #expect(try credentialRequirement(identityID: server.id, credentials: fixture.destinationCredentials) == .credentialRequired) + let second = try fixture.migrator().migrateIfNeeded() + #expect(second == first) + #expect(try fixture.storage.servers.all().count == 1) + } + + @Test("malformed JSON and duplicate UUIDs receive terminal invalid dispositions") + func malformedAndDuplicates() throws { + let malformed = try Fixture() + try Data("not json".utf8).write(to: malformed.legacyURL) + let malformedReport = try malformed.migrator().migrateIfNeeded() + #expect(malformedReport.records.map(\.disposition) == [.skippedInvalid]) + #expect(try malformed.storage.migration.loadIfPresent() != nil) + + let fixture = try Fixture() + let server = fixture.legacyServer() + _ = try fixture.writeLegacy([server, server]) + let report = try fixture.migrator().migrateIfNeeded() + #expect(report.records.map(\.disposition) == [.migratedWithoutCredential, .skippedInvalid]) + #expect(try fixture.storage.servers.all().count == 1) + } + + @Test("mixed invalid and dated records have deterministic recent-first order") + func mixedInvalidAndDatedRecords() throws { + let fixture = try Fixture() + let early = fixture.legacyServer(name: "Early", date: Date(timeIntervalSince1970: 1)) + let late = fixture.legacyServer(name: "Late", date: Date(timeIntervalSince1970: 2)) + let earlyObject = try JSONSerialization.jsonObject(with: JSONEncoder().encode(early)) + let lateObject = try JSONSerialization.jsonObject(with: JSONEncoder().encode(late)) + try JSONSerialization.data(withJSONObject: [earlyObject, "invalid", lateObject]).write(to: fixture.legacyURL) + + let report = try fixture.migrator().migrateIfNeeded() + #expect(report.records.map(\.legacyID) == [late.id.uuidString, early.id.uuidString, "invalid-1"]) + #expect(try fixture.storage.servers.all().map(\.id) == [late.id, early.id]) + } + + @Test("whitespace default session normalizes to main while newline remains invalid") + func normalizesEmptyDefaultSession() throws { + let fixture = try Fixture() + let whitespace = fixture.legacyServer(name: "Whitespace", session: " \t ") + let newline = fixture.legacyServer(name: "Newline", session: "bad\nsession") + _ = try fixture.writeLegacy([whitespace, newline]) + + let report = try fixture.migrator().migrateIfNeeded() + #expect(report.records.map(\.disposition) == [.migratedWithoutCredential, .skippedInvalid]) + #expect(try fixture.storage.workspaces.all().map(\.tmuxSession) == ["main"]) + } + + @Test("a corrupt destination store fails closed without a completion marker") + func corruptDestinationStore() throws { + let fixture = try Fixture() + let server = fixture.legacyServer() + _ = try fixture.writeLegacy([server]) + let destination = fixture.storage.root.appendingPathComponent("servers.json") + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("corrupt".utf8).write(to: destination) + #expect(throws: PersistenceError.corruptStore("servers.json")) { + try fixture.migrator().migrateIfNeeded() + } + #expect(try fixture.storage.migration.loadIfPresent() == nil) + } + + @Test("write failure leaves marker absent and retry has no duplicate records") + func partialWriteRetry() throws { + let fixture = try Fixture() + let server = fixture.legacyServer() + _ = try fixture.writeLegacy([server]) + let failingStorage = MoriRemoteStorage(root: fixture.storage.root, writer: FailingWriter(failOnWrite: 4)) + #expect(throws: Error.self) { + try fixture.migrator(storage: failingStorage).migrateIfNeeded() + } + #expect(try fixture.storage.migration.loadIfPresent() == nil) + + _ = try fixture.migrator().migrateIfNeeded() + #expect(try fixture.storage.servers.all().map(\.id) == [server.id]) + #expect(try fixture.storage.identities.all().map(\.id) == [server.id]) + #expect(try fixture.storage.workspaces.all().map(\.id) == [server.id]) + } + + @Test("retry preserves post-interruption profile and password edits") + func interruptedMigrationPreservesUserEdits() throws { + let fixture = try Fixture() + let first = fixture.legacyServer(name: "Original") + let second = fixture.legacyServer(name: "Second") + _ = try fixture.writeLegacy([first, second]) + fixture.legacyCredentials.values[first.id] = "old-first" + fixture.legacyCredentials.values[second.id] = "old-second" + + let interrupted = MoriRemoteStorage(root: fixture.storage.root, writer: FailingWriter(failOnWrite: 4)) + #expect(throws: Error.self) { try fixture.migrator(storage: interrupted).migrateIfNeeded() } + var edited = try fixture.storage.servers.all().first! + edited.name = "Edited by user" + try fixture.storage.servers.replace(edited) + let editedCredential = ["new", "first"].joined(separator: "-") + try fixture.destinationCredentials.setPassword(editedCredential, for: first.id) + + _ = try fixture.migrator().migrateIfNeeded() + #expect(try fixture.storage.servers.all().first?.name == "Edited by user") + #expect(try fixture.destinationCredentials.password(for: first.id) == editedCredential) + #expect(try fixture.destinationCredentials.password(for: second.id) == "old-second") + } + + @Test("trust invalidation failure leaves the server at its old endpoint") + func trustInvalidationFailureIsFailClosed() throws { + let fixture = try Fixture() + let serverID = UUID() + let oldEndpoint = try CanonicalEndpoint(host: "old.example", port: 22) + let newEndpoint = try CanonicalEndpoint(host: "new.example", port: 22) + let serverURL = fixture.root.appendingPathComponent("servers.json") + let trustedURL = fixture.root.appendingPathComponent("trusted-hosts.json") + let trustedHosts = TrustedHostStore(url: trustedURL) + let initialRepository = SavedServerRepository(url: serverURL, trustedHosts: trustedHosts) + _ = try initialRepository.insertIfAbsent(SavedServer(id: serverID, name: "Server", host: oldEndpoint.host, port: oldEndpoint.port, username: "v")) + try trustedHosts.trust(TrustedHost(serverID: serverID, endpoint: oldEndpoint, algorithm: "ssh-ed25519", fingerprint: "abc", trustedAt: .distantPast)) + + let failingRepository = SavedServerRepository(url: serverURL, trustedHosts: TrustedHostStore(url: trustedURL, writer: AlwaysFailingWriter())) + #expect(throws: Error.self) { + try failingRepository.replace(SavedServer(id: serverID, name: "Server", host: newEndpoint.host, port: newEndpoint.port, username: "v")) + } + #expect(try failingRepository.all().first?.endpoint == oldEndpoint) + } + + @Test("canonical endpoint removes case, brackets and stale trust") + func canonicalEndpointInvalidatesTrust() throws { + let fixture = try Fixture() + let serverID = UUID() + let original = try CanonicalEndpoint(host: "[2001:DB8::1]", port: 22) + let changed = try CanonicalEndpoint(host: "example.com", port: 2200) + let server = SavedServer(id: serverID, name: "Server", host: "[2001:DB8::1]", port: 22, username: "v") + _ = try fixture.storage.servers.insertIfAbsent(server) + try fixture.storage.trustedHosts.trust(TrustedHost(serverID: serverID, endpoint: original, algorithm: "ssh-ed25519", fingerprint: "abc", trustedAt: .distantPast)) + #expect(original.host == "2001:db8::1") + try fixture.storage.servers.replace(SavedServer(id: serverID, name: "Server", host: changed.host, port: changed.port, username: "v")) + #expect(try fixture.storage.trustedHosts.trustedHost(for: serverID, endpoint: original) == nil) + } +} + +private final class Fixture: @unchecked Sendable { + let root: URL + let legacyURL: URL + let storage: MoriRemoteStorage + let legacyCredentials = MemoryCredentials() + let destinationCredentials = MemoryCredentials() + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + legacyURL = root.appendingPathComponent("Documents/servers.json") + storage = MoriRemoteStorage(root: root.appendingPathComponent("Application Support/MoriRemote")) + try FileManager.default.createDirectory(at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true) + } + + deinit { try? FileManager.default.removeItem(at: root) } + + func legacyServer(name: String = "Server", session: String = "main", date: Date? = nil) -> LegacyServerRecord { + LegacyServerRecord(id: UUID(), name: name, host: "Example.COM", port: 22, username: "v", defaultSession: session, lastConnectedAt: date) + } + + @discardableResult + func writeLegacy(_ servers: [LegacyServerRecord]) throws -> Data { + let data = try JSONEncoder().encode(servers) // Legacy ServerStore used JSONEncoder defaults. + try data.write(to: legacyURL) + return data + } + + func migrator(storage: MoriRemoteStorage? = nil) -> LegacyServerMigrator { + LegacyServerMigrator(storage: storage ?? self.storage, legacyServersURL: legacyURL, legacyCredentials: legacyCredentials, destinationCredentials: destinationCredentials, now: { .distantPast }) + } +} + +private final class MemoryCredentials: CredentialStoring, @unchecked Sendable { + private let lock = NSLock() + var values: [UUID: String] = [:] + + func password(for identityID: UUID) throws -> String? { + lock.withLock { values[identityID] } + } + + func createPasswordIfAbsent(_ password: String, for identityID: UUID) throws -> Bool { + lock.withLock { + guard values[identityID] == nil else { return false } + values[identityID] = password + return true + } + } + + func setPassword(_ password: String, for identityID: UUID) throws { + lock.withLock { values[identityID] = password } + } + + func deletePassword(for identityID: UUID) throws { + lock.withLock { values.removeValue(forKey: identityID) } + } +} + +private struct FailingWriter: AtomicDataWriting { + let failOnWrite: Int + private let state = FailureState() + + init(failOnWrite: Int) { self.failOnWrite = failOnWrite } + + func write(_ data: Data, to url: URL) throws { + let writeNumber = state.nextWriteNumber() + guard writeNumber != failOnWrite else { throw CocoaError(.fileWriteUnknown) } + try FoundationAtomicDataWriter().write(data, to: url) + } +} + +private struct AlwaysFailingWriter: AtomicDataWriting { + func write(_: Data, to _: URL) throws { throw CocoaError(.fileWriteUnknown) } +} + +private final class FailureState: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func nextWriteNumber() -> Int { + lock.withLock { count += 1; return count } + } +} diff --git a/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift b/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift new file mode 100644 index 00000000..07479e16 --- /dev/null +++ b/MoriRemote/MoriRemoteTests/Phase2TransportTests.swift @@ -0,0 +1,425 @@ +import Foundation +import Testing +@testable import MoriRemote + +@Suite("Phase 2 transport evidence") struct Phase2TransportTests { + @Test("private-key secret CRUD is fail-closed and passwords remain fallback") + func privateKeySecrets() throws { + let id = UUID() + let secrets = MemorySecrets() + let passwords = Passwords([id: "phase-one-password"]) + let store = KeychainSSHCredentialStore(passwords: passwords, secrets: secrets) + #expect(try store.credential(for: id) == .password("phase-one-password")) + + let first = SSHPrivateKeyInspector.generateEd25519(comment: "one").privateKeyPEM + try store.savePrivateKey(.init(privateKeyPEM: first, passphrase: "first passphrase"), for: id) + #expect(try store.credential(for: id) == .privateKey(.init(privateKeyPEM: first, passphrase: "first passphrase"))) + + let second = SSHPrivateKeyInspector.generateEd25519(comment: "two").privateKeyPEM + try store.savePrivateKey(.init(privateKeyPEM: second, passphrase: "second passphrase"), for: id) + #expect(try store.credential(for: id) == .privateKey(.init(privateKeyPEM: second, passphrase: "second passphrase"))) + try store.deletePrivateKey(for: id) + #expect(try store.credential(for: id) == .password("phase-one-password")) + + try secrets.createOrUpdate(Data("not json".utf8), service: KeychainSSHCredentialStore.privateKeyService, account: id.uuidString) + #expect(throws: SSHCredentialStoreError.self) { try store.credential(for: id) } + } + + @Test("Phase 2 user-facing errors resolve localized descriptions") + func localizedErrors() throws { + #expect(TmuxCommandError.unsupportedVersion.errorDescription == "tmux 3.2 or later is required.") + #expect(SSHAuthResolverError.missingCredential(UUID()).errorDescription == "SSH credential is required.") + #expect(SSHPrivateKeyInspectionError.unsupportedKeyType("ssh-dss").errorDescription == "SSH private key type “ssh-dss” is not supported.") + + let endpoint = try CanonicalEndpoint(host: "example.test", port: 22) + let unknown = SSHHostTrustChallenge( + kind: .unknown, + serverID: UUID(), + endpoint: endpoint, + algorithm: "ssh-ed25519", + receivedFingerprint: "SHA256:received", + trustedFingerprint: nil + ) + let changed = SSHHostTrustChallenge( + kind: .changed, + serverID: unknown.serverID, + endpoint: endpoint, + algorithm: "ssh-ed25519", + receivedFingerprint: "SHA256:received", + trustedFingerprint: "SHA256:trusted" + ) + #expect(SSHHostTrustError.trustRequired(unknown).errorDescription == "The SSH host key is unknown. Review and trust it before connecting.") + #expect(SSHHostTrustError.changedKey(changed).errorDescription == "The SSH host key changed. Connection refused.") + } + + @Test("authentication completion gate accepts exactly one terminal event") + func authenticationGateIsOneShot() { + let state = SSHAuthenticationCompletionState() + #expect(state.claim(.succeed) == .succeed) + #expect(state.claim(.fail(.closed)) == nil) + + let failureFirst = SSHAuthenticationCompletionState() + #expect(failureFirst.claim(.fail(.closed)) == .fail(.closed)) + #expect(failureFirst.claim(.succeed) == nil) + } + + @Test("host trust gate fails before authentication or child opening") + func trustPrecedesAuthentication() throws { + let server = SavedServer(name: "server", host: "example.test", username: "v") + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let connector = TrustSequencingConnector(server: server, trust: SSHHostTrustResolver(store: TrustedHostStore(url: root))) + #expect(throws: SSHHostTrustError.self) { try connector.connectSynchronously() } + #expect(!connector.authenticated) + #expect(connector.childrenOpened == 0) + } + + @Test("transport performs preflight then grouped shadow then isolated attach") + func lifecycle() async throws { + let id = UUID() + let root = FakeRoot(plans: [ + .finished("tmux 3.2a\n"), .finished(""), .open, + .finished("workspace--mori-remote-\(id.uuidString.lowercased())\tworkspace\n"), .finished(""), + ]) + let connector = FakeConnector(roots: [root]) + let transport = SSHTmuxControlTransport( + connector: connector, pool: SSHRootPool(), poolKey: try key(), sourceSession: "workspace", runtimeID: id + ) + try await transport.start() + let commands = root.commands() + #expect(commands.count == 3) + #expect(commands[0].contains("'-V'")) + #expect(commands[1].contains("'new-session'")) + #expect(commands[2].contains("'-C' 'attach-session'")) + #expect(commands[2].contains("'active-pane,ignore-size'")) + await transport.close(disposition: .reusable) + #expect(root.commands().count == 5) + #expect(root.commands()[3].contains("'display-message'")) + #expect(root.commands()[4].contains("'kill-session'")) + } + + @Test("control child bytes reach the public transport stream") + func forwardsControlBytes() async throws { + let expected = Data("%session-changed $0 workspace\n".utf8) + let id = UUID(uuidString: "00000000-0000-0000-0000-0000F04A2D1A")! + let root = FakeRoot(plans: [ + .finished("tmux 3.2a\n"), .finished(""), .streaming(expected), + .finished("workspace--mori-remote-\(id.uuidString.lowercased())\tworkspace\n"), .finished(""), + ]) + let transport = SSHTmuxControlTransport( + connector: FakeConnector(roots: [root]), + pool: SSHRootPool(), + poolKey: try key(), + sourceSession: "workspace", + runtimeID: id + ) + let recorder = DataRecorder() + let reader = Task { + do { + for try await bytes in transport.receivedBytes { + recorder.append(bytes) + break + } + } catch {} + } + + try await transport.start() + try await eventually { recorder.values() == [expected] } + await transport.close(disposition: .reusable) + reader.cancel() + } + + @Test("old or malformed tmux never mutates") + func rejectedPreflight() async throws { + for output in ["tmux 3.1\n", "not tmux\n"] { + let root = FakeRoot(plans: [.finished(output)]) + let transport = SSHTmuxControlTransport( + connector: FakeConnector(roots: [root]), pool: SSHRootPool(), poolKey: try key(), sourceSession: "workspace", runtimeID: UUID() + ) + await #expect(throws: Error.self) { try await transport.start() } + #expect(root.commands().count == 1) + } + let root = FakeRoot(plans: [.finished("tmux 3.1\n")]) + let transport = SSHTmuxControlTransport( + connector: FakeConnector(roots: [root]), pool: SSHRootPool(), poolKey: try key(), sourceSession: "workspace", runtimeID: UUID() + ) + do { + try await transport.start() + Issue.record("unsupported tmux unexpectedly started") + } catch let error as TmuxCommandError { + #expect(error == .unsupportedVersion) + } catch { + Issue.record("startup error was masked: \(error)") + } + } + + @Test("attach failure and cleanup mismatch invalidate without kill") + func failuresInvalidate() async throws { + let id = UUID() + let failedAttach = FakeRoot(plans: [ + .finished("tmux 3.2\n"), .finished(""), .failed, + .finished("workspace--mori-remote-\(id.uuidString.lowercased())\tworkspace\n"), .finished("") + ]) + let failureTransport = SSHTmuxControlTransport( + connector: FakeConnector(roots: [failedAttach]), pool: SSHRootPool(), poolKey: try key(), sourceSession: "workspace", runtimeID: id + ) + await #expect(throws: Error.self) { try await failureTransport.start() } + #expect(failedAttach.closed) + + let root = FakeRoot(plans: [.finished("tmux 3.2\n"), .finished(""), .open, .finished("wrong\tworkspace\n")]) + let transport = SSHTmuxControlTransport( + connector: FakeConnector(roots: [root]), pool: SSHRootPool(), poolKey: try key(), sourceSession: "workspace", runtimeID: id + ) + try await transport.start() + await transport.close(disposition: .reusable) + #expect(root.commands().count == 4) + #expect(!root.commands().contains { $0.contains("'kill-session'") }) + #expect(root.closed) + } + + @Test("close wins a blocked startup race and releases its child exactly once") + func closeDuringStartup() async throws { + let child = StartupBlockingChild() + let root = StartupRaceRoot(child: child) + let transport = SSHTmuxControlTransport( + connector: StartupRaceConnector(root: root), pool: SSHRootPool(), poolKey: try key(), sourceSession: "workspace" + ) + let start = Task { try await transport.start() } + await child.waitUntilExecuting() + await transport.close(disposition: .reusable) + #expect(await child.closeCount() == 1) + await #expect(throws: SSHTmuxControlTransportError.closed) { try await start.value } + #expect(!(await transport.isActive())) + } + + @Test("a root lease arriving after close returns reusable to the shared pool") + func closeBeforeLeaseArrivalKeepsHealthyRoot() async throws { + let root = FakeRoot(plans: []) + let connector = DelayedRootConnector(root: root) + let transport = SSHTmuxControlTransport( + connector: connector, pool: SSHRootPool(), poolKey: try key(), sourceSession: "workspace" + ) + let start = Task { try await transport.start() } + await connector.waitUntilRequested() + await transport.close(disposition: .reusable) + await connector.resume() + await #expect(throws: SSHTmuxControlTransportError.closed) { try await start.value } + #expect(!root.closed) + } + + @Test("root pool coalesces, bounds shared children, drains invalidation, and idles") + func pool() async throws { + let pool = SSHRootPool(idleTimeout: .milliseconds(20)) + let root = FakeRoot(plans: []) + let connector = FakeConnector(roots: [root, FakeRoot(plans: [])]) + let poolKey = try key() + async let one = pool.lease(for: poolKey, connector: connector) + async let two = pool.lease(for: poolKey, connector: connector) + async let three = pool.lease(for: poolKey, connector: connector) + async let four = pool.lease(for: poolKey, connector: connector) + let leases = try await [one, two, three, four] + #expect(connector.calls == 1) + let fifth = try await pool.lease(for: poolKey, connector: connector) + #expect(connector.calls == 2) + + await leases[0].release(.invalidated) + #expect(!root.closed) + await leases[1].release(.reusable) + await leases[1].release(.reusable) + await leases[2].release(.reusable) + await leases[3].release(.reusable) + #expect(root.closed) + await fifth.release(.reusable) + + let idleRoot = FakeRoot(plans: []) + let idleConnector = FakeConnector(roots: [idleRoot]) + let idleLease = try await pool.lease(for: try key(), connector: idleConnector) + await idleLease.release(.reusable) + try await eventually { idleRoot.closed } + } +} + +private final class TrustSequencingConnector: @unchecked Sendable { + let server: SavedServer + let trust: SSHHostTrustResolver + private(set) var authenticated = false + private(set) var childrenOpened = 0 + + init(server: SavedServer, trust: SSHHostTrustResolver) { + self.server = server + self.trust = trust + } + + func connectSynchronously() throws { + // Models the real adapter's ordering: validation is a prerequisite for + // authentication success and a root is the only object that can open children. + try trust.verify(server: server, algorithm: "ssh-ed25519", fingerprint: "unknown") + authenticated = true + } +} + +private final class MemorySecrets: SecretDataStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: Data] = [:] + func read(service: String, account: String) throws -> Data? { lock.withLock { values["\(service)|\(account)"] } } + func createOrUpdate(_ data: Data, service: String, account: String) throws { lock.withLock { values["\(service)|\(account)"] = data } } + func delete(service: String, account: String) throws { lock.withLock { values["\(service)|\(account)"] = nil } } +} + +private struct Passwords: CredentialReading { + let values: [UUID: String] + init(_ values: [UUID: String]) { self.values = values } + func password(for identityID: UUID) throws -> String? { values[identityID] } +} + +private enum FakePlan { case finished(String), streaming(Data), open, failed } + +private final class FakeConnector: SSHRootConnecting, @unchecked Sendable { + private let lock = NSLock() + private var queuedRoots: [FakeRoot] + private(set) var calls = 0 + init(roots: [FakeRoot]) { queuedRoots = roots } + func connect() async throws -> any SSHRootConnection { + lock.withLock { + calls += 1 + return queuedRoots.removeFirst() + } + } +} + +private final class FakeRoot: SSHRootConnection, @unchecked Sendable { + private let lock = NSLock() + private var plans: [FakePlan] + private var recordedCommands: [String] = [] + private(set) var closed = false + init(plans: [FakePlan]) { self.plans = plans } + func openSessionChannel() async throws -> any SSHChildChannel { + let plan = lock.withLock { plans.removeFirst() } + return FakeChild(plan: plan) { [weak self] command in self?.lock.withLock { self?.recordedCommands.append(command) } } + } + func close() async { lock.withLock { closed = true } } + func commands() -> [String] { lock.withLock { recordedCommands } } +} + +private final class FakeChild: SSHChildChannel, @unchecked Sendable { + nonisolated let receivedBytes: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private let plan: FakePlan + private let record: @Sendable (String) -> Void + private var active = true + init(plan: FakePlan, record: @escaping @Sendable (String) -> Void) { + self.plan = plan + self.record = record + var continuation: AsyncThrowingStream.Continuation! + receivedBytes = AsyncThrowingStream { continuation = $0 } + self.continuation = continuation + } + func execute(_ command: String) async throws { + record(command) + switch plan { + case .finished(let output): continuation.yield(Data(output.utf8)); continuation.finish() + case .streaming(let bytes): continuation.yield(bytes) + case .open: break + case .failed: throw SSHTmuxControlTransportError.closed + } + } + func write(_ data: Data) async throws {} + func isActive() async -> Bool { active } + func close() async throws { active = false; continuation.finish() } +} + +private actor DelayedRootConnector: SSHRootConnecting { + private let root: FakeRoot + private var requested = false + private var requestWaiter: CheckedContinuation? + private var connectionWaiter: CheckedContinuation? + + init(root: FakeRoot) { self.root = root } + + func connect() async throws -> any SSHRootConnection { + requested = true + requestWaiter?.resume() + requestWaiter = nil + return try await withCheckedThrowingContinuation { connectionWaiter = $0 } + } + + func waitUntilRequested() async { + guard !requested else { return } + await withCheckedContinuation { requestWaiter = $0 } + } + + func resume() { connectionWaiter?.resume(returning: root); connectionWaiter = nil } +} + +private actor StartupBlockingChild: SSHChildChannel { + nonisolated let receivedBytes: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private var executionWaiter: CheckedContinuation? + private var startedWaiter: CheckedContinuation? + private var executing = false + private var closes = 0 + + init() { + var continuation: AsyncThrowingStream.Continuation! + receivedBytes = AsyncThrowingStream { continuation = $0 } + self.continuation = continuation + } + + func execute(_ command: String) async throws { + _ = command + await withCheckedContinuation { continuation in + executionWaiter = continuation + executing = true + startedWaiter?.resume() + startedWaiter = nil + } + } + + func waitUntilExecuting() async { + guard !executing else { return } + await withCheckedContinuation { startedWaiter = $0 } + } + + func write(_ data: Data) async throws { _ = data } + func isActive() async -> Bool { closes == 0 } + func close() async throws { + closes += 1 + continuation.finish() + executionWaiter?.resume() + executionWaiter = nil + } + func closeCount() -> Int { closes } +} + +private final class StartupRaceRoot: SSHRootConnection, @unchecked Sendable { + let child: StartupBlockingChild + private let lock = NSLock() + private(set) var closed = false + init(child: StartupBlockingChild) { self.child = child } + func openSessionChannel() async throws -> any SSHChildChannel { child } + func close() async { lock.withLock { closed = true } } +} + +private struct StartupRaceConnector: SSHRootConnecting { + let root: StartupRaceRoot + func connect() async throws -> any SSHRootConnection { root } +} + +private func key() throws -> SSHRootPool.Key { + try .init(serverID: UUID(), endpoint: CanonicalEndpoint(host: "example.test", port: 22), username: "v", authenticationFingerprint: UUID().uuidString) +} + +private final class DataRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [Data] = [] + func append(_ data: Data) { lock.withLock { recorded.append(data) } } + func values() -> [Data] { lock.withLock { recorded } } +} + +private func eventually(_ condition: @escaping @Sendable () -> Bool) async throws { + for _ in 0..<40 { + if condition() { return } + try await Task.sleep(for: .milliseconds(5)) + } + Issue.record("condition did not become true") +} diff --git a/MoriRemote/MoriRemoteTests/Phase3RuntimeTests.swift b/MoriRemote/MoriRemoteTests/Phase3RuntimeTests.swift new file mode 100644 index 00000000..9ec2e354 --- /dev/null +++ b/MoriRemote/MoriRemoteTests/Phase3RuntimeTests.swift @@ -0,0 +1,158 @@ +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 new file mode 100644 index 00000000..74faf2e4 --- /dev/null +++ b/MoriRemote/MoriRemoteTests/Phase4ShellTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Security +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() { + let policy = WorkspaceReconnectPolicy() + #expect(policy.mayReconnect(status: .disconnected("lost"), attempts: 0)) + #expect(!policy.mayReconnect(status: .disconnected("lost"), attempts: 1)) + #expect(!policy.mayReconnect(status: .ready, attempts: 0)) + #expect(!policy.mayReconnect(status: .connecting, attempts: 0)) + } + + @Test("workspace draft owns a distinct record and rejects unsafe sessions") + func workspaceDraftValidation() throws { + let serverID = UUID() + var draft = WorkspaceDraft(serverID: serverID) + draft.name = "Logs" + draft.tmuxSession = "logs" + let workspace = try draft.record() + #expect(workspace.serverID == serverID) + #expect(workspace.id != serverID) + draft.tmuxSession = "bad\nname" + #expect(throws: SavedModelValidationError.invalidTmuxSession) { try draft.record() } + } + + @Test("profile draft keeps server identity and rejects unsafe sessions") + 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.0.identityID == records.2.id) + #expect(records.2.serverID == records.0.id) + draft.tmuxSession = "bad\nname" + #expect(throws: SavedModelValidationError.invalidTmuxSession) { try draft.records() } + } + + @Test("connection attempt admission is synchronous and stale tokens cannot finish") + func connectionAttemptLedger() { + var attempts = WorkspaceConnectionAttemptLedger() + let workspace = UUID() + guard let first = attempts.begin(workspaceID: workspace) else { Issue.record("missing first attempt"); return } + #expect(attempts.begin(workspaceID: workspace) == nil) + #expect(attempts.isCurrent(first, for: workspace)) + attempts.cancel(workspaceID: workspace) + #expect(!attempts.isCurrent(first, for: workspace)) + guard let replacement = attempts.begin(workspaceID: workspace) else { Issue.record("missing replacement attempt"); return } + attempts.end(first, for: workspace) + #expect(attempts.isCurrent(replacement, for: workspace)) + attempts.end(replacement, for: workspace) + #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() + 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 records = try draft.records(existingIdentityID: identityID) + #expect(records.1?.id == workspaceID) + #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) + } + + @Test("profile persistence preserves recency and never inserts a server-edit workspace") + func profilePersistencePreservesRecency() 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(), identityID = UUID() + let date = Date(timeIntervalSince1970: 456) + 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) + 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) + #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) + } + + @Test("stale SSH trust challenges become localized errors rather than disappearing") + func staleTrustPresentation() { + #expect(SSHTrustPresentation.resolve(.staleChallenge) == .error("The SSH host-key confirmation is no longer valid. Try again.")) + } + + @Test("scrollback settings clamp old values to the 2k to 10k contract") + func scrollbackClamp() { + #expect(RemoteSettings(initialScrollbackLines: 500).effectiveInitialScrollbackLines == 2_000) + #expect(RemoteSettings(initialScrollbackLines: 8_000).effectiveInitialScrollbackLines == 8_000) + #expect(RemoteSettings(initialScrollbackLines: 50_000).effectiveInitialScrollbackLines == 10_000) + } + + @Test("memory pressure evicts only dormant workspace runtimes") + func memoryPressurePolicy() { + let active = UUID(), dormantA = UUID(), dormantB = UUID() + let evicted = WorkspaceMemoryPressurePolicy().workspaceIDsToDisconnect(active: active, all: [active, dormantA, dormantB]) + #expect(Set(evicted) == [dormantA, dormantB]) + #expect(WorkspaceMemoryPressurePolicy().workspaceIDsToDisconnect(active: active, all: [active]).isEmpty) + } + + @Test("Keychain writes are device-bound and require an unlocked device") + func keychainProtection() { + let attributes = MoriRemoteKeychainProtection.writeAttributes() + let value = attributes[kSecAttrAccessible as String]! + #expect(CFEqual(value as CFTypeRef, kSecAttrAccessibleWhenUnlockedThisDeviceOnly)) + let item = MoriRemoteKeychainProtection.item(service: "test", account: "account") + #expect(item[kSecAttrService as String] as? String == "test") + #expect(item[kSecAttrAccount as String] as? String == "account") + #expect(KeychainCredentialStore().upgradesProtectionOnRead) + let legacy = KeychainCredentialStore.legacyReader() + #expect(legacy.service == KeychainCredentialStore.legacyService) + #expect(!legacy.upgradesProtectionOnRead) + #expect(!KeychainCredentialStore(service: KeychainCredentialStore.legacyService).upgradesProtectionOnRead) + } + + @Test("profile JSON never contains password, private key, or passphrase") + func profileJSONExcludesSecrets() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let storage = MoriRemoteStorage(root: root) + let passwords = MemoryProfilePasswords() + let secrets = MemoryProfileSecrets() + let library = RemoteLibrary( + storage: storage, + migrator: LegacyServerMigrator(storage: storage, legacyServersURL: root.appendingPathComponent("legacy.json")), + passwords: passwords, + secretData: secrets + ) + let serverID = UUID() + let identity = SSHIdentity(id: serverID, serverID: serverID, kind: .privateKey) + let server = SavedServer(id: serverID, name: "Build", host: "build.example", username: "mori", identityID: serverID) + 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))) + let persisted = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + .reduce(into: "") { $0 += (try? String(contentsOf: $1, encoding: .utf8)) ?? "" } + #expect(!persisted.contains("BEGIN OPENSSH PRIVATE KEY")) + #expect(!persisted.contains(passphrase)) + _ = 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 { + private var values: [UUID: String] = [:] + func password(for identityID: UUID) throws -> String? { values[identityID] } + func createPasswordIfAbsent(_ password: String, for identityID: UUID) throws -> Bool { + guard values[identityID] == nil else { return false } + values[identityID] = password + return true + } + func setPassword(_ password: String, for identityID: UUID) throws { values[identityID] = password } + func deletePassword(for identityID: UUID) throws { values.removeValue(forKey: identityID) } +} + +private final class MemoryProfileSecrets: SecretDataStore, @unchecked Sendable { + private var values: [String: Data] = [:] + private func key(service: String, account: String) -> String { service + "\u{0}" + account } + func read(service: String, account: String) throws -> Data? { values[key(service: service, account: account)] } + func createOrUpdate(_ data: Data, service: String, account: String) throws { values[key(service: service, account: account)] = data } + func delete(service: String, account: String) throws { values.removeValue(forKey: key(service: service, account: account)) } +} diff --git a/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift new file mode 100644 index 00000000..44eae517 --- /dev/null +++ b/MoriRemote/MoriRemoteTests/Phase5AgentMetadataTests.swift @@ -0,0 +1,169 @@ +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") + 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(parser.parse(String(repeating: "x", count: AgentMetadataResponseParser.maximumResponseBytes + 1)).isEmpty) + } + + @Test("injected valid pane rows and over-limit responses fail closed") + func parserRejectsInjectionAndRecordOverflow() { + let parser = AgentMetadataResponseParser() + let injectedName = "claude\n%2\twaiting\tclaude" + let response = "%1\tworking\t\(injectedName)\n%2\tdone\tpi\n" + #expect(parser.parse(response).isEmpty) + + let overLimit = (0...AgentMetadataResponseParser.maximumRecords) + .map { "%\($0)\tworking\tclaude\n" } + .joined() + #expect(parser.parse(overLimit).isEmpty) + } + + @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 merged = AgentMetadataProjection.merge(records, into: topology) + #expect(merged == [ + .init(1): .init(state: .working, name: "claude"), + .init(2): .unknown + ]) + } + + @Test("duplicate 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]) + } + + @Test("visible projector observes option changes without topology or terminal interruption") @MainActor + func projectorRefreshesOptions() async { + let relay = QueryRelay() + let projector = AgentMetadataProjector(instanceID: UUID()) { relay.set($0) } + projector.topologyDidChange(makeTopology(paneIDs: [.init(1)])) + projector.setVisible(true) + + relay.complete(.success, "%1\tworking\tclaude\n") + await Task.yield() + #expect(projector.metadata[.init(1)] == .init(state: .working, name: "claude")) + + projector.foregrounded() + relay.complete(.success, "%1\twaiting\tclaude\n") + await Task.yield() + #expect(projector.metadata[.init(1)] == .init(state: .waiting, name: "claude")) + + projector.foregrounded() + relay.complete(.success, "%1\tdone\tclaude\n") + await Task.yield() + #expect(projector.metadata[.init(1)] == .init(state: .done, 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)])) + projector.setVisible(true) + relay.complete(.error, "transport closed") + await Task.yield() + #expect(projector.metadata[.init(1)] == .unknown) + #expect(projector.lastFailure == "transport closed") + + projector.foregrounded() + projector.stop() + relay.complete(.success, "%1\tworking\tlate\n") + await Task.yield() + #expect(projector.metadata.isEmpty) + } + + @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)])) + projector.setVisible(true) + relay.complete(.success, "%1\tworking\tclaude\n") + await Task.yield() + #expect(projector.metadata[.init(1)]?.state == .working) + + projector.foregrounded() // leave this generation in flight + projector.setVisible(false) + #expect(projector.metadata.isEmpty) + #expect(changes >= 3) + projector.setVisible(true) + relay.complete(.success, "%1\tdone\tlate\n") + await Task.yield() + #expect(projector.metadata.isEmpty) + relay.complete(.success, "%1\twaiting\tclaude\n") + await Task.yield() + #expect(projector.metadata[.init(1)] == .init(state: .waiting, name: "claude")) + projector.stop() + } + + @Test("replaced runtime projector cannot publish an old response") @MainActor + func runtimeReplacementFence() async { + let oldRelay = QueryRelay() + let old = AgentMetadataProjector(instanceID: UUID()) { oldRelay.set($0) } + old.topologyDidChange(makeTopology(paneIDs: [.init(1)])) + old.setVisible(true) + old.stop() + + let newRelay = QueryRelay() + let replacement = AgentMetadataProjector(instanceID: UUID()) { newRelay.set($0) } + replacement.topologyDidChange(makeTopology(paneIDs: [.init(1)])) + replacement.setVisible(true) + oldRelay.complete(.success, "%1\tdone\told\n") + newRelay.complete(.success, "%1\tworking\tnew\n") + await Task.yield() + #expect(old.metadata.isEmpty) + #expect(replacement.metadata[.init(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")) + } + + @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) + } + + 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) + } +} + +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)) + } +} diff --git a/MoriRemote/MoriRemoteTests/SSHTransportTests.swift b/MoriRemote/MoriRemoteTests/SSHTransportTests.swift new file mode 100644 index 00000000..4e2e2fde --- /dev/null +++ b/MoriRemote/MoriRemoteTests/SSHTransportTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import MoriRemote + +@Suite("SSH and tmux transport") struct SSHTransportTests { + @Test("private key inspector accepts generated Ed25519 key") func privateKey() throws { + let key = SSHPrivateKeyInspector.generateEd25519(comment: "test") + let inspection = try SSHPrivateKeyInspector.inspect(key.privateKeyPEM) + #expect(inspection.keyType == .ed25519) + #expect(inspection.publicFingerprint == key.publicFingerprint) + } + @Test("auth resolver supports password and imported private key") func auth() throws { + let id = UUID(), server = SavedServer(id: UUID(), name: "s", host: "host", username: "u", identityID: id) + let password = SSHIdentity(id: id, serverID: server.id, kind: .password) + #expect(try SSHAuthResolver(credentials: Credentials([id: .password("pw")])).resolve(server: server, identity: password) == .password(username: "u", password: "pw", identityID: id, label: "")) + let keyID = UUID(), keyIdentity = SSHIdentity(id: keyID, serverID: server.id, kind: .privateKey) + let key = SSHPrivateKeyInspector.generateEd25519(comment: "test").privateKeyPEM + guard case .privateKey = try SSHAuthResolver(credentials: Credentials([keyID: .privateKey(.init(privateKeyPEM: key, passphrase: nil))])).resolve(server: SavedServer(id: server.id, name: "s", host: "host", username: "u", identityID: keyID), identity: keyIdentity) else { Issue.record("key was not resolved"); return } + } + @Test("root-pool fingerprint partitions changed secrets without exposing them") + func rootPoolFingerprint() { + let id = UUID() + let first = ResolvedSSHAuth.password(username: "v", password: "first-secret", identityID: id, label: "") + let same = ResolvedSSHAuth.password(username: "v", password: "first-secret", identityID: id, label: "") + let changed = ResolvedSSHAuth.password(username: "v", password: "second-secret", identityID: id, label: "") + #expect(first.rootPoolFingerprint == same.rootPoolFingerprint) + #expect(first.rootPoolFingerprint != changed.rootPoolFingerprint) + #expect(first.rootPoolFingerprint.count == 64) + #expect(!first.rootPoolFingerprint.contains("first-secret")) + } + @Test("unknown and changed host keys fail closed") func trust() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString); defer { try? FileManager.default.removeItem(at: root) } + let store = TrustedHostStore(url: root), server = SavedServer(name: "s", host: "Host", username: "u"), resolver = SSHHostTrustResolver(store: store) + #expect(throws: SSHHostTrustError.self) { try resolver.verify(server: server, algorithm: "ssh-ed25519", fingerprint: "new") } + let endpoint = try #require(server.endpoint); let challenge = SSHHostTrustChallenge(kind: .unknown, serverID: server.id, endpoint: endpoint, algorithm: "ssh-ed25519", receivedFingerprint: "old", trustedFingerprint: nil) + try resolver.explicitlyTrust(challenge); try resolver.verify(server: server, algorithm: "ssh-ed25519", fingerprint: "old") + #expect(throws: SSHHostTrustError.self) { try resolver.verify(server: server, algorithm: "ssh-ed25519", fingerprint: "new") } + } + @Test("tmux version accepts real suffixes and rejects old or malformed output") func version() { + #expect(TmuxVersion.parse("tmux 3.1")! < TmuxVersion(major: 3, minor: 2)) + #expect(TmuxVersion.parse("tmux 3.2a") == TmuxVersion(major: 3, minor: 2)) + #expect(TmuxVersion.parse("tmux 3.6a") == TmuxVersion(major: 3, minor: 6)) + #expect(TmuxVersion.parse("tmux 3.7b") == TmuxVersion(major: 3, minor: 7)) + #expect(TmuxVersion.parse("tmux nope") == nil) + } + @Test("command quoting rejects injection and cleanup owns exact shadow") func commands() throws { + let id = UUID(), shadow = try TmuxCommandBuilder.shadowName(source: "project/main", runtimeID: id) + 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 new file mode 100644 index 00000000..0a51a49c --- /dev/null +++ b/MoriRemote/UPSTREAM.md @@ -0,0 +1,59 @@ +# 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 | +| --- | --- | --- | --- | +| `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. diff --git a/MoriRemote/project.yml b/MoriRemote/project.yml index ec21a316..7ff2d251 100644 --- a/MoriRemote/project.yml +++ b/MoriRemote/project.yml @@ -4,17 +4,15 @@ options: iOS: "17.0" groupSortPosition: top packages: - MoriCore: - path: ../Packages/MoriCore - MoriTmux: - path: ../Packages/MoriTmux - MoriSSH: - path: ../Packages/MoriSSH - MoriTerminal: - path: ../Packages/MoriTerminal - SwiftTerm: - url: https://github.com/migueldeicaza/SwiftTerm.git - from: "1.13.0" + Citadel: + url: https://github.com/h3nock/Citadel.git + revision: 1d0eadd81d0a521b00ede6663c8b3301f5fc252e + NIO: + url: https://github.com/apple/swift-nio.git + exactVersion: 2.97.1 + NIOSSH: + url: https://github.com/h3nock/swift-nio-ssh.git + revision: 7588777b8f6439efa1a33117f86cb2729abd864c targets: MoriRemote: type: application @@ -23,20 +21,26 @@ targets: sources: - path: MoriRemote dependencies: - - package: MoriCore - product: MoriCore - - package: MoriTmux - product: MoriTmux - - package: MoriSSH - product: MoriSSH - - package: MoriTerminal - product: MoriTerminal - - package: SwiftTerm - product: SwiftTerm + - package: Citadel + product: Citadel + - package: NIO + product: NIO + - package: NIO + 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 @@ -49,8 +53,10 @@ targets: GENERATE_INFOPLIST_FILE: NO EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 DEVELOPMENT_TEAM: ${DEVELOPMENT_TEAM} - MARKETING_VERSION: ${MARKETING_VERSION:0.1.0} - CURRENT_PROJECT_VERSION: ${CURRENT_PROJECT_VERSION:1} + # TestFlight overrides only the build number; the marketing version is + # deliberately pinned to 0.3.5 unless a product decision changes it. + MARKETING_VERSION: "0.3.5" + CURRENT_PROJECT_VERSION: "1" configs: Debug: CODE_SIGN_IDENTITY: "Apple Development" @@ -59,6 +65,28 @@ targets: CODE_SIGN_IDENTITY: "Apple Distribution" CODE_SIGN_STYLE: Manual PROVISIONING_PROFILE_SPECIFIER: "MoriRemote App Store" + postBuildScripts: + - name: Embed MoriRemote third-party notices + basedOnDependencyAnalysis: false + script: | + set -euo pipefail + destination="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" + 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" + MoriRemoteTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + sources: + - path: MoriRemoteTests + dependencies: + - target: MoriRemote + settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + GENERATE_INFOPLIST_FILE: YES schemes: MoriRemote: build: @@ -66,3 +94,6 @@ schemes: MoriRemote: all archive: config: Release + test: + targets: + - MoriRemoteTests diff --git a/Packages/MoriSSH/Package.resolved b/Packages/MoriSSH/Package.resolved index 256d48bc..1c92c25c 100644 --- a/Packages/MoriSSH/Package.resolved +++ b/Packages/MoriSSH/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "80899a16d7182ed59a05e1591a5ab4f18505ce2a90cfea6ecf44b21f04344bd8", + "originHash" : "3b37fda54d2aa50b2135e954025f9e77a17bbafba2831bb85290ccbbe7dbd09d", "pins" : [ { "identity" : "swift-asn1", @@ -49,10 +49,9 @@ { "identity" : "swift-nio-ssh", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssh.git", + "location" : "https://github.com/h3nock/swift-nio-ssh.git", "state" : { - "revision" : "8f33cac67309a13aecc0a4d95044543549b20ffb", - "version" : "0.12.0" + "revision" : "7588777b8f6439efa1a33117f86cb2729abd864c" } }, { diff --git a/Packages/MoriSSH/Package.swift b/Packages/MoriSSH/Package.swift index 78aae93f..88e1c209 100644 --- a/Packages/MoriSSH/Package.swift +++ b/Packages/MoriSSH/Package.swift @@ -13,7 +13,7 @@ let package = Package( .library(name: "MoriSSH", targets: ["MoriSSH"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio-ssh.git", from: "0.8.0"), + .package(url: "https://github.com/h3nock/swift-nio-ssh.git", revision: "7588777b8f6439efa1a33117f86cb2729abd864c"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), ], targets: [ diff --git a/Packages/MoriSSH/Sources/MoriSSH/SSHConnectionManager.swift b/Packages/MoriSSH/Sources/MoriSSH/SSHConnectionManager.swift index 807ea84d..70a920eb 100644 --- a/Packages/MoriSSH/Sources/MoriSSH/SSHConnectionManager.swift +++ b/Packages/MoriSSH/Sources/MoriSSH/SSHConnectionManager.swift @@ -1,7 +1,7 @@ import Foundation import NIOCore import NIOPosix -import NIOSSH +@preconcurrency import NIOSSH import os.log private let sshLog = Logger(subsystem: "com.vaayne.mori", category: "SSH") diff --git a/README.md b/README.md index 26918a78..cefd8f6f 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,36 @@ brew install --cask mori Or download from [GitHub Releases](https://github.com/vaayne/mori/releases). MoriRemote for iOS is on [TestFlight](https://testflight.apple.com/join/k2GFJPC2). +### MoriRemote + +MoriRemote is an iPhone/iPad SSH and tmux companion, not a remote desktop. It +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. + +Building MoriRemote from source requires Xcode, XcodeGen, and the pinned remux +Ghostty source. The local task builds one universal macOS + iOS XCFramework; +CI builds the same source once and shares that artifact with macOS and iOS jobs. + +```bash +mise run ios:test +mise run ios:run # verifies launch, liveness, crash logs, screenshots +``` + +The framework is built from `h3nock/remux-ghostty` at the pinned source commit, +not downloaded from a third-party prebuilt artifact. See +[`MoriRemote/UPSTREAM.md`](MoriRemote/UPSTREAM.md). +
Build from source Requires macOS 14+, tmux, [mise](https://mise.jdx.dev/), Zig 0.15.2, and Xcode. ```bash -mise run build # Debug build (bootstraps libghostty automatically) +mise run build # Debug build (bootstraps macOS libghostty automatically) +# `mise run ios:*` bootstraps the universal macOS + iOS GhosttyKit artifact mise run dev # Build + run mise run test # Run all tests ``` diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 10e66e18..09de8d80 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -55,13 +55,28 @@ brew install --cask mori 也可以从 [GitHub Releases](https://github.com/vaayne/mori/releases) 下载。MoriRemote iOS 版在 [TestFlight](https://testflight.apple.com/join/k2GFJPC2)。 +### MoriRemote + +MoriRemote 是 iPhone/iPad 上的 SSH 与 tmux 伴侣,不是远程桌面。它要求显式确认主机密钥,支持密码和导入的 OpenSSH 私钥,并保证移动端导航不干扰其他 tmux 客户端。服务器需要 tmux 3.2 或更高版本。本地终端历史和选择只保留在设备上;分屏、关闭与新建窗口属于共享工作区操作。 + +从源码构建 MoriRemote 需要 Xcode、XcodeGen 和固定的 remux Ghostty 源码。本地任务会构建一份同时支持 macOS 与 iOS 的通用 XCFramework;CI 也只构建一次同一源码制品,并由 macOS 和 iOS job 共享。 + +```bash +mise run ios:test +mise run ios:run # 验证启动、存活、崩溃日志和截图 +``` + +该框架从固定 commit 的 `h3nock/remux-ghostty` 源码构建,而非下载第三方预构建制品。详见 +[`MoriRemote/UPSTREAM.md`](MoriRemote/UPSTREAM.md)。 +
从源码编译 需要 macOS 14+、tmux、[mise](https://mise.jdx.dev/)、Zig 0.15.2 和 Xcode。 ```bash -mise run build # Debug 构建(自动拉取 libghostty) +mise run build # Debug 构建(自动引导 macOS libghostty) +# `mise run ios:*` 会自动引导通用 macOS + iOS GhosttyKit 制品 mise run dev # 构建并运行 mise run test # 跑所有测试 ``` diff --git a/THIRD_PARTY_LICENSES/Apache-2.0.txt b/THIRD_PARTY_LICENSES/Apache-2.0.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/THIRD_PARTY_LICENSES/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/THIRD_PARTY_LICENSES/BigInt-MIT.txt b/THIRD_PARTY_LICENSES/BigInt-MIT.txt new file mode 100644 index 00000000..18cefd11 --- /dev/null +++ b/THIRD_PARTY_LICENSES/BigInt-MIT.txt @@ -0,0 +1,20 @@ + +Copyright (c) 2016-2017 Károly Lőrentey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/THIRD_PARTY_LICENSES/swift-asn1-NOTICE.txt b/THIRD_PARTY_LICENSES/swift-asn1-NOTICE.txt new file mode 100644 index 00000000..b78ebbdd --- /dev/null +++ b/THIRD_PARTY_LICENSES/swift-asn1-NOTICE.txt @@ -0,0 +1,43 @@ + + The SwiftASN1 Project + ===================== + +Please visit the SwiftASN1 web site for more information: + + * https://github.com/apple/swift-asn1 + +Copyright 2022 The SwiftASN1 Project + +The SwiftASN1 Project licenses this file to you under the Apache License, +version 2.0 (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at: + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. + +Also, please refer to each LICENSE.txt file, which is located in +the 'license' directory of the distribution file, for the license terms of the +components that this product depends on. + +--- + +This product contains derivations of various scripts from SwiftNIO. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-nio + +--- + +This product contains derivations of various scripts from Swift OpenAPI Generator. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-openapi-generator diff --git a/THIRD_PARTY_LICENSES/swift-crypto-NOTICE.txt b/THIRD_PARTY_LICENSES/swift-crypto-NOTICE.txt new file mode 100644 index 00000000..a7756f29 --- /dev/null +++ b/THIRD_PARTY_LICENSES/swift-crypto-NOTICE.txt @@ -0,0 +1,42 @@ + The SwiftCrypto Project + ======================= + +Please visit the SwiftCrypto web site for more information: + + * https://github.com/apple/swift-crypto + +Copyright 2019 The SwiftCrypto Project + +The SwiftCrypto Project licenses this file to you under the Apache License, +version 2.0 (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at: + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. + +Also, please refer to each LICENSE..txt file, which is located in +the 'license' directory of the distribution file, for the license terms of the +components that this product depends on. + +------------------------------------------------------------------------------- + +This product contains test vectors from Google's wycheproof project. + + * LICENSE (Apache License 2.0): + * https://github.com/C2SP/wycheproof/blob/31387e2cd596587c859c611027b6a44d2e2b65ff/LICENSE + * HOMEPAGE: + * https://github.com/google/wycheproof + +--- + +This product contains a derivation of various files from SwiftNIO. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-nio diff --git a/THIRD_PARTY_LICENSES/swift-log-NOTICE.txt b/THIRD_PARTY_LICENSES/swift-log-NOTICE.txt new file mode 100644 index 00000000..ff1b6caf --- /dev/null +++ b/THIRD_PARTY_LICENSES/swift-log-NOTICE.txt @@ -0,0 +1,35 @@ + + The SwiftLog Project + ======================== + +Please visit the SwiftLog web site for more information: + + * https://github.com/apple/swift-log + +Copyright 2018, 2019 The SwiftLog Project + +The SwiftLog Project licenses this file to you under the Apache License, +version 2.0 (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at: + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. + +Also, please refer to each LICENSE..txt file, which is located in +the 'license' directory of the distribution file, for the license terms of the +components that this product depends on. + +------------------------------------------------------------------------------- + +This product contains a derivation of the lock implementation and various +scripts from SwiftNIO. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-nio diff --git a/THIRD_PARTY_LICENSES/swift-nio-NOTICE.txt b/THIRD_PARTY_LICENSES/swift-nio-NOTICE.txt new file mode 100644 index 00000000..f4389cb2 --- /dev/null +++ b/THIRD_PARTY_LICENSES/swift-nio-NOTICE.txt @@ -0,0 +1,106 @@ + + The SwiftNIO Project + ==================== + +Please visit the SwiftNIO web site for more information: + + * https://github.com/apple/swift-nio + +Copyright 2017, 2018 The SwiftNIO Project + +The SwiftNIO Project licenses this file to you under the Apache License, +version 2.0 (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at: + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. + +Also, please refer to each LICENSE..txt file, which is located in +the 'license' directory of the distribution file, for the license terms of the +components that this product depends on. + +------------------------------------------------------------------------------- + +This product is heavily influenced by Netty. + + * LICENSE (Apache License 2.0): + * https://github.com/netty/netty/blob/4.1/LICENSE.txt + * HOMEPAGE: + * https://netty.io + +--- + +This product contains NodeJS's llhttp. + + * LICENSE (MIT): + * https://github.com/nodejs/llhttp/blob/1e1c5b43326494e97cf8244ff57475eb72a1b62c/LICENSE-MIT + * HOMEPAGE: + * https://github.com/nodejs/llhttp + +--- + +This product contains "cpp_magic.h" from Thomas Nixon & Jonathan Heathcote's uSHET + + * LICENSE (MIT): + * https://github.com/18sg/uSHET/blob/c09e0acafd86720efe42dc15c63e0cc228244c32/lib/cpp_magic.h + * HOMEPAGE: + * https://github.com/18sg/uSHET + +--- + +This product contains "sha1.c" and "sha1.h" from FreeBSD (Copyright (C) 1995, 1996, 1997, and 1998 WIDE Project) + + * LICENSE (BSD-3): + * https://opensource.org/licenses/BSD-3-Clause + * HOMEPAGE: + * https://github.com/freebsd/freebsd-src + +--- + +This product contains a derivation of Fabian Fett's 'Base64.swift'. + + * LICENSE (Apache License 2.0): + * https://github.com/swift-extras/swift-extras-base64/blob/b8af49699d59ad065b801715a5009619100245ca/LICENSE + * HOMEPAGE: + * https://github.com/fabianfett/swift-base64-kit + +--- + +This product contains a derivation of "XCTest+AsyncAwait.swift" & "StructuredConcurrencyHelpers" from AsyncHTTPClient. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/swift-server/async-http-client + +--- + +This product contains a derivation of "_TinyArray.swift" from SwiftCertificates. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-certificates + +--- + +This product contains a derivation of the mocking infrastructure from Swift System. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-system + +--- + +This product contains a derivation of "TokenBucket.swift" from Swift Package Manager. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/swiftlang/swift-package-manager diff --git a/THIRD_PARTY_LICENSES/swift-nio-ssh-LICENSE.txt b/THIRD_PARTY_LICENSES/swift-nio-ssh-LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/THIRD_PARTY_LICENSES/swift-nio-ssh-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..8201470e --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,113 @@ +# Third-party notices + +MoriRemote bundles this document and `THIRD_PARTY_LICENSES/` in every app +archive. The versions below are the resolved versions in +`MoriRemote/MoriRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`. + +## Modified Ghostty / remux-ghostty + +Mori and MoriRemote statically link the Mori-built universal +`GhosttyKit.xcframework`, compiled from +[remux-ghostty](https://github.com/h3nock/remux-ghostty) source commit +`aeb8f73790946d9c9ad175b3dafaec9911ef36bb` (211 commits atop Ghostty +`b213a72c03b427607b43c89ff4223a7baa079fe8`). The source adds the +`ghostty_tmux_client_*` ABI required by MoriRemote. CI verifies the source +provenance, macOS/iOS slices, iOS 17 compatibility, and ABI before either app +consumes its same-workflow artifact. + +Ghostty and the modified distribution are MIT licensed: + +> MIT License +> +> Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## remux reference application + +MoriRemote adapts selected control, lifecycle, and terminal semantics from +[remux](https://github.com/h3nock/remux) commit +`b3a3e5f5dfa4759ab189e203b9a03749e821540c`. The adapted areas and test +provenance are recorded in `MoriRemote/UPSTREAM.md`. + +> MIT License +> +> Copyright (c) 2026 h3nock +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## Citadel + +MoriRemote uses [Citadel](https://github.com/h3nock/Citadel) commit +`1d0eadd81d0a521b00ede6663c8b3301f5fc252e`. + +> MIT License +> +> Copyright (c) 2022 Orlandos +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## Swift packages linked by MoriRemote + +| Package | Resolved revision/version | License / distributed notice | +| --- | --- | --- | +| [BigInt](https://github.com/attaswift/BigInt) | `e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe` / 5.7.0 | MIT: `THIRD_PARTY_LICENSES/BigInt-MIT.txt` | +| [swift-nio-ssh](https://github.com/h3nock/swift-nio-ssh) | `7588777b8f6439efa1a33117f86cb2729abd864c` | Apache-2.0: `Apache-2.0.txt`; GitHub's recursive tree API for this exact commit contains `LICENSE.txt` and no `NOTICE` file, so no invented package notice is bundled. | +| [swift-asn1](https://github.com/apple/swift-asn1) | `9f542610331815e29cc3821d3b6f488db8715517` / 1.6.0 | Apache-2.0: `Apache-2.0.txt`, `swift-asn1-NOTICE.txt` | +| [swift-atomics](https://github.com/apple/swift-atomics) | `b601256eab081c0f92f059e12818ac1d4f178ff7` / 1.3.0 | Apache-2.0: `Apache-2.0.txt` | +| [swift-collections](https://github.com/apple/swift-collections) | `6675bc0ff86e61436e615df6fc5174e043e57924` / 1.4.1 | Apache-2.0: `Apache-2.0.txt` | +| [swift-crypto](https://github.com/apple/swift-crypto) | `95ba0316a9b733e92bb6b071255ff46263bbe7dc` / 3.15.1 | Apache-2.0: `Apache-2.0.txt`, `swift-crypto-NOTICE.txt` | +| [swift-log](https://github.com/apple/swift-log) | `a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a` / 1.14.0 | Apache-2.0: `Apache-2.0.txt`, `swift-log-NOTICE.txt` | +| [swift-nio](https://github.com/apple/swift-nio) | `558f24a4647193b5a0e2104031b71c55d31ff83a` / 2.97.1 | Apache-2.0: `Apache-2.0.txt`, `swift-nio-NOTICE.txt` | +| [swift-system](https://github.com/apple/swift-system) | `7c6ad0fc39d0763e0b699210e4124afd5041c5df` / 1.6.4 | Apache-2.0: `Apache-2.0.txt` | + +`THIRD_PARTY_LICENSES/Apache-2.0.txt` contains the complete Apache License +2.0 text. Every listed notice file comes from the resolved source; trailing whitespace is normalized for distribution. diff --git a/docs/architecture.md b/docs/architecture.md index 3a93fbe7..ed4cc4d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Pane pane tmux pane ID, e.g. %3 ## Terminal Rendering -`TerminalHost` protocol abstracts terminal backends. Primary implementation is `GhosttyAdapter` (libghostty — GPU-accelerated Metal rendering, native mouse/scroll/paste/IME). `NativeTerminalAdapter` (PTY via `forkpty()`) is kept as an emergency fallback. GhosttyKit is built from the pinned remux Ghostty source: `mise run build:ghostty` builds the native macOS slice, while `mise run build:ghostty-universal` builds the shared macOS + iOS artifact (requires Zig 0.15.2 + Xcode). +`TerminalHost` protocol abstracts terminal backends. Primary implementation is `GhosttyAdapter` (libghostty — GPU-accelerated Metal rendering, native mouse/scroll/paste/IME). `NativeTerminalAdapter` (PTY via `forkpty()`) is kept as an emergency fallback. GhosttyKit is built from the pinned remux Ghostty source (including MoriRemote's tmux ABI): `mise run build:ghostty` makes the macOS slice, while `mise run build:ghostty-universal` makes the shared macOS + iOS artifact (requires Zig 0.15.2 + Xcode). ## Persistence diff --git a/mise.toml b/mise.toml index 953dd1b8..ad438a27 100644 --- a/mise.toml +++ b/mise.toml @@ -73,18 +73,6 @@ run = "bash scripts/build-ghostty.sh" description = "Build the universal macOS + iOS GhosttyKit XCFramework from the pinned remux source" run = "bash scripts/build-ghostty.sh --universal" -[tasks."test:ghosttykit-contract"] -description = "Run offline adversarial GhosttyKit lock and source-contract fixtures" -run = "bash scripts/tests/test-ghosttykit-contract.sh" - -[tasks."test:release-workflows"] -description = "Verify immutable-safe GitHub release draft flows offline" -run = "bash scripts/tests/test-release-workflows.sh" - -[tasks."test:ghosttykit-verify"] -description = "Prove the universal GhosttyKit verifier rejects framework tampering" -run = "bash scripts/tests/test-ghosttykit-verify.sh" - [tasks.bundle] description = "Build release and create Mori.app bundle" run = "bash scripts/bundle.sh" @@ -124,13 +112,26 @@ run = "swift package generate-xcodeproj" # ─── iOS (MoriRemote) ─────────────────────────────────────────── +[tasks."test:tmux-command-builder"] +description = "Run the macOS POSIX argv contract for MoriRemote tmux commands" +run = "bash scripts/tests/test-tmux-command-builder.sh" + +[tasks."test:tmux-shadow-group"] +description = "Verify local tmux grouped-shadow cleanup semantics" +run = "bash scripts/tests/test-tmux-shadow-group.sh" + [tasks."ios:generate"] description = "Generate MoriRemote.xcodeproj from project.yml" run = "cd MoriRemote && xcodegen generate" +[tasks."ios:verify-framework"] +description = "Verify the Mori-built universal GhosttyKit framework" +depends = ["build:ghostty-universal"] +run = "bash scripts/verify-ghosttykit.sh" + [tasks."ios:build"] description = "Build MoriRemote for iOS Simulator" -depends = ["ios:generate"] +depends = ["ios:generate", "ios:verify-framework"] run = ''' set -euo pipefail xcodebuild \ @@ -143,7 +144,7 @@ xcodebuild \ [tasks."ios:test"] description = "Build and run MoriRemote tests on iOS Simulator" -depends = ["ios:generate"] +depends = ["ios:generate", "ios:verify-framework"] run = ''' set -euo pipefail SIMULATOR=${MORI_IOS_SIMULATOR:-$(xcrun simctl list devices available | grep -m1 'iPhone' | sed 's/^ *//' | sed 's/ (.*//')} @@ -156,30 +157,39 @@ xcodebuild \ ''' [tasks."ios:run"] -description = "Build and launch MoriRemote on iOS Simulator" +description = "Build and prove MoriRemote library + Ghostty simulator paths stay alive" depends = ["ios:build"] -run = ''' -set -euo pipefail -SIMULATOR=${MORI_IOS_SIMULATOR:-$(xcrun simctl list devices available | grep -m1 'iPhone' | sed 's/^ *//' | sed 's/ (.*//')} -APP_PATH=$(find "$DERIVED_DATA" -name "MoriRemote.app" -path "*/Debug-iphonesimulator/*" | head -1) -if [ -z "$APP_PATH" ]; then - echo "❌ MoriRemote.app not found in derived data" - exit 1 -fi -xcrun simctl boot "$SIMULATOR" 2>/dev/null || true -xcrun simctl install "$SIMULATOR" "$APP_PATH" -xcrun simctl launch "$SIMULATOR" "$(plutil -extract CFBundleIdentifier raw "$APP_PATH/Info.plist")" -echo "✅ MoriRemote launched on $SIMULATOR simulator" -''' +run = "bash scripts/smoke-moriremote-simulator.sh" + +[tasks."ios:smoke"] +description = "Install MoriRemote and verify library/Ghostty launch, liveness, crash logs, and screenshots" +depends = ["ios:build"] +run = "bash scripts/smoke-moriremote-simulator.sh" + +[tasks."test:ghosttykit-contract"] +description = "Run offline adversarial GhosttyKit lock and source-contract fixtures" +run = "bash scripts/tests/test-ghosttykit-contract.sh" + +[tasks."test:ghosttykit-verify"] +description = "Prove the universal GhosttyKit verifier rejects framework tampering" +run = "bash scripts/tests/test-ghosttykit-verify.sh" + +[tasks."test:release-workflows"] +description = "Verify immutable-safe GitHub release draft flows offline" +run = "bash scripts/tests/test-release-workflows.sh" + +[tasks."test:moriremote-archive-verify"] +description = "Prove the MoriRemote archive verifier rejects wrong build numbers and legacy payloads" +run = "bash scripts/tests/test-verify-moriremote-archive.sh" [tasks."ios:archive"] description = "Archive MoriRemote for distribution" -depends = ["ios:generate"] +depends = ["ios:generate", "ios:verify-framework"] run = ''' set -euo pipefail DERIVED_IOS="${DERIVED_DATA}-ios" ARCHIVE_PATH="${DERIVED_IOS}/MoriRemote.xcarchive" -MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}" +MARKETING_VERSION="${MARKETING_VERSION:-0.3.5}" CURRENT_PROJECT_VERSION="${CURRENT_PROJECT_VERSION:-1}" xcodebuild archive \ -project MoriRemote/MoriRemote.xcodeproj \ diff --git a/scripts/smoke-moriremote-simulator.sh b/scripts/smoke-moriremote-simulator.sh new file mode 100755 index 00000000..167a0d67 --- /dev/null +++ b/scripts/smoke-moriremote-simulator.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Install and prove both the library and deterministic Ghostty paths stay alive. +# simctl launch returning a PID is intentionally not accepted as success. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" +configuration="${MORI_IOS_CONFIGURATION:-Debug}" +derived_data="${DERIVED_DATA:-$repo_root/.derived-data}" +bundle_id="com.vaayne.mori-remote" +output_dir="${MORI_IOS_SMOKE_OUTPUT:-$derived_data/moriremote-smoke}" +device="${MORI_IOS_SIMULATOR:-}" + +if [[ -z "$device" ]]; then + device="$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ { print $2; exit }')" +fi +[[ -n "$device" ]] || { echo "No available iPhone simulator." >&2; exit 1; } + +app_path="$(find "$derived_data" -type d -path "*/${configuration}-iphonesimulator/MoriRemote.app" -print -quit)" +[[ -n "$app_path" ]] || { echo "MoriRemote.app (${configuration}) not found below $derived_data. Build it first." >&2; exit 1; } + +mkdir -p "$output_dir" +xcrun simctl boot "$device" 2>/dev/null || true +xcrun simctl bootstatus "$device" -b +xcrun simctl install "$device" "$app_path" + +assert_alive() { + local pid="$1" label="$2" + # Let a launch-time abort win before testing the process table. + sleep 3 + local processes + processes="$(xcrun simctl spawn "$device" /bin/ps -axo pid=,comm=)" + if ! awk -v pid="$pid" '$1 == pid && $0 ~ /MoriRemote\.app\/MoriRemote$/ { found = 1 } END { exit !found }' <<<"$processes"; then + echo "MoriRemote $label launch PID $pid is not alive." >&2 + printf '%s\n' "$processes" >&2 + return 1 + fi + # A live PID can still be an app headed for a fatal abort. Fail on the + # simulator's process-attributed crash/termination diagnostics too. + local logs log_error + log_error="$(mktemp "${TMPDIR:-/tmp}/mori-simctl-log.XXXXXX")" + if ! logs="$(xcrun simctl spawn "$device" log show --style compact --last 20s --predicate 'process == "MoriRemote" AND (eventMessage CONTAINS[c] "Terminating app" OR eventMessage CONTAINS[c] "fatal error" OR eventMessage CONTAINS[c] "uncaught exception")' 2>"$log_error" | awk 'NR > 1')"; then + echo "Unable to inspect MoriRemote simulator crash diagnostics:" >&2 + cat "$log_error" >&2 + rm -f "$log_error" + return 1 + fi + rm -f "$log_error" + if [[ -n "$logs" ]]; then + echo "MoriRemote $label emitted fatal simulator diagnostics:" >&2 + printf '%s\n' "$logs" >&2 + return 1 + fi +} + +probe_logs() { + local pid="$1" + xcrun simctl spawn "$device" log show --style compact --last 1m \ + --predicate "process == \"MoriRemote\" AND processID == $pid AND eventMessage CONTAINS \"MORI_GHOSTTY_PROBE_RESULT\"" \ + 2>/dev/null | awk 'NR > 1' +} + +wait_for_probe_result() { + local pid="$1" + local deadline=$((SECONDS + 30)) logs + 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 + printf '%s\n' "$logs" >&2 + return 1 + fi + if grep -Fq 'MORI_GHOSTTY_PROBE_RESULT success=true' <<<"$logs"; then + return 0 + fi + sleep 1 + done + echo "Ghostty probe did not report successful native rendering within 30 seconds:" >&2 + probe_logs "$pid" >&2 || true + return 1 +} + +launch_and_capture() { + local label="$1" + shift + local launch_result pid + launch_result="$(xcrun simctl launch --terminate-running-process "$device" "$bundle_id" "$@")" + # Current simctl prints either a PID or "bundle.identifier: PID". + pid="${launch_result##*: }" + [[ "$pid" =~ ^[0-9]+$ ]] || { echo "Unexpected simctl launch result: $launch_result" >&2; return 1; } + assert_alive "$pid" "$label" + if [[ "$label" == "ghostty-terminal" ]]; then + wait_for_probe_result "$pid" + fi + xcrun simctl io "$device" screenshot "$output_dir/${label}.png" + [[ -s "$output_dir/${label}.png" ]] || { echo "Missing $label screenshot." >&2; return 1; } +} + +launch_and_capture library +launch_and_capture ghostty-terminal --ghostty-terminal-probe + +echo "✅ MoriRemote simulator smoke passed on $device" +echo " Screenshots: $output_dir/library.png, $output_dir/ghostty-terminal.png" diff --git a/scripts/tests/test-tmux-command-builder.sh b/scripts/tests/test-tmux-command-builder.sh new file mode 100755 index 00000000..43243634 --- /dev/null +++ b/scripts/tests/test-tmux-command-builder.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Host-only contract test. Compile the exact production Foundation-only command +# assembler, then execute its generated shell against a fake absolute executable. +set -euo pipefail +repo_root=$(cd "$(dirname "$0")/../.." && pwd) +root=$(mktemp -d) +trap 'rm -rf "$root"' EXIT +fake="$root/fake tmux" +output="$root/argv" +fixture="$root/main.swift" +runner="$root/tmux-command-fixture" + +cat >"$fake" <<'EOF' +#!/usr/bin/env bash +: "${ARGV_OUTPUT:?}" +printf '%s\0' "$@" > "$ARGV_OUTPUT" +EOF +chmod +x "$fake" + +cat >"$fixture" <<'EOF' +import Foundation +let arguments = ["two words", "", "it's quoted; touch should-not-run", "-leading-dash"] +print(TmuxShellCommand.command(executable: CommandLine.arguments[1], arguments: arguments)) +EOF +xcrun swiftc "$repo_root/MoriRemote/MoriRemote/Tmux/TmuxShellCommand.swift" "$fixture" -o "$runner" +generated=$("$runner" "$fake") +ARGV_OUTPUT="$output" /bin/sh -c "$generated" +python3 - "$output" <<'PY' +import pathlib, sys +actual = pathlib.Path(sys.argv[1]).read_bytes().split(b'\0')[:-1] +expected = [b'two words', b'', b"it's quoted; touch should-not-run", b'-leading-dash'] +if actual != expected: + raise SystemExit(f'argv mismatch: {actual!r} != {expected!r}') +PY +if [[ -e "$root/should-not-run" ]]; then + echo 'quoted semicolon executed unexpectedly' >&2 + exit 1 +fi +printf 'tmux command builder argv contract passed\n' diff --git a/scripts/tests/test-tmux-shadow-group.sh b/scripts/tests/test-tmux-shadow-group.sh new file mode 100755 index 00000000..a2838295 --- /dev/null +++ b/scripts/tests/test-tmux-shadow-group.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Local tmux semantics only; it never contacts an SSH host or user workspace. +set -euo pipefail +socket="mori-remote-phase2-${RANDOM}-${RANDOM}" +source="mori-remote-source-${RANDOM}" +shadow="${source}--mori-remote-shadow" +cleanup() { + tmux -L "$socket" kill-server 2>/dev/null || true +} +trap cleanup EXIT + +tmux -L "$socket" new-session -d -s "$source" +tmux -L "$socket" new-session -d -t "$source" -s "$shadow" +format=$'#{session_name}\t#{session_group}' +actual=$(tmux -L "$socket" display-message -p -t "$shadow" "$format") +expected="$shadow"$'\t'"$source" +if [[ "$actual" != "$expected" ]]; then + printf 'unexpected grouped session metadata: %q (expected %q)\n' "$actual" "$expected" >&2 + exit 1 +fi +tmux -L "$socket" kill-session -t "$shadow" +tmux -L "$socket" has-session -t "$source" +if tmux -L "$socket" has-session -t "$shadow" 2>/dev/null; then + echo 'shadow still exists after exact cleanup' >&2 + exit 1 +fi +printf 'tmux grouped shadow cleanup contract passed\n' diff --git a/scripts/tests/test-verify-moriremote-archive.sh b/scripts/tests/test-verify-moriremote-archive.sh new file mode 100755 index 00000000..e9fea7f7 --- /dev/null +++ b/scripts/tests/test-verify-moriremote-archive.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Keep archive contract checks independent from signing or App Store upload. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +verifier="$repo_root/scripts/verify-moriremote-archive.sh" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mori-archive-verifier.XXXXXX")" +trap 'rm -rf "$work_dir"' EXIT +app="$work_dir/MoriRemote.xcarchive/Products/Applications/MoriRemote.app" +mkdir -p "$app/en.lproj" "$app/zh-Hans.lproj" "$app/THIRD_PARTY_LICENSES" +touch "$app/en.lproj/Localizable.strings" "$app/zh-Hans.lproj/Localizable.strings" +touch "$app/PrivacyInfo.xcprivacy" "$app/THIRD_PARTY_NOTICES.md" "$app/THIRD_PARTY_LICENSES/Apache-2.0.txt" "$app/MoriRemote" +cat >"$app/Info.plist" <<'PLIST' + + + + CFBundleIdentifiercom.vaayne.mori-remote + CFBundleShortVersionString0.3.5 + CFBundleVersion42 + +PLIST + +"$verifier" "$work_dir/MoriRemote.xcarchive" 0.3.5 42 +if "$verifier" "$work_dir/MoriRemote.xcarchive" 0.3.5 43; then + echo "Archive verifier accepted a mismatched build number." >&2 + exit 1 +fi + +for legacy_name in SwiftTerm MoriTmux MoriTerminal MoriCore MoriSSH; do + touch "$app/$legacy_name" + if "$verifier" "$work_dir/MoriRemote.xcarchive" 0.3.5 42; then + echo "Archive verifier accepted legacy payload $legacy_name." >&2 + exit 1 + fi + rm "$app/$legacy_name" +done + +echo "✅ MoriRemote archive verifier enforces build number and legacy payload contract" diff --git a/scripts/verify-moriremote-archive.sh b/scripts/verify-moriremote-archive.sh new file mode 100755 index 00000000..9325626a --- /dev/null +++ b/scripts/verify-moriremote-archive.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Inspect an unsigned or signed MoriRemote archive without exporting/uploading it. +set -euo pipefail + +archive="${1:?Usage: $0 path/to/MoriRemote.xcarchive [marketing-version] [build-number]}" +expected_version="${2:-0.3.5}" +expected_build_number="${3:-}" +app="$archive/Products/Applications/MoriRemote.app" +plist="$app/Info.plist" + +fail() { echo "MoriRemote archive verification failed: $*" >&2; exit 1; } +[[ -d "$app" ]] || fail "missing application bundle" +[[ -f "$plist" ]] || fail "missing Info.plist" +[[ "$(plutil -extract CFBundleIdentifier raw "$plist")" == "com.vaayne.mori-remote" ]] || fail "unexpected bundle identifier" +[[ "$(plutil -extract CFBundleShortVersionString raw "$plist")" == "$expected_version" ]] || fail "unexpected marketing version" +if [[ -n "$expected_build_number" ]]; then + [[ "$(plutil -extract CFBundleVersion raw "$plist")" == "$expected_build_number" ]] || fail "unexpected build number" +fi +[[ -f "$app/en.lproj/Localizable.strings" ]] || fail "missing English localization" +[[ -f "$app/zh-Hans.lproj/Localizable.strings" ]] || fail "missing Simplified Chinese localization" +[[ -f "$app/PrivacyInfo.xcprivacy" ]] || fail "missing privacy manifest" +[[ -f "$app/THIRD_PARTY_NOTICES.md" ]] || fail "missing bundled third-party notices" +[[ -f "$app/THIRD_PARTY_LICENSES/Apache-2.0.txt" ]] || fail "missing bundled Apache notice" + +legacy_payload="$(find "$app" \( -iname '*swiftterm*' -o -iname '*moritmux*' -o -iname '*moriterminal*' -o -iname '*moricore*' -o -iname '*morissh*' \) -print -quit)" +[[ -z "$legacy_payload" ]] || fail "legacy terminal payload found: $legacy_payload" +if otool -L "$app/MoriRemote" 2>/dev/null | grep -qi 'swiftterm'; then + fail "MoriRemote links SwiftTerm" +fi + +printf 'Verified MoriRemote archive: bundle ID, version %s%s, localizations, privacy manifest, notices, and no legacy terminal payload.\n' "$expected_version" "${expected_build_number:+ build $expected_build_number}"