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}"