Integrating SwiftTerm into a SwiftUI App
TermOnMac’s terminal display is built on SwiftTerm, an open-source terminal emulator library for Swift. The integration wraps CustomTerminalView (a SwiftTerm class) in a UIViewRepresentable for use in SwiftUI.
The UIViewRepresentable Wrapper
// ios_remote_dev_ios_app/RemoteDevApp/Views/TerminalView.swift
struct SwiftTermWrapper: UIViewRepresentable {
let connection: iOSRelayConnection
@ObservedObject var shortcutStore: ShortcutStore
var clipboardGuardActive: Bool = false
var onGearTapped: () -> Void
func makeUIView(context: Context) -> CustomTerminalView {
let tv = CustomTerminalView(frame: .zero)
tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
tv.nativeBackgroundColor = .black
tv.nativeForegroundColor = .white
tv.getTerminal().silentLog = true
tv.getTerminal().changeHistorySize(1500)
tv.terminalDelegate = context.coordinator
// ...
return tv
}
}
Configuration at creation time:
- Font: System monospaced, 16pt, regular weight
- Colors: Black background, white foreground
- History: 1,500 lines of scrollback
- silentLog: Suppresses SwiftTerm’s internal logging
Feeding PTY Data
The connection’s onPtyData callback feeds bytes into the terminal:
connection.onPtyData = { [weak tv] sessionId, data in
DispatchQueue.main.async {
guard let tv = tv else { return }
let slice = ArraySlice([UInt8](data))
tv.feedData(slice)
}
}
feedData accepts an ArraySlice<UInt8>. The data is first converted to [UInt8] (from Data), then sliced. The call is dispatched to the main thread — SwiftTerm’s view updates must happen on the main thread.
Terminal Reset on Reconnect
When the iPhone reconnects after a network drop, the terminal is reset before replay:
connection.onTerminalReset = { [weak tv] sessionId in
DispatchQueue.main.async {
guard let tv else { return }
if tv.userIsScrolledUp {
tv.prepareForReplayWhileScrolledUp()
}
tv.clearPendingData()
tv.getTerminal().resetToInitialState()
tv.userIsScrolledUp = false
tv.forceContentSizeRefresh()
}
}
The sequence:
- If the user was scrolled up, save state for position restoration after replay
clearPendingData()— discard any data queued but not yet renderedresetToInitialState()— clear the terminal buffer, reset cursor and attributesforceContentSizeRefresh()— update the scroll view’s content size
After this, the replay data from PTYManager.replayIncremental is fed into the terminal, restoring the session state.
Buffer Switch Callback
SwiftTerm calls onBufferSwitch when the terminal switches between normal and alternate screen buffer (e.g. when entering/exiting vim):
tv.onBufferSwitch = { [weak tv, weak connection] in
guard let tv, let connection else { return }
let cols = tv.getTerminal().cols
let rows = tv.getTerminal().rows
guard cols >= 20, rows >= 5 else { return }
connection.sendResize(cols: cols, rows: rows)
}
On a buffer switch, the terminal dimensions are sent to the Mac as a resize event. This ensures the PTY is sized correctly for the new screen mode.
Reinit Callback
onNeedsReinit fires when SwiftTerm detects that a terminal reinitalization is needed (for example, after certain view layout changes):
tv.onNeedsReinit = { [weak tv, weak connection] in
guard let tv, let connection else { return }
let cols = tv.getTerminal().cols
let rows = tv.getTerminal().rows
connection.sendResize(cols: cols, rows: rows)
// Ctrl-L tells the shell to clear and redraw the prompt.
// DECAWM has already been re-enabled by scheduleReinit's feed.
connection.sendInput(Data([0x0c]))
}
After a resize, 0x0c (Ctrl-L) is sent to the Mac, triggering a shell prompt redraw. The comment explains that scheduleReinit in SwiftTerm has already re-enabled DECAWM (auto-wrap mode) before this callback fires.
Clipboard Guard
A 10-second clipboard guard prevents accidental pastes immediately after copying text from the terminal:
struct TerminalView: View {
@State private var clipboardCooldownActive = false
var body: some View {
SwiftTermWrapper(
clipboardGuardActive: showShortcutManager || clipboardCooldownActive,
// ...
)
.sheet(isPresented: $showShortcutManager) {
ShortcutManagerView(store: shortcutStore, auth: authService, onCopy: {
clipboardCooldownActive = true
Task {
try? await Task.sleep(for: .seconds(10))
clipboardCooldownActive = false
}
})
}
}
}
When clipboardGuardActive is true, paste operations in the terminal are blocked. The guard activates when the shortcut manager is visible or for 10 seconds after copying text.
Custom Context Menu
A “Copy Flat” item is added to the context menu via UIMenuController:
UIMenuController.shared.menuItems = [
UIMenuItem(title: "Copy Flat", action: #selector(CustomTerminalView.copyNoNewlines(_:)))
]
“Copy Flat” copies selected text with newlines removed — useful when copying multi-line command output that should be pasted as a single line.