mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
fix: CI and linting issues across all platforms
- Fix code signing in Mac and iOS test workflows - Fix all SwiftFormat and SwiftLint issues - Fix ESLint issues in web code - Remove force casts and unwrapping in Swift code - Update build scripts to use correct file paths
This commit is contained in:
parent
d72b009696
commit
baaaa5a033
61 changed files with 685 additions and 609 deletions
3
.github/workflows/mac.yml
vendored
3
.github/workflows/mac.yml
vendored
|
|
@ -206,6 +206,9 @@ jobs:
|
|||
-scheme VibeTunnel-Mac \
|
||||
-configuration Debug \
|
||||
-destination "platform=macOS" \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
| xcbeautify || {
|
||||
echo "::error::Tests failed"
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ struct VibeTunnelApp: App {
|
|||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.macCatalystWindowStyle(getStoredWindowStyle())
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private func getStoredWindowStyle() -> MacWindowStyle {
|
||||
let styleRaw = UserDefaults.standard.string(forKey: "macWindowStyle") ?? "standard"
|
||||
|
|
@ -45,7 +45,8 @@ struct VibeTunnelApp: App {
|
|||
|
||||
if url.host == "session",
|
||||
let sessionId = url.pathComponents.last,
|
||||
!sessionId.isEmpty {
|
||||
!sessionId.isEmpty
|
||||
{
|
||||
navigationManager.navigateToSession(sessionId)
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +76,8 @@ class ConnectionManager {
|
|||
|
||||
private func loadSavedConnection() {
|
||||
if let data = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let config = try? JSONDecoder().decode(ServerConfig.self, from: data) {
|
||||
let config = try? JSONDecoder().decode(ServerConfig.self, from: data)
|
||||
{
|
||||
self.serverConfig = config
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ enum AppConfig {
|
|||
/// Change this to control verbosity of logs
|
||||
static func configureLogging() {
|
||||
#if DEBUG
|
||||
// In debug builds, default to info level to reduce noise
|
||||
// Change to .verbose only when debugging binary protocol issues
|
||||
Logger.globalLevel = .info
|
||||
// In debug builds, default to info level to reduce noise
|
||||
// Change to .verbose only when debugging binary protocol issues
|
||||
Logger.globalLevel = .info
|
||||
#else
|
||||
// In release builds, only show warnings and errors
|
||||
Logger.globalLevel = .warning
|
||||
// In release builds, only show warnings and errors
|
||||
Logger.globalLevel = .warning
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,7 +181,8 @@ class CastRecorder {
|
|||
let eventArray: [Any] = [event.time, event.type, event.data]
|
||||
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: eventArray),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
let jsonString = String(data: jsonData, encoding: .utf8)
|
||||
{
|
||||
castContent += jsonString + "\n"
|
||||
}
|
||||
}
|
||||
|
|
@ -292,7 +293,7 @@ class CastPlayer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Modern async version of play that supports cancellation and error handling.
|
||||
///
|
||||
/// - Parameter onEvent: Async closure called for each event during playback.
|
||||
|
|
@ -305,12 +306,12 @@ class CastPlayer {
|
|||
for event in events {
|
||||
// Check for cancellation
|
||||
try Task.checkCancellation()
|
||||
|
||||
|
||||
// Wait for the appropriate time
|
||||
if event.time > 0 {
|
||||
try await Task.sleep(nanoseconds: UInt64(event.time * 1_000_000_000))
|
||||
}
|
||||
|
||||
|
||||
await onEvent(event)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,20 +37,20 @@ struct ServerProfile: Identifiable, Codable, Equatable {
|
|||
/// Create a ServerConfig from this profile
|
||||
func toServerConfig(password: String? = nil) -> ServerConfig? {
|
||||
guard let urlComponents = URLComponents(string: url),
|
||||
let host = urlComponents.host else {
|
||||
let host = urlComponents.host
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Determine default port based on scheme
|
||||
let defaultPort: Int
|
||||
if let scheme = urlComponents.scheme?.lowercased() {
|
||||
defaultPort = scheme == "https" ? 443 : 80
|
||||
let defaultPort: Int = if let scheme = urlComponents.scheme?.lowercased() {
|
||||
scheme == "https" ? 443 : 80
|
||||
} else {
|
||||
defaultPort = 80
|
||||
80
|
||||
}
|
||||
|
||||
|
||||
let port = urlComponents.port ?? defaultPort
|
||||
|
||||
|
||||
return ServerConfig(
|
||||
host: host,
|
||||
port: port,
|
||||
|
|
@ -68,7 +68,8 @@ extension ServerProfile {
|
|||
/// Load all saved profiles from UserDefaults
|
||||
static func loadAll() -> [ServerProfile] {
|
||||
guard let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let profiles = try? JSONDecoder().decode([ServerProfile].self, from: data) else {
|
||||
let profiles = try? JSONDecoder().decode([ServerProfile].self, from: data)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return profiles
|
||||
|
|
@ -117,7 +118,8 @@ extension ServerProfile {
|
|||
|
||||
static func suggestedName(for url: String) -> String {
|
||||
if let urlComponents = URLComponents(string: url),
|
||||
let host = urlComponents.host {
|
||||
let host = urlComponents.host
|
||||
{
|
||||
// Remove common suffixes
|
||||
let cleanHost = host
|
||||
.replacingOccurrences(of: ".local", with: "")
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ enum TerminalEvent {
|
|||
let exitString = array[0] as? String,
|
||||
exitString == "exit",
|
||||
let exitCode = array[1] as? Int,
|
||||
let sessionId = array[2] as? String {
|
||||
let sessionId = array[2] as? String
|
||||
{
|
||||
self = .exit(code: exitCode, sessionId: sessionId)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ enum TerminalRenderer: String, CaseIterable, Codable {
|
|||
static var selected: Self {
|
||||
get {
|
||||
if let rawValue = UserDefaults.standard.string(forKey: "selectedTerminalRenderer"),
|
||||
let renderer = Self(rawValue: rawValue) {
|
||||
let renderer = Self(rawValue: rawValue)
|
||||
{
|
||||
return renderer
|
||||
}
|
||||
return .swiftTerm // Default
|
||||
|
|
|
|||
|
|
@ -384,7 +384,8 @@ class APIClient: APIClientProtocol {
|
|||
// This is the header
|
||||
if let version = json["version"] as? Int,
|
||||
let width = json["width"] as? Int,
|
||||
let height = json["height"] as? Int {
|
||||
let height = json["height"] as? Int
|
||||
{
|
||||
header = AsciinemaHeader(
|
||||
version: version,
|
||||
width: width,
|
||||
|
|
@ -401,7 +402,8 @@ class APIClient: APIClientProtocol {
|
|||
if json.count >= 3,
|
||||
let timestamp = json[0] as? Double,
|
||||
let typeStr = json[1] as? String,
|
||||
let eventData = json[2] as? String {
|
||||
let eventData = json[2] as? String
|
||||
{
|
||||
let eventType: AsciinemaEvent.EventType
|
||||
switch typeStr {
|
||||
case "o": eventType = .output
|
||||
|
|
@ -479,7 +481,8 @@ class APIClient: APIClientProtocol {
|
|||
showHidden: Bool = false,
|
||||
gitFilter: String = "all"
|
||||
)
|
||||
async throws -> DirectoryListing {
|
||||
async throws -> DirectoryListing
|
||||
{
|
||||
guard let baseURL else {
|
||||
throw APIError.noServerConfigured
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ enum WebSocketError: Error {
|
|||
@Observable
|
||||
class BufferWebSocketClient: NSObject {
|
||||
static let shared = BufferWebSocketClient()
|
||||
|
||||
|
||||
private let logger = Logger(category: "BufferWebSocket")
|
||||
/// Magic byte for binary messages
|
||||
private static let bufferMagicByte: UInt8 = 0xBF
|
||||
|
|
@ -114,7 +114,8 @@ class BufferWebSocketClient: NSObject {
|
|||
// Add authentication header if needed
|
||||
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config),
|
||||
let authHeader = serverConfig.authorizationHeader {
|
||||
let authHeader = serverConfig.authorizationHeader
|
||||
{
|
||||
headers["Authorization"] = authHeader
|
||||
}
|
||||
|
||||
|
|
@ -211,7 +212,8 @@ class BufferWebSocketClient: NSObject {
|
|||
|
||||
// Decode terminal event
|
||||
if let event = decodeTerminalEvent(from: messageData),
|
||||
let handler = subscriptions[sessionId] {
|
||||
let handler = subscriptions[sessionId]
|
||||
{
|
||||
logger.verbose("Dispatching event to handler")
|
||||
handler(event)
|
||||
} else {
|
||||
|
|
@ -598,14 +600,16 @@ class BufferWebSocketClient: NSObject {
|
|||
(0xFF00...0xFF60).contains(value) || // Fullwidth Forms
|
||||
(0xFFE0...0xFFE6).contains(value) || // Fullwidth Forms
|
||||
(0x20000...0x2FFFD).contains(value) || // CJK Extension B-F
|
||||
(0x30000...0x3FFFD).contains(value) { // CJK Extension G
|
||||
(0x30000...0x3FFFD).contains(value)
|
||||
{ // CJK Extension G
|
||||
return 2
|
||||
}
|
||||
|
||||
// Zero-width characters
|
||||
if (0x200B...0x200F).contains(value) || // Zero-width spaces
|
||||
(0xFE00...0xFE0F).contains(value) || // Variation selectors
|
||||
scalar.properties.isJoinControl {
|
||||
scalar.properties.isJoinControl
|
||||
{
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,30 +4,30 @@ import Security
|
|||
/// Service for securely storing credentials in the iOS Keychain
|
||||
enum KeychainService {
|
||||
private static let serviceName = "com.vibetunnel.ios"
|
||||
|
||||
|
||||
enum KeychainError: Error {
|
||||
case unexpectedData
|
||||
case unexpectedPasswordData
|
||||
case unhandledError(status: OSStatus)
|
||||
case itemNotFound
|
||||
}
|
||||
|
||||
|
||||
/// Save a password for a server profile
|
||||
static func savePassword(_ password: String, for profileId: UUID) throws {
|
||||
let account = "server-\(profileId.uuidString)"
|
||||
guard let passwordData = password.data(using: .utf8) else {
|
||||
throw KeychainError.unexpectedPasswordData
|
||||
}
|
||||
|
||||
|
||||
// Check if password already exists
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
|
||||
|
||||
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||
|
||||
|
||||
if status == errSecItemNotFound {
|
||||
// Add new password
|
||||
let attributes: [String: Any] = [
|
||||
|
|
@ -37,7 +37,7 @@ enum KeychainService {
|
|||
kSecValueData as String: passwordData,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
]
|
||||
|
||||
|
||||
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw KeychainError.unhandledError(status: addStatus)
|
||||
|
|
@ -47,7 +47,7 @@ enum KeychainService {
|
|||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: passwordData
|
||||
]
|
||||
|
||||
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw KeychainError.unhandledError(status: updateStatus)
|
||||
|
|
@ -56,11 +56,11 @@ enum KeychainService {
|
|||
throw KeychainError.unhandledError(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Retrieve a password for a server profile
|
||||
static func getPassword(for profileId: UUID) throws -> String {
|
||||
let account = "server-\(profileId.uuidString)"
|
||||
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
|
|
@ -68,48 +68,49 @@ enum KeychainService {
|
|||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
|
||||
guard status == errSecSuccess else {
|
||||
if status == errSecItemNotFound {
|
||||
throw KeychainError.itemNotFound
|
||||
}
|
||||
throw KeychainError.unhandledError(status: status)
|
||||
}
|
||||
|
||||
|
||||
guard let data = result as? Data,
|
||||
let password = String(data: data, encoding: .utf8) else {
|
||||
let password = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
throw KeychainError.unexpectedData
|
||||
}
|
||||
|
||||
|
||||
return password
|
||||
}
|
||||
|
||||
|
||||
/// Delete a password for a server profile
|
||||
static func deletePassword(for profileId: UUID) throws {
|
||||
let account = "server-\(profileId.uuidString)"
|
||||
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.unhandledError(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Delete all passwords for the app
|
||||
static func deleteAllPasswords() throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName
|
||||
]
|
||||
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.unhandledError(status: status)
|
||||
|
|
|
|||
|
|
@ -9,25 +9,25 @@ import SwiftUI
|
|||
@Observable
|
||||
final class LivePreviewManager {
|
||||
static let shared = LivePreviewManager()
|
||||
|
||||
|
||||
private let logger = Logger(category: "LivePreviewManager")
|
||||
private let bufferClient = BufferWebSocketClient.shared
|
||||
private var subscriptions: [String: LivePreviewSubscription] = [:]
|
||||
private var updateTimers: [String: Timer] = [:]
|
||||
|
||||
|
||||
/// Maximum number of concurrent live previews
|
||||
private let maxConcurrentPreviews = 6
|
||||
|
||||
|
||||
/// Update interval for previews (in seconds)
|
||||
private let updateInterval: TimeInterval = 1.0
|
||||
|
||||
|
||||
private init() {
|
||||
// Ensure WebSocket is connected when manager is created
|
||||
if !bufferClient.isConnected {
|
||||
bufferClient.connect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Subscribe to live updates for a session.
|
||||
func subscribe(to sessionId: String) -> LivePreviewSubscription {
|
||||
// Check if we already have a subscription
|
||||
|
|
@ -35,30 +35,30 @@ final class LivePreviewManager {
|
|||
existing.referenceCount += 1
|
||||
return existing
|
||||
}
|
||||
|
||||
|
||||
// Create new subscription
|
||||
let subscription = LivePreviewSubscription(sessionId: sessionId)
|
||||
subscriptions[sessionId] = subscription
|
||||
|
||||
|
||||
// Manage concurrent preview limit
|
||||
if subscriptions.count > maxConcurrentPreviews {
|
||||
// Remove oldest subscriptions that have no references
|
||||
let sortedSubs = subscriptions.values
|
||||
.filter { $0.referenceCount == 0 }
|
||||
.sorted { $0.subscriptionTime < $1.subscriptionTime }
|
||||
|
||||
|
||||
if let oldest = sortedSubs.first {
|
||||
unsubscribe(from: oldest.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set up WebSocket subscription with throttling
|
||||
var lastUpdateTime: Date = .distantPast
|
||||
var pendingSnapshot: BufferSnapshot?
|
||||
|
||||
|
||||
bufferClient.subscribe(to: sessionId) { [weak self, weak subscription] event in
|
||||
guard let self, let subscription else { return }
|
||||
|
||||
|
||||
Task { @MainActor in
|
||||
switch event {
|
||||
case .bufferUpdate(let snapshot):
|
||||
|
|
@ -72,59 +72,60 @@ final class LivePreviewManager {
|
|||
} else {
|
||||
// Store pending update
|
||||
pendingSnapshot = snapshot
|
||||
|
||||
|
||||
// Schedule delayed update if not already scheduled
|
||||
if self.updateTimers[sessionId] == nil {
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: self.updateInterval, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
if let pending = pendingSnapshot {
|
||||
subscription.latestSnapshot = pending
|
||||
subscription.lastUpdate = Date()
|
||||
pendingSnapshot = nil
|
||||
let timer = Timer
|
||||
.scheduledTimer(withTimeInterval: self.updateInterval, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
if let pending = pendingSnapshot {
|
||||
subscription.latestSnapshot = pending
|
||||
subscription.lastUpdate = Date()
|
||||
pendingSnapshot = nil
|
||||
}
|
||||
self.updateTimers.removeValue(forKey: sessionId)
|
||||
}
|
||||
self.updateTimers.removeValue(forKey: sessionId)
|
||||
}
|
||||
}
|
||||
self.updateTimers[sessionId] = timer
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
case .exit:
|
||||
subscription.isSessionActive = false
|
||||
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
|
||||
/// Unsubscribe from a session's live updates.
|
||||
func unsubscribe(from sessionId: String) {
|
||||
guard let subscription = subscriptions[sessionId] else { return }
|
||||
|
||||
|
||||
subscription.referenceCount -= 1
|
||||
|
||||
|
||||
if subscription.referenceCount <= 0 {
|
||||
// Clean up
|
||||
updateTimers[sessionId]?.invalidate()
|
||||
updateTimers.removeValue(forKey: sessionId)
|
||||
bufferClient.unsubscribe(from: sessionId)
|
||||
subscriptions.removeValue(forKey: sessionId)
|
||||
|
||||
|
||||
logger.debug("Unsubscribed from session: \(sessionId)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Clean up all subscriptions.
|
||||
func cleanup() {
|
||||
for timer in updateTimers.values {
|
||||
timer.invalidate()
|
||||
}
|
||||
updateTimers.removeAll()
|
||||
|
||||
|
||||
for sessionId in subscriptions.keys {
|
||||
bufferClient.unsubscribe(from: sessionId)
|
||||
}
|
||||
|
|
@ -138,12 +139,12 @@ final class LivePreviewManager {
|
|||
final class LivePreviewSubscription {
|
||||
let sessionId: String
|
||||
let subscriptionTime = Date()
|
||||
|
||||
|
||||
var latestSnapshot: BufferSnapshot?
|
||||
var lastUpdate = Date()
|
||||
var isSessionActive = true
|
||||
var referenceCount = 1
|
||||
|
||||
|
||||
init(sessionId: String) {
|
||||
self.sessionId = sessionId
|
||||
}
|
||||
|
|
@ -153,9 +154,9 @@ final class LivePreviewSubscription {
|
|||
struct LivePreviewModifier: ViewModifier {
|
||||
let sessionId: String
|
||||
let isEnabled: Bool
|
||||
|
||||
|
||||
@State private var subscription: LivePreviewSubscription?
|
||||
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear {
|
||||
|
|
@ -173,7 +174,7 @@ struct LivePreviewModifier: ViewModifier {
|
|||
}
|
||||
}
|
||||
|
||||
// Environment key for passing subscription down the view hierarchy
|
||||
/// Environment key for passing subscription down the view hierarchy
|
||||
private struct LivePreviewSubscriptionKey: EnvironmentKey {
|
||||
static let defaultValue: LivePreviewSubscription? = nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ class QuickLookManager: NSObject, ObservableObject {
|
|||
|
||||
for file in files {
|
||||
if let creationDate = try? file.resourceValues(forKeys: [.creationDateKey]).creationDate,
|
||||
creationDate < oneHourAgo {
|
||||
creationDate < oneHourAgo
|
||||
{
|
||||
try? FileManager.default.removeItem(at: file)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,16 @@ class ReconnectionManager {
|
|||
private let maxRetries = 5
|
||||
private var currentRetry = 0
|
||||
private var reconnectionTask: Task<Void, Never>?
|
||||
|
||||
|
||||
var isReconnecting = false
|
||||
var nextRetryTime: Date?
|
||||
var lastError: Error?
|
||||
|
||||
|
||||
init(connectionManager: ConnectionManager) {
|
||||
self.connectionManager = connectionManager
|
||||
setupNetworkMonitoring()
|
||||
}
|
||||
|
||||
|
||||
private func setupNetworkMonitoring() {
|
||||
// Listen for network changes
|
||||
NotificationCenter.default.addObserver(
|
||||
|
|
@ -28,7 +28,7 @@ class ReconnectionManager {
|
|||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@objc
|
||||
private func networkStatusChanged() {
|
||||
if NetworkMonitor.shared.isConnected && !connectionManager.isConnected {
|
||||
|
|
@ -36,21 +36,21 @@ class ReconnectionManager {
|
|||
startReconnection()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func startReconnection() {
|
||||
guard !isReconnecting,
|
||||
let serverConfig = connectionManager.serverConfig else { return }
|
||||
|
||||
|
||||
isReconnecting = true
|
||||
currentRetry = 0
|
||||
lastError = nil
|
||||
|
||||
|
||||
reconnectionTask?.cancel()
|
||||
reconnectionTask = Task {
|
||||
await performReconnection(config: serverConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func stopReconnection() {
|
||||
isReconnecting = false
|
||||
currentRetry = 0
|
||||
|
|
@ -58,7 +58,7 @@ class ReconnectionManager {
|
|||
reconnectionTask?.cancel()
|
||||
reconnectionTask = nil
|
||||
}
|
||||
|
||||
|
||||
private func performReconnection(config: ServerConfig) async {
|
||||
while isReconnecting && currentRetry < maxRetries {
|
||||
// Check if we still have network
|
||||
|
|
@ -67,41 +67,41 @@ class ReconnectionManager {
|
|||
try? await Task.sleep(for: .seconds(5))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
// Attempt connection
|
||||
_ = try await APIClient.shared.getSessions()
|
||||
|
||||
|
||||
// Success!
|
||||
connectionManager.isConnected = true
|
||||
isReconnecting = false
|
||||
currentRetry = 0
|
||||
nextRetryTime = nil
|
||||
lastError = nil
|
||||
|
||||
|
||||
// Update last connection time
|
||||
connectionManager.saveConnection(config)
|
||||
|
||||
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
currentRetry += 1
|
||||
|
||||
|
||||
if currentRetry < maxRetries {
|
||||
// Calculate exponential backoff
|
||||
let backoffSeconds = min(pow(2.0, Double(currentRetry - 1)), 60.0)
|
||||
nextRetryTime = Date().addingTimeInterval(backoffSeconds)
|
||||
|
||||
|
||||
try? await Task.sleep(for: .seconds(backoffSeconds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Max retries reached
|
||||
isReconnecting = false
|
||||
connectionManager.disconnect()
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
|
@ -111,7 +111,13 @@ class ReconnectionManager {
|
|||
|
||||
extension ReconnectionManager {
|
||||
/// Calculate the next retry delay using exponential backoff
|
||||
static func calculateBackoff(attempt: Int, baseDelay: TimeInterval = 1.0, maxDelay: TimeInterval = 60.0) -> TimeInterval {
|
||||
static func calculateBackoff(
|
||||
attempt: Int,
|
||||
baseDelay: TimeInterval = 1.0,
|
||||
maxDelay: TimeInterval = 60.0
|
||||
)
|
||||
-> TimeInterval
|
||||
{
|
||||
let exponentialDelay = baseDelay * pow(2.0, Double(attempt - 1))
|
||||
return min(exponentialDelay, maxDelay)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,13 +117,15 @@ final class SSEClient: NSObject, @unchecked Sendable {
|
|||
// Check for exit event
|
||||
if let firstElement = array[0] as? String, firstElement == "exit",
|
||||
let exitCode = array[1] as? Int,
|
||||
let sessionId = array[2] as? String {
|
||||
let sessionId = array[2] as? String
|
||||
{
|
||||
delegate?.sseClient(self, didReceiveEvent: .exit(exitCode: exitCode, sessionId: sessionId))
|
||||
}
|
||||
// Regular terminal output
|
||||
else if let timestamp = array[0] as? Double,
|
||||
let type = array[1] as? String,
|
||||
let outputData = array[2] as? String {
|
||||
let outputData = array[2] as? String
|
||||
{
|
||||
delegate?.sseClient(
|
||||
self,
|
||||
didReceiveEvent: .terminalOutput(timestamp: timestamp, type: type, data: outputData)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import SwiftUI
|
|||
struct ErrorAlertModifier: ViewModifier {
|
||||
@Binding var error: Error?
|
||||
let onDismiss: (() -> Void)?
|
||||
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert(
|
||||
|
|
@ -29,7 +29,9 @@ extension View {
|
|||
func errorAlert(
|
||||
error: Binding<Error?>,
|
||||
onDismiss: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
)
|
||||
-> some View
|
||||
{
|
||||
modifier(ErrorAlertModifier(error: error, onDismiss: onDismiss))
|
||||
}
|
||||
}
|
||||
|
|
@ -70,22 +72,22 @@ extension APIError: RecoverableError {
|
|||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .noServerConfigured:
|
||||
return "Please configure a server connection in Settings."
|
||||
"Please configure a server connection in Settings."
|
||||
case .networkError:
|
||||
return "Check your internet connection and try again."
|
||||
"Check your internet connection and try again."
|
||||
case .serverError(let code, _):
|
||||
switch code {
|
||||
case 401:
|
||||
return "Check your authentication credentials in Settings."
|
||||
"Check your authentication credentials in Settings."
|
||||
case 500...599:
|
||||
return "The server is experiencing issues. Please try again later."
|
||||
"The server is experiencing issues. Please try again later."
|
||||
default:
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
case .resizeDisabledByServer:
|
||||
return "Terminal resizing is not supported by this server."
|
||||
"Terminal resizing is not supported by this server."
|
||||
default:
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,7 +99,7 @@ struct ErrorBanner: View {
|
|||
let message: String
|
||||
let isOffline: Bool
|
||||
let onDismiss: (() -> Void)?
|
||||
|
||||
|
||||
init(
|
||||
message: String,
|
||||
isOffline: Bool = false,
|
||||
|
|
@ -107,18 +109,18 @@ struct ErrorBanner: View {
|
|||
self.isOffline = isOffline
|
||||
self.onDismiss = onDismiss
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Image(systemName: isOffline ? "wifi.exclamationmark" : "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 14))
|
||||
|
||||
|
||||
Text(message)
|
||||
.font(Theme.Typography.terminalSystem(size: 13))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
if let onDismiss {
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
|
|
@ -151,7 +153,9 @@ extension Task where Failure == Error {
|
|||
priority: TaskPriority? = nil,
|
||||
errorHandler: @escaping @Sendable (Error) -> Void,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) -> Task<T, Error> {
|
||||
)
|
||||
-> Task<T, Error>
|
||||
{
|
||||
Task<T, Error>(priority: priority) {
|
||||
do {
|
||||
return try await operation()
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ struct Logger {
|
|||
|
||||
// Global log level - only messages at this level or higher will be printed
|
||||
#if DEBUG
|
||||
nonisolated(unsafe) static var globalLevel: LogLevel = .info // Default to info level in debug builds
|
||||
nonisolated(unsafe) static var globalLevel: LogLevel = .info // Default to info level in debug builds
|
||||
#else
|
||||
nonisolated(unsafe) static var globalLevel: LogLevel = .warning // Only warnings and errors in release
|
||||
nonisolated(unsafe) static var globalLevel: LogLevel = .warning // Only warnings and errors in release
|
||||
#endif
|
||||
|
||||
init(category: String) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import SwiftUI
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
import Dynamic
|
||||
import UIKit
|
||||
|
||||
// MARK: - Window Style
|
||||
|
||||
enum MacWindowStyle {
|
||||
case standard // Normal title bar with traffic lights
|
||||
case inline // Hidden title bar with repositioned traffic lights
|
||||
case standard // Normal title bar with traffic lights
|
||||
case inline // Hidden title bar with repositioned traffic lights
|
||||
}
|
||||
|
||||
// MARK: - UIWindow Extension
|
||||
|
|
@ -26,49 +26,50 @@ extension UIWindow {
|
|||
@MainActor
|
||||
class MacCatalystWindowManager: ObservableObject {
|
||||
static let shared = MacCatalystWindowManager()
|
||||
|
||||
|
||||
@Published var windowStyle: MacWindowStyle = .standard
|
||||
|
||||
|
||||
private var window: UIWindow?
|
||||
private var windowResizeObserver: NSObjectProtocol?
|
||||
private var windowDidBecomeKeyObserver: NSObjectProtocol?
|
||||
private let logger = Logger(category: "MacCatalystWindow")
|
||||
|
||||
|
||||
// Traffic light button configuration
|
||||
private let trafficLightInset = CGPoint(x: 20, y: 20)
|
||||
private let trafficLightSpacing: CGFloat = 20
|
||||
|
||||
|
||||
private init() {}
|
||||
|
||||
|
||||
/// Configure the window with the specified style
|
||||
func configureWindow(_ window: UIWindow, style: MacWindowStyle) {
|
||||
self.window = window
|
||||
self.windowStyle = style
|
||||
|
||||
|
||||
// Wait for window to be fully initialized
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
self.applyWindowStyle(style)
|
||||
}
|
||||
|
||||
|
||||
// Observe window events
|
||||
setupWindowObservers()
|
||||
}
|
||||
|
||||
|
||||
/// Switch between window styles at runtime
|
||||
func setWindowStyle(_ style: MacWindowStyle) {
|
||||
windowStyle = style
|
||||
applyWindowStyle(style)
|
||||
}
|
||||
|
||||
|
||||
private func applyWindowStyle(_ style: MacWindowStyle) {
|
||||
guard let window = window,
|
||||
let nsWindow = window.nsWindow else {
|
||||
guard let window,
|
||||
let nsWindow = window.nsWindow
|
||||
else {
|
||||
logger.warning("Unable to access NSWindow")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let dynamic = Dynamic(nsWindow)
|
||||
|
||||
|
||||
switch style {
|
||||
case .standard:
|
||||
applyStandardStyle(dynamic)
|
||||
|
|
@ -76,70 +77,79 @@ class MacCatalystWindowManager: ObservableObject {
|
|||
applyInlineStyle(dynamic, window: window)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func applyStandardStyle(_ nsWindow: Dynamic) {
|
||||
logger.info("Applying standard window style")
|
||||
|
||||
|
||||
// Show title bar
|
||||
nsWindow.titlebarAppearsTransparent = false
|
||||
nsWindow.titleVisibility = Dynamic.NSWindowTitleVisibility.visible
|
||||
nsWindow.styleMask = nsWindow.styleMask.asObject! as! UInt | Dynamic.NSWindowStyleMask.titled.asObject! as! UInt
|
||||
|
||||
guard let currentMask = nsWindow.styleMask.asObject as? UInt,
|
||||
let titledMask = Dynamic.NSWindowStyleMask.titled.asObject as? UInt else {
|
||||
logger.error("Failed to get window style masks")
|
||||
return
|
||||
}
|
||||
nsWindow.styleMask = currentMask | titledMask
|
||||
|
||||
// Reset traffic light positions
|
||||
resetTrafficLightPositions(nsWindow)
|
||||
|
||||
|
||||
// Show all buttons
|
||||
for i in 0...2 {
|
||||
let button = nsWindow.standardWindowButton(i)
|
||||
button.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func applyInlineStyle(_ nsWindow: Dynamic, window: UIWindow) {
|
||||
logger.info("Applying inline window style")
|
||||
|
||||
|
||||
// Make title bar transparent and hide title
|
||||
nsWindow.titlebarAppearsTransparent = true
|
||||
nsWindow.titleVisibility = Dynamic.NSWindowTitleVisibility.hidden
|
||||
nsWindow.backgroundColor = Dynamic.NSColor.clearColor
|
||||
|
||||
|
||||
// Keep the titled style mask to preserve traffic lights
|
||||
let currentMask = nsWindow.styleMask.asObject! as! UInt
|
||||
nsWindow.styleMask = currentMask | Dynamic.NSWindowStyleMask.titled.asObject! as! UInt
|
||||
|
||||
guard let currentMask = nsWindow.styleMask.asObject as? UInt,
|
||||
let titledMask = Dynamic.NSWindowStyleMask.titled.asObject as? UInt else {
|
||||
logger.error("Failed to get window style masks")
|
||||
return
|
||||
}
|
||||
nsWindow.styleMask = currentMask | titledMask
|
||||
|
||||
// Reposition traffic lights
|
||||
repositionTrafficLights(nsWindow, window: window)
|
||||
}
|
||||
|
||||
|
||||
private func repositionTrafficLights(_ nsWindow: Dynamic, window: UIWindow) {
|
||||
// Access the buttons (0=close, 1=minimize, 2=zoom)
|
||||
let closeButton = nsWindow.standardWindowButton(0)
|
||||
let minButton = nsWindow.standardWindowButton(1)
|
||||
let zoomButton = nsWindow.standardWindowButton(2)
|
||||
|
||||
|
||||
// Get button size
|
||||
let buttonFrame = closeButton.frame
|
||||
let buttonSize = (buttonFrame.size.width.asDouble ?? 14.0) as CGFloat
|
||||
|
||||
|
||||
// Calculate positions
|
||||
let yPosition = window.frame.height - trafficLightInset.y - buttonSize
|
||||
|
||||
|
||||
// Set new positions
|
||||
closeButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x, yPosition))
|
||||
minButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x + trafficLightSpacing, yPosition))
|
||||
zoomButton.setFrameOrigin(Dynamic.NSMakePoint(trafficLightInset.x + (trafficLightSpacing * 2), yPosition))
|
||||
|
||||
|
||||
// Make sure buttons are visible
|
||||
closeButton.isHidden = false
|
||||
minButton.isHidden = false
|
||||
zoomButton.isHidden = false
|
||||
|
||||
|
||||
// Update tracking areas for hover effects
|
||||
updateTrafficLightTrackingAreas(nsWindow)
|
||||
|
||||
|
||||
logger.debug("Repositioned traffic lights to inline positions")
|
||||
}
|
||||
|
||||
|
||||
private func resetTrafficLightPositions(_ nsWindow: Dynamic) {
|
||||
// Get the superview of the traffic lights
|
||||
let closeButton = nsWindow.standardWindowButton(0)
|
||||
|
|
@ -149,31 +159,36 @@ class MacCatalystWindowManager: ObservableObject {
|
|||
superview.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func updateTrafficLightTrackingAreas(_ nsWindow: Dynamic) {
|
||||
// Update tracking areas for each button to ensure hover effects work
|
||||
for i in 0...2 {
|
||||
let button = nsWindow.standardWindowButton(i)
|
||||
|
||||
|
||||
// Remove old tracking areas
|
||||
if let trackingAreas = button.trackingAreas {
|
||||
for area in trackingAreas.asArray ?? [] {
|
||||
button.removeTrackingArea(area)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add new tracking area at the button's current position
|
||||
let trackingRect = button.bounds
|
||||
let options = Dynamic.NSTrackingAreaOptions.mouseEnteredAndExited.asObject! as! UInt |
|
||||
Dynamic.NSTrackingAreaOptions.activeAlways.asObject! as! UInt
|
||||
|
||||
guard let mouseEnteredAndExited = Dynamic.NSTrackingAreaOptions.mouseEnteredAndExited.asObject as? UInt,
|
||||
let activeAlways = Dynamic.NSTrackingAreaOptions.activeAlways.asObject as? UInt
|
||||
else {
|
||||
logger.error("Failed to get tracking area options")
|
||||
return
|
||||
}
|
||||
let options = mouseEnteredAndExited | activeAlways
|
||||
|
||||
let trackingArea = Dynamic.NSTrackingArea.alloc()
|
||||
.initWithRect(trackingRect, options: options, owner: button, userInfo: nil)
|
||||
|
||||
|
||||
button.addTrackingArea(trackingArea)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func setupWindowObservers() {
|
||||
// Clean up existing observers
|
||||
if let observer = windowResizeObserver {
|
||||
|
|
@ -182,58 +197,59 @@ class MacCatalystWindowManager: ObservableObject {
|
|||
if let observer = windowDidBecomeKeyObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
|
||||
|
||||
// Observe window resize events
|
||||
windowResizeObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("NSWindowDidResizeNotification"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let self = self,
|
||||
guard let self,
|
||||
self.windowStyle == .inline,
|
||||
let window = self.window,
|
||||
let notificationWindow = notification.object as? NSObject,
|
||||
let currentNSWindow = window.nsWindow,
|
||||
notificationWindow == currentNSWindow else { return }
|
||||
|
||||
|
||||
// Reapply inline style on resize
|
||||
DispatchQueue.main.async {
|
||||
self.applyWindowStyle(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Observe window becoming key
|
||||
windowDidBecomeKeyObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIWindow.didBecomeKeyNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self,
|
||||
guard let self,
|
||||
self.windowStyle == .inline else { return }
|
||||
|
||||
|
||||
// Reapply inline style when window becomes key
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.applyWindowStyle(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also observe the NS notification for tracking area updates
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("NSViewDidUpdateTrackingAreasNotification"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self,
|
||||
guard let self,
|
||||
self.windowStyle == .inline else { return }
|
||||
|
||||
|
||||
// Reposition if needed
|
||||
if let window = self.window,
|
||||
let nsWindow = window.nsWindow {
|
||||
let nsWindow = window.nsWindow
|
||||
{
|
||||
self.repositionTrafficLights(Dynamic(nsWindow), window: window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
if let observer = windowResizeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
|
|
@ -249,20 +265,21 @@ class MacCatalystWindowManager: ObservableObject {
|
|||
struct MacCatalystWindowStyle: ViewModifier {
|
||||
let style: MacWindowStyle
|
||||
@StateObject private var windowManager = MacCatalystWindowManager.shared
|
||||
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear {
|
||||
setupWindow()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func setupWindow() {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first else {
|
||||
let window = windowScene.windows.first
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
windowManager.configureWindow(window, style: style)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,44 +8,44 @@ class ServerProfilesViewModel {
|
|||
var profiles: [ServerProfile] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
|
||||
init() {
|
||||
loadProfiles()
|
||||
}
|
||||
|
||||
|
||||
func loadProfiles() {
|
||||
profiles = ServerProfile.loadAll().sorted { profile1, profile2 in
|
||||
// Sort by last connected (most recent first), then by name
|
||||
if let date1 = profile1.lastConnected, let date2 = profile2.lastConnected {
|
||||
return date1 > date2
|
||||
date1 > date2
|
||||
} else if profile1.lastConnected != nil {
|
||||
return true
|
||||
true
|
||||
} else if profile2.lastConnected != nil {
|
||||
return false
|
||||
false
|
||||
} else {
|
||||
return profile1.name < profile2.name
|
||||
profile1.name < profile2.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func addProfile(_ profile: ServerProfile, password: String? = nil) async throws {
|
||||
ServerProfile.save(profile)
|
||||
|
||||
|
||||
// Save password to keychain if provided
|
||||
if let password = password, !password.isEmpty {
|
||||
if let password, !password.isEmpty {
|
||||
try KeychainService.savePassword(password, for: profile.id)
|
||||
}
|
||||
|
||||
|
||||
loadProfiles()
|
||||
}
|
||||
|
||||
|
||||
func updateProfile(_ profile: ServerProfile, password: String? = nil) async throws {
|
||||
var updatedProfile = profile
|
||||
updatedProfile.updatedAt = Date()
|
||||
ServerProfile.save(updatedProfile)
|
||||
|
||||
|
||||
// Update password if provided
|
||||
if let password = password {
|
||||
if let password {
|
||||
if password.isEmpty {
|
||||
// Delete password if empty
|
||||
try KeychainService.deletePassword(for: profile.id)
|
||||
|
|
@ -54,19 +54,19 @@ class ServerProfilesViewModel {
|
|||
try KeychainService.savePassword(password, for: profile.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
loadProfiles()
|
||||
}
|
||||
|
||||
|
||||
func deleteProfile(_ profile: ServerProfile) async throws {
|
||||
ServerProfile.delete(profile)
|
||||
|
||||
|
||||
// Delete password from keychain
|
||||
try KeychainService.deletePassword(for: profile.id)
|
||||
|
||||
|
||||
loadProfiles()
|
||||
}
|
||||
|
||||
|
||||
func getPassword(for profile: ServerProfile) -> String? {
|
||||
do {
|
||||
return try KeychainService.getPassword(for: profile.id)
|
||||
|
|
@ -75,29 +75,29 @@ class ServerProfilesViewModel {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func connectToProfile(_ profile: ServerProfile, connectionManager: ConnectionManager) async throws {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
|
||||
defer { isLoading = false }
|
||||
|
||||
|
||||
// Get password from keychain if needed
|
||||
let password = profile.requiresAuth ? getPassword(for: profile) : nil
|
||||
|
||||
|
||||
// Create server config
|
||||
guard let config = profile.toServerConfig(password: password) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
|
||||
// Save connection
|
||||
connectionManager.saveConnection(config)
|
||||
|
||||
|
||||
// Test connection
|
||||
do {
|
||||
_ = try await APIClient.shared.getSessions()
|
||||
connectionManager.isConnected = true
|
||||
|
||||
|
||||
// Update last connected time
|
||||
ServerProfile.updateLastConnected(for: profile.id)
|
||||
loadProfiles()
|
||||
|
|
@ -106,17 +106,17 @@ class ServerProfilesViewModel {
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func testConnection(for profile: ServerProfile) async -> Bool {
|
||||
let password = profile.requiresAuth ? getPassword(for: profile) : nil
|
||||
guard let config = profile.toServerConfig(password: password) else {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Save the config temporarily to test
|
||||
let connectionManager = ConnectionManager()
|
||||
connectionManager.saveConnection(config)
|
||||
|
||||
|
||||
do {
|
||||
_ = try await APIClient.shared.getSessions()
|
||||
return true
|
||||
|
|
@ -132,21 +132,22 @@ extension ServerProfilesViewModel {
|
|||
func createProfileFromURL(_ urlString: String) -> ServerProfile? {
|
||||
// Clean up the URL
|
||||
var cleanURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
|
||||
// Add http:// if no scheme is present
|
||||
if !cleanURL.contains("://") {
|
||||
cleanURL = "http://\(cleanURL)"
|
||||
}
|
||||
|
||||
|
||||
// Validate URL
|
||||
guard let url = URL(string: cleanURL),
|
||||
let _ = url.host else {
|
||||
let _ = url.host
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Generate suggested name
|
||||
let suggestedName = ServerProfile.suggestedName(for: cleanURL)
|
||||
|
||||
|
||||
return ServerProfile(
|
||||
name: suggestedName,
|
||||
url: cleanURL,
|
||||
|
|
|
|||
|
|
@ -121,7 +121,8 @@ class ConnectionViewModel {
|
|||
|
||||
func loadLastConnection() {
|
||||
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) {
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config)
|
||||
{
|
||||
self.host = serverConfig.host
|
||||
self.port = String(serverConfig.port)
|
||||
self.name = serverConfig.name ?? ""
|
||||
|
|
@ -162,7 +163,8 @@ class ConnectionViewModel {
|
|||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 {
|
||||
httpResponse.statusCode == 200
|
||||
{
|
||||
onSuccess(config)
|
||||
} else {
|
||||
errorMessage = "Failed to connect to server"
|
||||
|
|
|
|||
|
|
@ -12,53 +12,53 @@ struct EnhancedConnectionView: View {
|
|||
@State private var showingNewServerForm = false
|
||||
@State private var selectedProfile: ServerProfile?
|
||||
@State private var showingProfileEditor = false
|
||||
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@StateObject private var windowManager = MacCatalystWindowManager.shared
|
||||
#endif
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.extraLarge) {
|
||||
// Logo and Title
|
||||
headerView
|
||||
.padding(.top, {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
return windowManager.windowStyle == .inline ? 60 : 40
|
||||
#else
|
||||
return 40
|
||||
#endif
|
||||
}())
|
||||
|
||||
// Quick Connect Section
|
||||
if !profilesViewModel.profiles.isEmpty && !showingNewServerForm {
|
||||
quickConnectSection
|
||||
.opacity(contentOpacity)
|
||||
.onAppear {
|
||||
withAnimation(Theme.Animation.smooth.delay(0.3)) {
|
||||
contentOpacity = 1.0
|
||||
VStack(spacing: Theme.Spacing.extraLarge) {
|
||||
// Logo and Title
|
||||
headerView
|
||||
.padding(.top, {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
return windowManager.windowStyle == .inline ? 60 : 40
|
||||
#else
|
||||
return 40
|
||||
#endif
|
||||
}())
|
||||
|
||||
// Quick Connect Section
|
||||
if !profilesViewModel.profiles.isEmpty && !showingNewServerForm {
|
||||
quickConnectSection
|
||||
.opacity(contentOpacity)
|
||||
.onAppear {
|
||||
withAnimation(Theme.Animation.smooth.delay(0.3)) {
|
||||
contentOpacity = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New Connection Form
|
||||
if showingNewServerForm || profilesViewModel.profiles.isEmpty {
|
||||
newConnectionSection
|
||||
.opacity(contentOpacity)
|
||||
.onAppear {
|
||||
withAnimation(Theme.Animation.smooth.delay(0.3)) {
|
||||
contentOpacity = 1.0
|
||||
}
|
||||
|
||||
// New Connection Form
|
||||
if showingNewServerForm || profilesViewModel.profiles.isEmpty {
|
||||
newConnectionSection
|
||||
.opacity(contentOpacity)
|
||||
.onAppear {
|
||||
withAnimation(Theme.Animation.smooth.delay(0.3)) {
|
||||
contentOpacity = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 50)
|
||||
}
|
||||
|
||||
Spacer(minLength: 50)
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.background(Theme.Colors.terminalBackground.ignoresSafeArea())
|
||||
|
|
@ -86,9 +86,9 @@ struct EnhancedConnectionView: View {
|
|||
profilesViewModel.loadProfiles()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Header View
|
||||
|
||||
|
||||
private var headerView: some View {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
ZStack {
|
||||
|
|
@ -98,7 +98,7 @@ struct EnhancedConnectionView: View {
|
|||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.blur(radius: 20)
|
||||
.opacity(0.5)
|
||||
|
||||
|
||||
// Main icon
|
||||
Image(systemName: "terminal.fill")
|
||||
.font(.system(size: 80))
|
||||
|
|
@ -111,35 +111,35 @@ struct EnhancedConnectionView: View {
|
|||
logoScale = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
VStack(spacing: Theme.Spacing.small) {
|
||||
Text("VibeTunnel")
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Text("Terminal Multiplexer")
|
||||
.font(Theme.Typography.terminalSystem(size: 16))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
.tracking(2)
|
||||
|
||||
|
||||
// Network status
|
||||
ConnectionStatusView()
|
||||
.padding(.top, Theme.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Quick Connect Section
|
||||
|
||||
|
||||
private var quickConnectSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
||||
HStack {
|
||||
Text("Saved Servers")
|
||||
.font(Theme.Typography.terminalSystem(size: 18, weight: .semibold))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
showingNewServerForm.toggle()
|
||||
|
|
@ -150,7 +150,7 @@ struct EnhancedConnectionView: View {
|
|||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
VStack(spacing: Theme.Spacing.small) {
|
||||
ForEach(profilesViewModel.profiles) { profile in
|
||||
ServerProfileCard(
|
||||
|
|
@ -167,9 +167,9 @@ struct EnhancedConnectionView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - New Connection Section
|
||||
|
||||
|
||||
private var newConnectionSection: some View {
|
||||
VStack(spacing: Theme.Spacing.large) {
|
||||
if !profilesViewModel.profiles.isEmpty {
|
||||
|
|
@ -177,11 +177,11 @@ struct EnhancedConnectionView: View {
|
|||
Text("New Server Connection")
|
||||
.font(Theme.Typography.terminalSystem(size: 18, weight: .semibold))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ServerConfigForm(
|
||||
host: $viewModel.host,
|
||||
port: $viewModel.port,
|
||||
|
|
@ -191,7 +191,7 @@ struct EnhancedConnectionView: View {
|
|||
errorMessage: viewModel.errorMessage,
|
||||
onConnect: saveAndConnect
|
||||
)
|
||||
|
||||
|
||||
if !profilesViewModel.profiles.isEmpty {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
|
|
@ -206,15 +206,15 @@ struct EnhancedConnectionView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
|
||||
private func connectToProfile(_ profile: ServerProfile) {
|
||||
guard networkMonitor.isConnected else {
|
||||
viewModel.errorMessage = "No internet connection available"
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await profilesViewModel.connectToProfile(profile, connectionManager: connectionManager)
|
||||
|
|
@ -223,33 +223,33 @@ struct EnhancedConnectionView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func saveAndConnect() {
|
||||
guard networkMonitor.isConnected else {
|
||||
viewModel.errorMessage = "No internet connection available"
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Create profile from form data
|
||||
let urlString = viewModel.port.isEmpty ? viewModel.host : "\(viewModel.host):\(viewModel.port)"
|
||||
guard let profile = profilesViewModel.createProfileFromURL(urlString) else {
|
||||
viewModel.errorMessage = "Invalid server URL"
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var updatedProfile = profile
|
||||
updatedProfile.name = viewModel.name.isEmpty ? profile.name : viewModel.name
|
||||
updatedProfile.requiresAuth = !viewModel.password.isEmpty
|
||||
updatedProfile.username = updatedProfile.requiresAuth ? "admin" : nil
|
||||
|
||||
|
||||
// Save profile and password
|
||||
Task {
|
||||
try await profilesViewModel.addProfile(updatedProfile, password: viewModel.password)
|
||||
|
||||
|
||||
// Connect
|
||||
connectToProfile(updatedProfile)
|
||||
}
|
||||
|
||||
|
||||
// Reset form
|
||||
viewModel = ConnectionViewModel()
|
||||
showingNewServerForm = false
|
||||
|
|
@ -263,9 +263,9 @@ struct ServerProfileCard: View {
|
|||
let isLoading: Bool
|
||||
let onConnect: () -> Void
|
||||
let onEdit: () -> Void
|
||||
|
||||
|
||||
@State private var isPressed = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Theme.Spacing.medium) {
|
||||
// Icon
|
||||
|
|
@ -275,34 +275,34 @@ struct ServerProfileCard: View {
|
|||
.frame(width: 40, height: 40)
|
||||
.background(Theme.Colors.primaryAccent.opacity(0.1))
|
||||
.cornerRadius(Theme.CornerRadius.small)
|
||||
|
||||
|
||||
// Server Info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(profile.name)
|
||||
.font(Theme.Typography.terminalSystem(size: 16, weight: .medium))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(profile.url)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.secondaryText)
|
||||
|
||||
|
||||
if profile.requiresAuth {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Theme.Colors.warningAccent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let lastConnected = profile.lastConnected {
|
||||
Text(RelativeDateTimeFormatter().localizedString(for: lastConnected, relativeTo: Date()))
|
||||
.font(Theme.Typography.terminalSystem(size: 11))
|
||||
.foregroundColor(Theme.Colors.secondaryText.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Action Buttons
|
||||
HStack(spacing: Theme.Spacing.small) {
|
||||
Button(action: onEdit) {
|
||||
|
|
@ -311,7 +311,7 @@ struct ServerProfileCard: View {
|
|||
.foregroundColor(Theme.Colors.secondaryText)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
|
||||
Button(action: onConnect) {
|
||||
HStack(spacing: 4) {
|
||||
if isLoading {
|
||||
|
|
@ -354,12 +354,12 @@ struct ServerProfileEditView: View {
|
|||
@State var profile: ServerProfile
|
||||
let onSave: (ServerProfile, String?) -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
|
||||
@State private var password: String = ""
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@Environment(\.dismiss)
|
||||
private var dismiss
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
|
|
@ -371,12 +371,12 @@ struct ServerProfileEditView: View {
|
|||
.font(.system(size: 24))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
|
||||
|
||||
TextField("Name", text: $profile.name)
|
||||
TextField("URL", text: $profile.url)
|
||||
|
||||
|
||||
Toggle("Requires Authentication", isOn: $profile.requiresAuth)
|
||||
|
||||
|
||||
if profile.requiresAuth {
|
||||
TextField("Username", text: Binding(
|
||||
get: { profile.username ?? "admin" },
|
||||
|
|
@ -386,7 +386,7 @@ struct ServerProfileEditView: View {
|
|||
.textContentType(.password)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Section {
|
||||
Button(role: .destructive, action: {
|
||||
showingDeleteConfirmation = true
|
||||
|
|
@ -404,7 +404,7 @@ struct ServerProfileEditView: View {
|
|||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
onSave(profile, profile.requiresAuth ? password : nil)
|
||||
|
|
@ -418,7 +418,7 @@ struct ServerProfileEditView: View {
|
|||
onDelete()
|
||||
dismiss()
|
||||
}
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete \"\(profile.name)\"? This action cannot be undone.")
|
||||
}
|
||||
|
|
@ -426,7 +426,8 @@ struct ServerProfileEditView: View {
|
|||
.task {
|
||||
// Load existing password from keychain
|
||||
if profile.requiresAuth,
|
||||
let existingPassword = try? KeychainService.getPassword(for: profile.id) {
|
||||
let existingPassword = try? KeychainService.getPassword(for: profile.id)
|
||||
{
|
||||
password = existingPassword
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,13 +143,13 @@ struct ServerConfigForm: View {
|
|||
}
|
||||
})
|
||||
.foregroundColor(isConnecting || !networkMonitor.isConnected ? Theme.Colors.terminalForeground : Theme
|
||||
.Colors.primaryAccent
|
||||
.Colors.primaryAccent
|
||||
)
|
||||
.padding(.vertical, Theme.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(isConnecting || !networkMonitor.isConnected ? Theme.Colors.cardBackground : Theme.Colors
|
||||
.terminalBackground
|
||||
.terminalBackground
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
|
|
@ -218,7 +218,8 @@ struct ServerConfigForm: View {
|
|||
private func loadRecentServers() {
|
||||
// Load recent servers from UserDefaults
|
||||
if let data = UserDefaults.standard.data(forKey: "recentServers"),
|
||||
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data) {
|
||||
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data)
|
||||
{
|
||||
recentServers = servers
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,8 @@ struct FilePreviewView: View {
|
|||
case .image:
|
||||
if let content = preview.content,
|
||||
let data = Data(base64Encoded: content),
|
||||
let uiImage = UIImage(data: data) {
|
||||
let uiImage = UIImage(data: data)
|
||||
{
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
|
|
|
|||
|
|
@ -109,14 +109,14 @@ struct FileBrowserView: View {
|
|||
.font(.custom("SF Mono", size: 12))
|
||||
}
|
||||
.foregroundColor(viewModel.gitFilter == .changed ? Theme.Colors.successAccent : Theme.Colors
|
||||
.terminalGray
|
||||
.terminalGray
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(viewModel.gitFilter == .changed ? Theme.Colors.successAccent.opacity(0.2) : Theme.Colors
|
||||
.terminalGray.opacity(0.1)
|
||||
.terminalGray.opacity(0.1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ struct FileBrowserView: View {
|
|||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(viewModel.showHidden ? Theme.Colors.terminalAccent.opacity(0.2) : Theme.Colors
|
||||
.terminalGray.opacity(0.1)
|
||||
.terminalGray.opacity(0.1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -566,7 +566,7 @@ struct FileBrowserRow: View {
|
|||
Text(name)
|
||||
.font(.custom("SF Mono", size: 14))
|
||||
.foregroundColor(isParent ? Theme.Colors
|
||||
.terminalAccent : (isDirectory ? Theme.Colors.terminalWhite : Theme.Colors.terminalGray)
|
||||
.terminalAccent : (isDirectory ? Theme.Colors.terminalWhite : Theme.Colors.terminalGray)
|
||||
)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
|
|
|||
|
|
@ -18,14 +18,15 @@ struct SessionCardView: View {
|
|||
@State private var scale: CGFloat = 1.0
|
||||
@State private var rotation: Double = 0
|
||||
@State private var brightness: Double = 1.0
|
||||
|
||||
|
||||
@Environment(\.livePreviewSubscription) private var livePreview
|
||||
|
||||
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 userEndIndex = session.workingDir[homePrefix.endIndex...].firstIndex(of: "/")
|
||||
{
|
||||
let restOfPath = String(session.workingDir[userEndIndex...])
|
||||
return "~\(restOfPath)"
|
||||
}
|
||||
|
|
@ -61,7 +62,7 @@ struct SessionCardView: View {
|
|||
Image(systemName: session.isRunning ? "xmark.circle" : "trash.circle")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(session.isRunning ? Theme.Colors.errorAccent : Theme.Colors
|
||||
.terminalForeground.opacity(0.6)
|
||||
.terminalForeground.opacity(0.6)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -106,15 +107,15 @@ struct SessionCardView: View {
|
|||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground
|
||||
.opacity(0.3)
|
||||
.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)
|
||||
.terminalForeground.opacity(0.5)
|
||||
)
|
||||
|
||||
|
||||
// Live preview indicator
|
||||
if session.isRunning && livePreview?.latestSnapshot != nil {
|
||||
HStack(spacing: 2) {
|
||||
|
|
@ -256,9 +257,9 @@ struct SessionCardView: View {
|
|||
opacity = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - View Components
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var commandInfoView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
|
@ -294,7 +295,7 @@ struct SessionCardView: View {
|
|||
}
|
||||
.padding(Theme.Spacing.small)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private func staticSnapshotView(_ snapshot: TerminalSnapshot) -> some View {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
|
|
@ -327,7 +328,7 @@ struct SessionCardView: View {
|
|||
}
|
||||
.padding(Theme.Spacing.small)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private func exitedSessionView(_ snapshot: TerminalSnapshot) -> some View {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
|
|
|
|||
|
|
@ -117,9 +117,9 @@ struct SessionCreateView: View {
|
|||
if presentedError != nil {
|
||||
ErrorBanner(
|
||||
message: presentedError?.error.localizedDescription ?? "An error occurred"
|
||||
) {
|
||||
presentedError = nil
|
||||
}
|
||||
) {
|
||||
presentedError = nil
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.Colors.errorAccent.opacity(0.3), lineWidth: 1)
|
||||
|
|
@ -156,14 +156,14 @@ struct SessionCreateView: View {
|
|||
.font(Theme.Typography.terminalSystem(size: 13))
|
||||
}
|
||||
.foregroundColor(workingDirectory == dir ? Theme.Colors
|
||||
.terminalBackground : Theme.Colors.terminalForeground
|
||||
.terminalBackground : Theme.Colors.terminalForeground
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(workingDirectory == dir ? Theme.Colors
|
||||
.primaryAccent : Theme.Colors.cardBorder.opacity(0.1)
|
||||
.primaryAccent : Theme.Colors.cardBorder.opacity(0.1)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
|
|
@ -209,16 +209,16 @@ struct SessionCreateView: View {
|
|||
Spacer()
|
||||
}
|
||||
.foregroundColor(command == item.command ? Theme.Colors
|
||||
.terminalBackground : Theme.Colors
|
||||
.terminalForeground
|
||||
.terminalBackground : Theme.Colors
|
||||
.terminalForeground
|
||||
)
|
||||
.padding(.horizontal, Theme.Spacing.medium)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(command == item.command ? Theme.Colors.primaryAccent : Theme
|
||||
.Colors
|
||||
.cardBackground
|
||||
.Colors
|
||||
.cardBackground
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
|
|
@ -283,7 +283,7 @@ struct SessionCreateView: View {
|
|||
Text("Create")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(command.isEmpty ? Theme.Colors.primaryAccent.opacity(0.5) : Theme
|
||||
.Colors.primaryAccent
|
||||
.Colors.primaryAccent
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ struct SessionListView: View {
|
|||
.searchable(text: $searchText, prompt: "Search sessions")
|
||||
.task {
|
||||
await viewModel.loadSessions()
|
||||
|
||||
|
||||
// Refresh every 3 seconds
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||
|
|
@ -200,7 +200,8 @@ struct SessionListView: View {
|
|||
.onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in
|
||||
if shouldNavigate,
|
||||
let sessionId = navigationManager.selectedSessionId,
|
||||
let session = viewModel.sessions.first(where: { $0.id == sessionId }) {
|
||||
let session = viewModel.sessions.first(where: { $0.id == sessionId })
|
||||
{
|
||||
selectedSession = session
|
||||
navigationManager.clearNavigation()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ struct SettingsView: View {
|
|||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.medium)
|
||||
.foregroundColor(selectedTab == tab ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.terminalForeground.opacity(0.5)
|
||||
.terminalForeground.opacity(0.5)
|
||||
)
|
||||
.background(
|
||||
selectedTab == tab ? Theme.Colors.primaryAccent.opacity(0.1) : Color.clear
|
||||
|
|
@ -172,7 +172,7 @@ struct GeneralSettingsView: View {
|
|||
.padding()
|
||||
.background(Theme.Colors.cardBackground)
|
||||
.cornerRadius(Theme.CornerRadius.card)
|
||||
|
||||
|
||||
// Live Previews
|
||||
Toggle(isOn: $enableLivePreviews) {
|
||||
HStack {
|
||||
|
|
@ -207,12 +207,12 @@ struct AdvancedSettingsView: View {
|
|||
@AppStorage("debugModeEnabled")
|
||||
private var debugModeEnabled = false
|
||||
@State private var showingSystemLogs = false
|
||||
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@AppStorage("macWindowStyle")
|
||||
private var macWindowStyleRaw = "standard"
|
||||
@StateObject private var windowManager = MacCatalystWindowManager.shared
|
||||
|
||||
|
||||
private var macWindowStyle: MacWindowStyle {
|
||||
macWindowStyleRaw == "inline" ? .inline : .standard
|
||||
}
|
||||
|
|
@ -273,14 +273,14 @@ struct AdvancedSettingsView: View {
|
|||
Text("Mac Catalyst")
|
||||
.font(.headline)
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
VStack(spacing: Theme.Spacing.medium) {
|
||||
// Window Style Picker
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Text("Window Style")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
|
||||
|
||||
Picker("Window Style", selection: $macWindowStyleRaw) {
|
||||
Label("Standard", systemImage: "macwindow")
|
||||
.tag("standard")
|
||||
|
|
@ -292,12 +292,13 @@ struct AdvancedSettingsView: View {
|
|||
let style: MacWindowStyle = newValue == "inline" ? .inline : .standard
|
||||
windowManager.setWindowStyle(style)
|
||||
}
|
||||
|
||||
|
||||
Text(macWindowStyle == .inline ?
|
||||
"Traffic light buttons appear inline with content" :
|
||||
"Standard macOS title bar with traffic lights")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||
"Traffic light buttons appear inline with content" :
|
||||
"Standard macOS title bar with traffic lights"
|
||||
)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||
}
|
||||
.padding()
|
||||
.background(Theme.Colors.cardBackground)
|
||||
|
|
@ -350,11 +351,11 @@ struct AboutSettingsView: View {
|
|||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||
}
|
||||
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.xlarge) {
|
||||
// App icon and info
|
||||
|
|
@ -364,19 +365,19 @@ struct AboutSettingsView: View {
|
|||
.frame(width: 100, height: 100)
|
||||
.cornerRadius(22)
|
||||
.shadow(color: Theme.Colors.primaryAccent.opacity(0.3), radius: 10, y: 5)
|
||||
|
||||
|
||||
VStack(spacing: Theme.Spacing.small) {
|
||||
Text("VibeTunnel")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
|
||||
Text("Version \(appVersion) (\(buildNumber))")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.secondaryText)
|
||||
}
|
||||
}
|
||||
.padding(.top, Theme.Spacing.large)
|
||||
|
||||
|
||||
// Links section
|
||||
VStack(spacing: Theme.Spacing.medium) {
|
||||
LinkRow(
|
||||
|
|
@ -385,21 +386,21 @@ struct AboutSettingsView: View {
|
|||
subtitle: "vibetunnel.sh",
|
||||
url: URL(string: "https://vibetunnel.sh")
|
||||
)
|
||||
|
||||
|
||||
LinkRow(
|
||||
icon: "doc.text",
|
||||
title: "Documentation",
|
||||
subtitle: "Learn how to use VibeTunnel",
|
||||
url: URL(string: "https://docs.vibetunnel.sh")
|
||||
)
|
||||
|
||||
|
||||
LinkRow(
|
||||
icon: "exclamationmark.bubble",
|
||||
title: "Report an Issue",
|
||||
subtitle: "Help us improve",
|
||||
url: URL(string: "https://github.com/vibetunnel/vibetunnel/issues")
|
||||
)
|
||||
|
||||
|
||||
LinkRow(
|
||||
icon: "heart",
|
||||
title: "Rate on App Store",
|
||||
|
|
@ -407,20 +408,20 @@ struct AboutSettingsView: View {
|
|||
url: URL(string: "https://apps.apple.com/app/vibetunnel")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Credits
|
||||
VStack(spacing: Theme.Spacing.small) {
|
||||
Text("Made with ❤️ by the VibeTunnel team")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.secondaryText)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
|
||||
Text("© 2024 VibeTunnel. All rights reserved.")
|
||||
.font(Theme.Typography.terminalSystem(size: 11))
|
||||
.foregroundColor(Theme.Colors.secondaryText.opacity(0.7))
|
||||
}
|
||||
.padding(.top, Theme.Spacing.large)
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
|
@ -431,10 +432,10 @@ struct LinkRow: View {
|
|||
let title: String
|
||||
let subtitle: String
|
||||
let url: URL?
|
||||
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
if let url = url {
|
||||
if let url {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
|
|
@ -443,19 +444,19 @@ struct LinkRow: View {
|
|||
.font(.system(size: 20))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.frame(width: 30)
|
||||
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
|
||||
Text(subtitle)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.secondaryText)
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(Theme.Colors.secondaryText.opacity(0.5))
|
||||
|
|
|
|||
|
|
@ -296,7 +296,8 @@ struct SystemLogsView: View {
|
|||
// Present it
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first,
|
||||
let rootVC = window.rootViewController {
|
||||
let rootVC = window.rootViewController
|
||||
{
|
||||
rootVC.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,7 +233,8 @@ struct CtrlKeyButton: View {
|
|||
Button(action: {
|
||||
// Calculate control character (Ctrl+A = 1, Ctrl+B = 2, etc.)
|
||||
if let scalar = char.unicodeScalars.first,
|
||||
let ctrlScalar = UnicodeScalar(scalar.value - 64) {
|
||||
let ctrlScalar = UnicodeScalar(scalar.value - 64)
|
||||
{
|
||||
let ctrlChar = Character(ctrlScalar)
|
||||
onPress(String(ctrlChar))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ private let logger = Logger(category: "CtrlKeyGrid")
|
|||
struct CtrlKeyGrid: View {
|
||||
@Binding var isPresented: Bool
|
||||
let onKeyPress: (String) -> Void
|
||||
|
||||
// Common Ctrl combinations organized by category
|
||||
|
||||
/// Common Ctrl combinations organized by category
|
||||
let navigationKeys = [
|
||||
("A", "Beginning of line"),
|
||||
("E", "End of line"),
|
||||
|
|
@ -16,7 +16,7 @@ struct CtrlKeyGrid: View {
|
|||
("P", "Previous command"),
|
||||
("N", "Next command")
|
||||
]
|
||||
|
||||
|
||||
let editingKeys = [
|
||||
("D", "Delete character"),
|
||||
("H", "Backspace"),
|
||||
|
|
@ -25,7 +25,7 @@ struct CtrlKeyGrid: View {
|
|||
("K", "Delete to end"),
|
||||
("Y", "Paste")
|
||||
]
|
||||
|
||||
|
||||
let processKeys = [
|
||||
("C", "Interrupt (SIGINT)"),
|
||||
("Z", "Suspend (SIGTSTP)"),
|
||||
|
|
@ -34,7 +34,7 @@ struct CtrlKeyGrid: View {
|
|||
("Q", "Resume output"),
|
||||
("L", "Clear screen")
|
||||
]
|
||||
|
||||
|
||||
let searchKeys = [
|
||||
("R", "Search history"),
|
||||
("T", "Transpose chars"),
|
||||
|
|
@ -43,9 +43,9 @@ struct CtrlKeyGrid: View {
|
|||
("G", "Cancel command"),
|
||||
("O", "Execute + new line")
|
||||
]
|
||||
|
||||
|
||||
@State private var selectedCategory = 0
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -58,7 +58,7 @@ struct CtrlKeyGrid: View {
|
|||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.padding()
|
||||
|
||||
|
||||
// Key grid
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [
|
||||
|
|
@ -70,18 +70,18 @@ struct CtrlKeyGrid: View {
|
|||
CtrlGridKeyButton(
|
||||
key: key,
|
||||
description: description
|
||||
) { sendCtrlKey(key) }
|
||||
) { sendCtrlKey(key) }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
|
||||
// Quick reference
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
Text("Tip: Long press any key to see its function")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.secondaryText)
|
||||
|
||||
|
||||
Text("These shortcuts work in most terminal applications")
|
||||
.font(Theme.Typography.terminalSystem(size: 11))
|
||||
.foregroundColor(Theme.Colors.secondaryText.opacity(0.7))
|
||||
|
|
@ -103,17 +103,17 @@ struct CtrlKeyGrid: View {
|
|||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
|
||||
private var currentKeys: [(String, String)] {
|
||||
switch selectedCategory {
|
||||
case 0: return navigationKeys
|
||||
case 1: return editingKeys
|
||||
case 2: return processKeys
|
||||
case 3: return searchKeys
|
||||
default: return navigationKeys
|
||||
case 0: navigationKeys
|
||||
case 1: editingKeys
|
||||
case 2: processKeys
|
||||
case 3: searchKeys
|
||||
default: navigationKeys
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func sendCtrlKey(_ key: String) {
|
||||
// Convert letter to control character
|
||||
if let charCode = key.first?.asciiValue {
|
||||
|
|
@ -123,7 +123,7 @@ struct CtrlKeyGrid: View {
|
|||
Task { @MainActor in
|
||||
HapticFeedback.impact(.medium)
|
||||
}
|
||||
|
||||
|
||||
// Auto-dismiss for common keys
|
||||
if ["C", "D", "Z"].contains(key) {
|
||||
isPresented = false
|
||||
|
|
@ -138,17 +138,17 @@ struct CtrlGridKeyButton: View {
|
|||
let key: String
|
||||
let description: String
|
||||
let onPress: () -> Void
|
||||
|
||||
|
||||
@State private var isPressed = false
|
||||
@State private var showingTooltip = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
Button(action: onPress) {
|
||||
VStack(spacing: 4) {
|
||||
Text("^" + key)
|
||||
.font(Theme.Typography.terminalSystem(size: 20, weight: .bold))
|
||||
.foregroundColor(isPressed ? .white : Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
Text("Ctrl+" + key)
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
.foregroundColor(isPressed ? .white.opacity(0.8) : Theme.Colors.secondaryText)
|
||||
|
|
@ -184,7 +184,7 @@ struct CtrlGridKeyButton: View {
|
|||
Task { @MainActor in
|
||||
HapticFeedback.impact(.light)
|
||||
}
|
||||
|
||||
|
||||
// Hide tooltip after 3 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
showingTooltip = false
|
||||
|
|
@ -195,7 +195,7 @@ struct CtrlGridKeyButton: View {
|
|||
Text("Ctrl+" + key)
|
||||
.font(Theme.Typography.terminalSystem(size: 14, weight: .bold))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
||||
|
||||
Text(description)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
|
|
|||
|
|
@ -81,14 +81,14 @@ struct FontSizeSheet: View {
|
|||
Text("\(Int(size))")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(fontSize == size ? Theme.Colors.terminalBackground : Theme.Colors
|
||||
.terminalForeground
|
||||
.terminalForeground
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.fill(fontSize == size ? Theme.Colors.primaryAccent : Theme.Colors
|
||||
.cardBorder.opacity(0.3)
|
||||
.cardBorder.opacity(0.3)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ struct FullscreenTextInput: View {
|
|||
@State private var text: String = ""
|
||||
@FocusState private var isFocused: Bool
|
||||
@State private var showingOptions = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -28,7 +28,7 @@ struct FullscreenTextInput: View {
|
|||
.background(Theme.Colors.cardBackground)
|
||||
.cornerRadius(Theme.CornerRadius.medium)
|
||||
.padding()
|
||||
|
||||
|
||||
// Quick actions
|
||||
HStack(spacing: Theme.Spacing.medium) {
|
||||
// Template commands
|
||||
|
|
@ -36,25 +36,25 @@ struct FullscreenTextInput: View {
|
|||
Button(action: { insertTemplate("ls -la") }, label: {
|
||||
Label("List Files", systemImage: "folder")
|
||||
})
|
||||
|
||||
|
||||
Button(action: { insertTemplate("cd ") }, label: {
|
||||
Label("Change Directory", systemImage: "arrow.right.square")
|
||||
})
|
||||
|
||||
|
||||
Button(action: { insertTemplate("git status") }, label: {
|
||||
Label("Git Status", systemImage: "arrow.triangle.branch")
|
||||
})
|
||||
|
||||
|
||||
Button(action: { insertTemplate("sudo ") }, label: {
|
||||
Label("Sudo Command", systemImage: "lock")
|
||||
})
|
||||
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
Button(action: { insertTemplate("ssh ") }, label: {
|
||||
Label("SSH Connect", systemImage: "network")
|
||||
})
|
||||
|
||||
|
||||
Button(action: { insertTemplate("docker ps") }, label: {
|
||||
Label("Docker List", systemImage: "shippingbox")
|
||||
})
|
||||
|
|
@ -63,14 +63,14 @@ struct FullscreenTextInput: View {
|
|||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Character count
|
||||
Text("\(text.count) characters")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.secondaryText)
|
||||
|
||||
|
||||
// Clear button
|
||||
if !text.isEmpty {
|
||||
Button(action: {
|
||||
|
|
@ -84,10 +84,10 @@ struct FullscreenTextInput: View {
|
|||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, Theme.Spacing.small)
|
||||
|
||||
|
||||
Divider()
|
||||
.background(Theme.Colors.cardBorder)
|
||||
|
||||
|
||||
// Input options
|
||||
VStack(spacing: Theme.Spacing.small) {
|
||||
// Common special characters
|
||||
|
|
@ -110,7 +110,7 @@ struct FullscreenTextInput: View {
|
|||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
|
||||
// Submit options
|
||||
HStack(spacing: Theme.Spacing.medium) {
|
||||
// Execute immediately
|
||||
|
|
@ -128,7 +128,7 @@ struct FullscreenTextInput: View {
|
|||
.background(Theme.Colors.primaryAccent)
|
||||
.cornerRadius(Theme.CornerRadius.medium)
|
||||
})
|
||||
|
||||
|
||||
// Insert without executing
|
||||
Button(action: {
|
||||
insertAndClose()
|
||||
|
|
@ -163,7 +163,7 @@ struct FullscreenTextInput: View {
|
|||
}
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { showingOptions.toggle() }, label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
|
|
@ -177,17 +177,17 @@ struct FullscreenTextInput: View {
|
|||
isFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func insertText(_ text: String) {
|
||||
self.text.append(text)
|
||||
HapticFeedback.impact(.light)
|
||||
}
|
||||
|
||||
|
||||
private func insertTemplate(_ template: String) {
|
||||
self.text = template
|
||||
HapticFeedback.impact(.light)
|
||||
}
|
||||
|
||||
|
||||
private func submitAndClose() {
|
||||
if !text.isEmpty {
|
||||
onSubmit(text + "\n") // Add newline to execute
|
||||
|
|
@ -195,7 +195,7 @@ struct FullscreenTextInput: View {
|
|||
}
|
||||
isPresented = false
|
||||
}
|
||||
|
||||
|
||||
private func insertAndClose() {
|
||||
if !text.isEmpty {
|
||||
onSubmit(text) // Don't add newline, just insert
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ struct QuickFontSizeButtons: View {
|
|||
@Binding var fontSize: CGFloat
|
||||
let minSize: CGFloat = 8
|
||||
let maxSize: CGFloat = 32
|
||||
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Decrease button
|
||||
Button(action: decreaseFontSize) {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(fontSize > minSize ? Theme.Colors.primaryAccent : Theme.Colors.secondaryText.opacity(0.5))
|
||||
.foregroundColor(fontSize > minSize ? Theme.Colors.primaryAccent : Theme.Colors.secondaryText
|
||||
.opacity(0.5)
|
||||
)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Theme.Colors.cardBackground)
|
||||
.overlay(
|
||||
|
|
@ -21,7 +23,7 @@ struct QuickFontSizeButtons: View {
|
|||
)
|
||||
}
|
||||
.disabled(fontSize <= minSize)
|
||||
|
||||
|
||||
// Current size display
|
||||
Text("\(Int(fontSize))")
|
||||
.font(Theme.Typography.terminalSystem(size: 12, weight: .medium))
|
||||
|
|
@ -36,12 +38,14 @@ struct QuickFontSizeButtons: View {
|
|||
.background(Theme.Colors.cardBorder)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
// Increase button
|
||||
Button(action: increaseFontSize) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(fontSize < maxSize ? Theme.Colors.primaryAccent : Theme.Colors.secondaryText.opacity(0.5))
|
||||
.foregroundColor(fontSize < maxSize ? Theme.Colors.primaryAccent : Theme.Colors.secondaryText
|
||||
.opacity(0.5)
|
||||
)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Theme.Colors.cardBackground)
|
||||
.overlay(
|
||||
|
|
@ -55,12 +59,12 @@ struct QuickFontSizeButtons: View {
|
|||
.cornerRadius(Theme.CornerRadius.small)
|
||||
.shadow(color: Theme.CardShadow.color, radius: 2, y: 1)
|
||||
}
|
||||
|
||||
|
||||
private func decreaseFontSize() {
|
||||
fontSize = max(minSize, fontSize - 1)
|
||||
HapticFeedback.impact(.light)
|
||||
}
|
||||
|
||||
|
||||
private func increaseFontSize() {
|
||||
fontSize = min(maxSize, fontSize + 1)
|
||||
HapticFeedback.impact(.light)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import SwiftUI
|
|||
struct TerminalBufferPreview: View {
|
||||
let snapshot: BufferSnapshot
|
||||
let fontSize: CGFloat
|
||||
|
||||
|
||||
init(snapshot: BufferSnapshot, fontSize: CGFloat = 10) {
|
||||
self.snapshot = snapshot
|
||||
self.fontSize = fontSize
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { _ in
|
||||
ScrollViewReader { scrollProxy in
|
||||
|
|
@ -52,7 +52,7 @@ struct TerminalBufferPreview: View {
|
|||
.background(Theme.Colors.terminalBackground)
|
||||
.cornerRadius(Theme.CornerRadius.small)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private func cellView(for cell: BufferCell) -> some View {
|
||||
Text(cell.char.isEmpty ? " " : cell.char)
|
||||
|
|
@ -61,14 +61,14 @@ struct TerminalBufferPreview: View {
|
|||
.background(backgroundColor(for: cell))
|
||||
.frame(width: fontSize * 0.6 * CGFloat(max(1, cell.width)))
|
||||
}
|
||||
|
||||
|
||||
private func foregroundColor(for cell: BufferCell) -> Color {
|
||||
guard let fg = cell.fg else {
|
||||
return Theme.Colors.terminalForeground
|
||||
}
|
||||
|
||||
|
||||
// Check if RGB color (has alpha channel flag)
|
||||
if (fg & 0xFF000000) != 0 {
|
||||
if (fg & 0xFF00_0000) != 0 {
|
||||
// RGB color
|
||||
let red = Double((fg >> 16) & 0xFF) / 255.0
|
||||
let green = Double((fg >> 8) & 0xFF) / 255.0
|
||||
|
|
@ -79,14 +79,14 @@ struct TerminalBufferPreview: View {
|
|||
return paletteColor(fg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func backgroundColor(for cell: BufferCell) -> Color {
|
||||
guard let bg = cell.bg else {
|
||||
return .clear
|
||||
}
|
||||
|
||||
|
||||
// Check if RGB color (has alpha channel flag)
|
||||
if (bg & 0xFF000000) != 0 {
|
||||
if (bg & 0xFF00_0000) != 0 {
|
||||
// RGB color
|
||||
let red = Double((bg >> 16) & 0xFF) / 255.0
|
||||
let green = Double((bg >> 8) & 0xFF) / 255.0
|
||||
|
|
@ -97,7 +97,7 @@ struct TerminalBufferPreview: View {
|
|||
return paletteColor(bg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func paletteColor(_ index: Int) -> Color {
|
||||
// ANSI 256-color palette
|
||||
switch index {
|
||||
|
|
@ -133,17 +133,17 @@ struct TerminalBufferPreview: View {
|
|||
struct CompactTerminalPreview: View {
|
||||
let snapshot: BufferSnapshot
|
||||
let maxLines: Int
|
||||
|
||||
|
||||
init(snapshot: BufferSnapshot, maxLines: Int = 6) {
|
||||
self.snapshot = snapshot
|
||||
self.maxLines = maxLines
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
// Get the last non-empty lines
|
||||
let visibleLines = getVisibleLines()
|
||||
|
||||
|
||||
ForEach(Array(visibleLines.enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
|
|
@ -151,25 +151,25 @@ struct CompactTerminalPreview: View {
|
|||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
|
||||
private func getVisibleLines() -> [String] {
|
||||
var lines: [String] = []
|
||||
|
||||
|
||||
// Start from the bottom and work up to find non-empty lines
|
||||
for row in (0..<snapshot.rows).reversed() {
|
||||
guard row < snapshot.cells.count else { continue }
|
||||
|
||||
|
||||
let line = snapshot.cells[row]
|
||||
.map { $0.char.isEmpty ? " " : $0.char }
|
||||
.joined()
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
|
||||
if !line.isEmpty {
|
||||
lines.insert(line, at: 0)
|
||||
if lines.count >= maxLines {
|
||||
|
|
@ -177,12 +177,12 @@ struct CompactTerminalPreview: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If we have fewer lines than maxLines, add empty lines at the top
|
||||
while lines.count < min(maxLines, snapshot.rows) && lines.count < maxLines {
|
||||
lines.insert("", at: 0)
|
||||
}
|
||||
|
||||
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,7 +383,8 @@ struct TerminalHostingView: UIViewRepresentable {
|
|||
from oldSnapshot: BufferSnapshot,
|
||||
to newSnapshot: BufferSnapshot
|
||||
)
|
||||
-> String {
|
||||
-> String
|
||||
{
|
||||
var output = ""
|
||||
var currentFg: Int?
|
||||
var currentBg: Int?
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ struct TerminalThemeSheet: View {
|
|||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(selectedTheme.id == theme.id
|
||||
? Theme.Colors.primaryAccent.opacity(0.1)
|
||||
: Theme.Colors.cardBorder.opacity(0.1)
|
||||
? Theme.Colors.primaryAccent.opacity(0.1)
|
||||
: Theme.Colors.cardBorder.opacity(0.1)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
|
|
|
|||
|
|
@ -112,7 +112,9 @@ struct TerminalView: View {
|
|||
}
|
||||
)
|
||||
.task {
|
||||
for await notification in NotificationCenter.default.notifications(named: UIResponder.keyboardWillShowNotification) {
|
||||
for await notification in NotificationCenter.default
|
||||
.notifications(named: UIResponder.keyboardWillShowNotification)
|
||||
{
|
||||
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
||||
withAnimation(Theme.Animation.standard) {
|
||||
keyboardHeight = keyboardFrame.height
|
||||
|
|
@ -290,11 +292,11 @@ struct TerminalView: View {
|
|||
Button(action: { viewModel.clearTerminal() }, label: {
|
||||
Label("Clear", systemImage: "clear")
|
||||
})
|
||||
|
||||
|
||||
Button(action: { showingFullscreenInput = true }, label: {
|
||||
Label("Compose Command", systemImage: "text.viewfinder")
|
||||
})
|
||||
|
||||
|
||||
Button(action: { showingCtrlKeyGrid = true }, label: {
|
||||
Label("Ctrl Shortcuts", systemImage: "command.square")
|
||||
})
|
||||
|
|
@ -524,10 +526,10 @@ struct TerminalView: View {
|
|||
.overlay(
|
||||
ScrollToBottomButton(
|
||||
isVisible: showScrollToBottom
|
||||
) {
|
||||
viewModel.scrollToBottom()
|
||||
showScrollToBottom = false
|
||||
}
|
||||
) {
|
||||
viewModel.scrollToBottom()
|
||||
showScrollToBottom = false
|
||||
}
|
||||
.padding(.bottom, Theme.Spacing.large)
|
||||
.padding(.leading, Theme.Spacing.large),
|
||||
alignment: .bottomLeading
|
||||
|
|
@ -698,7 +700,7 @@ class TerminalViewModel {
|
|||
logger.info("Terminal initialized: \(width)x\(height)")
|
||||
terminalCols = width
|
||||
terminalRows = height
|
||||
// The terminal will be resized when created
|
||||
// The terminal will be resized when created
|
||||
|
||||
case .output(_, let data):
|
||||
// Feed output data directly to the terminal
|
||||
|
|
@ -723,7 +725,8 @@ class TerminalViewModel {
|
|||
let parts = dimensions.split(separator: "x")
|
||||
if parts.count == 2,
|
||||
let cols = Int(parts[0]),
|
||||
let rows = Int(parts[1]) {
|
||||
let rows = Int(parts[1])
|
||||
{
|
||||
// Update terminal dimensions
|
||||
terminalCols = cols
|
||||
terminalRows = rows
|
||||
|
|
|
|||
|
|
@ -142,8 +142,8 @@ struct TerminalWidthSheet: View {
|
|||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(selectedWidth == preset.columns
|
||||
? Theme.Colors.primaryAccent.opacity(0.1)
|
||||
: Theme.Colors.cardBorder.opacity(0.1)
|
||||
? Theme.Colors.primaryAccent.opacity(0.1)
|
||||
: Theme.Colors.cardBorder.opacity(0.1)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ struct WidthSelectorPopover: View {
|
|||
let customWidths = TerminalWidthManager.shared.customWidths
|
||||
if !customWidths.isEmpty {
|
||||
Section(header: Text("Recent Custom Widths")
|
||||
.font(.caption)
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
.font(.caption)
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
) {
|
||||
ForEach(customWidths, id: \.self) { width in
|
||||
WidthPresetRow(
|
||||
|
|
|
|||
|
|
@ -251,7 +251,8 @@ struct XtermWebView: UIViewRepresentable {
|
|||
case "terminalResize":
|
||||
if let dict = message.body as? [String: Any],
|
||||
let cols = dict["cols"] as? Int,
|
||||
let rows = dict["rows"] as? Int {
|
||||
let rows = dict["rows"] as? Int
|
||||
{
|
||||
parent.onResize(cols, rows)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ if command -v xcpretty &> /dev/null; then
|
|||
-scheme VibeTunnel-iOS \
|
||||
-destination "platform=iOS Simulator,id=$SIMULATOR_ID" \
|
||||
-resultBundlePath TestResults.xcresult \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
2>&1 | xcpretty || {
|
||||
EXIT_CODE=$?
|
||||
echo "Tests failed with exit code: $EXIT_CODE"
|
||||
|
|
@ -68,6 +71,9 @@ else
|
|||
-scheme VibeTunnel-iOS \
|
||||
-destination "platform=iOS Simulator,id=$SIMULATOR_ID" \
|
||||
-resultBundlePath TestResults.xcresult \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
|| {
|
||||
EXIT_CODE=$?
|
||||
echo "Tests failed with exit code: $EXIT_CODE"
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ extension View {
|
|||
systemPermissionManager: SystemPermissionManager? = nil,
|
||||
terminalLauncher: TerminalLauncher? = nil
|
||||
)
|
||||
-> some View
|
||||
-> some View
|
||||
{
|
||||
self
|
||||
.environment(\.serverManager, serverManager ?? ServerManager.shared)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ final class DockIconManager: NSObject, @unchecked Sendable {
|
|||
|
||||
// Log window details for debugging
|
||||
// for window in visibleWindows {
|
||||
// logger.debug(" Visible window: \(window.title.isEmpty ? "(untitled)" : window.title, privacy: .public)")
|
||||
// logger.debug(" Visible window: \(window.title.isEmpty ? "(untitled)" : window.title, privacy:
|
||||
// .public)")
|
||||
// }
|
||||
|
||||
// Show dock if user wants it shown OR if any windows are open
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
|||
/// Static URLs to ensure they're validated at compile time
|
||||
private static let stableAppcastURL: URL = {
|
||||
guard let url =
|
||||
URL(string: "https://stats.store/api/v1/appcast/appcast.xml")
|
||||
URL(string: "https://stats.store/api/v1/appcast/appcast.xml")
|
||||
else {
|
||||
fatalError("Invalid stable appcast URL - this should never happen with a hardcoded URL")
|
||||
}
|
||||
|
|
@ -50,9 +50,9 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
|||
|
||||
private static let prereleaseAppcastURL: URL = {
|
||||
guard let url =
|
||||
URL(
|
||||
string: "https://stats.store/api/v1/appcast/appcast-prerelease.xml"
|
||||
)
|
||||
URL(
|
||||
string: "https://stats.store/api/v1/appcast/appcast-prerelease.xml"
|
||||
)
|
||||
else {
|
||||
fatalError("Invalid prerelease appcast URL - this should never happen with a hardcoded URL")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -480,7 +480,7 @@ final class BunServer {
|
|||
seconds: TimeInterval,
|
||||
operation: @escaping @Sendable () async -> T
|
||||
)
|
||||
async -> T?
|
||||
async -> T?
|
||||
{
|
||||
await withTaskGroup(of: T?.self) { group in
|
||||
group.addTask {
|
||||
|
|
|
|||
|
|
@ -20,33 +20,33 @@ final class DashboardKeychain {
|
|||
/// Get the dashboard password from keychain
|
||||
func getPassword() -> String? {
|
||||
#if DEBUG
|
||||
// In debug builds, skip keychain access to avoid authorization dialogs
|
||||
logger
|
||||
.info(
|
||||
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
|
||||
)
|
||||
return nil
|
||||
// In debug builds, skip keychain access to avoid authorization dialogs
|
||||
logger
|
||||
.info(
|
||||
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
|
||||
)
|
||||
return nil
|
||||
#else
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let password = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
logger.debug("No password found in keychain")
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let password = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
logger.debug("No password found in keychain")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.debug("Password retrieved from keychain")
|
||||
return password
|
||||
logger.debug("Password retrieved from keychain")
|
||||
return password
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
@ -103,12 +103,12 @@ final class DashboardKeychain {
|
|||
logger.info("Password \(success ? "saved to" : "failed to save to") keychain")
|
||||
|
||||
#if DEBUG
|
||||
if success {
|
||||
logger
|
||||
.info(
|
||||
"Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development."
|
||||
)
|
||||
}
|
||||
if success {
|
||||
logger
|
||||
.info(
|
||||
"Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development."
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
return success
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ final class NgrokService: NgrokTunnelProtocol {
|
|||
seconds: TimeInterval,
|
||||
operation: @Sendable @escaping () async throws -> T
|
||||
)
|
||||
async throws -> T
|
||||
async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class ServerManager {
|
|||
get {
|
||||
let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? ""
|
||||
) ??
|
||||
.localhost
|
||||
.localhost
|
||||
return mode.bindAddress
|
||||
}
|
||||
set {
|
||||
|
|
|
|||
|
|
@ -52,38 +52,38 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {
|
|||
|
||||
// Initialize Sparkle with standard configuration
|
||||
#if DEBUG
|
||||
// In debug mode, start the updater for testing
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: userDriverDelegate
|
||||
)
|
||||
// In debug mode, start the updater for testing
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: userDriverDelegate
|
||||
)
|
||||
#else
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: userDriverDelegate
|
||||
)
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: userDriverDelegate
|
||||
)
|
||||
#endif
|
||||
|
||||
// Configure automatic updates
|
||||
if let updater = updaterController?.updater {
|
||||
#if DEBUG
|
||||
// Enable automatic checks in debug too
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
updater.automaticallyDownloadsUpdates = false
|
||||
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates enabled for testing")
|
||||
// Enable automatic checks in debug too
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
updater.automaticallyDownloadsUpdates = false
|
||||
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates enabled for testing")
|
||||
#else
|
||||
// Enable automatic checking for updates
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
// Enable automatic checking for updates
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
|
||||
// Enable automatic downloading of updates
|
||||
updater.automaticallyDownloadsUpdates = true
|
||||
// Enable automatic downloading of updates
|
||||
updater.automaticallyDownloadsUpdates = true
|
||||
|
||||
// Set update check interval to 24 hours
|
||||
updater.updateCheckInterval = 86_400
|
||||
// Set update check interval to 24 hours
|
||||
updater.updateCheckInterval = 86_400
|
||||
|
||||
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
|
||||
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
|
||||
#endif
|
||||
|
||||
// Start the updater for both debug and release builds
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ final class SparkleUserDriverDelegate: NSObject, @preconcurrency SPUStandardUser
|
|||
_ update: SUAppcastItem,
|
||||
andInImmediateFocus immediateFocus: Bool
|
||||
)
|
||||
-> Bool
|
||||
-> Bool
|
||||
{
|
||||
logger.info("Should handle showing update: \(update.displayVersionString), immediate: \(immediateFocus)")
|
||||
|
||||
|
|
@ -211,7 +211,7 @@ final class SparkleUserDriverDelegate: NSObject, @preconcurrency SPUStandardUser
|
|||
|
||||
case "LATER_ACTION":
|
||||
logger.info("User tapped 'Remind Me Later' in notification")
|
||||
// The next reminder is already scheduled
|
||||
// The next reminder is already scheduled
|
||||
|
||||
default:
|
||||
break
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ actor TerminalManager {
|
|||
seconds: TimeInterval,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
)
|
||||
async throws -> T
|
||||
async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ final class WindowTracker {
|
|||
tabReference: String?,
|
||||
tabID: String?
|
||||
)
|
||||
-> WindowInfo?
|
||||
-> WindowInfo?
|
||||
{
|
||||
let allWindows = Self.getAllTerminalWindows()
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ extension View {
|
|||
error: Binding<Error?>,
|
||||
onDismiss: (() -> Void)? = nil
|
||||
)
|
||||
-> some View
|
||||
-> some View
|
||||
{
|
||||
modifier(ErrorAlertModifier(error: error, title: title, onDismiss: onDismiss))
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ extension Task where Failure == Error {
|
|||
errorBinding: Binding<Error?>,
|
||||
operation: @escaping () async throws -> T
|
||||
)
|
||||
-> Task<T, Error>
|
||||
-> Task<T, Error>
|
||||
{
|
||||
Task<T, Error>(priority: priority) {
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ extension View {
|
|||
horizontal: CGFloat = 16,
|
||||
vertical: CGFloat = 14
|
||||
)
|
||||
-> some View
|
||||
-> some View
|
||||
{
|
||||
self
|
||||
.padding(.horizontal, horizontal)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ struct MenuBarView: View {
|
|||
// Show Tutorial
|
||||
Button(action: {
|
||||
#if !SWIFT_PACKAGE
|
||||
AppDelegate.showWelcomeScreen()
|
||||
AppDelegate.showWelcomeScreen()
|
||||
#endif
|
||||
}, label: {
|
||||
HStack {
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ private struct DeveloperToolsSection: View {
|
|||
Spacer()
|
||||
Button("Show Welcome") {
|
||||
#if !SWIFT_PACKAGE
|
||||
AppDelegate.showWelcomeScreen()
|
||||
AppDelegate.showWelcomeScreen()
|
||||
#endif
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ struct GlowingAppIcon: View {
|
|||
enableInteraction: true,
|
||||
glowIntensity: 0.3
|
||||
) {
|
||||
print("Icon clicked!")
|
||||
// Icon clicked - action handled here
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ struct ProtectDashboardPageView: View {
|
|||
|
||||
// When password is set for the first time, automatically switch to network mode
|
||||
let currentMode = DashboardAccessMode(rawValue: UserDefaults.standard
|
||||
.string(forKey: "dashboardAccessMode") ?? ""
|
||||
.string(forKey: "dashboardAccessMode") ?? ""
|
||||
) ?? .localhost
|
||||
if currentMode == .localhost {
|
||||
UserDefaults.standard.set(DashboardAccessMode.network.rawValue, forKey: "dashboardAccessMode")
|
||||
|
|
|
|||
|
|
@ -172,8 +172,8 @@ final class ApplicationMover {
|
|||
logger.debug("ApplicationMover: hdiutil returned \(data.count) bytes")
|
||||
|
||||
guard let plist = try PropertyListSerialization
|
||||
.propertyList(from: data, options: [], format: nil) as? [String: Any],
|
||||
let images = plist["images"] as? [[String: Any]]
|
||||
.propertyList(from: data, options: [], format: nil) as? [String: Any],
|
||||
let images = plist["images"] as? [[String: Any]]
|
||||
else {
|
||||
logger.debug("ApplicationMover: No disk images found in hdiutil output")
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ final class TerminalLauncher {
|
|||
var runningTerminals: [Terminal] = []
|
||||
|
||||
for terminal in Terminal.allCases
|
||||
where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier })
|
||||
where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier })
|
||||
{
|
||||
runningTerminals.append(terminal)
|
||||
logger.debug("Detected running terminal: \(terminal.rawValue)")
|
||||
|
|
@ -416,7 +416,7 @@ final class TerminalLauncher {
|
|||
_ config: TerminalLaunchConfig,
|
||||
sessionId: String? = nil
|
||||
)
|
||||
throws -> TerminalLaunchResult
|
||||
throws -> TerminalLaunchResult
|
||||
{
|
||||
logger.debug("Launch config - command: \(config.command)")
|
||||
logger.debug("Launch config - fullCommand: \(config.fullCommand)")
|
||||
|
|
@ -519,7 +519,7 @@ final class TerminalLauncher {
|
|||
|
||||
if process.terminationStatus != 0 {
|
||||
throw TerminalLauncherError
|
||||
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
|
||||
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to launch terminal: \(error.localizedDescription)")
|
||||
|
|
@ -676,7 +676,7 @@ final class TerminalLauncher {
|
|||
sessionId: String,
|
||||
vibetunnelPath: String? = nil
|
||||
)
|
||||
throws
|
||||
throws
|
||||
{
|
||||
// Expand tilde in working directory path
|
||||
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
|
||||
|
|
@ -801,7 +801,7 @@ final class TerminalLauncher {
|
|||
workingDir: String,
|
||||
sessionId: String? = nil
|
||||
)
|
||||
-> String
|
||||
-> String
|
||||
{
|
||||
// Bun executable has fwd command built-in
|
||||
logger.info("Using Bun executable for session creation")
|
||||
|
|
|
|||
|
|
@ -21,81 +21,81 @@ struct VibeTunnelApp: App {
|
|||
|
||||
var body: some Scene {
|
||||
#if os(macOS)
|
||||
// Hidden WindowGroup to make Settings work in MenuBarExtra-only apps
|
||||
// This is a workaround for FB10184971
|
||||
WindowGroup("HiddenWindow") {
|
||||
HiddenWindowView()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 1, height: 1)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
// Hidden WindowGroup to make Settings work in MenuBarExtra-only apps
|
||||
// This is a workaround for FB10184971
|
||||
WindowGroup("HiddenWindow") {
|
||||
HiddenWindowView()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 1, height: 1)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
|
||||
// Welcome Window
|
||||
WindowGroup("Welcome", id: "welcome") {
|
||||
WelcomeView()
|
||||
// Welcome Window
|
||||
WindowGroup("Welcome", id: "welcome") {
|
||||
WelcomeView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 580, height: 480)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
|
||||
// Session Detail Window
|
||||
WindowGroup("Session Details", id: "session-detail", for: String.self) { $sessionId in
|
||||
if let sessionId,
|
||||
let session = sessionMonitor.sessions[sessionId]
|
||||
{
|
||||
SessionDetailView(session: session)
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} else {
|
||||
Text("Session not found")
|
||||
.frame(width: 400, height: 300)
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 580, height: 480)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
// Session Detail Window
|
||||
WindowGroup("Session Details", id: "session-detail", for: String.self) { $sessionId in
|
||||
if let sessionId,
|
||||
let session = sessionMonitor.sessions[sessionId]
|
||||
{
|
||||
SessionDetailView(session: session)
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} else {
|
||||
Text("Session not found")
|
||||
.frame(width: 400, height: 300)
|
||||
}
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("About VibeTunnel") {
|
||||
SettingsOpener.openSettings()
|
||||
// Navigate to About tab after settings opens
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
NotificationCenter.default.post(
|
||||
name: .openSettingsTab,
|
||||
object: SettingsTab.about
|
||||
)
|
||||
}
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("About VibeTunnel") {
|
||||
SettingsOpener.openSettings()
|
||||
// Navigate to About tab after settings opens
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
NotificationCenter.default.post(
|
||||
name: .openSettingsTab,
|
||||
object: SettingsTab.about
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} label: {
|
||||
Image("menubar")
|
||||
.renderingMode(.template)
|
||||
}
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} label: {
|
||||
Image("menubar")
|
||||
.renderingMode(.template)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -121,25 +121,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
|||
NSClassFromString("XCTestCase") != nil
|
||||
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
#if DEBUG
|
||||
let isRunningInDebug = true
|
||||
let isRunningInDebug = true
|
||||
#else
|
||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||
.contains("libMainThreadChecker.dylib") ?? false ||
|
||||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
|
||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||
.contains("libMainThreadChecker.dylib") ?? false ||
|
||||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
|
||||
#endif
|
||||
|
||||
// Handle single instance check before doing anything else
|
||||
#if DEBUG
|
||||
// Skip single instance check in debug builds
|
||||
#else
|
||||
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
|
||||
handleSingleInstanceCheck()
|
||||
registerForDistributedNotifications()
|
||||
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
|
||||
handleSingleInstanceCheck()
|
||||
registerForDistributedNotifications()
|
||||
|
||||
// Check if app needs to be moved to Applications folder
|
||||
let applicationMover = ApplicationMover()
|
||||
applicationMover.checkAndOfferToMoveToApplications()
|
||||
}
|
||||
// Check if app needs to be moved to Applications folder
|
||||
let applicationMover = ApplicationMover()
|
||||
applicationMover.checkAndOfferToMoveToApplications()
|
||||
}
|
||||
#endif
|
||||
|
||||
// Initialize Sparkle updater manager
|
||||
|
|
@ -340,11 +340,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
|||
NSClassFromString("XCTestCase") != nil
|
||||
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
#if DEBUG
|
||||
let isRunningInDebug = true
|
||||
let isRunningInDebug = true
|
||||
#else
|
||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||
.contains("libMainThreadChecker.dylib") ?? false ||
|
||||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
|
||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||
.contains("libMainThreadChecker.dylib") ?? false ||
|
||||
processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil
|
||||
#endif
|
||||
|
||||
// Skip cleanup during tests
|
||||
|
|
@ -374,13 +374,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
|||
#if DEBUG
|
||||
// Skip removing observer in debug builds
|
||||
#else
|
||||
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: Self.showSettingsNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
if !isRunningInPreview && !isRunningInTests && !isRunningInDebug {
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: Self.showSettingsNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Remove update check notification observer
|
||||
|
|
|
|||
Loading…
Reference in a new issue