Skip to content

Commit 20767a5

Browse files
Add push notifications onboarding screen (amantus-ai#474)
Co-authored-by: Diego Petrucci <baulei@icloud.com>
1 parent 3e760e6 commit 20767a5

6 files changed

Lines changed: 185 additions & 52 deletions

File tree

docs/CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ We love your input! We want to make contributing to VibeTunnel as easy and trans
3939
3. **Open the Xcode project**
4040
```bash
4141
# From the root directory
42-
open mac/VibeTunnel-Mac.xcworkspace
42+
open mac/VibeTunnel-Mac.xcodeproj
4343
```
4444

4545
4. **Configure code signing (optional for development)**

mac/VibeTunnel/Core/Services/NotificationService.swift

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,6 @@ final class NotificationService: NSObject {
6868

6969
logger.info("🔔 Starting notification service...")
7070

71-
// Check authorization status first
72-
await checkAndRequestNotificationPermissions()
73-
7471
connect()
7572
}
7673

@@ -83,10 +80,7 @@ final class NotificationService: NSObject {
8380
func requestPermissionAndShowTestNotification() async -> Bool {
8481
let center = UNUserNotificationCenter.current()
8582

86-
// First check current authorization status
87-
let settings = await center.notificationSettings()
88-
89-
switch settings.authorizationStatus {
83+
switch await authorizationStatus() {
9084
case .notDetermined:
9185
// First time - request permission
9286
do {
@@ -205,7 +199,7 @@ final class NotificationService: NSObject {
205199
}
206200

207201
/// Open System Settings to the Notifications pane
208-
private func openNotificationSettings() {
202+
func openNotificationSettings() {
209203
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") {
210204
NSWorkspace.shared.open(url)
211205
}
@@ -241,35 +235,34 @@ final class NotificationService: NSObject {
241235
// In the future, we could add a proper observation mechanism
242236
}
243237

244-
// MARK: - Private Methods
238+
/// Check the local notifications authorization status
239+
func authorizationStatus() async -> UNAuthorizationStatus {
240+
await UNUserNotificationCenter.current()
241+
.notificationSettings()
242+
.authorizationStatus
243+
}
245244

246-
private nonisolated func checkAndRequestNotificationPermissions() async {
247-
let center = UNUserNotificationCenter.current()
248-
let settings = await center.notificationSettings()
249-
let authStatus = settings.authorizationStatus
245+
/// Request notifications authorization
246+
@discardableResult
247+
func requestAuthorization() async throws -> Bool {
248+
do {
249+
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [
250+
.alert,
251+
.sound,
252+
.badge
253+
])
250254

251-
await MainActor.run {
252-
if authStatus == .notDetermined {
253-
logger.info("🔔 Notification permissions not determined, requesting authorization...")
254-
} else {
255-
logger.info("🔔 Notification authorization status: \(authStatus.rawValue)")
256-
}
257-
}
255+
logger.info("Notification permission granted: \(granted)")
258256

259-
if authStatus == .notDetermined {
260-
do {
261-
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
262-
await MainActor.run {
263-
logger.info("🔔 Notification permission granted: \(granted)")
264-
}
265-
} catch {
266-
await MainActor.run {
267-
logger.error("🔔 Failed to request notification permissions: \(error)")
268-
}
269-
}
257+
return granted
258+
} catch {
259+
logger.error("Failed to request notification permissions: \(error)")
260+
throw error
270261
}
271262
}
272263

264+
// MARK: - Private Methods
265+
273266
private func setupNotifications() {
274267
// Listen for server state changes
275268
NotificationCenter.default.addObserver(
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Foundation
2+
3+
func isRunningPreviews() -> Bool {
4+
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != nil
5+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import os.log
2+
import SwiftUI
3+
import UserNotifications
4+
5+
/// Notification permission page for onboarding flow.
6+
///
7+
/// Allows users to enable native macOS notifications for VibeTunnel events
8+
/// during the welcome flow. Users can grant permissions or skip and enable later.
9+
struct NotificationPermissionPageView: View {
10+
private let notificationService = NotificationService.shared
11+
@State private var isRequestingPermission = false
12+
@State private var permissionStatus: UNAuthorizationStatus = .notDetermined
13+
14+
private let logger = Logger(
15+
subsystem: "sh.vibetunnel.vibetunnel",
16+
category: "NotificationPermissionPageView"
17+
)
18+
19+
#if DEBUG
20+
init(permissionStatus: UNAuthorizationStatus = .notDetermined) {
21+
self.permissionStatus = permissionStatus
22+
}
23+
#endif
24+
25+
var body: some View {
26+
VStack(spacing: 30) {
27+
VStack(spacing: 16) {
28+
Text("Enable Notifications")
29+
.font(.largeTitle)
30+
.fontWeight(.semibold)
31+
32+
Text(
33+
"Get notified about session events, command completions, and errors. You can customize which notifications to receive in Settings."
34+
)
35+
.font(.body)
36+
.foregroundColor(.secondary)
37+
.multilineTextAlignment(.center)
38+
.frame(maxWidth: 480)
39+
.fixedSize(horizontal: false, vertical: true)
40+
41+
if permissionStatus != .denied {
42+
// Notification examples
43+
VStack(alignment: .leading, spacing: 12) {
44+
Label("Session starts and exits", systemImage: "terminal")
45+
Label("Command completions and errors", systemImage: "exclamationmark.triangle")
46+
Label("Terminal bell events", systemImage: "bell")
47+
}
48+
.font(.callout)
49+
.foregroundColor(.secondary)
50+
.padding()
51+
.background(Color(NSColor.controlBackgroundColor))
52+
.cornerRadius(8)
53+
.frame(maxWidth: 400)
54+
}
55+
56+
// Permission button/status
57+
if permissionStatus == .authorized {
58+
HStack {
59+
Image(systemName: "checkmark.circle.fill")
60+
.foregroundColor(.green)
61+
Text("Notifications enabled")
62+
.foregroundColor(.secondary)
63+
}
64+
.font(.body)
65+
.frame(height: 32)
66+
} else if permissionStatus == .denied {
67+
VStack(spacing: 8) {
68+
HStack {
69+
Image(systemName: "exclamationmark.triangle.fill")
70+
.foregroundColor(.orange)
71+
Text("Notifications are disabled")
72+
.foregroundColor(.secondary)
73+
}
74+
.font(.body)
75+
76+
Button("Open System Settings") {
77+
notificationService.openNotificationSettings()
78+
}
79+
.buttonStyle(.borderedProminent)
80+
.frame(height: 32)
81+
}
82+
} else {
83+
Button(action: requestNotificationPermission) {
84+
if isRequestingPermission {
85+
ProgressView()
86+
.scaleEffect(0.5)
87+
.frame(width: 8, height: 8)
88+
} else {
89+
Text("Enable Notifications")
90+
}
91+
}
92+
.buttonStyle(.borderedProminent)
93+
.disabled(isRequestingPermission)
94+
.frame(height: 32)
95+
}
96+
}
97+
Spacer()
98+
}
99+
.padding()
100+
.task {
101+
if !isRunningPreviews() {
102+
await checkNotificationPermission()
103+
}
104+
}
105+
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
106+
// Check permissions when returning from System Settings
107+
Task {
108+
await checkNotificationPermission()
109+
}
110+
}
111+
}
112+
113+
private func checkNotificationPermission() async {
114+
permissionStatus = await notificationService.authorizationStatus()
115+
}
116+
117+
private func requestNotificationPermission() {
118+
Task {
119+
isRequestingPermission = true
120+
defer { isRequestingPermission = false }
121+
_ = try? await notificationService.requestAuthorization()
122+
// Update permission status after request
123+
await checkNotificationPermission()
124+
}
125+
}
126+
}
127+
128+
#Preview("Not determined") {
129+
NotificationPermissionPageView(permissionStatus: .notDetermined)
130+
.frame(width: 640, height: 480)
131+
.background(Color(NSColor.windowBackgroundColor))
132+
}
133+
134+
#Preview("Authorized") {
135+
NotificationPermissionPageView(permissionStatus: .authorized)
136+
.frame(width: 640, height: 480)
137+
.background(Color(NSColor.windowBackgroundColor))
138+
}
139+
140+
#Preview("Permissions denied") {
141+
NotificationPermissionPageView(permissionStatus: .denied)
142+
.frame(width: 640, height: 480)
143+
.background(Color(NSColor.windowBackgroundColor))
144+
}

mac/VibeTunnel/Presentation/Views/WelcomeView.swift

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import SwiftUI
1010
/// ## Topics
1111
///
1212
/// ### Overview
13-
/// The welcome flow consists of eight pages:
13+
/// The welcome flow consists of nine pages:
1414
/// - ``WelcomePageView`` - Introduction and app overview
1515
/// - ``VTCommandPageView`` - CLI tool installation
1616
/// - ``RequestPermissionsPageView`` - System permissions setup
1717
/// - ``SelectTerminalPageView`` - Terminal selection and testing
1818
/// - ``ProjectFolderPageView`` - Project folder configuration
1919
/// - ``ProtectDashboardPageView`` - Dashboard security configuration
20+
/// - ``NotificationPermissionPageView`` - Notification permissions setup
2021
/// - ``ControlAgentArmyPageView`` - Managing multiple AI agent sessions
2122
/// - ``AccessDashboardPageView`` - Remote access instructions
2223
struct WelcomeView: View {
@@ -72,11 +73,15 @@ struct WelcomeView: View {
7273
ProtectDashboardPageView()
7374
.frame(width: pageWidth)
7475

75-
// Page 7: Control Your Agent Army
76+
// Page 7: Notification Permissions
77+
NotificationPermissionPageView()
78+
.frame(width: pageWidth)
79+
80+
// Page 8: Control Your Agent Army
7681
ControlAgentArmyPageView()
7782
.frame(width: pageWidth)
7883

79-
// Page 8: Accessing Dashboard
84+
// Page 9: Accessing Dashboard
8085
AccessDashboardPageView()
8186
.frame(width: pageWidth)
8287
}
@@ -123,7 +128,7 @@ struct WelcomeView: View {
123128

124129
// Page indicators centered
125130
HStack(spacing: 8) {
126-
ForEach(0..<8) { index in
131+
ForEach(0..<9) { index in
127132
Button {
128133
withAnimation {
129134
currentPage = index
@@ -164,7 +169,7 @@ struct WelcomeView: View {
164169
}
165170

166171
private var buttonTitle: String {
167-
currentPage == 7 ? "Finish" : "Next"
172+
currentPage == 8 ? "Finish" : "Next"
168173
}
169174

170175
private func handleBackAction() {
@@ -174,7 +179,7 @@ struct WelcomeView: View {
174179
}
175180

176181
private func handleNextAction() {
177-
if currentPage < 7 {
182+
if currentPage < 8 {
178183
withAnimation {
179184
currentPage += 1
180185
}

mac/VibeTunnel/VibeTunnelApp.swift

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -201,20 +201,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
201201
// Set up notification center delegate
202202
UNUserNotificationCenter.current().delegate = self
203203

204-
// Request notification permissions
205-
Task {
206-
do {
207-
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [
208-
.alert,
209-
.sound,
210-
.badge
211-
])
212-
logger.info("Notification permission granted: \(granted)")
213-
} catch {
214-
logger.error("Failed to request notification permissions: \(error)")
215-
}
216-
}
217-
218204
// Initialize dock icon visibility through DockIconManager
219205
DockIconManager.shared.updateDockVisibility()
220206

0 commit comments

Comments
 (0)