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 Tool
A lightweight macOS menu bar utility for quickly testing Expo push notifications, Live Activity updates, native APNs, and FCM (Android) pushes
📺 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
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
## License
MIT
---
Made with ❤️ by codewithbeto.dev