From c6a299ac5f70bd2a6a02e8b0cd9e17d7eb628259 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 27 Jul 2025 14:12:30 +0200 Subject: [PATCH] Add push notifications onboarding screen (#474) Co-authored-by: Diego Petrucci --- docs/CONTRIBUTING.md | 2 +- .../Core/Services/NotificationService.swift | 55 +++---- .../Core/Utilities/IsRunningPreviews.swift | 5 + .../NotificationPermissionPageView.swift | 144 ++++++++++++++++++ .../Presentation/Views/WelcomeView.swift | 17 ++- mac/VibeTunnel/VibeTunnelApp.swift | 14 -- 6 files changed, 185 insertions(+), 52 deletions(-) create mode 100644 mac/VibeTunnel/Core/Utilities/IsRunningPreviews.swift create mode 100644 mac/VibeTunnel/Presentation/Views/Welcome/NotificationPermissionPageView.swift diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ff313ead..a4fb45b0 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -39,7 +39,7 @@ We love your input! We want to make contributing to VibeTunnel as easy and trans 3. **Open the Xcode project** ```bash # From the root directory - open mac/VibeTunnel-Mac.xcworkspace + open mac/VibeTunnel-Mac.xcodeproj ``` 4. **Configure code signing (optional for development)** diff --git a/mac/VibeTunnel/Core/Services/NotificationService.swift b/mac/VibeTunnel/Core/Services/NotificationService.swift index 4ad7761b..d18a63d7 100644 --- a/mac/VibeTunnel/Core/Services/NotificationService.swift +++ b/mac/VibeTunnel/Core/Services/NotificationService.swift @@ -68,9 +68,6 @@ final class NotificationService: NSObject { logger.info("🔔 Starting notification service...") - // Check authorization status first - await checkAndRequestNotificationPermissions() - connect() } @@ -83,10 +80,7 @@ final class NotificationService: NSObject { func requestPermissionAndShowTestNotification() async -> Bool { let center = UNUserNotificationCenter.current() - // First check current authorization status - let settings = await center.notificationSettings() - - switch settings.authorizationStatus { + switch await authorizationStatus() { case .notDetermined: // First time - request permission do { @@ -205,7 +199,7 @@ final class NotificationService: NSObject { } /// Open System Settings to the Notifications pane - private func openNotificationSettings() { + func openNotificationSettings() { if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") { NSWorkspace.shared.open(url) } @@ -241,35 +235,34 @@ final class NotificationService: NSObject { // In the future, we could add a proper observation mechanism } - // MARK: - Private Methods + /// Check the local notifications authorization status + func authorizationStatus() async -> UNAuthorizationStatus { + await UNUserNotificationCenter.current() + .notificationSettings() + .authorizationStatus + } - private nonisolated func checkAndRequestNotificationPermissions() async { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - let authStatus = settings.authorizationStatus + /// Request notifications authorization + @discardableResult + func requestAuthorization() async throws -> Bool { + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [ + .alert, + .sound, + .badge + ]) - await MainActor.run { - if authStatus == .notDetermined { - logger.info("🔔 Notification permissions not determined, requesting authorization...") - } else { - logger.info("🔔 Notification authorization status: \(authStatus.rawValue)") - } - } + logger.info("Notification permission granted: \(granted)") - if authStatus == .notDetermined { - do { - let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) - await MainActor.run { - logger.info("🔔 Notification permission granted: \(granted)") - } - } catch { - await MainActor.run { - logger.error("🔔 Failed to request notification permissions: \(error)") - } - } + return granted + } catch { + logger.error("Failed to request notification permissions: \(error)") + throw error } } + // MARK: - Private Methods + private func setupNotifications() { // Listen for server state changes NotificationCenter.default.addObserver( diff --git a/mac/VibeTunnel/Core/Utilities/IsRunningPreviews.swift b/mac/VibeTunnel/Core/Utilities/IsRunningPreviews.swift new file mode 100644 index 00000000..d171b69a --- /dev/null +++ b/mac/VibeTunnel/Core/Utilities/IsRunningPreviews.swift @@ -0,0 +1,5 @@ +import Foundation + +func isRunningPreviews() -> Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != nil +} diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/NotificationPermissionPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/NotificationPermissionPageView.swift new file mode 100644 index 00000000..2ec31dc4 --- /dev/null +++ b/mac/VibeTunnel/Presentation/Views/Welcome/NotificationPermissionPageView.swift @@ -0,0 +1,144 @@ +import os.log +import SwiftUI +import UserNotifications + +/// Notification permission page for onboarding flow. +/// +/// Allows users to enable native macOS notifications for VibeTunnel events +/// during the welcome flow. Users can grant permissions or skip and enable later. +struct NotificationPermissionPageView: View { + private let notificationService = NotificationService.shared + @State private var isRequestingPermission = false + @State private var permissionStatus: UNAuthorizationStatus = .notDetermined + + private let logger = Logger( + subsystem: "sh.vibetunnel.vibetunnel", + category: "NotificationPermissionPageView" + ) + + #if DEBUG + init(permissionStatus: UNAuthorizationStatus = .notDetermined) { + self.permissionStatus = permissionStatus + } + #endif + + var body: some View { + VStack(spacing: 30) { + VStack(spacing: 16) { + Text("Enable Notifications") + .font(.largeTitle) + .fontWeight(.semibold) + + Text( + "Get notified about session events, command completions, and errors. You can customize which notifications to receive in Settings." + ) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + .fixedSize(horizontal: false, vertical: true) + + if permissionStatus != .denied { + // Notification examples + VStack(alignment: .leading, spacing: 12) { + Label("Session starts and exits", systemImage: "terminal") + Label("Command completions and errors", systemImage: "exclamationmark.triangle") + Label("Terminal bell events", systemImage: "bell") + } + .font(.callout) + .foregroundColor(.secondary) + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(8) + .frame(maxWidth: 400) + } + + // Permission button/status + if permissionStatus == .authorized { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Notifications enabled") + .foregroundColor(.secondary) + } + .font(.body) + .frame(height: 32) + } else if permissionStatus == .denied { + VStack(spacing: 8) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Notifications are disabled") + .foregroundColor(.secondary) + } + .font(.body) + + Button("Open System Settings") { + notificationService.openNotificationSettings() + } + .buttonStyle(.borderedProminent) + .frame(height: 32) + } + } else { + Button(action: requestNotificationPermission) { + if isRequestingPermission { + ProgressView() + .scaleEffect(0.5) + .frame(width: 8, height: 8) + } else { + Text("Enable Notifications") + } + } + .buttonStyle(.borderedProminent) + .disabled(isRequestingPermission) + .frame(height: 32) + } + } + Spacer() + } + .padding() + .task { + if !isRunningPreviews() { + await checkNotificationPermission() + } + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + // Check permissions when returning from System Settings + Task { + await checkNotificationPermission() + } + } + } + + private func checkNotificationPermission() async { + permissionStatus = await notificationService.authorizationStatus() + } + + private func requestNotificationPermission() { + Task { + isRequestingPermission = true + defer { isRequestingPermission = false } + _ = try? await notificationService.requestAuthorization() + // Update permission status after request + await checkNotificationPermission() + } + } +} + +#Preview("Not determined") { + NotificationPermissionPageView(permissionStatus: .notDetermined) + .frame(width: 640, height: 480) + .background(Color(NSColor.windowBackgroundColor)) +} + +#Preview("Authorized") { + NotificationPermissionPageView(permissionStatus: .authorized) + .frame(width: 640, height: 480) + .background(Color(NSColor.windowBackgroundColor)) +} + +#Preview("Permissions denied") { + NotificationPermissionPageView(permissionStatus: .denied) + .frame(width: 640, height: 480) + .background(Color(NSColor.windowBackgroundColor)) +} diff --git a/mac/VibeTunnel/Presentation/Views/WelcomeView.swift b/mac/VibeTunnel/Presentation/Views/WelcomeView.swift index 2153d45f..32eabf3c 100644 --- a/mac/VibeTunnel/Presentation/Views/WelcomeView.swift +++ b/mac/VibeTunnel/Presentation/Views/WelcomeView.swift @@ -10,13 +10,14 @@ import SwiftUI /// ## Topics /// /// ### Overview -/// The welcome flow consists of eight pages: +/// The welcome flow consists of nine pages: /// - ``WelcomePageView`` - Introduction and app overview /// - ``VTCommandPageView`` - CLI tool installation /// - ``RequestPermissionsPageView`` - System permissions setup /// - ``SelectTerminalPageView`` - Terminal selection and testing /// - ``ProjectFolderPageView`` - Project folder configuration /// - ``ProtectDashboardPageView`` - Dashboard security configuration +/// - ``NotificationPermissionPageView`` - Notification permissions setup /// - ``ControlAgentArmyPageView`` - Managing multiple AI agent sessions /// - ``AccessDashboardPageView`` - Remote access instructions struct WelcomeView: View { @@ -72,11 +73,15 @@ struct WelcomeView: View { ProtectDashboardPageView() .frame(width: pageWidth) - // Page 7: Control Your Agent Army + // Page 7: Notification Permissions + NotificationPermissionPageView() + .frame(width: pageWidth) + + // Page 8: Control Your Agent Army ControlAgentArmyPageView() .frame(width: pageWidth) - // Page 8: Accessing Dashboard + // Page 9: Accessing Dashboard AccessDashboardPageView() .frame(width: pageWidth) } @@ -123,7 +128,7 @@ struct WelcomeView: View { // Page indicators centered HStack(spacing: 8) { - ForEach(0..<8) { index in + ForEach(0..<9) { index in Button { withAnimation { currentPage = index @@ -164,7 +169,7 @@ struct WelcomeView: View { } private var buttonTitle: String { - currentPage == 7 ? "Finish" : "Next" + currentPage == 8 ? "Finish" : "Next" } private func handleBackAction() { @@ -174,7 +179,7 @@ struct WelcomeView: View { } private func handleNextAction() { - if currentPage < 7 { + if currentPage < 8 { withAnimation { currentPage += 1 } diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index b8c18b56..372506bf 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -201,20 +201,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // Set up notification center delegate UNUserNotificationCenter.current().delegate = self - // Request notification permissions - Task { - do { - let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [ - .alert, - .sound, - .badge - ]) - logger.info("Notification permission granted: \(granted)") - } catch { - logger.error("Failed to request notification permissions: \(error)") - } - } - // Initialize dock icon visibility through DockIconManager DockIconManager.shared.updateDockVisibility()