├── .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 |
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 |
35 |
36 |
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 |
--------------------------------------------------------------------------------