Architecture

The attach/detach Model: PTY Keeps Running While You're Away

TermOnMac separates the lifetime of a PTY session from its attachment to an iOS view. A session can exist on the Mac and continue running even when no iOS device is displaying it.

Two Session Lists

SessionStore maintains two distinct lists:

// ios_remote_dev_ios_app/RemoteDevApp/Models/SessionStore.swift
@MainActor
final class SessionStore: ObservableObject {
    @Published var sessions: [TerminalSession] = []           // attached: visible in UI
    @Published var availableSessions: [TerminalSession] = []  // exist on Mac, not displayed
    @Published var activeSessionId: String?
  • sessions: sessions the iPhone is actively receiving output from
  • availableSessions: sessions that exist on the Mac but aren’t being displayed

Attaching a Session

Moving a session from available to attached:

/// Move a session from `availableSessions` to `sessions` (attach it).
@discardableResult
func attachSession(id: String) -> TerminalSession? {
    guard canAddSession,
          let idx = availableSessions.firstIndex(where: { $0.id == id }) else { return nil }
    let session = availableSessions.remove(at: idx)
    sessions.append(session)
    activeSessionId = session.id
    return session
}

canAddSession checks the max sessions limit (maxSessions < 0 || sessions.count < maxSessions). Unlimited sessions are represented as -1.

Detaching a Session

Moving a session from attached to available (PTY continues running on Mac):

/// Move a session from `sessions` to `availableSessions` (detach without destroying PTY).
func detachSession(id: String) {
    guard sessions.count > 1,
          let idx = sessions.firstIndex(where: { $0.id == id }) else { return }
    var session = sessions.remove(at: idx)
    session.isTakenOver = false
    draftInputTexts.removeValue(forKey: id)
    availableSessions.append(session)
    // activeSessionId shifts to another session
}

The PTY process on the Mac is unaffected by detach. PTYManager.switchToBufferOnly() ensures the ring buffer continues accumulating output even without an attached viewer.

Mac-Side Buffer-Only Mode

When the iOS disconnects (not just detaches a session), PTYManager switches all sessions to buffer-only mode:

// mac_agent/Sources/MacAgentLib/PTYManager.swift
public func switchToBufferOnly() {
    for (_, managed) in allSessions {
        managed.pty.setOnOutput { data in
            managed.replayBuffer.append(data)
            // no live delivery
        }
    }
}

public func switchToLive() {
    for (sid, managed) in allSessions {
        managed.pty.setOnOutput { [weak self] data in
            managed.replayBuffer.append(data)
            self?.onOutput?(sid, data)  // resume live delivery
        }
    }
}

Draft Input Preservation

Each session stores its unsent input text:

@Published var draftInputTexts: [String: String] = [:]

func draftTextBinding(for sessionId: String) -> Binding<String> {
    Binding(
        get: { self.draftInputTexts[sessionId, default: ""] },
        set: { self.draftInputTexts[sessionId] = $0 }
    )
}

When you switch away from a session mid-command, the text you’ve typed is preserved. When you switch back, the draft is still there.

On detachSession, draft text is cleared:

draftInputTexts.removeValue(forKey: id)

When a session is detached and reattached later, it starts with an empty draft — the assumption is that detached sessions are “away” long enough that old drafts are no longer relevant.

Removing a Session

Full removal destroys the PTY on the Mac and removes from both lists:

func removeSession(id: String) {
    sessions.removeAll { $0.id == id }
    availableSessions.removeAll { $0.id == id }
    draftInputTexts.removeValue(forKey: id)
    if activeSessionId == id {
        activeSessionId = sessions.first?.id
    }
}

This sends a ptyDestroy message to the Mac, which terminates the shell process.