Add SSE connection status indicator to notification settings

- Show real-time connection status with colored indicator
- Display 'Event Stream: Connected/Disconnected' on separate row
- Add warning message when notifications enabled but connection is down
- Update connection status via NotificationCenter events
- Add debugging logs to EventSource for better troubleshooting
This commit is contained in:
Peter Steinberger 2025-07-28 18:06:02 +02:00
parent 5083cb5c1e
commit dad2f3380e
13 changed files with 261 additions and 116 deletions

View file

@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(pnpm test:*)",
"Bash(rg:*)"
"Bash(rg:*)",
"Bash(./scripts/vtlog.sh:*)",
"Bash(ls:*)"
],
"deny": []
},

View file

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

View file

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

View file

@ -72,6 +72,11 @@ final class BunServer {
return localAuthToken
}
/// Get the current authentication mode
var authMode: String {
AuthConfig.current().mode
}
// MARK: - Initialization
init() {

View file

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

View file

@ -22,6 +22,9 @@ final class NotificationService: NSObject {
private var recentlyNotifiedSessions = Set<String>()
private var notificationCleanupTimer: Timer?
/// Public property to check SSE connection status
var isSSEConnected: Bool { isConnected }
/// Notification types that can be enabled/disabled
struct NotificationPreferences {
var sessionStart: Bool
@ -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)")
// 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")
}

View file

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

View file

@ -23,6 +23,10 @@ final class SharedUnixSocketManager {
logger.info("🚀 SharedUnixSocketManager initialized")
}
// MARK: - Notifications
static let unixSocketReadyNotification = Notification.Name("unixSocketReady")
// MARK: - Public Methods
/// Get or create the shared Unix socket connection
@ -42,10 +46,36 @@ final class SharedUnixSocketManager {
}
}
// 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 {
unixSocket?.isConnected ?? false

View file

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

View file

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

View file

@ -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;
}
}
</style>
`;
}
@ -455,7 +412,7 @@ export class TerminalQuickKeys extends LitElement {
>
<div class="quick-keys-bar">
<!-- Row 1 -->
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap">
<div class="flex gap-0.5 mb-0.5 overflow-x-auto scrollbar-hide px-0.5">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map(
({ key, label, modifier, arrow, toggle }) => html`
<button
@ -507,7 +464,7 @@ export class TerminalQuickKeys extends LitElement {
this.showCtrlKeys
? html`
<!-- Ctrl shortcuts row -->
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5">
<div class="flex gap-0.5 mb-0.5 overflow-x-auto scrollbar-hide px-0.5">
${CTRL_SHORTCUTS.map(
({ key, label, combo, special }) => html`
<button
@ -542,7 +499,7 @@ export class TerminalQuickKeys extends LitElement {
: this.showFunctionKeys
? html`
<!-- Function keys row -->
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5">
<div class="flex gap-0.5 mb-0.5 overflow-x-auto scrollbar-hide px-0.5">
${FUNCTION_KEYS.map(
({ key, label }) => html`
<button
@ -576,7 +533,7 @@ export class TerminalQuickKeys extends LitElement {
`
: html`
<!-- Regular row 2 -->
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap">
<div class="flex gap-0.5 mb-0.5 overflow-x-auto scrollbar-hide px-0.5">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map(
({ key, label, modifier, combo, special, toggle }) => html`
<button
@ -597,12 +554,12 @@ export class TerminalQuickKeys extends LitElement {
if (key === 'Paste') {
this.handlePasteImmediate(e);
} else {
this.handleKeyPress(key, modifier || combo, special, toggle, e);
this.handleKeyPress(key, modifier || combo, special, false, e);
}
}}
@click=${(e: MouseEvent) => {
if (e.detail !== 0) {
this.handleKeyPress(key, modifier || combo, special, toggle, e);
this.handleKeyPress(key, modifier || combo, special, false, e);
}
}}
>
@ -615,7 +572,7 @@ export class TerminalQuickKeys extends LitElement {
}
<!-- Row 3 - Additional special characters (always visible) -->
<div class="flex gap-0.5 justify-center flex-wrap">
<div class="flex gap-0.5 overflow-x-auto scrollbar-hide px-0.5">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map(
({ key, label, modifier, combo, special }) => html`
<button

View file

@ -473,6 +473,17 @@
/* Micro-interactions and animations */
@layer utilities {
/* Hide scrollbar for webkit browsers */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Smooth transitions for interactive elements */
.interactive {
@apply transition-all duration-200 ease-out;

View file

@ -40,9 +40,9 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
}
};
// Send initial connection event
// Send initial connection event as default message event
try {
res.write('event: connected\ndata: {"type": "connected"}\n\n');
res.write('data: {"type": "connected"}\n\n');
} catch (error) {
logger.debug('Failed to send initial connection event:', error);
return;
@ -69,8 +69,9 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
logger.info('🧪 Forwarding test notification through SSE:', event);
}
// Proper SSE format with id, event, and data fields
const sseMessage = `id: ${++eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
// Send as default message event (not named event) for compatibility with Mac EventSource
// The event type is already included in the data payload
const sseMessage = `id: ${++eventId}\ndata: ${JSON.stringify(event)}\n\n`;
try {
res.write(sseMessage);