Modernize SwiftUI on iOS

This commit is contained in:
Peter Steinberger 2025-06-20 07:29:46 +02:00
parent 596c3393ea
commit 856a581608
13 changed files with 139 additions and 119 deletions

View file

@ -2,7 +2,7 @@ import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
@EnvironmentObject var connectionManager: ConnectionManager
@Environment(ConnectionManager.self) var connectionManager
@State private var showingFilePicker = false
@State private var showingCastPlayer = false
@State private var selectedCastFile: URL?

View file

@ -1,15 +1,16 @@
import SwiftUI
import Observation
@main
struct VibeTunnelApp: App {
@StateObject private var connectionManager = ConnectionManager()
@StateObject private var navigationManager = NavigationManager()
@State private var connectionManager = ConnectionManager()
@State private var navigationManager = NavigationManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(connectionManager)
.environmentObject(navigationManager)
.environment(connectionManager)
.environment(navigationManager)
.onOpenURL { url in
handleURL(url)
}
@ -28,9 +29,10 @@ struct VibeTunnelApp: App {
}
}
class ConnectionManager: ObservableObject {
@Published var isConnected: Bool = false
@Published var serverConfig: ServerConfig?
@Observable
class ConnectionManager {
var isConnected: Bool = false
var serverConfig: ServerConfig?
init() {
loadSavedConnection()
@ -55,9 +57,10 @@ class ConnectionManager: ObservableObject {
}
}
class NavigationManager: ObservableObject {
@Published var selectedSessionId: String?
@Published var shouldNavigateToSession: Bool = false
@Observable
class NavigationManager {
var selectedSessionId: String?
var shouldNavigateToSession: Bool = false
func navigateToSession(_ sessionId: String) {
selectedSessionId = sessionId

View file

@ -1,4 +1,5 @@
import Foundation
import Observation
// Asciinema cast v2 format support
struct CastFile: Codable {
@ -25,10 +26,11 @@ struct CastEvent: Codable {
// Cast file recorder for terminal sessions
@MainActor
class CastRecorder: ObservableObject {
@Published var isRecording = false
@Published var recordingStartTime: Date?
@Published var events: [CastEvent] = []
@Observable
class CastRecorder {
var isRecording = false
var recordingStartTime: Date?
var events: [CastEvent] = []
private let sessionId: String
private let width: Int

View file

@ -327,14 +327,8 @@ class BufferWebSocketClient: NSObject {
}
deinit {
reconnectTimer?.invalidate()
reconnectTimer = nil
pingTimer?.invalidate()
pingTimer = nil
// Cancel the WebSocket task
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
subscriptions.removeAll()
// Timers will be cleaned up automatically when the object is deallocated
}
}

View file

@ -1,13 +1,14 @@
import SwiftUI
import Observation
struct ConnectionView: View {
@EnvironmentObject var connectionManager: ConnectionManager
@StateObject private var viewModel = ConnectionViewModel()
@Environment(ConnectionManager.self) var connectionManager
@State private var viewModel = ConnectionViewModel()
@State private var logoScale: CGFloat = 0.8
@State private var contentOpacity: Double = 0
var body: some View {
NavigationView {
NavigationStack {
ZStack {
// Background
Theme.Colors.terminalBackground
@ -72,7 +73,7 @@ struct ConnectionView: View {
}
.padding()
}
.navigationBarHidden(true)
.toolbar(.hidden, for: .navigationBar)
}
.navigationViewStyle(StackNavigationViewStyle())
.preferredColorScheme(.dark)
@ -91,13 +92,14 @@ struct ConnectionView: View {
}
}
class ConnectionViewModel: ObservableObject {
@Published var host: String = "127.0.0.1"
@Published var port: String = "4020"
@Published var name: String = ""
@Published var password: String = ""
@Published var isConnecting: Bool = false
@Published var errorMessage: String?
@Observable
class ConnectionViewModel {
var host: String = "127.0.0.1"
var port: String = "4020"
var name: String = ""
var password: String = ""
var isConnecting: Bool = false
var errorMessage: String?
func loadLastConnection() {
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),

View file

@ -1,7 +1,8 @@
import SwiftUI
import Observation
struct FileBrowserView: View {
@StateObject private var viewModel = FileBrowserViewModel()
@State private var viewModel = FileBrowserViewModel()
@Environment(\.dismiss) private var dismiss
let onSelect: (String) -> Void
@ -148,7 +149,7 @@ struct FileBrowserView: View {
.background(Theme.Colors.terminalDarkGray)
}
}
.navigationBarHidden(true)
.toolbar(.hidden, for: .navigationBar)
.alert("Create Folder", isPresented: $viewModel.showCreateFolder) {
TextField("Folder name", text: $viewModel.newFolderName)
.textInputAutocapitalization(.never)
@ -259,14 +260,15 @@ struct TerminalButtonStyle: ButtonStyle {
}
@MainActor
class FileBrowserViewModel: ObservableObject {
@Published var currentPath = "~"
@Published var entries: [FileEntry] = []
@Published var isLoading = false
@Published var showCreateFolder = false
@Published var newFolderName = ""
@Published var showError = false
@Published var errorMessage: String?
@Observable
class FileBrowserViewModel {
var currentPath = "~"
var entries: [FileEntry] = []
var isLoading = false
var showCreateFolder = false
var newFolderName = ""
var showError = false
var errorMessage: String?
private let apiClient = APIClient.shared

View file

@ -36,7 +36,7 @@ struct SessionCreateView: View {
}
var body: some View {
NavigationView {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()

View file

@ -1,15 +1,16 @@
import SwiftUI
import Observation
struct SessionListView: View {
@EnvironmentObject var connectionManager: ConnectionManager
@EnvironmentObject var navigationManager: NavigationManager
@StateObject private var viewModel = SessionListViewModel()
@Environment(ConnectionManager.self) var connectionManager
@Environment(NavigationManager.self) var navigationManager
@State private var viewModel = SessionListViewModel()
@State private var showingCreateSession = false
@State private var selectedSession: Session?
@State private var showExitedSessions = true
var body: some View {
NavigationView {
NavigationStack {
ZStack {
// Background
Theme.Colors.terminalBackground
@ -78,9 +79,7 @@ struct SessionListView: View {
viewModel.stopAutoRefresh()
}
}
.navigationViewStyle(StackNavigationViewStyle())
.preferredColorScheme(.dark)
.environmentObject(connectionManager)
.onChange(of: navigationManager.shouldNavigateToSession) { shouldNavigate in
if shouldNavigate,
let sessionId = navigationManager.selectedSessionId,
@ -294,30 +293,33 @@ struct SessionListView: View {
}
@MainActor
class SessionListViewModel: ObservableObject {
@Published var sessions: [Session] = []
@Published var isLoading = false
@Published var errorMessage: String?
@Observable
class SessionListViewModel {
var sessions: [Session] = []
var isLoading = false
var errorMessage: String?
private var refreshTimer: Timer?
private var refreshTask: Task<Void, Never>?
private let sessionService = SessionService.shared
func startAutoRefresh() {
Task {
refreshTask?.cancel()
refreshTask = Task {
await loadSessions()
}
// Refresh every 3 seconds
refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
Task { @MainActor in
await self.loadSessions()
// Refresh every 3 seconds using modern async approach
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
if !Task.isCancelled {
await loadSessions()
}
}
}
}
func stopAutoRefresh() {
refreshTimer?.invalidate()
refreshTimer = nil
refreshTask?.cancel()
refreshTask = nil
}
func loadSessions() async {

View file

@ -1,18 +1,19 @@
import SwiftUI
import Observation
import SwiftTerm
import UniformTypeIdentifiers
struct CastPlayerView: View {
let castFileURL: URL
@Environment(\.dismiss) var dismiss
@StateObject private var viewModel = CastPlayerViewModel()
@State private var viewModel = CastPlayerViewModel()
@State private var fontSize: CGFloat = 14
@State private var isPlaying = false
@State private var currentTime: TimeInterval = 0
@State private var playbackSpeed: Double = 1.0
var body: some View {
NavigationView {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
@ -143,9 +144,9 @@ struct CastPlayerView: View {
.padding()
.background(Theme.Colors.cardBackground)
}
.onReceive(viewModel.$currentTime) { time in
.onChange(of: viewModel.currentTime) { _, newTime in
if !viewModel.isSeeking {
currentTime = time
currentTime = newTime
}
}
}
@ -177,7 +178,7 @@ struct CastPlayerView: View {
// Simple terminal view for cast playback
struct CastTerminalView: UIViewRepresentable {
@Binding var fontSize: CGFloat
@ObservedObject var viewModel: CastPlayerViewModel
let viewModel: CastPlayerViewModel
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
let terminal = SwiftTerm.TerminalView()
@ -251,11 +252,12 @@ struct CastTerminalView: UIViewRepresentable {
}
@MainActor
class CastPlayerViewModel: ObservableObject {
@Published var isLoading = true
@Published var errorMessage: String?
@Published var currentTime: TimeInterval = 0
@Published var isSeeking = false
@Observable
class CastPlayerViewModel {
var isLoading = true
var errorMessage: String?
var currentTime: TimeInterval = 0
var isSeeking = false
var player: CastPlayer?
var header: CastFile? { player?.header }
@ -293,19 +295,21 @@ class CastPlayerViewModel: ObservableObject {
guard let player = player else { return }
player.play(from: currentTime, speed: speed) { [weak self] event in
guard let self = self else { return }
switch event.type {
case "o":
self.onTerminalOutput?(event.data)
case "r":
// Handle resize if needed
break
default:
break
Task { @MainActor in
guard let self = self else { return }
switch event.type {
case "o":
self.onTerminalOutput?(event.data)
case "r":
// Handle resize if needed
break
default:
break
}
self.currentTime = event.time
}
self.currentTime = event.time
} completion: {
// Playback completed
}

View file

@ -7,7 +7,7 @@ struct FontSizeSheet: View {
let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32]
var body: some View {
NavigationView {
NavigationStack {
VStack(spacing: 0) {
// Font size preview
VStack(spacing: Theme.Spacing.lg) {

View file

@ -2,7 +2,7 @@ import SwiftUI
import UniformTypeIdentifiers
struct RecordingExportSheet: View {
@ObservedObject var recorder: CastRecorder
var recorder: CastRecorder
let sessionName: String
@Environment(\.dismiss) var dismiss
@State private var isExporting = false
@ -10,7 +10,7 @@ struct RecordingExportSheet: View {
@State private var exportedFileURL: URL?
var body: some View {
NavigationView {
NavigationStack {
VStack(spacing: Theme.Spacing.xl) {
// Icon
ZStack {

View file

@ -6,7 +6,7 @@ struct TerminalHostingView: UIViewRepresentable {
@Binding var fontSize: CGFloat
let onInput: (String) -> Void
let onResize: (Int, Int) -> Void
@ObservedObject var viewModel: TerminalViewModel
var viewModel: TerminalViewModel
@State private var isAutoScrollEnabled = true
func makeUIView(context: Context) -> SwiftTerm.TerminalView {

View file

@ -1,11 +1,11 @@
import SwiftUI
import Combine
import Observation
import SwiftTerm
struct TerminalView: View {
let session: Session
@Environment(\.dismiss) var dismiss
@StateObject private var viewModel: TerminalViewModel
@State private var viewModel: TerminalViewModel
@State private var fontSize: CGFloat = 14
@State private var showingFontSizeSheet = false
@State private var showingRecordingSheet = false
@ -14,11 +14,11 @@ struct TerminalView: View {
init(session: Session) {
self.session = session
self._viewModel = StateObject(wrappedValue: TerminalViewModel(session: session))
self._viewModel = State(initialValue: TerminalViewModel(session: session))
}
var body: some View {
NavigationView {
NavigationStack {
ZStack {
// Background
Theme.Colors.terminalBackground
@ -157,7 +157,6 @@ struct TerminalView: View {
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.preferredColorScheme(.dark)
.onAppear {
viewModel.connect()
@ -257,20 +256,22 @@ struct TerminalView: View {
}
@MainActor
class TerminalViewModel: ObservableObject {
@Published var isConnecting = true
@Published var isConnected = false
@Published var errorMessage: String?
@Published var terminalViewId = UUID()
@Published var terminalCols: Int = 0
@Published var terminalRows: Int = 0
@Published var isAutoScrollEnabled = true
@Published var recordingPulse = false
@Observable
class TerminalViewModel {
var isConnecting = true
var isConnected = false
var errorMessage: String?
var terminalViewId = UUID()
var terminalCols: Int = 0
var terminalRows: Int = 0
var isAutoScrollEnabled = true
var recordingPulse = false
let session: Session
let castRecorder: CastRecorder
private var bufferWebSocketClient: BufferWebSocketClient?
var cancellables = Set<AnyCancellable>()
private var connectionStatusTask: Task<Void, Never>?
private var connectionErrorTask: Task<Void, Never>?
weak var terminalCoordinator: TerminalHostingView.Coordinator?
init(session: Session) {
@ -311,9 +312,12 @@ class TerminalViewModel: ObservableObject {
}
// Monitor connection status
bufferWebSocketClient?.$isConnected
.sink { [weak self] connected in
Task { @MainActor in
connectionStatusTask?.cancel()
connectionStatusTask = Task { [weak self] in
guard let client = self?.bufferWebSocketClient else { return }
while !Task.isCancelled {
let connected = client.isConnected
await MainActor.run {
self?.isConnecting = false
self?.isConnected = connected
if !connected {
@ -322,19 +326,24 @@ class TerminalViewModel: ObservableObject {
self?.errorMessage = nil
}
}
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
}
.store(in: &cancellables)
}
// Monitor connection errors
bufferWebSocketClient?.$connectionError
.compactMap { $0 }
.sink { [weak self] error in
Task { @MainActor in
self?.errorMessage = error.localizedDescription
self?.isConnecting = false
connectionErrorTask?.cancel()
connectionErrorTask = Task { [weak self] in
guard let client = self?.bufferWebSocketClient else { return }
while !Task.isCancelled {
if let error = client.connectionError {
await MainActor.run {
self?.errorMessage = error.localizedDescription
self?.isConnecting = false
}
}
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
}
.store(in: &cancellables)
}
}
@MainActor
@ -353,6 +362,8 @@ class TerminalViewModel: ObservableObject {
}
func disconnect() {
connectionStatusTask?.cancel()
connectionErrorTask?.cancel()
bufferWebSocketClient?.unsubscribe(from: session.id)
bufferWebSocketClient?.disconnect()
bufferWebSocketClient = nil
@ -369,13 +380,13 @@ class TerminalViewModel: ObservableObject {
terminalRows = height
// The terminal will be resized when created
case .output(let timestamp, let data):
case .output(_, let data):
// Feed output data directly to the terminal
terminalCoordinator?.feedData(data)
// Record output if recording
castRecorder.recordOutput(data)
case .resize(let timestamp, let dimensions):
case .resize(_, let dimensions):
// Parse dimensions like "120x30"
let parts = dimensions.split(separator: "x")
if parts.count == 2,