diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f2acc28f..c3b30faf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,9 @@ "permissions": { "allow": [ "Bash(pnpm test:*)", - "Bash(rg:*)" + "Bash(rg:*)", + "Bash(./scripts/vtlog.sh:*)", + "Bash(ls:*)" ], "deny": [] }, diff --git a/CLAUDE.md b/CLAUDE.md index b325e2e1..70cd9c1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,13 +282,19 @@ VibeTunnel includes a powerful log viewing utility for debugging and monitoring: **Common usage**: ```bash -./scripts/vtlog.sh -f # Follow logs in real-time +# View recent logs (RECOMMENDED for debugging): ./scripts/vtlog.sh -n 200 # Show last 200 lines +./scripts/vtlog.sh -n 500 -s "error" # Search last 500 lines for "error" ./scripts/vtlog.sh -e # Show only errors ./scripts/vtlog.sh -c ServerManager # Show logs from specific component ./scripts/vtlog.sh --server -e # Show server errors + +# Follow logs in real-time (AVOID in Claude Code - causes timeouts): +./scripts/vtlog.sh -f # Follow logs in real-time - DO NOT USE for checking existing logs ``` +**Important for Claude Code**: Always use `-n` to check a specific number of recent log lines. Do NOT use `-f` (follow mode) as it will block and timeout after 2 minutes. Follow mode is only useful when monitoring logs in a separate terminal while performing actions. + **Log prefixes**: - `[FE]` - Frontend (browser) logs - `[SRV]` - Server-side logs from Node.js/Bun diff --git a/mac/VibeTunnel/Core/Constants/NotificationNames.swift b/mac/VibeTunnel/Core/Constants/NotificationNames.swift index 739dc390..fd57def9 100644 --- a/mac/VibeTunnel/Core/Constants/NotificationNames.swift +++ b/mac/VibeTunnel/Core/Constants/NotificationNames.swift @@ -13,6 +13,10 @@ extension Notification.Name { // MARK: - Welcome static let showWelcomeScreen = Notification.Name("showWelcomeScreen") + + // MARK: - Services + + static let notificationServiceConnectionChanged = Notification.Name("notificationServiceConnectionChanged") } /// Notification categories for user notifications. diff --git a/mac/VibeTunnel/Core/Services/BunServer.swift b/mac/VibeTunnel/Core/Services/BunServer.swift index 4c7d8571..91259186 100644 --- a/mac/VibeTunnel/Core/Services/BunServer.swift +++ b/mac/VibeTunnel/Core/Services/BunServer.swift @@ -72,6 +72,11 @@ final class BunServer { return localAuthToken } + /// Get the current authentication mode + var authMode: String { + AuthConfig.current().mode + } + // MARK: - Initialization init() { diff --git a/mac/VibeTunnel/Core/Services/EventSource.swift b/mac/VibeTunnel/Core/Services/EventSource.swift index 8d095824..ee76a55c 100644 --- a/mac/VibeTunnel/Core/Services/EventSource.swift +++ b/mac/VibeTunnel/Core/Services/EventSource.swift @@ -121,6 +121,7 @@ final class EventSource: NSObject { } // Dispatch event + logger.debug("๐ŸŽฏ Dispatching event - type: \(event.event ?? "default"), data: \(event.data ?? "none")") DispatchQueue.main.async { self.onMessage?(event) } @@ -201,8 +202,12 @@ extension EventSource: URLSessionDataDelegate { } func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - guard let text = String(data: data, encoding: .utf8) else { return } + guard let text = String(data: data, encoding: .utf8) else { + logger.error("Failed to decode data as UTF-8") + return + } + logger.debug("๐Ÿ“จ EventSource received data: \(text)") buffer += text processBuffer() } diff --git a/mac/VibeTunnel/Core/Services/NotificationService.swift b/mac/VibeTunnel/Core/Services/NotificationService.swift index 3ccb50eb..e9c0ab33 100644 --- a/mac/VibeTunnel/Core/Services/NotificationService.swift +++ b/mac/VibeTunnel/Core/Services/NotificationService.swift @@ -21,6 +21,9 @@ final class NotificationService: NSObject { private var isConnected = false private var recentlyNotifiedSessions = Set() private var notificationCleanupTimer: Timer? + + /// Public property to check SSE connection status + var isSSEConnected: Bool { isConnected } /// Notification types that can be enabled/disabled struct NotificationPreferences { @@ -63,14 +66,50 @@ final class NotificationService: NSObject { /// Start monitoring server events func start() async { + logger.info("๐Ÿš€ NotificationService.start() called") guard serverManager.isRunning else { logger.warning("๐Ÿ”ด Server not running, cannot start notification service") return } - logger.info("๐Ÿ”” Starting notification service...") + logger.info("๐Ÿ”” Starting notification service - server is running on port \(self.serverManager.port)") - connect() + // Wait for Unix socket to be ready before connecting SSE + // This ensures the server is fully ready to accept connections + await MainActor.run { + waitForUnixSocketAndConnect() + } + } + + /// Wait for Unix socket ready notification then connect + private func waitForUnixSocketAndConnect() { + logger.info("โณ Waiting for Unix socket ready notification...") + + // Check if Unix socket is already connected + if SharedUnixSocketManager.shared.isConnected { + logger.info("โœ… Unix socket already connected, connecting to SSE immediately") + connect() + return + } + + // Listen for Unix socket ready notification + NotificationCenter.default.addObserver( + forName: SharedUnixSocketManager.unixSocketReadyNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.logger.info("โœ… Unix socket ready notification received, connecting to SSE") + self?.connect() + + // Remove observer after first notification to prevent duplicate connections + NotificationCenter.default.removeObserver( + self as Any, + name: SharedUnixSocketManager.unixSocketReadyNotification, + object: nil + ) + } + } } /// Stop monitoring server events @@ -372,60 +411,49 @@ final class NotificationService: NSObject { // MARK: - Private Methods private func setupNotifications() { - // Listen for server state changes - NotificationCenter.default.addObserver( - self, - selector: #selector(serverStateChanged), - name: .serverStateChanged, - object: nil - ) + // Note: We do NOT listen for server state changes here + // Connection is managed explicitly via start() and stop() methods + // This prevents dual-path connection attempts } - @objc - private func serverStateChanged(_ notification: Notification) { - if serverManager.isRunning { - logger.info("๐Ÿ”” Server started, initializing notification service...") - // Delay connection to ensure server is ready - Task { - try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds (increased delay) - await MainActor.run { - if serverManager.isRunning { - logger.info("๐Ÿ”” Server ready, connecting notification service...") - connect() - } else { - logger.warning("๐Ÿ”ด Server stopped before notification service could connect") - } - } - } - } else { - logger.info("๐Ÿ”” Server stopped, disconnecting notification service...") - disconnect() - } - } private func connect() { + logger.info("๐Ÿ”Œ NotificationService.connect() called - isConnected: \(self.isConnected)") guard !isConnected else { logger.info("Already connected to notification service") return } - guard let authToken = serverManager.localAuthToken else { - logger.error("No auth token available for notification service") + // When auth mode is "none", we can connect without a token. + // In any other auth mode, a token is required for the local Mac app to connect. + if serverManager.authMode != "none", serverManager.localAuthToken == nil { + logger.error("No auth token available for notification service in auth mode '\(self.serverManager.authMode)'") return } - guard let url = URL(string: "http://localhost:\(serverManager.port)/api/events") else { - logger.error("Invalid events URL") + let eventsURL = "http://localhost:\(self.serverManager.port)/api/events" + logger.info("๐Ÿ“ก Attempting to connect to SSE endpoint: \(eventsURL)") + + guard let url = URL(string: eventsURL) else { + logger.error("Invalid events URL: \(eventsURL)") return } // Create headers var headers: [String: String] = [ - "Authorization": "Bearer \(authToken)", "Accept": "text/event-stream", "Cache-Control": "no-cache" ] + // Add authorization header if auth token is available. + // When auth mode is "none", there's no token, and that's okay. + if let authToken = serverManager.localAuthToken { + headers["Authorization"] = "Bearer \(authToken)" + logger.info("๐Ÿ”‘ Using auth token for SSE connection") + } else { + logger.info("๐Ÿ”“ Connecting to SSE without an auth token (auth mode: '\(self.serverManager.authMode)')") + } + // Add custom header to indicate this is the Mac app headers["X-VibeTunnel-Client"] = "mac-app" @@ -435,6 +463,8 @@ final class NotificationService: NSObject { Task { @MainActor in self?.logger.info("โœ… Connected to notification event stream") self?.isConnected = true + // Post notification for UI update + NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) } } @@ -444,12 +474,15 @@ final class NotificationService: NSObject { self?.logger.error("โŒ EventSource error: \(error)") } self?.isConnected = false + // Post notification for UI update + NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) // Don't reconnect here - let server state changes trigger reconnection } } eventSource?.onMessage = { [weak self] event in Task { @MainActor in + self?.logger.info("๐ŸŽฏ EventSource onMessage fired! Event type: \(event.event ?? "default"), Has data: \(event.data != nil)") self?.handleEvent(event) } } @@ -462,12 +495,19 @@ final class NotificationService: NSObject { eventSource = nil isConnected = false logger.info("Disconnected from notification service") + // Post notification for UI update + NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) } private func handleEvent(_ event: Event) { - guard let data = event.data else { return } + guard let data = event.data else { + logger.warning("Received event with no data") + return + } - logger.debug("๐Ÿ“จ Received event: \(data)") + // Log event details for debugging + logger.debug("๐Ÿ“จ Received SSE event - Type: \(event.event ?? "message"), ID: \(event.id ?? "none")") + logger.debug("๐Ÿ“จ Event data: \(data)") do { guard let jsonData = data.data(using: .utf8) else { @@ -757,27 +797,63 @@ final class NotificationService: NSObject { func sendServerTestNotification() async { logger.info("๐Ÿงช Sending test notification through server...") - guard let url = serverManager.buildURL(endpoint: "/api/test-notification") else { - logger.error("Failed to build test notification URL") + // Check if server is running + guard serverManager.isRunning else { + logger.error("โŒ Cannot send test notification - server is not running") return } + // If not connected to SSE, try to connect first + if !isConnected { + logger.warning("โš ๏ธ Not connected to SSE endpoint, attempting to connect...") + await MainActor.run { + connect() + } + // Give it a moment to connect + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + } + + // Log server info + logger.info("Server info - Port: \(self.serverManager.port), Running: \(self.serverManager.isRunning), SSE Connected: \(self.isConnected)") + + guard let url = serverManager.buildURL(endpoint: "/api/test-notification") else { + logger.error("โŒ Failed to build test notification URL") + return + } + + logger.info("๐Ÿ“ค Sending POST request to: \(url)") + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // Add auth token if available + if let authToken = serverManager.localAuthToken { + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + logger.debug("Added auth token to request") + } + do { - let (_, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { + logger.info("๐Ÿ“ฅ Received response - Status: \(httpResponse.statusCode)") + if httpResponse.statusCode == 200 { logger.info("โœ… Server test notification sent successfully") + if let responseData = String(data: data, encoding: .utf8) { + logger.debug("Response data: \(responseData)") + } } else { logger.error("โŒ Server test notification failed with status: \(httpResponse.statusCode)") + if let errorData = String(data: data, encoding: .utf8) { + logger.error("Error response: \(errorData)") + } } } } catch { logger.error("โŒ Failed to send server test notification: \(error)") + logger.error("Error details: \(error.localizedDescription)") } } @@ -788,8 +864,3 @@ final class NotificationService: NSObject { } } -// MARK: - Extensions - -extension Notification.Name { - static let serverStateChanged = Notification.Name("ServerStateChanged") -} diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift index d999ee83..7330f158 100644 --- a/mac/VibeTunnel/Core/Services/ServerManager.swift +++ b/mac/VibeTunnel/Core/Services/ServerManager.swift @@ -65,6 +65,11 @@ class ServerManager { bunServer?.localToken } + /// The current authentication mode of the server + var authMode: String { + bunServer?.authMode ?? "os" + } + var bindAddress: String { get { // Get the raw value from UserDefaults, defaulting to the app default @@ -309,8 +314,7 @@ class ServerManager { isRunning = false - // Post notification that server state has changed - NotificationCenter.default.post(name: .serverStateChanged, object: nil) + // Notification service connection is now handled explicitly via start() method // Clear the auth token from SessionMonitor SessionMonitor.shared.setLocalAuthToken(nil) diff --git a/mac/VibeTunnel/Core/Services/SharedUnixSocketManager.swift b/mac/VibeTunnel/Core/Services/SharedUnixSocketManager.swift index 22d8c85d..00e561c5 100644 --- a/mac/VibeTunnel/Core/Services/SharedUnixSocketManager.swift +++ b/mac/VibeTunnel/Core/Services/SharedUnixSocketManager.swift @@ -22,6 +22,10 @@ final class SharedUnixSocketManager { private init() { logger.info("๐Ÿš€ SharedUnixSocketManager initialized") } + + // MARK: - Notifications + + static let unixSocketReadyNotification = Notification.Name("unixSocketReady") // MARK: - Public Methods @@ -41,10 +45,36 @@ final class SharedUnixSocketManager { self?.distributeMessage(data) } } + + // Set up state change handler to notify when socket is ready + socket.onStateChange = { [weak self] state in + Task { @MainActor [weak self] in + self?.handleSocketStateChange(state) + } + } unixSocket = socket return socket } + + /// Handle socket state changes and notify when ready + private func handleSocketStateChange(_ state: UnixSocketConnection.ConnectionState) { + switch state { + case .ready: + logger.info("๐Ÿš€ Unix socket is ready, posting notification") + NotificationCenter.default.post(name: Self.unixSocketReadyNotification, object: nil) + case .failed(let error): + logger.error("โŒ Unix socket connection failed: \(error)") + case .cancelled: + logger.info("๐Ÿ›‘ Unix socket connection cancelled") + case .preparing: + logger.debug("๐Ÿ”„ Unix socket is preparing connection") + case .setup: + logger.debug("๐Ÿ”ง Unix socket is in setup state") + case .waiting(let error): + logger.warning("โณ Unix socket is waiting: \(error)") + } + } /// Check if the shared connection is connected var isConnected: Bool { diff --git a/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift index 6a0ca01f..76b53d04 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift @@ -14,6 +14,7 @@ struct NotificationSettingsView: View { @State private var isTestingNotification = false @State private var showingPermissionAlert = false + @State private var sseConnectionStatus = false private func updateNotificationPreferences() { // Load current preferences from ConfigManager and notify the service @@ -62,6 +63,36 @@ struct NotificationSettingsView: View { Text("Display native macOS notifications for session and command events") .font(.caption) .foregroundStyle(.secondary) + + // SSE Connection Status Row + HStack(spacing: 6) { + Circle() + .fill(sseConnectionStatus ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text("Event Stream:") + .font(.caption) + .foregroundStyle(.secondary) + Text(sseConnectionStatus ? "Connected" : "Disconnected") + .font(.caption) + .foregroundStyle(sseConnectionStatus ? .green : .red) + .fontWeight(.medium) + Spacer() + } + .help(sseConnectionStatus + ? "Real-time notification stream is connected" + : "Real-time notification stream is disconnected. Check if the server is running.") + + // Show warning when disconnected + if showNotifications && !sseConnectionStatus { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .font(.caption) + Text("Real-time notifications are unavailable. The server connection may be down.") + .font(.caption) + .foregroundStyle(.secondary) + } + } } .padding(.vertical, 8) } @@ -192,6 +223,14 @@ struct NotificationSettingsView: View { .onAppear { // Sync the AppStorage value with ConfigManager on first load showNotifications = configManager.notificationsEnabled + + // Update initial connection status + sseConnectionStatus = notificationService.isSSEConnected + } + .onReceive(NotificationCenter.default.publisher(for: .notificationServiceConnectionChanged)) { _ in + // Update connection status when it changes + sseConnectionStatus = notificationService.isSSEConnected + logger.debug("SSE connection status changed: \(sseConnectionStatus)") } } .alert("Notification Permission Required", isPresented: $showingPermissionAlert) { diff --git a/web/src/client/components/session-view/direct-keyboard-manager.ts b/web/src/client/components/session-view/direct-keyboard-manager.ts index 5ebcaedc..0e434a09 100644 --- a/web/src/client/components/session-view/direct-keyboard-manager.ts +++ b/web/src/client/components/session-view/direct-keyboard-manager.ts @@ -204,9 +204,19 @@ export class DirectKeyboardManager { // Focus synchronously - critical for iOS Safari this.hiddenInput.focus(); - // Also click synchronously to help trigger keyboard - this.hiddenInput.click(); - logger.log('Focused and clicked hidden input synchronously'); + // Set a dummy value and select it to help trigger iOS keyboard + // This helps iOS recognize that we want to show the keyboard + this.hiddenInput.value = ' '; + this.hiddenInput.setSelectionRange(0, 1); + + // Clear the dummy value after a short delay + setTimeout(() => { + if (this.hiddenInput) { + this.hiddenInput.value = ''; + } + }, 50); + + logger.log('Focused hidden input with dummy value trick'); } } diff --git a/web/src/client/components/terminal-quick-keys.ts b/web/src/client/components/terminal-quick-keys.ts index 0176c9d2..eb13b66f 100644 --- a/web/src/client/components/terminal-quick-keys.ts +++ b/web/src/client/components/terminal-quick-keys.ts @@ -109,17 +109,9 @@ export class TerminalQuickKeys extends LitElement { this.isLandscape = window.innerWidth > window.innerHeight && window.innerWidth > 600; } - private getButtonSizeClass(label: string): string { - if (label.length >= 4) { - // Long text: compact with max-width constraint - return this.isLandscape ? 'px-0.5 py-1 flex-1 max-w-14' : 'px-0.5 py-1.5 flex-1 max-w-16'; - } else if (label.length === 3) { - // Medium text: slightly more padding, larger max-width - return this.isLandscape ? 'px-1 py-1 flex-1 max-w-16' : 'px-1 py-1.5 flex-1 max-w-18'; - } else { - // Short text: can grow freely - return this.isLandscape ? 'px-1 py-1 flex-1' : 'px-1 py-1.5 flex-1'; - } + private getButtonSizeClass(_label: string): string { + // Use flexible sizing without constraining width + return this.isLandscape ? 'px-1 py-1' : 'px-1.5 py-1.5'; } private getButtonFontClass(label: string): string { @@ -210,7 +202,7 @@ export class TerminalQuickKeys extends LitElement { } if (this.onKeyPress) { - this.onKeyPress(key, isModifier, isSpecial); + this.onKeyPress(key, isModifier, isSpecial, isToggle); } } @@ -233,7 +225,7 @@ export class TerminalQuickKeys extends LitElement { // Send first key immediately if (this.onKeyPress) { - this.onKeyPress(key, isModifier, isSpecial); + this.onKeyPress(key, isModifier, isSpecial, false); } // Start repeat after 500ms initial delay @@ -279,31 +271,20 @@ export class TerminalQuickKeys extends LitElement { position: fixed; left: 0; right: 0; - /* Default to bottom of screen */ bottom: 0; z-index: 999999; - /* Ensure it stays on top */ - isolation: isolate; - /* Smooth transition when keyboard appears/disappears */ - transition: bottom 0.3s ease-out; background-color: rgb(var(--color-bg-secondary)); - /* Prevent horizontal overflow */ width: 100%; - max-width: 100vw; - overflow-x: hidden; + /* Properly handle safe areas */ + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); } /* The actual bar with buttons */ .quick-keys-bar { background: rgb(var(--color-bg-secondary)); border-top: 1px solid rgb(var(--color-border-base)); - padding: 0.25rem 0.25rem; - /* Prevent iOS from adding its own styling */ - -webkit-appearance: none; - appearance: none; - /* Add shadow for visibility */ - box-shadow: 0 -2px 10px rgb(var(--color-bg-secondary) / 0.5); - /* Ensure proper width */ + padding: 0.25rem 0; width: 100%; box-sizing: border-box; } @@ -314,8 +295,6 @@ export class TerminalQuickKeys extends LitElement { -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none; - /* Ensure buttons are clickable */ - touch-action: manipulation; } /* Modifier key styling */ @@ -360,20 +339,6 @@ export class TerminalQuickKeys extends LitElement { font-size: 8px; } - /* Max width constraints for buttons */ - .max-w-14 { - max-width: 3.5rem; /* 56px */ - } - - .max-w-16 { - max-width: 4rem; /* 64px */ - } - - .max-w-18 { - max-width: 4.5rem; /* 72px */ - } - - /* Combo key styling (like ^C, ^Z) */ .combo-key { background-color: rgb(var(--color-bg-tertiary)); @@ -401,7 +366,6 @@ export class TerminalQuickKeys extends LitElement { -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none; - touch-action: manipulation; } /* Toggle button styling */ @@ -430,15 +394,8 @@ export class TerminalQuickKeys extends LitElement { -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none; - touch-action: manipulation; } - /* Landscape mode adjustments */ - @media (orientation: landscape) and (max-width: 926px) { - .quick-keys-bar { - padding: 0.2rem 0.2rem; - } - } `; } @@ -455,7 +412,7 @@ export class TerminalQuickKeys extends LitElement { >
-
+
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map( ({ key, label, modifier, arrow, toggle }) => html`