mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Add push notifications onboarding screen (#474)
Co-authored-by: Diego Petrucci <baulei@icloud.com>
This commit is contained in:
parent
5128142b1b
commit
c6a299ac5f
6 changed files with 185 additions and 52 deletions
|
|
@ -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)**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
5
mac/VibeTunnel/Core/Utilities/IsRunningPreviews.swift
Normal file
5
mac/VibeTunnel/Core/Utilities/IsRunningPreviews.swift
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
func isRunningPreviews() -> Bool {
|
||||
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != nil
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue