Repository: betomoedano/quick-push Branch: main Commit: f5da1c5fcb9f Files: 69 Total size: 219.1 KB Directory structure: gitextract_jnez51gj/ ├── LICENSE ├── QuickPush/ │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── QuickPushIcon/ │ │ ├── Assets/ │ │ │ ├── Contents.json │ │ │ └── bolt.brakesignal 1.imageset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── icon.dataset/ │ │ ├── Contents.json │ │ └── icon.json │ ├── ContentView.swift │ ├── Controllers/ │ │ ├── APNsService.swift │ │ ├── FCMService.swift │ │ ├── JWTSigner.swift │ │ └── PushNotificationService.swift │ ├── Models/ │ │ ├── APNsConfigStore.swift │ │ ├── APNsConfiguration.swift │ │ ├── FCMConfigStore.swift │ │ ├── FCMConfiguration.swift │ │ ├── FCMPayload.swift │ │ ├── LiveActivityPayload.swift │ │ ├── NativePushPayload.swift │ │ ├── PushNotification.swift │ │ ├── PushResponse.swift │ │ ├── ReceiptResponse.swift │ │ ├── SavedToken.swift │ │ └── TokenToSave.swift │ ├── Preview Content/ │ │ └── Preview Assets.xcassets/ │ │ └── Contents.json │ ├── QuickPush.entitlements │ ├── QuickPushApp.swift │ ├── QuickPushIcon.icon/ │ │ └── icon.json │ ├── Utilities/ │ │ ├── ColorHexConverter.swift │ │ ├── FCMFileManager.swift │ │ ├── FloatingPanel.swift │ │ ├── SavedTokenStore.swift │ │ ├── SecurityBookmarkManager.swift │ │ ├── View+Extensions.swift │ │ └── WindowManager.swift │ ├── ViewModels/ │ │ ├── FCMViewModel.swift │ │ ├── LiveActivityViewModel.swift │ │ └── NativePushViewModel.swift │ └── Views/ │ ├── APNsConfigurationView.swift │ ├── APNsCurlCommandView.swift │ ├── APNsResponseDetailView.swift │ ├── APNsView.swift │ ├── ColorPickerField.swift │ ├── ExpoCurlCommandView.swift │ ├── ExpoReceiptView.swift │ ├── ExpoResponseDetailView.swift │ ├── FCMConfigurationView.swift │ ├── FCMCurlCommandView.swift │ ├── FCMResponseDetailView.swift │ ├── FCMView.swift │ ├── FooterView.swift │ ├── JSONImportExportView.swift │ ├── KeyValueInputView.swift │ ├── LiveActivityAlertSection.swift │ ├── LiveActivityAttributesSection.swift │ ├── LiveActivityContentStateSection.swift │ ├── LiveActivityView.swift │ ├── MainContentView.swift │ ├── PushNotificationView.swift │ ├── SaveTokenSheet.swift │ └── SavedTokenRowView.swift ├── QuickPush.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ ├── xcshareddata/ │ │ │ └── swiftpm/ │ │ │ └── Package.resolved │ │ └── xcuserdata/ │ │ └── beto.xcuserdatad/ │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata/ │ └── beto.xcuserdatad/ │ ├── xcdebugger/ │ │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes/ │ └── xcschememanagement.plist └── README.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Code with Beto LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: QuickPush/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: QuickPush/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: QuickPush/Assets.xcassets/QuickPushIcon/Assets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: QuickPush/Assets.xcassets/QuickPushIcon/Assets/bolt.brakesignal 1.imageset/Contents.json ================================================ { "images" : [ { "filename" : "bolt.brakesignal 1.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: QuickPush/Assets.xcassets/QuickPushIcon/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: QuickPush/Assets.xcassets/QuickPushIcon/icon.dataset/Contents.json ================================================ { "data" : [ { "filename" : "icon.json", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: QuickPush/Assets.xcassets/QuickPushIcon/icon.dataset/icon.json ================================================ { "fill" : "automatic", "groups" : [ { "layers" : [ { "fill-specializations" : [ { "appearance" : "dark", "value" : { "solid" : "extended-gray:1.00000,1.00000" } } ], "glass" : true, "image-name" : "bolt.brakesignal 1.svg", "name" : "bolt.brakesignal 1", "position" : { "scale" : 6.81, "translation-in-points" : [ 0, 0 ] } } ], "position" : { "scale" : 1, "translation-in-points" : [ 5, 0 ] }, "shadow" : { "kind" : "neutral", "opacity" : 0.5 }, "translucency" : { "enabled" : true, "value" : 0.5 } } ], "supported-platforms" : { "circles" : [ "watchOS" ], "squares" : "shared" } } ================================================ FILE: QuickPush/ContentView.swift ================================================ // // ContentView.swift // QuickPush // // Created by beto on 2/20/25. // import SwiftUI enum AppTab: String, CaseIterable { case pushNotification = "Expo Notification" case liveActivity = "Live Activity" case apnsPush = "APNs" case fcm = "FCM" var icon: String { switch self { case .pushNotification: return "bell.badge" case .liveActivity: return "waveform" case .apnsPush: return "antenna.radiowaves.left.and.right" case .fcm: return "server.rack" } } /// Label shown in the segmented picker, embedding the ⌘N shortcut hint. var tabLabel: String { switch self { case .pushNotification: return "Expo Notification ⌘1" case .liveActivity: return "Live Activity ⌘2" case .apnsPush: return "APNs ⌘3" case .fcm: return "FCM ⌘4" } } } struct ContentView: View { @State private var selectedTab: AppTab = .pushNotification var body: some View { VStack(spacing: 0) { // Tab Picker Picker("", selection: $selectedTab) { ForEach(AppTab.allCases, id: \.self) { tab in Label(tab.tabLabel, systemImage: tab.icon).tag(tab) } } .pickerStyle(.segmented) .padding(.top, 12) .padding(.bottom, 4) // Tab Content — all views stay alive so @State is preserved PushNotificationView(isActive: selectedTab == .pushNotification) .opacity(selectedTab == .pushNotification ? 1 : 0) .frame(height: selectedTab == .pushNotification ? nil : 0) .allowsHitTesting(selectedTab == .pushNotification) LiveActivityView(isActive: selectedTab == .liveActivity) .opacity(selectedTab == .liveActivity ? 1 : 0) .frame(height: selectedTab == .liveActivity ? nil : 0) .allowsHitTesting(selectedTab == .liveActivity) APNsView(isActive: selectedTab == .apnsPush) .opacity(selectedTab == .apnsPush ? 1 : 0) .frame(height: selectedTab == .apnsPush ? nil : 0) .allowsHitTesting(selectedTab == .apnsPush) FCMView(isActive: selectedTab == .fcm) .opacity(selectedTab == .fcm ? 1 : 0) .frame(height: selectedTab == .fcm ? nil : 0) .allowsHitTesting(selectedTab == .fcm) } .frame(minHeight: 410) // ⌘1/2/3/4 to switch tabs .background( Group { Button("") { selectedTab = .pushNotification } .keyboardShortcut("1", modifiers: .command) Button("") { selectedTab = .liveActivity } .keyboardShortcut("2", modifiers: .command) Button("") { selectedTab = .apnsPush } .keyboardShortcut("3", modifiers: .command) Button("") { selectedTab = .fcm } .keyboardShortcut("4", modifiers: .command) } .frame(width: 0, height: 0) .opacity(0) ) } } // MARK: - Toast Types and View enum ToastType { case success case error var backgroundColor: Color { switch self { case .success: return Color.green.opacity(0.9) case .error: return Color.red.opacity(0.9) } } var icon: String { switch self { case .success: return "checkmark.circle.fill" case .error: return "exclamationmark.circle.fill" } } } struct ToastView: View { let message: String let type: ToastType @Binding var isPresented: Bool private var dismissDelay: TimeInterval { type == .error ? 5 : 3 } var body: some View { VStack { Spacer() if isPresented { HStack(spacing: 12) { Image(systemName: type.icon) Text(message) .foregroundColor(.white) } .padding() .background(type.backgroundColor) .cornerRadius(8) .padding(.bottom, 20) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + dismissDelay) { withAnimation { isPresented = false } } } } } } } // MARK: - Reusable InputField with Tooltip struct InputField: View { let label: String @Binding var text: String let helpText: String var isRequired: Bool = false var showError: Bool = false var body: some View { HStack { Text("\(label):") TextField(label, text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(showError ? Color.red : Color.clear, lineWidth: 1) ) HelpButton(helpText: helpText) } } } // MARK: - Help Button with Popover struct HelpButton: View { let helpText: String @State private var showHelp = false var body: some View { Button(action: { showHelp.toggle() }) { Image(systemName: "questionmark.circle") .foregroundColor(.secondary) } .popover(isPresented: $showHelp) { VStack(alignment: .leading, spacing: 8) { Text(attributedHelpText) .textSelection(.enabled) } .padding() .frame(width: 300) } .buttonStyle(PlainButtonStyle()) .focusable(false) } private var attributedHelpText: AttributedString { var attributedString = AttributedString(helpText) // Find all URLs and make them clickable var searchStart = helpText.startIndex while let urlRange = helpText.range(of: "https://[^\\s]+", options: .regularExpression, range: searchStart..) -> Void ) { guard configuration.isValid else { completion(.failure(APNsError.invalidConfiguration)) return } guard let p8Contents = configuration.p8Contents, !p8Contents.isEmpty else { completion(.failure(APNsError.cannotReadP8File)) return } let jwt: String do { jwt = try getOrRefreshToken( teamId: configuration.teamId, keyId: configuration.keyId, p8Contents: p8Contents ) } catch { completion(.failure(error)) return } let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines) let urlString = "https://\(configuration.hostname)/3/device/\(cleanToken)" guard let url = URL(string: urlString) else { completion(.failure(APNsError.networkError("Invalid URL"))) return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") request.setValue(configuration.topic, forHTTPHeaderField: "apns-topic") request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type") request.setValue("10", forHTTPHeaderField: "apns-priority") request.setValue("application/json", forHTTPHeaderField: "content-type") let payloadTimestamp = payload.aps.timestamp do { let encoder = JSONEncoder() request.httpBody = try encoder.encode(payload) } catch { completion(.failure(error)) return } URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(APNsError.networkError(error.localizedDescription))) return } guard let httpResponse = response as? HTTPURLResponse else { completion(.failure(APNsError.networkError("Invalid response"))) return } let apnsId = httpResponse.value(forHTTPHeaderField: "apns-id") let apnsUniqueId = httpResponse.value(forHTTPHeaderField: "apns-unique-id") var reason: String? if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let r = json["reason"] as? String { reason = r } let apnsResponse = APNsResponse( statusCode: httpResponse.statusCode, reason: reason, apnsId: apnsId, apnsUniqueId: apnsUniqueId, environment: configuration.environment, topic: configuration.topic, timestamp: payloadTimestamp, hostname: configuration.hostname, attributesType: payload.aps.attributesType, event: payload.aps.event.rawValue ) if httpResponse.statusCode == 200 { completion(.success(apnsResponse)) } else { completion(.failure(APNsError.requestFailed(response: apnsResponse))) } }.resume() } func sendNativePush( payload: NativePushPayload, token: String, pushType: NativePushType, priority: Int, configuration: APNsConfiguration, completion: @escaping (Result) -> Void ) { guard configuration.isValid else { completion(.failure(APNsError.invalidConfiguration)) return } guard let p8Contents = configuration.p8Contents, !p8Contents.isEmpty else { completion(.failure(APNsError.cannotReadP8File)) return } let jwt: String do { jwt = try getOrRefreshToken( teamId: configuration.teamId, keyId: configuration.keyId, p8Contents: p8Contents ) } catch { completion(.failure(error)) return } let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines) let urlString = "https://\(configuration.hostname)/3/device/\(cleanToken)" guard let url = URL(string: urlString) else { completion(.failure(APNsError.networkError("Invalid URL"))) return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") request.setValue(configuration.topic, forHTTPHeaderField: "apns-topic") request.setValue(pushType.rawValue, forHTTPHeaderField: "apns-push-type") request.setValue(String(priority), forHTTPHeaderField: "apns-priority") request.setValue("application/json", forHTTPHeaderField: "content-type") let timestamp = Int(Date().timeIntervalSince1970) do { let encoder = JSONEncoder() request.httpBody = try encoder.encode(payload) } catch { completion(.failure(error)) return } URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(APNsError.networkError(error.localizedDescription))) return } guard let httpResponse = response as? HTTPURLResponse else { completion(.failure(APNsError.networkError("Invalid response"))) return } let apnsId = httpResponse.value(forHTTPHeaderField: "apns-id") let apnsUniqueId = httpResponse.value(forHTTPHeaderField: "apns-unique-id") var reason: String? if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let r = json["reason"] as? String { reason = r } let apnsResponse = APNsResponse( statusCode: httpResponse.statusCode, reason: reason, apnsId: apnsId, apnsUniqueId: apnsUniqueId, environment: configuration.environment, topic: configuration.topic, timestamp: timestamp, hostname: configuration.hostname, attributesType: nil, event: pushType.rawValue ) if httpResponse.statusCode == 200 { completion(.success(apnsResponse)) } else { completion(.failure(APNsError.requestFailed(response: apnsResponse))) } }.resume() } private func getOrRefreshToken(teamId: String, keyId: String, p8Contents: String) throws -> String { if let cached = cachedToken, let generatedAt = tokenGeneratedAt, Date().timeIntervalSince(generatedAt) < tokenLifetime { return cached } let token = try JWTSigner.generateToken(teamId: teamId, keyId: keyId, p8Contents: p8Contents) cachedToken = token tokenGeneratedAt = Date() return token } func invalidateToken() { cachedToken = nil tokenGeneratedAt = nil } } ================================================ FILE: QuickPush/Controllers/FCMService.swift ================================================ // // FCMService.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation import Security /// Full diagnostic info returned after every FCM request. struct FCMResponse { let statusCode: Int let messageId: String? let errorCode: String? let errorMessage: String? let projectId: String let token: String let timestamp: Int var isSuccess: Bool { statusCode == 200 } var summary: String { if isSuccess { return "200 OK" } else { return "\(statusCode): \(errorCode ?? errorMessage ?? "Unknown")" } } var diagnosticDetails: String { var lines: [String] = [] lines.append("Status: \(statusCode) \(isSuccess ? "OK" : "Error")") if let messageId { lines.append("Message ID: \(messageId)") } if let errorCode { lines.append("Error Code: \(errorCode)") } if let errorMessage { lines.append("Error Msg: \(errorMessage)") } lines.append("Project ID: \(projectId)") lines.append("Token: \(token.prefix(20))…") lines.append("Timestamp: \(timestamp) (Unix seconds)") return lines.joined(separator: "\n") } } class FCMService { static let shared = FCMService() private var cachedAccessToken: String? private var tokenExpiresAt: Date? private let tokenLifetime: TimeInterval = 55 * 60 // 55 min (Google tokens valid 60 min) enum FCMError: Error, LocalizedError { case invalidConfiguration case cannotReadServiceAccount case invalidPrivateKey case signingFailed case oauthFailed(String) case requestFailed(response: FCMResponse) case networkError(String) var errorDescription: String? { switch self { case .invalidConfiguration: return "FCM configuration is incomplete. Please fill in all fields." case .cannotReadServiceAccount: return "Cannot read service account JSON. Please re-select the file." case .invalidPrivateKey: return "Invalid private key in service account JSON." case .signingFailed: return "Failed to sign OAuth JWT." case .oauthFailed(let msg): return "OAuth token exchange failed: \(msg)" case .requestFailed(let response): return "FCM error: \(response.summary)" case .networkError(let message): return "Network error: \(message)" } } } // MARK: - Send func send( message: FCMMessage, configuration: FCMConfiguration, completion: @escaping (Result) -> Void ) { guard configuration.isValid else { completion(.failure(FCMError.invalidConfiguration)) return } guard let serviceAccountContents = configuration.serviceAccountContents, !serviceAccountContents.isEmpty else { completion(.failure(FCMError.cannotReadServiceAccount)) return } getOrRefreshAccessToken(serviceAccountContents: serviceAccountContents, clientEmail: configuration.clientEmail) { result in switch result { case .failure(let error): completion(.failure(error)) case .success(let accessToken): self.sendWithToken( accessToken: accessToken, message: message, projectId: configuration.projectId, completion: completion ) } } } private func sendWithToken( accessToken: String, message: FCMMessage, projectId: String, completion: @escaping (Result) -> Void ) { let urlString = "https://fcm.googleapis.com/v1/projects/\(projectId)/messages:send" guard let url = URL(string: urlString) else { completion(.failure(FCMError.networkError("Invalid FCM URL"))) return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = FCMRequestBody(message: message) let encoder = JSONEncoder() do { request.httpBody = try encoder.encode(body) } catch { completion(.failure(error)) return } let timestamp = Int(Date().timeIntervalSince1970) URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(FCMError.networkError(error.localizedDescription))) return } guard let httpResponse = response as? HTTPURLResponse else { completion(.failure(FCMError.networkError("Invalid response"))) return } var messageId: String? var errorCode: String? var errorMessage: String? if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { messageId = json["name"] as? String if let errorObj = json["error"] as? [String: Any] { errorCode = errorObj["status"] as? String errorMessage = errorObj["message"] as? String // Also check nested details for FCM-specific error codes if errorCode == nil, let details = errorObj["details"] as? [[String: Any]] { for detail in details { if let code = detail["errorCode"] as? String { errorCode = code break } } } } } let fcmResponse = FCMResponse( statusCode: httpResponse.statusCode, messageId: messageId, errorCode: errorCode, errorMessage: errorMessage, projectId: projectId, token: message.token, timestamp: timestamp ) if httpResponse.statusCode == 200 { completion(.success(fcmResponse)) } else { completion(.failure(FCMError.requestFailed(response: fcmResponse))) } }.resume() } // MARK: - OAuth Token private func getOrRefreshAccessToken( serviceAccountContents: String, clientEmail: String, completion: @escaping (Result) -> Void ) { if let token = cachedAccessToken, let expiresAt = tokenExpiresAt, Date() < expiresAt { completion(.success(token)) return } do { let jwt = try buildOAuthJWT(serviceAccountContents: serviceAccountContents, clientEmail: clientEmail) exchangeJWTForAccessToken(jwt: jwt, completion: { result in switch result { case .success(let token): self.cachedAccessToken = token self.tokenExpiresAt = Date().addingTimeInterval(self.tokenLifetime) completion(.success(token)) case .failure(let error): completion(.failure(error)) } }) } catch { completion(.failure(error)) } } private func exchangeJWTForAccessToken( jwt: String, completion: @escaping (Result) -> Void ) { guard let url = URL(string: "https://oauth2.googleapis.com/token") else { completion(.failure(FCMError.networkError("Invalid OAuth URL"))) return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=\(jwt)" request.httpBody = body.data(using: .utf8) URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(FCMError.oauthFailed(error.localizedDescription))) return } guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { completion(.failure(FCMError.oauthFailed("Invalid response"))) return } if let token = json["access_token"] as? String { completion(.success(token)) } else { let errDesc = (json["error_description"] as? String) ?? (json["error"] as? String) ?? "Unknown OAuth error" completion(.failure(FCMError.oauthFailed(errDesc))) } }.resume() } // MARK: - RS256 JWT for OAuth private func buildOAuthJWT(serviceAccountContents: String, clientEmail: String) throws -> String { guard let privateKeyPEM = FCMFileManager.parsePrivateKey(from: serviceAccountContents) else { throw FCMError.cannotReadServiceAccount } let privateKey = try loadRSAPrivateKey(pem: privateKeyPEM) let header = ["alg": "RS256", "typ": "JWT"] let now = Int(Date().timeIntervalSince1970) let claims: [String: Any] = [ "iss": clientEmail, "sub": clientEmail, "aud": "https://oauth2.googleapis.com/token", "scope": "https://www.googleapis.com/auth/firebase.messaging", "iat": now, "exp": now + 3600 ] let headerData = try JSONSerialization.data(withJSONObject: header) let claimsData = try JSONSerialization.data(withJSONObject: claims) let headerB64 = base64urlEncode(headerData) let claimsB64 = base64urlEncode(claimsData) let signingInput = "\(headerB64).\(claimsB64)" guard let signingData = signingInput.data(using: .utf8) else { throw FCMError.signingFailed } var error: Unmanaged? guard let signatureData = SecKeyCreateSignature( privateKey, .rsaSignatureMessagePKCS1v15SHA256, signingData as CFData, &error ) as Data? else { throw FCMError.signingFailed } let signatureB64 = base64urlEncode(signatureData) return "\(signingInput).\(signatureB64)" } // MARK: - RSA Key Loading (PKCS#8 → PKCS#1) private func loadRSAPrivateKey(pem: String) throws -> SecKey { // Strip PEM headers and decode base64 let lines = pem.components(separatedBy: "\n") let keyLines = lines.filter { !$0.hasPrefix("-----") && !$0.isEmpty } let base64Key = keyLines.joined() guard let pkcs8DER = Data(base64Encoded: base64Key) else { throw FCMError.invalidPrivateKey } // Google service accounts use PKCS#8 format. SecKey requires PKCS#1. // We must strip the PKCS#8 wrapper to get the inner PKCS#1 RSAPrivateKey. let pkcs1DER = try extractPKCS1FromPKCS8(pkcs8DER) let attributes: [String: Any] = [ kSecAttrKeyType as String: kSecAttrKeyTypeRSA, kSecAttrKeyClass as String: kSecAttrKeyClassPrivate, kSecAttrKeySizeInBits as String: 2048 ] var error: Unmanaged? guard let secKey = SecKeyCreateWithData(pkcs1DER as CFData, attributes as CFDictionary, &error) else { throw FCMError.invalidPrivateKey } return secKey } /// Strips the PKCS#8 AlgorithmIdentifier wrapper to extract the inner PKCS#1 RSAPrivateKey bytes. /// /// PKCS#8 DER layout: /// SEQUENCE { /// INTEGER (version = 0) /// SEQUENCE { OID(rsaEncryption), NULL } ← algorithm identifier /// OCTET STRING { } /// } private func extractPKCS1FromPKCS8(_ der: Data) throws -> Data { let bytes = Array(der) var index = 0 // Helper to skip a DER length field and return the content length. func readLength() throws -> Int { guard index < bytes.count else { throw FCMError.invalidPrivateKey } if bytes[index] & 0x80 == 0 { let len = Int(bytes[index]); index += 1; return len } let numBytes = Int(bytes[index] & 0x7F); index += 1 guard index + numBytes <= bytes.count else { throw FCMError.invalidPrivateKey } var len = 0 for _ in 0.. String { data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } func invalidateToken() { cachedAccessToken = nil tokenExpiresAt = nil } } ================================================ FILE: QuickPush/Controllers/JWTSigner.swift ================================================ // // JWTSigner.swift // QuickPush // // Created by beto on 2/8/26. // import Foundation import CryptoKit class JWTSigner { enum JWTError: Error, LocalizedError { case invalidP8File case signingFailed case invalidKeyData var errorDescription: String? { switch self { case .invalidP8File: return "Invalid .p8 key file" case .signingFailed: return "Failed to sign JWT token" case .invalidKeyData: return "Invalid key data in .p8 file" } } } static func generateToken(teamId: String, keyId: String, p8Contents: String) throws -> String { let privateKey = try parseP8Key(p8Contents) let header = [ "alg": "ES256", "kid": keyId, "typ": "JWT" ] let now = Int(Date().timeIntervalSince1970) let claims: [String: Any] = [ "iss": teamId, "iat": now ] let headerData = try JSONSerialization.data(withJSONObject: header) let claimsData = try JSONSerialization.data(withJSONObject: claims) let headerBase64 = base64urlEncode(headerData) let claimsBase64 = base64urlEncode(claimsData) let signingInput = "\(headerBase64).\(claimsBase64)" guard let signingData = signingInput.data(using: .utf8) else { throw JWTError.signingFailed } let signature = try privateKey.signature(for: signingData) let signatureBase64 = base64urlEncode(signature.rawRepresentation) return "\(signingInput).\(signatureBase64)" } private static func parseP8Key(_ p8Contents: String) throws -> P256.Signing.PrivateKey { let lines = p8Contents.components(separatedBy: "\n") let keyLines = lines.filter { line in !line.hasPrefix("-----") && !line.isEmpty } let base64Key = keyLines.joined() guard let keyData = Data(base64Encoded: base64Key) else { throw JWTError.invalidKeyData } do { return try P256.Signing.PrivateKey(derRepresentation: keyData) } catch { throw JWTError.invalidP8File } } private static func base64urlEncode(_ data: Data) -> String { data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } } ================================================ FILE: QuickPush/Controllers/PushNotificationService.swift ================================================ // // PushNotificationService.swift // QuickPush // // Created by beto on 2/20/25. // import Foundation class PushNotificationService { static let shared = PushNotificationService() // Singleton instance private let expoPushEndpoint = "https://exp.host/--/api/v2/push/send" private let expoReceiptsEndpoint = "https://exp.host/--/api/v2/push/getReceipts" /// Result containing the decoded response, HTTP status code, and raw JSON string. struct SendResult { let response: PushResponse let httpStatusCode: Int? let rawJSON: String? } struct ReceiptResult { let response: ReceiptResponse let httpStatusCode: Int? let rawJSON: String? } func sendPushNotification(notification: PushNotification, accessToken: String? = nil, completion: @escaping (Result) -> Void) { guard let url = URL(string: expoPushEndpoint) else { completion(.failure(APIError.invalidURL)) return } // Prepare request var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("exp.host", forHTTPHeaderField: "host") request.setValue("application/json", forHTTPHeaderField: "accept") request.setValue("gzip, deflate", forHTTPHeaderField: "accept-encoding") request.setValue("application/json", forHTTPHeaderField: "content-type") // Add authorization header if access token is provided if let accessToken = accessToken, !accessToken.isEmpty { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } do { let jsonData = try JSONEncoder().encode(notification) request.httpBody = jsonData } catch { completion(.failure(APIError.encodingFailed)) return } // Perform network request URLSession.shared.dataTask(with: request) { data, response, error in // Handle network errors if let error = error { completion(.failure(error)) return } let httpStatusCode = (response as? HTTPURLResponse)?.statusCode // Ensure we have valid data guard let data = data else { completion(.failure(APIError.noData)) return } let rawJSON = Self.prettyPrint(data) // Decode API response do { let responseObject = try JSONDecoder().decode(PushResponse.self, from: data) // UNAUTHORIZED REQUESTS CHECK - Possibly no Access Token if let errors = responseObject.errors, errors.contains(where: { $0.code == "UNAUTHORIZED" }) { completion(.failure(APIError.insufficientPermissions)) return } completion(.success(SendResult(response: responseObject, httpStatusCode: httpStatusCode, rawJSON: rawJSON))) } catch { completion(.failure(APIError.decodingFailed)) } }.resume() } func checkReceipts( ids: [String], accessToken: String? = nil, completion: @escaping (Result) -> Void ) { guard let url = URL(string: expoReceiptsEndpoint) else { completion(.failure(APIError.invalidURL)) return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("exp.host", forHTTPHeaderField: "host") request.setValue("application/json", forHTTPHeaderField: "accept") request.setValue("gzip, deflate", forHTTPHeaderField: "accept-encoding") request.setValue("application/json", forHTTPHeaderField: "content-type") if let accessToken = accessToken, !accessToken.isEmpty { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } do { let body = ["ids": ids] request.httpBody = try JSONSerialization.data(withJSONObject: body) } catch { completion(.failure(APIError.encodingFailed)) return } URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(error)) return } let httpStatusCode = (response as? HTTPURLResponse)?.statusCode guard let data = data else { completion(.failure(APIError.noData)) return } let rawJSON = Self.prettyPrint(data) do { let responseObject = try JSONDecoder().decode(ReceiptResponse.self, from: data) completion(.success(ReceiptResult(response: responseObject, httpStatusCode: httpStatusCode, rawJSON: rawJSON))) } catch { completion(.failure(APIError.decodingFailed)) } }.resume() } /// Pretty-print raw JSON data for display. private static func prettyPrint(_ data: Data) -> String? { guard let json = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) else { return String(data: data, encoding: .utf8) } return String(data: pretty, encoding: .utf8) } } // MARK: - API Error Enum enum APIError: Error, LocalizedError { case invalidURL case encodingFailed case noData case decodingFailed case insufficientPermissions var errorDescription: String? { switch self { case .invalidURL: return "Invalid API URL" case .encodingFailed: return "Failed to encode request data" case .noData: return "No response data received" case .decodingFailed: return "Failed to decode API response" case .insufficientPermissions: return "Insufficient permissions. Push security may be enabled for this app - please provide a valid access token above." } } } ================================================ FILE: QuickPush/Models/APNsConfigStore.swift ================================================ // // APNsConfigStore.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation /// Shared APNs credentials store used by both the Live Activity and APNs tabs. @Observable class APNsConfigStore { static let shared = APNsConfigStore() var teamId: String = "" { didSet { UserDefaults.standard.set(teamId, forKey: "apns_teamId") } } var keyId: String = "" { didSet { UserDefaults.standard.set(keyId, forKey: "apns_keyId") } } var bundleId: String = "" { didSet { UserDefaults.standard.set(bundleId, forKey: "apns_bundleId") } } var p8FileName: String? var hasP8Key: Bool = false var environment: APNsEnvironment = .sandbox { didSet { UserDefaults.standard.set(environment.rawValue, forKey: "apns_environment") } } init() { teamId = UserDefaults.standard.string(forKey: "apns_teamId") ?? "" keyId = UserDefaults.standard.string(forKey: "apns_keyId") ?? "" bundleId = UserDefaults.standard.string(forKey: "apns_bundleId") ?? "" p8FileName = SecurityBookmarkManager.shared.storedP8Filename() hasP8Key = SecurityBookmarkManager.shared.storedP8Contents() != nil if let envString = UserDefaults.standard.string(forKey: "apns_environment"), let env = APNsEnvironment(rawValue: envString) { environment = env } } func selectP8File() { SecurityBookmarkManager.shared.selectP8File { [weak self] contents, filename in DispatchQueue.main.async { guard let self else { return } self.p8FileName = filename self.hasP8Key = contents != nil } } } /// Build an `APNsConfiguration` with an optional topic suffix appended to bundleId. /// - Parameter topicSuffix: e.g. `".push-type.liveactivity"` for Live Activity, `nil` for plain bundle ID. func apnsConfiguration(topicSuffix: String?) -> APNsConfiguration { let topicOverride: String? = topicSuffix.map { "\(bundleId)\($0)" } return APNsConfiguration( teamId: teamId, keyId: keyId, bundleId: bundleId, p8Contents: SecurityBookmarkManager.shared.storedP8Contents(), environment: environment, topicOverride: topicOverride ) } } ================================================ FILE: QuickPush/Models/APNsConfiguration.swift ================================================ // // APNsConfiguration.swift // QuickPush // // Created by beto on 2/8/26. // import Foundation struct APNsConfiguration { var teamId: String = "" var keyId: String = "" var bundleId: String = "" var p8Contents: String? var environment: APNsEnvironment = .sandbox var topicOverride: String? = nil var isValid: Bool { !teamId.isEmpty && !keyId.isEmpty && !bundleId.isEmpty && p8Contents != nil && !p8Contents!.isEmpty } var hostname: String { environment.hostname } var topic: String { topicOverride ?? bundleId } } enum APNsEnvironment: String, CaseIterable { case sandbox case production var hostname: String { switch self { case .sandbox: return "api.sandbox.push.apple.com" case .production: return "api.push.apple.com" } } } ================================================ FILE: QuickPush/Models/FCMConfigStore.swift ================================================ // // FCMConfigStore.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation /// Shared FCM credentials store used by the FCM tab. @Observable class FCMConfigStore { static let shared = FCMConfigStore() var projectId: String = "" { didSet { UserDefaults.standard.set(projectId, forKey: "fcm_projectId") } } var clientEmail: String = "" { didSet { UserDefaults.standard.set(clientEmail, forKey: "fcm_clientEmail") } } var serviceAccountFilename: String? var hasServiceAccount: Bool = false var fileError: String? init() { projectId = UserDefaults.standard.string(forKey: "fcm_projectId") ?? "" clientEmail = UserDefaults.standard.string(forKey: "fcm_clientEmail") ?? "" serviceAccountFilename = FCMFileManager.shared.storedFilename() hasServiceAccount = FCMFileManager.shared.storedContents() != nil } func selectServiceAccountFile() { FCMFileManager.shared.selectServiceAccountFile { [weak self] result in DispatchQueue.main.async { guard let self else { return } switch result { case .success(let info): self.fileError = nil self.serviceAccountFilename = info.filename self.hasServiceAccount = true if let projectId = info.projectId, !projectId.isEmpty { self.projectId = projectId } if let clientEmail = info.clientEmail, !clientEmail.isEmpty { self.clientEmail = clientEmail } case .failure(let error): self.fileError = error.localizedDescription } } } } func fcmConfiguration() -> FCMConfiguration { FCMConfiguration( projectId: projectId, clientEmail: clientEmail, serviceAccountContents: FCMFileManager.shared.storedContents() ) } } ================================================ FILE: QuickPush/Models/FCMConfiguration.swift ================================================ // // FCMConfiguration.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation struct FCMConfiguration { var projectId: String = "" var clientEmail: String = "" var serviceAccountContents: String? var isValid: Bool { !projectId.isEmpty && !clientEmail.isEmpty && serviceAccountContents != nil && !serviceAccountContents!.isEmpty } } enum FCMMessageType: String, CaseIterable { case notification = "Notification" case data = "Data" } ================================================ FILE: QuickPush/Models/FCMPayload.swift ================================================ // // FCMPayload.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation struct FCMMessage: Encodable { var token: String var notification: FCMNotification? var android: FCMAndroidConfig? var data: [String: String]? enum CodingKeys: String, CodingKey { case token, notification, android, data } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(token, forKey: .token) try container.encodeIfPresent(notification, forKey: .notification) try container.encodeIfPresent(android, forKey: .android) if let data, !data.isEmpty { try container.encode(data, forKey: .data) } } } struct FCMNotification: Encodable { var title: String? var body: String? var image: String? func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(title, forKey: .title) try container.encodeIfPresent(body, forKey: .body) try container.encodeIfPresent(image, forKey: .image) } enum CodingKeys: String, CodingKey { case title, body, image } } struct FCMAndroidConfig: Encodable { var priority: String = "HIGH" var notification: FCMAndroidNotification? enum CodingKeys: String, CodingKey { case priority, notification } } struct FCMAndroidNotification: Encodable { var channelId: String? var sound: String? var color: String? func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(channelId, forKey: .channelId) try container.encodeIfPresent(sound, forKey: .sound) try container.encodeIfPresent(color, forKey: .color) } enum CodingKeys: String, CodingKey { case channelId = "channel_id" case sound case color } } struct FCMRequestBody: Encodable { var message: FCMMessage } ================================================ FILE: QuickPush/Models/LiveActivityPayload.swift ================================================ // // LiveActivityPayload.swift // QuickPush // // Created by beto on 2/8/26. // import Foundation struct LiveActivityAPNsPayload: Codable { let aps: LiveActivityAPS enum CodingKeys: String, CodingKey { case aps } } struct LiveActivityAPS: Codable { let timestamp: Int let event: LiveActivityEvent let contentState: LiveActivityContentState let attributesType: String? let attributes: LiveActivityAttributes? let alert: LiveActivityAlert? let dismissalDate: Int? let relevanceScore: Double? let staleDate: Int? enum CodingKeys: String, CodingKey { case timestamp, event case contentState = "content-state" case attributesType = "attributes-type" case attributes, alert case dismissalDate = "dismissal-date" case relevanceScore = "relevance-score" case staleDate = "stale-date" } } enum LiveActivityEvent: String, Codable, CaseIterable { case start case update case end } // Matches ExpoLiveActivityAttributes.ContentState exactly struct LiveActivityContentState: Codable { var title: String var subtitle: String? var timerEndDateInMilliseconds: Double? var progress: Double? var imageName: String? var dynamicIslandImageName: String? } // Matches ExpoLiveActivityAttributes exactly struct LiveActivityAttributes: Codable { var name: String var backgroundColor: String? var titleColor: String? var subtitleColor: String? var progressViewTint: String? var progressViewLabelColor: String? var deepLinkUrl: String? var timerType: String? var padding: Int? var paddingDetails: LiveActivityPaddingDetails? var imagePosition: String? var imageWidth: Int? var imageHeight: Int? } struct LiveActivityPaddingDetails: Codable { var top: Int? var bottom: Int? var left: Int? var right: Int? var vertical: Int? var horizontal: Int? } struct LiveActivityAlert: Codable { var title: String? var body: String? var sound: String? } ================================================ FILE: QuickPush/Models/NativePushPayload.swift ================================================ // // NativePushPayload.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation enum NativePushType: String, CaseIterable { case alert case background } enum NativeInterruptionLevel: String, CaseIterable { case active case passive case timeSensitive = "time-sensitive" case critical var displayName: String { switch self { case .active: return "Active" case .passive: return "Passive" case .timeSensitive: return "Time Sensitive" case .critical: return "Critical" } } } struct NativeAlert: Codable { var title: String? var subtitle: String? var body: String? } struct NativeAPS: Encodable { var alert: NativeAlert? var badge: Int? var sound: String? var contentAvailable: Int? var mutableContent: Int? var threadId: String? var category: String? var interruptionLevel: String? enum CodingKeys: String, CodingKey { case alert case badge case sound case contentAvailable = "content-available" case mutableContent = "mutable-content" case threadId = "thread-id" case category case interruptionLevel = "interruption-level" } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(alert, forKey: .alert) try container.encodeIfPresent(badge, forKey: .badge) try container.encodeIfPresent(sound, forKey: .sound) try container.encodeIfPresent(contentAvailable, forKey: .contentAvailable) try container.encodeIfPresent(mutableContent, forKey: .mutableContent) try container.encodeIfPresent(threadId, forKey: .threadId) try container.encodeIfPresent(category, forKey: .category) try container.encodeIfPresent(interruptionLevel, forKey: .interruptionLevel) } } private struct RichBody: Encodable { struct RichContent: Encodable { let image: String } let _richContent: RichContent enum CodingKeys: String, CodingKey { case _richContent = "_richContent" } } struct NativePushPayload: Encodable { var aps: NativeAPS var customData: [String: String] var imageUrl: String? func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: DynamicCodingKey.self) try container.encode(aps, forKey: DynamicCodingKey(stringValue: "aps")) if let imageUrl, !imageUrl.isEmpty { let richBody = RichBody(_richContent: .init(image: imageUrl)) try container.encode(richBody, forKey: DynamicCodingKey(stringValue: "body")) } for (key, value) in customData { try container.encode(value, forKey: DynamicCodingKey(stringValue: key)) } } } private struct DynamicCodingKey: CodingKey { var stringValue: String init(stringValue: String) { self.stringValue = stringValue } var intValue: Int? { nil } init?(intValue: Int) { return nil } } ================================================ FILE: QuickPush/Models/PushNotification.swift ================================================ // // PushNotification.swift // QuickPush // // Created by beto on 2/20/25. // import Foundation struct PushNotification: Codable { let to: [String] // Supports both single and multiple recipients let title: String let body: String let data: [String: String]? let ttl: Int? let expiration: Int? let priority: Priority? let subtitle: String? let sound: String? let badge: Int? let interruptionLevel: InterruptionLevel? let channelId: String? let categoryId: String? let mutableContent: Bool? let contentAvailable: Bool? let richContent: RichContent? enum Priority: String, Codable { case `default`, normal, high } enum InterruptionLevel: String, Codable { case active, critical, passive, timeSensitive = "time-sensitive" } struct RichContent: Codable { let image: String } enum CodingKeys: String, CodingKey { case to, title, body, data, ttl, expiration, priority, subtitle, sound, badge case interruptionLevel = "interruptionLevel" case channelId = "channelId" case categoryId = "categoryId" case mutableContent = "mutableContent" case contentAvailable = "_contentAvailable" case richContent } init( to: [String], title: String, body: String, data: [String: String]? = nil, ttl: Int? = nil, expiration: Int? = nil, priority: Priority? = .default, subtitle: String? = nil, sound: String? = "default", badge: Int? = nil, interruptionLevel: InterruptionLevel? = nil, channelId: String? = nil, categoryId: String? = nil, mutableContent: Bool? = false, contentAvailable: Bool? = nil, richContent: RichContent? = nil ) { self.to = to self.title = title self.body = body self.data = data self.ttl = ttl self.expiration = expiration self.priority = priority self.subtitle = subtitle self.sound = sound self.badge = badge self.interruptionLevel = interruptionLevel self.channelId = channelId self.categoryId = categoryId self.mutableContent = mutableContent self.contentAvailable = contentAvailable self.richContent = richContent } } ================================================ FILE: QuickPush/Models/PushResponse.swift ================================================ // // PushResponse.swift // QuickPush // // Created by beto on 2/20/25. // import Foundation struct PushResponse: Codable { let data: [PushTicket]? let errors: [PushError]? struct PushTicket: Codable { let status: String let id: String? // Present only if status == "ok" let message: String? // Present only if status == "error" let details: [String: String]? // JSON details object, can vary } struct PushError: Codable { let code: String let message: String let type: String? let isTransient: Bool? let metadata: ErrorMetadata? let requestId: String? struct ErrorMetadata: Codable { let appId: String? let experienceId: String? } } } ================================================ FILE: QuickPush/Models/ReceiptResponse.swift ================================================ // // ReceiptResponse.swift // QuickPush // // Created by beto on 2/20/26. // import Foundation struct ReceiptResponse: Codable { let data: [String: PushReceipt]? let errors: [PushResponse.PushError]? struct PushReceipt: Codable { let status: String // "ok" or "error" let message: String? let details: ReceiptDetails? struct ReceiptDetails: Codable { let error: String? // e.g. "DeviceNotRegistered" } } } ================================================ FILE: QuickPush/Models/SavedToken.swift ================================================ // // SavedToken.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation struct SavedToken: Codable, Identifiable, Equatable { let id: UUID var label: String var token: String var isEnabled: Bool init(id: UUID = UUID(), label: String, token: String, isEnabled: Bool = true) { self.id = id self.label = label self.token = token self.isEnabled = isEnabled } } ================================================ FILE: QuickPush/Models/TokenToSave.swift ================================================ // // TokenToSave.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation /// Identifiable wrapper for the `.sheet(item:)` token-save presentation. struct TokenToSave: Identifiable { let id = UUID() let index: Int let token: String } ================================================ FILE: QuickPush/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: QuickPush/QuickPush.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only com.apple.security.network.client com.apple.security.files.bookmarks.app-scope ================================================ FILE: QuickPush/QuickPushApp.swift ================================================ // // QuickPushApp.swift // QuickPush // // Created by beto on 2/20/25. // import SwiftUI @main struct QuickPushApp: App { @State private var windowManager = WindowManager() var body: some Scene { MenuBarExtra("QuickPush", systemImage: "bolt.brakesignal") { MainContentView() .environment(windowManager) .onAppear { // Register the global hotkey after the app has launched. windowManager.startMonitoring() // If the panel is already pinned, bring it to front // instead of showing the popover content. if windowManager.isPinned { windowManager.bringPanelToFront() } } } .menuBarExtraStyle(.window) } } ================================================ FILE: QuickPush/QuickPushIcon.icon/icon.json ================================================ { "fill" : "automatic", "groups" : [ { "layers" : [ { "fill-specializations" : [ { "appearance" : "dark", "value" : { "solid" : "extended-gray:1.00000,1.00000" } } ], "glass" : true, "image-name" : "bolt.brakesignal 1.svg", "name" : "bolt.brakesignal 1", "position" : { "scale" : 6.81, "translation-in-points" : [ 0, 0 ] } } ], "position" : { "scale" : 1, "translation-in-points" : [ 5, 0 ] }, "shadow" : { "kind" : "neutral", "opacity" : 0.5 }, "translucency" : { "enabled" : true, "value" : 0.5 } } ], "supported-platforms" : { "circles" : [ "watchOS" ], "squares" : "shared" } } ================================================ FILE: QuickPush/Utilities/ColorHexConverter.swift ================================================ // // ColorHexConverter.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI import AppKit extension Color { init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 Scanner(string: hex).scanHexInt64(&int) let r, g, b, a: Double switch hex.count { case 6: (r, g, b, a) = ( Double((int >> 16) & 0xFF) / 255, Double((int >> 8) & 0xFF) / 255, Double(int & 0xFF) / 255, 1 ) case 8: (r, g, b, a) = ( Double((int >> 24) & 0xFF) / 255, Double((int >> 16) & 0xFF) / 255, Double((int >> 8) & 0xFF) / 255, Double(int & 0xFF) / 255 ) default: (r, g, b, a) = (0, 0, 0, 1) } self.init(.sRGB, red: r, green: g, blue: b, opacity: a) } func toHexString() -> String { guard let nsColor = NSColor(self).usingColorSpace(.sRGB) else { return "000000" } let r = Int(round(nsColor.redComponent * 255)) let g = Int(round(nsColor.greenComponent * 255)) let b = Int(round(nsColor.blueComponent * 255)) return String(format: "%02X%02X%02X", r, g, b) } func toHexStringWithAlpha() -> String { guard let nsColor = NSColor(self).usingColorSpace(.sRGB) else { return "000000FF" } let r = Int(round(nsColor.redComponent * 255)) let g = Int(round(nsColor.greenComponent * 255)) let b = Int(round(nsColor.blueComponent * 255)) let a = Int(round(nsColor.alphaComponent * 255)) return String(format: "%02X%02X%02X%02X", r, g, b, a) } } ================================================ FILE: QuickPush/Utilities/FCMFileManager.swift ================================================ // // FCMFileManager.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation import AppKit /// Manages selection and persistent storage of the Firebase service account JSON. /// /// We read the file contents once when selected and store them directly in /// UserDefaults (service account JSON is ~3–5 KB, well within limits). class FCMFileManager { static let shared = FCMFileManager() private let contentsKey = "fcm_serviceAccountContents" private let filenameKey = "fcm_serviceAccountFilename" private let projectIdKey = "fcm_projectId" private let clientEmailKey = "fcm_clientEmail" // MARK: - File Picker enum SelectionError: Error { case notServiceAccount case unreadable var localizedDescription: String { switch self { case .notServiceAccount: return "This looks like a google-services.json (client config), not a service account key. Go to Firebase Console → Project Settings → Service Accounts → Generate new private key to get the correct file." case .unreadable: return "Could not read the selected file." } } } func selectServiceAccountFile(completion: @escaping (Result<(contents: String, filename: String, projectId: String?, clientEmail: String?), SelectionError>) -> Void) { let panel = NSOpenPanel() panel.title = "Select Firebase Service Account JSON" panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.begin { response in guard response == .OK, let url = panel.url else { return } guard let contents = try? String(contentsOf: url, encoding: .utf8) else { completion(.failure(.unreadable)) return } // Validate this is a service account file, not google-services.json guard Self.parsePrivateKey(from: contents) != nil else { completion(.failure(.notServiceAccount)) return } let filename = url.lastPathComponent let (projectId, clientEmail) = Self.parse(serviceAccountContents: contents) self.save(contents: contents, filename: filename, projectId: projectId ?? "", clientEmail: clientEmail ?? "") completion(.success((contents, filename, projectId, clientEmail))) } } // MARK: - Parsing static func parse(serviceAccountContents: String) -> (projectId: String?, clientEmail: String?) { guard let data = serviceAccountContents.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (nil, nil) } let projectId = json["project_id"] as? String let clientEmail = json["client_email"] as? String return (projectId, clientEmail) } static func parsePrivateKey(from serviceAccountContents: String) -> String? { guard let data = serviceAccountContents.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return json["private_key"] as? String } // MARK: - Storage func save(contents: String, filename: String, projectId: String, clientEmail: String) { UserDefaults.standard.set(contents, forKey: contentsKey) UserDefaults.standard.set(filename, forKey: filenameKey) UserDefaults.standard.set(projectId, forKey: projectIdKey) UserDefaults.standard.set(clientEmail, forKey: clientEmailKey) } func storedContents() -> String? { UserDefaults.standard.string(forKey: contentsKey) } func storedFilename() -> String? { UserDefaults.standard.string(forKey: filenameKey) } func storedProjectId() -> String? { UserDefaults.standard.string(forKey: projectIdKey) } func storedClientEmail() -> String? { UserDefaults.standard.string(forKey: clientEmailKey) } func clearStored() { UserDefaults.standard.removeObject(forKey: contentsKey) UserDefaults.standard.removeObject(forKey: filenameKey) UserDefaults.standard.removeObject(forKey: projectIdKey) UserDefaults.standard.removeObject(forKey: clientEmailKey) } } ================================================ FILE: QuickPush/Utilities/FloatingPanel.swift ================================================ // // FloatingPanel.swift // QuickPush // // Created by beto on 2/8/26. // import AppKit /// A floating NSPanel that stays on top of other windows. /// Used for the "Pin Window" feature so the app persists /// even when the user clicks outside. final class FloatingPanel: NSPanel { /// Called when the user closes the panel via the title-bar X button. /// The WindowManager sets this so it can run `unpin()` cleanly. var onClose: (() -> Void)? init(contentRect: NSRect) { super.init( contentRect: contentRect, styleMask: [ .titled, .closable, .miniaturizable, .resizable, ], backing: .buffered, defer: false ) level = .floating isFloatingPanel = true hidesOnDeactivate = false collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] titlebarAppearsTransparent = false titleVisibility = .hidden isMovableByWindowBackground = true title = "Quick Push" isReleasedWhenClosed = false animationBehavior = .utilityWindow isOpaque = false backgroundColor = .clear // Match the menu bar popover appearance let blurView = NSVisualEffectView() blurView.material = .popover blurView.blendingMode = .behindWindow blurView.state = .active contentView = blurView // Custom icon + title placed directly in the titlebar if let titlebarView = standardWindowButton(.closeButton)?.superview { let iconView = NSImageView() let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium) iconView.image = NSImage( systemSymbolName: "bolt.brakesignal", accessibilityDescription: "Quick Push" )?.withSymbolConfiguration(config) iconView.contentTintColor = .secondaryLabelColor iconView.translatesAutoresizingMaskIntoConstraints = false let label = NSTextField(labelWithString: "Quick Push") label.font = .titleBarFont(ofSize: 13) label.textColor = .labelColor label.translatesAutoresizingMaskIntoConstraints = false let stack = NSStackView(views: [iconView, label]) stack.orientation = .horizontal stack.spacing = 4 stack.alignment = .centerY stack.translatesAutoresizingMaskIntoConstraints = false titlebarView.addSubview(stack) NSLayoutConstraint.activate([ stack.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor), stack.centerXAnchor.constraint(equalTo: titlebarView.centerXAnchor), ]) } // Minimum size to keep the UI usable minSize = NSSize(width: 570, height: 460) // Restore saved position or center on screen if let frameString = UserDefaults.standard.string(forKey: "FloatingPanelFrame"), !frameString.isEmpty { setFrame(NSRectFromString(frameString), display: true) } else { center() } } override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } /// Swallow the Escape key so it doesn't close the pinned panel. override func cancelOperation(_ sender: Any?) { } /// Save the panel frame whenever it moves or resizes. func saveFrame() { UserDefaults.standard.set(NSStringFromRect(frame), forKey: "FloatingPanelFrame") } override func close() { saveFrame() // Route through the WindowManager so it can cleanly detach // SwiftUI views before the window hides. if let onClose { onClose() } else { super.close() } } } ================================================ FILE: QuickPush/Utilities/SavedTokenStore.swift ================================================ // // SavedTokenStore.swift // QuickPush // // Created by beto on 2/19/26. // import Foundation /// Manages persistent storage of saved push tokens in UserDefaults. class SavedTokenStore { /// Store for Expo push tokens (ExponentPushToken format). static let shared = SavedTokenStore() /// Store for native APNs device tokens (hex format). static let nativePush = SavedTokenStore(storageKey: "savedNativePushTokens") /// Store for FCM registration tokens. static let fcm = SavedTokenStore(storageKey: "savedFCMTokens") private let storageKey: String init(storageKey: String = "savedPushTokens") { self.storageKey = storageKey } // MARK: - Read func loadTokens() -> [SavedToken] { guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] } return (try? JSONDecoder().decode([SavedToken].self, from: data)) ?? [] } // MARK: - Write func saveTokens(_ tokens: [SavedToken]) { if let data = try? JSONEncoder().encode(tokens) { UserDefaults.standard.set(data, forKey: storageKey) } } func addToken(_ token: SavedToken) { var tokens = loadTokens() tokens.append(token) saveTokens(tokens) } func removeToken(id: UUID) { var tokens = loadTokens() tokens.removeAll { $0.id == id } saveTokens(tokens) } } ================================================ FILE: QuickPush/Utilities/SecurityBookmarkManager.swift ================================================ // // SecurityBookmarkManager.swift // QuickPush // // Created by beto on 2/8/26. // import Foundation import AppKit /// Manages selection and persistent storage of the .p8 APNs auth key. /// /// Instead of relying on security-scoped bookmarks (which fail in sandbox /// and break across Xcode rebuilds), we read the file contents once when /// selected and store them directly in UserDefaults. class SecurityBookmarkManager { static let shared = SecurityBookmarkManager() private let p8ContentsKey = "p8FileContents" private let p8FilenameKey = "p8FileName" // MARK: - File Picker func selectP8File(completion: @escaping (String?, String?) -> Void) { let panel = NSOpenPanel() panel.title = "Select APNs Auth Key (.p8)" panel.allowedContentTypes = [.init(filenameExtension: "p8")!] panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.begin { response in guard response == .OK, let url = panel.url else { completion(nil, nil) return } // Read the file contents immediately while NSOpenPanel access is active. let contents = try? String(contentsOf: url, encoding: .utf8) let filename = url.lastPathComponent if let contents { self.saveP8(contents: contents, filename: filename) } completion(contents, filename) } } // MARK: - Storage func saveP8(contents: String, filename: String) { UserDefaults.standard.set(contents, forKey: p8ContentsKey) UserDefaults.standard.set(filename, forKey: p8FilenameKey) } func storedP8Contents() -> String? { UserDefaults.standard.string(forKey: p8ContentsKey) } func storedP8Filename() -> String? { UserDefaults.standard.string(forKey: p8FilenameKey) } func clearStoredP8() { UserDefaults.standard.removeObject(forKey: p8ContentsKey) UserDefaults.standard.removeObject(forKey: p8FilenameKey) } } ================================================ FILE: QuickPush/Utilities/View+Extensions.swift ================================================ import SwiftUI extension View { @ViewBuilder func applying(@ViewBuilder _ modifier: (Self) -> V) -> some View { modifier(self) } /// Present content as a `.sheet` when pinned, or `.popover` when in the MenuBarExtra. @ViewBuilder func adaptivePresentation( isPresented: Binding, isPinned: Bool, @ViewBuilder content: @escaping () -> Content ) -> some View { if isPinned { self.sheet(isPresented: isPresented, content: content) } else { self.popover(isPresented: isPresented, arrowEdge: .top, content: content) } } /// Present content as a `.sheet` when pinned, or `.popover` when in the MenuBarExtra (item variant). @ViewBuilder func adaptivePresentation( item: Binding, isPinned: Bool, @ViewBuilder content: @escaping (Item) -> Content ) -> some View { if isPinned { self.sheet(item: item, content: content) } else { self.popover(item: item, arrowEdge: .top, content: content) } } } ================================================ FILE: QuickPush/Utilities/WindowManager.swift ================================================ // // WindowManager.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI /// Manages the floating "pinned" window and the global keyboard shortcut. @Observable final class WindowManager { /// Whether the floating panel is currently visible. private(set) var isPinned: Bool = false private var panel: FloatingPanel? private var globalMonitor: Any? private var localMonitor: Any? private var hotkeyRegistered = false deinit { removeHotkey() } /// Call once after app has finished launching (e.g. from onAppear). func startMonitoring() { guard !hotkeyRegistered else { return } hotkeyRegistered = true registerHotkey() } // MARK: - Pin / Unpin func togglePin() { if isPinned { unpin() } else { pin() } } func pin() { guard !isPinned else { return } // Dismiss the MenuBarExtra popover by closing every visible window // that isn't our floating panel. The popover is backed by an internal // NSWindow — ordering it out hides it without destroying it. for window in NSApp.windows where window !== panel && window.isVisible { window.orderOut(nil) } // Create the floating panel if needed if panel == nil { let rect = NSRect(x: 0, y: 0, width: 570, height: 500) let newPanel = FloatingPanel(contentRect: rect) newPanel.onClose = { [weak self] in self?.unpin() } panel = newPanel } // Host the SwiftUI content inside the panel's blur view let content = MainContentView() .environment(self) let hostingView = NSHostingView(rootView: content) hostingView.sizingOptions = [.minSize] hostingView.translatesAutoresizingMaskIntoConstraints = false hostingView.layer?.backgroundColor = .clear if let blurView = panel?.contentView { // Remove any previous hosting view blurView.subviews.forEach { $0.removeFromSuperview() } blurView.addSubview(hostingView) NSLayoutConstraint.activate([ hostingView.topAnchor.constraint(equalTo: blurView.topAnchor), hostingView.bottomAnchor.constraint(equalTo: blurView.bottomAnchor), hostingView.leadingAnchor.constraint(equalTo: blurView.leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: blurView.trailingAnchor), ]) } // Show the panel after a brief delay so the popover finishes closing. DispatchQueue.main.async { [weak self] in guard let self, let panel = self.panel else { return } // Temporarily become a regular app so macOS lets us show a window, // then switch back to accessory so we don't linger in the Dock. NSApp.setActivationPolicy(.regular) panel.makeKeyAndOrderFront(nil) DispatchQueue.main.async { NSApp.setActivationPolicy(.accessory) } self.isPinned = true } } func unpin() { guard isPinned else { return } guard let panel else { isPinned = false return } panel.saveFrame() // Detach the SwiftUI hosting view FIRST so it doesn't try to // re-render while the window disappears underneath it. // Keep the blur view alive — only remove the hosting subview. panel.contentView?.subviews.forEach { $0.removeFromSuperview() } // Now hide the panel safely. panel.orderOut(nil) isPinned = false } /// Called when the user clicks the menu bar icon while the panel is pinned. func bringPanelToFront() { guard isPinned, let panel else { return } panel.orderFrontRegardless() panel.makeKey() } // MARK: - Global Keyboard Shortcut (Cmd+Shift+P) private func registerHotkey() { // Global monitor – fires when our app is NOT focused globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in self?.handleHotkey(event) } // Local monitor – fires when our app IS focused localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in if self?.handleHotkey(event) == true { return nil // swallow the event } return event } } private func removeHotkey() { if let globalMonitor { NSEvent.removeMonitor(globalMonitor) } if let localMonitor { NSEvent.removeMonitor(localMonitor) } globalMonitor = nil localMonitor = nil } /// Returns `true` if the event matched our shortcut and was handled. @discardableResult private func handleHotkey(_ event: NSEvent) -> Bool { // Cmd + Shift + P guard event.modifierFlags.contains([.command, .shift]), event.charactersIgnoringModifiers?.lowercased() == "p" else { return false } DispatchQueue.main.async { [weak self] in self?.togglePin() } return true } } ================================================ FILE: QuickPush/ViewModels/FCMViewModel.swift ================================================ // // FCMViewModel.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI @Observable class FCMViewModel { // MARK: - Shared FCM config var config: FCMConfigStore = .shared // MARK: - Tokens var tokens: [String] = [""] var savedTokens: [SavedToken] = [] var allValidTokens: [String] { savedTokens.filter(\.isEnabled).map(\.token) + tokens.filter { !$0.isEmpty } } // MARK: - Message fields var messageType: FCMMessageType = .notification var title: String = "" var body: String = "" var imageUrl: String = "" var channelId: String = "default" var sound: String = "default" var color: String = "" var priority: String = "HIGH" var customData: [String: String] = [:] // MARK: - UI State var isSending: Bool = false var showToast: Bool = false var toastMessage: String = "" var toastType: ToastType = .success var showResponseSheet: Bool = false var showCurlSheet: Bool = false var lastResponse: FCMResponse? var tokenToSave: TokenToSave? var curlCommand: String = "" // MARK: - Init init() { savedTokens = SavedTokenStore.fcm.loadTokens() } // MARK: - Validation var canSend: Bool { !allValidTokens.isEmpty && config.fcmConfiguration().isValid && !isSending } // MARK: - Build Message func buildMessage(token: String) -> FCMMessage { var notification: FCMNotification? if messageType == .notification { notification = FCMNotification( title: title.isEmpty ? nil : title, body: body.isEmpty ? nil : body, image: imageUrl.isEmpty ? nil : imageUrl ) } var androidNotification: FCMAndroidNotification? if messageType == .notification { androidNotification = FCMAndroidNotification( channelId: channelId.isEmpty ? nil : channelId, sound: sound.isEmpty ? nil : sound, color: color.isEmpty ? nil : "#\(color.trimmingCharacters(in: CharacterSet(charactersIn: "#")))" ) } let androidConfig = FCMAndroidConfig( priority: priority, notification: androidNotification ) let data = customData.isEmpty ? nil : customData return FCMMessage( token: token, notification: notification, android: androidConfig, data: data ) } // MARK: - Send func send() { guard canSend else { return } if messageType == .notification && title.isEmpty && body.isEmpty { showToastMessage("Notification messages require at least a title or body.", type: .error) return } isSending = true let validTokens = allValidTokens let configuration = config.fcmConfiguration() let group = DispatchGroup() var responses: [FCMResponse] = [] var firstError: Error? for token in validTokens { let message = buildMessage(token: token) group.enter() FCMService.shared.send( message: message, configuration: configuration ) { result in switch result { case .success(let response): responses.append(response) case .failure(let error): if firstError == nil { firstError = error } if case FCMService.FCMError.requestFailed(let response) = error { responses.append(response) } } group.leave() } } group.notify(queue: .main) { [weak self] in guard let self else { return } self.isSending = false self.lastResponse = responses.last if let error = firstError { self.showToastMessage(error.localizedDescription, type: .error) } else { let msg = responses.count == 1 ? responses[0].summary : "\(responses.count) pushes sent" self.showToastMessage(msg, type: .success) } } } // MARK: - cURL func generateCurlCommand() -> String { let configuration = config.fcmConfiguration() let cleanToken = (allValidTokens.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let message = buildMessage(token: cleanToken.isEmpty ? "" : cleanToken) let body = FCMRequestBody(message: message) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let bodyString: String if let data = try? encoder.encode(body), let str = String(data: data, encoding: .utf8) { bodyString = str } else { bodyString = "{}" } var lines: [String] = [] lines.append("# Replace with a valid OAuth 2.0 bearer token.") lines.append("# Obtain one by running: gcloud auth print-access-token") if allValidTokens.count > 1 { lines.append("# cURL shown for the first token. Repeat for each additional token.") } lines.append("curl -X POST \\") lines.append(" https://fcm.googleapis.com/v1/projects/\(configuration.projectId)/messages:send \\") lines.append(" -H \"Authorization: Bearer \" \\") lines.append(" -H \"Content-Type: application/json\" \\") lines.append(" -d '\(bodyString)'") return lines.joined(separator: "\n") } // MARK: - Clipboard func pasteToken(at index: Int) { guard let clipboardString = NSPasteboard.general.string(forType: .string) else { return } let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { showToastMessage("Clipboard is empty.", type: .error) return } if index < tokens.count { tokens[index] = trimmed } } // MARK: - Toast func showToastMessage(_ message: String, type: ToastType) { toastMessage = message toastType = type showToast = true } } ================================================ FILE: QuickPush/ViewModels/LiveActivityViewModel.swift ================================================ // // LiveActivityViewModel.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI @Observable class LiveActivityViewModel { // MARK: - Event Type var eventType: LiveActivityEvent = .start // MARK: - Token var deviceToken: String = "" // MARK: - Content State var contentTitle: String = "" var contentSubtitle: String = "" var includeProgress: Bool = false var progress: Double = 0.5 var includeTimerEnd: Bool = false var timerEndDate: Date = Date().addingTimeInterval(3600) var imageName: String = "" var dynamicIslandImageName: String = "" // MARK: - Attributes (Start only) var attributeName: String = "" var backgroundColor: Color = Color(hex: "001A72") var titleColor: Color = Color(hex: "EBEBF0") var subtitleColor: Color = Color(hex: "EBEBF599") var progressViewTint: Color = Color(hex: "FFFFFF") var progressViewLabelColor: Color = Color(hex: "EBEBF0") var deepLinkUrl: String = "" var timerType: String = "digital" var imagePosition: String = "left" var imageWidth: Int = 40 var imageHeight: Int = 40 var useCustomPadding: Bool = false var uniformPadding: Int = 16 var paddingTop: Int = 16 var paddingBottom: Int = 16 var paddingLeft: Int = 16 var paddingRight: Int = 16 // MARK: - Alert (Start only) var includeAlert: Bool = false var alertTitle: String = "" var alertBody: String = "" var alertSound: String = "default" // MARK: - APNs Configuration (shared with APNs tab) var config: APNsConfigStore = .shared var attributesType: String = "LiveActivityAttributes" { didSet { UserDefaults.standard.set(attributesType, forKey: "apns_attributesType") } } // MARK: - UI State var isSending: Bool = false var showToast: Bool = false var toastMessage: String = "" var toastType: ToastType = .success var showJSONSheet: Bool = false var showResponseSheet: Bool = false var lastResponse: APNsResponse? // MARK: - Init init() { attributesType = UserDefaults.standard.string(forKey: "apns_attributesType") ?? "LiveActivityAttributes" } // MARK: - Validation var canSend: Bool { !deviceToken.isEmpty && apnsConfiguration.isValid && !isSending } var apnsConfiguration: APNsConfiguration { config.apnsConfiguration(topicSuffix: ".push-type.liveactivity") } var tokenLabel: String { eventType == .start ? "Push-to-Start Token" : "Activity Token" } // MARK: - Build Payload func buildPayload() -> LiveActivityAPNsPayload { let contentState = LiveActivityContentState( title: contentTitle.isEmpty ? "Activity" : contentTitle, subtitle: contentSubtitle.isEmpty ? nil : contentSubtitle, timerEndDateInMilliseconds: includeTimerEnd ? timerEndDate.timeIntervalSince1970 * 1000 : nil, progress: includeProgress ? progress : nil, imageName: imageName.isEmpty ? nil : imageName, dynamicIslandImageName: dynamicIslandImageName.isEmpty ? nil : dynamicIslandImageName ) var attributes: LiveActivityAttributes? if eventType == .start { let paddingDetails: LiveActivityPaddingDetails? if useCustomPadding { paddingDetails = LiveActivityPaddingDetails( top: paddingTop, bottom: paddingBottom, left: paddingLeft, right: paddingRight ) } else { paddingDetails = nil } attributes = LiveActivityAttributes( name: attributeName.isEmpty ? "LiveActivity" : attributeName, backgroundColor: backgroundColor.toHexString(), titleColor: titleColor.toHexString(), subtitleColor: subtitleColor.toHexStringWithAlpha(), progressViewTint: progressViewTint.toHexString(), progressViewLabelColor: progressViewLabelColor.toHexString(), deepLinkUrl: deepLinkUrl.isEmpty ? nil : deepLinkUrl, timerType: timerType, padding: useCustomPadding ? nil : uniformPadding, paddingDetails: paddingDetails, imagePosition: imagePosition, imageWidth: (!imageName.isEmpty || !dynamicIslandImageName.isEmpty) ? imageWidth : nil, imageHeight: (!imageName.isEmpty || !dynamicIslandImageName.isEmpty) ? imageHeight : nil ) } var alert: LiveActivityAlert? if includeAlert && eventType == .start { alert = LiveActivityAlert( title: alertTitle.isEmpty ? nil : alertTitle, body: alertBody.isEmpty ? nil : alertBody, sound: alertSound.isEmpty ? nil : alertSound ) } let aps = LiveActivityAPS( timestamp: Int(Date().addingTimeInterval(5).timeIntervalSince1970), event: eventType, contentState: contentState, attributesType: eventType == .start ? attributesType : nil, attributes: attributes, alert: alert, dismissalDate: eventType == .end ? Int(Date().addingTimeInterval(5).timeIntervalSince1970) : nil, relevanceScore: nil, staleDate: nil ) return LiveActivityAPNsPayload(aps: aps) } // MARK: - Send func send() { guard canSend else { return } isSending = true let payload = buildPayload() APNsService.shared.sendLiveActivityPush( payload: payload, token: deviceToken, configuration: apnsConfiguration ) { [weak self] result in DispatchQueue.main.async { guard let self else { return } self.isSending = false switch result { case .success(let response): self.lastResponse = response self.showToastMessage(response.summary, type: .success) case .failure(let error): if case APNsService.APNsError.requestFailed(let response) = error { self.lastResponse = response } self.showToastMessage(error.localizedDescription, type: .error) } } } } // MARK: - JSON Export/Import func exportJSON() -> String { let payload = buildPayload() let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] guard let data = try? encoder.encode(payload), let json = String(data: data, encoding: .utf8) else { return "{}" } return json } func importJSON(_ jsonString: String) -> Bool { guard let data = jsonString.data(using: .utf8) else { return false } do { let payload = try JSONDecoder().decode(LiveActivityAPNsPayload.self, from: data) applyPayload(payload) return true } catch { showToastMessage("Invalid JSON: \(error.localizedDescription)", type: .error) return false } } private func applyPayload(_ payload: LiveActivityAPNsPayload) { let aps = payload.aps eventType = aps.event // Content State let cs = aps.contentState contentTitle = cs.title contentSubtitle = cs.subtitle ?? "" if let p = cs.progress { includeProgress = true progress = p } else { includeProgress = false } if let timerEnd = cs.timerEndDateInMilliseconds { includeTimerEnd = true timerEndDate = Date(timeIntervalSince1970: timerEnd / 1000) } else { includeTimerEnd = false } imageName = cs.imageName ?? "" dynamicIslandImageName = cs.dynamicIslandImageName ?? "" // Attributes if let attrs = aps.attributes { attributeName = attrs.name if let bg = attrs.backgroundColor { backgroundColor = Color(hex: bg) } if let tc = attrs.titleColor { titleColor = Color(hex: tc) } if let sc = attrs.subtitleColor { subtitleColor = Color(hex: sc) } if let pt = attrs.progressViewTint { progressViewTint = Color(hex: pt) } if let pl = attrs.progressViewLabelColor { progressViewLabelColor = Color(hex: pl) } deepLinkUrl = attrs.deepLinkUrl ?? "" timerType = attrs.timerType ?? "digital" imagePosition = attrs.imagePosition ?? "left" if let w = attrs.imageWidth { imageWidth = w } if let h = attrs.imageHeight { imageHeight = h } if let p = attrs.padding { useCustomPadding = false uniformPadding = p } if let pd = attrs.paddingDetails { useCustomPadding = true paddingTop = pd.top ?? 16 paddingBottom = pd.bottom ?? 16 paddingLeft = pd.left ?? 16 paddingRight = pd.right ?? 16 } } // Alert if let alert = aps.alert { includeAlert = true alertTitle = alert.title ?? "" alertBody = alert.body ?? "" alertSound = alert.sound ?? "default" } else { includeAlert = false } } // MARK: - Toast private func showToastMessage(_ message: String, type: ToastType) { toastMessage = message toastType = type showToast = true } // MARK: - File Picker func selectP8File() { config.selectP8File() } // MARK: - Clipboard func pasteToken() { if let clipboardString = NSPasteboard.general.string(forType: .string) { let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines) let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdefABCDEF") if trimmed.unicodeScalars.allSatisfy({ hexCharacterSet.contains($0) }) && !trimmed.isEmpty { deviceToken = trimmed } else { showToastMessage("Invalid token format. Expected hex string.", type: .error) } } } } ================================================ FILE: QuickPush/ViewModels/NativePushViewModel.swift ================================================ // // NativePushViewModel.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI @Observable class NativePushViewModel { // MARK: - Shared APNs config var config: APNsConfigStore = .shared // MARK: - Tokens var tokens: [String] = [""] var savedTokens: [SavedToken] = [] var allValidTokens: [String] { savedTokens.filter(\.isEnabled).map(\.token) + tokens.filter { !$0.isEmpty } } // MARK: - Push fields var pushType: NativePushType = .alert var title: String = "" var subtitle: String = "" var body: String = "" var sound: String = "default" var badge: String = "" var threadId: String = "" var categoryId: String = "" var interruptionLevel: NativeInterruptionLevel = .active var imageUrl: String = "" var mutableContent: Bool = false var contentAvailable: Bool = false var priority: Int = 10 var customData: [String: String] = [:] // MARK: - UI State var isSending: Bool = false var showToast: Bool = false var toastMessage: String = "" var toastType: ToastType = .success var showResponseSheet: Bool = false var showCurlSheet: Bool = false var lastResponse: APNsResponse? var tokenToSave: TokenToSave? var curlCommand: String = "" // MARK: - Init init() { savedTokens = SavedTokenStore.nativePush.loadTokens() } // MARK: - Validation var canSend: Bool { !allValidTokens.isEmpty && config.apnsConfiguration(topicSuffix: nil).isValid && !isSending } // MARK: - Build Payload func buildPayload() -> NativePushPayload { var alert: NativeAlert? if pushType == .alert { let t = title.isEmpty ? nil : title let s = subtitle.isEmpty ? nil : subtitle let b = body.isEmpty ? nil : body // Only include alert key if there is at least some visible content if t != nil || s != nil || b != nil { alert = NativeAlert(title: t, subtitle: s, body: b) } } let effectiveMutableContent = mutableContent || !imageUrl.isEmpty let aps = NativeAPS( alert: alert, badge: Int(badge), sound: (pushType == .alert && !sound.isEmpty) ? sound : nil, contentAvailable: contentAvailable ? 1 : nil, mutableContent: effectiveMutableContent ? 1 : nil, threadId: threadId.isEmpty ? nil : threadId, category: categoryId.isEmpty ? nil : categoryId, interruptionLevel: (pushType == .alert) ? interruptionLevel.rawValue : nil ) return NativePushPayload(aps: aps, customData: customData, imageUrl: imageUrl.isEmpty ? nil : imageUrl) } // MARK: - Send func send() { guard canSend else { return } if pushType == .alert && title.isEmpty && body.isEmpty { showToastMessage("Alert notifications require at least a title or body.", type: .error) return } isSending = true let validTokens = allValidTokens let payload = buildPayload() let configuration = config.apnsConfiguration(topicSuffix: nil) let group = DispatchGroup() var responses: [APNsResponse] = [] var firstError: Error? for token in validTokens { group.enter() APNsService.shared.sendNativePush( payload: payload, token: token, pushType: pushType, priority: priority, configuration: configuration ) { result in switch result { case .success(let response): responses.append(response) case .failure(let error): if firstError == nil { firstError = error } if case APNsService.APNsError.requestFailed(let response) = error { responses.append(response) } } group.leave() } } group.notify(queue: .main) { [weak self] in guard let self else { return } self.isSending = false self.lastResponse = responses.last if let error = firstError { self.showToastMessage(error.localizedDescription, type: .error) } else { let msg = responses.count == 1 ? responses[0].summary : "\(responses.count) pushes sent" self.showToastMessage(msg, type: .success) } } } // MARK: - cURL func generateCurlCommand() -> String { let configuration = config.apnsConfiguration(topicSuffix: nil) let jwt: String if let p8Contents = configuration.p8Contents, let token = try? JWTSigner.generateToken( teamId: configuration.teamId, keyId: configuration.keyId, p8Contents: p8Contents ) { jwt = token } else { jwt = "" } let cleanToken = (allValidTokens.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let payload = buildPayload() let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let bodyString: String if let data = try? encoder.encode(payload), let str = String(data: data, encoding: .utf8) { bodyString = str } else { bodyString = "{}" } var lines: [String] = [] lines.append("# Note: JWT tokens expire after 1 hour.") if allValidTokens.count > 1 { lines.append("# cURL shown for the first token. Repeat for each additional token.") } lines.append("curl --http2 -X POST \\") lines.append(" https://\(configuration.hostname)/3/device/\(cleanToken) \\") lines.append(" -H \"authorization: bearer \(jwt)\" \\") lines.append(" -H \"apns-topic: \(configuration.topic)\" \\") lines.append(" -H \"apns-push-type: \(pushType.rawValue)\" \\") lines.append(" -H \"apns-priority: \(priority)\" \\") lines.append(" -H \"content-type: application/json\" \\") lines.append(" -d '\(bodyString)'") return lines.joined(separator: "\n") } // MARK: - Clipboard func pasteToken(at index: Int) { guard let clipboardString = NSPasteboard.general.string(forType: .string) else { return } let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines) let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdefABCDEF") if trimmed.unicodeScalars.allSatisfy({ hexCharacterSet.contains($0) }) && !trimmed.isEmpty { if index < tokens.count { tokens[index] = trimmed } } else { showToastMessage("Invalid token format. Expected hex string.", type: .error) } } // MARK: - Toast func showToastMessage(_ message: String, type: ToastType) { toastMessage = message toastType = type showToast = true } } ================================================ FILE: QuickPush/Views/APNsConfigurationView.swift ================================================ // // APNsConfigurationView.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI struct APNsConfigurationView: View { @Bindable var config: APNsConfigStore @State private var isExpanded: Bool = true private var isValid: Bool { !config.teamId.isEmpty && !config.keyId.isEmpty && !config.bundleId.isEmpty && config.hasP8Key } var body: some View { VStack(alignment: .leading, spacing: 8) { Button(action: { withAnimation { isExpanded.toggle() } }) { HStack { Text("APNs Configuration") .font(.subheadline) .fontWeight(.medium) Spacer() Circle() .fill(isValid ? Color.green : Color.orange) .frame(width: 8, height: 8) Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .foregroundColor(.secondary) } } .buttonStyle(.plain) if isExpanded { VStack(alignment: .leading, spacing: 8) { HStack { Text("Team ID:") .frame(width: 70, alignment: .leading) TextField("e.g. A1B2C3D4E5", text: $config.teamId) .textFieldStyle(.roundedBorder) } HStack { Text("Key ID:") .frame(width: 70, alignment: .leading) TextField("e.g. ABCDE12345", text: $config.keyId) .textFieldStyle(.roundedBorder) } HStack { Text("Bundle ID:") .frame(width: 70, alignment: .leading) TextField("e.g. com.example.app", text: $config.bundleId) .textFieldStyle(.roundedBorder) } HStack { Text(".p8 Key:") .frame(width: 70, alignment: .leading) if let name = config.p8FileName, config.hasP8Key { Text(name) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) } else { Text("No file selected") .foregroundColor(.secondary) } Spacer() Button("Browse...") { config.selectP8File() } .controlSize(.small) } HStack { Text("Environment:") Picker("", selection: $config.environment) { Text("Sandbox").tag(APNsEnvironment.sandbox) Text("Production").tag(APNsEnvironment.production) } .pickerStyle(.segmented) } } .padding(.leading, 4) } } } } ================================================ FILE: QuickPush/Views/APNsCurlCommandView.swift ================================================ // // APNsCurlCommandView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI /// Sheet that displays a copyable APNs curl command with a pre-generated JWT. struct APNsCurlCommandView: View { let curlCommand: String @Environment(\.dismiss) private var dismiss @State private var copied = false var body: some View { VStack(spacing: 12) { HStack { Text("cURL Command") .font(.headline) Spacer() Button("Close") { dismiss() } .keyboardShortcut(.cancelAction) } ScrollView { Text(curlCommand) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) } .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.2)) ) HStack { Button(copied ? "Copied!" : "Copy") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(curlCommand, forType: .string) copied = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false } } .help("Copy curl command to clipboard") Spacer() Text("JWT expires after 1 hour") .font(.caption) .foregroundColor(.secondary) } } .padding() .frame(minWidth: 450, minHeight: 300) } } ================================================ FILE: QuickPush/Views/APNsResponseDetailView.swift ================================================ // // APNsResponseDetailView.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI /// Sheet that displays the full APNs response diagnostics after a send attempt. struct APNsResponseDetailView: View { let response: APNsResponse? @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 12) { HStack { Text("APNs Response") .font(.headline) Spacer() Button("Close") { dismiss() } .keyboardShortcut(.cancelAction) } if let response { VStack(alignment: .leading, spacing: 0) { // Status header HStack(spacing: 8) { Image(systemName: response.isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundColor(response.isSuccess ? .green : .red) .font(.title2) Text(response.isSuccess ? "Push Accepted" : "Push Rejected") .font(.title3) .fontWeight(.semibold) Spacer() } .padding(.bottom, 12) // Diagnostic rows Group { ResponseRow(label: "Status", value: "\(response.statusCode)") if let reason = response.reason { ResponseRow(label: "Reason", value: reason, isError: true) } ResponseRow(label: "Event", value: response.event) ResponseRow(label: "Environment", value: "\(response.environment.rawValue.capitalized)") ResponseRow(label: "Hostname", value: response.hostname) ResponseRow(label: "Topic", value: response.topic) if let attributesType = response.attributesType { ResponseRow(label: "Attributes Type", value: attributesType, isHighlighted: true) } ResponseRow(label: "Timestamp", value: "\(response.timestamp) (Unix sec)") if let apnsId = response.apnsId { ResponseRow(label: "apns-id", value: apnsId) } if let apnsUniqueId = response.apnsUniqueId { ResponseRow(label: "apns-unique-id", value: apnsUniqueId) } } } .padding() .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.2)) ) // Troubleshooting tips for common errors if let reason = response.reason { troubleshootingTip(for: reason) } // Warn about silent drops on 200 with start event if response.isSuccess && response.event == "start" { successButNoActivityTip(attributesType: response.attributesType) } HStack { Button("Copy Diagnostics") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(response.diagnosticDetails, forType: .string) } .help("Copy full diagnostic info to clipboard") Spacer() } } else { Text("No response yet. Send a push first.") .foregroundColor(.secondary) } Spacer() } .padding() .frame(minWidth: 420, minHeight: 300) } @ViewBuilder private func troubleshootingTip(for reason: String) -> some View { let tip: String? = switch reason { case "BadDeviceToken": "The token is invalid. Check: wrong environment (sandbox vs production), token is stale, or token format is incorrect." case "DeviceTokenNotForTopic": "The device token doesn't match the topic (bundle ID). Verify your Bundle ID is correct and the token was generated for this app." case "Unregistered": "The token is no longer valid. The device may have uninstalled the app or the token has expired." case "TopicDisallowed": "Push notifications are not allowed for this topic. Check your provisioning profile and entitlements." case "InvalidProviderToken": "The JWT token is invalid. Verify your Team ID, Key ID, and .p8 key file." case "ExpiredProviderToken": "The JWT token has expired. It will be refreshed on the next send." case "MissingTopic": "The apns-topic header is missing. This is a QuickPush bug — please report it." default: nil } if let tip { TipBox(icon: "lightbulb.fill", color: .yellow, text: tip) } } private func successButNoActivityTip(attributesType: String?) -> some View { let tip = Self.buildSuccessTip(attributesType: attributesType) return TipBox( icon: "exclamationmark.triangle.fill", color: .orange, text: tip ) } private static func buildSuccessTip(attributesType: String?) -> String { let attrType = attributesType ?? "unknown" let needsModulePrefix = !attrType.contains(".") var lines = """ APNs returned 200 but the Live Activity didn't appear? Common causes: 1. **attributes-type mismatch** — iOS expects the module-qualified name. Current value: "\(attrType)" """ if needsModulePrefix { lines += "\n Try: \"YourWidgetTarget.\(attrType)\"" } lines += """ 2. **Timestamp too far from "now"** — must be current Unix seconds. 3. **Token/environment mismatch** — sandbox token with production, or vice versa. To find the exact attributes-type, add this to your iOS app: `print(String(reflecting: YourAttributes.self))` """ return lines } } /// Styled tip box used for troubleshooting hints. private struct TipBox: View { let icon: String let color: Color let text: String var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: icon) .foregroundColor(color) // Use .init() to enable Markdown rendering in the string Text(.init(text)) .font(.callout) .foregroundColor(.secondary) } .padding(10) .background(color.opacity(0.1)) .cornerRadius(6) } } /// A single label–value row in the response detail view. private struct ResponseRow: View { let label: String let value: String var isError: Bool = false var isHighlighted: Bool = false var body: some View { HStack(alignment: .top) { Text(label) .foregroundColor(.secondary) .frame(width: 110, alignment: .trailing) Text(value) .fontWeight((isError || isHighlighted) ? .semibold : .regular) .foregroundColor(isError ? .red : isHighlighted ? .orange : .primary) .textSelection(.enabled) Spacer() } .padding(.vertical, 3) } } ================================================ FILE: QuickPush/Views/APNsView.swift ================================================ // // APNsView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI struct APNsView: View { var isActive: Bool = true @Environment(WindowManager.self) var windowManager @State private var viewModel = NativePushViewModel() var body: some View { VStack(spacing: 16) { // Title bar HStack { Text("APNs") .font(.headline) Spacer() if viewModel.lastResponse != nil { Button { viewModel.showResponseSheet = true } label: { HStack(spacing: 4) { Circle() .fill(viewModel.lastResponse?.isSuccess == true ? Color.green : Color.red) .frame(width: 6, height: 6) Text("Response") } } .controlSize(.small) } Button("cURL") { viewModel.curlCommand = viewModel.generateCurlCommand() viewModel.showCurlSheet = true } .controlSize(.small) .disabled(!viewModel.canSend) Button { viewModel.send() } label: { HStack(spacing: 4) { Text("Send") HStack(spacing: 1) { Image(systemName: "command") Image(systemName: "return") } .font(.caption2) .opacity(0.7) } } .applying { view in if isActive { view.keyboardShortcut(.return, modifiers: .command) } else { view } } .applying { view in if #available(macOS 26.0, *) { view.buttonStyle(.glassProminent) } else { view.buttonStyle(.borderedProminent) } } .disabled(!viewModel.canSend) } ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { // Token Section VStack(alignment: .leading, spacing: 8) { Text("Device Token:") .font(.subheadline) // Saved tokens ForEach(viewModel.savedTokens) { saved in SavedTokenRowView( savedToken: saved, onToggle: { if let index = viewModel.savedTokens.firstIndex(where: { $0.id == saved.id }) { viewModel.savedTokens[index].isEnabled.toggle() SavedTokenStore.nativePush.saveTokens(viewModel.savedTokens) } }, onCopy: { NSPasteboard.general.clearContents() NSPasteboard.general.setString(saved.token, forType: .string) viewModel.showToastMessage("Token copied to clipboard", type: .success) }, onRemove: { SavedTokenStore.nativePush.removeToken(id: saved.id) viewModel.savedTokens.removeAll { $0.id == saved.id } } ) } // Unsaved token rows ForEach(viewModel.tokens.indices, id: \.self) { index in HStack(spacing: 8) { TextField("Hex device token", text: $viewModel.tokens[index]) .textFieldStyle(.roundedBorder) Button(action: { viewModel.pasteToken(at: index) }) { Image(systemName: "doc.on.clipboard") .foregroundColor(.secondary) } .buttonStyle(.plain) .focusable(false) .help("Paste hex token from clipboard") Button(action: { viewModel.tokenToSave = TokenToSave(index: index, token: viewModel.tokens[index]) }) { Image(systemName: "square.and.arrow.down") .foregroundColor(.secondary) } .buttonStyle(.plain) .focusable(false) .disabled(viewModel.tokens[index].trimmingCharacters(in: .whitespaces).isEmpty) .help("Save this token for future sessions") if viewModel.tokens.count > 1 || !viewModel.savedTokens.isEmpty { Button(action: { viewModel.tokens.remove(at: index) }) { Image(systemName: "minus.circle.fill") .foregroundColor(.red) } .buttonStyle(.plain) .focusable(false) } } } Button(action: { viewModel.tokens.append("") }) { HStack { Image(systemName: "plus.circle.fill") Text("Add Token") } } .buttonStyle(.borderless) .padding(.top, 5) } // APNs Configuration APNsConfigurationView(config: viewModel.config) Divider() // Push Type Picker HStack { Text("Push Type:") Picker("", selection: $viewModel.pushType) { Text("Alert").tag(NativePushType.alert) Text("Background").tag(NativePushType.background) } .pickerStyle(.segmented) } .onChange(of: viewModel.pushType) { _, newType in viewModel.priority = newType == .background ? 5 : 10 } // Alert Fields if viewModel.pushType == .alert { VStack(alignment: .leading, spacing: 8) { InputField(label: "Title", text: $viewModel.title, helpText: "Title of the notification") InputField(label: "Subtitle", text: $viewModel.subtitle, helpText: "Secondary line below the title") InputField(label: "Body", text: $viewModel.body, helpText: "Main message content") InputField(label: "Sound", text: $viewModel.sound, helpText: "Sound name. Use \"default\" for the system sound, or provide a custom sound file name.") InputField(label: "Badge", text: $viewModel.badge, helpText: "Number to display on the app icon badge") InputField( label: "Image URL", text: $viewModel.imageUrl, helpText: "URL of an image to display in the notification. Requires a Notification Service Extension in the app. Automatically enables Mutable Content. Injected as body._richContent.image in the payload. Learn how to create an NSE: https://codewithbeto.dev/rnCourse/expoNotificationsExtension" ) } } // Advanced DisclosureGroup("Advanced") { VStack(alignment: .leading, spacing: 8) { InputField(label: "Thread ID", text: $viewModel.threadId, helpText: "Groups related notifications in the notification center") InputField(label: "Category", text: $viewModel.categoryId, helpText: "Notification category for interactive actions") if viewModel.pushType == .alert { HStack { Text("Interruption:") Picker("", selection: $viewModel.interruptionLevel) { ForEach(NativeInterruptionLevel.allCases, id: \.self) { level in Text(level.displayName).tag(level) } } .pickerStyle(.menu) HelpButton(helpText: "Controls delivery timing:\n• Active — default, plays sound and lights screen\n• Passive — delivered quietly, no sound or badge\n• Time Sensitive — breaks through Focus modes\n• Critical — bypasses mute and Focus (requires entitlement)") } Toggle("Mutable Content", isOn: $viewModel.mutableContent) .help("Allows a Notification Service Extension to modify the payload before display") } Toggle("Content Available", isOn: $viewModel.contentAvailable) .help("Wakes the app in the background to process the notification silently") HStack { Text("Priority:") Picker("", selection: $viewModel.priority) { Text("High (10)").tag(10) Text("Normal (5)").tag(5) } .pickerStyle(.segmented) HelpButton(helpText: "Priority 10 = immediate delivery (wakes device). Priority 5 = normal/background delivery. Background push type requires priority 5.") } } .padding(.top, 8) } // Custom Data KeyValueInputView(data: $viewModel.customData) } } } .padding(.horizontal) .padding(.top) .overlay( ToastView(message: viewModel.toastMessage, type: viewModel.toastType, isPresented: $viewModel.showToast) .animation(.easeInOut, value: viewModel.showToast) ) .adaptivePresentation(isPresented: $viewModel.showResponseSheet, isPinned: windowManager.isPinned) { APNsResponseDetailView(response: viewModel.lastResponse) } .adaptivePresentation(isPresented: $viewModel.showCurlSheet, isPinned: windowManager.isPinned) { APNsCurlCommandView(curlCommand: viewModel.curlCommand) } .adaptivePresentation(item: $viewModel.tokenToSave, isPinned: windowManager.isPinned) { item in SaveTokenSheet( token: item.token, onSave: { label in let savedToken = SavedToken(label: label, token: item.token) SavedTokenStore.nativePush.addToken(savedToken) viewModel.savedTokens.append(savedToken) if item.index < viewModel.tokens.count { viewModel.tokens.remove(at: item.index) } if viewModel.tokens.isEmpty { viewModel.tokens.append("") } }, warningText: "APNs device tokens may change when you reinstall the app or on OS updates." ) } } } ================================================ FILE: QuickPush/Views/ColorPickerField.swift ================================================ // // ColorPickerField.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI struct ColorPickerField: View { let label: String @Binding var color: Color var body: some View { HStack { Text("\(label):") Spacer() Text(color.toHexString()) .font(.system(.body, design: .monospaced)) .foregroundColor(.secondary) ColorPicker("", selection: $color, supportsOpacity: label.contains("Subtitle")) .labelsHidden() .frame(width: 30) } } } ================================================ FILE: QuickPush/Views/ExpoCurlCommandView.swift ================================================ // // ExpoCurlCommandView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI /// Sheet that displays a copyable curl command for the current Expo push notification. struct ExpoCurlCommandView: View { let notification: PushNotification let accessToken: String? @Environment(\.dismiss) private var dismiss @State private var copied = false private var curlCommand: String { buildCurlCommand() } var body: some View { VStack(spacing: 12) { HStack { Text("cURL Command") .font(.headline) Spacer() Button("Close") { dismiss() } .keyboardShortcut(.cancelAction) } ScrollView { Text(curlCommand) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) } .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.2)) ) HStack { Button(copied ? "Copied!" : "Copy") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(curlCommand, forType: .string) copied = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false } } .help("Copy curl command to clipboard") Spacer() } } .padding() .frame(minWidth: 450, minHeight: 300) } // MARK: - Build cURL private func buildCurlCommand() -> String { var lines: [String] = [] lines.append("curl -X POST https://exp.host/--/api/v2/push/send \\") lines.append(" -H \"Content-Type: application/json\" \\") lines.append(" -H \"Accept: application/json\" \\") lines.append(" -H \"Host: exp.host\" \\") if let token = accessToken, !token.isEmpty { lines.append(" -H \"Authorization: Bearer \(token)\" \\") } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] if let jsonData = try? encoder.encode(notification), let jsonString = String(data: jsonData, encoding: .utf8) { lines.append(" -d '\(jsonString)'") } return lines.joined(separator: "\n") } } ================================================ FILE: QuickPush/Views/ExpoReceiptView.swift ================================================ // // ExpoReceiptView.swift // QuickPush // // Created by beto on 2/20/26. // import SwiftUI struct ExpoReceiptView: View { let initialTicketIds: [String] let accessToken: String? @Environment(\.dismiss) private var dismiss @State private var ids: [String] @State private var isLoading: Bool = false @State private var result: PushNotificationService.ReceiptResult? @State private var errorMessage: String? init(ticketIds: [String], accessToken: String?) { self.initialTicketIds = ticketIds self.accessToken = accessToken _ids = State(initialValue: ticketIds.isEmpty ? [""] : ticketIds) } var body: some View { VStack(spacing: 12) { // Header HStack { Text("Check Receipts") .font(.headline) Spacer() Button("Close") { dismiss() } .keyboardShortcut(.cancelAction) } // IDs input list VStack(alignment: .leading, spacing: 8) { Text("Ticket IDs:") .font(.subheadline) ForEach(ids.indices, id: \.self) { index in HStack(spacing: 8) { TextField("Ticket ID (e.g. abc123...)", text: $ids[index]) .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.system(.caption, design: .monospaced)) Button(action: { if let clip = NSPasteboard.general.string(forType: .string) { ids[index] = clip.trimmingCharacters(in: .whitespacesAndNewlines) } }) { Image(systemName: "doc.on.clipboard") .foregroundColor(.secondary) } .buttonStyle(.plain) .help("Paste from clipboard") if ids.count > 1 { Button(action: { ids.remove(at: index) }) { Image(systemName: "minus.circle.fill") .foregroundColor(.red) } .buttonStyle(.plain) } } } Button(action: { ids.append("") }) { HStack { Image(systemName: "plus.circle.fill") Text("Add ID") } } .buttonStyle(.borderless) } // Check button + error HStack { if let errorMessage { Text(errorMessage) .font(.caption) .foregroundColor(.red) } Spacer() Button { checkReceipts() } label: { if isLoading { HStack(spacing: 6) { ProgressView() .scaleEffect(0.6) .frame(width: 14, height: 14) Text("Checking…") } } else { Text("Check Receipts") } } .buttonStyle(.borderedProminent) .disabled(validIds.isEmpty || isLoading) } Divider() // Results if let result { resultsSection(result) } else { Text("Enter ticket IDs and tap \"Check Receipts\".") .font(.callout) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } Spacer() } .padding() .frame(minWidth: 460, minHeight: 340) } // MARK: - Results @ViewBuilder private func resultsSection(_ result: PushNotificationService.ReceiptResult) -> some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 10) { // API-level errors if let errors = result.response.errors, !errors.isEmpty { VStack(alignment: .leading, spacing: 6) { Label("API Error", systemImage: "xmark.circle.fill") .foregroundColor(.red) .font(.subheadline.weight(.semibold)) ForEach(Array(errors.enumerated()), id: \.offset) { _, err in VStack(alignment: .leading, spacing: 2) { Text(err.code) .font(.system(.caption, design: .monospaced)) .fontWeight(.semibold) .foregroundColor(.red) Text(err.message) .font(.caption) .foregroundColor(.secondary) } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.red.opacity(0.08)) .cornerRadius(6) } } } // Per-receipt results if let receipts = result.response.data, !receipts.isEmpty { ForEach(receipts.sorted(by: { $0.key < $1.key }), id: \.key) { id, receipt in receiptRow(id: id, receipt: receipt) } } else if result.response.errors == nil { Text("No receipts returned for the given IDs. They may be expired or invalid.") .font(.callout) .foregroundColor(.secondary) } // Raw JSON disclosure if let raw = result.rawJSON, !raw.isEmpty { DisclosureGroup("Raw JSON") { ScrollView { Text(raw) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 140) .padding(8) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(6) } } } } } @ViewBuilder private func receiptRow(id: String, receipt: ReceiptResponse.PushReceipt) -> some View { let isOk = receipt.status == "ok" let errorCode = receipt.details?.error VStack(alignment: .leading, spacing: 6) { // Status badge + ID HStack(spacing: 6) { Circle() .fill(isOk ? Color.green : Color.red) .frame(width: 8, height: 8) Text(receipt.status) .font(.system(.caption, design: .monospaced)) .fontWeight(.semibold) .foregroundColor(isOk ? .green : .red) Spacer() Text(id) .font(.system(.caption2, design: .monospaced)) .foregroundColor(.secondary) .textSelection(.enabled) .lineLimit(1) .truncationMode(.middle) } // "ok" informational note if isOk { Label( "\"ok\" means FCM/APNs accepted the notification — not that the device displayed it.", systemImage: "info.circle" ) .font(.caption) .foregroundColor(.secondary) } // Error message if let message = receipt.message { Text(message) .font(.caption) .foregroundColor(.red) .textSelection(.enabled) } // Error code + actionable guidance if let code = errorCode { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { Text("Error code:") .font(.caption) .foregroundColor(.secondary) Text(code) .font(.system(.caption, design: .monospaced)) .fontWeight(.semibold) .foregroundColor(.orange) } Text(guidance(for: code)) .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(6) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.08)) .cornerRadius(6) } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(isOk ? Color.green.opacity(0.07) : Color.red.opacity(0.07)) .cornerRadius(8) } // MARK: - Helpers private var validIds: [String] { ids.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } } private func checkReceipts() { errorMessage = nil isLoading = true result = nil PushNotificationService.shared.checkReceipts( ids: validIds, accessToken: accessToken ) { outcome in DispatchQueue.main.async { isLoading = false switch outcome { case .success(let receiptResult): result = receiptResult case .failure(let error): errorMessage = error.localizedDescription } } } } private func guidance(for errorCode: String) -> String { switch errorCode { case "DeviceNotRegistered": return "Remove this token — the device has opted out or uninstalled the app." case "MessageTooBig": return "Reduce the notification payload size." case "MessageRateExceeded": return "Back off — too many messages have been sent to this device recently." case "MismatchSenderId": return "This token belongs to a different FCM sender ID. Check your push credentials." case "InvalidCredentials": return "Your push credentials are invalid. Reconfigure them in the EAS Dashboard." default: return "Check the Expo push notifications documentation for details on this error." } } } ================================================ FILE: QuickPush/Views/ExpoResponseDetailView.swift ================================================ // // ExpoResponseDetailView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI /// Sheet that displays the full Expo Push API response in a developer-friendly way. struct ExpoResponseDetailView: View { let response: PushResponse? let httpStatusCode: Int? let rawJSON: String? @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 12) { HStack { Text("Response") .font(.headline) Spacer() Button("Close") { dismiss() } .keyboardShortcut(.cancelAction) } if let response { // Top-level API errors (e.g. UNAUTHORIZED) if let errors = response.errors, !errors.isEmpty { apiErrorsSection(errors) } // Per-ticket results if let tickets = response.data, !tickets.isEmpty { ticketsSection(tickets) } // Raw JSON if let rawJSON, !rawJSON.isEmpty { rawJSONSection(rawJSON) } HStack { Button("Copy Response") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(rawJSON ?? "", forType: .string) } .help("Copy raw JSON response to clipboard") .disabled(rawJSON == nil) Spacer() } } else { Text("No response yet. Send a push first.") .foregroundColor(.secondary) } Spacer() } .padding() .frame(minWidth: 420, minHeight: 300) } // MARK: - Sections @ViewBuilder private func apiErrorsSection(_ errors: [PushResponse.PushError]) -> some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: "xmark.circle.fill") .foregroundColor(.red) .font(.title2) Text("API Error") .font(.title3) .fontWeight(.semibold) if let statusCode = httpStatusCode { Text("(\(statusCode))") .foregroundColor(.secondary) } Spacer() } ForEach(Array(errors.enumerated()), id: \.offset) { _, error in VStack(alignment: .leading, spacing: 4) { HStack { Text(error.code) .font(.system(.body, design: .monospaced)) .fontWeight(.semibold) .foregroundColor(.red) if error.isTransient == true { Text("(transient)") .font(.caption) .foregroundColor(.orange) } } Text(error.message) .font(.callout) .foregroundColor(.secondary) .textSelection(.enabled) } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.red.opacity(0.08)) .cornerRadius(6) } } } @ViewBuilder private func ticketsSection(_ tickets: [PushResponse.PushTicket]) -> some View { VStack(alignment: .leading, spacing: 8) { let successCount = tickets.filter { $0.status == "ok" }.count let errorCount = tickets.filter { $0.status == "error" }.count HStack(spacing: 8) { Image(systemName: errorCount == 0 ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") .foregroundColor(errorCount == 0 ? .green : .orange) .font(.title2) Text(errorCount == 0 ? "All Delivered" : "\(successCount) OK, \(errorCount) Failed") .font(.title3) .fontWeight(.semibold) if let statusCode = httpStatusCode { Text("(\(statusCode))") .foregroundColor(.secondary) } Spacer() } ForEach(Array(tickets.enumerated()), id: \.offset) { index, ticket in VStack(alignment: .leading, spacing: 4) { HStack { Text("Ticket \(index + 1)") .font(.subheadline) .fontWeight(.medium) Circle() .fill(ticket.status == "ok" ? Color.green : Color.red) .frame(width: 8, height: 8) Text(ticket.status) .font(.system(.caption, design: .monospaced)) Spacer() } if let id = ticket.id { HStack(spacing: 4) { Text("ID:") .foregroundColor(.secondary) .font(.caption) Text(id) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) } } if let message = ticket.message { Text(message) .font(.callout) .foregroundColor(.red) .textSelection(.enabled) } if let details = ticket.details { ForEach(Array(details.sorted(by: { $0.key < $1.key })), id: \.key) { key, value in HStack(spacing: 4) { Text("\(key):") .foregroundColor(.secondary) .font(.caption) Text(value) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) } } } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(ticket.status == "ok" ? Color.green.opacity(0.08) : Color.red.opacity(0.08)) .cornerRadius(6) } } } @ViewBuilder private func rawJSONSection(_ json: String) -> some View { DisclosureGroup("Raw JSON") { ScrollView { Text(json) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 150) .padding(8) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(6) } } } ================================================ FILE: QuickPush/Views/FCMConfigurationView.swift ================================================ // // FCMConfigurationView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI struct FCMConfigurationView: View { @Bindable var config: FCMConfigStore private var isValid: Bool { !config.projectId.isEmpty && !config.clientEmail.isEmpty && config.hasServiceAccount } @State private var isExpanded: Bool = false init(config: FCMConfigStore) { self.config = config // Start collapsed if already configured, expanded if not yet set up _isExpanded = State(initialValue: !config.hasServiceAccount) } var body: some View { VStack(alignment: .leading, spacing: 8) { Button(action: { withAnimation { isExpanded.toggle() } }) { HStack { Text("FCM Configuration") .font(.subheadline) .fontWeight(.medium) Spacer() Circle() .fill(isValid ? Color.green : Color.orange) .frame(width: 8, height: 8) Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .foregroundColor(.secondary) } } .buttonStyle(.plain) if isExpanded { VStack(alignment: .leading, spacing: 8) { HStack { Text("Service Account:") .frame(width: 110, alignment: .leading) if let name = config.serviceAccountFilename, config.hasServiceAccount { Text(name) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) } else { Text("No file selected") .foregroundColor(.secondary) } Spacer() Button("Browse...") { config.selectServiceAccountFile() } .controlSize(.small) } if let error = config.fileError { HStack(alignment: .top, spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) .font(.caption) Text(error) .font(.caption) .foregroundColor(.orange) .fixedSize(horizontal: false, vertical: true) } .padding(8) .background(Color.orange.opacity(0.1)) .cornerRadius(6) } HStack { Text("Project ID:") .frame(width: 110, alignment: .leading) TextField("e.g. my-app-12345", text: $config.projectId) .textFieldStyle(.roundedBorder) } HStack { Text("Client Email:") .frame(width: 110, alignment: .leading) TextField("service-account@project.iam.gserviceaccount.com", text: $config.clientEmail) .textFieldStyle(.roundedBorder) .foregroundColor(.secondary) } } .padding(.leading, 4) } } } } ================================================ FILE: QuickPush/Views/FCMCurlCommandView.swift ================================================ // // FCMCurlCommandView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI /// Sheet that displays a copyable FCM curl command. struct FCMCurlCommandView: View { let curlCommand: String @Environment(\.dismiss) private var dismiss @State private var copied = false var body: some View { VStack(spacing: 12) { HStack { Text("cURL Command") .font(.headline) Spacer() Button("Close") { dismiss() } .keyboardShortcut(.cancelAction) } ScrollView { Text(curlCommand) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) } .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.2)) ) HStack { Button(copied ? "Copied!" : "Copy") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(curlCommand, forType: .string) copied = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false } } .help("Copy curl command to clipboard") Spacer() Text("Replace with a valid OAuth 2.0 bearer token") .font(.caption) .foregroundColor(.secondary) } } .padding() .frame(minWidth: 500, minHeight: 300) } } ================================================ FILE: QuickPush/Views/FCMResponseDetailView.swift ================================================ // // FCMResponseDetailView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI /// Sheet that displays the full FCM response diagnostics after a send attempt. struct FCMResponseDetailView: View { let response: FCMResponse? @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 12) { HStack { Text("FCM Response") .font(.headline) Spacer() Button("Close") { dismiss() } .keyboardShortcut(.cancelAction) } if let response { VStack(alignment: .leading, spacing: 0) { // Status header HStack(spacing: 8) { Image(systemName: response.isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundColor(response.isSuccess ? .green : .red) .font(.title2) Text(response.isSuccess ? "Push Accepted" : "Push Rejected") .font(.title3) .fontWeight(.semibold) Spacer() } .padding(.bottom, 12) Group { FCMResponseRow(label: "Status", value: "\(response.statusCode)") if let messageId = response.messageId { FCMResponseRow(label: "Message ID", value: messageId) } if let errorCode = response.errorCode { FCMResponseRow(label: "Error Code", value: errorCode, isError: true) } if let errorMessage = response.errorMessage { FCMResponseRow(label: "Error Message", value: errorMessage, isError: true) } FCMResponseRow(label: "Project ID", value: response.projectId) FCMResponseRow(label: "Timestamp", value: "\(response.timestamp) (Unix sec)") } } .padding() .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.2)) ) // Troubleshooting tips if let errorCode = response.errorCode { fcmTroubleshootingTip(for: errorCode) } else if let errorMessage = response.errorMessage, !response.isSuccess { fcmTroubleshootingTip(for: errorMessage) } HStack { Button("Copy Diagnostics") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(response.diagnosticDetails, forType: .string) } .help("Copy full diagnostic info to clipboard") Spacer() } } else { Text("No response yet. Send a push first.") .foregroundColor(.secondary) } Spacer() } .padding() .frame(minWidth: 420, minHeight: 300) } @ViewBuilder private func fcmTroubleshootingTip(for code: String) -> some View { let tip: String? = switch code { case "INVALID_ARGUMENT": "The request payload is invalid. Check that the FCM registration token is correct and the message payload is well-formed." case "NOT_FOUND", "UNREGISTERED": "The FCM registration token is no longer valid. The device may have uninstalled the app or the token has expired. Remove this token from your records." case "SENDER_ID_MISMATCH": "The token was registered with a different sender (project). Ensure you are using the correct Firebase project and service account." case "QUOTA_EXCEEDED": "Sending rate exceeded. Slow down your message sending rate and retry with exponential backoff." case "UNAVAILABLE": "The FCM service is temporarily unavailable. Retry with exponential backoff." case "INTERNAL": "An internal error occurred on the FCM server. Retry with exponential backoff." default: nil } if let tip { FCMTipBox(icon: "lightbulb.fill", color: .yellow, text: tip) } } } private struct FCMTipBox: View { let icon: String let color: Color let text: String var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: icon) .foregroundColor(color) Text(.init(text)) .font(.callout) .foregroundColor(.secondary) } .padding(10) .background(color.opacity(0.1)) .cornerRadius(6) } } private struct FCMResponseRow: View { let label: String let value: String var isError: Bool = false var body: some View { HStack(alignment: .top) { Text(label) .foregroundColor(.secondary) .frame(width: 110, alignment: .trailing) Text(value) .fontWeight(isError ? .semibold : .regular) .foregroundColor(isError ? .red : .primary) .textSelection(.enabled) Spacer() } .padding(.vertical, 3) } } ================================================ FILE: QuickPush/Views/FCMView.swift ================================================ // // FCMView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI import AppKit struct FCMView: View { var isActive: Bool = true @Environment(WindowManager.self) var windowManager @State private var viewModel = FCMViewModel() var body: some View { VStack(spacing: 16) { // Title bar HStack { Text("FCM") .font(.headline) Spacer() if viewModel.lastResponse != nil { Button { viewModel.showResponseSheet = true } label: { HStack(spacing: 4) { Circle() .fill(viewModel.lastResponse?.isSuccess == true ? Color.green : Color.red) .frame(width: 6, height: 6) Text("Response") } } .controlSize(.small) } Button("cURL") { viewModel.curlCommand = viewModel.generateCurlCommand() viewModel.showCurlSheet = true } .controlSize(.small) .disabled(!viewModel.canSend) Button { viewModel.send() } label: { HStack(spacing: 4) { Text("Send") HStack(spacing: 1) { Image(systemName: "command") Image(systemName: "return") } .font(.caption2) .opacity(0.7) } } .applying { view in if isActive { view.keyboardShortcut(.return, modifiers: .command) } else { view } } .applying { view in if #available(macOS 26.0, *) { view.buttonStyle(.glassProminent) } else { view.buttonStyle(.borderedProminent) } } .disabled(!viewModel.canSend) } ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { // Token Section VStack(alignment: .leading, spacing: 8) { Text("FCM Registration Token:") .font(.subheadline) // Saved tokens ForEach(viewModel.savedTokens) { saved in SavedTokenRowView( savedToken: saved, onToggle: { if let index = viewModel.savedTokens.firstIndex(where: { $0.id == saved.id }) { viewModel.savedTokens[index].isEnabled.toggle() SavedTokenStore.fcm.saveTokens(viewModel.savedTokens) } }, onCopy: { NSPasteboard.general.clearContents() NSPasteboard.general.setString(saved.token, forType: .string) viewModel.showToastMessage("Token copied to clipboard", type: .success) }, onRemove: { SavedTokenStore.fcm.removeToken(id: saved.id) viewModel.savedTokens.removeAll { $0.id == saved.id } } ) } // Unsaved token rows ForEach(viewModel.tokens.indices, id: \.self) { index in HStack(spacing: 8) { TextField("FCM registration token", text: $viewModel.tokens[index]) .textFieldStyle(.roundedBorder) Button(action: { viewModel.pasteToken(at: index) }) { Image(systemName: "doc.on.clipboard") .foregroundColor(.secondary) } .buttonStyle(.plain) .focusable(false) .help("Paste token from clipboard") Button(action: { viewModel.tokenToSave = TokenToSave(index: index, token: viewModel.tokens[index]) }) { Image(systemName: "square.and.arrow.down") .foregroundColor(.secondary) } .buttonStyle(.plain) .focusable(false) .disabled(viewModel.tokens[index].trimmingCharacters(in: .whitespaces).isEmpty) .help("Save this token for future sessions") if viewModel.tokens.count > 1 || !viewModel.savedTokens.isEmpty { Button(action: { viewModel.tokens.remove(at: index) }) { Image(systemName: "minus.circle.fill") .foregroundColor(.red) } .buttonStyle(.plain) .focusable(false) } } } Button(action: { viewModel.tokens.append("") }) { HStack { Image(systemName: "plus.circle.fill") Text("Add Token") } } .buttonStyle(.borderless) .padding(.top, 5) } // FCM Configuration FCMConfigurationView(config: viewModel.config) Divider() // Message Type Picker HStack { Text("Message Type:") Picker("", selection: $viewModel.messageType) { Text("Notification").tag(FCMMessageType.notification) Text("Data").tag(FCMMessageType.data) } .pickerStyle(.segmented) HelpButton(helpText: "Notification — displays a visible notification on the device.\nData — delivers a data payload to the app without showing a notification UI.") } // Notification Fields if viewModel.messageType == .notification { VStack(alignment: .leading, spacing: 8) { InputField(label: "Title", text: $viewModel.title, helpText: "Title of the notification") InputField(label: "Body", text: $viewModel.body, helpText: "Main message content") InputField(label: "Image URL", text: $viewModel.imageUrl, helpText: "URL of an image to display in the notification") Text("Android") .font(.subheadline) .fontWeight(.medium) .padding(.top, 4) InputField(label: "Channel ID", text: $viewModel.channelId, helpText: "Android notification channel ID. The app must create this channel. Use \"default\" for the default channel.") InputField(label: "Sound", text: $viewModel.sound, helpText: "Sound to play. Use \"default\" for the device default sound.") // Color row: hex input + presets + color picker HStack(spacing: 6) { Text("Color:") TextField("e.g. FF5733", text: $viewModel.color) .textFieldStyle(.roundedBorder) ForEach(["FF3B30", "FF9500", "007AFF"], id: \.self) { preset in Circle() .fill(Color(hex: preset)) .frame(width: 18, height: 18) .overlay( Circle() .stroke( viewModel.color.uppercased() == preset ? Color.primary.opacity(0.7) : Color.clear, lineWidth: 2 ) .padding(-2) ) .onTapGesture { viewModel.color = preset } } FCMColorWell(hex: $viewModel.color) .frame(width: 22, height: 22) .padding(.leading, 4) HelpButton(helpText: "Hex color that tints the notification's small icon in the status bar (e.g. FF5733 — no # prefix). This affects the icon accent color, not the notification background. Visibility depends on Android version and device.") } } } // Priority HStack { Text("Priority:") Picker("", selection: $viewModel.priority) { Text("HIGH").tag("HIGH") Text("NORMAL").tag("NORMAL") } .pickerStyle(.segmented) HelpButton(helpText: "HIGH — wakes the device immediately (use for user-visible notifications).\nNORMAL — may be delayed for battery optimization.") } // Custom Data KeyValueInputView(data: $viewModel.customData) } } } .padding(.horizontal) .padding(.top) .overlay( ToastView(message: viewModel.toastMessage, type: viewModel.toastType, isPresented: $viewModel.showToast) .animation(.easeInOut, value: viewModel.showToast) ) .adaptivePresentation(isPresented: $viewModel.showResponseSheet, isPinned: windowManager.isPinned) { FCMResponseDetailView(response: viewModel.lastResponse) } .adaptivePresentation(isPresented: $viewModel.showCurlSheet, isPinned: windowManager.isPinned) { FCMCurlCommandView(curlCommand: viewModel.curlCommand) } .adaptivePresentation(item: $viewModel.tokenToSave, isPinned: windowManager.isPinned) { item in SaveTokenSheet( token: item.token, onSave: { label in let savedToken = SavedToken(label: label, token: item.token) SavedTokenStore.fcm.addToken(savedToken) viewModel.savedTokens.append(savedToken) if item.index < viewModel.tokens.count { viewModel.tokens.remove(at: item.index) } if viewModel.tokens.isEmpty { viewModel.tokens.append("") } }, warningText: "FCM registration tokens may change when the user reinstalls the app or clears app data." ) } } } // MARK: - Native color well (renders as a square swatch, opens system color panel) private struct FCMColorWell: NSViewRepresentable { @Binding var hex: String func makeNSView(context: Context) -> NSColorWell { let well = NSColorWell() well.color = NSColor(Color(hex: hex)) well.target = context.coordinator well.action = #selector(Coordinator.colorChanged(_:)) return well } func updateNSView(_ well: NSColorWell, context: Context) { // Only update when the hex actually differs to avoid infinite loops if Color(nsColor: well.color).toHexString() != hex.uppercased() { well.color = NSColor(Color(hex: hex)) } } func makeCoordinator() -> Coordinator { Coordinator(hex: $hex) } final class Coordinator: NSObject { @Binding var hex: String init(hex: Binding) { _hex = hex } @objc func colorChanged(_ sender: NSColorWell) { hex = Color(nsColor: sender.color).toHexString() } } } ================================================ FILE: QuickPush/Views/FooterView.swift ================================================ // // FooterView.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI import LaunchAtLogin /// Footer shown at the bottom of the app with launch-at-login toggle, /// pin/unpin button, quit button, and attribution. struct FooterView: View { @Environment(WindowManager.self) var windowManager @State private var showCopied = false private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "–" } private let appStoreURL = URL(string: "https://apple.co/4tvT4wF")! var body: some View { VStack(spacing: 6) { // Main row: launch at login, utility icons, pin HStack(spacing: 12) { LaunchAtLogin.Toggle() Spacer() Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(appStoreURL.absoluteString, forType: .string) showCopied = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { showCopied = false } } label: { Image(systemName: showCopied ? "checkmark" : "square.and.arrow.up") .animation(.easeInOut, value: showCopied) } .help("Share QuickPush — copies App Store link to clipboard") Link(destination: URL(string: "https://github.com/betomoedano/quick-push")!) { Image(systemName: "book.closed") } .help("Open documentation on GitHub") Button { NSWorkspace.shared.open(appStoreURL) } label: { Image(systemName: "arrow.triangle.2.circlepath") } .help("Check for updates on the App Store") Button { windowManager.togglePin() } label: { Image(systemName: windowManager.isPinned ? "pin.slash.fill" : "pin.fill") } .help(windowManager.isPinned ? "Unpin Window" : "Pin Window (⌘⇧P)") } .buttonStyle(.borderless) .imageScale(.medium) .foregroundStyle(.secondary) // Bottom row: version left, attribution right HStack { Text("v\(appVersion)") .foregroundStyle(.tertiary) Spacer() HStack(spacing: 0) { Text("Made with ❤️ by ") Link("codewithbeto.dev", destination: URL(string: "https://codewithbeto.dev")!) .foregroundColor(.blue) } } .font(.caption) } .padding(.horizontal) .padding(.vertical, 8) } } ================================================ FILE: QuickPush/Views/JSONImportExportView.swift ================================================ // // JSONImportExportView.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI struct JSONImportExportView: View { @Bindable var viewModel: LiveActivityViewModel @State private var jsonText: String = "" @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 12) { HStack { Text("JSON Payload") .font(.headline) Spacer() Button("Close") { dismiss() } .keyboardShortcut(.cancelAction) } TextEditor(text: $jsonText) .font(.system(.body, design: .monospaced)) .frame(minHeight: 250) .border(Color.secondary.opacity(0.3)) HStack { Button("Export") { jsonText = viewModel.exportJSON() } .help("Generate JSON from current form values") Button("Copy") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(jsonText, forType: .string) } .help("Copy JSON to clipboard") .disabled(jsonText.isEmpty) Spacer() Button("Paste") { if let clipboardString = NSPasteboard.general.string(forType: .string) { jsonText = clipboardString } } .help("Paste JSON from clipboard") Button("Import") { if viewModel.importJSON(jsonText) { dismiss() } } .help("Apply JSON to form fields") .disabled(jsonText.isEmpty) } } .padding() .frame(minWidth: 400, minHeight: 350) .onAppear { jsonText = viewModel.exportJSON() } } } ================================================ FILE: QuickPush/Views/KeyValueInputView.swift ================================================ // // KeyValueInputView.swift // QuickPush // // Created by beto on 2/20/25. // import SwiftUI struct KeyValueInputView: View { @Binding var data: [String: String] @State private var editingKeys: [String: String] = [:] // Temporary key storage var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Custom Data:") .font(.subheadline) HelpButton(helpText: "Add custom JSON data to be sent with the notification. Up to 4KB total payload size.") } if data.isEmpty { Text("No custom data") .foregroundColor(.secondary) .font(.subheadline) .padding(.vertical, 4) } ForEach(Array(data.keys), id: \.self) { key in HStack(spacing: 8) { // Key Input TextField("Key", text: Binding( get: { editingKeys[key] ?? key }, set: { newKey in let sanitizedKey = sanitizeKey(newKey) editingKeys[key] = sanitizedKey } )) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 120) .onSubmit { finalizeKeyEdit(oldKey: key) } Text(":") .foregroundColor(.secondary) // Value Input TextField("Value", text: Binding( get: { data[key] ?? "" }, set: { newValue in data[key] = newValue } )) .textFieldStyle(RoundedBorderTextFieldStyle()) // Remove Button Button(action: { removeKey(key) }) { Image(systemName: "minus.circle.fill") .foregroundColor(.red) } .buttonStyle(PlainButtonStyle()) .help("Remove this key-value pair") } } // Add Button Button(action: { addNewKeyValue() }) { HStack { Image(systemName: "plus.circle.fill") Text("Add Key-Value Pair") } } .buttonStyle(.borderless) .disabled(data.count >= 10) // Prevent too many items .help(data.count >= 10 ? "Maximum number of custom data pairs reached" : "Add a new key-value pair") .padding(.top, 4) } .padding(.vertical, 4) } // MARK: - Helper Functions /// Sanitizes a key: lowercase, replaces spaces with "-", removes invalid characters private func sanitizeKey(_ key: String) -> String { let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")) return key .lowercased() .replacingOccurrences(of: " ", with: "-") // Replace spaces with "-" .components(separatedBy: allowedCharacters.inverted) // Remove special characters .joined() } /// Finalizes key edit: prevents empty or duplicate keys private func finalizeKeyEdit(oldKey: String) { if let newKey = editingKeys[oldKey]?.trimmingCharacters(in: .whitespacesAndNewlines), !newKey.isEmpty, newKey != oldKey, !data.keys.contains(newKey) { // Prevent duplicate keys if let value = data.removeValue(forKey: oldKey) { data[newKey] = value } } editingKeys[oldKey] = nil // Clear temp key storage } /// Adds a new key-value pair with a default key private func addNewKeyValue() { guard data.count < 10 else { return } // Safety check let baseKey = "key" var newKey = baseKey var counter = 1 while data.keys.contains(newKey) { newKey = "\(baseKey)\(counter)" counter += 1 } data[newKey] = "" editingKeys[newKey] = newKey } /// Removes a key-value pair private func removeKey(_ key: String) { data.removeValue(forKey: key) editingKeys.removeValue(forKey: key) } } ================================================ FILE: QuickPush/Views/LiveActivityAlertSection.swift ================================================ // // LiveActivityAlertSection.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI struct LiveActivityAlertSection: View { @Bindable var viewModel: LiveActivityViewModel var body: some View { VStack(alignment: .leading, spacing: 8) { Toggle("Include Alert", isOn: $viewModel.includeAlert) if viewModel.includeAlert { VStack(alignment: .leading, spacing: 8) { HStack { Text("Alert Title:") TextField("Alert title", text: $viewModel.alertTitle) .textFieldStyle(.roundedBorder) } HStack { Text("Alert Body:") TextField("Alert body", text: $viewModel.alertBody) .textFieldStyle(.roundedBorder) } HStack { Text("Sound:") TextField("default", text: $viewModel.alertSound) .textFieldStyle(.roundedBorder) } } .padding(.leading, 16) } } } } ================================================ FILE: QuickPush/Views/LiveActivityAttributesSection.swift ================================================ // // LiveActivityAttributesSection.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI struct LiveActivityAttributesSection: View { @Bindable var viewModel: LiveActivityViewModel var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Attributes") .font(.subheadline) .fontWeight(.medium) HStack { Text("Name:") TextField("Attribute name", text: $viewModel.attributeName) .textFieldStyle(.roundedBorder) } // Color Pickers ColorPickerField(label: "Background", color: $viewModel.backgroundColor) ColorPickerField(label: "Title Color", color: $viewModel.titleColor) ColorPickerField(label: "Subtitle Color", color: $viewModel.subtitleColor) ColorPickerField(label: "Progress Tint", color: $viewModel.progressViewTint) ColorPickerField(label: "Progress Label", color: $viewModel.progressViewLabelColor) HStack { Text("Deep Link URL:") TextField("e.g. /dashboard", text: $viewModel.deepLinkUrl) .textFieldStyle(.roundedBorder) } // Timer Type HStack { Text("Timer Type:") Picker("", selection: $viewModel.timerType) { Text("Digital").tag("digital") Text("Circular").tag("circular") } .pickerStyle(.segmented) } // Image Position HStack { Text("Image Position:") Picker("", selection: $viewModel.imagePosition) { Text("Left").tag("left") Text("Right").tag("right") } .pickerStyle(.segmented) } // Image Size HStack { Text("Image Size:") TextField("W", value: $viewModel.imageWidth, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 60) Text("x") TextField("H", value: $viewModel.imageHeight, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 60) } // Padding Toggle("Custom Padding", isOn: $viewModel.useCustomPadding) if viewModel.useCustomPadding { HStack { Text("T:") TextField("", value: $viewModel.paddingTop, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 45) Text("B:") TextField("", value: $viewModel.paddingBottom, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 45) Text("L:") TextField("", value: $viewModel.paddingLeft, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 45) Text("R:") TextField("", value: $viewModel.paddingRight, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 45) } .padding(.leading, 16) } else { HStack { Text("Padding:") TextField("", value: $viewModel.uniformPadding, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 60) } } } } } ================================================ FILE: QuickPush/Views/LiveActivityContentStateSection.swift ================================================ // // LiveActivityContentStateSection.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI struct LiveActivityContentStateSection: View { @Bindable var viewModel: LiveActivityViewModel var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Content State") .font(.subheadline) .fontWeight(.medium) HStack { Text("Title:") TextField("Activity title", text: $viewModel.contentTitle) .textFieldStyle(.roundedBorder) } HStack { Text("Subtitle:") TextField("Activity subtitle", text: $viewModel.contentSubtitle) .textFieldStyle(.roundedBorder) } // Progress Toggle("Include Progress", isOn: $viewModel.includeProgress) if viewModel.includeProgress { HStack { Text("Progress:") Slider(value: $viewModel.progress, in: 0...1, step: 0.01) Text("\(Int(viewModel.progress * 100))%") .frame(width: 40, alignment: .trailing) .font(.system(.body, design: .monospaced)) } .padding(.leading, 16) } // Timer End Date Toggle("Include Timer End Date", isOn: $viewModel.includeTimerEnd) if viewModel.includeTimerEnd { HStack { Text("Timer End:") DatePicker("", selection: $viewModel.timerEndDate) .labelsHidden() } .padding(.leading, 16) } HStack { Text("Image Name:") TextField("Optional image name", text: $viewModel.imageName) .textFieldStyle(.roundedBorder) } HStack { Text("DI Image:") TextField("Dynamic Island image", text: $viewModel.dynamicIslandImageName) .textFieldStyle(.roundedBorder) } } } } ================================================ FILE: QuickPush/Views/LiveActivityView.swift ================================================ // // LiveActivityView.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI struct LiveActivityView: View { var isActive: Bool = true @Environment(WindowManager.self) var windowManager @State private var viewModel = LiveActivityViewModel() var body: some View { VStack(spacing: 16) { // Title bar HStack { Text("Live Activity") .font(.headline) Spacer() if viewModel.lastResponse != nil { Button { viewModel.showResponseSheet = true } label: { HStack(spacing: 4) { Circle() .fill(viewModel.lastResponse?.isSuccess == true ? Color.green : Color.red) .frame(width: 6, height: 6) Text("Response") } } .controlSize(.small) } Button("JSON") { viewModel.showJSONSheet = true } .controlSize(.small) Button { viewModel.send() } label: { HStack(spacing: 4) { Text("Send") HStack(spacing: 1) { Image(systemName: "command") Image(systemName: "return") } .font(.caption2) .opacity(0.7) } } .applying { view in if isActive { view.keyboardShortcut(.return, modifiers: .command) } else { view } } .applying { view in if #available(macOS 26.0, *) { view.buttonStyle(.glassProminent) } else { view.buttonStyle(.borderedProminent) } } .disabled(!viewModel.canSend) } ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { // Event Type Picker HStack { Text("Event:") Picker("", selection: $viewModel.eventType) { Text("Start").tag(LiveActivityEvent.start) Text("Update").tag(LiveActivityEvent.update) Text("End").tag(LiveActivityEvent.end) } .pickerStyle(.segmented) } // Token Field VStack(alignment: .leading, spacing: 8) { Text("\(viewModel.tokenLabel):") .font(.subheadline) HStack { TextField("Hex device token", text: $viewModel.deviceToken) .textFieldStyle(.roundedBorder) Button(action: { viewModel.pasteToken() }) { Image(systemName: "doc.on.clipboard") .foregroundColor(.secondary) } .buttonStyle(.plain) .help("Paste hex token from clipboard") } } Divider() // APNs Configuration APNsConfigurationView(config: viewModel.config) HStack { Text("Attributes Type:") .frame(width: 95, alignment: .leading) TextField("e.g. MyWidget.LiveActivityAttributes", text: $viewModel.attributesType) .textFieldStyle(.roundedBorder) HelpButton(helpText: "Must match the fully module-qualified Swift type name of your ActivityAttributes struct. Run this in your iOS app to get the exact value:\n\nprint(String(reflecting: LiveActivityAttributes.self))\n\nCommon formats:\n• MyWidgetExtension.LiveActivityAttributes\n• LiveActivityAttributes\n\nA mismatch causes iOS to silently drop the push even if APNs returns 200.") } Divider() // Content State LiveActivityContentStateSection(viewModel: viewModel) // Attributes (Start only) if viewModel.eventType == .start { Divider() LiveActivityAttributesSection(viewModel: viewModel) } // Alert (Start only) if viewModel.eventType == .start { Divider() LiveActivityAlertSection(viewModel: viewModel) } } } } .padding(.horizontal) .padding(.top) .overlay( ToastView(message: viewModel.toastMessage, type: viewModel.toastType, isPresented: $viewModel.showToast) .animation(.easeInOut, value: viewModel.showToast) ) .adaptivePresentation(isPresented: $viewModel.showJSONSheet, isPinned: windowManager.isPinned) { JSONImportExportView(viewModel: viewModel) } .adaptivePresentation(isPresented: $viewModel.showResponseSheet, isPinned: windowManager.isPinned) { APNsResponseDetailView(response: viewModel.lastResponse) } } } ================================================ FILE: QuickPush/Views/MainContentView.swift ================================================ // // MainContentView.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI /// Shared root view used by both the MenuBarExtra popover and the floating panel. struct MainContentView: View { @Environment(WindowManager.self) var windowManager var body: some View { VStack { ContentView() Divider() FooterView() } .frame(minWidth: 570) } } ================================================ FILE: QuickPush/Views/PushNotificationView.swift ================================================ // // PushNotificationView.swift // QuickPush // // Created by beto on 2/8/26. // import SwiftUI struct PushNotificationView: View { var isActive: Bool = true @Environment(WindowManager.self) var windowManager @State private var tokens: [String] = [""] @State private var accessToken: String = "" @State private var title: String = "" @State private var notificationBody: String = "" @State private var sound: String = "default" @State private var priority: PushNotification.Priority = .default @State private var ttl: String = "" @State private var expiration: String = "" @State private var data: [String: String] = [:] @State private var showTitleError: Bool = false // Advanced fields @State private var showAdvancedSettings: Bool = true @State private var subtitle: String = "" @State private var badge: String = "" @State private var interruptionLevel: PushNotification.InterruptionLevel = .active @State private var channelId: String = "" @State private var categoryId: String = "" @State private var mutableContent: Bool = false @State private var contentAvailable: Bool = false @State private var imageUrl: String = "" // Toast notification state @State private var showToast: Bool = false @State private var toastMessage: String = "" @State private var toastType: ToastType = .success // Response & cURL sheet state @State private var lastResponse: PushResponse? @State private var lastHttpStatusCode: Int? @State private var lastRawJSON: String? @State private var showResponseSheet: Bool = false @State private var showCurlSheet: Bool = false @State private var showReceiptSheet: Bool = false // Saved tokens state @State private var savedTokens: [SavedToken] = [] @State private var tokenToSave: TokenToSave? = nil var body: some View { VStack { // Title and Send Button HStack { Text("Expo Notification") .font(.headline) Spacer() if lastResponse != nil { Button { showResponseSheet = true } label: { HStack(spacing: 4) { Circle() .fill(responseHasErrors ? Color.red : Color.green) .frame(width: 6, height: 6) Text("Response") } } .controlSize(.small) } if !lastTicketIds.isEmpty { Button("Receipt") { showReceiptSheet = true } .controlSize(.small) } Button("cURL") { showCurlSheet = true } .controlSize(.small) .disabled(allValidTokens.isEmpty || title.isEmpty) Button { sendPushNotification() } label: { HStack(spacing: 4) { Text("Send Push") HStack(spacing: 1) { Image(systemName: "command") Image(systemName: "return") } .font(.caption2) .opacity(0.7) } } .applying { view in if isActive { view.keyboardShortcut(.return, modifiers: .command) } else { view } } .applying { view in if #available(macOS 26.0, *) { view.buttonStyle(.glassProminent) } else { view.buttonStyle(.borderedProminent) } } .disabled(allValidTokens.isEmpty) } // Main Content ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 12) { // Basic Fields Section VStack(alignment: .leading, spacing: 12) { // Tokens Section Text("Expo Push Tokens:") .font(.subheadline) // Saved tokens (compact display) ForEach(savedTokens) { saved in SavedTokenRowView( savedToken: saved, onToggle: { if let index = savedTokens.firstIndex(where: { $0.id == saved.id }) { savedTokens[index].isEnabled.toggle() SavedTokenStore.shared.saveTokens(savedTokens) } }, onCopy: { NSPasteboard.general.clearContents() NSPasteboard.general.setString(saved.token, forType: .string) showToastNotification(message: "Token copied to clipboard", type: .success) }, onRemove: { SavedTokenStore.shared.removeToken(id: saved.id) savedTokens.removeAll { $0.id == saved.id } } ) } // Unsaved token rows (text fields) ForEach(tokens.indices, id: \.self) { index in HStack(spacing: 8) { TextField("e.g. ExponentPushToken[N1QHiEF4mnLGP8HeQrj9AR]", text: $tokens[index]) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.leading, 4) // Paste from clipboard Button(action: { if let clipboardString = NSPasteboard.general.string(forType: .string) { let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.count <= 41 && trimmed.starts(with: "ExponentPushToken[") && trimmed.hasSuffix("]") { tokens[index] = trimmed } else { showToastNotification(message: "Token should be in format: ExponentPushToken[xxxxxxxxxxxxxxxxxxxxx]", type: .error) } } }) { Image(systemName: "doc.on.clipboard") .foregroundColor(.secondary) } .buttonStyle(.plain) .help("Paste from clipboard (ExponentPushToken[xxxxxxxxxxxxxxxxxxxxx])") // Save token for future sessions Button(action: { tokenToSave = TokenToSave(index: index, token: tokens[index]) }) { Image(systemName: "square.and.arrow.down") .foregroundColor(.secondary) } .buttonStyle(.plain) .disabled(tokens[index].trimmingCharacters(in: .whitespaces).isEmpty) .help("Save this token for future sessions") // Delete token row if tokens.count > 1 || !savedTokens.isEmpty { Button(action: { tokens.remove(at: index) }) { Image(systemName: "minus.circle.fill") .foregroundColor(.red) } .buttonStyle(PlainButtonStyle()) } } } Button(action: { tokens.append("") }) { HStack { Image(systemName: "plus.circle.fill") Text("Add Token") } } .buttonStyle(.borderless) .padding(.top, 5) // Access Token Section VStack(alignment: .leading, spacing: 8) { HStack { Text("Access Token (Optional):") .font(.subheadline) HelpButton(helpText: "Enhanced push security token. Required if you've enabled push security in your EAS Dashboard.") } HStack { SecureField("Access token for enhanced security", text: $accessToken) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.leading, 4) Button(action: { if let clipboardString = NSPasteboard.general.string(forType: .string) { let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines) accessToken = trimmed } }) { Image(systemName: "doc.on.clipboard") .foregroundColor(.secondary) } .buttonStyle(.plain) .help("Paste access token from clipboard") } } Divider() // Basic Notification Fields VStack(alignment: .leading, spacing: 8) { InputField(label: "Title", text: $title, helpText: "Title of the notification", isRequired: true, showError: showTitleError) InputField(label: "Body", text: $notificationBody, helpText: "Message content displayed in the notification") // Priority Picker HStack { Text("Priority:") Picker("", selection: $priority) { Text("Default").tag(PushNotification.Priority.default) Text("Normal").tag(PushNotification.Priority.normal) Text("High").tag(PushNotification.Priority.high) } .pickerStyle(SegmentedPickerStyle()) HelpButton(helpText: "Affects delivery timing. 'High' wakes sleeping devices.") } Divider() KeyValueInputView(data: $data) } } // Advanced Settings Toggle Button(action: { showAdvancedSettings.toggle() }) { HStack { Text("Advanced Settings") .font(.subheadline) Spacer() Image(systemName: showAdvancedSettings ? "chevron.up" : "chevron.down") } } .buttonStyle(.borderless) if showAdvancedSettings { VStack(alignment: .leading, spacing: 12) { // iOS Specific Settings Group { Text("iOS Specific") .font(.subheadline) .foregroundColor(.secondary) InputField(label: "Sound", text: $sound, helpText: "Specify 'default' or custom sound name (iOS only)") InputField(label: "Subtitle", text: $subtitle, helpText: "Additional text below the title (iOS only)") InputField(label: "Badge", text: $badge, helpText: "Number to display on app icon (iOS only)") // Interruption Level Picker HStack { Text("Interruption Level:") Picker("", selection: $interruptionLevel) { Text("Active").tag(PushNotification.InterruptionLevel.active) Text("Critical").tag(PushNotification.InterruptionLevel.critical) Text("Passive").tag(PushNotification.InterruptionLevel.passive) Text("Time Sensitive").tag(PushNotification.InterruptionLevel.timeSensitive) } .pickerStyle(MenuPickerStyle()) HelpButton(helpText: "Controls the delivery timing and importance of the notification") } Toggle("Mutable Content", isOn: $mutableContent) .help("Allows notification content modification by the app") Toggle("Content Available", isOn: $contentAvailable) .help("Triggers background fetch on delivery") } Divider() // Android Specific Settings Group { Text("Android Specific") .font(.subheadline) .foregroundColor(.secondary) InputField(label: "Channel ID", text: $channelId, helpText: "Android notification channel identifier") } Divider() // Common Advanced Settings Group { Text("Common Settings") .font(.subheadline) .foregroundColor(.secondary) InputField(label: "Image (richContent)", text: $imageUrl, helpText: "URL of image to display in rich notification. Android shows it out of the box. iOS requires a Notification Service Extension — learn how in this free lesson: https://codewithbeto.dev/rnCourse/expoNotificationsExtension See also https://github.com/expo/expo/pull/36202") InputField(label: "Category ID", text: $categoryId, helpText: "Notification category for interactive notifications") InputField(label: "TTL", text: $ttl, helpText: "Time-to-live in seconds") InputField(label: "Expiration", text: $expiration, helpText: "Unix timestamp for expiration") } } } } } } .padding(.horizontal) .padding(.top) .overlay( ToastView(message: toastMessage, type: toastType, isPresented: $showToast) .animation(.easeInOut, value: showToast) ) .adaptivePresentation(isPresented: $showResponseSheet, isPinned: windowManager.isPinned) { ExpoResponseDetailView( response: lastResponse, httpStatusCode: lastHttpStatusCode, rawJSON: lastRawJSON ) } .adaptivePresentation(isPresented: $showCurlSheet, isPinned: windowManager.isPinned) { ExpoCurlCommandView( notification: buildNotification(), accessToken: accessToken.isEmpty ? nil : accessToken ) } .adaptivePresentation(isPresented: $showReceiptSheet, isPinned: windowManager.isPinned) { ExpoReceiptView( ticketIds: lastTicketIds, accessToken: accessToken.isEmpty ? nil : accessToken ) } .adaptivePresentation(item: $tokenToSave, isPinned: windowManager.isPinned) { item in SaveTokenSheet(token: item.token) { label in let savedToken = SavedToken(label: label, token: item.token) SavedTokenStore.shared.addToken(savedToken) savedTokens.append(savedToken) // Move from unsaved to saved if item.index < tokens.count { tokens.remove(at: item.index) } if tokens.isEmpty { tokens.append("") } } } .onAppear { savedTokens = SavedTokenStore.shared.loadTokens() } } /// All valid tokens from both saved (enabled only) and unsaved sources. private var allValidTokens: [String] { savedTokens.filter(\.isEnabled).map(\.token) + tokens.filter { !$0.isEmpty } } /// Ticket IDs from the last successful send (status == "ok" tickets only). private var lastTicketIds: [String] { lastResponse?.data?.compactMap { $0.id } ?? [] } /// Whether the last response contains errors (API-level or per-ticket). private var responseHasErrors: Bool { guard let response = lastResponse else { return false } if let errors = response.errors, !errors.isEmpty { return true } if let tickets = response.data, tickets.contains(where: { $0.status == "error" }) { return true } return false } /// Build a `PushNotification` from the current form values. private func buildNotification() -> PushNotification { let validTokens = allValidTokens let richContent: PushNotification.RichContent? = imageUrl.isEmpty ? nil : PushNotification.RichContent(image: imageUrl) return PushNotification( to: validTokens.isEmpty ? ["ExponentPushToken[...]"] : validTokens, title: title.isEmpty ? "Title" : title, body: notificationBody.isEmpty ? " " : notificationBody, data: data.isEmpty ? nil : data, ttl: Int(ttl), expiration: Int(expiration), priority: priority, subtitle: subtitle.isEmpty ? nil : subtitle, sound: sound.isEmpty ? nil : sound, badge: Int(badge), interruptionLevel: interruptionLevel, channelId: channelId.isEmpty ? nil : channelId, categoryId: categoryId.isEmpty ? nil : categoryId, mutableContent: mutableContent, contentAvailable: contentAvailable, richContent: richContent ) } private func sendPushNotification() { let validTokens = allValidTokens guard !validTokens.isEmpty, !title.isEmpty else { showTitleError = title.isEmpty if title.isEmpty { showToastNotification(message: "Title is required", type: .error) } return } showTitleError = false let notification = buildNotification() PushNotificationService.shared.sendPushNotification( notification: notification, accessToken: accessToken.isEmpty ? nil : accessToken ) { result in DispatchQueue.main.async { switch result { case .success(let sendResult): lastResponse = sendResult.response lastHttpStatusCode = sendResult.httpStatusCode lastRawJSON = sendResult.rawJSON print("Push sent successfully: \(sendResult.response)") showToastNotification(message: "Push notification sent successfully!", type: .success) case .failure(let error): print("Failed to send push: \(error.localizedDescription)") showToastNotification(message: error.localizedDescription, type: .error) } } } } private func showToastNotification(message: String, type: ToastType) { toastMessage = message toastType = type showToast = true } } ================================================ FILE: QuickPush/Views/SaveTokenSheet.swift ================================================ // // SaveTokenSheet.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI /// Modal sheet for saving a push token with a user-defined label. struct SaveTokenSheet: View { let token: String let onSave: (String) -> Void var warningText: String = "Expo push tokens may change when you reinstall the app on your device." @Environment(\.dismiss) private var dismiss @State private var label: String = "" var body: some View { VStack(spacing: 16) { // Header HStack { Text("Save Token") .font(.headline) Spacer() Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) } // Explanatory text Text("Save this token for future sessions so you don't have to paste it again.") .font(.callout) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) // Token preview (read-only) Text(token) .font(.system(.caption, design: .monospaced)) .foregroundColor(.secondary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .center) .padding(10) .background(Color(nsColor: .controlBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.2)) ) .cornerRadius(6) // Label input VStack(alignment: .leading, spacing: 4) { Text("Label:") .font(.subheadline) TextField("e.g. iPhone 17 Pro, Android Pixel", text: $label) .textFieldStyle(.roundedBorder) } // Warning note HStack(alignment: .top, spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) .font(.caption) Text(warningText) .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) // Action buttons HStack { Spacer() Button("Save") { onSave(label.trimmingCharacters(in: .whitespaces)) dismiss() } .buttonStyle(.borderedProminent) .disabled(label.trimmingCharacters(in: .whitespaces).isEmpty) .keyboardShortcut(.defaultAction) } } .padding() .frame(width: 400) } } ================================================ FILE: QuickPush/Views/SavedTokenRowView.swift ================================================ // // SavedTokenRowView.swift // QuickPush // // Created by beto on 2/19/26. // import SwiftUI /// Compact row display for a saved push token, showing label and token. struct SavedTokenRowView: View { let savedToken: SavedToken let onToggle: () -> Void let onCopy: () -> Void let onRemove: () -> Void var body: some View { HStack(spacing: 8) { // Enable/disable toggle Button(action: onToggle) { Image(systemName: savedToken.isEnabled ? "checkmark.circle.fill" : "circle") .foregroundColor(savedToken.isEnabled ? .accentColor : .secondary) .font(.system(size: 14)) } .buttonStyle(.plain) .help(savedToken.isEnabled ? "Disable this token" : "Enable this token") VStack(alignment: .leading, spacing: 2) { Text(savedToken.label) .font(.subheadline) .fontWeight(.medium) .lineLimit(1) Text(savedToken.token) .font(.system(.caption2, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) } Spacer() // Copy token button Button(action: onCopy) { Image(systemName: "doc.on.doc") .foregroundColor(.secondary) } .buttonStyle(.plain) .help("Copy token to clipboard") // Remove from saved button Button(action: onRemove) { Image(systemName: "bookmark.slash.fill") .foregroundColor(.orange) } .buttonStyle(.plain) .help("Remove from saved tokens") } .padding(.vertical, 4) .padding(.horizontal, 8) .background(Color(nsColor: .controlBackgroundColor).opacity(0.5)) .cornerRadius(6) .opacity(savedToken.isEnabled ? 1 : 0.5) } } ================================================ FILE: QuickPush.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 77; objects = { /* Begin PBXBuildFile section */ CAF9CB4F2D6824C800E87790 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = CAF9CB4E2D6824C800E87790 /* LaunchAtLogin */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ CAF9CB2E2D67FE0400E87790 /* QuickPush.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickPush.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ CAF9CB302D67FE0400E87790 /* QuickPush */ = { isa = PBXFileSystemSynchronizedRootGroup; path = QuickPush; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ CAF9CB2B2D67FE0400E87790 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( CAF9CB4F2D6824C800E87790 /* LaunchAtLogin in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ CAF9CB252D67FE0400E87790 = { isa = PBXGroup; children = ( CAF9CB302D67FE0400E87790 /* QuickPush */, CAF9CB2F2D67FE0400E87790 /* Products */, ); sourceTree = ""; }; CAF9CB2F2D67FE0400E87790 /* Products */ = { isa = PBXGroup; children = ( CAF9CB2E2D67FE0400E87790 /* QuickPush.app */, ); name = Products; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ CAF9CB2D2D67FE0400E87790 /* QuickPush */ = { isa = PBXNativeTarget; buildConfigurationList = CAF9CB3D2D67FE0500E87790 /* Build configuration list for PBXNativeTarget "QuickPush" */; buildPhases = ( CAF9CB2A2D67FE0400E87790 /* Sources */, CAF9CB2B2D67FE0400E87790 /* Frameworks */, CAF9CB2C2D67FE0400E87790 /* Resources */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( CAF9CB302D67FE0400E87790 /* QuickPush */, ); name = QuickPush; packageProductDependencies = ( CAF9CB4E2D6824C800E87790 /* LaunchAtLogin */, ); productName = QuickPush; productReference = CAF9CB2E2D67FE0400E87790 /* QuickPush.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ CAF9CB262D67FE0400E87790 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1610; LastUpgradeCheck = 1610; TargetAttributes = { CAF9CB2D2D67FE0400E87790 = { CreatedOnToolsVersion = 16.1; }; }; }; buildConfigurationList = CAF9CB292D67FE0400E87790 /* Build configuration list for PBXProject "QuickPush" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = CAF9CB252D67FE0400E87790; minimizedProjectReferenceProxies = 1; packageReferences = ( CAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF9CB2F2D67FE0400E87790 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( CAF9CB2D2D67FE0400E87790 /* QuickPush */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ CAF9CB2C2D67FE0400E87790 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ CAF9CB2A2D67FE0400E87790 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ CAF9CB3B2D67FE0500E87790 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 15.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; CAF9CB3C2D67FE0500E87790 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 15.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; }; CAF9CB3E2D67FE0500E87790 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = QuickPushIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = QuickPush/QuickPush.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"QuickPush/Preview Content\""; DEVELOPMENT_TEAM = T2A8YY9YDW; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = dev.codewithbeto.QuickPush; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; }; CAF9CB3F2D67FE0500E87790 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = QuickPushIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = QuickPush/QuickPush.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"QuickPush/Preview Content\""; DEVELOPMENT_TEAM = T2A8YY9YDW; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = dev.codewithbeto.QuickPush; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ CAF9CB292D67FE0400E87790 /* Build configuration list for PBXProject "QuickPush" */ = { isa = XCConfigurationList; buildConfigurations = ( CAF9CB3B2D67FE0500E87790 /* Debug */, CAF9CB3C2D67FE0500E87790 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; CAF9CB3D2D67FE0500E87790 /* Build configuration list for PBXNativeTarget "QuickPush" */ = { isa = XCConfigurationList; buildConfigurations = ( CAF9CB3E2D67FE0500E87790 /* Debug */, CAF9CB3F2D67FE0500E87790 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ CAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin-Modern.git"; requirement = { branch = main; kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ CAF9CB4E2D6824C800E87790 /* LaunchAtLogin */ = { isa = XCSwiftPackageProductDependency; package = CAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */; productName = LaunchAtLogin; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CAF9CB262D67FE0400E87790 /* Project object */; } ================================================ FILE: QuickPush.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: QuickPush.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved ================================================ { "originHash" : "50c271549071e9a517f8a4da8966700ae5e4b29169d39bb1ae8c64c11eb330cd", "pins" : [ { "identity" : "launchatlogin-modern", "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/LaunchAtLogin-Modern.git", "state" : { "branch" : "main", "revision" : "a04ec1c363be3627734f6dad757d82f5d4fa8fcc" } } ], "version" : 3 } ================================================ FILE: QuickPush.xcodeproj/xcuserdata/beto.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist ================================================ ================================================ FILE: QuickPush.xcodeproj/xcuserdata/beto.xcuserdatad/xcschemes/xcschememanagement.plist ================================================ SchemeUserState QuickPush.xcscheme_^#shared#^_ orderHint 0 ================================================ FILE: README.md ================================================

QuickPush

QuickPush Tool

A lightweight macOS menu bar utility for quickly testing Expo push notifications, Live Activity updates, native APNs, and FCM (Android) pushes

Download on the Mac App Store

📺 Watch it in action

### Features - Send test push notifications to your Expo apps directly from the menu bar - **Send Live Activity pushes** (start, update, end) directly to APNs - Simple and intuitive interface for quick testing - Easy configuration of notification payload and options - **Rich content (image) support** for Android notifications via `richContent` - Advanced push notification features (priority, interruption level, TTL, and more) - Platform-specific settings for iOS and Android - Color pickers, progress sliders, and date pickers for Live Activity content - JSON import/export for Live Activity payloads - APNs JWT authentication with .p8 key files (no third-party dependencies) - **Native APNs push** directly to raw device tokens — full payload control, image URL support - **Native FCM push** directly to Firebase Cloud Messaging HTTP v1 API — service account auth, no SDKs required ## 🛠️ Installation Download on the Mac App Store The easiest way to get QuickPush is from the [Mac App Store](https://apps.apple.com/us/app/quickpush-tool/id6758917536). One-time purchase, automatic updates, no setup required. #### Build from source QuickPush is open source. If you prefer, you can clone the repo and build it locally with Xcode: ```bash git clone https://github.com/betomoedano/quick-push.git open quick-push/QuickPush.xcodeproj ``` Requires Xcode 16+ and macOS 14.6+. ## 🔴 Live Activity Setup To send Live Activity push notifications, QuickPush communicates directly with Apple's APNs (Apple Push Notification service) using JWT-based authentication. You'll need a few things from your Apple Developer account before you can start. ### 1. Get your Team ID Your Team ID is a 10-character string that identifies your Apple Developer team. 1. Go to [Apple Developer Account](https://developer.apple.com/account) 2. Sign in with your Apple ID 3. Your **Team ID** is displayed under **Membership Details** ### 2. Create an APNs Authentication Key (.p8 file) The .p8 key file is used to sign JWT tokens that authenticate your requests with APNs. You only need to create this once — it works for all your apps. 1. Go to [Certificates, Identifiers & Profiles > Keys](https://developer.apple.com/account/resources/authkeys/list) 2. Click the **+** button to create a new key 3. Give it a name (e.g. "QuickPush APNs Key") 4. Check **Apple Push Notifications service (APNs)** 5. Click **Continue**, then **Register** 6. **Download the .p8 file** — you can only download it once, so save it somewhere safe 7. Note the **Key ID** shown on this page (10-character string, e.g. `ABC123DEF4`) > **Important:** Apple only lets you download the .p8 file once. If you lose it, you'll need to create a new key. For more details, see Apple's documentation: [Establishing a Token-Based Connection to APNs](https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns) ### 3. Find your Bundle ID The Bundle ID is the unique identifier for your app (e.g. `com.yourcompany.yourapp`). 1. Go to [Certificates, Identifiers & Profiles > Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. Find your app in the list 3. The **Bundle ID** is shown next to each app This must match the bundle identifier of the app that registered the Live Activity. ### 4. Configure QuickPush 1. Open QuickPush from your menu bar 2. Switch to the **Live Activity** tab 3. Expand the **APNs Configuration** section 4. Enter your **Team ID**, **Key ID**, and **Bundle ID** 5. Click **Browse...** to select your `.p8` key file 6. Choose **Sandbox** (for development/TestFlight builds) or **Production** (for App Store builds) > Your configuration is saved automatically and persists between sessions. ### 5. Send a Live Activity Push 1. Choose an event type: - **Start** — creates a new Live Activity on the device (requires a push-to-start token) - **Update** — updates an existing Live Activity (requires an activity token) - **End** — ends an existing Live Activity (requires an activity token) 2. Paste the device token (hex string) from your app 3. Fill in the content state fields (title, subtitle, progress, etc.) 4. For **Start** events, configure the attributes (colors, layout options) 5. Click **Send** ### Sandbox vs Production | Environment | When to use | APNs hostname | |---|---|---| | **Sandbox** | Development builds, TestFlight | `api.sandbox.push.apple.com` | | **Production** | App Store releases | `api.push.apple.com` | If you're testing on a device with a development or TestFlight build, use **Sandbox**. If your app is installed from the App Store, use **Production**. ### Getting Device Tokens Your app needs to provide the device token for Live Activities. Depending on the event type: - **Push-to-start token**: Obtained via `Activity.pushToStartToken` — use this for **Start** events - **Activity token**: Obtained via `activity.pushToken` after a Live Activity has been started — use this for **Update** and **End** events These are raw APNs hex tokens (not Expo push tokens). See the [Expo LiveActivity documentation](https://docs.expo.dev/versions/latest/sdk/live-activity/) for implementation details with `expo-live-activity`. ### JSON Import/Export Click the **JSON** button in the Live Activity tab to: - **Export** the current form as a JSON payload (useful for debugging or sharing) - **Import** a JSON payload to populate the form fields (useful for quickly loading saved payloads) ## 📡 APNs Tab The **APNs** tab lets you send native iOS push notifications directly to Apple's Push Notification service — no Expo push token required. It's useful for testing notifications on devices where you only have the raw APNs device token, or when you need full control over the APNs payload. ### Device Tokens APNs device tokens are raw hex strings (64 hex characters), different from Expo push tokens (`ExponentPushToken[...]`). Your app receives this token from the system: ```swift // Swift (iOS) func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02x", $0) }.joined() print("APNs token:", token) } ``` Tokens can be saved across sessions using the save button next to the token field. ### Payload Fields | Field | Description | |---|---| | **Title / Subtitle / Body** | Standard alert text | | **Sound** | `default` or a custom sound file name bundled in the app | | **Badge** | Number shown on the app icon | | **Image URL** | URL of an image to attach to the notification (see below) | | **Thread ID** | Groups related notifications together in the notification center | | **Category** | Enables interactive notification actions registered in the app | | **Interruption Level** | Controls delivery timing (Active, Passive, Time Sensitive, Critical) | | **Mutable Content** | Allows a Notification Service Extension to modify the payload before display | | **Content Available** | Wakes the app in the background to process the notification silently | | **Priority** | `10` = immediate, `5` = normal/background | ### Image URL (via Notification Service Extension) APNs does not have a built-in image field. To display an image in a notification, the iOS app must include a **Notification Service Extension** — a small app extension that intercepts the push before it's shown, downloads the image, and attaches it as a media attachment. The Image URL field in QuickPush injects the URL into the payload under a custom key path: ```json { "aps": { "alert": { "title": "Hello" }, "mutable-content": 1 }, "body": { "_richContent": { "image": "https://example.com/photo.jpg" } } } ``` Your Notification Service Extension would read it like this: ```swift // In NotificationService.swift let imageUrl = (request.content.userInfo["body"] as? [String: Any]) .flatMap { $0["_richContent"] as? [String: Any] } .flatMap { $0["image"] as? String } .flatMap { URL(string: $0) } ``` > **Note:** The exact key path (`body._richContent.image`) is the convention QuickPush uses. The key name your app listens for depends entirely on what the Notification Service Extension in that app is coded to read. If you're testing against an app that uses a different key, use the **Custom Data** section to inject the key manually instead. Filling in the Image URL field automatically enables **Mutable Content** in the payload, which is required for the Notification Service Extension to be invoked. ## 🤖 FCM Tab The **FCM** tab lets you send native Android push notifications directly to Firebase Cloud Messaging's HTTP v1 API — no Expo push token required. It authenticates using a Firebase service account, signs OAuth 2.0 tokens locally with RS256 (no third-party SDKs), and supports both notification and data-only messages. ### 1. Create a Firebase Project If you don't already have one: 1. Go to the [Firebase Console](https://console.firebase.google.com/) 2. Click **Add project** and follow the setup wizard 3. Once created, add your Android app to the project (**Project settings → Your apps → Add app**) ### 2. Generate a Service Account Key QuickPush authenticates with FCM using a **service account JSON** file. This is the same credential type used by Firebase Admin SDKs. 1. In the [Firebase Console](https://console.firebase.google.com/), open your project 2. Go to **Project settings** (gear icon) → **Service accounts** tab 3. Click **Generate new private key** 4. Confirm by clicking **Generate key** — a `.json` file will download The JSON file contains your project ID, client email, and private key. QuickPush reads all three from the file automatically when you select it. > **Keep this file secure.** Anyone with it can send push notifications to all users of your app. Don't commit it to version control. ### 3. Find your FCM Registration Token The FCM registration token identifies a specific app install on a device. It's not the same as an Expo push token. **React Native / Expo (using `@react-native-firebase/messaging`):** ```js import messaging from '@react-native-firebase/messaging'; const token = await messaging().getToken(); console.log('FCM token:', token); ``` **React Native / Expo (using `expo-notifications`):** ```js import * as Notifications from 'expo-notifications'; const { data: token } = await Notifications.getDevicePushTokenAsync(); // On Android this returns the raw FCM registration token console.log('FCM token:', token); ``` FCM tokens are long base64url strings (~163+ characters). They can change when the user reinstalls the app or clears app data. ### 4. Configure QuickPush 1. Open QuickPush from your menu bar 2. Switch to the **FCM** tab (⌘4) 3. Expand the **FCM Configuration** section 4. Click **Browse...** and select your downloaded service account `.json` file 5. **Project ID** and **Client Email** are auto-filled from the file — verify they look correct > Your configuration is saved automatically and persists between sessions. The service account JSON contents are stored in macOS user defaults (local to your machine, not synced). ### 5. Send a Push 1. Paste your FCM registration token into the token field (or use the save button to store it for future sessions) 2. Choose the **Message Type**: - **Notification** — displays a visible notification on the device - **Data** — delivers a silent data payload to the app; no notification UI is shown 3. Fill in the notification fields (Title, Body, Image URL, Channel ID, Sound, Color) 4. Optionally add **Custom Data** key-value pairs — these are delivered in the message's `data` block 5. Click **Send** (or ⌘↵) ### Message Types | Type | Description | When to use | |---|---|---| | **Notification** | Shows a visible notification with title, body, and optional image | User-facing alerts | | **Data** | Silent payload delivered to the app; no system UI | Background processing, in-app messages | ### Notification Fields | Field | Description | |---|---| | **Title** | Title of the notification | | **Body** | Main message content | | **Image URL** | URL of an image displayed in the expanded notification | | **Channel ID** | Android notification channel the app must have pre-created (defaults to `default`) | | **Sound** | Sound to play — use `default` for the device default | | **Color** | Accent color for the notification icon, in hex format (e.g. `FF5733`) | ### Priority | Value | Description | |---|---| | **HIGH** | Wakes the device immediately — use for user-visible notifications | | **NORMAL** | May be delayed for battery optimization — suitable for non-urgent data messages | ### FCM Error Codes | Code | Meaning | |---|---| | `INVALID_ARGUMENT` | The request payload is malformed or the token format is wrong | | `NOT_FOUND` / `UNREGISTERED` | The token is no longer valid; the app was uninstalled or the token expired | | `SENDER_ID_MISMATCH` | The token was registered with a different Firebase project | | `QUOTA_EXCEEDED` | Sending rate too high; retry with exponential backoff | | `UNAVAILABLE` | FCM service temporarily unavailable; retry with exponential backoff | | `INTERNAL` | Internal FCM server error; retry with exponential backoff | ### Getting a cURL Command Click the **cURL** button to generate a ready-to-run curl command for the current configuration. Because the OAuth access token requires an async exchange with Google's servers, the cURL output uses an `` placeholder. To fill it in: ```bash # Using the Google Cloud CLI gcloud auth print-access-token # Or using the service account directly gcloud auth activate-service-account --key-file=/path/to/service-account.json gcloud auth print-access-token ``` Replace `` in the copied curl command with the token output. ### Saved Tokens Click the **save** icon (↓) next to any token to store it with a label. Saved tokens persist across app restarts and appear at the top of the token list with a toggle to include or exclude them from sends. ## 🖼️ Rich Content (Image Notifications) QuickPush supports the `richContent` field, allowing you to attach an image to your push notifications. 1. Open the **Push Notification** tab 2. Expand **Advanced Settings** 3. In the **Common Settings** section, find the **Image (richContent)** field 4. Paste the URL of the image you want to display ### Platform behavior | Platform | Support | |---|---| | **Android** | Works out of the box — the image will display in the notification | | **iOS** | Requires a [Notification Service Extension](https://github.com/expo/expo/pull/36202) target in your app to process and display the image | > **Tip:** The help tooltip for this field includes a clickable link to the Expo PR with an iOS implementation example. ## 📸 Screenshots QuickPush Push Notification QuickPush Advanced Settings ## License MIT ---

Made with ❤️ by codewithbeto.dev