From 2a63599ce0b09d139ddc9954f41f2a5840264f9f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Jun 2025 18:33:01 +0200 Subject: [PATCH] Various iOS improvements, remove halucinated bearer, make project sync based --- ios/VibeTunnel.xcodeproj/project.pbxproj | 4 +- ios/VibeTunnel/Models/FileInfo.swift | 25 +++ ios/VibeTunnel/Models/ServerConfig.swift | 70 ++------- ios/VibeTunnel/Models/Session.swift | 10 +- ios/VibeTunnel/Services/APIClient.swift | 59 +++++++ .../Services/QuickLookManager.swift | 147 ++++++++++++++++++ .../Views/Connection/ConnectionView.swift | 13 +- .../Views/Connection/ServerConfigForm.swift | 69 ++------ 8 files changed, 263 insertions(+), 134 deletions(-) create mode 100644 ios/VibeTunnel/Models/FileInfo.swift create mode 100644 ios/VibeTunnel/Services/QuickLookManager.swift diff --git a/ios/VibeTunnel.xcodeproj/project.pbxproj b/ios/VibeTunnel.xcodeproj/project.pbxproj index dbef349b..a8161542 100644 --- a/ios/VibeTunnel.xcodeproj/project.pbxproj +++ b/ios/VibeTunnel.xcodeproj/project.pbxproj @@ -18,7 +18,7 @@ 78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "Resources/Info.plist", + Resources/Info.plist, ); target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */; }; @@ -463,4 +463,4 @@ /* End XCSwiftPackageProductDependency section */ }; rootObject = 4C82CEFDA8E4EFE0B37538D7 /* Project object */; -} \ No newline at end of file +} diff --git a/ios/VibeTunnel/Models/FileInfo.swift b/ios/VibeTunnel/Models/FileInfo.swift new file mode 100644 index 00000000..38912cfe --- /dev/null +++ b/ios/VibeTunnel/Models/FileInfo.swift @@ -0,0 +1,25 @@ +import Foundation + +struct FileInfo: Codable { + let name: String + let path: String + let isDir: Bool + let size: Int64 + let mode: String + let modTime: Date + let mimeType: String + let readable: Bool + let executable: Bool + + enum CodingKeys: String, CodingKey { + case name + case path + case isDir = "is_dir" + case size + case mode + case modTime = "mod_time" + case mimeType = "mime_type" + case readable + case executable + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Models/ServerConfig.swift b/ios/VibeTunnel/Models/ServerConfig.swift index e95f44bf..22a8b5b3 100644 --- a/ios/VibeTunnel/Models/ServerConfig.swift +++ b/ios/VibeTunnel/Models/ServerConfig.swift @@ -1,20 +1,5 @@ import Foundation -/// Authentication type for server connections -enum AuthType: String, Codable, CaseIterable { - case none = "none" - case basic = "basic" - case bearer = "bearer" - - var displayName: String { - switch self { - case .none: return "No Authentication" - case .basic: return "Basic Auth (Username/Password)" - case .bearer: return "Bearer Token" - } - } -} - /// Configuration for connecting to a VibeTunnel server. /// /// ServerConfig stores all necessary information to establish @@ -25,23 +10,17 @@ struct ServerConfig: Codable, Equatable { let port: Int let name: String? let password: String? - let authType: AuthType - let bearerToken: String? init( host: String, port: Int, name: String? = nil, - password: String? = nil, - authType: AuthType = .none, - bearerToken: String? = nil + password: String? = nil ) { self.host = host self.port = port self.name = name self.password = password - self.authType = authType - self.bearerToken = bearerToken } /// Constructs the base URL for API requests. @@ -67,46 +46,23 @@ struct ServerConfig: Codable, Equatable { /// Indicates whether the server requires authentication. /// - /// - Returns: true if authentication is configured, false otherwise. + /// - Returns: true if a password is configured, false otherwise. var requiresAuthentication: Bool { - switch authType { - case .none: - return false - case .basic: - if let password { - return !password.isEmpty - } - return false - case .bearer: - if let bearerToken { - return !bearerToken.isEmpty - } - return false + if let password { + return !password.isEmpty } + return false } - /// Generates the Authorization header value based on auth type. + /// Generates the Authorization header value if a password is configured. /// - /// - Returns: A properly formatted auth header string, - /// or nil if no authentication is configured. - /// - /// For Basic auth: uses "admin" as the username with the configured password. - /// For Bearer auth: uses the configured bearer token. + /// - Returns: A Basic auth header string using "admin" as username, + /// or nil if no password is configured. var authorizationHeader: String? { - switch authType { - case .none: - return nil - - case .basic: - guard let password, !password.isEmpty else { return nil } - let credentials = "admin:\(password)" - guard let data = credentials.data(using: .utf8) else { return nil } - let base64 = data.base64EncodedString() - return "Basic \(base64)" - - case .bearer: - guard let bearerToken, !bearerToken.isEmpty else { return nil } - return "Bearer \(bearerToken)" - } + guard let password, !password.isEmpty else { return nil } + let credentials = "admin:\(password)" + guard let data = credentials.data(using: .utf8) else { return nil } + let base64 = data.base64EncodedString() + return "Basic \(base64)" } } diff --git a/ios/VibeTunnel/Models/Session.swift b/ios/VibeTunnel/Models/Session.swift index 3c0e2b0f..cb5b71a9 100644 --- a/ios/VibeTunnel/Models/Session.swift +++ b/ios/VibeTunnel/Models/Session.swift @@ -21,13 +21,13 @@ struct Session: Codable, Identifiable, Equatable { enum CodingKeys: String, CodingKey { case id - case command = "cmdline" - case workingDir = "cwd" + case command + case workingDir case name case status - case exitCode = "exit_code" - case startedAt = "started_at" - case lastModified = "last_modified" + case exitCode + case startedAt + case lastModified case pid case waiting case width diff --git a/ios/VibeTunnel/Services/APIClient.swift b/ios/VibeTunnel/Services/APIClient.swift index 7d6ec4bc..7dc38d64 100644 --- a/ios/VibeTunnel/Services/APIClient.swift +++ b/ios/VibeTunnel/Services/APIClient.swift @@ -456,4 +456,63 @@ class APIClient: APIClientProtocol { try validateResponse(response) } + func downloadFile(path: String, progressHandler: ((Double) -> Void)? = nil) async throws -> Data { + guard let baseURL else { + throw APIError.noServerConfigured + } + + guard var components = URLComponents( + url: baseURL.appendingPathComponent("api/fs/read"), + resolvingAgainstBaseURL: false + ) else { + throw APIError.invalidURL + } + components.queryItems = [URLQueryItem(name: "path", value: path)] + + guard let url = components.url else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + // Add authentication header if needed + addAuthenticationIfNeeded(&request) + + // For progress tracking, we'll use URLSession delegate + // For now, just download the whole file + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + return data + } + + func getFileInfo(path: String) async throws -> FileInfo { + guard let baseURL else { + throw APIError.noServerConfigured + } + + guard var components = URLComponents( + url: baseURL.appendingPathComponent("api/fs/info"), + resolvingAgainstBaseURL: false + ) else { + throw APIError.invalidURL + } + components.queryItems = [URLQueryItem(name: "path", value: path)] + + guard let url = components.url else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + // Add authentication header if needed + addAuthenticationIfNeeded(&request) + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + return try decoder.decode(FileInfo.self, from: data) + } } diff --git a/ios/VibeTunnel/Services/QuickLookManager.swift b/ios/VibeTunnel/Services/QuickLookManager.swift new file mode 100644 index 00000000..a57bd2b4 --- /dev/null +++ b/ios/VibeTunnel/Services/QuickLookManager.swift @@ -0,0 +1,147 @@ +import Foundation +import QuickLook +import SwiftUI + +@MainActor +class QuickLookManager: NSObject, ObservableObject { + static let shared = QuickLookManager() + + @Published var isPresenting = false + @Published var downloadProgress: Double = 0 + @Published var isDownloading = false + + private var previewItems: [QLPreviewItem] = [] + private var currentFile: FileEntry? + private let temporaryDirectory: URL + + override init() { + // Create a temporary directory for downloaded files + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("QuickLookCache", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + self.temporaryDirectory = tempDir + super.init() + + // Clean up old files on init + cleanupTemporaryFiles() + } + + func previewFile(_ file: FileEntry, apiClient: APIClient) async throws { + guard !file.isDir else { + throw QuickLookError.isDirectory + } + + currentFile = file + isDownloading = true + downloadProgress = 0 + + do { + let localURL = try await downloadFileForPreview(file: file, apiClient: apiClient) + + // Create preview item + let previewItem = PreviewItem(url: localURL, title: file.name) + previewItems = [previewItem] + + isDownloading = false + isPresenting = true + } catch { + isDownloading = false + throw error + } + } + + private func downloadFileForPreview(file: FileEntry, apiClient: APIClient) async throws -> URL { + // Check if file is already cached + let cachedURL = temporaryDirectory.appendingPathComponent(file.name) + + // For now, always download fresh (could implement proper caching later) + if FileManager.default.fileExists(atPath: cachedURL.path) { + try FileManager.default.removeItem(at: cachedURL) + } + + // Download the file + let data = try await apiClient.downloadFile(path: file.path) { progress in + Task { @MainActor in + self.downloadProgress = progress + } + } + + // Save to temporary location + try data.write(to: cachedURL) + + return cachedURL + } + + func cleanupTemporaryFiles() { + // Remove files older than 1 hour + let oneHourAgo = Date().addingTimeInterval(-3600) + + guard let files = try? FileManager.default.contentsOfDirectory(at: temporaryDirectory, includingPropertiesForKeys: [.creationDateKey]) else { + return + } + + for file in files { + if let creationDate = try? file.resourceValues(forKeys: [.creationDateKey]).creationDate, + creationDate < oneHourAgo { + try? FileManager.default.removeItem(at: file) + } + } + } + + func makePreviewController() -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = self + controller.delegate = self + return controller + } +} + +// MARK: - QLPreviewControllerDataSource +extension QuickLookManager: QLPreviewControllerDataSource { + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + previewItems.count + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + previewItems[index] + } +} + +// MARK: - QLPreviewControllerDelegate +extension QuickLookManager: QLPreviewControllerDelegate { + nonisolated func previewControllerDidDismiss(_ controller: QLPreviewController) { + Task { @MainActor in + isPresenting = false + previewItems = [] + currentFile = nil + } + } +} + +// MARK: - Preview Item +private class PreviewItem: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + + init(url: URL, title: String) { + self.previewItemURL = url + self.previewItemTitle = title + } +} + +// MARK: - Errors +enum QuickLookError: LocalizedError { + case isDirectory + case downloadFailed + case unsupportedFileType + + var errorDescription: String? { + switch self { + case .isDirectory: + return "Cannot preview directories" + case .downloadFailed: + return "Failed to download file" + case .unsupportedFileType: + return "This file type cannot be previewed" + } + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Connection/ConnectionView.swift b/ios/VibeTunnel/Views/Connection/ConnectionView.swift index 735ffdba..f84c51ce 100644 --- a/ios/VibeTunnel/Views/Connection/ConnectionView.swift +++ b/ios/VibeTunnel/Views/Connection/ConnectionView.swift @@ -6,7 +6,8 @@ import SwiftUI /// Displays the app branding and provides interface for entering /// server connection details with saved server management. struct ConnectionView: View { - @Environment(ConnectionManager.self) var connectionManager + @Environment(ConnectionManager.self) + var connectionManager @ObservedObject private var networkMonitor = NetworkMonitor.shared @State private var viewModel = ConnectionViewModel() @State private var logoScale: CGFloat = 0.8 @@ -67,8 +68,6 @@ struct ConnectionView: View { port: $viewModel.port, name: $viewModel.name, password: $viewModel.password, - authType: $viewModel.authType, - bearerToken: $viewModel.bearerToken, isConnecting: viewModel.isConnecting, errorMessage: viewModel.errorMessage, onConnect: connectToServer @@ -115,8 +114,6 @@ class ConnectionViewModel { var port: String = "4020" var name: String = "" var password: String = "" - var authType: AuthType = .none - var bearerToken: String = "" var isConnecting: Bool = false var errorMessage: String? @@ -128,8 +125,6 @@ class ConnectionViewModel { self.port = String(serverConfig.port) self.name = serverConfig.name ?? "" self.password = serverConfig.password ?? "" - self.authType = serverConfig.authType - self.bearerToken = serverConfig.bearerToken ?? "" } } @@ -153,9 +148,7 @@ class ConnectionViewModel { host: host, port: portNumber, name: name.isEmpty ? nil : name, - password: password.isEmpty ? nil : password, - authType: authType, - bearerToken: bearerToken.isEmpty ? nil : bearerToken + password: password.isEmpty ? nil : password ) do { diff --git a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift index b885beeb..9e8cdf8f 100644 --- a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift +++ b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift @@ -9,8 +9,6 @@ struct ServerConfigForm: View { @Binding var port: String @Binding var name: String @Binding var password: String - @Binding var authType: AuthType - @Binding var bearerToken: String let isConnecting: Bool let errorMessage: String? let onConnect: () -> Void @@ -24,7 +22,6 @@ struct ServerConfigForm: View { case port case name case password - case bearerToken } var body: some View { @@ -79,66 +76,20 @@ struct ServerConfigForm: View { } } - // Authentication Type + // Password Field (optional) VStack(alignment: .leading, spacing: Theme.Spacing.small) { - Label("Authentication", systemImage: "lock.shield") + Label("Password (optional)", systemImage: "lock") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.primaryAccent) - Picker("Auth Type", selection: $authType) { - ForEach(AuthType.allCases, id: \.self) { type in - Text(type.displayName).tag(type) + SecureField("Enter password", text: $password) + .textFieldStyle(TerminalTextFieldStyle()) + .focused($focusedField, equals: .password) + .submitLabel(.done) + .onSubmit { + focusedField = nil + onConnect() } - } - .pickerStyle(.segmented) - .background( - RoundedRectangle(cornerRadius: Theme.CornerRadius.small) - .stroke(Theme.Colors.cardBorder, lineWidth: 1) - ) - } - - // Password Field (for Basic Auth) - if authType == .basic { - VStack(alignment: .leading, spacing: Theme.Spacing.small) { - Label("Password", systemImage: "lock") - .font(Theme.Typography.terminalSystem(size: 12)) - .foregroundColor(Theme.Colors.primaryAccent) - - SecureField("Enter password", text: $password) - .textFieldStyle(TerminalTextFieldStyle()) - .focused($focusedField, equals: .password) - .submitLabel(.done) - .onSubmit { - focusedField = nil - onConnect() - } - } - .transition(.asymmetric( - insertion: .scale.combined(with: .opacity), - removal: .scale.combined(with: .opacity) - )) - } - - // Bearer Token Field - if authType == .bearer { - VStack(alignment: .leading, spacing: Theme.Spacing.small) { - Label("Bearer Token", systemImage: "key") - .font(Theme.Typography.terminalSystem(size: 12)) - .foregroundColor(Theme.Colors.primaryAccent) - - SecureField("Enter bearer token", text: $bearerToken) - .textFieldStyle(TerminalTextFieldStyle()) - .focused($focusedField, equals: .bearerToken) - .submitLabel(.done) - .onSubmit { - focusedField = nil - onConnect() - } - } - .transition(.asymmetric( - insertion: .scale.combined(with: .opacity), - removal: .scale.combined(with: .opacity) - )) } } .padding(.horizontal) @@ -224,8 +175,6 @@ struct ServerConfigForm: View { port = String(server.port) name = server.name ?? "" password = server.password ?? "" - authType = server.authType - bearerToken = server.bearerToken ?? "" HapticFeedback.selection() }, label: { VStack(alignment: .leading, spacing: 4) {