Various iOS improvements, remove halucinated bearer, make project sync based

This commit is contained in:
Peter Steinberger 2025-06-20 18:33:01 +02:00
parent e5a7d22bf4
commit 2a63599ce0
8 changed files with 263 additions and 134 deletions

View file

@ -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 */;
}
}

View file

@ -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
}
}

View file

@ -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)"
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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"
}
}
}

View file

@ -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 {

View file

@ -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) {