├── .github └── resources │ ├── icon-dark.png │ └── icon-white.png ├── QuickPush ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── 16x16.png │ │ ├── 128x128.png │ │ ├── 16x16@2x.png │ │ ├── 32x32@2x.png │ │ ├── 512x512.png │ │ ├── 128x128@2x.png │ │ ├── 16x16@2x 2.png │ │ ├── 256x256@2x.png │ │ ├── 512x512 1.png │ │ ├── 128x128@2x 1.png │ │ └── Contents.json │ ├── MenuBarIcon.imageset │ │ ├── MenuBarIcon.pdf │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── QuickPush.entitlements ├── Models │ ├── PushResponse.swift │ └── PushNotification.swift ├── QuickPushApp.swift ├── Controllers │ └── PushNotificationService.swift ├── Views │ └── KeyValueInputView.swift └── ContentView.swift ├── QuickPush.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved ├── xcuserdata │ └── beto.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── project.pbxproj ├── LICENSE └── README.md /.github/resources/icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/.github/resources/icon-dark.png -------------------------------------------------------------------------------- /.github/resources/icon-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/.github/resources/icon-white.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /QuickPush/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/16x16.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/128x128.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/16x16@2x.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/32x32@2x.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/512x512.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/128x128@2x.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/16x16@2x 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/16x16@2x 2.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/256x256@2x.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/512x512 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/512x512 1.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/128x128@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/AppIcon.appiconset/128x128@2x 1.png -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betomoedano/quick-push/HEAD/QuickPush/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.pdf -------------------------------------------------------------------------------- /QuickPush.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/MenuBarIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "MenuBarIcon.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /QuickPush/QuickPush.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /QuickPush.xcodeproj/xcuserdata/beto.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | QuickPush.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /QuickPush.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "50c271549071e9a517f8a4da8966700ae5e4b29169d39bb1ae8c64c11eb330cd", 3 | "pins" : [ 4 | { 5 | "identity" : "launchatlogin-modern", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sindresorhus/LaunchAtLogin-Modern.git", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "a04ec1c363be3627734f6dad757d82f5d4fa8fcc" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /QuickPush/Models/PushResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushResponse.swift 3 | // QuickPush 4 | // 5 | // Created by beto on 2/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PushResponse: Codable { 11 | let data: [PushTicket]? 12 | let errors: [PushError]? 13 | 14 | struct PushTicket: Codable { 15 | let status: String 16 | let id: String? // Present only if status == "ok" 17 | let message: String? // Present only if status == "error" 18 | let details: [String: String]? // JSON details object, can vary 19 | } 20 | 21 | struct PushError: Codable { 22 | let code: String 23 | let message: String 24 | let type: String? 25 | let isTransient: Bool? 26 | let metadata: ErrorMetadata? 27 | let requestId: String? 28 | 29 | struct ErrorMetadata: Codable { 30 | let appId: String? 31 | let experienceId: String? 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /QuickPush/QuickPushApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuickPushApp.swift 3 | // QuickPush 4 | // 5 | // Created by beto on 2/20/25. 6 | // 7 | 8 | import SwiftUI 9 | import LaunchAtLogin 10 | 11 | @main 12 | struct QuickPushApp: App { 13 | var body: some Scene { 14 | MenuBarExtra("QuickPush", image: "MenuBarIcon") { 15 | VStack { 16 | ContentView() 17 | Divider() 18 | VStack(spacing: 8) { 19 | HStack { 20 | LaunchAtLogin.Toggle() 21 | Spacer() 22 | Button("Quit") { 23 | NSApplication.shared.terminate(nil) 24 | } 25 | .keyboardShortcut("q") 26 | } 27 | HStack(spacing: 0) { // Ensures no extra spacing 28 | Text("Made with ❤️ by ") 29 | Link("codewithbeto.dev", destination: URL(string: "https://codewithbeto.dev")!) 30 | .foregroundColor(.blue) 31 | } 32 | .font(.caption) 33 | } 34 | .padding() 35 | } 36 | .frame(minWidth: 400) 37 | } 38 | .menuBarExtraStyle(.window) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Code with Beto LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /QuickPush/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "16x16@2x 2.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "128x128@2x 1.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "512x512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "512x512 1.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "256x256@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | QuickPush 5 | 6 |

QuickPush

7 |

8 | 9 |

A lightweight macOS menu bar utility for quickly testing Expo push notifications

