Skip to content

feat: add MoriRemote iOS app with SSH terminal#30

Merged
vaayne merged 92 commits intomainfrom
feat/mori-remote
Mar 31, 2026
Merged

feat: add MoriRemote iOS app with SSH terminal#30
vaayne merged 92 commits intomainfrom
feat/mori-remote

Conversation

@vaayne
Copy link
Copy Markdown
Owner

@vaayne vaayne commented Mar 30, 2026

Summary

  • Add MoriRemote iOS app target — a companion to Mori that connects to remote servers via SSH and provides a terminal interface using SwiftTerm
  • Add MoriSSH package — SSH transport layer built on SwiftNIO SSH with connection management, exec channels, and async streams
  • Add tmux control-mode support in MoriTmux — TmuxControlParser, TmuxControlClient, and TmuxTransport protocol for managing remote tmux sessions over SSH
  • Add SwiftTermRenderer in MoriTerminal — iOS-compatible terminal rendering backend replacing GhosttyKit on mobile
  • Platform-gate all macOS-only terminal sources with #if os(macOS)

MoriRemote iOS App

  • Server list with add/edit/delete, persisted via ServerStore (JSON in app documents)
  • SSH connection via ShellCoordinator (direct shell) and SpikeCoordinator (tmux control-mode)
  • Terminal UI with SwiftTerm, toolbar with detach/disconnect, proper keyboard handling
  • Localized (en + zh-Hans)

Infrastructure

  • MoriSSH: SSHConnectionManager actor, SSHChannel with async inbound stream, key-based + password auth
  • Tmux control-mode: stateless parser with octal unescape, line-buffered client with command correlation
  • 42 new MoriSSH test assertions, 537-line MoriTmux test expansion
  • mise tasks for iOS build/test/run

Test plan

  • mise run test — all existing tests pass
  • mise run test:tmux — new control-mode parser and client tests pass
  • Build MoriRemote in Xcode for iOS simulator
  • Connect to a test SSH server and verify terminal interaction
  • Verify macOS Mori app builds without regression (mise run build:release)

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9381274e7c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

