Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 294 additions & 5 deletions cmux-tui/Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions cmux-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ members = [
"crates/cmux-pty",
"crates/cmux-remote-protocol",
"crates/cmux-remote",
"crates/cmux-remote-mobile",
"crates/cmux-terminal-client",
"crates/cmux-relay",
"crates/cmux-tui-machine-agent-protocol",
Expand Down
2 changes: 2 additions & 0 deletions cmux-tui/apps/ios/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Generated by `xcodegen generate` from project.yml.
CmuxRemote.xcodeproj/
38 changes: 38 additions & 0 deletions cmux-tui/apps/ios/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>cmux remote</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSBonjourServices</key>
<array>
<string>_cmux._udp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>cmux connects directly to your computer on this network instead of relaying through the internet.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
64 changes: 64 additions & 0 deletions cmux-tui/apps/ios/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# cmux remote for iOS

A phone client for the cmux remote daemon, over Iroh.

It exists to exercise the transport on the network a phone actually has: NAT'd,
changing between cellular and Wi-Fi, and often unable to reach the daemon
directly at all. Those are the conditions the relay path and session resume are
for, and they do not occur on a loopback test.

## What it does

Paste a `cmux://enroll/...` invitation, connect, and get a shell on the machine
that issued it. The status bar shows whether Iroh settled on a direct or relayed
path, and counts session resumes, so a walk out of Wi-Fi range is visible rather
than inferred.

The path picker forces direct-only or relay-only. Automatic is what a user would
run; the constrained modes are there so a failure can be attributed to one path
instead of "the network".

## Architecture

The app implements none of the daemon protocol. Enrollment is a
PSK-authenticated Noise handshake, sessions are mutually authenticated and
resumable, frames carry per-lane sequence numbers with bounded replay, and Iroh
adds path selection and relay fallback underneath. A Swift reimplementation
would be a second set of bugs in exactly the parts that are hardest to test, so
the app links `cmux-remote-mobile`, a C ABI over the same Rust client the TUI
uses.

It also carries no VT parser. The daemon keeps the terminal model and answers
`SnapshotProcessTerminal` with styled runs, so `TerminalScreen` only lays out a
monospaced grid. Wrapping, scroll regions, and character sets stay on the side
that already got them right.

## Build

```bash
brew install xcodegen
cd cmux-tui/apps/ios
xcodegen generate
open CmuxRemote.xcodeproj
```

The Rust archive builds from a pre-build script, so a plain Xcode build is
enough. No Zig toolchain is involved: `cmux-remote-mobile` depends on
`cmux-remote` with `default-features = false`, which leaves out the daemon's
workspace service and with it libghostty-vt.

`CmuxRemote.xcodeproj` is generated and not committed.

## Getting an invitation

On the machine you want to reach:

```bash
cmux-tui daemon invite
```

It prints a `cmux://enroll/...` URI that expires in five minutes and carries the
daemon's public key plus route hints. The key is what the phone pins during the
handshake; the hints only say where to try, so a wrong or stale hint cannot
redirect the session to another host. The daemon asks the machine's owner to
approve the device before the first session completes.
1 change: 1 addition & 0 deletions cmux-tui/apps/ios/Sources/Bridging.h
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#import "cmux_remote_mobile.h"
98 changes: 98 additions & 0 deletions cmux-tui/apps/ios/Sources/CmuxRemoteApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import SwiftUI

@main
struct CmuxRemoteApp: App {
var body: some Scene {
WindowGroup {
SessionView()
}
}
}

@MainActor
@Observable
final class SessionModel {
enum Phase: Equatable {
case disconnected
case connecting
case connected
case failed(String)
}

var invitation = ""
var pathMode = RemoteClient.PathMode.auto
var root = "~"
var phase = Phase.disconnected
var terminal: TerminalSnapshot?
var connection: ConnectionSnapshot?

private let client = RemoteClient()
private var pump: Task<Void, Never>?
private var grid: (cols: UInt16, rows: UInt16) = (80, 24)

func connect() {
guard phase != .connecting else { return }
phase = .connecting
let invitation = invitation.trimmingCharacters(in: .whitespacesAndNewlines)
let pathMode = pathMode
let root = root
let grid = grid
let device = UIDevice.current.name

Task {
do {
try await client.connect(
invitation: invitation, deviceName: device, pathMode: pathMode)
try await client.openTerminal(root: root, cols: grid.cols, rows: grid.rows)
phase = .connected
startPump()
} catch {
phase = .failed(error.localizedDescription)
}
}
}

func disconnect() {
pump?.cancel()
pump = nil
terminal = nil
connection = nil
phase = .disconnected
Task { await client.disconnect() }
}

func send(_ text: String) {
Task { try? await client.write(text) }
}

/// Match the remote PTY to what the phone can actually show. Called on
/// rotation and whenever the keyboard changes the visible area.
func resize(to grid: (cols: UInt16, rows: UInt16)) {
guard grid != self.grid else { return }
self.grid = grid
guard phase == .connected else { return }
Task {
try? await client.resize(cols: grid.cols, rows: grid.rows)
terminal = await client.terminal()
}
}

/// Wait on output, then re-read the model. Blocking on the read rather than
/// polling on a timer means an idle shell costs nothing and a busy one
/// refreshes as fast as bytes arrive.
private func startPump() {
pump?.cancel()
pump = Task { [client] in
var lastSequence: UInt64 = .max
while !Task.isCancelled {
let produced = await client.readOutput() != nil
if let snapshot = await client.terminal(),
produced || snapshot.throughSequence != lastSequence {
lastSequence = snapshot.throughSequence
terminal = snapshot
}
connection = await client.connection()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Idle pump still polls RPCs

Medium Severity

The pump comment claims an idle shell costs nothing, but after every readOutput timeout it still awaits terminal() and connection(), each of which crosses into Rust and hits the daemon. On a phone that becomes a SnapshotProcessTerminal plus diagnostics RPC about every 250ms while the shell is idle.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 41058ee. Configure here.

}
}
Loading
Loading