10 | 11 | ### Features highlights 12 | 13 | - Send test push notifications to your Expo apps directly from the menu bar 14 | - Simple and intuitive interface for quick testing 15 | - Easy configuration of notification payload and options 16 | - Advanced push notification features available 17 | - Platform specific settings available 18 | - Instant feedback on notification delivery status (coming soon) 19 | 20 | Try out QuickPush now, explore its capabilities, and share your feedback. Your input will shape the future of this tool and guide us on where to take it next. 21 | 22 | ## 🛠️ Installation 23 | 24 | You can download the latest version of QuickPush for macOS from [quickpush/releases](https://github.com/betomoedano/quick-push/releases) page. 25 | 26 | 1. Download the QuickPush.zip file 27 | 2. Extract the zip file by double-clicking it 28 | 3. Drag and drop the QuickPush app to your Applications folder 29 | 4. Open QuickPush from your Applications folder 30 | 5. The QuickPush icon will appear in your menu bar 31 | 32 | ## 📸 Screenshots 33 | 34 | Screenshot 2025-02-22 at 7 08 40 PM 35 | 36 | Screenshot 2025-02-22 at 7 08 33 PM 37 | -------------------------------------------------------------------------------- /QuickPush/Models/PushNotification.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushNotification.swift 3 | // QuickPush 4 | // 5 | // Created by beto on 2/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PushNotification: Codable { 11 | let to: [String] // Supports both single and multiple recipients 12 | let title: String 13 | let body: String 14 | let data: [String: String]? 15 | let ttl: Int? 16 | let expiration: Int? 17 | let priority: Priority? 18 | let subtitle: String? 19 | let sound: String? 20 | let badge: Int? 21 | let interruptionLevel: InterruptionLevel? 22 | let channelId: String? 23 | let categoryId: String? 24 | let mutableContent: Bool? 25 | let contentAvailable: Bool? 26 | 27 | enum Priority: String, Codable { 28 | case `default`, normal, high 29 | } 30 | 31 | enum InterruptionLevel: String, Codable { 32 | case active, critical, passive, timeSensitive = "time-sensitive" 33 | } 34 | 35 | enum CodingKeys: String, CodingKey { 36 | case to, title, body, data, ttl, expiration, priority, subtitle, sound, badge 37 | case interruptionLevel = "interruptionLevel" 38 | case channelId = "channelId" 39 | case categoryId = "categoryId" 40 | case mutableContent = "mutableContent" 41 | case contentAvailable = "_contentAvailable" 42 | } 43 | 44 | init( 45 | to: [String], 46 | title: String, 47 | body: String, 48 | data: [String: String]? = nil, 49 | ttl: Int? = nil, 50 | expiration: Int? = nil, 51 | priority: Priority? = .default, 52 | subtitle: String? = nil, 53 | sound: String? = "default", 54 | badge: Int? = nil, 55 | interruptionLevel: InterruptionLevel? = nil, 56 | channelId: String? = nil, 57 | categoryId: String? = nil, 58 | mutableContent: Bool? = false, 59 | contentAvailable: Bool? = nil 60 | ) { 61 | self.to = to 62 | self.title = title 63 | self.body = body 64 | self.data = data 65 | self.ttl = ttl 66 | self.expiration = expiration 67 | self.priority = priority 68 | self.subtitle = subtitle 69 | self.sound = sound 70 | self.badge = badge 71 | self.interruptionLevel = interruptionLevel 72 | self.channelId = channelId 73 | self.categoryId = categoryId 74 | self.mutableContent = mutableContent 75 | self.contentAvailable = contentAvailable 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /QuickPush/Controllers/PushNotificationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushNotificationService.swift 3 | // QuickPush 4 | // 5 | // Created by beto on 2/20/25. 6 | // 7 | 8 | import Foundation 9 | 10 | class PushNotificationService { 11 | static let shared = PushNotificationService() // Singleton instance 12 | 13 | private let expoPushEndpoint = "https://exp.host/--/api/v2/push/send" 14 | 15 | func sendPushNotification(notification: PushNotification, accessToken: String? = nil, completion: @escaping (Result) -> Void) { 16 | guard let url = URL(string: expoPushEndpoint) else { 17 | completion(.failure(APIError.invalidURL)) 18 | return 19 | } 20 | 21 | // Prepare request 22 | var request = URLRequest(url: url) 23 | request.httpMethod = "POST" 24 | request.setValue("exp.host", forHTTPHeaderField: "host") 25 | request.setValue("application/json", forHTTPHeaderField: "accept") 26 | request.setValue("gzip, deflate", forHTTPHeaderField: "accept-encoding") 27 | request.setValue("application/json", forHTTPHeaderField: "content-type") 28 | 29 | // Add authorization header if access token is provided 30 | if let accessToken = accessToken, !accessToken.isEmpty { 31 | request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 32 | } 33 | 34 | do { 35 | let jsonData = try JSONEncoder().encode(notification) 36 | request.httpBody = jsonData 37 | } catch { 38 | completion(.failure(APIError.encodingFailed)) 39 | return 40 | } 41 | 42 | // Perform network request 43 | URLSession.shared.dataTask(with: request) { data, response, error in 44 | // Handle network errors 45 | if let error = error { 46 | completion(.failure(error)) 47 | return 48 | } 49 | 50 | // Ensure we have valid data 51 | guard let data = data else { 52 | completion(.failure(APIError.noData)) 53 | return 54 | } 55 | 56 | // Decode API response 57 | do { 58 | let responseObject = try JSONDecoder().decode(PushResponse.self, from: data) 59 | 60 | // UNAUTHORIZED REQUESTS CHECK - Possibly no Access Token 61 | if let errors = responseObject.errors, 62 | errors.contains(where: { $0.code == "UNAUTHORIZED" }) { 63 | completion(.failure(APIError.insufficientPermissions)) 64 | return 65 | } 66 | 67 | completion(.success(responseObject)) 68 | } catch { 69 | completion(.failure(APIError.decodingFailed)) 70 | } 71 | }.resume() 72 | } 73 | } 74 | 75 | // MARK: - API Error Enum 76 | enum APIError: Error, LocalizedError { 77 | case invalidURL 78 | case encodingFailed 79 | case noData 80 | case decodingFailed 81 | case insufficientPermissions 82 | 83 | var errorDescription: String? { 84 | switch self { 85 | case .invalidURL: return "Invalid API URL" 86 | case .encodingFailed: return "Failed to encode request data" 87 | case .noData: return "No response data received" 88 | case .decodingFailed: return "Failed to decode API response" 89 | case .insufficientPermissions: return "Insufficient permissions. Push security may be enabled for this app - please provide a valid access token above." 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /QuickPush/Views/KeyValueInputView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyValueInputView.swift 3 | // QuickPush 4 | // 5 | // Created by beto on 2/20/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct KeyValueInputView: View { 11 | @Binding var data: [String: String] 12 | @State private var editingKeys: [String: String] = [:] // Temporary key storage 13 | 14 | var body: some View { 15 | VStack(alignment: .leading, spacing: 8) { 16 | HStack { 17 | Text("Custom Data:") 18 | .font(.subheadline) 19 | HelpButton(helpText: "Add custom JSON data to be sent with the notification. Up to 4KB total payload size.") 20 | } 21 | 22 | if data.isEmpty { 23 | Text("No custom data") 24 | .foregroundColor(.secondary) 25 | .font(.subheadline) 26 | .padding(.vertical, 4) 27 | } 28 | 29 | ForEach(Array(data.keys), id: \.self) { key in 30 | HStack(spacing: 8) { 31 | // Key Input 32 | TextField("Key", text: Binding( 33 | get: { editingKeys[key] ?? key }, 34 | set: { newKey in 35 | let sanitizedKey = sanitizeKey(newKey) 36 | editingKeys[key] = sanitizedKey 37 | } 38 | )) 39 | .textFieldStyle(RoundedBorderTextFieldStyle()) 40 | .frame(width: 120) 41 | .onSubmit { 42 | finalizeKeyEdit(oldKey: key) 43 | } 44 | 45 | Text(":") 46 | .foregroundColor(.secondary) 47 | 48 | // Value Input 49 | TextField("Value", text: Binding( 50 | get: { data[key] ?? "" }, 51 | set: { newValue in data[key] = newValue } 52 | )) 53 | .textFieldStyle(RoundedBorderTextFieldStyle()) 54 | 55 | // Remove Button 56 | Button(action: { removeKey(key) }) { 57 | Image(systemName: "minus.circle.fill") 58 | .foregroundColor(.red) 59 | } 60 | .buttonStyle(PlainButtonStyle()) 61 | .help("Remove this key-value pair") 62 | } 63 | } 64 | 65 | // Add Button 66 | Button(action: { addNewKeyValue() }) { 67 | HStack { 68 | Image(systemName: "plus.circle.fill") 69 | Text("Add Key-Value Pair") 70 | } 71 | } 72 | .buttonStyle(.borderless) 73 | .disabled(data.count >= 10) // Prevent too many items 74 | .help(data.count >= 10 ? "Maximum number of custom data pairs reached" : "Add a new key-value pair") 75 | .padding(.top, 4) 76 | } 77 | .padding(.vertical, 4) 78 | } 79 | 80 | // MARK: - Helper Functions 81 | 82 | /// Sanitizes a key: lowercase, replaces spaces with "-", removes invalid characters 83 | private func sanitizeKey(_ key: String) -> String { 84 | let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")) 85 | return key 86 | .lowercased() 87 | .replacingOccurrences(of: " ", with: "-") // Replace spaces with "-" 88 | .components(separatedBy: allowedCharacters.inverted) // Remove special characters 89 | .joined() 90 | } 91 | 92 | /// Finalizes key edit: prevents empty or duplicate keys 93 | private func finalizeKeyEdit(oldKey: String) { 94 | if let newKey = editingKeys[oldKey]?.trimmingCharacters(in: .whitespacesAndNewlines), 95 | !newKey.isEmpty, 96 | newKey != oldKey, 97 | !data.keys.contains(newKey) { // Prevent duplicate keys 98 | if let value = data.removeValue(forKey: oldKey) { 99 | data[newKey] = value 100 | } 101 | } 102 | editingKeys[oldKey] = nil // Clear temp key storage 103 | } 104 | 105 | /// Adds a new key-value pair with a default key 106 | private func addNewKeyValue() { 107 | guard data.count < 10 else { return } // Safety check 108 | 109 | let baseKey = "key" 110 | var newKey = baseKey 111 | var counter = 1 112 | 113 | while data.keys.contains(newKey) { 114 | newKey = "\(baseKey)\(counter)" 115 | counter += 1 116 | } 117 | 118 | data[newKey] = "" 119 | editingKeys[newKey] = newKey 120 | } 121 | 122 | /// Removes a key-value pair 123 | private func removeKey(_ key: String) { 124 | data.removeValue(forKey: key) 125 | editingKeys.removeValue(forKey: key) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /QuickPush.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | CAF9CB4F2D6824C800E87790 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = CAF9CB4E2D6824C800E87790 /* LaunchAtLogin */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXFileReference section */ 14 | CAF9CB2E2D67FE0400E87790 /* QuickPush.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickPush.app; sourceTree = BUILT_PRODUCTS_DIR; }; 15 | /* End PBXFileReference section */ 16 | 17 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 18 | CAF9CB302D67FE0400E87790 /* QuickPush */ = { 19 | isa = PBXFileSystemSynchronizedRootGroup; 20 | path = QuickPush; 21 | sourceTree = ""; 22 | }; 23 | /* End PBXFileSystemSynchronizedRootGroup section */ 24 | 25 | /* Begin PBXFrameworksBuildPhase section */ 26 | CAF9CB2B2D67FE0400E87790 /* Frameworks */ = { 27 | isa = PBXFrameworksBuildPhase; 28 | buildActionMask = 2147483647; 29 | files = ( 30 | CAF9CB4F2D6824C800E87790 /* LaunchAtLogin in Frameworks */, 31 | ); 32 | runOnlyForDeploymentPostprocessing = 0; 33 | }; 34 | /* End PBXFrameworksBuildPhase section */ 35 | 36 | /* Begin PBXGroup section */ 37 | CAF9CB252D67FE0400E87790 = { 38 | isa = PBXGroup; 39 | children = ( 40 | CAF9CB302D67FE0400E87790 /* QuickPush */, 41 | CAF9CB2F2D67FE0400E87790 /* Products */, 42 | ); 43 | sourceTree = ""; 44 | }; 45 | CAF9CB2F2D67FE0400E87790 /* Products */ = { 46 | isa = PBXGroup; 47 | children = ( 48 | CAF9CB2E2D67FE0400E87790 /* QuickPush.app */, 49 | ); 50 | name = Products; 51 | sourceTree = ""; 52 | }; 53 | /* End PBXGroup section */ 54 | 55 | /* Begin PBXNativeTarget section */ 56 | CAF9CB2D2D67FE0400E87790 /* QuickPush */ = { 57 | isa = PBXNativeTarget; 58 | buildConfigurationList = CAF9CB3D2D67FE0500E87790 /* Build configuration list for PBXNativeTarget "QuickPush" */; 59 | buildPhases = ( 60 | CAF9CB2A2D67FE0400E87790 /* Sources */, 61 | CAF9CB2B2D67FE0400E87790 /* Frameworks */, 62 | CAF9CB2C2D67FE0400E87790 /* Resources */, 63 | ); 64 | buildRules = ( 65 | ); 66 | dependencies = ( 67 | ); 68 | fileSystemSynchronizedGroups = ( 69 | CAF9CB302D67FE0400E87790 /* QuickPush */, 70 | ); 71 | name = QuickPush; 72 | packageProductDependencies = ( 73 | CAF9CB4E2D6824C800E87790 /* LaunchAtLogin */, 74 | ); 75 | productName = QuickPush; 76 | productReference = CAF9CB2E2D67FE0400E87790 /* QuickPush.app */; 77 | productType = "com.apple.product-type.application"; 78 | }; 79 | /* End PBXNativeTarget section */ 80 | 81 | /* Begin PBXProject section */ 82 | CAF9CB262D67FE0400E87790 /* Project object */ = { 83 | isa = PBXProject; 84 | attributes = { 85 | BuildIndependentTargetsInParallel = 1; 86 | LastSwiftUpdateCheck = 1610; 87 | LastUpgradeCheck = 1610; 88 | TargetAttributes = { 89 | CAF9CB2D2D67FE0400E87790 = { 90 | CreatedOnToolsVersion = 16.1; 91 | }; 92 | }; 93 | }; 94 | buildConfigurationList = CAF9CB292D67FE0400E87790 /* Build configuration list for PBXProject "QuickPush" */; 95 | developmentRegion = en; 96 | hasScannedForEncodings = 0; 97 | knownRegions = ( 98 | en, 99 | Base, 100 | ); 101 | mainGroup = CAF9CB252D67FE0400E87790; 102 | minimizedProjectReferenceProxies = 1; 103 | packageReferences = ( 104 | CAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */, 105 | ); 106 | preferredProjectObjectVersion = 77; 107 | productRefGroup = CAF9CB2F2D67FE0400E87790 /* Products */; 108 | projectDirPath = ""; 109 | projectRoot = ""; 110 | targets = ( 111 | CAF9CB2D2D67FE0400E87790 /* QuickPush */, 112 | ); 113 | }; 114 | /* End PBXProject section */ 115 | 116 | /* Begin PBXResourcesBuildPhase section */ 117 | CAF9CB2C2D67FE0400E87790 /* Resources */ = { 118 | isa = PBXResourcesBuildPhase; 119 | buildActionMask = 2147483647; 120 | files = ( 121 | ); 122 | runOnlyForDeploymentPostprocessing = 0; 123 | }; 124 | /* End PBXResourcesBuildPhase section */ 125 | 126 | /* Begin PBXSourcesBuildPhase section */ 127 | CAF9CB2A2D67FE0400E87790 /* Sources */ = { 128 | isa = PBXSourcesBuildPhase; 129 | buildActionMask = 2147483647; 130 | files = ( 131 | ); 132 | runOnlyForDeploymentPostprocessing = 0; 133 | }; 134 | /* End PBXSourcesBuildPhase section */ 135 | 136 | /* Begin XCBuildConfiguration section */ 137 | CAF9CB3B2D67FE0500E87790 /* Debug */ = { 138 | isa = XCBuildConfiguration; 139 | buildSettings = { 140 | ALWAYS_SEARCH_USER_PATHS = NO; 141 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 142 | CLANG_ANALYZER_NONNULL = YES; 143 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 144 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 145 | CLANG_ENABLE_MODULES = YES; 146 | CLANG_ENABLE_OBJC_ARC = YES; 147 | CLANG_ENABLE_OBJC_WEAK = YES; 148 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 149 | CLANG_WARN_BOOL_CONVERSION = YES; 150 | CLANG_WARN_COMMA = YES; 151 | CLANG_WARN_CONSTANT_CONVERSION = YES; 152 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 153 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 154 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 155 | CLANG_WARN_EMPTY_BODY = YES; 156 | CLANG_WARN_ENUM_CONVERSION = YES; 157 | CLANG_WARN_INFINITE_RECURSION = YES; 158 | CLANG_WARN_INT_CONVERSION = YES; 159 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 160 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 161 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 162 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 163 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 164 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 165 | CLANG_WARN_STRICT_PROTOTYPES = YES; 166 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 167 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 168 | CLANG_WARN_UNREACHABLE_CODE = YES; 169 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 170 | COPY_PHASE_STRIP = NO; 171 | DEBUG_INFORMATION_FORMAT = dwarf; 172 | ENABLE_STRICT_OBJC_MSGSEND = YES; 173 | ENABLE_TESTABILITY = YES; 174 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 175 | GCC_C_LANGUAGE_STANDARD = gnu17; 176 | GCC_DYNAMIC_NO_PIC = NO; 177 | GCC_NO_COMMON_BLOCKS = YES; 178 | GCC_OPTIMIZATION_LEVEL = 0; 179 | GCC_PREPROCESSOR_DEFINITIONS = ( 180 | "DEBUG=1", 181 | "$(inherited)", 182 | ); 183 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 184 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 185 | GCC_WARN_UNDECLARED_SELECTOR = YES; 186 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 187 | GCC_WARN_UNUSED_FUNCTION = YES; 188 | GCC_WARN_UNUSED_VARIABLE = YES; 189 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 190 | MACOSX_DEPLOYMENT_TARGET = 15.1; 191 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 192 | MTL_FAST_MATH = YES; 193 | ONLY_ACTIVE_ARCH = YES; 194 | SDKROOT = macosx; 195 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 196 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 197 | }; 198 | name = Debug; 199 | }; 200 | CAF9CB3C2D67FE0500E87790 /* Release */ = { 201 | isa = XCBuildConfiguration; 202 | buildSettings = { 203 | ALWAYS_SEARCH_USER_PATHS = NO; 204 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 205 | CLANG_ANALYZER_NONNULL = YES; 206 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 207 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 208 | CLANG_ENABLE_MODULES = YES; 209 | CLANG_ENABLE_OBJC_ARC = YES; 210 | CLANG_ENABLE_OBJC_WEAK = YES; 211 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 212 | CLANG_WARN_BOOL_CONVERSION = YES; 213 | CLANG_WARN_COMMA = YES; 214 | CLANG_WARN_CONSTANT_CONVERSION = YES; 215 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 216 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 217 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 218 | CLANG_WARN_EMPTY_BODY = YES; 219 | CLANG_WARN_ENUM_CONVERSION = YES; 220 | CLANG_WARN_INFINITE_RECURSION = YES; 221 | CLANG_WARN_INT_CONVERSION = YES; 222 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 223 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 224 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 225 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 226 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 227 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 228 | CLANG_WARN_STRICT_PROTOTYPES = YES; 229 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 230 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 231 | CLANG_WARN_UNREACHABLE_CODE = YES; 232 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 233 | COPY_PHASE_STRIP = NO; 234 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 235 | ENABLE_NS_ASSERTIONS = NO; 236 | ENABLE_STRICT_OBJC_MSGSEND = YES; 237 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 238 | GCC_C_LANGUAGE_STANDARD = gnu17; 239 | GCC_NO_COMMON_BLOCKS = YES; 240 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 241 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 242 | GCC_WARN_UNDECLARED_SELECTOR = YES; 243 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 244 | GCC_WARN_UNUSED_FUNCTION = YES; 245 | GCC_WARN_UNUSED_VARIABLE = YES; 246 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 247 | MACOSX_DEPLOYMENT_TARGET = 15.1; 248 | MTL_ENABLE_DEBUG_INFO = NO; 249 | MTL_FAST_MATH = YES; 250 | SDKROOT = macosx; 251 | SWIFT_COMPILATION_MODE = wholemodule; 252 | }; 253 | name = Release; 254 | }; 255 | CAF9CB3E2D67FE0500E87790 /* Debug */ = { 256 | isa = XCBuildConfiguration; 257 | buildSettings = { 258 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 259 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 260 | CODE_SIGN_ENTITLEMENTS = QuickPush/QuickPush.entitlements; 261 | CODE_SIGN_STYLE = Automatic; 262 | COMBINE_HIDPI_IMAGES = YES; 263 | CURRENT_PROJECT_VERSION = 1; 264 | DEVELOPMENT_ASSET_PATHS = "\"QuickPush/Preview Content\""; 265 | DEVELOPMENT_TEAM = T2A8YY9YDW; 266 | ENABLE_HARDENED_RUNTIME = YES; 267 | ENABLE_PREVIEWS = YES; 268 | GENERATE_INFOPLIST_FILE = YES; 269 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; 270 | INFOPLIST_KEY_LSUIElement = YES; 271 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 272 | LD_RUNPATH_SEARCH_PATHS = ( 273 | "$(inherited)", 274 | "@executable_path/../Frameworks", 275 | ); 276 | MACOSX_DEPLOYMENT_TARGET = 14.6; 277 | MARKETING_VERSION = 1.1.1; 278 | PRODUCT_BUNDLE_IDENTIFIER = dev.codewithbeto.QuickPush; 279 | PRODUCT_NAME = "$(TARGET_NAME)"; 280 | SWIFT_EMIT_LOC_STRINGS = YES; 281 | SWIFT_VERSION = 5.0; 282 | }; 283 | name = Debug; 284 | }; 285 | CAF9CB3F2D67FE0500E87790 /* Release */ = { 286 | isa = XCBuildConfiguration; 287 | buildSettings = { 288 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 289 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 290 | CODE_SIGN_ENTITLEMENTS = QuickPush/QuickPush.entitlements; 291 | CODE_SIGN_STYLE = Automatic; 292 | COMBINE_HIDPI_IMAGES = YES; 293 | CURRENT_PROJECT_VERSION = 1; 294 | DEVELOPMENT_ASSET_PATHS = "\"QuickPush/Preview Content\""; 295 | DEVELOPMENT_TEAM = T2A8YY9YDW; 296 | ENABLE_HARDENED_RUNTIME = YES; 297 | ENABLE_PREVIEWS = YES; 298 | GENERATE_INFOPLIST_FILE = YES; 299 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; 300 | INFOPLIST_KEY_LSUIElement = YES; 301 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 302 | LD_RUNPATH_SEARCH_PATHS = ( 303 | "$(inherited)", 304 | "@executable_path/../Frameworks", 305 | ); 306 | MACOSX_DEPLOYMENT_TARGET = 14.6; 307 | MARKETING_VERSION = 1.1.1; 308 | PRODUCT_BUNDLE_IDENTIFIER = dev.codewithbeto.QuickPush; 309 | PRODUCT_NAME = "$(TARGET_NAME)"; 310 | SWIFT_EMIT_LOC_STRINGS = YES; 311 | SWIFT_VERSION = 5.0; 312 | }; 313 | name = Release; 314 | }; 315 | /* End XCBuildConfiguration section */ 316 | 317 | /* Begin XCConfigurationList section */ 318 | CAF9CB292D67FE0400E87790 /* Build configuration list for PBXProject "QuickPush" */ = { 319 | isa = XCConfigurationList; 320 | buildConfigurations = ( 321 | CAF9CB3B2D67FE0500E87790 /* Debug */, 322 | CAF9CB3C2D67FE0500E87790 /* Release */, 323 | ); 324 | defaultConfigurationIsVisible = 0; 325 | defaultConfigurationName = Release; 326 | }; 327 | CAF9CB3D2D67FE0500E87790 /* Build configuration list for PBXNativeTarget "QuickPush" */ = { 328 | isa = XCConfigurationList; 329 | buildConfigurations = ( 330 | CAF9CB3E2D67FE0500E87790 /* Debug */, 331 | CAF9CB3F2D67FE0500E87790 /* Release */, 332 | ); 333 | defaultConfigurationIsVisible = 0; 334 | defaultConfigurationName = Release; 335 | }; 336 | /* End XCConfigurationList section */ 337 | 338 | /* Begin XCRemoteSwiftPackageReference section */ 339 | CAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = { 340 | isa = XCRemoteSwiftPackageReference; 341 | repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin-Modern.git"; 342 | requirement = { 343 | branch = main; 344 | kind = branch; 345 | }; 346 | }; 347 | /* End XCRemoteSwiftPackageReference section */ 348 | 349 | /* Begin XCSwiftPackageProductDependency section */ 350 | CAF9CB4E2D6824C800E87790 /* LaunchAtLogin */ = { 351 | isa = XCSwiftPackageProductDependency; 352 | package = CAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */; 353 | productName = LaunchAtLogin; 354 | }; 355 | /* End XCSwiftPackageProductDependency section */ 356 | }; 357 | rootObject = CAF9CB262D67FE0400E87790 /* Project object */; 358 | } 359 | -------------------------------------------------------------------------------- /QuickPush/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // QuickPush 4 | // 5 | // Created by beto on 2/20/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | @State private var tokens: [String] = [""] 12 | @State private var accessToken: String = "" 13 | @State private var title: String = "" 14 | @State private var notificationBody: String = "" 15 | @State private var sound: String = "default" 16 | @State private var priority: PushNotification.Priority = .default 17 | @State private var ttl: String = "" 18 | @State private var expiration: String = "" 19 | @State private var data: [String: String] = [:] 20 | @State private var showTitleError: Bool = false 21 | 22 | // New state variables for advanced fields 23 | @State private var showAdvancedSettings: Bool = false 24 | @State private var subtitle: String = "" 25 | @State private var badge: String = "" 26 | @State private var interruptionLevel: PushNotification.InterruptionLevel = .active 27 | @State private var channelId: String = "" 28 | @State private var categoryId: String = "" 29 | @State private var mutableContent: Bool = false 30 | @State private var contentAvailable: Bool = false 31 | 32 | // Toast notification state 33 | @State private var showToast: Bool = false 34 | @State private var toastMessage: String = "" 35 | @State private var toastType: ToastType = .success 36 | 37 | var body: some View { 38 | VStack(spacing: 16) { 39 | // Title and Send Button 40 | HStack { 41 | Text("QuickPush") 42 | .font(.headline) 43 | Spacer() 44 | Button("Send Push") { 45 | sendPushNotification() 46 | } 47 | .buttonStyle(.borderedProminent) 48 | .disabled(tokens.filter { !$0.isEmpty }.isEmpty) // Disable if no valid tokens 49 | } 50 | 51 | // Main Content 52 | ScrollView(.vertical, showsIndicators: false) { 53 | VStack(alignment: .leading, spacing: 16) { 54 | // Basic Fields Section 55 | VStack(alignment: .leading, spacing: 12) { 56 | // Tokens Section 57 | Text("Expo Push Tokens:") 58 | .font(.subheadline) 59 | 60 | ForEach(tokens.indices, id: \.self) { index in 61 | HStack { 62 | TextField("e.g. ExponentPushToken[N1QHiEF4mnLGP8HeQrj9AR]", text: $tokens[index]) 63 | .textFieldStyle(RoundedBorderTextFieldStyle()) 64 | 65 | Button(action: { 66 | if let clipboardString = NSPasteboard.general.string(forType: .string) { 67 | // Valid token length is 41 characters (ExponentPushToken[xxxxxxxxxxxxxxxxxxxxx]) 68 | let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines) 69 | if trimmed.count <= 41 && trimmed.starts(with: "ExponentPushToken[") && trimmed.hasSuffix("]") { 70 | tokens[index] = trimmed 71 | } else { 72 | showToast(message: "Token should be in format: ExponentPushToken[xxxxxxxxxxxxxxxxxxxxx]", type: .error) 73 | } 74 | } 75 | }) { 76 | Image(systemName: "doc.on.clipboard") 77 | .foregroundColor(.secondary) 78 | } 79 | .buttonStyle(.plain) 80 | .help("Paste from clipboard (ExponentPushToken[xxxxxxxxxxxxxxxxxxxxx])") 81 | 82 | if tokens.count > 1 { 83 | Button(action: { tokens.remove(at: index) }) { 84 | Image(systemName: "minus.circle.fill") 85 | .foregroundColor(.red) 86 | } 87 | .buttonStyle(PlainButtonStyle()) 88 | } 89 | } 90 | } 91 | 92 | Button(action: { tokens.append("") }) { 93 | HStack { 94 | Image(systemName: "plus.circle.fill") 95 | Text("Add Token") 96 | } 97 | } 98 | .buttonStyle(.borderless) 99 | .padding(.top, 5) 100 | 101 | // Access Token Section 102 | VStack(alignment: .leading, spacing: 8) { 103 | HStack { 104 | Text("Access Token (Optional):") 105 | .font(.subheadline) 106 | HelpButton(helpText: "Enhanced push security token. Required if you've enabled push security in your EAS Dashboard.") 107 | } 108 | 109 | HStack { 110 | SecureField("Access token for enhanced security", text: $accessToken) 111 | .textFieldStyle(RoundedBorderTextFieldStyle()) 112 | 113 | Button(action: { 114 | if let clipboardString = NSPasteboard.general.string(forType: .string) { 115 | let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines) 116 | accessToken = trimmed 117 | } 118 | }) { 119 | Image(systemName: "doc.on.clipboard") 120 | .foregroundColor(.secondary) 121 | } 122 | .buttonStyle(.plain) 123 | .help("Paste access token from clipboard") 124 | } 125 | } 126 | 127 | Divider() 128 | 129 | // Basic Notification Fields 130 | VStack(alignment: .leading, spacing: 8) { 131 | InputField(label: "Title", text: $title, helpText: "Title of the notification", isRequired: true, showError: showTitleError) 132 | InputField(label: "Body", text: $notificationBody, helpText: "Message content displayed in the notification") 133 | 134 | // Priority Picker 135 | HStack { 136 | Text("Priority:") 137 | Picker("", selection: $priority) { 138 | Text("Default").tag(PushNotification.Priority.default) 139 | Text("Normal").tag(PushNotification.Priority.normal) 140 | Text("High").tag(PushNotification.Priority.high) 141 | } 142 | .pickerStyle(SegmentedPickerStyle()) 143 | 144 | HelpButton(helpText: "Affects delivery timing. 'High' wakes sleeping devices.") 145 | } 146 | 147 | Divider() 148 | KeyValueInputView(data: $data) 149 | } 150 | } 151 | 152 | // Advanced Settings Toggle 153 | Button(action: { showAdvancedSettings.toggle() }) { 154 | HStack { 155 | Text("Advanced Settings") 156 | .font(.subheadline) 157 | Spacer() 158 | Image(systemName: showAdvancedSettings ? "chevron.up" : "chevron.down") 159 | } 160 | } 161 | .buttonStyle(.borderless) 162 | 163 | if showAdvancedSettings { 164 | VStack(alignment: .leading, spacing: 12) { 165 | // iOS Specific Settings 166 | Group { 167 | Text("iOS Specific") 168 | .font(.subheadline) 169 | .foregroundColor(.secondary) 170 | 171 | InputField(label: "Sound", text: $sound, helpText: "Specify 'default' or custom sound name (iOS only)") 172 | InputField(label: "Subtitle", text: $subtitle, helpText: "Additional text below the title (iOS only)") 173 | InputField(label: "Badge", text: $badge, helpText: "Number to display on app icon (iOS only)") 174 | 175 | // Interruption Level Picker 176 | HStack { 177 | Text("Interruption Level:") 178 | Picker("", selection: $interruptionLevel) { 179 | Text("Active").tag(PushNotification.InterruptionLevel.active) 180 | Text("Critical").tag(PushNotification.InterruptionLevel.critical) 181 | Text("Passive").tag(PushNotification.InterruptionLevel.passive) 182 | Text("Time Sensitive").tag(PushNotification.InterruptionLevel.timeSensitive) 183 | } 184 | .pickerStyle(MenuPickerStyle()) 185 | 186 | HelpButton(helpText: "Controls the delivery timing and importance of the notification") 187 | } 188 | 189 | Toggle("Mutable Content", isOn: $mutableContent) 190 | .help("Allows notification content modification by the app") 191 | 192 | Toggle("Content Available", isOn: $contentAvailable) 193 | .help("Triggers background fetch on delivery") 194 | } 195 | 196 | Divider() 197 | 198 | // Android Specific Settings 199 | Group { 200 | Text("Android Specific") 201 | .font(.subheadline) 202 | .foregroundColor(.secondary) 203 | 204 | InputField(label: "Channel ID", text: $channelId, helpText: "Android notification channel identifier") 205 | } 206 | 207 | Divider() 208 | 209 | // Common Advanced Settings 210 | Group { 211 | Text("Common Settings") 212 | .font(.subheadline) 213 | .foregroundColor(.secondary) 214 | 215 | InputField(label: "Category ID", text: $categoryId, helpText: "Notification category for interactive notifications") 216 | InputField(label: "TTL", text: $ttl, helpText: "Time-to-live in seconds") 217 | InputField(label: "Expiration", text: $expiration, helpText: "Unix timestamp for expiration") 218 | } 219 | } 220 | } 221 | } 222 | } 223 | } 224 | .padding() 225 | .frame(minHeight: 410, maxHeight: showAdvancedSettings ? 650 : 410) 226 | .overlay( 227 | ToastView(message: toastMessage, type: toastType, isPresented: $showToast) 228 | .animation(.easeInOut, value: showToast) 229 | ) 230 | } 231 | 232 | private func sendPushNotification() { 233 | let validTokens = tokens.filter { !$0.isEmpty } 234 | 235 | guard !validTokens.isEmpty, !title.isEmpty else { 236 | showTitleError = title.isEmpty 237 | if title.isEmpty { 238 | showToast(message: "Title is required", type: .error) 239 | } 240 | return 241 | } 242 | 243 | showTitleError = false 244 | 245 | let notification = PushNotification( 246 | to: validTokens, 247 | title: title, 248 | body: notificationBody.isEmpty ? " " : notificationBody, 249 | data: data.isEmpty ? nil : data, 250 | ttl: Int(ttl), 251 | expiration: Int(expiration), 252 | priority: priority, 253 | subtitle: subtitle.isEmpty ? nil : subtitle, 254 | sound: sound.isEmpty ? nil : sound, 255 | badge: Int(badge), 256 | interruptionLevel: interruptionLevel, 257 | channelId: channelId.isEmpty ? nil : channelId, 258 | categoryId: categoryId.isEmpty ? nil : categoryId, 259 | mutableContent: mutableContent, 260 | contentAvailable: contentAvailable 261 | ) 262 | 263 | PushNotificationService.shared.sendPushNotification( 264 | notification: notification, 265 | accessToken: accessToken.isEmpty ? nil : accessToken 266 | ) { result in 267 | DispatchQueue.main.async { 268 | switch result { 269 | case .success(let response): 270 | print("Push sent successfully: \(response)") 271 | showToast(message: "Push notification sent successfully!", type: .success) 272 | case .failure(let error): 273 | print("Failed to send push: \(error.localizedDescription)") 274 | showToast(message: error.localizedDescription, type: .error) 275 | } 276 | } 277 | } 278 | } 279 | 280 | private func showToast(message: String, type: ToastType) { 281 | toastMessage = message 282 | toastType = type 283 | showToast = true 284 | } 285 | } 286 | 287 | // MARK: - Toast Types and View 288 | enum ToastType { 289 | case success 290 | case error 291 | 292 | var backgroundColor: Color { 293 | switch self { 294 | case .success: return Color.green.opacity(0.9) 295 | case .error: return Color.red.opacity(0.9) 296 | } 297 | } 298 | 299 | var icon: String { 300 | switch self { 301 | case .success: return "checkmark.circle.fill" 302 | case .error: return "exclamationmark.circle.fill" 303 | } 304 | } 305 | } 306 | 307 | struct ToastView: View { 308 | let message: String 309 | let type: ToastType 310 | @Binding var isPresented: Bool 311 | 312 | var body: some View { 313 | VStack { 314 | Spacer() 315 | if isPresented { 316 | HStack(spacing: 12) { 317 | Image(systemName: type.icon) 318 | Text(message) 319 | .foregroundColor(.white) 320 | } 321 | .padding() 322 | .background(type.backgroundColor) 323 | .cornerRadius(8) 324 | .padding(.bottom, 20) 325 | .onAppear { 326 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 327 | withAnimation { 328 | isPresented = false 329 | } 330 | } 331 | } 332 | } 333 | } 334 | } 335 | } 336 | 337 | // MARK: - Reusable InputField with Tooltip 338 | struct InputField: View { 339 | let label: String 340 | @Binding var text: String 341 | let helpText: String 342 | var isRequired: Bool = false 343 | var showError: Bool = false 344 | 345 | var body: some View { 346 | HStack { 347 | Text("\(label):") 348 | TextField(label, text: $text) 349 | .textFieldStyle(RoundedBorderTextFieldStyle()) 350 | .overlay( 351 | RoundedRectangle(cornerRadius: 6) 352 | .stroke(showError ? Color.red : Color.clear, lineWidth: 1) 353 | ) 354 | HelpButton(helpText: helpText) 355 | } 356 | } 357 | } 358 | 359 | // MARK: - Help Button with Popover 360 | struct HelpButton: View { 361 | let helpText: String 362 | @State private var showHelp = false 363 | 364 | var body: some View { 365 | Button(action: { showHelp.toggle() }) { 366 | Image(systemName: "questionmark.circle") 367 | .foregroundColor(.secondary) 368 | } 369 | .popover(isPresented: $showHelp) { 370 | Text(helpText) 371 | .padding() 372 | .frame(width: 250) 373 | } 374 | .buttonStyle(PlainButtonStyle()) 375 | } 376 | } 377 | #Preview { 378 | ContentView() 379 | } 380 | --------------------------------------------------------------------------------