mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-10 12:05:53 +00:00
Various iOS improvements, remove halucinated bearer, make project sync based
This commit is contained in:
parent
e5a7d22bf4
commit
2a63599ce0
8 changed files with 263 additions and 134 deletions
|
|
@ -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 */;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
ios/VibeTunnel/Models/FileInfo.swift
Normal file
25
ios/VibeTunnel/Models/FileInfo.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
147
ios/VibeTunnel/Services/QuickLookManager.swift
Normal file
147
ios/VibeTunnel/Services/QuickLookManager.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue