diff --git a/apple/aesthetic-computer-Info.plist b/apple/aesthetic-computer-Info.plist index 97d8787558..ab8410c270 100644 --- a/apple/aesthetic-computer-Info.plist +++ b/apple/aesthetic-computer-Info.plist @@ -2,8 +2,6 @@ - FirebaseAppDelegateProxyEnabled - UIBackgroundModes remote-notification diff --git a/apple/aesthetic.computer.xcodeproj/project.pbxproj b/apple/aesthetic.computer.xcodeproj/project.pbxproj index 6b7435fe1f..0de712aac1 100644 --- a/apple/aesthetic.computer.xcodeproj/project.pbxproj +++ b/apple/aesthetic.computer.xcodeproj/project.pbxproj @@ -11,16 +11,10 @@ 41F5CDC92B2931AF00F7FF87 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41F5CDC82B2931AF00F7FF87 /* ContentView.swift */; }; 41F5CDCB2B2931B000F7FF87 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 41F5CDCA2B2931B000F7FF87 /* Assets.xcassets */; }; 41F5CDCE2B2931B000F7FF87 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 41F5CDCD2B2931B000F7FF87 /* Preview Assets.xcassets */; }; - 41F5CDD62B29333300F7FF87 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDD52B29333300F7FF87 /* FirebaseAnalytics */; }; - 41F5CDD82B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDD72B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion */; }; - 41F5CDDC2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */; }; - 41F5CDDE2B29333300F7FF87 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDDD2B29333300F7FF87 /* FirebaseAppCheck */; }; - 41F5CDE12B29348800F7FF87 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 41F5CDE02B29348800F7FF87 /* FirebaseMessaging */; }; 41F5CDE82B29356F00F7FF87 /* Messages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 41F5CDE72B29356F00F7FF87 /* Messages.framework */; }; 41F5CDEB2B29356F00F7FF87 /* MessagesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41F5CDEA2B29356F00F7FF87 /* MessagesViewController.swift */; }; 41F5CDEE2B29356F00F7FF87 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 41F5CDEC2B29356F00F7FF87 /* MainInterface.storyboard */; }; 41F5CDF42B29356F00F7FF87 /* aesthetic.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 41F5CDE62B29356F00F7FF87 /* aesthetic.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 41F5CDFA2B2937D600F7FF87 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 41F5CDF92B2937D600F7FF87 /* GoogleService-Info.plist */; }; 41F5CDFD2B293C7B00F7FF87 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 41F5CDEF2B29356F00F7FF87 /* Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -61,7 +55,6 @@ 41F5CDED2B29356F00F7FF87 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 41F5CDEF2B29356F00F7FF87 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 41F5CDF12B29356F00F7FF87 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 41F5CDF92B2937D600F7FF87 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 41F5CDFB2B2937E300F7FF87 /* aesthetic.computer-release.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "aesthetic.computer-release.entitlements"; sourceTree = ""; }; 41F5CDFC2B2937E300F7FF87 /* aesthetic.computer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = aesthetic.computer.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -71,11 +64,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 41F5CDE12B29348800F7FF87 /* FirebaseMessaging in Frameworks */, - 41F5CDD62B29333300F7FF87 /* FirebaseAnalytics in Frameworks */, - 41F5CDD82B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */, - 41F5CDDE2B29333300F7FF87 /* FirebaseAppCheck in Frameworks */, - 41F5CDDC2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -115,7 +103,6 @@ 41CFB5D12B2A5D8D001954C9 /* aesthetic-computer-Info.plist */, 41F5CDFB2B2937E300F7FF87 /* aesthetic.computer-release.entitlements */, 41F5CDFC2B2937E300F7FF87 /* aesthetic.computer.entitlements */, - 41F5CDF92B2937D600F7FF87 /* GoogleService-Info.plist */, 41F5CDC62B2931AF00F7FF87 /* aesthetic_computerApp.swift */, 41F5CDC82B2931AF00F7FF87 /* ContentView.swift */, 41F5CDCA2B2931B000F7FF87 /* Assets.xcassets */, @@ -170,11 +157,6 @@ ); name = aesthetic.computer; packageProductDependencies = ( - 41F5CDD52B29333300F7FF87 /* FirebaseAnalytics */, - 41F5CDD72B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion */, - 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */, - 41F5CDDD2B29333300F7FF87 /* FirebaseAppCheck */, - 41F5CDE02B29348800F7FF87 /* FirebaseMessaging */, ); productName = aesthetic.computer; productReference = 41F5CDC32B2931AF00F7FF87 /* aesthetic.computer.app */; @@ -225,7 +207,6 @@ ); mainGroup = 41F5CDBA2B2931AF00F7FF87; packageReferences = ( - 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 41F5CDC42B2931AF00F7FF87 /* Products */; projectDirPath = ""; @@ -244,7 +225,6 @@ files = ( 41F5CDCE2B2931B000F7FF87 /* Preview Assets.xcassets in Resources */, 41F5CDCB2B2931B000F7FF87 /* Assets.xcassets in Resources */, - 41F5CDFA2B2937D600F7FF87 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,44 +554,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 11.0.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 41F5CDD52B29333300F7FF87 /* FirebaseAnalytics */ = { - isa = XCSwiftPackageProductDependency; - package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalytics; - }; - 41F5CDD72B29333300F7FF87 /* FirebaseAnalyticsOnDeviceConversion */ = { - isa = XCSwiftPackageProductDependency; - package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalyticsOnDeviceConversion; - }; - 41F5CDDB2B29333300F7FF87 /* FirebaseAnalyticsWithoutAdIdSupport */ = { - isa = XCSwiftPackageProductDependency; - package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalyticsWithoutAdIdSupport; - }; - 41F5CDDD2B29333300F7FF87 /* FirebaseAppCheck */ = { - isa = XCSwiftPackageProductDependency; - package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAppCheck; - }; - 41F5CDE02B29348800F7FF87 /* FirebaseMessaging */ = { - isa = XCSwiftPackageProductDependency; - package = 41F5CDD42B29333300F7FF87 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseMessaging; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 41F5CDBB2B2931AF00F7FF87 /* Project object */; } diff --git a/apple/aesthetic.computer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/aesthetic.computer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index b5623d9e60..0000000000 --- a/apple/aesthetic.computer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,132 +0,0 @@ -{ - "originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5", - "version" : "11.15.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed", - "version" : "2.3.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "45ce435e9406d3c674dd249a042b932bee006f60", - "version" : "11.15.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "c756a29784521063b6a1202907e2cc47f41b667c", - "version" : "4.5.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "9d108e9112aa1d65ce508facf804674546116d9c", - "version" : "1.22.3" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "65e8f29b2d63c4e38e736b25c27b83e012159be8", - "version" : "1.25.2" - } - } - ], - "version" : 3 -} diff --git a/apple/aesthetic.computer/GoogleService-Info.plist b/apple/aesthetic.computer/GoogleService-Info.plist deleted file mode 100644 index 487ce26ae2..0000000000 --- a/apple/aesthetic.computer/GoogleService-Info.plist +++ /dev/null @@ -1,34 +0,0 @@ - - - - - CLIENT_ID - 839964586768-nv2hpp6urm0l0anjg67ltp68ura0otda.apps.googleusercontent.com - REVERSED_CLIENT_ID - com.googleusercontent.apps.839964586768-nv2hpp6urm0l0anjg67ltp68ura0otda - API_KEY - AIzaSyAZkbN9uHHrBvgwqb3n8sGCCeQmHbuaT9c - GCM_SENDER_ID - 839964586768 - PLIST_VERSION - 1 - BUNDLE_ID - aesthetic.computer - PROJECT_ID - aesthetic-computer - STORAGE_BUCKET - aesthetic-computer.appspot.com - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:839964586768:ios:168e6cc6d22b12cd4ceb95 - - diff --git a/apple/aesthetic.computer/aesthetic_computerApp.swift b/apple/aesthetic.computer/aesthetic_computerApp.swift index 71b245f072..69fba7011e 100644 --- a/apple/aesthetic.computer/aesthetic_computerApp.swift +++ b/apple/aesthetic.computer/aesthetic_computerApp.swift @@ -1,222 +1,144 @@ -import SwiftUI -import UserNotifications -import FirebaseCore -import FirebaseMessaging -import WebKit - -@main -struct aesthetic_computerApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate - - var body: some Scene { - WindowGroup { - ContentView() - } - } - -} - -class AppDelegate: NSObject, UIApplicationDelegate { - let gcmMessageIDKey = "gcm.message_id" - - static var shared: AppDelegate? - var appWebView: WKWebView? - - override init() { - super.init() - AppDelegate.shared = self - } - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - FirebaseApp.configure() - Messaging.messaging().delegate = self - - // πŸ”” Register for remote notifications. This shows a permission dialog on first run, to - // show the dialog at a more appropriate time move this registration accordingly. - UNUserNotificationCenter.current().delegate = self - - let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] - UNUserNotificationCenter.current().requestAuthorization( - options: authOptions, - completionHandler: { _, _ in } - ) - - application.registerForRemoteNotifications() - - return true - } - - func application( - _ application: UIApplication, - didReceiveRemoteNotification userInfo: [AnyHashable: Any] - ) async - -> UIBackgroundFetchResult - { - // If you are receiving a notification message while your app is in the background, - // this callback will not be fired till the user taps on the notification launching the application. - - // TODO: Handle data of notification - Messaging.messaging().appDidReceiveMessage(userInfo) // With swizzling disabled you must inform messaging, for Analytics - // Print message ID. - - if let messageID = userInfo[gcmMessageIDKey] { - print("Message ID: \(messageID)") - } - - print(userInfo) // Print full message. - - return UIBackgroundFetchResult.newData - } - - func application( - _ application: UIApplication, - didFailToRegisterForRemoteNotificationsWithError error: Error - ) { - print("Unable to register for remote notifications: \(error.localizedDescription)") - } - - // This function is added here only for debugging purposes, and can be removed if swizzling is enabled. - // If swizzling is disabled then this function must be implemented so that the APNs token can be paired to - // the FCM registration token. - func application( - _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data - ) { - print("APNs token retrieved: \(deviceToken)") - Messaging.messaging().apnsToken = deviceToken // With swizzling disabled you must set the APNs token here. - //TOPICS for Notifications, decided when the app first boots on a phone - subscribeToTopics() - } - func subscribeToTopics() { - Messaging.messaging().subscribe(toTopic: "scream") { error in - print("Subscribed to scream topic") - } - Messaging.messaging().subscribe(toTopic: "mood") { error in - print("Subscribed to mood topic") - } - } - func unsubscribe(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - // Unsubscribing from the 'scream' topic - Messaging.messaging().unsubscribe(fromTopic: "scream") { error in - if let error = error { - print("Error unsubscribing from scream topic: \(error)") - } else { - print("Unsubscribed from scream topic") - } - } - // Unsubscribing from the 'mood' topic - Messaging.messaging().unsubscribe(fromTopic: "mood") { error in - if let error = error { - print("Error unsubscribing from mood topic: \(error)") - } else { - print("Unsubscribed from mood topic") - } - } - } - func triggerSubscribe() { - subscribeToTopics() - } - func triggerUnsubscribe() { - unsubscribe(UIApplication.shared, didRegisterForRemoteNotificationsWithDeviceToken: Data()) - } - -} - -extension AppDelegate: UNUserNotificationCenterDelegate { - - // Receive displayed notifications for iOS 10 (or later) devices. - // App notification while open - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification - ) async - -> UNNotificationPresentationOptions - { - let userInfo = notification.request.content.userInfo - - Messaging.messaging().appDidReceiveMessage(userInfo) // With swizzling disabled you must let Messaging know about the message, for Analytics - - if let messageID = userInfo[gcmMessageIDKey] { - print("Message ID: \(messageID)") // Print message ID. - } - - print(userInfo) // Print full message. - - // Change this to your preferred presentation option. - return [[.sound]] // return [[.banner, .sound]] - } - //App notification when app isn't open, you get banner and tap - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse - ) async { - let userInfo = response.notification.request.content.userInfo - - // Print message ID. - if let messageID = userInfo[gcmMessageIDKey] { - print("Message ID: \(messageID)") - } - // Print Data String - if let pieceData = userInfo["piece"] as? String { - if pieceData != ""{ - let script = "iOSAppSwitchPiece('\(pieceData)');" - appWebView?.evaluateJavaScript(script, completionHandler: nil) - } - } - - Messaging.messaging().appDidReceiveMessage(userInfo) // With swizzling disabled you must let Messaging know about the message, for Analytics - // Print full message. - print(userInfo) - } -} - -extension AppDelegate: MessagingDelegate { - func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - print("Firebase registration token: \(String(describing: fcmToken))") - let dataDict: [String: String] = ["token": fcmToken ?? ""] - NotificationCenter.default.post( - name: Notification.Name("FCMToken"), - object: nil, - userInfo: dataDict - ) - // Hand the token to the WebView so the AC runtime can POST it to - // /api/register-push-token against the logged-in user. - // This callback is fired at each app startup and whenever a new token is generated. - guard let token = fcmToken, !token.isEmpty else { return } - deliverPushTokenToWebView(token) - } - - private func deliverPushTokenToWebView(_ token: String, attempt: Int = 0) { - // Retry a few times so we don't lose the token if the WebView hasn't - // finished loading AC's bios.mjs yet (iOSReceivePushToken lives there). - let escaped = token.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - let script = "window.iOSReceivePushToken && window.iOSReceivePushToken('\(escaped)', 'ios');" - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - guard let webView = self.appWebView else { - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.deliverPushTokenToWebView(token, attempt: attempt + 1) - } - } - return - } - webView.evaluateJavaScript(script) { _, error in - if let error = error { - print("πŸ“± πŸ”” Failed to hand token to WebView: \(error)") - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.deliverPushTokenToWebView(token, attempt: attempt + 1) - } - } - } else { - print("πŸ“± πŸ”” Handed FCM token to WebView (attempt \(attempt)).") - } - } - } - } -} +import SwiftUI +import UserNotifications +import WebKit + +@main +struct aesthetic_computerApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + + var body: some Scene { + WindowGroup { + ContentView() + } + } + +} + +// πŸ”” Push notifications arrive straight from APNs now β€” no Firebase. +// The raw device token is handed to the WebView, where bios.mjs registers it +// with /api/register-push-token (kind: "apns") and the server fans out topic +// + per-device sends itself (see shared/push.mjs). +class AppDelegate: NSObject, UIApplicationDelegate { + static var shared: AppDelegate? + var appWebView: WKWebView? + private var apnsToken: String? + + override init() { + super.init() + AppDelegate.shared = self + } + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + // πŸ”” Register for remote notifications. This shows a permission dialog on first run, to + // show the dialog at a more appropriate time move this registration accordingly. + UNUserNotificationCenter.current().delegate = self + + let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + UNUserNotificationCenter.current().requestAuthorization( + options: authOptions, + completionHandler: { _, _ in } + ) + + application.registerForRemoteNotifications() + + return true + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + print("Unable to register for remote notifications: \(error.localizedDescription)") + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + print("πŸ“± πŸ”” APNs token retrieved: \(token)") + apnsToken = token + deliverPushTokenToWebView(token) + } + + // Called from the WebView bridge when the user types "notifs". + func triggerSubscribe() { + if let token = apnsToken { + deliverPushTokenToWebView(token) + } else { + UIApplication.shared.registerForRemoteNotifications() + } + } + + // Called from the WebView bridge when the user types "nonotifs". + func triggerUnsubscribe() { + let script = "window.iOSUnregisterPushToken && window.iOSUnregisterPushToken();" + appWebView?.evaluateJavaScript(script, completionHandler: nil) + } + + private func deliverPushTokenToWebView(_ token: String, attempt: Int = 0) { + // Retry a few times so we don't lose the token if the WebView hasn't + // finished loading AC's bios.mjs yet (iOSReceivePushToken lives there). + let script = "window.iOSReceivePushToken && window.iOSReceivePushToken('\(token)');" + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard let webView = self.appWebView else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.deliverPushTokenToWebView(token, attempt: attempt + 1) + } + } + return + } + webView.evaluateJavaScript(script) { _, error in + if let error = error { + print("πŸ“± πŸ”” Failed to hand token to WebView: \(error)") + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.deliverPushTokenToWebView(token, attempt: attempt + 1) + } + } + } else { + print("πŸ“± πŸ”” Handed APNs token to WebView (attempt \(attempt)).") + } + } + } + } + +} + +extension AppDelegate: UNUserNotificationCenterDelegate { + + // Notification arrives while the app is open. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async + -> UNNotificationPresentationOptions + { + print(notification.request.content.userInfo) // Print full message. + + // Change this to your preferred presentation option. + return [[.sound]] // return [[.banner, .sound]] + } + + // Notification tapped while the app wasn't open β€” jump to its piece. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let userInfo = response.notification.request.content.userInfo + + if let pieceData = userInfo["piece"] as? String { + if pieceData != "" { + let script = "iOSAppSwitchPiece('\(pieceData)');" + appWebView?.evaluateJavaScript(script, completionHandler: nil) + } + } + + print(userInfo) // Print full message. + } +} diff --git a/nanos/chat.mjs b/nanos/chat.mjs index 8501991fc3..697e6f4987 100644 --- a/nanos/chat.mjs +++ b/nanos/chat.mjs @@ -14,7 +14,7 @@ // https://console.cloud.google.com/compute/instances?project=aesthetic-computer import { WebSocketServer, WebSocket } from "ws"; -import { promises as fs, readFileSync } from "fs"; +import { readFileSync } from "fs"; import fetch from "node-fetch"; import http from "http"; @@ -32,8 +32,7 @@ import { MongoClient } from "mongodb"; // MongoDB import dotenv from "dotenv"; dotenv.config({ path: "chat.env" }); -import { initializeApp, cert } from "firebase-admin/app"; // Firebase notifications. -import { getMessaging } from "firebase-admin/messaging"; +import { broadcastToTopic } from "./push.mjs"; // Standard push (no Firebase). const MAX_MESSAGES = 500; // Maximum messages to keep in memory @@ -95,27 +94,6 @@ const subsToSubscribers = {}; // Cached list of active subscribers for this // instance if supported. const authorizedConnections = {}; -let serviceAccount; -try { - // console.log( - // "πŸ”₯ Loading Firebase configuration from file: ./gcp-service-key.json", - // ); - const data = await fs.readFile("./gcp-firebase-service-key.json", "utf8"); - serviceAccount = JSON.parse(data); -} catch (error) { - console.error("Error loading service account:", error); - // Handle the error as needed -} - -// console.log("πŸ”₯ Initializing Firebase App from:", serviceAccount); - -initializeApp( - { credential: cert(serviceAccount) }, //, - // "aesthetic" + ~~performance.now(), -); - -// console.log("πŸ”₯ Firebase App initialized..."); - let server, agent, connections = {}, // All active socket connections. @@ -1006,55 +984,22 @@ async function getHandleFromSub(fromSub) { // #endregion function notify(title, body) { - if (!dev) { + if (!dev && db) { // ☎️ Send a notification - console.log("🟑 Sending FCM notification...", performance.now()); - // const topicName = "industry-tech"; - - getMessaging() - .send({ - notification: { title, body }, - // android: { - // notification: { - // imageUrl: "https://aesthetic.computer/api/logo.png", - // }, - apns: { - payload: { - aps: { - "mutable-content": 1, - "interruption-level": "time-sensitive", // Marks as time-sensitive - priority: 10, // Highest priority - "content-available": 1, // Tells iOS to wake the app - }, - }, - headers: { - "apns-priority": "10", // Immediate delivery priority - "apns-push-type": "alert", // Explicit push type - "apns-expiration": "0", // Message won't be stored by APNs - }, - // fcm_options: { - // image: "https://aesthetic.computer/api/logo.png", - // }, - }, - webpush: { - headers: { - Urgency: "high", - TTL: "0", - image: "https://aesthetic.computer/api/logo.png", - }, - fcmOptions: { - analyticsLabel: "immediate-delivery", - }, - }, - topic: "mood", // <- TODO: Eventually replace this to wider range topic - // that also must be set inside the iOS client. - // topic: "chat-system", - data: { piece: "chat" }, // This should send a tappable link to the chat piece. - }) - .then((response) => { + console.log("🟑 Sending push notification...", performance.now()); + + broadcastToTopic(db, "mood", { + // <- TODO: Eventually use a wider-range topic. + title, + body, + urgent: true, // time-sensitive on iOS, Urgency: high on web + ttl: 0, // don't store undelivered chat pings + data: { piece: "chat" }, // Tappable link to the chat piece. + }) + .then((summary) => { console.log( "☎️ Successfully sent notification:", - response, + summary, performance.now(), ); }) diff --git a/nanos/package.json b/nanos/package.json index e55f831b26..cba3a99a5e 100644 --- a/nanos/package.json +++ b/nanos/package.json @@ -44,7 +44,6 @@ "author": "Jeffrey Alan Scudder", "dependencies": { "dotenv": "^17.2.3", - "firebase-admin": "^13.5.0", "mongodb": "^6.20.0", "node-fetch": "^3.3.2", "obscenity": "^0.4.5", diff --git a/nanos/push.mjs b/nanos/push.mjs new file mode 100644 index 0000000000..a9a2da3f4f --- /dev/null +++ b/nanos/push.mjs @@ -0,0 +1,439 @@ +// push, 26.06.11 +// Vendor-free push delivery for aesthetic.computer. Replaces Firebase Cloud +// Messaging with two open channels: +// +// 1. Standard Web Push β€” RFC 8030 (protocol), RFC 8291 (aes128gcm payload +// encryption), RFC 8292 (VAPID) β€” for browsers and installed PWAs, +// including iOS home-screen PWAs (16.4+). +// 2. Direct APNs over HTTP/2 with ES256 token auth β€” for the native iOS +// app's WKWebView wrapper, which cannot use Web Push. +// +// Zero dependencies: node:crypto + node:http2 + global fetch only, so this +// module runs unchanged on lith, the session-server droplet, and nanos +// without touching any package.json. +// +// Device documents live in the Mongo `push-tokens` collection: +// { user, deviceId, label, kind: "webpush"|"apns", platform, token, +// subscription: { endpoint, keys: { p256dh, auth } } | null, +// topics: ["scream", "mood", ...], updatedAt } +// Legacy FCM-era docs (no `kind` field) are ignored by every query here. +// +// Env: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (web push) +// APNS_TEAM_ID, APNS_KEY_ID, APNS_KEY (base64 .p8), APNS_BUNDLE_ID (apns) + +import crypto from "node:crypto"; +import http2 from "node:http2"; + +const PUSH_COLLECTION = "push-tokens"; +const ICON = "https://aesthetic.computer/api/logo.png"; +const SEND_CONCURRENCY = 16; + +const b64u = (buf) => Buffer.from(buf).toString("base64url"); +const fromB64u = (s) => Buffer.from(s, "base64url"); + +// πŸ”Œ Configuration checks β€” senders no-op gracefully when keys are absent +// (e.g. local dev without the vault). + +export function webPushConfigured() { + return !!(process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY); +} + +export function apnsConfigured() { + return !!( + process.env.APNS_TEAM_ID && + process.env.APNS_KEY_ID && + process.env.APNS_KEY + ); +} + +// πŸ•ΈοΈ Web Push + +function vapidPrivateKey() { + const pub = fromB64u(process.env.VAPID_PUBLIC_KEY); // 65B: 0x04 || x || y + return crypto.createPrivateKey({ + key: { + kty: "EC", + crv: "P-256", + x: b64u(pub.subarray(1, 33)), + y: b64u(pub.subarray(33, 65)), + d: process.env.VAPID_PRIVATE_KEY, + }, + format: "jwk", + }); +} + +const vapidJwtCache = {}; // audience β†’ { token, exp } + +function vapidJWT(audience) { + const now = Math.floor(Date.now() / 1000); + const cached = vapidJwtCache[audience]; + if (cached && cached.exp - now > 600) return cached.token; + const header = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" })); + const exp = now + 12 * 3600; + const claims = b64u( + JSON.stringify({ + aud: audience, + exp, + sub: process.env.VAPID_SUBJECT || "mailto:mail@aesthetic.computer", + }), + ); + const unsigned = header + "." + claims; + const sig = crypto.sign("sha256", Buffer.from(unsigned), { + key: vapidPrivateKey(), + dsaEncoding: "ieee-p1363", + }); + const token = unsigned + "." + b64u(sig); + vapidJwtCache[audience] = { token, exp }; + return token; +} + +// Encrypt a payload for one subscription per RFC 8291 (aes128gcm). +// `testVector` injects { asJwk, salt } so the RFC's Appendix A vector can +// verify this implementation byte-for-byte β€” never used in production. +export function encryptWebPush(plaintext, p256dh, auth, testVector) { + const uaPublic = fromB64u(p256dh); // user agent's 65-byte P-256 point + const authSecret = fromB64u(auth); // 16-byte shared auth secret + + let asPrivateKey, asPublicRaw; + if (testVector?.asJwk) { + asPrivateKey = crypto.createPrivateKey({ + key: testVector.asJwk, + format: "jwk", + }); + asPublicRaw = Buffer.concat([ + Buffer.from([4]), + fromB64u(testVector.asJwk.x), + fromB64u(testVector.asJwk.y), + ]); + } else { + const pair = crypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + }); + asPrivateKey = pair.privateKey; + const jwk = pair.publicKey.export({ format: "jwk" }); + asPublicRaw = Buffer.concat([ + Buffer.from([4]), + fromB64u(jwk.x), + fromB64u(jwk.y), + ]); + } + + const uaPublicKey = crypto.createPublicKey({ + key: { + kty: "EC", + crv: "P-256", + x: b64u(uaPublic.subarray(1, 33)), + y: b64u(uaPublic.subarray(33, 65)), + }, + format: "jwk", + }); + + const ecdhSecret = crypto.diffieHellman({ + privateKey: asPrivateKey, + publicKey: uaPublicKey, + }); + + const keyInfo = Buffer.concat([ + Buffer.from("WebPush: info\0"), + uaPublic, + asPublicRaw, + ]); + const ikm = Buffer.from( + crypto.hkdfSync("sha256", ecdhSecret, authSecret, keyInfo, 32), + ); + const salt = testVector?.salt ?? crypto.randomBytes(16); + const cek = Buffer.from( + crypto.hkdfSync( + "sha256", + ikm, + salt, + Buffer.from("Content-Encoding: aes128gcm\0"), + 16, + ), + ); + const nonce = Buffer.from( + crypto.hkdfSync( + "sha256", + ikm, + salt, + Buffer.from("Content-Encoding: nonce\0"), + 12, + ), + ); + + // Single record: plaintext || 0x02 delimiter (last record marker). + const record = Buffer.concat([Buffer.from(plaintext), Buffer.from([2])]); + const cipher = crypto.createCipheriv("aes-128-gcm", cek, nonce); + const ciphertext = Buffer.concat([ + cipher.update(record), + cipher.final(), + cipher.getAuthTag(), + ]); + + // Body: salt(16) || record-size(4) || keyid-len(1) || as-public(65) || ct + const header = Buffer.alloc(21); + salt.copy(header, 0); + header.writeUInt32BE(4096, 16); + header.writeUInt8(65, 20); + return Buffer.concat([header, asPublicRaw, ciphertext]); +} + +async function sendWebPush(subscription, payload, { ttl = 60, urgent } = {}) { + const endpoint = subscription?.endpoint; + const keys = subscription?.keys; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return { ok: false, gone: true, detail: "malformed subscription" }; + } + try { + const body = encryptWebPush(payload, keys.p256dh, keys.auth); + const jwt = vapidJWT(new URL(endpoint).origin); + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Encoding": "aes128gcm", + "Content-Type": "application/octet-stream", + TTL: String(ttl), + Urgency: urgent ? "high" : "normal", + Authorization: `vapid t=${jwt}, k=${process.env.VAPID_PUBLIC_KEY}`, + }, + body, + }); + if (res.status >= 200 && res.status < 300) return { ok: true }; + return { + ok: false, + gone: res.status === 404 || res.status === 410, + status: res.status, + detail: (await res.text().catch(() => "")).slice(0, 200), + }; + } catch (err) { + return { ok: false, gone: false, detail: err?.message || String(err) }; + } +} + +// 🍎 APNs (direct, token-based ES256 auth β€” no Firebase relay) + +let apnsJwtCache = null; // { token, iat } + +function apnsKey() { + const raw = process.env.APNS_KEY || ""; + // Accept either a literal PEM or base64-of-the-.p8-file. + const pem = raw.includes("-----BEGIN") + ? raw.replace(/\\n/g, "\n") + : Buffer.from(raw, "base64").toString("utf8"); + return crypto.createPrivateKey(pem); +} + +function apnsJWT() { + const now = Math.floor(Date.now() / 1000); + // APNs rejects tokens older than 1h and refreshes faster than 20min; + // re-sign every ~50 minutes. + if (apnsJwtCache && now - apnsJwtCache.iat < 3000) return apnsJwtCache.token; + const header = b64u( + JSON.stringify({ alg: "ES256", kid: process.env.APNS_KEY_ID }), + ); + const claims = b64u(JSON.stringify({ iss: process.env.APNS_TEAM_ID, iat: now })); + const unsigned = header + "." + claims; + const sig = crypto.sign("sha256", Buffer.from(unsigned), { + key: apnsKey(), + dsaEncoding: "ieee-p1363", + }); + apnsJwtCache = { token: unsigned + "." + b64u(sig), iat: now }; + return apnsJwtCache.token; +} + +const APNS_HOST = process.env.APNS_HOST || "https://api.push.apple.com"; + +function apnsRequest(session, deviceToken, payload, headers) { + return new Promise((resolve) => { + const req = session.request({ + ":method": "POST", + ":path": `/3/device/${deviceToken}`, + "content-type": "application/json", + ...headers, + }); + let status = 0; + const chunks = []; + req.on("response", (h) => (status = h[":status"])); + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + let reason; + try { + reason = JSON.parse(Buffer.concat(chunks).toString()).reason; + } catch {} + resolve({ + ok: status === 200, + gone: + status === 410 || + reason === "BadDeviceToken" || + reason === "Unregistered" || + reason === "DeviceTokenNotForTopic", + status, + detail: reason, + }); + }); + req.on("error", (err) => + resolve({ ok: false, gone: false, detail: err?.message }), + ); + // Stream closed without a response (e.g. session timeout) β€” don't hang + // the batch; double-resolve after a normal "end" is a harmless no-op. + req.on("close", () => + resolve({ ok: false, gone: false, status, detail: "stream closed" }), + ); + req.setTimeout(10_000, () => req.close()); + req.end(payload); + }); +} + +// Send to many APNs tokens over a single HTTP/2 session. +async function sendAPNsBatch(tokens, note) { + if (!apnsConfigured() || tokens.length === 0) { + return tokens.map(() => ({ ok: false, detail: "apns not configured" })); + } + const aps = { + alert: { title: note.title, body: note.body }, + sound: "default", + "mutable-content": 1, + }; + if (note.urgent) aps["interruption-level"] = "time-sensitive"; + const payload = JSON.stringify({ aps, ...(note.data || {}) }); + const headers = { + authorization: `bearer ${apnsJWT()}`, + "apns-topic": process.env.APNS_BUNDLE_ID || "aesthetic.computer", + "apns-push-type": "alert", + "apns-priority": "10", + "apns-expiration": "0", + }; + + return new Promise((resolve) => { + const session = http2.connect(APNS_HOST); + session.on("error", (err) => { + resolve(tokens.map(() => ({ ok: false, detail: err?.message }))); + }); + session.once("connect", async () => { + const results = await Promise.all( + tokens.map((t) => apnsRequest(session, t, payload, headers)), + ); + session.close(); + resolve(results); + }); + session.setTimeout(15_000, () => session.close()); + }); +} + +// πŸ“¦ Notification shape +// note = { title, body, image?, data? (flat string map, e.g. { piece }), +// urgent?, ttl? } + +function webPayload(note) { + return JSON.stringify({ + title: note.title, + body: note.body, + icon: ICON, + image: note.image, + data: note.data || {}, + }); +} + +async function pool(items, limit, work) { + const results = new Array(items.length); + let i = 0; + async function lane() { + while (i < items.length) { + const idx = i++; + results[idx] = await work(items[idx], idx); + } + } + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, lane), + ); + return results; +} + +// 🚚 Fan-out to a list of push-token documents, pruning dead ones. +export async function sendToDevices(db, docs, note, log = console.log) { + const webDocs = docs.filter( + (d) => d.kind === "webpush" && d.subscription?.endpoint, + ); + const apnsDocs = docs.filter((d) => d.kind === "apns" && d.token); + + const summary = { + attempted: webDocs.length + apnsDocs.length, + succeeded: 0, + failed: 0, + pruned: 0, + }; + if (summary.attempted === 0) return summary; + + const goneIds = []; + const payload = webPayload(note); + + const webResults = webPushConfigured() + ? await pool(webDocs, SEND_CONCURRENCY, (doc) => + sendWebPush(doc.subscription, payload, { + ttl: note.ttl ?? 60, + urgent: note.urgent, + }), + ) + : webDocs.map(() => ({ ok: false, detail: "vapid not configured" })); + + const apnsResults = await sendAPNsBatch( + apnsDocs.map((d) => d.token), + note, + ); + + [...webResults, ...apnsResults].forEach((res, i) => { + const doc = i < webResults.length ? webDocs[i] : apnsDocs[i - webResults.length]; + if (res.ok) { + summary.succeeded++; + } else { + summary.failed++; + if (res.gone) goneIds.push(doc._id); + else if (res.detail) { + log(`πŸ”” push failed (${doc.kind} ${doc.label || doc.deviceId || ""}):`, res.status || "", res.detail); + } + } + }); + + if (goneIds.length && db) { + await db + .collection(PUSH_COLLECTION) + .deleteMany({ _id: { $in: goneIds } }) + .then(() => { + summary.pruned = goneIds.length; + log(`🧹 Pruned ${goneIds.length} dead push registrations.`); + }) + .catch((err) => log("πŸ”΄ push prune failed:", err?.message)); + } + + return summary; +} + +// πŸ“£ Broadcast to every device subscribed to a topic ("scream", "mood", +// "chat-system", ...). +export async function broadcastToTopic(db, topic, note, log = console.log) { + const docs = await db + .collection(PUSH_COLLECTION) + .find({ topics: topic, kind: { $in: ["webpush", "apns"] } }) + .toArray(); + const summary = await sendToDevices(db, docs, note, log); + log(`πŸ”” "${topic}" broadcast:`, JSON.stringify(summary)); + return summary; +} + +// 🎯 Send to one user's devices β€” all of them, or a single device addressed +// by deviceId (exact) or label (case-insensitive substring). +export async function sendToUser(db, userSub, note, { device } = {}, log = console.log) { + let docs = await db + .collection(PUSH_COLLECTION) + .find({ user: userSub, kind: { $in: ["webpush", "apns"] } }) + .toArray(); + if (device) { + const needle = String(device).toLowerCase(); + docs = docs.filter( + (d) => + d.deviceId === device || + (d.label || "").toLowerCase().includes(needle), + ); + } + return sendToDevices(db, docs, note, log); +} diff --git a/session-server/chat-manager.mjs b/session-server/chat-manager.mjs index 1f9a29f59a..f81890f54c 100644 --- a/session-server/chat-manager.mjs +++ b/session-server/chat-manager.mjs @@ -12,7 +12,7 @@ import { redact, unredact } from "./redact.mjs"; import { ensureIndexes as ensureHeartsIndexes, toggleHeart, countHearts } from "./hearts.mjs"; import { MongoClient, ObjectId } from "mongodb"; -import { getMessaging } from "firebase-admin/messaging"; +import { broadcastToTopic } from "../shared/push.mjs"; // Standard push (no Firebase). const MAX_MESSAGES = 500; @@ -725,41 +725,18 @@ export class ChatManager { title = handle + " " + getClockEmoji(when); } - try { - getMessaging().send({ - notification: { title, body: text }, - apns: { - payload: { - aps: { - "mutable-content": 1, - "interruption-level": "time-sensitive", - priority: 10, - "content-available": 1, - }, - }, - headers: { - "apns-priority": "10", - "apns-push-type": "alert", - "apns-expiration": "0", - }, - }, - webpush: { - headers: { - Urgency: "high", - TTL: "0", - image: "https://aesthetic.computer/api/logo.png", - }, - }, - topic: instance.config.topic, - data: { piece: "chat" }, - }).then((response) => { - console.log("πŸ’¬ Notification sent:", response); - }).catch((err) => { - console.log("πŸ’¬ Notification error:", err); - }); - } catch (err) { - console.error("πŸ’¬ Notification error:", err); - } + if (!this.db) return; + broadcastToTopic(this.db, instance.config.topic, { + title, + body: text, + urgent: true, // time-sensitive on iOS, Urgency: high on web + ttl: 0, // don't store undelivered chat pings + data: { piece: "chat" }, + }).then((summary) => { + console.log("πŸ’¬ Notification sent:", summary); + }).catch((err) => { + console.log("πŸ’¬ Notification error:", err); + }); } // Handle HTTP log endpoint diff --git a/session-server/package.json b/session-server/package.json index 570ae80d1d..461b522e79 100644 --- a/session-server/package.json +++ b/session-server/package.json @@ -25,7 +25,6 @@ "chokidar": "^4.0.3", "dotenv": "^16.5.0", "fastify": "^5.3.0", - "firebase-admin": "^13.2.0", "geoip-lite": "^1.4.10", "ip": "^2.0.1", "jamsocket": "^0.10.8", diff --git a/session-server/session.mjs b/session-server/session.mjs index 26d619ee5a..452b0e1434 100644 --- a/session-server/session.mjs +++ b/session-server/session.mjs @@ -113,27 +113,8 @@ process.on('unhandledRejection', (reason, promise) => { import { exec } from "child_process"; -// FCM (Firebase Cloud Messaging) -import { initializeApp, cert } from "firebase-admin/app"; // Firebase notifications. -//import serviceAccount from "./aesthetic-computer-firebase-adminsdk-79w8j-5b5cdfced8.json" assert { type: "json" }; -import { getMessaging } from "firebase-admin/messaging"; - -let serviceAccount; -try { - const response = await fetch(process.env.GCM_FIREBASE_CONFIG_URL); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - serviceAccount = await response.json(); -} catch (error) { - console.error("Error fetching service account:", error); - // Handle the error as needed -} - -initializeApp( - { credential: cert(serviceAccount) }, //, - //"aesthetic" + ~~performance.now(), -); +// πŸ”” Push notifications (standard Web Push + direct APNs β€” no Firebase). +import { broadcastToTopic } from "../shared/push.mjs"; // Initialize ChatManager for multi-instance chat support const chatManager = new ChatManager({ dev: process.env.NODE_ENV === "development" }); @@ -2465,50 +2446,22 @@ wss.on("connection", async (ws, req) => { if (out.indexOf("pond") > -1) piece = "pond"; else if (out.indexOf("field") > -1) piece = "field"; - //if (!dev) { - getMessaging() - .send({ - notification: { + if (chatManager?.db) { + broadcastToTopic( + chatManager.db, + "scream", + { title: "😱 Scream", - body: out, //, + body: out, + urgent: true, // time-sensitive on iOS, Urgency: high on web + ttl: 0, // don't store undelivered screams + data: { piece }, }, - // android: { - // notification: { - // imageUrl: "https://aesthetic.computer/api/logo.png", - // }, - apns: { - payload: { - aps: { - "mutable-content": 1, - "interruption-level": "time-sensitive", // Marks as time-sensitive - priority: 10, // Highest priority - "content-available": 1, // Tells iOS to wake the app - }, - }, - headers: { - "apns-priority": "10", // Immediate delivery priority - "apns-push-type": "alert", // Explicit push type - "apns-expiration": "0", // Message won't be stored by APNs - }, - fcm_options: { - image: "https://aesthetic.computer/api/logo.png", - }, - }, - webpush: { - headers: { - image: "https://aesthetic.computer/api/logo.png", - }, - }, - topic: "scream", - data: { piece }, - }) - .then((response) => { - log("☎️ Successfully sent notification:", response); - }) - .catch((error) => { + log, + ).catch((error) => { log("πŸ“΅ Error sending notification:", error); }); - //} + } }) .catch((error) => { log("πŸ™…β€β™€οΈ Error publishing scream:", error); diff --git a/shared/push.e2e.test.mjs b/shared/push.e2e.test.mjs new file mode 100644 index 0000000000..a5293cc81d --- /dev/null +++ b/shared/push.e2e.test.mjs @@ -0,0 +1,227 @@ +// push.e2e.test, 26.06.11 +// Loopback end-to-end test for shared/push.mjs: runs the real fan-out +// (sendToDevices) against a local mock Web Push endpoint and a local mock +// APNs HTTP/2 server, then verifies on the "receiving" side: +// - Web Push: VAPID JWT signature + audience, aes128gcm decryption with the +// subscriber's private key, payload JSON shape, prune-on-410. +// - APNs: ES256 bearer JWT, :path device token, apns headers, aps payload. +// Run: node shared/push.e2e.test.mjs + +import crypto from "node:crypto"; +import http from "node:http"; +import http2 from "node:http2"; + +// Test VAPID identity. +const vapidPair = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); +const vapidJwk = vapidPair.privateKey.export({ format: "jwk" }); +process.env.VAPID_PUBLIC_KEY = Buffer.concat([ + Buffer.from([4]), + Buffer.from(vapidJwk.x, "base64url"), + Buffer.from(vapidJwk.y, "base64url"), +]).toString("base64url"); +process.env.VAPID_PRIVATE_KEY = vapidJwk.d; +process.env.VAPID_SUBJECT = "mailto:test@aesthetic.computer"; + +// Test APNs identity (self-minted P-256 "p8"). +const apnsPair = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); +process.env.APNS_TEAM_ID = "TESTTEAM01"; +process.env.APNS_KEY_ID = "TESTKEY123"; +process.env.APNS_KEY = Buffer.from( + apnsPair.privateKey.export({ type: "pkcs8", format: "pem" }), +).toString("base64"); +process.env.APNS_BUNDLE_ID = "aesthetic.computer"; + +let failures = 0; +function check(name, ok, detail = "") { + console.log(`${ok ? "βœ…" : "❌"} ${name}${ok ? "" : " β€” " + detail}`); + if (!ok) failures++; +} + +function verifyJWT(jwt, publicKey) { + const [h, c, s] = jwt.split("."); + const ok = crypto.verify( + "sha256", + Buffer.from(`${h}.${c}`), + { key: publicKey, dsaEncoding: "ieee-p1363" }, + Buffer.from(s, "base64url"), + ); + return { ok, claims: JSON.parse(Buffer.from(c, "base64url")) }; +} + +// Subscriber ("browser") keys. +const uaPair = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); +const uaJwk = uaPair.privateKey.export({ format: "jwk" }); +const uaPublicRaw = Buffer.concat([ + Buffer.from([4]), + Buffer.from(uaJwk.x, "base64url"), + Buffer.from(uaJwk.y, "base64url"), +]); +const uaAuth = crypto.randomBytes(16); + +function decryptWebPush(body) { + // Reverse of RFC 8291: header = salt(16) || rs(4) || idlen(1) || asPub(65) + const salt = body.subarray(0, 16); + const idlen = body.readUInt8(20); + const asPublicRaw = body.subarray(21, 21 + idlen); + const ciphertext = body.subarray(21 + idlen); + const asPublicKey = crypto.createPublicKey({ + key: { + kty: "EC", + crv: "P-256", + x: asPublicRaw.subarray(1, 33).toString("base64url"), + y: asPublicRaw.subarray(33, 65).toString("base64url"), + }, + format: "jwk", + }); + const ecdh = crypto.diffieHellman({ + privateKey: uaPair.privateKey, + publicKey: asPublicKey, + }); + const keyInfo = Buffer.concat([ + Buffer.from("WebPush: info\0"), + uaPublicRaw, + asPublicRaw, + ]); + const ikm = Buffer.from(crypto.hkdfSync("sha256", ecdh, uaAuth, keyInfo, 32)); + const cek = Buffer.from( + crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16), + ); + const nonce = Buffer.from( + crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12), + ); + const decipher = crypto.createDecipheriv("aes-128-gcm", cek, nonce); + decipher.setAuthTag(ciphertext.subarray(-16)); + const record = Buffer.concat([ + decipher.update(ciphertext.subarray(0, -16)), + decipher.final(), + ]); + return record.subarray(0, record.lastIndexOf(2)).toString(); // strip 0x02 pad +} + +// β€” Mock Web Push endpoint β€” +const webRequests = []; +const webServer = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + webRequests.push({ headers: req.headers, body: Buffer.concat(chunks), url: req.url }); + res.statusCode = req.url === "/gone" ? 410 : 201; + res.end(); + }); +}); +await new Promise((r) => webServer.listen(0, "127.0.0.1", r)); +const webPort = webServer.address().port; + +// β€” Mock APNs HTTP/2 server (h2c) β€” +const apnsRequests = []; +const apnsServer = http2.createServer(); +apnsServer.on("stream", (stream, headers) => { + const chunks = []; + stream.on("data", (c) => chunks.push(c)); + stream.on("end", () => { + apnsRequests.push({ headers, body: Buffer.concat(chunks).toString() }); + stream.respond({ ":status": 200 }); + stream.end(); + }); +}); +await new Promise((r) => apnsServer.listen(0, "127.0.0.1", r)); +process.env.APNS_HOST = `http://127.0.0.1:${apnsServer.address().port}`; + +// Import AFTER env is staged (module reads env lazily anyway, but be safe). +const { sendToDevices } = await import("./push.mjs"); + +const pruned = []; +const fakeDb = { + collection: () => ({ + deleteMany: async (q) => { + pruned.push(...q._id.$in); + return { deletedCount: q._id.$in.length }; + }, + }), +}; + +const docs = [ + { + _id: "web-ok", + kind: "webpush", + label: "Mock Browser", + subscription: { + endpoint: `http://127.0.0.1:${webPort}/ok`, + keys: { + p256dh: uaPublicRaw.toString("base64url"), + auth: uaAuth.toString("base64url"), + }, + }, + }, + { + _id: "web-gone", + kind: "webpush", + label: "Dead Browser", + subscription: { + endpoint: `http://127.0.0.1:${webPort}/gone`, + keys: { + p256dh: uaPublicRaw.toString("base64url"), + auth: uaAuth.toString("base64url"), + }, + }, + }, + { + _id: "ios-ok", + kind: "apns", + label: "Mock iPhone", + token: "ab".repeat(32), + }, +]; + +const note = { + title: "😱 Scream", + body: "the push stack is vendor-free", + urgent: true, + data: { piece: "chat" }, +}; + +const summary = await sendToDevices(fakeDb, docs, note, () => {}); + +check("fan-out summary counts", summary.attempted === 3 && summary.succeeded === 2 && summary.failed === 1, JSON.stringify(summary)); +check("dead web subscription pruned", pruned.length === 1 && pruned[0] === "web-gone", JSON.stringify(pruned)); + +// Web Push assertions. +const okReq = webRequests.find((r) => r.url === "/ok"); +check("web push request arrived", !!okReq); +if (okReq) { + check("aes128gcm content encoding", okReq.headers["content-encoding"] === "aes128gcm"); + check("urgency high", okReq.headers["urgency"] === "high"); + const auth = okReq.headers["authorization"] || ""; + const m = auth.match(/^vapid t=([^,]+), k=(.+)$/); + check("vapid authorization header shape", !!m); + if (m) { + const { ok, claims } = verifyJWT(m[1], vapidPair.publicKey); + check("vapid JWT signature verifies", ok); + check("vapid JWT audience", claims.aud === `http://127.0.0.1:${webPort}`, claims.aud); + check("vapid k matches public key", m[2] === process.env.VAPID_PUBLIC_KEY); + } + const decrypted = JSON.parse(decryptWebPush(okReq.body)); + check("payload decrypts to note", decrypted.title === note.title && decrypted.body === note.body, JSON.stringify(decrypted)); + check("payload carries piece", decrypted.data?.piece === "chat"); +} + +// APNs assertions. +const apnsReq = apnsRequests[0]; +check("apns request arrived", !!apnsReq); +if (apnsReq) { + check("apns path carries device token", apnsReq.headers[":path"] === `/3/device/${"ab".repeat(32)}`); + check("apns topic header", apnsReq.headers["apns-topic"] === "aesthetic.computer"); + check("apns push type alert", apnsReq.headers["apns-push-type"] === "alert"); + const bearer = (apnsReq.headers["authorization"] || "").replace("bearer ", ""); + const { ok, claims } = verifyJWT(bearer, apnsPair.publicKey); + check("apns JWT signature verifies", ok); + check("apns JWT issuer is team id", claims.iss === "TESTTEAM01"); + const payload = JSON.parse(apnsReq.body); + check("aps alert title/body", payload.aps?.alert?.title === note.title && payload.aps?.alert?.body === note.body); + check("aps time-sensitive when urgent", payload.aps?.["interruption-level"] === "time-sensitive"); + check("custom piece key at top level", payload.piece === "chat"); +} + +webServer.close(); +apnsServer.close(); +process.exit(failures ? 1 : 0); diff --git a/shared/push.mjs b/shared/push.mjs new file mode 100644 index 0000000000..a9a2da3f4f --- /dev/null +++ b/shared/push.mjs @@ -0,0 +1,439 @@ +// push, 26.06.11 +// Vendor-free push delivery for aesthetic.computer. Replaces Firebase Cloud +// Messaging with two open channels: +// +// 1. Standard Web Push β€” RFC 8030 (protocol), RFC 8291 (aes128gcm payload +// encryption), RFC 8292 (VAPID) β€” for browsers and installed PWAs, +// including iOS home-screen PWAs (16.4+). +// 2. Direct APNs over HTTP/2 with ES256 token auth β€” for the native iOS +// app's WKWebView wrapper, which cannot use Web Push. +// +// Zero dependencies: node:crypto + node:http2 + global fetch only, so this +// module runs unchanged on lith, the session-server droplet, and nanos +// without touching any package.json. +// +// Device documents live in the Mongo `push-tokens` collection: +// { user, deviceId, label, kind: "webpush"|"apns", platform, token, +// subscription: { endpoint, keys: { p256dh, auth } } | null, +// topics: ["scream", "mood", ...], updatedAt } +// Legacy FCM-era docs (no `kind` field) are ignored by every query here. +// +// Env: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (web push) +// APNS_TEAM_ID, APNS_KEY_ID, APNS_KEY (base64 .p8), APNS_BUNDLE_ID (apns) + +import crypto from "node:crypto"; +import http2 from "node:http2"; + +const PUSH_COLLECTION = "push-tokens"; +const ICON = "https://aesthetic.computer/api/logo.png"; +const SEND_CONCURRENCY = 16; + +const b64u = (buf) => Buffer.from(buf).toString("base64url"); +const fromB64u = (s) => Buffer.from(s, "base64url"); + +// πŸ”Œ Configuration checks β€” senders no-op gracefully when keys are absent +// (e.g. local dev without the vault). + +export function webPushConfigured() { + return !!(process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY); +} + +export function apnsConfigured() { + return !!( + process.env.APNS_TEAM_ID && + process.env.APNS_KEY_ID && + process.env.APNS_KEY + ); +} + +// πŸ•ΈοΈ Web Push + +function vapidPrivateKey() { + const pub = fromB64u(process.env.VAPID_PUBLIC_KEY); // 65B: 0x04 || x || y + return crypto.createPrivateKey({ + key: { + kty: "EC", + crv: "P-256", + x: b64u(pub.subarray(1, 33)), + y: b64u(pub.subarray(33, 65)), + d: process.env.VAPID_PRIVATE_KEY, + }, + format: "jwk", + }); +} + +const vapidJwtCache = {}; // audience β†’ { token, exp } + +function vapidJWT(audience) { + const now = Math.floor(Date.now() / 1000); + const cached = vapidJwtCache[audience]; + if (cached && cached.exp - now > 600) return cached.token; + const header = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" })); + const exp = now + 12 * 3600; + const claims = b64u( + JSON.stringify({ + aud: audience, + exp, + sub: process.env.VAPID_SUBJECT || "mailto:mail@aesthetic.computer", + }), + ); + const unsigned = header + "." + claims; + const sig = crypto.sign("sha256", Buffer.from(unsigned), { + key: vapidPrivateKey(), + dsaEncoding: "ieee-p1363", + }); + const token = unsigned + "." + b64u(sig); + vapidJwtCache[audience] = { token, exp }; + return token; +} + +// Encrypt a payload for one subscription per RFC 8291 (aes128gcm). +// `testVector` injects { asJwk, salt } so the RFC's Appendix A vector can +// verify this implementation byte-for-byte β€” never used in production. +export function encryptWebPush(plaintext, p256dh, auth, testVector) { + const uaPublic = fromB64u(p256dh); // user agent's 65-byte P-256 point + const authSecret = fromB64u(auth); // 16-byte shared auth secret + + let asPrivateKey, asPublicRaw; + if (testVector?.asJwk) { + asPrivateKey = crypto.createPrivateKey({ + key: testVector.asJwk, + format: "jwk", + }); + asPublicRaw = Buffer.concat([ + Buffer.from([4]), + fromB64u(testVector.asJwk.x), + fromB64u(testVector.asJwk.y), + ]); + } else { + const pair = crypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + }); + asPrivateKey = pair.privateKey; + const jwk = pair.publicKey.export({ format: "jwk" }); + asPublicRaw = Buffer.concat([ + Buffer.from([4]), + fromB64u(jwk.x), + fromB64u(jwk.y), + ]); + } + + const uaPublicKey = crypto.createPublicKey({ + key: { + kty: "EC", + crv: "P-256", + x: b64u(uaPublic.subarray(1, 33)), + y: b64u(uaPublic.subarray(33, 65)), + }, + format: "jwk", + }); + + const ecdhSecret = crypto.diffieHellman({ + privateKey: asPrivateKey, + publicKey: uaPublicKey, + }); + + const keyInfo = Buffer.concat([ + Buffer.from("WebPush: info\0"), + uaPublic, + asPublicRaw, + ]); + const ikm = Buffer.from( + crypto.hkdfSync("sha256", ecdhSecret, authSecret, keyInfo, 32), + ); + const salt = testVector?.salt ?? crypto.randomBytes(16); + const cek = Buffer.from( + crypto.hkdfSync( + "sha256", + ikm, + salt, + Buffer.from("Content-Encoding: aes128gcm\0"), + 16, + ), + ); + const nonce = Buffer.from( + crypto.hkdfSync( + "sha256", + ikm, + salt, + Buffer.from("Content-Encoding: nonce\0"), + 12, + ), + ); + + // Single record: plaintext || 0x02 delimiter (last record marker). + const record = Buffer.concat([Buffer.from(plaintext), Buffer.from([2])]); + const cipher = crypto.createCipheriv("aes-128-gcm", cek, nonce); + const ciphertext = Buffer.concat([ + cipher.update(record), + cipher.final(), + cipher.getAuthTag(), + ]); + + // Body: salt(16) || record-size(4) || keyid-len(1) || as-public(65) || ct + const header = Buffer.alloc(21); + salt.copy(header, 0); + header.writeUInt32BE(4096, 16); + header.writeUInt8(65, 20); + return Buffer.concat([header, asPublicRaw, ciphertext]); +} + +async function sendWebPush(subscription, payload, { ttl = 60, urgent } = {}) { + const endpoint = subscription?.endpoint; + const keys = subscription?.keys; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return { ok: false, gone: true, detail: "malformed subscription" }; + } + try { + const body = encryptWebPush(payload, keys.p256dh, keys.auth); + const jwt = vapidJWT(new URL(endpoint).origin); + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Encoding": "aes128gcm", + "Content-Type": "application/octet-stream", + TTL: String(ttl), + Urgency: urgent ? "high" : "normal", + Authorization: `vapid t=${jwt}, k=${process.env.VAPID_PUBLIC_KEY}`, + }, + body, + }); + if (res.status >= 200 && res.status < 300) return { ok: true }; + return { + ok: false, + gone: res.status === 404 || res.status === 410, + status: res.status, + detail: (await res.text().catch(() => "")).slice(0, 200), + }; + } catch (err) { + return { ok: false, gone: false, detail: err?.message || String(err) }; + } +} + +// 🍎 APNs (direct, token-based ES256 auth β€” no Firebase relay) + +let apnsJwtCache = null; // { token, iat } + +function apnsKey() { + const raw = process.env.APNS_KEY || ""; + // Accept either a literal PEM or base64-of-the-.p8-file. + const pem = raw.includes("-----BEGIN") + ? raw.replace(/\\n/g, "\n") + : Buffer.from(raw, "base64").toString("utf8"); + return crypto.createPrivateKey(pem); +} + +function apnsJWT() { + const now = Math.floor(Date.now() / 1000); + // APNs rejects tokens older than 1h and refreshes faster than 20min; + // re-sign every ~50 minutes. + if (apnsJwtCache && now - apnsJwtCache.iat < 3000) return apnsJwtCache.token; + const header = b64u( + JSON.stringify({ alg: "ES256", kid: process.env.APNS_KEY_ID }), + ); + const claims = b64u(JSON.stringify({ iss: process.env.APNS_TEAM_ID, iat: now })); + const unsigned = header + "." + claims; + const sig = crypto.sign("sha256", Buffer.from(unsigned), { + key: apnsKey(), + dsaEncoding: "ieee-p1363", + }); + apnsJwtCache = { token: unsigned + "." + b64u(sig), iat: now }; + return apnsJwtCache.token; +} + +const APNS_HOST = process.env.APNS_HOST || "https://api.push.apple.com"; + +function apnsRequest(session, deviceToken, payload, headers) { + return new Promise((resolve) => { + const req = session.request({ + ":method": "POST", + ":path": `/3/device/${deviceToken}`, + "content-type": "application/json", + ...headers, + }); + let status = 0; + const chunks = []; + req.on("response", (h) => (status = h[":status"])); + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + let reason; + try { + reason = JSON.parse(Buffer.concat(chunks).toString()).reason; + } catch {} + resolve({ + ok: status === 200, + gone: + status === 410 || + reason === "BadDeviceToken" || + reason === "Unregistered" || + reason === "DeviceTokenNotForTopic", + status, + detail: reason, + }); + }); + req.on("error", (err) => + resolve({ ok: false, gone: false, detail: err?.message }), + ); + // Stream closed without a response (e.g. session timeout) β€” don't hang + // the batch; double-resolve after a normal "end" is a harmless no-op. + req.on("close", () => + resolve({ ok: false, gone: false, status, detail: "stream closed" }), + ); + req.setTimeout(10_000, () => req.close()); + req.end(payload); + }); +} + +// Send to many APNs tokens over a single HTTP/2 session. +async function sendAPNsBatch(tokens, note) { + if (!apnsConfigured() || tokens.length === 0) { + return tokens.map(() => ({ ok: false, detail: "apns not configured" })); + } + const aps = { + alert: { title: note.title, body: note.body }, + sound: "default", + "mutable-content": 1, + }; + if (note.urgent) aps["interruption-level"] = "time-sensitive"; + const payload = JSON.stringify({ aps, ...(note.data || {}) }); + const headers = { + authorization: `bearer ${apnsJWT()}`, + "apns-topic": process.env.APNS_BUNDLE_ID || "aesthetic.computer", + "apns-push-type": "alert", + "apns-priority": "10", + "apns-expiration": "0", + }; + + return new Promise((resolve) => { + const session = http2.connect(APNS_HOST); + session.on("error", (err) => { + resolve(tokens.map(() => ({ ok: false, detail: err?.message }))); + }); + session.once("connect", async () => { + const results = await Promise.all( + tokens.map((t) => apnsRequest(session, t, payload, headers)), + ); + session.close(); + resolve(results); + }); + session.setTimeout(15_000, () => session.close()); + }); +} + +// πŸ“¦ Notification shape +// note = { title, body, image?, data? (flat string map, e.g. { piece }), +// urgent?, ttl? } + +function webPayload(note) { + return JSON.stringify({ + title: note.title, + body: note.body, + icon: ICON, + image: note.image, + data: note.data || {}, + }); +} + +async function pool(items, limit, work) { + const results = new Array(items.length); + let i = 0; + async function lane() { + while (i < items.length) { + const idx = i++; + results[idx] = await work(items[idx], idx); + } + } + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, lane), + ); + return results; +} + +// 🚚 Fan-out to a list of push-token documents, pruning dead ones. +export async function sendToDevices(db, docs, note, log = console.log) { + const webDocs = docs.filter( + (d) => d.kind === "webpush" && d.subscription?.endpoint, + ); + const apnsDocs = docs.filter((d) => d.kind === "apns" && d.token); + + const summary = { + attempted: webDocs.length + apnsDocs.length, + succeeded: 0, + failed: 0, + pruned: 0, + }; + if (summary.attempted === 0) return summary; + + const goneIds = []; + const payload = webPayload(note); + + const webResults = webPushConfigured() + ? await pool(webDocs, SEND_CONCURRENCY, (doc) => + sendWebPush(doc.subscription, payload, { + ttl: note.ttl ?? 60, + urgent: note.urgent, + }), + ) + : webDocs.map(() => ({ ok: false, detail: "vapid not configured" })); + + const apnsResults = await sendAPNsBatch( + apnsDocs.map((d) => d.token), + note, + ); + + [...webResults, ...apnsResults].forEach((res, i) => { + const doc = i < webResults.length ? webDocs[i] : apnsDocs[i - webResults.length]; + if (res.ok) { + summary.succeeded++; + } else { + summary.failed++; + if (res.gone) goneIds.push(doc._id); + else if (res.detail) { + log(`πŸ”” push failed (${doc.kind} ${doc.label || doc.deviceId || ""}):`, res.status || "", res.detail); + } + } + }); + + if (goneIds.length && db) { + await db + .collection(PUSH_COLLECTION) + .deleteMany({ _id: { $in: goneIds } }) + .then(() => { + summary.pruned = goneIds.length; + log(`🧹 Pruned ${goneIds.length} dead push registrations.`); + }) + .catch((err) => log("πŸ”΄ push prune failed:", err?.message)); + } + + return summary; +} + +// πŸ“£ Broadcast to every device subscribed to a topic ("scream", "mood", +// "chat-system", ...). +export async function broadcastToTopic(db, topic, note, log = console.log) { + const docs = await db + .collection(PUSH_COLLECTION) + .find({ topics: topic, kind: { $in: ["webpush", "apns"] } }) + .toArray(); + const summary = await sendToDevices(db, docs, note, log); + log(`πŸ”” "${topic}" broadcast:`, JSON.stringify(summary)); + return summary; +} + +// 🎯 Send to one user's devices β€” all of them, or a single device addressed +// by deviceId (exact) or label (case-insensitive substring). +export async function sendToUser(db, userSub, note, { device } = {}, log = console.log) { + let docs = await db + .collection(PUSH_COLLECTION) + .find({ user: userSub, kind: { $in: ["webpush", "apns"] } }) + .toArray(); + if (device) { + const needle = String(device).toLowerCase(); + docs = docs.filter( + (d) => + d.deviceId === device || + (d.label || "").toLowerCase().includes(needle), + ); + } + return sendToDevices(db, docs, note, log); +} diff --git a/shared/push.test.mjs b/shared/push.test.mjs new file mode 100644 index 0000000000..1a97296d92 --- /dev/null +++ b/shared/push.test.mjs @@ -0,0 +1,78 @@ +// push.test, 26.06.11 +// Validates shared/push.mjs Web Push encryption against the RFC 8291 +// Appendix A test vector, byte-for-byte, plus VAPID JWT shape. +// Run: node shared/push.test.mjs + +import crypto from "node:crypto"; +import { encryptWebPush } from "./push.mjs"; + +let failures = 0; +function check(name, actual, expected) { + const ok = actual === expected; + console.log(`${ok ? "βœ…" : "❌"} ${name}`); + if (!ok) { + failures++; + console.log(" expected:", expected); + console.log(" actual: ", actual); + } +} + +// β€” RFC 8291 Appendix A β€” +const uaPublic = + "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; +const authSecret = "BTBZMqHH6r4Tts7J_aSIgg"; +const plaintext = "When I grow up, I want to be a watermelon"; +const asPublicRaw = Buffer.from( + "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", + "base64url", +); +const asJwk = { + kty: "EC", + crv: "P-256", + x: asPublicRaw.subarray(1, 33).toString("base64url"), + y: asPublicRaw.subarray(33, 65).toString("base64url"), + d: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw", +}; +const salt = Buffer.from("DGv6ra1nlYgDCS1FRnbzlw", "base64url"); +const expectedBody = + "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"; + +const body = encryptWebPush(plaintext, uaPublic, authSecret, { asJwk, salt }); +check("RFC 8291 aes128gcm test vector", body.toString("base64url"), expectedBody); + +// β€” VAPID JWT shape (signature verifies against the public key) β€” +process.env.VAPID_PUBLIC_KEY ||= + "BKyIh2AHGF417ZQSlcYoR5Enixh_EAKtmXWMlw7dMtg8NonwBm7O8uCrGxHCOogbiKcWo-UqjSZmjUy6r2L5JzY"; +process.env.VAPID_PRIVATE_KEY ||= "-Ltkk_V0ndbYgrhUdjtJc4dLIhGKoRcu2hFo5tdosmc"; + +// vapidJWT isn't exported; verify indirectly by reconstructing one the same way. +const pub = Buffer.from(process.env.VAPID_PUBLIC_KEY, "base64url"); +const privateKey = crypto.createPrivateKey({ + key: { + kty: "EC", + crv: "P-256", + x: pub.subarray(1, 33).toString("base64url"), + y: pub.subarray(33, 65).toString("base64url"), + d: process.env.VAPID_PRIVATE_KEY, + }, + format: "jwk", +}); +const header = Buffer.from(JSON.stringify({ typ: "JWT", alg: "ES256" })).toString("base64url"); +const claims = Buffer.from( + JSON.stringify({ aud: "https://fcm.googleapis.com", exp: 0, sub: "mailto:t@t" }), +).toString("base64url"); +const unsigned = `${header}.${claims}`; +const sig = crypto.sign("sha256", Buffer.from(unsigned), { + key: privateKey, + dsaEncoding: "ieee-p1363", +}); +const publicKey = crypto.createPublicKey(privateKey); +const verified = crypto.verify( + "sha256", + Buffer.from(unsigned), + { key: publicKey, dsaEncoding: "ieee-p1363" }, + sig, +); +check("VAPID ES256 keypair signs + verifies", verified, true); + +process.exit(failures ? 1 : 0); diff --git a/system/netlify/functions/firebase-config.mjs b/system/netlify/functions/firebase-config.mjs index 64df472422..8caf14859c 100644 --- a/system/netlify/functions/firebase-config.mjs +++ b/system/netlify/functions/firebase-config.mjs @@ -1,37 +1,16 @@ -// Runtime Firebase client config (served from env vars) -// -// Goal: avoid committing client keys/config into the monorepo while still -// allowing the browser + firebase-messaging service worker to initialize. -// -// Usage: -// - JSON (page JS): /api/firebase-config -// - JS (SW import): /api/firebase-config?format=sw +// firebase-config, retired 26.06.11 +// AC moved off Firebase Cloud Messaging to standard Web Push (VAPID) and +// direct APNs β€” see shared/push.mjs. This endpoint stays only so cached +// pre-migration clients get a clean "disabled" answer instead of a 500: +// old boot.mjs treats a non-ok response as "notifications unavailable" and +// the old firebase-messaging service worker treats a null config as a no-op. export default async (request) => { const url = new URL(request.url); const format = url.searchParams.get("format") || "json"; - const firebaseConfig = { - apiKey: process.env.AC_FIREBASE_API_KEY || "", - authDomain: - process.env.AC_FIREBASE_AUTH_DOMAIN || "aesthetic-computer.firebaseapp.com", - projectId: process.env.AC_FIREBASE_PROJECT_ID || "aesthetic-computer", - storageBucket: - process.env.AC_FIREBASE_STORAGE_BUCKET || "aesthetic-computer.appspot.com", - messagingSenderId: - process.env.AC_FIREBASE_MESSAGING_SENDER_ID || "839964586768", - appId: - process.env.AC_FIREBASE_APP_ID || "1:839964586768:web:466139ee473df1954ceb95", - }; - - const hasApiKey = Boolean(firebaseConfig.apiKey); - if (format === "sw" || format === "js") { - const body = hasApiKey - ? `self.AC_FIREBASE_CONFIG = ${JSON.stringify(firebaseConfig)};\n` - : `self.AC_FIREBASE_CONFIG = null;\n`; - - return new Response(body, { + return new Response("self.AC_FIREBASE_CONFIG = null;\n", { headers: { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store", @@ -39,12 +18,7 @@ export default async (request) => { }); } - return new Response(JSON.stringify(hasApiKey ? firebaseConfig : null), { - headers: { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - }, - }); + return new Response(null, { status: 410 }); }; export const config = { path: "/api/firebase-config" }; diff --git a/system/netlify/functions/mood.mjs b/system/netlify/functions/mood.mjs index b6ddb50e33..d7f7d2ceff 100644 --- a/system/netlify/functions/mood.mjs +++ b/system/netlify/functions/mood.mjs @@ -40,8 +40,7 @@ import { shouldMirror, postMoodToBluesky } from "../../backend/bluesky-mirror.mj import { fetchBlueskyEngagement } from "../../backend/bluesky-engagement.mjs"; // const dev = process.env.CONTEXT === "dev"; -import { initializeApp, cert } from "firebase-admin/app"; // Firebase notifications. -import { getMessaging } from "firebase-admin/messaging"; +import { broadcastToTopic } from "../../../shared/push.mjs"; // Push notifications. import { shell } from "../../backend/shell.mjs"; import { publishProfileEvent } from "../../backend/profile-stream.mjs"; @@ -367,39 +366,13 @@ export async function handler(event, context) { } } - const { got } = await import("got"); - - const serviceAccount = ( - await got(process.env.GCM_FIREBASE_CONFIG_URL, { - responseType: "json", - }) - ).body; - - const app = initializeApp({ credential: cert(serviceAccount) }); // Send a notification. - console.log("πŸŒ™ Setting a mood for:", handle, body.mood); - const response = await getMessaging().send({ - notification: { - title: `${handle}'s mood is`, - body: `${mood}`, - }, - apns: { - payload: { - aps: { "mutable-content": 1 }, - }, - fcm_options: { - image: "https://aesthetic.computer/api/logo.png", - }, - }, - webpush: { - headers: { - image: "https://aesthetic.computer/api/logo.png", - }, - }, - topic: "mood", + await broadcastToTopic(database.db, "mood", { + title: `${handle}'s mood is`, + body: `${mood}`, + data: { piece: "moods" }, }); - console.log("☎️ Successfully sent notification:", response); } await database.disconnect(); diff --git a/system/netlify/functions/push-devices.mjs b/system/netlify/functions/push-devices.mjs new file mode 100644 index 0000000000..6c7c9c196a --- /dev/null +++ b/system/netlify/functions/push-devices.mjs @@ -0,0 +1,44 @@ +// push-devices, 26.06.11 +// Lists the logged-in user's registered push devices so they can be +// individually addressed (by deviceId or label) in `tell` and `/api/push`. +// +// GET /api/push-devices +// headers: Authorization: Bearer +// β†’ { devices: [{ deviceId, label, platform, kind, topics, updatedAt }] } + +import { authorize } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; + +export async function handler(event) { + if (event.httpMethod !== "GET") { + return respond(405, { message: "Method Not Allowed" }); + } + + const user = await authorize(event.headers); + if (!user?.sub) return respond(401, { message: "Unauthorized" }); + + const database = await connect(); + try { + const devices = await database.db + .collection("push-tokens") + .find({ user: user.sub, kind: { $in: ["webpush", "apns"] } }) + .project({ + _id: 0, + deviceId: 1, + label: 1, + platform: 1, + kind: 1, + topics: 1, + updatedAt: 1, + }) + .sort({ updatedAt: -1 }) + .toArray(); + return respond(200, { devices }); + } catch (err) { + console.error("πŸ”΄ push-devices error:", err); + return respond(500, { message: err?.message || "Server error" }); + } finally { + await database.disconnect(); + } +} diff --git a/system/netlify/functions/push.mjs b/system/netlify/functions/push.mjs new file mode 100644 index 0000000000..42236c9949 --- /dev/null +++ b/system/netlify/functions/push.mjs @@ -0,0 +1,69 @@ +// push, 26.06.11 +// Self-notify: send a push to your own registered device(s) β€” ntfy-style. +// Useful from pieces, scripts, and the CLI (e.g. "ping my phone when the +// render finishes"). +// +// POST /api/push +// body: { +// body: string, // notification text (required) +// title?: string, // defaults to "aesthetic.computer" +// device?: string, // deviceId (exact) or label substring; +// // omit to hit every registered device +// piece?: string, // piece to open on tap, e.g. "chat" +// } +// headers: Authorization: Bearer +// β†’ { status: "pushed", push: { attempted, succeeded, failed, pruned } } + +import { authorize } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { sendToUser } from "../../../shared/push.mjs"; + +const MAX_TEXT = 500; + +export async function handler(event) { + if (event.httpMethod !== "POST") { + return respond(405, { message: "Method Not Allowed" }); + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return respond(400, { message: "Invalid JSON body" }); + } + + const text = typeof body.body === "string" ? body.body.trim().slice(0, MAX_TEXT) : ""; + if (!text) return respond(400, { message: "Empty body" }); + const title = + typeof body.title === "string" && body.title.trim() + ? body.title.trim().slice(0, 120) + : "aesthetic.computer"; + + const user = await authorize(event.headers); + if (!user?.sub) return respond(401, { message: "Unauthorized" }); + + const database = await connect(); + try { + const note = { title, body: text, data: { kind: "push" } }; + if (typeof body.piece === "string" && body.piece) { + note.data.piece = body.piece.slice(0, 128); + } + const push = await sendToUser(database.db, user.sub, note, { + device: typeof body.device === "string" ? body.device : undefined, + }); + if (push.attempted === 0) { + return respond(404, { + message: body.device + ? `No device matching "${body.device}"` + : "No registered devices", + }); + } + return respond(200, { status: "pushed", push }); + } catch (err) { + console.error("πŸ”΄ push error:", err); + return respond(500, { message: err?.message || "Server error" }); + } finally { + await database.disconnect(); + } +} diff --git a/system/netlify/functions/register-push-token.mjs b/system/netlify/functions/register-push-token.mjs index 4768d5a624..e159256b37 100644 --- a/system/netlify/functions/register-push-token.mjs +++ b/system/netlify/functions/register-push-token.mjs @@ -1,21 +1,36 @@ -// register-push-token, 26.04.23 -// Stores a device's push-notification token against the logged-in user so the -// backend can deliver targeted notifications (fast, per-device) instead of -// topic broadcasts. Used by the iOS app (FCM) and eventually web/android. - +// register-push-token, 26.04.23 β†’ 26.06.11 (FCM β†’ standard push migration) +// Registers a device for push delivery. Two kinds: +// - "webpush": a standard PushSubscription from any browser / installed PWA +// - "apns": a raw APNs device token from the native iOS app +// // POST /api/register-push-token -// body: { token: string, platform: "ios" | "android" | "web", remove?: true } -// headers: Authorization: Bearer -// behavior: -// - remove=true β†’ delete this (user, token) pair (logout) -// - otherwise β†’ upsert { user: sub, token, platform, updatedAt } -// and delete any other user's binding to the same token -// (account-switch on the same device). +// body: { +// kind: "webpush" | "apns", +// subscription?: { endpoint, keys: { p256dh, auth } }, // webpush +// token?: string, // apns (hex) +// deviceId: string, // stable per-install UUID (client-generated) +// label?: string, // human name, e.g. "iPhone" / "Chrome on macOS" +// platform: "web" | "ios", +// topics?: string[], // e.g. ["scream", "mood"] +// remove?: true, // unregister this device +// } +// headers: Authorization: Bearer β€” OPTIONAL. With auth the +// device is bound to the user (enables tells + per-device addressing); +// without, it's an anonymous topic-only registration. import { authorize } from "../../backend/authorization.mjs"; import { connect } from "../../backend/database.mjs"; import { respond } from "../../backend/http.mjs"; +const KNOWN_TOPICS = [ + "scream", + "mood", + "chat-system", + "chat-sotce", + "chat-clock", +]; +const MAX_LABEL = 64; + export async function handler(event) { if (event.httpMethod !== "POST") { return respond(405, { message: "Method Not Allowed" }); @@ -28,35 +43,101 @@ export async function handler(event) { return respond(400, { message: "Invalid JSON body" }); } - const { token, platform, remove } = body; - if (typeof token !== "string" || token.length < 16 || token.length > 4096) { - return respond(400, { message: "Invalid token" }); + const { kind, subscription, deviceId, label, platform, remove } = body; + + // The unique key for a registration: APNs device token, or the Web Push + // subscription endpoint (itself an unguessable capability URL). + let token; + if (kind === "apns") { + token = body.token; + if ( + typeof token !== "string" || + !/^[0-9a-fA-F]{32,512}$/.test(token) + ) { + return respond(400, { message: "Invalid APNs token" }); + } + } else if (kind === "webpush") { + if ( + typeof subscription?.endpoint !== "string" || + !subscription.endpoint.startsWith("https://") || + typeof subscription?.keys?.p256dh !== "string" || + typeof subscription?.keys?.auth !== "string" + ) { + return respond(400, { message: "Invalid subscription" }); + } + token = subscription.endpoint; + } else if (remove && typeof body.token === "string") { + token = body.token; // legacy-shape removal (old clients on logout) + } else { + return respond(400, { message: "Invalid kind" }); } - if (!remove && !["ios", "android", "web"].includes(platform)) { - return respond(400, { message: "Invalid platform" }); + + if (!remove) { + if (!["web", "ios"].includes(platform)) { + return respond(400, { message: "Invalid platform" }); + } + if (typeof deviceId !== "string" || deviceId.length < 8 || deviceId.length > 128) { + return respond(400, { message: "Invalid deviceId" }); + } } - const user = await authorize(event.headers); - if (!user?.sub) return respond(401, { message: "Unauthorized" }); + const topics = Array.isArray(body.topics) + ? body.topics.filter((t) => KNOWN_TOPICS.includes(t)) + : ["scream", "mood"]; + + const user = await authorize(event.headers); // null is fine (anonymous) const database = await connect(); try { const collection = database.db.collection("push-tokens"); - await collection.createIndex({ user: 1, token: 1 }, { unique: true }); - await collection.createIndex({ token: 1 }); + await collection.createIndex({ token: 1 }, { unique: true }); + await collection.createIndex({ user: 1 }); + await collection.createIndex({ topics: 1 }); if (remove) { - const result = await collection.deleteOne({ user: user.sub, token }); + // The token/endpoint is a capability β€” owning it is proof enough. + const result = await collection.deleteOne({ token }); return respond(200, { status: "removed", deleted: result.deletedCount }); } - await collection.deleteMany({ token, user: { $ne: user.sub } }); - await collection.updateOne( - { user: user.sub, token }, - { $set: { user: user.sub, token, platform, updatedAt: new Date() } }, - { upsert: true }, - ); - return respond(200, { status: "registered" }); + const doc = { + kind, + token, + subscription: kind === "webpush" ? subscription : null, + deviceId, + label: typeof label === "string" ? label.slice(0, MAX_LABEL) : "", + platform, + topics, + updatedAt: new Date(), + }; + + if (user?.sub) { + // Bind to user; claim the token from any previous account on this + // device, and drop stale rows for this device (rotated endpoints). + doc.user = user.sub; + await collection.deleteMany({ token, user: { $ne: user.sub } }); + await collection.deleteMany({ + user: user.sub, + deviceId, + token: { $ne: token }, + }); + await collection.updateOne({ token }, { $set: doc }, { upsert: true }); + } else { + // Anonymous: never unbind an existing user from this token β€” only set + // user on first insert, refresh everything else. + await collection.deleteMany({ + user: null, + deviceId, + token: { $ne: token }, + }); + await collection.updateOne( + { token }, + { $set: doc, $setOnInsert: { user: null } }, + { upsert: true }, + ); + } + + return respond(200, { status: "registered", topics }); } catch (err) { console.error("πŸ”΄ register-push-token error:", err); return respond(500, { message: err?.message || "Server error" }); diff --git a/system/netlify/functions/subscribe-to-topic.js b/system/netlify/functions/subscribe-to-topic.js index ecaf137ab6..c7b30affa2 100644 --- a/system/netlify/functions/subscribe-to-topic.js +++ b/system/netlify/functions/subscribe-to-topic.js @@ -1,59 +1,63 @@ -// subscribe-to-topic, 24.02.26.19.55 -// Subscribes a user's web client to certain notification topics. -// Such as "mood", and "scream". - -import { respond } from "../../backend/http.mjs"; -// const dev = process.env.CONTEXT === "dev"; -import { initializeApp, cert } from "firebase-admin/app"; // Firebase notifications. -import { getMessaging } from "firebase-admin/messaging"; -let app; - -export async function handler(event, context) { - // console.log("πŸ˜€ Subscribing..."); - - if (event.httpMethod !== "POST") { - return respond(405, { message: "Method Not Allowed" }); - } - - const body = JSON.parse(event.body); - - try { - const { got } = await import("got"); - - const serviceAccount = ( - await got(process.env.GCM_FIREBASE_CONFIG_URL, { responseType: "json" }) - ).body; - - app ||= initializeApp({ credential: cert(serviceAccount) }); - - // πŸ“― Subscribe to a topic. - try { - const messaging = getMessaging(); - const response = await messaging.subscribeToTopic( - [body.token], - body.topic, - ); - console.log(`🟒 Successfully subscribed to topic: ${body.topic}`, response); - return respond(200, { status: "subscribed", topic: body.topic }); - } catch (err) { - console.log("πŸ”΄ Error subscribing to topic:", err); - throw new Error(err); - } - - // πŸ”” Send a notification. - // getMessaging() - // .send({ - // notification: { title: `${handle}'s mood is`, body: `${mood}` }, - // topic: "mood", - // }) - // .then((response) => { - // console.log("☎️ Successfully sent notification:", response); - // }) - // .catch((error) => { - // console.log("πŸ“΅ Error sending notification:", error); - // }); - } catch (error) { - console.log("πŸ”΄ Error:", error); - return respond(500, { status: error }); - } -} +// subscribe-to-topic, 24.02.26.19.55 β†’ 26.06.11 (FCM β†’ standard push migration) +// Adds or removes a notification topic ("mood", "scream", "chat-system", ...) +// on an existing push registration. Topic fan-out now lives in our own Mongo +// `push-tokens` collection (see shared/push.mjs) β€” no Firebase. +// +// POST /api/subscribe-to-topic +// body: { token: string, topic: string, unsubscribe?: true } +// `token` is the registration key: the Web Push subscription endpoint, or +// the APNs device token. Owning it is the capability β€” no auth needed. + +import { respond } from "../../backend/http.mjs"; +import { connect } from "../../backend/database.mjs"; + +const KNOWN_TOPICS = [ + "scream", + "mood", + "chat-system", + "chat-sotce", + "chat-clock", +]; + +export async function handler(event) { + if (event.httpMethod !== "POST") { + return respond(405, { message: "Method Not Allowed" }); + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return respond(400, { message: "Invalid JSON body" }); + } + + const { token, topic, unsubscribe } = body; + if (typeof token !== "string" || !token) { + return respond(400, { message: "Invalid token" }); + } + if (!KNOWN_TOPICS.includes(topic)) { + return respond(400, { message: "Unknown topic" }); + } + + const database = await connect(); + try { + const collection = database.db.collection("push-tokens"); + const update = unsubscribe + ? { $pull: { topics: topic } } + : { $addToSet: { topics: topic } }; + const result = await collection.updateOne({ token }, update); + if (result.matchedCount === 0) { + // Includes legacy FCM clients posting old tokens β€” nothing to do. + return respond(404, { message: "Registration not found" }); + } + return respond(200, { + status: unsubscribe ? "unsubscribed" : "subscribed", + topic, + }); + } catch (err) { + console.error("πŸ”΄ subscribe-to-topic error:", err); + return respond(500, { message: err?.message || "Server error" }); + } finally { + await database.disconnect(); + } +} diff --git a/system/netlify/functions/tell.mjs b/system/netlify/functions/tell.mjs index f8cd7839a6..a5d8474f5e 100644 --- a/system/netlify/functions/tell.mjs +++ b/system/netlify/functions/tell.mjs @@ -1,10 +1,11 @@ -// tell, 26.04.23 +// tell, 26.04.23 β†’ 26.06.11 (FCM β†’ standard push migration) // Send a one-way "tell" from one AC user to another. The recipient gets a -// push notification on every registered device and the message is stored -// in the `tells` collection as their inbox. +// push notification on every registered device β€” or just one, when `device` +// names a deviceId or label β€” and the message is stored in the `tells` +// collection as their inbox. // // POST /api/tell -// body: { to: "@handle", text: "message" } +// body: { to: "@handle", text: "message", device?: "deviceId-or-label" } // headers: Authorization: Bearer import { @@ -16,9 +17,7 @@ import { connect } from "../../backend/database.mjs"; import { respond } from "../../backend/http.mjs"; import { filter } from "../../backend/filter.mjs"; import { shell } from "../../backend/shell.mjs"; - -import { initializeApp, cert, getApps } from "firebase-admin/app"; -import { getMessaging } from "firebase-admin/messaging"; +import { sendToUser } from "../../../shared/push.mjs"; const MAX_TEXT_LENGTH = 500; @@ -67,78 +66,32 @@ export async function handler(event) { when, }); - // Look up recipient's registered push tokens. - const pushTokens = await database.db - .collection("push-tokens") - .find({ user: recipientSub }) - .project({ _id: 0, token: 1 }) - .toArray(); - const tokens = pushTokens.map((d) => d.token).filter(Boolean); - - let pushSummary = { attempted: 0, succeeded: 0, failed: 0 }; - - if (tokens.length > 0) { - const { got } = await import("got"); - const serviceAccount = ( - await got(process.env.GCM_FIREBASE_CONFIG_URL, { responseType: "json" }) - ).body; - if (getApps().length === 0) { - initializeApp({ credential: cert(serviceAccount) }); - } - - const message = { - notification: { + // Push to the recipient's registered devices (all, or one if addressed). + let pushSummary = { attempted: 0, succeeded: 0, failed: 0, pruned: 0 }; + try { + pushSummary = await sendToUser( + database.db, + recipientSub, + { title: `${fromHandle} told you`, body: text, + data: { + kind: "tell", + from: fromHandle || "", + tellId: insertResult.insertedId.toString(), + piece: "chat", + }, }, - data: { - kind: "tell", - from: fromHandle || "", - tellId: insertResult.insertedId.toString(), + { + device: + typeof body.device === "string" && body.device + ? body.device + : undefined, }, - apns: { - payload: { aps: { sound: "default", "mutable-content": 1 } }, - }, - }; - - try { - const response = await getMessaging().sendEachForMulticast({ - tokens, - ...message, - }); - pushSummary = { - attempted: tokens.length, - succeeded: response.successCount, - failed: response.failureCount, - }; - - const invalid = []; - response.responses.forEach((resp, i) => { - if (resp.success) return; - const code = resp.error?.code || ""; - if ( - code === "messaging/registration-token-not-registered" || - code === "messaging/invalid-registration-token" || - code === "messaging/invalid-argument" - ) { - invalid.push(tokens[i]); - } else { - shell.log( - `⚠️ tell push error for ${recipientSub}:`, - code, - resp.error?.message, - ); - } - }); - if (invalid.length) { - await database.db - .collection("push-tokens") - .deleteMany({ token: { $in: invalid } }); - shell.log(`🧹 Pruned ${invalid.length} stale push tokens.`); - } - } catch (err) { - shell.log("πŸ”΄ tell push send failed:", err?.message || err); - } + shell.log, + ); + } catch (err) { + shell.log("πŸ”΄ tell push send failed:", err?.message || err); } return respond(200, { diff --git a/system/package.json b/system/package.json index 457a16ad41..1f21535201 100644 --- a/system/package.json +++ b/system/package.json @@ -72,7 +72,6 @@ "express-oauth2-jwt-bearer": "^1.7.1", "ffmpeg-static": "^5.3.0", "ffprobe-static": "^3.1.0", - "firebase-admin": "^13.6.0", "gcp-metadata": "^7.0.1", "gl-matrix": "^3.4.4", "got": "^14.6.4", diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index ad811556a9..5e288a5460 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -16766,7 +16766,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Cache-bust the stream URL to force a fresh connection and avoid stale // buffered data. Matches the approach used by kpbj.fm's own navbar player. - const bustedUrl = url + (url.includes("?") ? "&" : "?") + "t=" + Date.now(); + // (blob: object urls β€” dropped local files β€” don't take query strings.) + const bustedUrl = url.startsWith("blob:") + ? url + : url + (url.includes("?") ? "&" : "?") + "t=" + Date.now(); // Per-station CORS policy: // cors === false β†’ Icecast/no-CORS server (e.g. stream.kpbj.fm); skip @@ -16954,6 +16957,58 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } + // Set playback rate (dj pitch fader / scratch). preservesPitch is turned + // off so speed bends pitch like vinyl. HTMLMediaElement can't run in + // reverse, so the rate floors just above zero β€” backward scratching is + // the piece's job (silent seeks). + if (type === "stream:speed") { + const { id, speed } = content; + const entry = streamAudio[id]; + if (entry?.audio) { + const rate = Math.min(4, Math.max(0.0625, speed)); + entry.audio.preservesPitch = false; + entry.audio.webkitPreservesPitch = false; + entry.audio.playbackRate = rate; + send({ type: "stream:speed-data", content: { id, speed: rate } }); + } + return; + } + + // Whole-track peak overview for waveform strips (dj). Fetches and decodes + // the full file alongside the streaming element β€” needs CORS-readable + // audio (the ac-electron loopback server sends ACAO: *). Replies with an + // empty array on any failure so pieces can fall back to a plain bar. + if (type === "stream:peaks") { + const { id, url, count } = content; + (async () => { + const res = await fetch(url); + const buf = await res.arrayBuffer(); + // The main audioContext only exists after sound activation; a + // suspended throwaway context decodes just fine before that. + const ctx = audioContext || new AudioContext(); + const decoded = await ctx.decodeAudioData(buf); + if (ctx !== audioContext) ctx.close(); + const chan = decoded.getChannelData(0); + const n = Math.max(16, Math.min(4096, count || 1024)); + const step = Math.floor(chan.length / n) || 1; + const peaks = new Array(n).fill(0); + for (let i = 0; i < n; i += 1) { + let max = 0; + const end = Math.min((i + 1) * step, chan.length); + for (let j = i * step; j < end; j += 16) { + const v = Math.abs(chan[j]); + if (v > max) max = v; + } + peaks[i] = max; + } + send({ type: "stream:peaks-data", content: { id, peaks } }); + })().catch((err) => { + console.warn("🎡 stream:peaks failed:", err?.message || err); + send({ type: "stream:peaks-data", content: { id, peaks: [] } }); + }); + return; + } + // Get current playback time, duration, and buffer state if (type === "stream:time") { const { id } = content; @@ -20969,6 +21024,16 @@ async function boot(parsed, bpm = 60, resolution, debug) { } }; reader.readAsArrayBuffer(file); + // 🎡 Long-form audio (.mp3 etc.) β€” same `dropped:file` path the + // ac-electron host uses, but with an object url, so the `dj` piece + // streams it straight from memory. + } else if (["mp3", "m4a", "aac", "ogg", "opus", "flac", "webm"].includes(ext)) { + const url = URL.createObjectURL(file); + console.log("🎡 BIOS: Dropped audio file:", file.name, url); + send({ + type: "dropped:file", + content: { url, name: file.name, mime: file.type }, + }); // 🎡 Ableton Live Set file (ZIP archive containing XML) } else if (ext === "als") { @@ -21581,45 +21646,75 @@ async function boot(parsed, bpm = 60, resolution, debug) { }; // πŸ”” Native push-token bridge. - // Swift calls window.iOSReceivePushToken when FCM hands it a token. - // If the user is logged in we POST it to /api/register-push-token right - // away; otherwise we stash it and register once session:started fires - // (see iOSTryRegisterPushToken, called from boot.mjs after login). + // Swift calls window.iOSReceivePushToken with the raw APNs device token + // (hex) β€” no Firebase. We register it right away, anonymously if no one is + // logged in (topic broadcasts still arrive), then re-register bound to the + // user once session:started fires (iOSTryRegisterPushToken, called from + // boot.mjs after login) so tells and per-device addressing work. let _iosPushToken = null; - let _iosPushPlatform = "ios"; - let _iosPushRegistered = false; + let _iosPushRegisteredAs; // undefined = not registered; null = anonymous let _iosPushInflight = false; - window.iOSReceivePushToken = (token, platform) => { - if (typeof token !== "string" || !token) return; - if (_iosPushToken === token && _iosPushRegistered) return; - _iosPushToken = token; - _iosPushPlatform = platform || "ios"; - _iosPushRegistered = false; + function iosPushDeviceId() { + let id; + try { + id = localStorage.getItem("ac-push-device-id"); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem("ac-push-device-id", id); + } + } catch { + id = crypto.randomUUID(); + } + return id; + } + + window.iOSReceivePushToken = (token) => { + if (typeof token !== "string" || !/^[0-9a-fA-F]{32,512}$/.test(token)) { + console.warn("πŸ“± πŸ”” Ignoring malformed APNs token."); + return; + } + if (_iosPushToken !== token) { + _iosPushToken = token; + _iosPushRegisteredAs = undefined; + } window.iOSTryRegisterPushToken(); }; window.iOSTryRegisterPushToken = async () => { - if (!_iosPushToken || _iosPushRegistered || _iosPushInflight) return; - if (!window.auth0Client || !window.acUSER) return; // wait for login + if (!_iosPushToken || _iosPushInflight) return; + const sub = window.acUSER?.sub || null; + if (_iosPushRegisteredAs !== undefined && _iosPushRegisteredAs === sub) + return; // Already registered for this identity. _iosPushInflight = true; try { - const authToken = await window.auth0Client.getTokenSilently(); - if (!authToken) return; + const headers = { "Content-Type": "application/json" }; + if (sub && window.auth0Client) { + try { + const authToken = await window.auth0Client.getTokenSilently(); + if (!authToken) return; // Retry after the session settles. + headers.Authorization = `Bearer ${authToken}`; + } catch { + return; + } + } const res = await fetch("/api/register-push-token", { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, + headers, body: JSON.stringify({ + kind: "apns", token: _iosPushToken, - platform: _iosPushPlatform, + platform: "ios", + deviceId: iosPushDeviceId(), + label: /iPad/.test(navigator.userAgent) ? "iPad app" : "iPhone app", + topics: ["scream", "mood"], }), }); if (res.ok) { - _iosPushRegistered = true; - console.log("πŸ“± πŸ”” Push token registered."); + _iosPushRegisteredAs = sub; + console.log( + `πŸ“± πŸ”” Push token registered${sub ? " (user-bound)" : " (anonymous)"}.`, + ); } else { console.warn("πŸ“± πŸ”” Push token registration failed:", res.status); } @@ -21631,19 +21726,20 @@ async function boot(parsed, bpm = 60, resolution, debug) { }; window.iOSUnregisterPushToken = async () => { - if (!_iosPushToken || !window.auth0Client || !window.acUSER) return; + if (!_iosPushToken) return; try { - const authToken = await window.auth0Client.getTokenSilently(); - if (!authToken) return; + // The token itself is the capability β€” no auth needed to remove it. await fetch("/api/register-push-token", { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ token: _iosPushToken, remove: true }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + kind: "apns", + token: _iosPushToken, + remove: true, + }), }); - _iosPushRegistered = false; + _iosPushRegisteredAs = undefined; + console.log("πŸ“± πŸ”• Push token unregistered."); } catch (err) { console.warn("πŸ“± πŸ”” Push token unregister error:", err); } diff --git a/system/public/aesthetic.computer/boot.mjs b/system/public/aesthetic.computer/boot.mjs index b2acc42283..65502b33b5 100644 --- a/system/public/aesthetic.computer/boot.mjs +++ b/system/public/aesthetic.computer/boot.mjs @@ -1881,99 +1881,142 @@ window.addEventListener("message", (event) => { } }); -// πŸ”” Subscribe to web / client notifications. -// TODO: Test this to make sure it's skipped in the native apps, -// and factor it out. 24.02.26.19.29 +// πŸ”” Web push notifications β€” standard Web Push (VAPID), no Firebase. +// The subscription rides the main /sw.js registration; payload + tap handling +// live there (push / notificationclick events). Server side: shared/push.mjs. -async function initNotifications() { -try { - const { initializeApp } = await import( - "https://www.gstatic.com/firebasejs/10.8.0/firebase-app.js" - ); +const AC_VAPID_PUBLIC_KEY = + "BKyIh2AHGF417ZQSlcYoR5Enixh_EAKtmXWMlw7dMtg8NonwBm7O8uCrGxHCOogbiKcWo-UqjSZmjUy6r2L5JzY"; - const { getMessaging, onMessage, getToken } = await import( - "https://www.gstatic.com/firebasejs/10.8.0/firebase-messaging.js" - ); +// Stable per-install identity so devices can be individually addressed +// (`tell @handle ... device:phone`, /api/push { device }). +function pushDeviceId() { + let id; + try { + id = localStorage.getItem("ac-push-device-id"); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem("ac-push-device-id", id); + } + } catch { + id = crypto.randomUUID(); + } + return id; +} - async function getFirebaseClientConfig() { - // 1) Optional: page can inject config at runtime. - if (window.AC_FIREBASE_CONFIG?.apiKey) return window.AC_FIREBASE_CONFIG; +function pushDeviceLabel() { + const ua = navigator.userAgent; + let browser = "Browser"; + if (/Edg\//.test(ua)) browser = "Edge"; + else if (/Chrome\//.test(ua)) browser = "Chrome"; + else if (/Firefox\//.test(ua)) browser = "Firefox"; + else if (/Safari\//.test(ua)) browser = "Safari"; + let os = ""; + if (/iPhone|iPad/.test(ua)) os = "iOS"; + else if (/Android/.test(ua)) os = "Android"; + else if (/Mac/.test(ua)) os = "macOS"; + else if (/Windows/.test(ua)) os = "Windows"; + else if (/Linux/.test(ua)) os = "Linux"; + const pwa = window.matchMedia?.("(display-mode: standalone)")?.matches; + return `${browser}${os ? " on " + os : ""}${pwa ? " (PWA)" : ""}`; +} - // 2) Default: fetch from Netlify function so secrets aren't in git. - try { - const res = await fetch("/api/firebase-config", { - cache: "no-store", - headers: { Accept: "application/json" }, - }); - if (!res.ok) return null; - return await res.json(); - } catch { - return null; +async function pushAuthHeaders() { + try { + if (window.auth0Client && window.acUSER) { + const token = await window.auth0Client.getTokenSilently(); + if (token) return { Authorization: `Bearer ${token}` }; } + } catch { + // Anonymous registration still receives topic broadcasts. } + return {}; +} - async function setupNotifications() { - const firebaseConfig = await getFirebaseClientConfig(); - if (!firebaseConfig?.apiKey) return; - - const app = initializeApp(firebaseConfig); - const messaging = getMessaging(app); +async function initNotifications(enable = true) { + try { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + console.warn("πŸ”” Push not supported in this browser."); + return; + } + const registration = await navigator.serviceWorker.ready; + + if (!enable) { + const existing = await registration.pushManager.getSubscription(); + if (existing) { + await fetch("/api/register-push-token", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(await pushAuthHeaders()), + }, + body: JSON.stringify({ + kind: "webpush", + token: existing.endpoint, + remove: true, + }), + }).catch(() => {}); + await existing.unsubscribe(); + } + console.log("πŸ”• Web push disabled."); + return; + } - getToken(messaging, { - vapidKey: - "BDEh3JDD1xZo7eQU00TgsYb_o8ENJlpU-ovbZzWoCOu4AOeFJD8PVbZ3pif_7rMEk65Uj00-lwdXgc3qJVLp4ys", - }) - .then((token) => { - if (token) { - // Send the token to your server and update the UI if necessary - function subscribe(token, topic, then) { - fetch("/api/subscribe-to-topic", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, topic }), - }) - .then((response) => { - if (!response.ok) throw new Error("Bad response."); - return response.json(); // Parse the JSON in the response - }) - .then((data) => { - // console.log("Subcribed to:", data.topic); - then?.(); // Subscribe to another topic if necessary. - }) - .catch((error) => { - console.error("🚫 Topic subscription error:", error); - }); - } + const applicationServerKey = Uint8Array.from( + atob(AC_VAPID_PUBLIC_KEY.replace(/-/g, "+").replace(/_/g, "/")), + (c) => c.charCodeAt(0), + ); + + // Drop any subscription minted under a previous VAPID key (e.g. the old + // Firebase one) β€” its pushes could never be authorized again. + let subscription = await registration.pushManager.getSubscription(); + if (subscription?.options?.applicationServerKey) { + const current = new Uint8Array(subscription.options.applicationServerKey); + const matches = + current.length === applicationServerKey.length && + current.every((b, i) => b === applicationServerKey[i]); + if (!matches) { + await subscription.unsubscribe(); + subscription = null; + } + } - subscribe(token, "scream", () => subscribe(token, "mood")); + subscription ||= await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey, + }); - onMessage(messaging, (payload) => { - console.log( - "πŸ—¨οΈ Client notification received. ", - payload.notification, - ); - }); - } else { - console.warn("πŸ”” No registration token available."); - } - }) - .catch((err) => { - // console.warn("πŸ”” An error occurred while retrieving token.", err); - }); + const res = await fetch("/api/register-push-token", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(await pushAuthHeaders()), + }, + body: JSON.stringify({ + kind: "webpush", + subscription: subscription.toJSON(), + deviceId: pushDeviceId(), + label: pushDeviceLabel(), + platform: "web", + topics: ["scream", "mood"], + }), + }); + if (res.ok) { + console.log("πŸ”” Web push registered."); + } else { + console.warn("πŸ”” Web push registration failed:", res.status); + } + } catch (err) { + console.warn("πŸ”” Could not initialize web push:", err); } - - // Call the setup function - await setupNotifications(); -} catch (err) { - console.warn("πŸ”₯ Could not initialize firebase notifications:", err); -} } let notificationsInitialized = false; -window.acRequestNotifications = function requestNotifications() { - if (notificationsInitialized) return; +window.acRequestNotifications = function requestNotifications(content) { + const enable = content?.enable !== false; + if (enable && notificationsInitialized) return; if (previewOrIcon || sandboxed) return; - notificationsInitialized = true; - initNotifications(); + notificationsInitialized = enable; + initNotifications(enable); }; diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index d14ea0e725..63af3cefa7 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -10157,20 +10157,23 @@ async function makeFrame({ data: { type, content } }) { // 🎡 ac-electron drag-drop: a file was dropped onto the app icon or // window. Stash it on system.droppedFile, fire a dropped:file act event - // for the live piece, and auto-jump to `play` when we aren't already in it. + // for the live piece, and auto-jump to `dj` unless a player piece (dj or + // play) is already running and will swap tracks itself. if (type === "dropped:file") { if (!content || !content.url) return; if ($commonApi?.system) $commonApi.system.droppedFile = content; actAlerts.push("dropped:file"); try { const slug = $commonApi?.slug || ""; - const inPlay = - slug === "play" || - slug.startsWith("play~") || - slug.startsWith("play:") || - (currentPath && currentPath.endsWith("/play")); - if (!inPlay && $commonApi?.jump) { - $commonApi.jump("play"); + const inPlayer = ["dj", "play"].some( + (p) => + slug === p || + slug.startsWith(p + "~") || + slug.startsWith(p + ":") || + (currentPath && currentPath.endsWith("/" + p)), + ); + if (!inPlayer && $commonApi?.jump) { + $commonApi.jump("dj"); } } catch (e) { console.warn("🎡 dropped:file auto-jump failed:", e?.message || e); @@ -11385,11 +11388,9 @@ async function makeFrame({ data: { type, content } }) { } // 🎡 Streaming Audio messages - forward to piece receive function - if (type === "stream:playing" || - type === "stream:paused" || - type === "stream:stopped" || - type === "stream:error" || - type === "stream:frequencies-data") { + // (the whole family: playing/paused/stopped/error/state, time-data, + // seeked, frequencies/waveform/peaks-data, speed-data) + if (type.startsWith("stream:")) { if (typeof receive === "function") { try { receive({ type, content }); diff --git a/system/public/firebase-messaging-sw.js b/system/public/firebase-messaging-sw.js index 0720130294..1c5416e3fc 100644 --- a/system/public/firebase-messaging-sw.js +++ b/system/public/firebase-messaging-sw.js @@ -1,38 +1,10 @@ -// Give the service worker access to Firebase Messaging. -// Note that you can only use Firebase Messaging here. Other Firebase libraries -// are not available in the service worker. -importScripts("https://www.gstatic.com/firebasejs/8.10.1/firebase-app.js"); -importScripts( - "https://www.gstatic.com/firebasejs/8.10.1/firebase-messaging.js", -); - -// Load runtime Firebase config (served from env vars via Netlify function). -// If config is missing, notifications stay disabled. -try { - importScripts("/api/firebase-config?format=sw"); -} catch (e) { - // Silent: don't break SW install if endpoint is unavailable. -} - -// Initialize the Firebase app in the service worker by passing in -// your app's Firebase config object. -// https://firebase.google.com/docs/web/setup#config-object -if (self.AC_FIREBASE_CONFIG?.apiKey) { - firebase.initializeApp(self.AC_FIREBASE_CONFIG); -} - -// Retrieve an instance of Firebase Messaging so that it can handle background -// messages. -const messaging = self.AC_FIREBASE_CONFIG?.apiKey ? firebase.messaging() : null; - -// messaging.onBackgroundMessage((payload) => { -// console.log("πŸ—¨οΈ Received background message:", payload); -// // Customize notification here -// const notificationTitle = payload.notification.title; -// const notificationOptions = { -// body: payload.notification.body, -// icon: "https://aesthetic.computer/api/logo.png", -// }; -// -// self.registration.showNotification(notificationTitle, notificationOptions); -// }); +// firebase-messaging-sw, retired 26.06.11 +// AC moved off Firebase Cloud Messaging to standard Web Push β€” payload and +// tap handling now live in /sw.js, and delivery comes straight from each +// browser's push service via VAPID (see shared/push.mjs server-side). +// This file stays only so devices that installed the old FCM service worker +// pick up one final update that unregisters it. +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (event) => { + event.waitUntil(self.registration.unregister()); +}); diff --git a/system/public/sw.js b/system/public/sw.js index 5925c04d20..84f0123aef 100644 --- a/system/public/sw.js +++ b/system/public/sw.js @@ -199,3 +199,40 @@ self.addEventListener('message', (event) => { }); } }); + +// πŸ”” Standard Web Push (no Firebase). The server encrypts a JSON payload per +// RFC 8291 (see shared/push.mjs): { title, body, icon, image, data: { piece } }. +self.addEventListener('push', (event) => { + let note = {}; + try { + note = event.data?.json() || {}; + } catch { + note = { body: event.data?.text() }; + } + event.waitUntil( + self.registration.showNotification(note.title || 'aesthetic.computer', { + body: note.body || '', + icon: note.icon || 'https://aesthetic.computer/api/logo.png', + image: note.image, + data: note.data || {}, + }) + ); +}); + +// Tapping a notification jumps to its piece (e.g. data.piece = "chat"), +// focusing an existing AC tab when one is open. +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const piece = event.notification.data?.piece || ''; + const url = `${self.location.origin}/${piece}`; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((tabs) => { + const tab = tabs.find((t) => t.url.startsWith(self.location.origin)); + if (tab) { + tab.focus(); + return tab.navigate ? tab.navigate(url) : undefined; + } + return clients.openWindow(url); + }) + ); +});