Add push notifications onboarding screen (#474)

Co-authored-by: Diego Petrucci <baulei@icloud.com>
This commit is contained in:
Peter Steinberger 2025-07-27 14:12:30 +02:00 committed by GitHub
parent 5128142b1b
commit c6a299ac5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 52 deletions

View file

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

View file

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

View file

@ -0,0 +1,5 @@
import Foundation
func isRunningPreviews() -> Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != nil
}

View file

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

View file

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

View file

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