mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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**
|
3. **Open the Xcode project**
|
||||||
```bash
|
```bash
|
||||||
# From the root directory
|
# From the root directory
|
||||||
open mac/VibeTunnel-Mac.xcworkspace
|
open mac/VibeTunnel-Mac.xcodeproj
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Configure code signing (optional for development)**
|
4. **Configure code signing (optional for development)**
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,6 @@ final class NotificationService: NSObject {
|
||||||
|
|
||||||
logger.info("🔔 Starting notification service...")
|
logger.info("🔔 Starting notification service...")
|
||||||
|
|
||||||
// Check authorization status first
|
|
||||||
await checkAndRequestNotificationPermissions()
|
|
||||||
|
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,10 +80,7 @@ final class NotificationService: NSObject {
|
||||||
func requestPermissionAndShowTestNotification() async -> Bool {
|
func requestPermissionAndShowTestNotification() async -> Bool {
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
// First check current authorization status
|
switch await authorizationStatus() {
|
||||||
let settings = await center.notificationSettings()
|
|
||||||
|
|
||||||
switch settings.authorizationStatus {
|
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
// First time - request permission
|
// First time - request permission
|
||||||
do {
|
do {
|
||||||
|
|
@ -205,7 +199,7 @@ final class NotificationService: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open System Settings to the Notifications pane
|
/// 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") {
|
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") {
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
@ -241,35 +235,34 @@ final class NotificationService: NSObject {
|
||||||
// In the future, we could add a proper observation mechanism
|
// 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 {
|
/// Request notifications authorization
|
||||||
let center = UNUserNotificationCenter.current()
|
@discardableResult
|
||||||
let settings = await center.notificationSettings()
|
func requestAuthorization() async throws -> Bool {
|
||||||
let authStatus = settings.authorizationStatus
|
do {
|
||||||
|
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||||
|
.alert,
|
||||||
|
.sound,
|
||||||
|
.badge
|
||||||
|
])
|
||||||
|
|
||||||
await MainActor.run {
|
logger.info("Notification permission granted: \(granted)")
|
||||||
if authStatus == .notDetermined {
|
|
||||||
logger.info("🔔 Notification permissions not determined, requesting authorization...")
|
|
||||||
} else {
|
|
||||||
logger.info("🔔 Notification authorization status: \(authStatus.rawValue)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if authStatus == .notDetermined {
|
return granted
|
||||||
do {
|
} catch {
|
||||||
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
logger.error("Failed to request notification permissions: \(error)")
|
||||||
await MainActor.run {
|
throw error
|
||||||
logger.info("🔔 Notification permission granted: \(granted)")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
logger.error("🔔 Failed to request notification permissions: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func setupNotifications() {
|
private func setupNotifications() {
|
||||||
// Listen for server state changes
|
// Listen for server state changes
|
||||||
NotificationCenter.default.addObserver(
|
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
|
/// ## Topics
|
||||||
///
|
///
|
||||||
/// ### Overview
|
/// ### Overview
|
||||||
/// The welcome flow consists of eight pages:
|
/// The welcome flow consists of nine pages:
|
||||||
/// - ``WelcomePageView`` - Introduction and app overview
|
/// - ``WelcomePageView`` - Introduction and app overview
|
||||||
/// - ``VTCommandPageView`` - CLI tool installation
|
/// - ``VTCommandPageView`` - CLI tool installation
|
||||||
/// - ``RequestPermissionsPageView`` - System permissions setup
|
/// - ``RequestPermissionsPageView`` - System permissions setup
|
||||||
/// - ``SelectTerminalPageView`` - Terminal selection and testing
|
/// - ``SelectTerminalPageView`` - Terminal selection and testing
|
||||||
/// - ``ProjectFolderPageView`` - Project folder configuration
|
/// - ``ProjectFolderPageView`` - Project folder configuration
|
||||||
/// - ``ProtectDashboardPageView`` - Dashboard security configuration
|
/// - ``ProtectDashboardPageView`` - Dashboard security configuration
|
||||||
|
/// - ``NotificationPermissionPageView`` - Notification permissions setup
|
||||||
/// - ``ControlAgentArmyPageView`` - Managing multiple AI agent sessions
|
/// - ``ControlAgentArmyPageView`` - Managing multiple AI agent sessions
|
||||||
/// - ``AccessDashboardPageView`` - Remote access instructions
|
/// - ``AccessDashboardPageView`` - Remote access instructions
|
||||||
struct WelcomeView: View {
|
struct WelcomeView: View {
|
||||||
|
|
@ -72,11 +73,15 @@ struct WelcomeView: View {
|
||||||
ProtectDashboardPageView()
|
ProtectDashboardPageView()
|
||||||
.frame(width: pageWidth)
|
.frame(width: pageWidth)
|
||||||
|
|
||||||
// Page 7: Control Your Agent Army
|
// Page 7: Notification Permissions
|
||||||
|
NotificationPermissionPageView()
|
||||||
|
.frame(width: pageWidth)
|
||||||
|
|
||||||
|
// Page 8: Control Your Agent Army
|
||||||
ControlAgentArmyPageView()
|
ControlAgentArmyPageView()
|
||||||
.frame(width: pageWidth)
|
.frame(width: pageWidth)
|
||||||
|
|
||||||
// Page 8: Accessing Dashboard
|
// Page 9: Accessing Dashboard
|
||||||
AccessDashboardPageView()
|
AccessDashboardPageView()
|
||||||
.frame(width: pageWidth)
|
.frame(width: pageWidth)
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +128,7 @@ struct WelcomeView: View {
|
||||||
|
|
||||||
// Page indicators centered
|
// Page indicators centered
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<8) { index in
|
ForEach(0..<9) { index in
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
currentPage = index
|
currentPage = index
|
||||||
|
|
@ -164,7 +169,7 @@ struct WelcomeView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var buttonTitle: String {
|
private var buttonTitle: String {
|
||||||
currentPage == 7 ? "Finish" : "Next"
|
currentPage == 8 ? "Finish" : "Next"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleBackAction() {
|
private func handleBackAction() {
|
||||||
|
|
@ -174,7 +179,7 @@ struct WelcomeView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleNextAction() {
|
private func handleNextAction() {
|
||||||
if currentPage < 7 {
|
if currentPage < 8 {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
currentPage += 1
|
currentPage += 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,20 +201,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
||||||
// Set up notification center delegate
|
// Set up notification center delegate
|
||||||
UNUserNotificationCenter.current().delegate = self
|
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
|
// Initialize dock icon visibility through DockIconManager
|
||||||
DockIconManager.shared.updateDockVisibility()
|
DockIconManager.shared.updateDockVisibility()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue