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 fromavailableSessions: 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.