do {
// PTY is allocated by the SSH channel, providing a proper terminal.
// Use full path to avoid PATH issues in exec channels.
let command = "/opt/homebrew/bin/tmux -C new-session -A -s \(Self.shellQuote(trimmedName))"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a portable tmux command path for remote attach

Constructing the control-mode command with a hardcoded /opt/homebrew/bin/tmux path makes session attach fail on most non-Homebrew hosts (for example Linux servers where tmux is usually /usr/bin/tmux or only on PATH). In those environments openExecChannel succeeds but tmux immediately exits with command-not-found, so users cannot attach at all; this should invoke tmux via PATH or probe multiple locations before launching.

Useful? React with 👍 / 👎.

// Write the command — the actor suspends here, allowing the read
// task to process incoming data and deliver the response.
let data = Data((command + "\n").utf8)
try? await transport.write(data)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate write failures when sending tmux commands

The command write error is being swallowed with try?, so sendCommand continues waiting on the response stream even when the outbound write failed. If the transport remains open but drops the write, callers can block until a later disconnect instead of receiving an immediate failure, which breaks command/error semantics and makes reconnect logic unreliable. This write should throw and clear pending response state immediately.

Useful? React with 👍 / 👎.

Comment on lines +99 to +100
try? await Task.sleep(for: .milliseconds(500))
markReady()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate readiness on handshake completion, not a fixed delay

waitForReady() marks the client ready after a fixed 500ms sleep instead of observing actual tmux handshake completion. On slower or high-latency connections, startup control lines can arrive after this timeout and then be mis-correlated with the first user command once awaitingResponse is true, causing wrong command output or spurious failures. Readiness should be driven by protocol events rather than elapsed time.

Useful? React with 👍 / 👎.

@vaayne vaayne force-pushed the feat/mori-remote branch from 2390208 to 06e30f9 Compare March 30, 2026 16:06
vaayne added 28 commits March 31, 2026 11:34
Keep TmuxSSHConfig and TmuxError cross-platform (used by TmuxControlling).
Gate SendableResumeGuard and TmuxCommandRunner actor which use Foundation.Process.
TmuxBackend depends on TmuxCommandRunner (macOS-only Process-based runner).
TCP connect via ClientBootstrap, SSH handshake, password auth,
exec channel creation. Accept-all host keys (spike only).
Uses MultiThreadedEventLoopGroup for both macOS and iOS.
Tests cover SSHAuthMethod construction, SSHError descriptions,
SSHConnectionManager initial state and disconnected exec behavior.
Uses RunLoop-based async test runner for Swift 6 compatibility.
- Use concrete PasswordAuthDelegate type to avoid protocol existential
  Sendable warning in channelInitializer closure capture
- PasswordAuthDelegate now offers credentials once (matches SimplePasswordDelegate pattern)
- Remove unused SSHChannelDataHandler (ExecHandler handles data in SSHConnectionManager)
…rage

- connect() now waits for UserAuthSuccessEvent before returning;
  auth failures surface as SSHError.authenticationFailed at connect time
- openExecChannel() now waits for ChannelSuccessEvent (exec accepted)
  before returning SSHChannel; ChannelFailureEvent rejects cleanly
- Added AuthCompletionHandler for parent-channel auth event tracking
- ExecHandler now handles ChannelSuccessEvent/ChannelFailureEvent
- SSHChannel.init made public for testability
- Tests expanded: 18 → 32 assertions covering inbound stream (single/multi
  chunk, error propagation), write to inactive channel, close idempotency,
  publicKey auth rejection, unreachable host connection failure
Parser tests: %output with octal escapes (\012, \134), high-bit bytes,
%begin/%end/%error blocks, notifications, plainLine, unknown, malformed.

Client tests: command success/error, line split across chunks,
multi-line chunk, EOF cancellation, paneOutput routing, notification
routing, command newline termination.
- Register pendingContinuation BEFORE writing to transport so fast
  %begin/%end responses are correctly correlated
- Write to transport via fire-and-forget Task; write failures handled
  by transport EOF → failPending() path
- Added FastResponseMockTransport + testClientFastResponseRace regression
  test that feeds %begin/%end immediately during write (no delay)
- MoriTmux: 242 → 243 assertions
vaayne added 17 commits March 31, 2026 11:34
- Send LANG=en_US.UTF-8 via EnvironmentRequest before shell request so
  tmux and other tools produce valid UTF-8 escape sequences
- Set explicit nativeBackgroundColor (.black) and nativeForegroundColor
  on SwiftTerm's TerminalView — the default .clear background caused
  old content to show through on redraws (clear, scrollback, tmux)
Replace SwiftTerm's default input accessory with a two-row custom bar:

- Customizable key bar: esc, ctrl, tab, symbols (~|/-), arrows
- Tmux popup menu: tap "tmux" button for New Tab, Split, Pane nav,
  Zoom, Close, Detach — uses real tmux CLI commands via SSH exec
  channel, works with any prefix configuration
- Gear button opens customize sheet to toggle keys on/off
- Key layout persisted in UserDefaults with version migration
- Scroll fade gradient hints at off-screen content
- Expose SwiftTermRenderer.swiftTermView for accessory wiring
- Add NoPTYExecHandler to MoriSSH for lightweight exec without PTY allocation
- Add runCommandNoPTY/openExecChannelNoPTY public API for background queries
- Re-enable tmux state polling (5s interval) using NoPTY exec channels
- Send tmux action commands through shell channel (not exec) for correct
  client/pane targeting — works with custom keymaps and any tmux config
- Add TmuxSidebarView: expandable session groups with window rows,
  swipe-to-close, new window/session, switch session, kill session
- Add SidebarContainer: slide-from-left overlay with edge swipe gesture
- Add observable tmux state on ShellCoordinator for reactive UI
- Add sidebar button (top-left) visible when tmux is active
- Add Identifiable conformance and sessionName to TmuxSession/TmuxWindow
- Add server card with name, status, Switch Host / Disconnect buttons
- Flat uppercase session labels (always expanded) with attached badge
- Window rows with 3px teal left accent bar for active window
- Long-press context menus on sessions (Switch/Rename/Kill) and
  windows (Switch/New After/Close)
- Footer bar with + Window / + Session buttons
- Empty state with + New Session action button
- Rename session support via alert dialog
- Simplify TmuxBarView to single status pill (session › window)
- Tapping tmux pill opens sidebar
- Fix: only highlight active window in attached session
- Fix: use switch-client for cross-session window selection
- Extract into smaller components: ServerCardView, TmuxSessionHeader,
  TmuxWindowRow, TmuxSidebarFooter
- Make SidebarContainer generic over sidebar content
- Remove TmuxBarView row from accessory bar (single key bar only)
- Remove ellipsis menu button and toolbar overlay
- Disconnect and server info now accessible via sidebar
- Cleaner terminal screen with only sidebar button overlay
- Auto-attach: switch-client when inside tmux, attach-session via
  shell when not attached (enables sidebar use without being in tmux)
- Show pane_current_path below each window name in sidebar
- TmuxWindow.shortPath shows last 2 path components with …/ prefix
- Fix sidebar scroll: ignore mostly-vertical drags in SidebarContainer
  so ScrollView receives them instead of the close gesture
Split single DragGesture on ZStack into two separate gestures:
- Edge-open gesture on content area only
- Close gesture on dimming overlay only
- Sidebar panel has no gesture, so ScrollView works freely
- Add 1024x1024 app icon to iOS asset catalog (was empty)
- Add UIRequiredDeviceCapabilities, UISupportedInterfaceOrientations,
  ITSAppUsesNonExemptEncryption to Info.plist
- Use env vars for DEVELOPMENT_TEAM/MARKETING_VERSION/BUILD_NUMBER
  in project.yml with fallback defaults
- Add archive scheme config to project.yml
- Add mise tasks: ios:archive, ios:export, ios:upload
- Add ExportOptions.plist for App Store Connect distribution
- Add ios-build job to ci.yml (simulator build)
- Add release-ios.yml workflow (archive, export, TestFlight upload)
  triggered by ios-v* tags or manual dispatch
- replace hardcoded team and version values with build-setting defaults
- route sidebar host switching through the existing disconnect handler
When tmux (or any app using alternate screen + mouse mode) is attached,
UIScrollView has no scrollback to scroll. This adds a UIPanGestureRecognizer
that translates vertical swipes into mouse wheel events (button 4/5),
enabling proper scroll behavior in tmux with `set -g mouse on`.

Three-part approach:
- syncScrollEnabled(): disables UIScrollView bounce in alternate screen
- alternateScrollGesture: sends mouse wheel events per cell-height of drag
- shouldBeRequiredToFailBy: prioritizes scroll over click-drag gestures
- macOS: dev.mori.app → com.vaayne.mori
- macOS shared suite: dev.mori.shared → com.vaayne.mori.shared
- macOS SSH keychain: dev.mori.app.ssh → com.vaayne.mori.ssh
- iOS: com.vaayne.mori → com.vaayne.mori.remote
- iOS keychain: com.vaayne.mori.servers → com.vaayne.mori.remote.servers
- Homebrew zap: keep old IDs for cleanup, add new ones
- project.yml: DEVELOPMENT_TEAM from env var (no fallback)
- release-ios.yml: pass APPLE_TEAM_ID secret as DEVELOPMENT_TEAM
- Local builds use Xcode automatic signing settings
Replace App Store Connect API key auth with Apple ID + app-specific
password (APPLE_ID / APPLE_APP_PASSWORD secrets already exist).
No new secrets needed for iOS release pipeline.
com.vaayne.mori.remote → com.vaayne.mori-remote (hyphen, not dot)
to match the registered App Store Connect bundle ID.
@vaayne vaayne force-pushed the feat/mori-remote branch from 763157e to 58ea585 Compare March 31, 2026 03:34
vaayne added 10 commits March 31, 2026 11:40
- Drop custom iOS embedding commit (using SwiftTerm now)
- Pin to upstream release tag instead of floating commit
- Add GhosttyKit stub in ios-build CI for SPM resolution
- Split Debug (automatic) / Release (manual) signing in project.yml
- Install provisioning profile by UUID on CI runner
- Remove alpha channel from iOS app icon (App Store requirement)
- Update ExportOptions.plist with manual signing + profile mapping
- Tested locally: archive + export both succeed
@vaayne vaayne merged commit 679e0bb into main Mar 31, 2026
6 checks passed
@vaayne vaayne deleted the feat/mori-remote branch March 31, 2026 11:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant