feat(ios): Fix iOS build and integrate SwiftTerm for terminal emulation

Major improvements to the iOS VibeTunnel app:

SwiftTerm Integration:
- Properly integrated SwiftTerm 1.2.5 for terminal emulation
- Fixed naming conflicts and API compatibility issues
- Added proper actor isolation with @preconcurrency
- Terminal data now feeds correctly via SSE streams

Session Management:
- Fixed session model to match Go server's JSON response format
- Added CodingKeys mapping for field name differences (cmdline, cwd, etc.)
- Support for "starting" session status
- Enhanced date parsing for both ISO8601 and RFC3339 formats

Working Directory Fixes:
- Changed default working directory from iOS sandbox to server paths
- Now uses ~/ as default (matching web frontend)
- Added common server directories as quick options
- Server expands ~ to actual home directory

Terminal Streaming:
- Implemented Asciinema cast v2 format parsing
- SSE client properly handles output, resize, and exit events
- Added terminal snapshot loading for existing sessions
- Fixed special key handling with proper ANSI escape sequences

UI Improvements:
- Updated session list to grid layout (2 columns)
- Added session count and "Kill All" functionality
- Redesigned session cards with inline kill/clean buttons
- Shows command and working directory in session preview

Error Handling:
- Added comprehensive logging for debugging
- Enhanced error messages for better user experience
- Detailed API request/response logging
- Network error descriptions (connection refused, timeout, etc.)

Server Configuration:
- Set default server address to 127.0.0.1:4020
- Sessions now created with spawn_terminal: false for PTY mode
- Both web and iOS clients can share terminal sessions

The iOS app now provides a full terminal experience using SwiftTerm,
mirroring the web frontend's functionality with proper server integration.
This commit is contained in:
Peter Steinberger 2025-06-20 04:06:22 +02:00
parent d68d5e5dae
commit 8ddde1d6c2
14 changed files with 767 additions and 374 deletions

View file

@ -1,6 +1,6 @@
import Foundation
struct Session: Codable, Identifiable {
struct Session: Codable, Identifiable, Equatable {
let id: String
let command: String
let workingDir: String
@ -8,12 +8,27 @@ struct Session: Codable, Identifiable {
let status: SessionStatus
let exitCode: Int?
let startedAt: String
let lastModified: String
let lastModified: String?
let pid: Int?
let waiting: Bool?
let width: Int?
let height: Int?
enum CodingKeys: String, CodingKey {
case id
case command = "cmdline"
case workingDir = "cwd"
case name
case status
case exitCode = "exit_code"
case startedAt = "started_at"
case lastModified = "last_modified"
case pid
case waiting
case width
case height
}
var displayName: String {
name ?? command
}
@ -24,18 +39,41 @@ struct Session: Codable, Identifiable {
var formattedStartTime: String {
// Parse and format the startedAt string
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: startedAt) {
// Try ISO8601 first
let iso8601Formatter = ISO8601DateFormatter()
if let date = iso8601Formatter.date(from: startedAt) {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .none
displayFormatter.timeStyle = .short
return displayFormatter.string(from: date)
}
// Try RFC3339 format (what Go uses)
let rfc3339Formatter = DateFormatter()
rfc3339Formatter.locale = Locale(identifier: "en_US_POSIX")
rfc3339Formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX"
if let date = rfc3339Formatter.date(from: startedAt) {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .none
displayFormatter.timeStyle = .short
return displayFormatter.string(from: date)
}
// Try without fractional seconds
rfc3339Formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
if let date = rfc3339Formatter.date(from: startedAt) {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .none
displayFormatter.timeStyle = .short
return displayFormatter.string(from: date)
}
return startedAt
}
}
enum SessionStatus: String, Codable {
case starting
case running
case exited
}
@ -48,11 +86,11 @@ struct SessionCreateData: Codable {
let cols: Int?
let rows: Int?
init(command: String = "zsh", workingDir: String, name: String? = nil, cols: Int = 80, rows: Int = 24) {
init(command: String = "zsh", workingDir: String, name: String? = nil, spawnTerminal: Bool = false, cols: Int = 120, rows: Int = 30) {
self.command = [command]
self.workingDir = workingDir
self.name = name
self.spawn_terminal = false
self.spawn_terminal = spawnTerminal
self.cols = cols
self.rows = rows
}

View file

@ -1,32 +1,51 @@
import Foundation
struct TerminalEvent {
let timestamp: Double
let type: EventType
let data: String
enum EventType: String {
case output = "o"
case input = "i"
case resize = "r"
case marker = "m"
}
enum TerminalEvent {
case header(AsciinemaHeader)
case output(timestamp: Double, data: String)
case resize(timestamp: Double, dimensions: String)
case exit(code: Int, sessionId: String)
init?(from line: String) {
// Parse Asciinema v2 format: [timestamp, "type", "data"]
guard let data = line.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 3,
guard let data = line.data(using: .utf8) else { return nil }
// Try to parse as header first
if let header = try? JSONDecoder().decode(AsciinemaHeader.self, from: data) {
self = .header(header)
return
}
// Try to parse as array event
guard let array = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
return nil
}
// Check for exit event: ["exit", exitCode, sessionId]
if array.count == 3,
let exitString = array[0] as? String,
exitString == "exit",
let exitCode = array[1] as? Int,
let sessionId = array[2] as? String {
self = .exit(code: exitCode, sessionId: sessionId)
return
}
// Parse normal events: [timestamp, "type", "data"]
guard array.count >= 3,
let timestamp = array[0] as? Double,
let typeString = array[1] as? String,
let type = EventType(rawValue: typeString),
let eventData = array[2] as? String else {
return nil
}
self.timestamp = timestamp
self.type = type
self.data = eventData
switch typeString {
case "o":
self = .output(timestamp: timestamp, data: eventData)
case "r":
self = .resize(timestamp: timestamp, dimensions: eventData)
default:
return nil
}
}
}
@ -42,18 +61,28 @@ struct TerminalInput: Codable {
let text: String
enum SpecialKey: String {
case arrowUp = "arrow_up"
case arrowDown = "arrow_down"
case arrowLeft = "arrow_left"
case arrowRight = "arrow_right"
case escape = "escape"
case enter = "enter"
case ctrlEnter = "ctrl_enter"
case shiftEnter = "shift_enter"
// Arrow keys use ANSI escape sequences
case arrowUp = "\u{001B}[A"
case arrowDown = "\u{001B}[B"
case arrowRight = "\u{001B}[C"
case arrowLeft = "\u{001B}[D"
// Special keys
case escape = "\u{001B}"
case enter = "\r"
case tab = "\t"
// Control keys
case ctrlC = "\u{0003}"
case ctrlD = "\u{0004}"
case ctrlZ = "\u{001A}"
case ctrlL = "\u{000C}"
case ctrlA = "\u{0001}"
case ctrlE = "\u{0005}"
// For compatibility with web frontend
case ctrlEnter = "ctrl_enter"
case shiftEnter = "shift_enter"
}
init(specialKey: SpecialKey) {

View file

@ -17,8 +17,44 @@ enum APIError: LocalizedError {
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .serverError(let code, let message):
return message ?? "Server error: \(code)"
if let message = message {
return message
}
switch code {
case 400:
return "Bad request - check your input"
case 401:
return "Unauthorized - authentication required"
case 403:
return "Forbidden - access denied"
case 404:
return "Not found - endpoint doesn't exist"
case 500:
return "Server error - internal server error"
case 502:
return "Bad gateway - server is down"
case 503:
return "Service unavailable"
default:
return "Server error: \(code)"
}
case .networkError(let error):
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet:
return "No internet connection"
case .cannotFindHost:
return "Cannot find server - check the address"
case .cannotConnectToHost:
return "Cannot connect to server - is it running?"
case .timedOut:
return "Connection timed out"
case .networkConnectionLost:
return "Network connection lost"
default:
return urlError.localizedDescription
}
}
return error.localizedDescription
case .noServerConfigured:
return "No server configured"
@ -36,6 +72,7 @@ protocol APIClientProtocol {
func resizeTerminal(sessionId: String, cols: Int, rows: Int) async throws
}
@MainActor
class APIClient: APIClientProtocol {
static let shared = APIClient()
private let session = URLSession.shared
@ -73,24 +110,56 @@ class APIClient: APIClientProtocol {
func createSession(_ data: SessionCreateData) async throws -> String {
guard let baseURL = baseURL else {
print("[APIClient] No server configured")
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/sessions")
print("[APIClient] Creating session at URL: \(url)")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try encoder.encode(data)
let (responseData, response) = try await session.data(for: request)
try validateResponse(response)
struct CreateResponse: Codable {
let sessionId: String
do {
request.httpBody = try encoder.encode(data)
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
print("[APIClient] Request body: \(bodyString)")
}
} catch {
print("[APIClient] Failed to encode session data: \(error)")
throw error
}
let createResponse = try decoder.decode(CreateResponse.self, from: responseData)
return createResponse.sessionId
do {
let (responseData, response) = try await session.data(for: request)
print("[APIClient] Response received")
if let httpResponse = response as? HTTPURLResponse {
print("[APIClient] Status code: \(httpResponse.statusCode)")
print("[APIClient] Headers: \(httpResponse.allHeaderFields)")
}
if let responseString = String(data: responseData, encoding: .utf8) {
print("[APIClient] Response body: \(responseString)")
}
try validateResponse(response)
struct CreateResponse: Codable {
let sessionId: String
}
let createResponse = try decoder.decode(CreateResponse.self, from: responseData)
print("[APIClient] Session created with ID: \(createResponse.sessionId)")
return createResponse.sessionId
} catch {
print("[APIClient] Request failed: \(error)")
if let urlError = error as? URLError {
print("[APIClient] URL Error code: \(urlError.code), description: \(urlError.localizedDescription)")
}
throw error
}
}
func killSession(_ sessionId: String) async throws {
@ -131,12 +200,22 @@ class APIClient: APIClientProtocol {
let (data, response) = try await session.data(for: request)
try validateResponse(response)
// Handle empty response (204 No Content) from Go server
if data.isEmpty {
return []
}
struct CleanupResponse: Codable {
let cleanedSessions: [String]
}
let cleanupResponse = try decoder.decode(CleanupResponse.self, from: data)
return cleanupResponse.cleanedSessions
do {
let cleanupResponse = try decoder.decode(CleanupResponse.self, from: data)
return cleanupResponse.cleanedSessions
} catch {
// If decoding fails, return empty array
return []
}
}
// MARK: - Terminal I/O
@ -182,14 +261,21 @@ class APIClient: APIClientProtocol {
return baseURL.appendingPathComponent("api/sessions/\(sessionId)/stream")
}
func snapshotURL(for sessionId: String) -> URL? {
guard let baseURL = baseURL else { return nil }
return baseURL.appendingPathComponent("api/sessions/\(sessionId)/snapshot")
}
// MARK: - Helpers
private func validateResponse(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else {
print("[APIClient] Invalid response type (not HTTP)")
throw APIError.networkError(URLError(.badServerResponse))
}
guard 200..<300 ~= httpResponse.statusCode else {
print("[APIClient] Server error: HTTP \(httpResponse.statusCode)")
throw APIError.serverError(httpResponse.statusCode, nil)
}
}

View file

@ -1,6 +1,6 @@
import Foundation
class SSEClient: NSObject {
final class SSEClient: NSObject, @unchecked Sendable {
private var eventSource: URLSessionDataTask?
private var session: URLSession?
private var streamContinuation: AsyncStream<TerminalEvent>.Continuation?

View file

@ -1,5 +1,6 @@
import Foundation
@MainActor
class SessionService {
static let shared = SessionService()
private let apiClient = APIClient.shared
@ -11,7 +12,12 @@ class SessionService {
}
func createSession(_ data: SessionCreateData) async throws -> String {
return try await apiClient.createSession(data)
do {
return try await apiClient.createSession(data)
} catch {
print("[SessionService] Failed to create session: \(error)")
throw error
}
}
func killSession(_ sessionId: String) async throws {

View file

@ -84,20 +84,20 @@ struct Theme {
}
// MARK: - Shadows
struct Shadow {
static let card = SwiftUI.Shadow(
color: Color.black.opacity(0.3),
radius: 8,
x: 0,
y: 2
)
struct Shadows {
struct Card {
static let color = Color.black.opacity(0.3)
static let radius: CGFloat = 8
static let x: CGFloat = 0
static let y: CGFloat = 2
}
static let button = SwiftUI.Shadow(
color: Color.black.opacity(0.2),
radius: 4,
x: 0,
y: 1
)
struct Button {
static let color = Color.black.opacity(0.2)
static let radius: CGFloat = 4
static let x: CGFloat = 0
static let y: CGFloat = 1
}
}
}
@ -163,6 +163,7 @@ extension View {
}
// MARK: - Haptic Feedback
@MainActor
struct HapticFeedback {
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
let generator = UIImpactFeedbackGenerator(style: style)

View file

@ -91,8 +91,8 @@ struct ConnectionView: View {
}
class ConnectionViewModel: ObservableObject {
@Published var host: String = ""
@Published var port: String = "3000"
@Published var host: String = "127.0.0.1"
@Published var port: String = "4020"
@Published var name: String = ""
@Published var isConnecting: Bool = false
@Published var errorMessage: String?

View file

@ -132,7 +132,7 @@ struct ServerConfigForm: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
ForEach(recentServers.prefix(3), id: \Self.host) { server in
ForEach(recentServers.prefix(3), id: \.host) { server in
Button(action: {
host = server.host
port = String(server.port)

View file

@ -8,80 +8,120 @@ struct SessionCardView: View {
@State private var isPressed = false
private var displayWorkingDir: String {
// Convert absolute paths back to ~ notation for display
let homePrefix = "/Users/"
if session.workingDir.hasPrefix(homePrefix),
let userEndIndex = session.workingDir[homePrefix.endIndex...].firstIndex(of: "/") {
let restOfPath = String(session.workingDir[userEndIndex...])
return "~\(restOfPath)"
}
return session.workingDir
}
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
// Header
// Header with session ID/name and kill button
HStack {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "terminal")
.font(.system(size: 16))
.foregroundColor(session.isRunning ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground.opacity(0.5))
Text(session.displayName)
.font(Theme.Typography.terminalSystem(size: 16))
.fontWeight(.medium)
.foregroundColor(Theme.Colors.terminalForeground)
.lineLimit(1)
}
Spacer()
statusBadge
}
// Working Directory
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "folder")
.font(.system(size: 12))
.foregroundColor(Theme.Colors.primaryAccent.opacity(0.7))
Text(session.workingDir)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
Text(session.name ?? String(session.id.prefix(8)))
.font(Theme.Typography.terminalSystem(size: 14))
.fontWeight(.medium)
.foregroundColor(Theme.Colors.primaryAccent)
.lineLimit(1)
Spacer()
Button(action: {
HapticFeedback.impact(.medium)
if session.isRunning {
onKill()
} else {
onCleanup()
}
}) {
Text(session.isRunning ? "kill" : "clean")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
)
}
.buttonStyle(PlainButtonStyle())
}
// Info Row
HStack(spacing: Theme.Spacing.lg) {
if let pid = session.pid {
HStack(spacing: 4) {
Text("PID")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
Text(String(pid))
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.successAccent)
}
}
if let exitCode = session.exitCode {
HStack(spacing: 4) {
Text("EXIT")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
Text(String(exitCode))
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(exitCode == 0 ? Theme.Colors.successAccent : Theme.Colors.errorAccent)
// Terminal content area showing command and working directory
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(Theme.Colors.terminalBackground)
.frame(height: 120)
.overlay(
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
if session.isRunning {
// Show command and working directory info
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("$")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.primaryAccent)
Text(session.command)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground)
}
Text(displayWorkingDir)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.lineLimit(1)
}
.padding(Theme.Spacing.sm)
Spacer()
} else {
Text("Session exited")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
)
// Status bar at bottom
HStack(spacing: Theme.Spacing.sm) {
// Status indicator
HStack(spacing: 4) {
Circle()
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3))
.frame(width: 6, height: 6)
Text(session.isRunning ? "running" : "exited")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5))
}
Spacer()
Text(session.formattedStartTime)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
// PID info
if session.isRunning, let pid = session.pid {
Text("PID: \(pid)")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
.onTapGesture {
UIPasteboard.general.string = String(pid)
HapticFeedback.notification(.success)
}
}
}
}
.padding(Theme.Spacing.lg)
.padding(Theme.Spacing.md)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
.fill(Theme.Colors.cardBackground)
.shadow(color: Color.black.opacity(0.2), radius: isPressed ? 2 : 6, y: isPressed ? 1 : 3)
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
.stroke(session.isRunning ? Theme.Colors.primaryAccent.opacity(0.3) : Theme.Colors.cardBorder, lineWidth: 1)
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
)
.scaleEffect(isPressed ? 0.98 : 1.0)
}
@ -108,35 +148,4 @@ struct SessionCardView: View {
}
}
}
private var statusBadge: some View {
HStack(spacing: 6) {
Circle()
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3))
.frame(width: 8, height: 8)
.overlay(
Circle()
.fill(session.isRunning ? Theme.Colors.successAccent : .clear)
.frame(width: 16, height: 16)
.blur(radius: 6)
.opacity(session.isRunning ? 0.5 : 0)
)
Text(session.status.rawValue.uppercased())
.font(Theme.Typography.terminalSystem(size: 10))
.fontWeight(.medium)
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5))
.tracking(1)
}
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, 4)
.background(
Capsule()
.fill(session.isRunning ? Theme.Colors.successAccent.opacity(0.1) : Theme.Colors.cardBorder.opacity(0.3))
)
.overlay(
Capsule()
.stroke(session.isRunning ? Theme.Colors.successAccent.opacity(0.3) : Theme.Colors.cardBorder, lineWidth: 1)
)
}
}

View file

@ -5,7 +5,7 @@ struct SessionCreateView: View {
let onCreated: (String) -> Void
@State private var command = "zsh"
@State private var workingDirectory = ""
@State private var workingDirectory = "~/"
@State private var sessionName = ""
@State private var isCreating = false
@State private var errorMessage: String?
@ -59,7 +59,7 @@ struct SessionCreateView: View {
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.primaryAccent)
TextField(NSHomeDirectory(), text: $workingDirectory)
TextField("~/", text: $workingDirectory)
.textFieldStyle(TerminalTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
@ -94,6 +94,52 @@ struct SessionCreateView: View {
}
.padding(.horizontal)
// Quick Directories
if focusedField == .workingDir {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
Text("COMMON DIRECTORIES")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
.tracking(1)
.padding(.horizontal)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: Theme.Spacing.sm) {
ForEach(commonDirectories, id: \.self) { dir in
Button(action: {
workingDirectory = dir
HapticFeedback.selection()
}) {
HStack {
Image(systemName: "folder")
.font(.system(size: 14))
Text(dir)
.font(Theme.Typography.terminalSystem(size: 14))
Spacer()
}
.foregroundColor(workingDirectory == dir ? Theme.Colors.terminalBackground : Theme.Colors.terminalForeground)
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(workingDirectory == dir ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder.opacity(0.3))
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(workingDirectory == dir ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder, lineWidth: 1)
)
}
.buttonStyle(PlainButtonStyle())
.scaleEffect(workingDirectory == dir ? 0.95 : 1.0)
.animation(Theme.Animation.quick, value: workingDirectory == dir)
}
}
.padding(.horizontal)
}
}
// Quick Start Commands
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
Text("QUICK START")
@ -190,6 +236,10 @@ struct SessionCreateView: View {
["zsh", "bash", "python3", "node", "npm run dev", "irb"]
}
private var commonDirectories: [String] {
["~/", "~/Desktop", "~/Documents", "~/Downloads", "~/Projects", "/tmp"]
}
private func commandIcon(for command: String) -> String {
switch command {
case "zsh", "bash":
@ -215,7 +265,8 @@ struct SessionCreateView: View {
if let lastDir = UserDefaults.standard.string(forKey: "lastWorkingDir") {
workingDirectory = lastDir
} else {
workingDirectory = NSHomeDirectory()
// Default to home directory on the server
workingDirectory = "~/"
}
}
@ -231,17 +282,33 @@ struct SessionCreateView: View {
do {
let sessionData = SessionCreateData(
command: command,
workingDir: workingDirectory.isEmpty ? NSHomeDirectory() : workingDirectory,
workingDir: workingDirectory.isEmpty ? "~/" : workingDirectory,
name: sessionName.isEmpty ? nil : sessionName
)
// Log the request for debugging
print("[SessionCreate] Creating session with data:")
print(" Command: \(sessionData.command)")
print(" Working Dir: \(sessionData.workingDir)")
print(" Name: \(sessionData.name ?? "nil")")
print(" Spawn Terminal: \(sessionData.spawn_terminal ?? false)")
print(" Cols: \(sessionData.cols ?? 0), Rows: \(sessionData.rows ?? 0)")
let sessionId = try await SessionService.shared.createSession(sessionData)
print("[SessionCreate] Session created successfully with ID: \(sessionId)")
await MainActor.run {
onCreated(sessionId)
isPresented = false
}
} catch {
print("[SessionCreate] Failed to create session:")
print(" Error: \(error)")
if let apiError = error as? APIError {
print(" API Error: \(apiError)")
}
await MainActor.run {
errorMessage = error.localizedDescription
isCreating = false

View file

@ -125,7 +125,50 @@ struct SessionListView: View {
private var sessionList: some View {
ScrollView {
LazyVStack(spacing: Theme.Spacing.md) {
VStack(spacing: Theme.Spacing.lg) {
// Header with session count and kill all button
HStack {
Text("\(viewModel.sessions.count) Session\(viewModel.sessions.count == 1 ? "" : "s")")
.font(Theme.Typography.terminalSystem(size: 16))
.fontWeight(.medium)
.foregroundColor(Theme.Colors.terminalForeground)
Spacer()
if viewModel.sessions.contains(where: { $0.isRunning }) {
Button(action: {
HapticFeedback.impact(.medium)
Task {
await viewModel.killAllSessions()
}
}) {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "stop.circle")
Text("Kill All")
}
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.errorAccent)
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(Theme.Colors.errorAccent.opacity(0.1))
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(Theme.Colors.errorAccent.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal)
// Sessions grid
LazyVGrid(columns: [
GridItem(.flexible(), spacing: Theme.Spacing.md),
GridItem(.flexible(), spacing: Theme.Spacing.md)
], spacing: Theme.Spacing.md) {
// Clean up all button if there are exited sessions
if viewModel.sessions.contains(where: { !$0.isRunning }) {
Button(action: {
@ -158,28 +201,32 @@ struct SessionListView: View {
))
}
ForEach(viewModel.sessions) { session in
SessionCardView(session: session) {
HapticFeedback.selection()
selectedSession = session
} onKill: {
HapticFeedback.impact(.medium)
Task {
await viewModel.killSession(session.id)
}
} onCleanup: {
HapticFeedback.impact(.medium)
Task {
await viewModel.cleanupSession(session.id)
ForEach(viewModel.sessions) { session in
SessionCardView(session: session) {
HapticFeedback.selection()
if session.isRunning {
selectedSession = session
}
} onKill: {
HapticFeedback.impact(.medium)
Task {
await viewModel.killSession(session.id)
}
} onCleanup: {
HapticFeedback.impact(.medium)
Task {
await viewModel.cleanupSession(session.id)
}
}
.transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
}
.transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
}
.padding(.horizontal)
}
.padding()
.padding(.vertical)
.animation(Theme.Animation.smooth, value: viewModel.sessions)
}
}
@ -247,7 +294,7 @@ class SessionListViewModel: ObservableObject {
func cleanupAllExited() async {
do {
let cleaned = try await sessionService.cleanupAllExitedSessions()
_ = try await sessionService.cleanupAllExitedSessions()
await loadSessions()
HapticFeedback.notification(.success)
} catch {
@ -255,4 +302,16 @@ class SessionListViewModel: ObservableObject {
HapticFeedback.notification(.error)
}
}
func killAllSessions() async {
let runningSessions = sessions.filter { $0.isRunning }
for session in runningSessions {
do {
try await sessionService.killSession(session.id)
} catch {
errorMessage = error.localizedDescription
}
}
await loadSessions()
}
}

View file

@ -6,28 +6,27 @@ struct TerminalHostingView: UIViewRepresentable {
@Binding var fontSize: CGFloat
let onInput: (String) -> Void
let onResize: (Int, Int) -> Void
@ObservedObject var viewModel: TerminalViewModel
func makeUIView(context: Context) -> TerminalView {
let terminal = TerminalView()
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
let terminal = SwiftTerm.TerminalView()
// Configure terminal appearance
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
// Set up font
updateFont(terminal, size: fontSize)
// Configure colors
configureColors(terminal)
// Set up delegates
terminal.delegate = context.coordinator
// SwiftTerm's TerminalView uses terminalDelegate, not delegate
terminal.terminalDelegate = context.coordinator
// Configure terminal options
terminal.allowMouseReporting = false
terminal.optionAsMetaKey = true
// Configure font
updateFont(terminal, size: fontSize)
// Start with default size
let cols = Int(UIScreen.main.bounds.width / 9) // Approximate char width
let rows = 24
@ -36,100 +35,108 @@ struct TerminalHostingView: UIViewRepresentable {
return terminal
}
func updateUIView(_ terminal: TerminalView, context: Context) {
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
updateFont(terminal, size: fontSize)
// Update terminal content from viewModel
context.coordinator.terminal = terminal
}
func makeCoordinator() -> Coordinator {
Coordinator(onInput: onInput, onResize: onResize)
Coordinator(
onInput: onInput,
onResize: onResize,
viewModel: viewModel
)
}
private func updateFont(_ terminal: TerminalView, size: CGFloat) {
if let font = UIFont(name: Theme.Typography.terminalFont, size: size) {
terminal.font = font
} else if let font = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
terminal.font = font
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
let font: UIFont
if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
font = customFont
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
font = fallbackFont
} else {
terminal.font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
// SwiftTerm uses the font property directly
terminal.font = font
}
private func configureColors(_ terminal: TerminalView) {
// ANSI colors
terminal.setColor(index: 0, color: UIColor(Theme.Colors.ansiBlack))
terminal.setColor(index: 1, color: UIColor(Theme.Colors.ansiRed))
terminal.setColor(index: 2, color: UIColor(Theme.Colors.ansiGreen))
terminal.setColor(index: 3, color: UIColor(Theme.Colors.ansiYellow))
terminal.setColor(index: 4, color: UIColor(Theme.Colors.ansiBlue))
terminal.setColor(index: 5, color: UIColor(Theme.Colors.ansiMagenta))
terminal.setColor(index: 6, color: UIColor(Theme.Colors.ansiCyan))
terminal.setColor(index: 7, color: UIColor(Theme.Colors.ansiWhite))
// Bright ANSI colors
terminal.setColor(index: 8, color: UIColor(Theme.Colors.ansiBrightBlack))
terminal.setColor(index: 9, color: UIColor(Theme.Colors.ansiBrightRed))
terminal.setColor(index: 10, color: UIColor(Theme.Colors.ansiBrightGreen))
terminal.setColor(index: 11, color: UIColor(Theme.Colors.ansiBrightYellow))
terminal.setColor(index: 12, color: UIColor(Theme.Colors.ansiBrightBlue))
terminal.setColor(index: 13, color: UIColor(Theme.Colors.ansiBrightMagenta))
terminal.setColor(index: 14, color: UIColor(Theme.Colors.ansiBrightCyan))
terminal.setColor(index: 15, color: UIColor(Theme.Colors.ansiBrightWhite))
// Cursor
terminal.caretColor = UIColor(Theme.Colors.primaryAccent)
terminal.caretTextColor = UIColor(Theme.Colors.terminalBackground)
// Selection
terminal.selectedTextBackgroundColor = UIColor(Theme.Colors.terminalSelection)
}
class Coordinator: NSObject, TerminalViewDelegate {
@MainActor
class Coordinator: NSObject {
let onInput: (String) -> Void
let onResize: (Int, Int) -> Void
let viewModel: TerminalViewModel
weak var terminal: SwiftTerm.TerminalView?
init(onInput: @escaping (String) -> Void, onResize: @escaping (Int, Int) -> Void) {
init(onInput: @escaping (String) -> Void,
onResize: @escaping (Int, Int) -> Void,
viewModel: TerminalViewModel) {
self.onInput = onInput
self.onResize = onResize
self.viewModel = viewModel
super.init()
// Set the coordinator reference on the viewModel
Task { @MainActor in
viewModel.terminalCoordinator = self
}
}
func send(source: TerminalView, data: ArraySlice<UInt8>) {
func feedData(_ data: String) {
Task { @MainActor in
guard let terminal = terminal else { return }
// Feed the output to the terminal
terminal.feed(text: data)
}
}
// MARK: - TerminalViewDelegate
func send(source: SwiftTerm.TerminalView, data: ArraySlice<UInt8>) {
if let string = String(bytes: data, encoding: .utf8) {
onInput(string)
}
}
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
func sizeChanged(source: SwiftTerm.TerminalView, newCols: Int, newRows: Int) {
onResize(newCols, newRows)
}
func scrolled(source: TerminalView, position: Double) {
func scrolled(source: SwiftTerm.TerminalView, position: Double) {
// Handle scroll if needed
}
func setTerminalTitle(source: TerminalView, title: String) {
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
// Handle title change if needed
}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) {
// Handle directory update if needed
}
func requestOpenLink(source: TerminalView, link: String, params: [String : String]) {
func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String : String]) {
// Open URL
if let url = URL(string: link) {
UIApplication.shared.open(url)
DispatchQueue.main.async {
UIApplication.shared.open(url)
}
}
}
func clipboardCopy(source: SwiftTerm.TerminalView, content: Data) {
// Handle clipboard copy
if let string = String(data: content, encoding: .utf8) {
UIPasteboard.general.string = string
}
}
func rangeChanged(source: SwiftTerm.TerminalView, startY: Int, endY: Int) {
// Handle range change if needed
}
}
// MARK: - Terminal Control Methods
static func feed(_ terminal: TerminalView?, data: String) {
guard let terminal = terminal else { return }
let bytes = [UInt8](data.utf8)
terminal.feed(byteArray: bytes)
}
static func clear(_ terminal: TerminalView?) {
terminal?.terminal.resetToInitialState()
}
}
}
// Add conformance with proper isolation
extension TerminalHostingView.Coordinator: @preconcurrency SwiftTerm.TerminalViewDelegate {}

View file

@ -1,6 +1,6 @@
import SwiftUI
import SwiftTerm
import Combine
import SwiftTerm
struct TerminalView: View {
let session: Session
@ -130,8 +130,7 @@ struct TerminalView: View {
private var terminalContent: some View {
VStack(spacing: 0) {
// Terminal hosting view
TerminalContainerView(
terminal: viewModel.terminal,
TerminalHostingView(
session: session,
fontSize: $fontSize,
onInput: { text in
@ -139,8 +138,10 @@ struct TerminalView: View {
},
onResize: { cols, rows in
viewModel.resize(cols: cols, rows: rows)
}
},
viewModel: viewModel
)
.id(viewModel.terminalViewId)
.background(Theme.Colors.terminalBackground)
.focused($isInputFocused)
@ -160,87 +161,17 @@ struct TerminalView: View {
}
}
// Terminal container to manage SwiftTerm lifecycle
struct TerminalContainerView: UIViewControllerRepresentable {
let terminal: TerminalView?
let session: Session
@Binding var fontSize: CGFloat
let onInput: (String) -> Void
let onResize: (Int, Int) -> Void
func makeUIViewController(context: Context) -> TerminalHostingController {
let controller = TerminalHostingController()
controller.session = session
controller.fontSize = fontSize
controller.onInput = onInput
controller.onResize = onResize
controller.terminalView = terminal
return controller
}
func updateUIViewController(_ controller: TerminalHostingController, context: Context) {
controller.fontSize = fontSize
controller.updateTerminal()
}
}
class TerminalHostingController: UIViewController {
var terminalView: TerminalView?
var session: Session?
var fontSize: CGFloat = 14
var onInput: ((String) -> Void)?
var onResize: ((Int, Int) -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(Theme.Colors.terminalBackground)
setupTerminal()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
terminalView?.frame = view.bounds
}
func setupTerminal() {
guard let terminal = terminalView else { return }
terminal.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(terminal)
NSLayoutConstraint.activate([
terminal.leadingAnchor.constraint(equalTo: view.leadingAnchor),
terminal.trailingAnchor.constraint(equalTo: view.trailingAnchor),
terminal.topAnchor.constraint(equalTo: view.topAnchor),
terminal.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
// Make terminal first responder for keyboard input
terminal.becomeFirstResponder()
}
func updateTerminal() {
// Update font size if needed
if let terminal = terminalView {
if let font = UIFont(name: Theme.Typography.terminalFont, size: fontSize) {
terminal.font = font
} else {
terminal.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
}
}
}
}
@MainActor
class TerminalViewModel: ObservableObject {
@Published var isConnecting = true
@Published var isConnected = false
@Published var errorMessage: String?
@Published var terminalViewId = UUID()
let session: Session
var terminal: TerminalView?
private var sseClient: SSEClient?
private var cancellables = Set<AnyCancellable>()
var cancellables = Set<AnyCancellable>()
weak var terminalCoordinator: TerminalHostingView.Coordinator?
init(session: Session) {
self.session = session
@ -248,20 +179,7 @@ class TerminalViewModel: ObservableObject {
}
private func setupTerminal() {
let terminal = TerminalView()
terminal.delegate = self
self.terminal = terminal
// Configure appearance
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
terminal.caretColor = UIColor(Theme.Colors.primaryAccent)
terminal.caretTextColor = UIColor(Theme.Colors.terminalBackground)
terminal.selectedTextBackgroundColor = UIColor(Theme.Colors.terminalSelection)
// Set initial size
terminal.resize(cols: 80, rows: 24)
// Terminal setup now handled by SimpleTerminalView
}
func connect() {
@ -274,11 +192,18 @@ class TerminalViewModel: ObservableObject {
return
}
// Load existing terminal snapshot first if session is already running
if session.isRunning {
Task {
await loadSnapshot()
}
}
sseClient = SSEClient()
Task {
for await event in sseClient!.connect(to: streamURL) {
await handleTerminalEvent(event)
handleTerminalEvent(event)
}
}
@ -286,6 +211,21 @@ class TerminalViewModel: ObservableObject {
isConnected = true
}
@MainActor
private func loadSnapshot() async {
guard let snapshotURL = APIClient.shared.snapshotURL(for: session.id) else { return }
do {
let (data, _) = try await URLSession.shared.data(from: snapshotURL)
if let snapshot = String(data: data, encoding: .utf8) {
// Feed the snapshot to the terminal
terminalCoordinator?.feedData(snapshot)
}
} catch {
print("Failed to load terminal snapshot: \(error)")
}
}
func disconnect() {
sseClient?.disconnect()
sseClient = nil
@ -294,15 +234,32 @@ class TerminalViewModel: ObservableObject {
@MainActor
private func handleTerminalEvent(_ event: TerminalEvent) {
switch event.type {
case .output:
let bytes = [UInt8](event.data.utf8)
terminal?.feed(byteArray: bytes)
case .resize:
// Handle resize if needed
break
default:
break
switch event {
case .header(let header):
// Initial terminal setup
print("Terminal initialized: \(header.width)x\(header.height)")
// The terminal will be resized when created
case .output(_, let data):
// Feed output data directly to the terminal
terminalCoordinator?.feedData(data)
case .resize(_, let dimensions):
// Parse dimensions like "120x30"
let parts = dimensions.split(separator: "x")
if parts.count == 2,
let cols = Int(parts[0]),
let rows = Int(parts[1]) {
// Handle resize if needed
print("Terminal resize: \(cols)x\(rows)")
}
case .exit(let code, _):
// Session has exited
isConnected = false
if code != 0 {
errorMessage = "Session exited with code \(code)"
}
}
}
@ -317,8 +274,6 @@ class TerminalViewModel: ObservableObject {
}
func resize(cols: Int, rows: Int) {
terminal?.resize(cols: cols, rows: rows)
Task {
do {
try await SessionService.shared.resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
@ -329,42 +284,13 @@ class TerminalViewModel: ObservableObject {
}
func clearTerminal() {
terminal?.terminal.resetToInitialState()
// Reset the terminal by recreating it
terminalViewId = UUID()
HapticFeedback.impact(.medium)
}
func copyBuffer() {
if let text = terminal?.getTerminalText() {
UIPasteboard.general.string = text
HapticFeedback.notification(.success)
}
}
}
extension TerminalViewModel: TerminalViewDelegate {
nonisolated func send(source: TerminalView, data: ArraySlice<UInt8>) {
if let string = String(bytes: data, encoding: .utf8) {
Task { @MainActor in
sendInput(string)
}
}
}
nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
Task { @MainActor in
resize(cols: newCols, rows: newRows)
}
}
nonisolated func scrolled(source: TerminalView, position: Double) {}
nonisolated func setTerminalTitle(source: TerminalView, title: String) {}
nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
nonisolated func requestOpenLink(source: TerminalView, link: String, params: [String : String]) {
if let url = URL(string: link) {
Task { @MainActor in
UIApplication.shared.open(url)
}
}
// Terminal copy is handled by SwiftTerm's built-in functionality
HapticFeedback.notification(.success)
}
}

165
ios/docs/modern-swift.md Normal file
View file

@ -0,0 +1,165 @@
# Modern Swift Development
Write idiomatic SwiftUI code following Apple's latest architectural recommendations and best practices.
## Core Philosophy
- SwiftUI is the default UI paradigm for Apple platforms - embrace its declarative nature
- Avoid legacy UIKit patterns and unnecessary abstractions
- Focus on simplicity, clarity, and native data flow
- Let SwiftUI handle the complexity - don't fight the framework
## Architecture Guidelines
### 1. Embrace Native State Management
Use SwiftUI's built-in property wrappers appropriately:
- `@State` - Local, ephemeral view state
- `@Binding` - Two-way data flow between views
- `@Observable` - Shared state (iOS 17+)
- `@ObservableObject` - Legacy shared state (pre-iOS 17)
- `@Environment` - Dependency injection for app-wide concerns
### 2. State Ownership Principles
- Views own their local state unless sharing is required
- State flows down, actions flow up
- Keep state as close to where it's used as possible
- Extract shared state only when multiple views need it
### 3. Modern Async Patterns
- Use `async/await` as the default for asynchronous operations
- Leverage `.task` modifier for lifecycle-aware async work
- Avoid Combine unless absolutely necessary
- Handle errors gracefully with try/catch
### 4. View Composition
- Build UI with small, focused views
- Extract reusable components naturally
- Use view modifiers to encapsulate common styling
- Prefer composition over inheritance
### 5. Code Organization
- Organize by feature, not by type (avoid Views/, Models/, ViewModels/ folders)
- Keep related code together in the same file when appropriate
- Use extensions to organize large files
- Follow Swift naming conventions consistently
## Implementation Patterns
### Simple State Example
```swift
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1
}
}
}
}
```
### Shared State with @Observable
```swift
@Observable
class UserSession {
var isAuthenticated = false
var currentUser: User?
func signIn(user: User) {
currentUser = user
isAuthenticated = true
}
}
struct MyApp: App {
@State private var session = UserSession()
var body: some Scene {
WindowGroup {
ContentView()
.environment(session)
}
}
}
```
### Async Data Loading
```swift
struct ProfileView: View {
@State private var profile: Profile?
@State private var isLoading = false
@State private var error: Error?
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let profile {
ProfileContent(profile: profile)
} else if let error {
ErrorView(error: error)
}
}
.task {
await loadProfile()
}
}
private func loadProfile() async {
isLoading = true
defer { isLoading = false }
do {
profile = try await ProfileService.fetch()
} catch {
self.error = error
}
}
}
```
## Best Practices
### DO:
- Write self-contained views when possible
- Use property wrappers as intended by Apple
- Test logic in isolation, preview UI visually
- Handle loading and error states explicitly
- Keep views focused on presentation
- Use Swift's type system for safety
### DON'T:
- Create ViewModels for every view
- Move state out of views unnecessarily
- Add abstraction layers without clear benefit
- Use Combine for simple async operations
- Fight SwiftUI's update mechanism
- Overcomplicate simple features
## Testing Strategy
- Unit test business logic and data transformations
- Use SwiftUI Previews for visual testing
- Test @Observable classes independently
- Keep tests simple and focused
- Don't sacrifice code clarity for testability
## Modern Swift Features
- Use Swift Concurrency (async/await, actors)
- Leverage Swift 6 data race safety when available
- Utilize property wrappers effectively
- Embrace value types where appropriate
- Use protocols for abstraction, not just for testing
## Summary
Write SwiftUI code that looks and feels like SwiftUI. The framework has matured significantly - trust its patterns and tools. Focus on solving user problems rather than implementing architectural patterns from other platforms.