[
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Code with Beto LLC\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "QuickPush/Assets.xcassets/AccentColor.colorset/Contents.json",
    "content": "{\n  \"colors\" : [\n    {\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "QuickPush/Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "QuickPush/Assets.xcassets/QuickPushIcon/Assets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "QuickPush/Assets.xcassets/QuickPushIcon/Assets/bolt.brakesignal 1.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"bolt.brakesignal 1.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "QuickPush/Assets.xcassets/QuickPushIcon/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "QuickPush/Assets.xcassets/QuickPushIcon/icon.dataset/Contents.json",
    "content": "{\n  \"data\" : [\n    {\n      \"filename\" : \"icon.json\",\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "QuickPush/Assets.xcassets/QuickPushIcon/icon.dataset/icon.json",
    "content": "{\n  \"fill\" : \"automatic\",\n  \"groups\" : [\n    {\n      \"layers\" : [\n        {\n          \"fill-specializations\" : [\n            {\n              \"appearance\" : \"dark\",\n              \"value\" : {\n                \"solid\" : \"extended-gray:1.00000,1.00000\"\n              }\n            }\n          ],\n          \"glass\" : true,\n          \"image-name\" : \"bolt.brakesignal 1.svg\",\n          \"name\" : \"bolt.brakesignal 1\",\n          \"position\" : {\n            \"scale\" : 6.81,\n            \"translation-in-points\" : [\n              0,\n              0\n            ]\n          }\n        }\n      ],\n      \"position\" : {\n        \"scale\" : 1,\n        \"translation-in-points\" : [\n          5,\n          0\n        ]\n      },\n      \"shadow\" : {\n        \"kind\" : \"neutral\",\n        \"opacity\" : 0.5\n      },\n      \"translucency\" : {\n        \"enabled\" : true,\n        \"value\" : 0.5\n      }\n    }\n  ],\n  \"supported-platforms\" : {\n    \"circles\" : [\n      \"watchOS\"\n    ],\n    \"squares\" : \"shared\"\n  }\n}"
  },
  {
    "path": "QuickPush/ContentView.swift",
    "content": "//\n//  ContentView.swift\n//  QuickPush\n//\n//  Created by beto on 2/20/25.\n//\n\nimport SwiftUI\n\nenum AppTab: String, CaseIterable {\n  case pushNotification = \"Expo Notification\"\n  case liveActivity = \"Live Activity\"\n  case apnsPush = \"APNs\"\n  case fcm = \"FCM\"\n\n  var icon: String {\n    switch self {\n    case .pushNotification: return \"bell.badge\"\n    case .liveActivity: return \"waveform\"\n    case .apnsPush: return \"antenna.radiowaves.left.and.right\"\n    case .fcm: return \"server.rack\"\n    }\n  }\n\n  /// Label shown in the segmented picker, embedding the ⌘N shortcut hint.\n  var tabLabel: String {\n    switch self {\n    case .pushNotification: return \"Expo Notification  ⌘1\"\n    case .liveActivity: return \"Live Activity  ⌘2\"\n    case .apnsPush: return \"APNs  ⌘3\"\n    case .fcm: return \"FCM  ⌘4\"\n    }\n  }\n}\n\nstruct ContentView: View {\n  @State private var selectedTab: AppTab = .pushNotification\n\n  var body: some View {\n    VStack(spacing: 0) {\n      // Tab Picker\n      Picker(\"\", selection: $selectedTab) {\n        ForEach(AppTab.allCases, id: \\.self) { tab in\n          Label(tab.tabLabel, systemImage: tab.icon).tag(tab)\n        }\n      }\n      .pickerStyle(.segmented)\n      .padding(.top, 12)\n      .padding(.bottom, 4)\n\n      // Tab Content — all views stay alive so @State is preserved\n      PushNotificationView(isActive: selectedTab == .pushNotification)\n        .opacity(selectedTab == .pushNotification ? 1 : 0)\n        .frame(height: selectedTab == .pushNotification ? nil : 0)\n        .allowsHitTesting(selectedTab == .pushNotification)\n\n      LiveActivityView(isActive: selectedTab == .liveActivity)\n        .opacity(selectedTab == .liveActivity ? 1 : 0)\n        .frame(height: selectedTab == .liveActivity ? nil : 0)\n        .allowsHitTesting(selectedTab == .liveActivity)\n\n      APNsView(isActive: selectedTab == .apnsPush)\n        .opacity(selectedTab == .apnsPush ? 1 : 0)\n        .frame(height: selectedTab == .apnsPush ? nil : 0)\n        .allowsHitTesting(selectedTab == .apnsPush)\n\n      FCMView(isActive: selectedTab == .fcm)\n        .opacity(selectedTab == .fcm ? 1 : 0)\n        .frame(height: selectedTab == .fcm ? nil : 0)\n        .allowsHitTesting(selectedTab == .fcm)\n    }\n    .frame(minHeight: 410)\n    // ⌘1/2/3/4 to switch tabs\n    .background(\n      Group {\n        Button(\"\") { selectedTab = .pushNotification }\n          .keyboardShortcut(\"1\", modifiers: .command)\n        Button(\"\") { selectedTab = .liveActivity }\n          .keyboardShortcut(\"2\", modifiers: .command)\n        Button(\"\") { selectedTab = .apnsPush }\n          .keyboardShortcut(\"3\", modifiers: .command)\n        Button(\"\") { selectedTab = .fcm }\n          .keyboardShortcut(\"4\", modifiers: .command)\n      }\n      .frame(width: 0, height: 0)\n      .opacity(0)\n    )\n  }\n}\n\n// MARK: - Toast Types and View\nenum ToastType {\n  case success\n  case error\n\n  var backgroundColor: Color {\n    switch self {\n    case .success: return Color.green.opacity(0.9)\n    case .error: return Color.red.opacity(0.9)\n    }\n  }\n\n  var icon: String {\n    switch self {\n    case .success: return \"checkmark.circle.fill\"\n    case .error: return \"exclamationmark.circle.fill\"\n    }\n  }\n}\n\nstruct ToastView: View {\n  let message: String\n  let type: ToastType\n  @Binding var isPresented: Bool\n\n  private var dismissDelay: TimeInterval {\n    type == .error ? 5 : 3\n  }\n\n  var body: some View {\n    VStack {\n      Spacer()\n      if isPresented {\n        HStack(spacing: 12) {\n          Image(systemName: type.icon)\n          Text(message)\n            .foregroundColor(.white)\n        }\n        .padding()\n        .background(type.backgroundColor)\n        .cornerRadius(8)\n        .padding(.bottom, 20)\n        .onAppear {\n          DispatchQueue.main.asyncAfter(deadline: .now() + dismissDelay) {\n            withAnimation {\n              isPresented = false\n            }\n          }\n        }\n      }\n    }\n  }\n}\n\n// MARK: - Reusable InputField with Tooltip\nstruct InputField: View {\n  let label: String\n  @Binding var text: String\n  let helpText: String\n  var isRequired: Bool = false\n  var showError: Bool = false\n\n  var body: some View {\n    HStack {\n      Text(\"\\(label):\")\n      TextField(label, text: $text)\n        .textFieldStyle(RoundedBorderTextFieldStyle())\n        .overlay(\n          RoundedRectangle(cornerRadius: 6)\n            .stroke(showError ? Color.red : Color.clear, lineWidth: 1)\n        )\n      HelpButton(helpText: helpText)\n    }\n  }\n}\n\n// MARK: - Help Button with Popover\nstruct HelpButton: View {\n  let helpText: String\n  @State private var showHelp = false\n\n  var body: some View {\n    Button(action: { showHelp.toggle() }) {\n      Image(systemName: \"questionmark.circle\")\n        .foregroundColor(.secondary)\n    }\n    .popover(isPresented: $showHelp) {\n      VStack(alignment: .leading, spacing: 8) {\n        Text(attributedHelpText)\n          .textSelection(.enabled)\n      }\n      .padding()\n      .frame(width: 300)\n    }\n    .buttonStyle(PlainButtonStyle())\n    .focusable(false)\n  }\n\n  private var attributedHelpText: AttributedString {\n    var attributedString = AttributedString(helpText)\n    // Find all URLs and make them clickable\n    var searchStart = helpText.startIndex\n    while let urlRange = helpText.range(of: \"https://[^\\\\s]+\", options: .regularExpression, range: searchStart..<helpText.endIndex),\n          let url = URL(string: String(helpText[urlRange])),\n          let attrRange = attributedString.range(of: String(helpText[urlRange])) {\n      attributedString[attrRange].link = url\n      attributedString[attrRange].foregroundColor = .blue\n      attributedString[attrRange].underlineStyle = .single\n      searchStart = urlRange.upperBound\n    }\n    return attributedString\n  }\n}\n\n#Preview {\n  ContentView()\n}\n"
  },
  {
    "path": "QuickPush/Controllers/APNsService.swift",
    "content": "//\n//  APNsService.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport Foundation\n\n/// Full diagnostic info returned after every APNs request.\nstruct APNsResponse {\n  let statusCode: Int\n  let reason: String?\n  let apnsId: String?\n  let apnsUniqueId: String?\n  let environment: APNsEnvironment\n  let topic: String\n  let timestamp: Int\n  let hostname: String\n  let attributesType: String?\n  let event: String\n\n  var isSuccess: Bool { statusCode == 200 }\n\n  /// Short summary shown in the toast.\n  var summary: String {\n    let envLabel = environment.rawValue.capitalized\n    if isSuccess {\n      return \"200 OK (\\(envLabel))\"\n    } else {\n      return \"\\(statusCode): \\(reason ?? \"Unknown\") (\\(envLabel))\"\n    }\n  }\n\n  /// Multi-line diagnostic block for the detail view.\n  var diagnosticDetails: String {\n    var lines: [String] = []\n    lines.append(\"Status:         \\(statusCode) \\(isSuccess ? \"OK\" : \"Error\")\")\n    if let reason { lines.append(\"Reason:         \\(reason)\") }\n    lines.append(\"Environment:    \\(environment.rawValue.capitalized) (\\(hostname))\")\n    lines.append(\"Topic:          \\(topic)\")\n    lines.append(\"Event:          \\(event)\")\n    if let attributesType { lines.append(\"Attributes Type: \\(attributesType)\") }\n    lines.append(\"Timestamp:      \\(timestamp) (Unix seconds)\")\n    if let apnsId { lines.append(\"apns-id:        \\(apnsId)\") }\n    if let apnsUniqueId { lines.append(\"apns-unique-id: \\(apnsUniqueId)\") }\n    return lines.joined(separator: \"\\n\")\n  }\n}\n\nclass APNsService {\n  static let shared = APNsService()\n\n  private var cachedToken: String?\n  private var tokenGeneratedAt: Date?\n  private let tokenLifetime: TimeInterval = 50 * 60 // 50 minutes (APNs allows 60)\n\n  enum APNsError: Error, LocalizedError {\n    case invalidConfiguration\n    case cannotReadP8File\n    case requestFailed(response: APNsResponse)\n    case networkError(String)\n\n    var errorDescription: String? {\n      switch self {\n      case .invalidConfiguration:\n        return \"APNs configuration is incomplete. Please fill in all fields.\"\n      case .cannotReadP8File:\n        return \"Cannot read .p8 key file. Please re-select the file.\"\n      case .requestFailed(let response):\n        return \"APNs error: \\(response.summary)\"\n      case .networkError(let message):\n        return \"Network error: \\(message)\"\n      }\n    }\n  }\n\n  func sendLiveActivityPush(\n    payload: LiveActivityAPNsPayload,\n    token: String,\n    configuration: APNsConfiguration,\n    completion: @escaping (Result<APNsResponse, Error>) -> Void\n  ) {\n    guard configuration.isValid else {\n      completion(.failure(APNsError.invalidConfiguration))\n      return\n    }\n\n    guard let p8Contents = configuration.p8Contents, !p8Contents.isEmpty else {\n      completion(.failure(APNsError.cannotReadP8File))\n      return\n    }\n\n    let jwt: String\n    do {\n      jwt = try getOrRefreshToken(\n        teamId: configuration.teamId,\n        keyId: configuration.keyId,\n        p8Contents: p8Contents\n      )\n    } catch {\n      completion(.failure(error))\n      return\n    }\n\n    let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines)\n    let urlString = \"https://\\(configuration.hostname)/3/device/\\(cleanToken)\"\n    guard let url = URL(string: urlString) else {\n      completion(.failure(APNsError.networkError(\"Invalid URL\")))\n      return\n    }\n\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"bearer \\(jwt)\", forHTTPHeaderField: \"authorization\")\n    request.setValue(configuration.topic, forHTTPHeaderField: \"apns-topic\")\n    request.setValue(\"liveactivity\", forHTTPHeaderField: \"apns-push-type\")\n    request.setValue(\"10\", forHTTPHeaderField: \"apns-priority\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"content-type\")\n\n    let payloadTimestamp = payload.aps.timestamp\n\n    do {\n      let encoder = JSONEncoder()\n      request.httpBody = try encoder.encode(payload)\n    } catch {\n      completion(.failure(error))\n      return\n    }\n\n    URLSession.shared.dataTask(with: request) { data, response, error in\n      if let error = error {\n        completion(.failure(APNsError.networkError(error.localizedDescription)))\n        return\n      }\n\n      guard let httpResponse = response as? HTTPURLResponse else {\n        completion(.failure(APNsError.networkError(\"Invalid response\")))\n        return\n      }\n\n      let apnsId = httpResponse.value(forHTTPHeaderField: \"apns-id\")\n      let apnsUniqueId = httpResponse.value(forHTTPHeaderField: \"apns-unique-id\")\n\n      var reason: String?\n      if let data = data,\n         let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n         let r = json[\"reason\"] as? String {\n        reason = r\n      }\n\n      let apnsResponse = APNsResponse(\n        statusCode: httpResponse.statusCode,\n        reason: reason,\n        apnsId: apnsId,\n        apnsUniqueId: apnsUniqueId,\n        environment: configuration.environment,\n        topic: configuration.topic,\n        timestamp: payloadTimestamp,\n        hostname: configuration.hostname,\n        attributesType: payload.aps.attributesType,\n        event: payload.aps.event.rawValue\n      )\n\n      if httpResponse.statusCode == 200 {\n        completion(.success(apnsResponse))\n      } else {\n        completion(.failure(APNsError.requestFailed(response: apnsResponse)))\n      }\n    }.resume()\n  }\n\n  func sendNativePush(\n    payload: NativePushPayload,\n    token: String,\n    pushType: NativePushType,\n    priority: Int,\n    configuration: APNsConfiguration,\n    completion: @escaping (Result<APNsResponse, Error>) -> Void\n  ) {\n    guard configuration.isValid else {\n      completion(.failure(APNsError.invalidConfiguration))\n      return\n    }\n\n    guard let p8Contents = configuration.p8Contents, !p8Contents.isEmpty else {\n      completion(.failure(APNsError.cannotReadP8File))\n      return\n    }\n\n    let jwt: String\n    do {\n      jwt = try getOrRefreshToken(\n        teamId: configuration.teamId,\n        keyId: configuration.keyId,\n        p8Contents: p8Contents\n      )\n    } catch {\n      completion(.failure(error))\n      return\n    }\n\n    let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines)\n    let urlString = \"https://\\(configuration.hostname)/3/device/\\(cleanToken)\"\n    guard let url = URL(string: urlString) else {\n      completion(.failure(APNsError.networkError(\"Invalid URL\")))\n      return\n    }\n\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"bearer \\(jwt)\", forHTTPHeaderField: \"authorization\")\n    request.setValue(configuration.topic, forHTTPHeaderField: \"apns-topic\")\n    request.setValue(pushType.rawValue, forHTTPHeaderField: \"apns-push-type\")\n    request.setValue(String(priority), forHTTPHeaderField: \"apns-priority\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"content-type\")\n\n    let timestamp = Int(Date().timeIntervalSince1970)\n\n    do {\n      let encoder = JSONEncoder()\n      request.httpBody = try encoder.encode(payload)\n    } catch {\n      completion(.failure(error))\n      return\n    }\n\n    URLSession.shared.dataTask(with: request) { data, response, error in\n      if let error = error {\n        completion(.failure(APNsError.networkError(error.localizedDescription)))\n        return\n      }\n\n      guard let httpResponse = response as? HTTPURLResponse else {\n        completion(.failure(APNsError.networkError(\"Invalid response\")))\n        return\n      }\n\n      let apnsId = httpResponse.value(forHTTPHeaderField: \"apns-id\")\n      let apnsUniqueId = httpResponse.value(forHTTPHeaderField: \"apns-unique-id\")\n\n      var reason: String?\n      if let data = data,\n         let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n         let r = json[\"reason\"] as? String {\n        reason = r\n      }\n\n      let apnsResponse = APNsResponse(\n        statusCode: httpResponse.statusCode,\n        reason: reason,\n        apnsId: apnsId,\n        apnsUniqueId: apnsUniqueId,\n        environment: configuration.environment,\n        topic: configuration.topic,\n        timestamp: timestamp,\n        hostname: configuration.hostname,\n        attributesType: nil,\n        event: pushType.rawValue\n      )\n\n      if httpResponse.statusCode == 200 {\n        completion(.success(apnsResponse))\n      } else {\n        completion(.failure(APNsError.requestFailed(response: apnsResponse)))\n      }\n    }.resume()\n  }\n\n  private func getOrRefreshToken(teamId: String, keyId: String, p8Contents: String) throws -> String {\n    if let cached = cachedToken,\n       let generatedAt = tokenGeneratedAt,\n       Date().timeIntervalSince(generatedAt) < tokenLifetime {\n      return cached\n    }\n\n    let token = try JWTSigner.generateToken(teamId: teamId, keyId: keyId, p8Contents: p8Contents)\n    cachedToken = token\n    tokenGeneratedAt = Date()\n    return token\n  }\n\n  func invalidateToken() {\n    cachedToken = nil\n    tokenGeneratedAt = nil\n  }\n}\n"
  },
  {
    "path": "QuickPush/Controllers/FCMService.swift",
    "content": "//\n//  FCMService.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\nimport Security\n\n/// Full diagnostic info returned after every FCM request.\nstruct FCMResponse {\n  let statusCode: Int\n  let messageId: String?\n  let errorCode: String?\n  let errorMessage: String?\n  let projectId: String\n  let token: String\n  let timestamp: Int\n\n  var isSuccess: Bool { statusCode == 200 }\n\n  var summary: String {\n    if isSuccess {\n      return \"200 OK\"\n    } else {\n      return \"\\(statusCode): \\(errorCode ?? errorMessage ?? \"Unknown\")\"\n    }\n  }\n\n  var diagnosticDetails: String {\n    var lines: [String] = []\n    lines.append(\"Status:      \\(statusCode) \\(isSuccess ? \"OK\" : \"Error\")\")\n    if let messageId { lines.append(\"Message ID:  \\(messageId)\") }\n    if let errorCode { lines.append(\"Error Code:  \\(errorCode)\") }\n    if let errorMessage { lines.append(\"Error Msg:   \\(errorMessage)\") }\n    lines.append(\"Project ID:  \\(projectId)\")\n    lines.append(\"Token:       \\(token.prefix(20))…\")\n    lines.append(\"Timestamp:   \\(timestamp) (Unix seconds)\")\n    return lines.joined(separator: \"\\n\")\n  }\n}\n\nclass FCMService {\n  static let shared = FCMService()\n\n  private var cachedAccessToken: String?\n  private var tokenExpiresAt: Date?\n  private let tokenLifetime: TimeInterval = 55 * 60 // 55 min (Google tokens valid 60 min)\n\n  enum FCMError: Error, LocalizedError {\n    case invalidConfiguration\n    case cannotReadServiceAccount\n    case invalidPrivateKey\n    case signingFailed\n    case oauthFailed(String)\n    case requestFailed(response: FCMResponse)\n    case networkError(String)\n\n    var errorDescription: String? {\n      switch self {\n      case .invalidConfiguration:\n        return \"FCM configuration is incomplete. Please fill in all fields.\"\n      case .cannotReadServiceAccount:\n        return \"Cannot read service account JSON. Please re-select the file.\"\n      case .invalidPrivateKey:\n        return \"Invalid private key in service account JSON.\"\n      case .signingFailed:\n        return \"Failed to sign OAuth JWT.\"\n      case .oauthFailed(let msg):\n        return \"OAuth token exchange failed: \\(msg)\"\n      case .requestFailed(let response):\n        return \"FCM error: \\(response.summary)\"\n      case .networkError(let message):\n        return \"Network error: \\(message)\"\n      }\n    }\n  }\n\n  // MARK: - Send\n\n  func send(\n    message: FCMMessage,\n    configuration: FCMConfiguration,\n    completion: @escaping (Result<FCMResponse, Error>) -> Void\n  ) {\n    guard configuration.isValid else {\n      completion(.failure(FCMError.invalidConfiguration))\n      return\n    }\n    guard let serviceAccountContents = configuration.serviceAccountContents,\n          !serviceAccountContents.isEmpty else {\n      completion(.failure(FCMError.cannotReadServiceAccount))\n      return\n    }\n\n    getOrRefreshAccessToken(serviceAccountContents: serviceAccountContents, clientEmail: configuration.clientEmail) { result in\n      switch result {\n      case .failure(let error):\n        completion(.failure(error))\n      case .success(let accessToken):\n        self.sendWithToken(\n          accessToken: accessToken,\n          message: message,\n          projectId: configuration.projectId,\n          completion: completion\n        )\n      }\n    }\n  }\n\n  private func sendWithToken(\n    accessToken: String,\n    message: FCMMessage,\n    projectId: String,\n    completion: @escaping (Result<FCMResponse, Error>) -> Void\n  ) {\n    let urlString = \"https://fcm.googleapis.com/v1/projects/\\(projectId)/messages:send\"\n    guard let url = URL(string: urlString) else {\n      completion(.failure(FCMError.networkError(\"Invalid FCM URL\")))\n      return\n    }\n\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"Bearer \\(accessToken)\", forHTTPHeaderField: \"Authorization\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\n\n    let body = FCMRequestBody(message: message)\n    let encoder = JSONEncoder()\n    do {\n      request.httpBody = try encoder.encode(body)\n    } catch {\n      completion(.failure(error))\n      return\n    }\n\n    let timestamp = Int(Date().timeIntervalSince1970)\n\n    URLSession.shared.dataTask(with: request) { data, response, error in\n      if let error = error {\n        completion(.failure(FCMError.networkError(error.localizedDescription)))\n        return\n      }\n      guard let httpResponse = response as? HTTPURLResponse else {\n        completion(.failure(FCMError.networkError(\"Invalid response\")))\n        return\n      }\n\n      var messageId: String?\n      var errorCode: String?\n      var errorMessage: String?\n\n      if let data = data,\n         let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {\n        messageId = json[\"name\"] as? String\n        if let errorObj = json[\"error\"] as? [String: Any] {\n          errorCode = errorObj[\"status\"] as? String\n          errorMessage = errorObj[\"message\"] as? String\n          // Also check nested details for FCM-specific error codes\n          if errorCode == nil,\n             let details = errorObj[\"details\"] as? [[String: Any]] {\n            for detail in details {\n              if let code = detail[\"errorCode\"] as? String {\n                errorCode = code\n                break\n              }\n            }\n          }\n        }\n      }\n\n      let fcmResponse = FCMResponse(\n        statusCode: httpResponse.statusCode,\n        messageId: messageId,\n        errorCode: errorCode,\n        errorMessage: errorMessage,\n        projectId: projectId,\n        token: message.token,\n        timestamp: timestamp\n      )\n\n      if httpResponse.statusCode == 200 {\n        completion(.success(fcmResponse))\n      } else {\n        completion(.failure(FCMError.requestFailed(response: fcmResponse)))\n      }\n    }.resume()\n  }\n\n  // MARK: - OAuth Token\n\n  private func getOrRefreshAccessToken(\n    serviceAccountContents: String,\n    clientEmail: String,\n    completion: @escaping (Result<String, Error>) -> Void\n  ) {\n    if let token = cachedAccessToken,\n       let expiresAt = tokenExpiresAt,\n       Date() < expiresAt {\n      completion(.success(token))\n      return\n    }\n\n    do {\n      let jwt = try buildOAuthJWT(serviceAccountContents: serviceAccountContents, clientEmail: clientEmail)\n      exchangeJWTForAccessToken(jwt: jwt, completion: { result in\n        switch result {\n        case .success(let token):\n          self.cachedAccessToken = token\n          self.tokenExpiresAt = Date().addingTimeInterval(self.tokenLifetime)\n          completion(.success(token))\n        case .failure(let error):\n          completion(.failure(error))\n        }\n      })\n    } catch {\n      completion(.failure(error))\n    }\n  }\n\n  private func exchangeJWTForAccessToken(\n    jwt: String,\n    completion: @escaping (Result<String, Error>) -> Void\n  ) {\n    guard let url = URL(string: \"https://oauth2.googleapis.com/token\") else {\n      completion(.failure(FCMError.networkError(\"Invalid OAuth URL\")))\n      return\n    }\n\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"application/x-www-form-urlencoded\", forHTTPHeaderField: \"Content-Type\")\n\n    let body = \"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=\\(jwt)\"\n    request.httpBody = body.data(using: .utf8)\n\n    URLSession.shared.dataTask(with: request) { data, response, error in\n      if let error = error {\n        completion(.failure(FCMError.oauthFailed(error.localizedDescription)))\n        return\n      }\n      guard let data = data,\n            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n        completion(.failure(FCMError.oauthFailed(\"Invalid response\")))\n        return\n      }\n      if let token = json[\"access_token\"] as? String {\n        completion(.success(token))\n      } else {\n        let errDesc = (json[\"error_description\"] as? String) ?? (json[\"error\"] as? String) ?? \"Unknown OAuth error\"\n        completion(.failure(FCMError.oauthFailed(errDesc)))\n      }\n    }.resume()\n  }\n\n  // MARK: - RS256 JWT for OAuth\n\n  private func buildOAuthJWT(serviceAccountContents: String, clientEmail: String) throws -> String {\n    guard let privateKeyPEM = FCMFileManager.parsePrivateKey(from: serviceAccountContents) else {\n      throw FCMError.cannotReadServiceAccount\n    }\n\n    let privateKey = try loadRSAPrivateKey(pem: privateKeyPEM)\n\n    let header = [\"alg\": \"RS256\", \"typ\": \"JWT\"]\n    let now = Int(Date().timeIntervalSince1970)\n    let claims: [String: Any] = [\n      \"iss\": clientEmail,\n      \"sub\": clientEmail,\n      \"aud\": \"https://oauth2.googleapis.com/token\",\n      \"scope\": \"https://www.googleapis.com/auth/firebase.messaging\",\n      \"iat\": now,\n      \"exp\": now + 3600\n    ]\n\n    let headerData = try JSONSerialization.data(withJSONObject: header)\n    let claimsData = try JSONSerialization.data(withJSONObject: claims)\n\n    let headerB64 = base64urlEncode(headerData)\n    let claimsB64 = base64urlEncode(claimsData)\n    let signingInput = \"\\(headerB64).\\(claimsB64)\"\n\n    guard let signingData = signingInput.data(using: .utf8) else {\n      throw FCMError.signingFailed\n    }\n\n    var error: Unmanaged<CFError>?\n    guard let signatureData = SecKeyCreateSignature(\n      privateKey,\n      .rsaSignatureMessagePKCS1v15SHA256,\n      signingData as CFData,\n      &error\n    ) as Data? else {\n      throw FCMError.signingFailed\n    }\n\n    let signatureB64 = base64urlEncode(signatureData)\n    return \"\\(signingInput).\\(signatureB64)\"\n  }\n\n  // MARK: - RSA Key Loading (PKCS#8 → PKCS#1)\n\n  private func loadRSAPrivateKey(pem: String) throws -> SecKey {\n    // Strip PEM headers and decode base64\n    let lines = pem.components(separatedBy: \"\\n\")\n    let keyLines = lines.filter { !$0.hasPrefix(\"-----\") && !$0.isEmpty }\n    let base64Key = keyLines.joined()\n    guard let pkcs8DER = Data(base64Encoded: base64Key) else {\n      throw FCMError.invalidPrivateKey\n    }\n\n    // Google service accounts use PKCS#8 format. SecKey requires PKCS#1.\n    // We must strip the PKCS#8 wrapper to get the inner PKCS#1 RSAPrivateKey.\n    let pkcs1DER = try extractPKCS1FromPKCS8(pkcs8DER)\n\n    let attributes: [String: Any] = [\n      kSecAttrKeyType as String: kSecAttrKeyTypeRSA,\n      kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,\n      kSecAttrKeySizeInBits as String: 2048\n    ]\n\n    var error: Unmanaged<CFError>?\n    guard let secKey = SecKeyCreateWithData(pkcs1DER as CFData, attributes as CFDictionary, &error) else {\n      throw FCMError.invalidPrivateKey\n    }\n    return secKey\n  }\n\n  /// Strips the PKCS#8 AlgorithmIdentifier wrapper to extract the inner PKCS#1 RSAPrivateKey bytes.\n  ///\n  /// PKCS#8 DER layout:\n  ///   SEQUENCE {\n  ///     INTEGER (version = 0)\n  ///     SEQUENCE { OID(rsaEncryption), NULL }   ← algorithm identifier\n  ///     OCTET STRING { <PKCS#1 RSAPrivateKey> }\n  ///   }\n  private func extractPKCS1FromPKCS8(_ der: Data) throws -> Data {\n    let bytes = Array(der)\n    var index = 0\n\n    // Helper to skip a DER length field and return the content length.\n    func readLength() throws -> Int {\n      guard index < bytes.count else { throw FCMError.invalidPrivateKey }\n      if bytes[index] & 0x80 == 0 {\n        let len = Int(bytes[index]); index += 1; return len\n      }\n      let numBytes = Int(bytes[index] & 0x7F); index += 1\n      guard index + numBytes <= bytes.count else { throw FCMError.invalidPrivateKey }\n      var len = 0\n      for _ in 0..<numBytes { len = (len << 8) | Int(bytes[index]); index += 1 }\n      return len\n    }\n\n    // Outer SEQUENCE\n    guard index < bytes.count, bytes[index] == 0x30 else { throw FCMError.invalidPrivateKey }\n    index += 1\n    _ = try readLength() // skip outer SEQUENCE length\n\n    // version INTEGER (0)\n    guard index < bytes.count, bytes[index] == 0x02 else { throw FCMError.invalidPrivateKey }\n    index += 1\n    let versionLen = try readLength()\n    index += versionLen\n\n    // AlgorithmIdentifier SEQUENCE — skip it\n    guard index < bytes.count, bytes[index] == 0x30 else { throw FCMError.invalidPrivateKey }\n    index += 1\n    let algoLen = try readLength()\n    index += algoLen\n\n    // OCTET STRING containing the PKCS#1 key\n    guard index < bytes.count, bytes[index] == 0x04 else { throw FCMError.invalidPrivateKey }\n    index += 1\n    let octetLen = try readLength()\n\n    guard index + octetLen <= bytes.count else { throw FCMError.invalidPrivateKey }\n    return Data(bytes[index..<(index + octetLen)])\n  }\n\n  // MARK: - Helpers\n\n  private func base64urlEncode(_ data: Data) -> String {\n    data.base64EncodedString()\n      .replacingOccurrences(of: \"+\", with: \"-\")\n      .replacingOccurrences(of: \"/\", with: \"_\")\n      .replacingOccurrences(of: \"=\", with: \"\")\n  }\n\n  func invalidateToken() {\n    cachedAccessToken = nil\n    tokenExpiresAt = nil\n  }\n}\n"
  },
  {
    "path": "QuickPush/Controllers/JWTSigner.swift",
    "content": "//\n//  JWTSigner.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport Foundation\nimport CryptoKit\n\nclass JWTSigner {\n  enum JWTError: Error, LocalizedError {\n    case invalidP8File\n    case signingFailed\n    case invalidKeyData\n\n    var errorDescription: String? {\n      switch self {\n      case .invalidP8File: return \"Invalid .p8 key file\"\n      case .signingFailed: return \"Failed to sign JWT token\"\n      case .invalidKeyData: return \"Invalid key data in .p8 file\"\n      }\n    }\n  }\n\n  static func generateToken(teamId: String, keyId: String, p8Contents: String) throws -> String {\n    let privateKey = try parseP8Key(p8Contents)\n\n    let header = [\n      \"alg\": \"ES256\",\n      \"kid\": keyId,\n      \"typ\": \"JWT\"\n    ]\n\n    let now = Int(Date().timeIntervalSince1970)\n    let claims: [String: Any] = [\n      \"iss\": teamId,\n      \"iat\": now\n    ]\n\n    let headerData = try JSONSerialization.data(withJSONObject: header)\n    let claimsData = try JSONSerialization.data(withJSONObject: claims)\n\n    let headerBase64 = base64urlEncode(headerData)\n    let claimsBase64 = base64urlEncode(claimsData)\n\n    let signingInput = \"\\(headerBase64).\\(claimsBase64)\"\n    guard let signingData = signingInput.data(using: .utf8) else {\n      throw JWTError.signingFailed\n    }\n\n    let signature = try privateKey.signature(for: signingData)\n    let signatureBase64 = base64urlEncode(signature.rawRepresentation)\n\n    return \"\\(signingInput).\\(signatureBase64)\"\n  }\n\n  private static func parseP8Key(_ p8Contents: String) throws -> P256.Signing.PrivateKey {\n    let lines = p8Contents.components(separatedBy: \"\\n\")\n    let keyLines = lines.filter { line in\n      !line.hasPrefix(\"-----\") && !line.isEmpty\n    }\n    let base64Key = keyLines.joined()\n\n    guard let keyData = Data(base64Encoded: base64Key) else {\n      throw JWTError.invalidKeyData\n    }\n\n    do {\n      return try P256.Signing.PrivateKey(derRepresentation: keyData)\n    } catch {\n      throw JWTError.invalidP8File\n    }\n  }\n\n  private static func base64urlEncode(_ data: Data) -> String {\n    data.base64EncodedString()\n      .replacingOccurrences(of: \"+\", with: \"-\")\n      .replacingOccurrences(of: \"/\", with: \"_\")\n      .replacingOccurrences(of: \"=\", with: \"\")\n  }\n}\n"
  },
  {
    "path": "QuickPush/Controllers/PushNotificationService.swift",
    "content": "//\n//  PushNotificationService.swift\n//  QuickPush\n//\n//  Created by beto on 2/20/25.\n//\n\nimport Foundation\n\nclass PushNotificationService {\n  static let shared = PushNotificationService() // Singleton instance\n\n  private let expoPushEndpoint = \"https://exp.host/--/api/v2/push/send\"\n  private let expoReceiptsEndpoint = \"https://exp.host/--/api/v2/push/getReceipts\"\n\n  /// Result containing the decoded response, HTTP status code, and raw JSON string.\n  struct SendResult {\n    let response: PushResponse\n    let httpStatusCode: Int?\n    let rawJSON: String?\n  }\n\n  struct ReceiptResult {\n    let response: ReceiptResponse\n    let httpStatusCode: Int?\n    let rawJSON: String?\n  }\n\n  func sendPushNotification(notification: PushNotification, accessToken: String? = nil, completion: @escaping (Result<SendResult, Error>) -> Void) {\n    guard let url = URL(string: expoPushEndpoint) else {\n      completion(.failure(APIError.invalidURL))\n      return\n    }\n\n    // Prepare request\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"exp.host\", forHTTPHeaderField: \"host\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"accept\")\n    request.setValue(\"gzip, deflate\", forHTTPHeaderField: \"accept-encoding\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"content-type\")\n\n    // Add authorization header if access token is provided\n    if let accessToken = accessToken, !accessToken.isEmpty {\n      request.setValue(\"Bearer \\(accessToken)\", forHTTPHeaderField: \"Authorization\")\n    }\n\n    do {\n      let jsonData = try JSONEncoder().encode(notification)\n      request.httpBody = jsonData\n    } catch {\n      completion(.failure(APIError.encodingFailed))\n      return\n    }\n\n    // Perform network request\n    URLSession.shared.dataTask(with: request) { data, response, error in\n      // Handle network errors\n      if let error = error {\n        completion(.failure(error))\n        return\n      }\n\n      let httpStatusCode = (response as? HTTPURLResponse)?.statusCode\n\n      // Ensure we have valid data\n      guard let data = data else {\n        completion(.failure(APIError.noData))\n        return\n      }\n\n      let rawJSON = Self.prettyPrint(data)\n\n      // Decode API response\n      do {\n        let responseObject = try JSONDecoder().decode(PushResponse.self, from: data)\n\n        // UNAUTHORIZED REQUESTS CHECK - Possibly no Access Token\n        if let errors = responseObject.errors,\n           errors.contains(where: { $0.code == \"UNAUTHORIZED\" }) {\n          completion(.failure(APIError.insufficientPermissions))\n          return\n        }\n\n        completion(.success(SendResult(response: responseObject, httpStatusCode: httpStatusCode, rawJSON: rawJSON)))\n      } catch {\n        completion(.failure(APIError.decodingFailed))\n      }\n    }.resume()\n  }\n\n  func checkReceipts(\n    ids: [String],\n    accessToken: String? = nil,\n    completion: @escaping (Result<ReceiptResult, Error>) -> Void\n  ) {\n    guard let url = URL(string: expoReceiptsEndpoint) else {\n      completion(.failure(APIError.invalidURL))\n      return\n    }\n\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"exp.host\", forHTTPHeaderField: \"host\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"accept\")\n    request.setValue(\"gzip, deflate\", forHTTPHeaderField: \"accept-encoding\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"content-type\")\n\n    if let accessToken = accessToken, !accessToken.isEmpty {\n      request.setValue(\"Bearer \\(accessToken)\", forHTTPHeaderField: \"Authorization\")\n    }\n\n    do {\n      let body = [\"ids\": ids]\n      request.httpBody = try JSONSerialization.data(withJSONObject: body)\n    } catch {\n      completion(.failure(APIError.encodingFailed))\n      return\n    }\n\n    URLSession.shared.dataTask(with: request) { data, response, error in\n      if let error = error {\n        completion(.failure(error))\n        return\n      }\n\n      let httpStatusCode = (response as? HTTPURLResponse)?.statusCode\n\n      guard let data = data else {\n        completion(.failure(APIError.noData))\n        return\n      }\n\n      let rawJSON = Self.prettyPrint(data)\n\n      do {\n        let responseObject = try JSONDecoder().decode(ReceiptResponse.self, from: data)\n        completion(.success(ReceiptResult(response: responseObject, httpStatusCode: httpStatusCode, rawJSON: rawJSON)))\n      } catch {\n        completion(.failure(APIError.decodingFailed))\n      }\n    }.resume()\n  }\n\n  /// Pretty-print raw JSON data for display.\n  private static func prettyPrint(_ data: Data) -> String? {\n    guard let json = try? JSONSerialization.jsonObject(with: data),\n          let pretty = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) else {\n      return String(data: data, encoding: .utf8)\n    }\n    return String(data: pretty, encoding: .utf8)\n  }\n}\n\n// MARK: - API Error Enum\nenum APIError: Error, LocalizedError {\n  case invalidURL\n  case encodingFailed\n  case noData\n  case decodingFailed\n  case insufficientPermissions\n  \n  var errorDescription: String? {\n    switch self {\n    case .invalidURL: return \"Invalid API URL\"\n    case .encodingFailed: return \"Failed to encode request data\"\n    case .noData: return \"No response data received\"\n    case .decodingFailed: return \"Failed to decode API response\"\n    case .insufficientPermissions: return \"Insufficient permissions. Push security may be enabled for this app - please provide a valid access token above.\"\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Models/APNsConfigStore.swift",
    "content": "//\n//  APNsConfigStore.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\n\n/// Shared APNs credentials store used by both the Live Activity and APNs tabs.\n@Observable\nclass APNsConfigStore {\n  static let shared = APNsConfigStore()\n\n  var teamId: String = \"\" {\n    didSet { UserDefaults.standard.set(teamId, forKey: \"apns_teamId\") }\n  }\n  var keyId: String = \"\" {\n    didSet { UserDefaults.standard.set(keyId, forKey: \"apns_keyId\") }\n  }\n  var bundleId: String = \"\" {\n    didSet { UserDefaults.standard.set(bundleId, forKey: \"apns_bundleId\") }\n  }\n  var p8FileName: String?\n  var hasP8Key: Bool = false\n  var environment: APNsEnvironment = .sandbox {\n    didSet { UserDefaults.standard.set(environment.rawValue, forKey: \"apns_environment\") }\n  }\n\n  init() {\n    teamId = UserDefaults.standard.string(forKey: \"apns_teamId\") ?? \"\"\n    keyId = UserDefaults.standard.string(forKey: \"apns_keyId\") ?? \"\"\n    bundleId = UserDefaults.standard.string(forKey: \"apns_bundleId\") ?? \"\"\n    p8FileName = SecurityBookmarkManager.shared.storedP8Filename()\n    hasP8Key = SecurityBookmarkManager.shared.storedP8Contents() != nil\n    if let envString = UserDefaults.standard.string(forKey: \"apns_environment\"),\n       let env = APNsEnvironment(rawValue: envString) {\n      environment = env\n    }\n  }\n\n  func selectP8File() {\n    SecurityBookmarkManager.shared.selectP8File { [weak self] contents, filename in\n      DispatchQueue.main.async {\n        guard let self else { return }\n        self.p8FileName = filename\n        self.hasP8Key = contents != nil\n      }\n    }\n  }\n\n  /// Build an `APNsConfiguration` with an optional topic suffix appended to bundleId.\n  /// - Parameter topicSuffix: e.g. `\".push-type.liveactivity\"` for Live Activity, `nil` for plain bundle ID.\n  func apnsConfiguration(topicSuffix: String?) -> APNsConfiguration {\n    let topicOverride: String? = topicSuffix.map { \"\\(bundleId)\\($0)\" }\n    return APNsConfiguration(\n      teamId: teamId,\n      keyId: keyId,\n      bundleId: bundleId,\n      p8Contents: SecurityBookmarkManager.shared.storedP8Contents(),\n      environment: environment,\n      topicOverride: topicOverride\n    )\n  }\n}\n"
  },
  {
    "path": "QuickPush/Models/APNsConfiguration.swift",
    "content": "//\n//  APNsConfiguration.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport Foundation\n\nstruct APNsConfiguration {\n  var teamId: String = \"\"\n  var keyId: String = \"\"\n  var bundleId: String = \"\"\n  var p8Contents: String?\n  var environment: APNsEnvironment = .sandbox\n  var topicOverride: String? = nil\n\n  var isValid: Bool {\n    !teamId.isEmpty && !keyId.isEmpty && !bundleId.isEmpty &&\n    p8Contents != nil && !p8Contents!.isEmpty\n  }\n\n  var hostname: String {\n    environment.hostname\n  }\n\n  var topic: String {\n    topicOverride ?? bundleId\n  }\n}\n\nenum APNsEnvironment: String, CaseIterable {\n  case sandbox\n  case production\n\n  var hostname: String {\n    switch self {\n    case .sandbox: return \"api.sandbox.push.apple.com\"\n    case .production: return \"api.push.apple.com\"\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Models/FCMConfigStore.swift",
    "content": "//\n//  FCMConfigStore.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\n\n/// Shared FCM credentials store used by the FCM tab.\n@Observable\nclass FCMConfigStore {\n  static let shared = FCMConfigStore()\n\n  var projectId: String = \"\" {\n    didSet { UserDefaults.standard.set(projectId, forKey: \"fcm_projectId\") }\n  }\n  var clientEmail: String = \"\" {\n    didSet { UserDefaults.standard.set(clientEmail, forKey: \"fcm_clientEmail\") }\n  }\n  var serviceAccountFilename: String?\n  var hasServiceAccount: Bool = false\n  var fileError: String?\n\n  init() {\n    projectId = UserDefaults.standard.string(forKey: \"fcm_projectId\") ?? \"\"\n    clientEmail = UserDefaults.standard.string(forKey: \"fcm_clientEmail\") ?? \"\"\n    serviceAccountFilename = FCMFileManager.shared.storedFilename()\n    hasServiceAccount = FCMFileManager.shared.storedContents() != nil\n  }\n\n  func selectServiceAccountFile() {\n    FCMFileManager.shared.selectServiceAccountFile { [weak self] result in\n      DispatchQueue.main.async {\n        guard let self else { return }\n        switch result {\n        case .success(let info):\n          self.fileError = nil\n          self.serviceAccountFilename = info.filename\n          self.hasServiceAccount = true\n          if let projectId = info.projectId, !projectId.isEmpty {\n            self.projectId = projectId\n          }\n          if let clientEmail = info.clientEmail, !clientEmail.isEmpty {\n            self.clientEmail = clientEmail\n          }\n        case .failure(let error):\n          self.fileError = error.localizedDescription\n        }\n      }\n    }\n  }\n\n  func fcmConfiguration() -> FCMConfiguration {\n    FCMConfiguration(\n      projectId: projectId,\n      clientEmail: clientEmail,\n      serviceAccountContents: FCMFileManager.shared.storedContents()\n    )\n  }\n}\n"
  },
  {
    "path": "QuickPush/Models/FCMConfiguration.swift",
    "content": "//\n//  FCMConfiguration.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\n\nstruct FCMConfiguration {\n  var projectId: String = \"\"\n  var clientEmail: String = \"\"\n  var serviceAccountContents: String?\n\n  var isValid: Bool {\n    !projectId.isEmpty && !clientEmail.isEmpty &&\n    serviceAccountContents != nil && !serviceAccountContents!.isEmpty\n  }\n}\n\nenum FCMMessageType: String, CaseIterable {\n  case notification = \"Notification\"\n  case data = \"Data\"\n}\n"
  },
  {
    "path": "QuickPush/Models/FCMPayload.swift",
    "content": "//\n//  FCMPayload.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\n\nstruct FCMMessage: Encodable {\n  var token: String\n  var notification: FCMNotification?\n  var android: FCMAndroidConfig?\n  var data: [String: String]?\n\n  enum CodingKeys: String, CodingKey {\n    case token, notification, android, data\n  }\n\n  func encode(to encoder: Encoder) throws {\n    var container = encoder.container(keyedBy: CodingKeys.self)\n    try container.encode(token, forKey: .token)\n    try container.encodeIfPresent(notification, forKey: .notification)\n    try container.encodeIfPresent(android, forKey: .android)\n    if let data, !data.isEmpty {\n      try container.encode(data, forKey: .data)\n    }\n  }\n}\n\nstruct FCMNotification: Encodable {\n  var title: String?\n  var body: String?\n  var image: String?\n\n  func encode(to encoder: Encoder) throws {\n    var container = encoder.container(keyedBy: CodingKeys.self)\n    try container.encodeIfPresent(title, forKey: .title)\n    try container.encodeIfPresent(body, forKey: .body)\n    try container.encodeIfPresent(image, forKey: .image)\n  }\n\n  enum CodingKeys: String, CodingKey {\n    case title, body, image\n  }\n}\n\nstruct FCMAndroidConfig: Encodable {\n  var priority: String = \"HIGH\"\n  var notification: FCMAndroidNotification?\n\n  enum CodingKeys: String, CodingKey {\n    case priority, notification\n  }\n}\n\nstruct FCMAndroidNotification: Encodable {\n  var channelId: String?\n  var sound: String?\n  var color: String?\n\n  func encode(to encoder: Encoder) throws {\n    var container = encoder.container(keyedBy: CodingKeys.self)\n    try container.encodeIfPresent(channelId, forKey: .channelId)\n    try container.encodeIfPresent(sound, forKey: .sound)\n    try container.encodeIfPresent(color, forKey: .color)\n  }\n\n  enum CodingKeys: String, CodingKey {\n    case channelId = \"channel_id\"\n    case sound\n    case color\n  }\n}\n\nstruct FCMRequestBody: Encodable {\n  var message: FCMMessage\n}\n"
  },
  {
    "path": "QuickPush/Models/LiveActivityPayload.swift",
    "content": "//\n//  LiveActivityPayload.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport Foundation\n\nstruct LiveActivityAPNsPayload: Codable {\n  let aps: LiveActivityAPS\n\n  enum CodingKeys: String, CodingKey {\n    case aps\n  }\n}\n\nstruct LiveActivityAPS: Codable {\n  let timestamp: Int\n  let event: LiveActivityEvent\n  let contentState: LiveActivityContentState\n  let attributesType: String?\n  let attributes: LiveActivityAttributes?\n  let alert: LiveActivityAlert?\n  let dismissalDate: Int?\n  let relevanceScore: Double?\n  let staleDate: Int?\n\n  enum CodingKeys: String, CodingKey {\n    case timestamp, event\n    case contentState = \"content-state\"\n    case attributesType = \"attributes-type\"\n    case attributes, alert\n    case dismissalDate = \"dismissal-date\"\n    case relevanceScore = \"relevance-score\"\n    case staleDate = \"stale-date\"\n  }\n}\n\nenum LiveActivityEvent: String, Codable, CaseIterable {\n  case start\n  case update\n  case end\n}\n\n// Matches ExpoLiveActivityAttributes.ContentState exactly\nstruct LiveActivityContentState: Codable {\n  var title: String\n  var subtitle: String?\n  var timerEndDateInMilliseconds: Double?\n  var progress: Double?\n  var imageName: String?\n  var dynamicIslandImageName: String?\n}\n\n// Matches ExpoLiveActivityAttributes exactly\nstruct LiveActivityAttributes: Codable {\n  var name: String\n  var backgroundColor: String?\n  var titleColor: String?\n  var subtitleColor: String?\n  var progressViewTint: String?\n  var progressViewLabelColor: String?\n  var deepLinkUrl: String?\n  var timerType: String?\n  var padding: Int?\n  var paddingDetails: LiveActivityPaddingDetails?\n  var imagePosition: String?\n  var imageWidth: Int?\n  var imageHeight: Int?\n}\n\nstruct LiveActivityPaddingDetails: Codable {\n  var top: Int?\n  var bottom: Int?\n  var left: Int?\n  var right: Int?\n  var vertical: Int?\n  var horizontal: Int?\n}\n\nstruct LiveActivityAlert: Codable {\n  var title: String?\n  var body: String?\n  var sound: String?\n}\n"
  },
  {
    "path": "QuickPush/Models/NativePushPayload.swift",
    "content": "//\n//  NativePushPayload.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\n\nenum NativePushType: String, CaseIterable {\n  case alert\n  case background\n}\n\nenum NativeInterruptionLevel: String, CaseIterable {\n  case active\n  case passive\n  case timeSensitive = \"time-sensitive\"\n  case critical\n\n  var displayName: String {\n    switch self {\n    case .active: return \"Active\"\n    case .passive: return \"Passive\"\n    case .timeSensitive: return \"Time Sensitive\"\n    case .critical: return \"Critical\"\n    }\n  }\n}\n\nstruct NativeAlert: Codable {\n  var title: String?\n  var subtitle: String?\n  var body: String?\n}\n\nstruct NativeAPS: Encodable {\n  var alert: NativeAlert?\n  var badge: Int?\n  var sound: String?\n  var contentAvailable: Int?\n  var mutableContent: Int?\n  var threadId: String?\n  var category: String?\n  var interruptionLevel: String?\n\n  enum CodingKeys: String, CodingKey {\n    case alert\n    case badge\n    case sound\n    case contentAvailable = \"content-available\"\n    case mutableContent = \"mutable-content\"\n    case threadId = \"thread-id\"\n    case category\n    case interruptionLevel = \"interruption-level\"\n  }\n\n  func encode(to encoder: Encoder) throws {\n    var container = encoder.container(keyedBy: CodingKeys.self)\n    try container.encodeIfPresent(alert, forKey: .alert)\n    try container.encodeIfPresent(badge, forKey: .badge)\n    try container.encodeIfPresent(sound, forKey: .sound)\n    try container.encodeIfPresent(contentAvailable, forKey: .contentAvailable)\n    try container.encodeIfPresent(mutableContent, forKey: .mutableContent)\n    try container.encodeIfPresent(threadId, forKey: .threadId)\n    try container.encodeIfPresent(category, forKey: .category)\n    try container.encodeIfPresent(interruptionLevel, forKey: .interruptionLevel)\n  }\n}\n\nprivate struct RichBody: Encodable {\n  struct RichContent: Encodable { let image: String }\n  let _richContent: RichContent\n  enum CodingKeys: String, CodingKey { case _richContent = \"_richContent\" }\n}\n\nstruct NativePushPayload: Encodable {\n  var aps: NativeAPS\n  var customData: [String: String]\n  var imageUrl: String?\n\n  func encode(to encoder: Encoder) throws {\n    var container = encoder.container(keyedBy: DynamicCodingKey.self)\n    try container.encode(aps, forKey: DynamicCodingKey(stringValue: \"aps\"))\n    if let imageUrl, !imageUrl.isEmpty {\n      let richBody = RichBody(_richContent: .init(image: imageUrl))\n      try container.encode(richBody, forKey: DynamicCodingKey(stringValue: \"body\"))\n    }\n    for (key, value) in customData {\n      try container.encode(value, forKey: DynamicCodingKey(stringValue: key))\n    }\n  }\n}\n\nprivate struct DynamicCodingKey: CodingKey {\n  var stringValue: String\n  init(stringValue: String) { self.stringValue = stringValue }\n  var intValue: Int? { nil }\n  init?(intValue: Int) { return nil }\n}\n"
  },
  {
    "path": "QuickPush/Models/PushNotification.swift",
    "content": "//\n//  PushNotification.swift\n//  QuickPush\n//\n//  Created by beto on 2/20/25.\n//\n\nimport Foundation\n\nstruct PushNotification: Codable {\n  let to: [String]  // Supports both single and multiple recipients\n  let title: String\n  let body: String\n  let data: [String: String]?\n  let ttl: Int?\n  let expiration: Int?\n  let priority: Priority?\n  let subtitle: String?\n  let sound: String?\n  let badge: Int?\n  let interruptionLevel: InterruptionLevel?\n  let channelId: String?\n  let categoryId: String?\n  let mutableContent: Bool?\n  let contentAvailable: Bool?\n  let richContent: RichContent?\n\n  enum Priority: String, Codable {\n    case `default`, normal, high\n  }\n\n  enum InterruptionLevel: String, Codable {\n    case active, critical, passive, timeSensitive = \"time-sensitive\"\n  }\n\n  struct RichContent: Codable {\n    let image: String\n  }\n  \n  enum CodingKeys: String, CodingKey {\n    case to, title, body, data, ttl, expiration, priority, subtitle, sound, badge\n    case interruptionLevel = \"interruptionLevel\"\n    case channelId = \"channelId\"\n    case categoryId = \"categoryId\"\n    case mutableContent = \"mutableContent\"\n    case contentAvailable = \"_contentAvailable\"\n    case richContent\n  }\n  \n  init(\n    to: [String],\n    title: String,\n    body: String,\n    data: [String: String]? = nil,\n    ttl: Int? = nil,\n    expiration: Int? = nil,\n    priority: Priority? = .default,\n    subtitle: String? = nil,\n    sound: String? = \"default\",\n    badge: Int? = nil,\n    interruptionLevel: InterruptionLevel? = nil,\n    channelId: String? = nil,\n    categoryId: String? = nil,\n    mutableContent: Bool? = false,\n    contentAvailable: Bool? = nil,\n    richContent: RichContent? = nil\n  ) {\n    self.to = to\n    self.title = title\n    self.body = body\n    self.data = data\n    self.ttl = ttl\n    self.expiration = expiration\n    self.priority = priority\n    self.subtitle = subtitle\n    self.sound = sound\n    self.badge = badge\n    self.interruptionLevel = interruptionLevel\n    self.channelId = channelId\n    self.categoryId = categoryId\n    self.mutableContent = mutableContent\n    self.contentAvailable = contentAvailable\n    self.richContent = richContent\n  }\n}\n"
  },
  {
    "path": "QuickPush/Models/PushResponse.swift",
    "content": "//\n//  PushResponse.swift\n//  QuickPush\n//\n//  Created by beto on 2/20/25.\n//\n\nimport Foundation\n\nstruct PushResponse: Codable {\n  let data: [PushTicket]?\n  let errors: [PushError]?\n  \n  struct PushTicket: Codable {\n    let status: String\n    let id: String?  // Present only if status == \"ok\"\n    let message: String?  // Present only if status == \"error\"\n    let details: [String: String]?  // JSON details object, can vary\n  }\n  \n  struct PushError: Codable {\n    let code: String\n    let message: String\n    let type: String?\n    let isTransient: Bool?\n    let metadata: ErrorMetadata?\n    let requestId: String?\n    \n    struct ErrorMetadata: Codable {\n      let appId: String?\n      let experienceId: String?\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Models/ReceiptResponse.swift",
    "content": "//\n//  ReceiptResponse.swift\n//  QuickPush\n//\n//  Created by beto on 2/20/26.\n//\n\nimport Foundation\n\nstruct ReceiptResponse: Codable {\n  let data: [String: PushReceipt]?\n  let errors: [PushResponse.PushError]?\n\n  struct PushReceipt: Codable {\n    let status: String          // \"ok\" or \"error\"\n    let message: String?\n    let details: ReceiptDetails?\n\n    struct ReceiptDetails: Codable {\n      let error: String?        // e.g. \"DeviceNotRegistered\"\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Models/SavedToken.swift",
    "content": "//\n//  SavedToken.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\n\nstruct SavedToken: Codable, Identifiable, Equatable {\n  let id: UUID\n  var label: String\n  var token: String\n  var isEnabled: Bool\n\n  init(id: UUID = UUID(), label: String, token: String, isEnabled: Bool = true) {\n    self.id = id\n    self.label = label\n    self.token = token\n    self.isEnabled = isEnabled\n  }\n}\n"
  },
  {
    "path": "QuickPush/Models/TokenToSave.swift",
    "content": "//\n//  TokenToSave.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\n\n/// Identifiable wrapper for the `.sheet(item:)` token-save presentation.\nstruct TokenToSave: Identifiable {\n  let id = UUID()\n  let index: Int\n  let token: String\n}\n"
  },
  {
    "path": "QuickPush/Preview Content/Preview Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "QuickPush/QuickPush.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.app-sandbox</key>\n\t<true/>\n\t<key>com.apple.security.files.user-selected.read-only</key>\n\t<true/>\n\t<key>com.apple.security.network.client</key>\n\t<true/>\n\t<key>com.apple.security.files.bookmarks.app-scope</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "QuickPush/QuickPushApp.swift",
    "content": "//\n//  QuickPushApp.swift\n//  QuickPush\n//\n//  Created by beto on 2/20/25.\n//\n\nimport SwiftUI\n\n@main\nstruct QuickPushApp: App {\n  @State private var windowManager = WindowManager()\n\n  var body: some Scene {\n    MenuBarExtra(\"QuickPush\", systemImage: \"bolt.brakesignal\") {\n      MainContentView()\n        .environment(windowManager)\n        .onAppear {\n          // Register the global hotkey after the app has launched.\n          windowManager.startMonitoring()\n\n          // If the panel is already pinned, bring it to front\n          // instead of showing the popover content.\n          if windowManager.isPinned {\n            windowManager.bringPanelToFront()\n          }\n        }\n    }\n    .menuBarExtraStyle(.window)\n  }\n}\n"
  },
  {
    "path": "QuickPush/QuickPushIcon.icon/icon.json",
    "content": "{\n  \"fill\" : \"automatic\",\n  \"groups\" : [\n    {\n      \"layers\" : [\n        {\n          \"fill-specializations\" : [\n            {\n              \"appearance\" : \"dark\",\n              \"value\" : {\n                \"solid\" : \"extended-gray:1.00000,1.00000\"\n              }\n            }\n          ],\n          \"glass\" : true,\n          \"image-name\" : \"bolt.brakesignal 1.svg\",\n          \"name\" : \"bolt.brakesignal 1\",\n          \"position\" : {\n            \"scale\" : 6.81,\n            \"translation-in-points\" : [\n              0,\n              0\n            ]\n          }\n        }\n      ],\n      \"position\" : {\n        \"scale\" : 1,\n        \"translation-in-points\" : [\n          5,\n          0\n        ]\n      },\n      \"shadow\" : {\n        \"kind\" : \"neutral\",\n        \"opacity\" : 0.5\n      },\n      \"translucency\" : {\n        \"enabled\" : true,\n        \"value\" : 0.5\n      }\n    }\n  ],\n  \"supported-platforms\" : {\n    \"circles\" : [\n      \"watchOS\"\n    ],\n    \"squares\" : \"shared\"\n  }\n}"
  },
  {
    "path": "QuickPush/Utilities/ColorHexConverter.swift",
    "content": "//\n//  ColorHexConverter.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\nimport AppKit\n\nextension Color {\n  init(hex: String) {\n    let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)\n    var int: UInt64 = 0\n    Scanner(string: hex).scanHexInt64(&int)\n\n    let r, g, b, a: Double\n    switch hex.count {\n    case 6:\n      (r, g, b, a) = (\n        Double((int >> 16) & 0xFF) / 255,\n        Double((int >> 8) & 0xFF) / 255,\n        Double(int & 0xFF) / 255,\n        1\n      )\n    case 8:\n      (r, g, b, a) = (\n        Double((int >> 24) & 0xFF) / 255,\n        Double((int >> 16) & 0xFF) / 255,\n        Double((int >> 8) & 0xFF) / 255,\n        Double(int & 0xFF) / 255\n      )\n    default:\n      (r, g, b, a) = (0, 0, 0, 1)\n    }\n\n    self.init(.sRGB, red: r, green: g, blue: b, opacity: a)\n  }\n\n  func toHexString() -> String {\n    guard let nsColor = NSColor(self).usingColorSpace(.sRGB) else {\n      return \"000000\"\n    }\n    let r = Int(round(nsColor.redComponent * 255))\n    let g = Int(round(nsColor.greenComponent * 255))\n    let b = Int(round(nsColor.blueComponent * 255))\n    return String(format: \"%02X%02X%02X\", r, g, b)\n  }\n\n  func toHexStringWithAlpha() -> String {\n    guard let nsColor = NSColor(self).usingColorSpace(.sRGB) else {\n      return \"000000FF\"\n    }\n    let r = Int(round(nsColor.redComponent * 255))\n    let g = Int(round(nsColor.greenComponent * 255))\n    let b = Int(round(nsColor.blueComponent * 255))\n    let a = Int(round(nsColor.alphaComponent * 255))\n    return String(format: \"%02X%02X%02X%02X\", r, g, b, a)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Utilities/FCMFileManager.swift",
    "content": "//\n//  FCMFileManager.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\nimport AppKit\n\n/// Manages selection and persistent storage of the Firebase service account JSON.\n///\n/// We read the file contents once when selected and store them directly in\n/// UserDefaults (service account JSON is ~3–5 KB, well within limits).\nclass FCMFileManager {\n  static let shared = FCMFileManager()\n\n  private let contentsKey = \"fcm_serviceAccountContents\"\n  private let filenameKey = \"fcm_serviceAccountFilename\"\n  private let projectIdKey = \"fcm_projectId\"\n  private let clientEmailKey = \"fcm_clientEmail\"\n\n  // MARK: - File Picker\n\n  enum SelectionError: Error {\n    case notServiceAccount\n    case unreadable\n\n    var localizedDescription: String {\n      switch self {\n      case .notServiceAccount:\n        return \"This looks like a google-services.json (client config), not a service account key. Go to Firebase Console → Project Settings → Service Accounts → Generate new private key to get the correct file.\"\n      case .unreadable:\n        return \"Could not read the selected file.\"\n      }\n    }\n  }\n\n  func selectServiceAccountFile(completion: @escaping (Result<(contents: String, filename: String, projectId: String?, clientEmail: String?), SelectionError>) -> Void) {\n    let panel = NSOpenPanel()\n    panel.title = \"Select Firebase Service Account JSON\"\n    panel.allowedContentTypes = [.json]\n    panel.allowsMultipleSelection = false\n    panel.canChooseDirectories = false\n\n    panel.begin { response in\n      guard response == .OK, let url = panel.url else { return }\n\n      guard let contents = try? String(contentsOf: url, encoding: .utf8) else {\n        completion(.failure(.unreadable))\n        return\n      }\n\n      // Validate this is a service account file, not google-services.json\n      guard Self.parsePrivateKey(from: contents) != nil else {\n        completion(.failure(.notServiceAccount))\n        return\n      }\n\n      let filename = url.lastPathComponent\n      let (projectId, clientEmail) = Self.parse(serviceAccountContents: contents)\n\n      self.save(contents: contents, filename: filename, projectId: projectId ?? \"\", clientEmail: clientEmail ?? \"\")\n      completion(.success((contents, filename, projectId, clientEmail)))\n    }\n  }\n\n  // MARK: - Parsing\n\n  static func parse(serviceAccountContents: String) -> (projectId: String?, clientEmail: String?) {\n    guard let data = serviceAccountContents.data(using: .utf8),\n          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n      return (nil, nil)\n    }\n    let projectId = json[\"project_id\"] as? String\n    let clientEmail = json[\"client_email\"] as? String\n    return (projectId, clientEmail)\n  }\n\n  static func parsePrivateKey(from serviceAccountContents: String) -> String? {\n    guard let data = serviceAccountContents.data(using: .utf8),\n          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n      return nil\n    }\n    return json[\"private_key\"] as? String\n  }\n\n  // MARK: - Storage\n\n  func save(contents: String, filename: String, projectId: String, clientEmail: String) {\n    UserDefaults.standard.set(contents, forKey: contentsKey)\n    UserDefaults.standard.set(filename, forKey: filenameKey)\n    UserDefaults.standard.set(projectId, forKey: projectIdKey)\n    UserDefaults.standard.set(clientEmail, forKey: clientEmailKey)\n  }\n\n  func storedContents() -> String? {\n    UserDefaults.standard.string(forKey: contentsKey)\n  }\n\n  func storedFilename() -> String? {\n    UserDefaults.standard.string(forKey: filenameKey)\n  }\n\n  func storedProjectId() -> String? {\n    UserDefaults.standard.string(forKey: projectIdKey)\n  }\n\n  func storedClientEmail() -> String? {\n    UserDefaults.standard.string(forKey: clientEmailKey)\n  }\n\n  func clearStored() {\n    UserDefaults.standard.removeObject(forKey: contentsKey)\n    UserDefaults.standard.removeObject(forKey: filenameKey)\n    UserDefaults.standard.removeObject(forKey: projectIdKey)\n    UserDefaults.standard.removeObject(forKey: clientEmailKey)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Utilities/FloatingPanel.swift",
    "content": "//\n//  FloatingPanel.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport AppKit\n\n/// A floating NSPanel that stays on top of other windows.\n/// Used for the \"Pin Window\" feature so the app persists\n/// even when the user clicks outside.\nfinal class FloatingPanel: NSPanel {\n\n  /// Called when the user closes the panel via the title-bar X button.\n  /// The WindowManager sets this so it can run `unpin()` cleanly.\n  var onClose: (() -> Void)?\n\n  init(contentRect: NSRect) {\n    super.init(\n      contentRect: contentRect,\n      styleMask: [\n        .titled,\n        .closable,\n        .miniaturizable,\n        .resizable,\n      ],\n      backing: .buffered,\n      defer: false\n    )\n\n    level = .floating\n    isFloatingPanel = true\n    hidesOnDeactivate = false\n    collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]\n    titlebarAppearsTransparent = false\n    titleVisibility = .hidden\n    isMovableByWindowBackground = true\n    title = \"Quick Push\"\n    isReleasedWhenClosed = false\n    animationBehavior = .utilityWindow\n    isOpaque = false\n    backgroundColor = .clear\n\n    // Match the menu bar popover appearance\n    let blurView = NSVisualEffectView()\n    blurView.material = .popover\n    blurView.blendingMode = .behindWindow\n    blurView.state = .active\n    contentView = blurView\n\n    // Custom icon + title placed directly in the titlebar\n    if let titlebarView = standardWindowButton(.closeButton)?.superview {\n      let iconView = NSImageView()\n      let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium)\n      iconView.image = NSImage(\n        systemSymbolName: \"bolt.brakesignal\",\n        accessibilityDescription: \"Quick Push\"\n      )?.withSymbolConfiguration(config)\n      iconView.contentTintColor = .secondaryLabelColor\n      iconView.translatesAutoresizingMaskIntoConstraints = false\n\n      let label = NSTextField(labelWithString: \"Quick Push\")\n      label.font = .titleBarFont(ofSize: 13)\n      label.textColor = .labelColor\n      label.translatesAutoresizingMaskIntoConstraints = false\n\n      let stack = NSStackView(views: [iconView, label])\n      stack.orientation = .horizontal\n      stack.spacing = 4\n      stack.alignment = .centerY\n      stack.translatesAutoresizingMaskIntoConstraints = false\n\n      titlebarView.addSubview(stack)\n      NSLayoutConstraint.activate([\n        stack.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor),\n        stack.centerXAnchor.constraint(equalTo: titlebarView.centerXAnchor),\n      ])\n    }\n\n    // Minimum size to keep the UI usable\n    minSize = NSSize(width: 570, height: 460)\n\n    // Restore saved position or center on screen\n    if let frameString = UserDefaults.standard.string(forKey: \"FloatingPanelFrame\"),\n       !frameString.isEmpty {\n      setFrame(NSRectFromString(frameString), display: true)\n    } else {\n      center()\n    }\n  }\n\n  override var canBecomeKey: Bool { true }\n  override var canBecomeMain: Bool { true }\n\n  /// Swallow the Escape key so it doesn't close the pinned panel.\n  override func cancelOperation(_ sender: Any?) { }\n\n  /// Save the panel frame whenever it moves or resizes.\n  func saveFrame() {\n    UserDefaults.standard.set(NSStringFromRect(frame), forKey: \"FloatingPanelFrame\")\n  }\n\n  override func close() {\n    saveFrame()\n    // Route through the WindowManager so it can cleanly detach\n    // SwiftUI views before the window hides.\n    if let onClose {\n      onClose()\n    } else {\n      super.close()\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Utilities/SavedTokenStore.swift",
    "content": "//\n//  SavedTokenStore.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport Foundation\n\n/// Manages persistent storage of saved push tokens in UserDefaults.\nclass SavedTokenStore {\n  /// Store for Expo push tokens (ExponentPushToken format).\n  static let shared = SavedTokenStore()\n  /// Store for native APNs device tokens (hex format).\n  static let nativePush = SavedTokenStore(storageKey: \"savedNativePushTokens\")\n  /// Store for FCM registration tokens.\n  static let fcm = SavedTokenStore(storageKey: \"savedFCMTokens\")\n\n  private let storageKey: String\n\n  init(storageKey: String = \"savedPushTokens\") {\n    self.storageKey = storageKey\n  }\n\n  // MARK: - Read\n\n  func loadTokens() -> [SavedToken] {\n    guard let data = UserDefaults.standard.data(forKey: storageKey) else {\n      return []\n    }\n    return (try? JSONDecoder().decode([SavedToken].self, from: data)) ?? []\n  }\n\n  // MARK: - Write\n\n  func saveTokens(_ tokens: [SavedToken]) {\n    if let data = try? JSONEncoder().encode(tokens) {\n      UserDefaults.standard.set(data, forKey: storageKey)\n    }\n  }\n\n  func addToken(_ token: SavedToken) {\n    var tokens = loadTokens()\n    tokens.append(token)\n    saveTokens(tokens)\n  }\n\n  func removeToken(id: UUID) {\n    var tokens = loadTokens()\n    tokens.removeAll { $0.id == id }\n    saveTokens(tokens)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Utilities/SecurityBookmarkManager.swift",
    "content": "//\n//  SecurityBookmarkManager.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport Foundation\nimport AppKit\n\n/// Manages selection and persistent storage of the .p8 APNs auth key.\n///\n/// Instead of relying on security-scoped bookmarks (which fail in sandbox\n/// and break across Xcode rebuilds), we read the file contents once when\n/// selected and store them directly in UserDefaults.\nclass SecurityBookmarkManager {\n  static let shared = SecurityBookmarkManager()\n\n  private let p8ContentsKey = \"p8FileContents\"\n  private let p8FilenameKey = \"p8FileName\"\n\n  // MARK: - File Picker\n\n  func selectP8File(completion: @escaping (String?, String?) -> Void) {\n    let panel = NSOpenPanel()\n    panel.title = \"Select APNs Auth Key (.p8)\"\n    panel.allowedContentTypes = [.init(filenameExtension: \"p8\")!]\n    panel.allowsMultipleSelection = false\n    panel.canChooseDirectories = false\n\n    panel.begin { response in\n      guard response == .OK, let url = panel.url else {\n        completion(nil, nil)\n        return\n      }\n\n      // Read the file contents immediately while NSOpenPanel access is active.\n      let contents = try? String(contentsOf: url, encoding: .utf8)\n      let filename = url.lastPathComponent\n\n      if let contents {\n        self.saveP8(contents: contents, filename: filename)\n      }\n\n      completion(contents, filename)\n    }\n  }\n\n  // MARK: - Storage\n\n  func saveP8(contents: String, filename: String) {\n    UserDefaults.standard.set(contents, forKey: p8ContentsKey)\n    UserDefaults.standard.set(filename, forKey: p8FilenameKey)\n  }\n\n  func storedP8Contents() -> String? {\n    UserDefaults.standard.string(forKey: p8ContentsKey)\n  }\n\n  func storedP8Filename() -> String? {\n    UserDefaults.standard.string(forKey: p8FilenameKey)\n  }\n\n  func clearStoredP8() {\n    UserDefaults.standard.removeObject(forKey: p8ContentsKey)\n    UserDefaults.standard.removeObject(forKey: p8FilenameKey)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Utilities/View+Extensions.swift",
    "content": "import SwiftUI\n\nextension View {\n    @ViewBuilder\n    func applying<V: View>(@ViewBuilder _ modifier: (Self) -> V) -> some View {\n        modifier(self)\n    }\n\n    /// Present content as a `.sheet` when pinned, or `.popover` when in the MenuBarExtra.\n    @ViewBuilder\n    func adaptivePresentation<Content: View>(\n        isPresented: Binding<Bool>,\n        isPinned: Bool,\n        @ViewBuilder content: @escaping () -> Content\n    ) -> some View {\n        if isPinned {\n            self.sheet(isPresented: isPresented, content: content)\n        } else {\n            self.popover(isPresented: isPresented, arrowEdge: .top, content: content)\n        }\n    }\n\n    /// Present content as a `.sheet` when pinned, or `.popover` when in the MenuBarExtra (item variant).\n    @ViewBuilder\n    func adaptivePresentation<Item: Identifiable, Content: View>(\n        item: Binding<Item?>,\n        isPinned: Bool,\n        @ViewBuilder content: @escaping (Item) -> Content\n    ) -> some View {\n        if isPinned {\n            self.sheet(item: item, content: content)\n        } else {\n            self.popover(item: item, arrowEdge: .top, content: content)\n        }\n    }\n}\n"
  },
  {
    "path": "QuickPush/Utilities/WindowManager.swift",
    "content": "//\n//  WindowManager.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\n/// Manages the floating \"pinned\" window and the global keyboard shortcut.\n@Observable\nfinal class WindowManager {\n\n  /// Whether the floating panel is currently visible.\n  private(set) var isPinned: Bool = false\n\n  private var panel: FloatingPanel?\n  private var globalMonitor: Any?\n  private var localMonitor: Any?\n  private var hotkeyRegistered = false\n\n  deinit {\n    removeHotkey()\n  }\n\n  /// Call once after app has finished launching (e.g. from onAppear).\n  func startMonitoring() {\n    guard !hotkeyRegistered else { return }\n    hotkeyRegistered = true\n    registerHotkey()\n  }\n\n  // MARK: - Pin / Unpin\n\n  func togglePin() {\n    if isPinned {\n      unpin()\n    } else {\n      pin()\n    }\n  }\n\n  func pin() {\n    guard !isPinned else { return }\n\n    // Dismiss the MenuBarExtra popover by closing every visible window\n    // that isn't our floating panel. The popover is backed by an internal\n    // NSWindow — ordering it out hides it without destroying it.\n    for window in NSApp.windows where window !== panel && window.isVisible {\n      window.orderOut(nil)\n    }\n\n    // Create the floating panel if needed\n    if panel == nil {\n      let rect = NSRect(x: 0, y: 0, width: 570, height: 500)\n      let newPanel = FloatingPanel(contentRect: rect)\n      newPanel.onClose = { [weak self] in\n        self?.unpin()\n      }\n      panel = newPanel\n    }\n\n    // Host the SwiftUI content inside the panel's blur view\n    let content = MainContentView()\n      .environment(self)\n\n    let hostingView = NSHostingView(rootView: content)\n    hostingView.sizingOptions = [.minSize]\n    hostingView.translatesAutoresizingMaskIntoConstraints = false\n    hostingView.layer?.backgroundColor = .clear\n\n    if let blurView = panel?.contentView {\n      // Remove any previous hosting view\n      blurView.subviews.forEach { $0.removeFromSuperview() }\n      blurView.addSubview(hostingView)\n      NSLayoutConstraint.activate([\n        hostingView.topAnchor.constraint(equalTo: blurView.topAnchor),\n        hostingView.bottomAnchor.constraint(equalTo: blurView.bottomAnchor),\n        hostingView.leadingAnchor.constraint(equalTo: blurView.leadingAnchor),\n        hostingView.trailingAnchor.constraint(equalTo: blurView.trailingAnchor),\n      ])\n    }\n\n    // Show the panel after a brief delay so the popover finishes closing.\n    DispatchQueue.main.async { [weak self] in\n      guard let self, let panel = self.panel else { return }\n\n      // Temporarily become a regular app so macOS lets us show a window,\n      // then switch back to accessory so we don't linger in the Dock.\n      NSApp.setActivationPolicy(.regular)\n      panel.makeKeyAndOrderFront(nil)\n\n      DispatchQueue.main.async {\n        NSApp.setActivationPolicy(.accessory)\n      }\n\n      self.isPinned = true\n    }\n  }\n\n  func unpin() {\n    guard isPinned else { return }\n\n    guard let panel else {\n      isPinned = false\n      return\n    }\n\n    panel.saveFrame()\n\n    // Detach the SwiftUI hosting view FIRST so it doesn't try to\n    // re-render while the window disappears underneath it.\n    // Keep the blur view alive — only remove the hosting subview.\n    panel.contentView?.subviews.forEach { $0.removeFromSuperview() }\n\n    // Now hide the panel safely.\n    panel.orderOut(nil)\n\n    isPinned = false\n  }\n\n  /// Called when the user clicks the menu bar icon while the panel is pinned.\n  func bringPanelToFront() {\n    guard isPinned, let panel else { return }\n    panel.orderFrontRegardless()\n    panel.makeKey()\n  }\n\n  // MARK: - Global Keyboard Shortcut (Cmd+Shift+P)\n\n  private func registerHotkey() {\n    // Global monitor – fires when our app is NOT focused\n    globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in\n      self?.handleHotkey(event)\n    }\n\n    // Local monitor – fires when our app IS focused\n    localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in\n      if self?.handleHotkey(event) == true {\n        return nil // swallow the event\n      }\n      return event\n    }\n  }\n\n  private func removeHotkey() {\n    if let globalMonitor { NSEvent.removeMonitor(globalMonitor) }\n    if let localMonitor { NSEvent.removeMonitor(localMonitor) }\n    globalMonitor = nil\n    localMonitor = nil\n  }\n\n  /// Returns `true` if the event matched our shortcut and was handled.\n  @discardableResult\n  private func handleHotkey(_ event: NSEvent) -> Bool {\n    // Cmd + Shift + P\n    guard event.modifierFlags.contains([.command, .shift]),\n          event.charactersIgnoringModifiers?.lowercased() == \"p\" else {\n      return false\n    }\n\n    DispatchQueue.main.async { [weak self] in\n      self?.togglePin()\n    }\n    return true\n  }\n}\n"
  },
  {
    "path": "QuickPush/ViewModels/FCMViewModel.swift",
    "content": "//\n//  FCMViewModel.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n@Observable\nclass FCMViewModel {\n  // MARK: - Shared FCM config\n  var config: FCMConfigStore = .shared\n\n  // MARK: - Tokens\n  var tokens: [String] = [\"\"]\n  var savedTokens: [SavedToken] = []\n\n  var allValidTokens: [String] {\n    savedTokens.filter(\\.isEnabled).map(\\.token) + tokens.filter { !$0.isEmpty }\n  }\n\n  // MARK: - Message fields\n  var messageType: FCMMessageType = .notification\n  var title: String = \"\"\n  var body: String = \"\"\n  var imageUrl: String = \"\"\n  var channelId: String = \"default\"\n  var sound: String = \"default\"\n  var color: String = \"\"\n  var priority: String = \"HIGH\"\n  var customData: [String: String] = [:]\n\n  // MARK: - UI State\n  var isSending: Bool = false\n  var showToast: Bool = false\n  var toastMessage: String = \"\"\n  var toastType: ToastType = .success\n  var showResponseSheet: Bool = false\n  var showCurlSheet: Bool = false\n  var lastResponse: FCMResponse?\n  var tokenToSave: TokenToSave?\n  var curlCommand: String = \"\"\n\n  // MARK: - Init\n  init() {\n    savedTokens = SavedTokenStore.fcm.loadTokens()\n  }\n\n  // MARK: - Validation\n  var canSend: Bool {\n    !allValidTokens.isEmpty && config.fcmConfiguration().isValid && !isSending\n  }\n\n  // MARK: - Build Message\n  func buildMessage(token: String) -> FCMMessage {\n    var notification: FCMNotification?\n    if messageType == .notification {\n      notification = FCMNotification(\n        title: title.isEmpty ? nil : title,\n        body: body.isEmpty ? nil : body,\n        image: imageUrl.isEmpty ? nil : imageUrl\n      )\n    }\n\n    var androidNotification: FCMAndroidNotification?\n    if messageType == .notification {\n      androidNotification = FCMAndroidNotification(\n        channelId: channelId.isEmpty ? nil : channelId,\n        sound: sound.isEmpty ? nil : sound,\n        color: color.isEmpty ? nil : \"#\\(color.trimmingCharacters(in: CharacterSet(charactersIn: \"#\")))\"\n      )\n    }\n\n    let androidConfig = FCMAndroidConfig(\n      priority: priority,\n      notification: androidNotification\n    )\n\n    let data = customData.isEmpty ? nil : customData\n\n    return FCMMessage(\n      token: token,\n      notification: notification,\n      android: androidConfig,\n      data: data\n    )\n  }\n\n  // MARK: - Send\n  func send() {\n    guard canSend else { return }\n\n    if messageType == .notification && title.isEmpty && body.isEmpty {\n      showToastMessage(\"Notification messages require at least a title or body.\", type: .error)\n      return\n    }\n\n    isSending = true\n\n    let validTokens = allValidTokens\n    let configuration = config.fcmConfiguration()\n\n    let group = DispatchGroup()\n    var responses: [FCMResponse] = []\n    var firstError: Error?\n\n    for token in validTokens {\n      let message = buildMessage(token: token)\n      group.enter()\n      FCMService.shared.send(\n        message: message,\n        configuration: configuration\n      ) { result in\n        switch result {\n        case .success(let response):\n          responses.append(response)\n        case .failure(let error):\n          if firstError == nil { firstError = error }\n          if case FCMService.FCMError.requestFailed(let response) = error {\n            responses.append(response)\n          }\n        }\n        group.leave()\n      }\n    }\n\n    group.notify(queue: .main) { [weak self] in\n      guard let self else { return }\n      self.isSending = false\n      self.lastResponse = responses.last\n      if let error = firstError {\n        self.showToastMessage(error.localizedDescription, type: .error)\n      } else {\n        let msg = responses.count == 1\n          ? responses[0].summary\n          : \"\\(responses.count) pushes sent\"\n        self.showToastMessage(msg, type: .success)\n      }\n    }\n  }\n\n  // MARK: - cURL\n  func generateCurlCommand() -> String {\n    let configuration = config.fcmConfiguration()\n    let cleanToken = (allValidTokens.first ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n    let message = buildMessage(token: cleanToken.isEmpty ? \"<FCM_REGISTRATION_TOKEN>\" : cleanToken)\n\n    let body = FCMRequestBody(message: message)\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]\n    let bodyString: String\n    if let data = try? encoder.encode(body),\n       let str = String(data: data, encoding: .utf8) {\n      bodyString = str\n    } else {\n      bodyString = \"{}\"\n    }\n\n    var lines: [String] = []\n    lines.append(\"# Replace <ACCESS_TOKEN> with a valid OAuth 2.0 bearer token.\")\n    lines.append(\"# Obtain one by running: gcloud auth print-access-token\")\n    if allValidTokens.count > 1 {\n      lines.append(\"# cURL shown for the first token. Repeat for each additional token.\")\n    }\n    lines.append(\"curl -X POST \\\\\")\n    lines.append(\"  https://fcm.googleapis.com/v1/projects/\\(configuration.projectId)/messages:send \\\\\")\n    lines.append(\"  -H \\\"Authorization: Bearer <ACCESS_TOKEN>\\\" \\\\\")\n    lines.append(\"  -H \\\"Content-Type: application/json\\\" \\\\\")\n    lines.append(\"  -d '\\(bodyString)'\")\n\n    return lines.joined(separator: \"\\n\")\n  }\n\n  // MARK: - Clipboard\n  func pasteToken(at index: Int) {\n    guard let clipboardString = NSPasteboard.general.string(forType: .string) else { return }\n    let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else {\n      showToastMessage(\"Clipboard is empty.\", type: .error)\n      return\n    }\n    if index < tokens.count {\n      tokens[index] = trimmed\n    }\n  }\n\n  // MARK: - Toast\n  func showToastMessage(_ message: String, type: ToastType) {\n    toastMessage = message\n    toastType = type\n    showToast = true\n  }\n}\n"
  },
  {
    "path": "QuickPush/ViewModels/LiveActivityViewModel.swift",
    "content": "//\n//  LiveActivityViewModel.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\n@Observable\nclass LiveActivityViewModel {\n  // MARK: - Event Type\n  var eventType: LiveActivityEvent = .start\n\n  // MARK: - Token\n  var deviceToken: String = \"\"\n\n  // MARK: - Content State\n  var contentTitle: String = \"\"\n  var contentSubtitle: String = \"\"\n  var includeProgress: Bool = false\n  var progress: Double = 0.5\n  var includeTimerEnd: Bool = false\n  var timerEndDate: Date = Date().addingTimeInterval(3600)\n  var imageName: String = \"\"\n  var dynamicIslandImageName: String = \"\"\n\n  // MARK: - Attributes (Start only)\n  var attributeName: String = \"\"\n  var backgroundColor: Color = Color(hex: \"001A72\")\n  var titleColor: Color = Color(hex: \"EBEBF0\")\n  var subtitleColor: Color = Color(hex: \"EBEBF599\")\n  var progressViewTint: Color = Color(hex: \"FFFFFF\")\n  var progressViewLabelColor: Color = Color(hex: \"EBEBF0\")\n  var deepLinkUrl: String = \"\"\n  var timerType: String = \"digital\"\n  var imagePosition: String = \"left\"\n  var imageWidth: Int = 40\n  var imageHeight: Int = 40\n  var useCustomPadding: Bool = false\n  var uniformPadding: Int = 16\n  var paddingTop: Int = 16\n  var paddingBottom: Int = 16\n  var paddingLeft: Int = 16\n  var paddingRight: Int = 16\n\n  // MARK: - Alert (Start only)\n  var includeAlert: Bool = false\n  var alertTitle: String = \"\"\n  var alertBody: String = \"\"\n  var alertSound: String = \"default\"\n\n  // MARK: - APNs Configuration (shared with APNs tab)\n  var config: APNsConfigStore = .shared\n  var attributesType: String = \"LiveActivityAttributes\" {\n    didSet { UserDefaults.standard.set(attributesType, forKey: \"apns_attributesType\") }\n  }\n\n  // MARK: - UI State\n  var isSending: Bool = false\n  var showToast: Bool = false\n  var toastMessage: String = \"\"\n  var toastType: ToastType = .success\n  var showJSONSheet: Bool = false\n  var showResponseSheet: Bool = false\n  var lastResponse: APNsResponse?\n\n  // MARK: - Init\n  init() {\n    attributesType = UserDefaults.standard.string(forKey: \"apns_attributesType\") ?? \"LiveActivityAttributes\"\n  }\n\n  // MARK: - Validation\n  var canSend: Bool {\n    !deviceToken.isEmpty && apnsConfiguration.isValid && !isSending\n  }\n\n  var apnsConfiguration: APNsConfiguration {\n    config.apnsConfiguration(topicSuffix: \".push-type.liveactivity\")\n  }\n\n  var tokenLabel: String {\n    eventType == .start ? \"Push-to-Start Token\" : \"Activity Token\"\n  }\n\n  // MARK: - Build Payload\n  func buildPayload() -> LiveActivityAPNsPayload {\n    let contentState = LiveActivityContentState(\n      title: contentTitle.isEmpty ? \"Activity\" : contentTitle,\n      subtitle: contentSubtitle.isEmpty ? nil : contentSubtitle,\n      timerEndDateInMilliseconds: includeTimerEnd ? timerEndDate.timeIntervalSince1970 * 1000 : nil,\n      progress: includeProgress ? progress : nil,\n      imageName: imageName.isEmpty ? nil : imageName,\n      dynamicIslandImageName: dynamicIslandImageName.isEmpty ? nil : dynamicIslandImageName\n    )\n\n    var attributes: LiveActivityAttributes?\n    if eventType == .start {\n      let paddingDetails: LiveActivityPaddingDetails?\n      if useCustomPadding {\n        paddingDetails = LiveActivityPaddingDetails(\n          top: paddingTop,\n          bottom: paddingBottom,\n          left: paddingLeft,\n          right: paddingRight\n        )\n      } else {\n        paddingDetails = nil\n      }\n\n      attributes = LiveActivityAttributes(\n        name: attributeName.isEmpty ? \"LiveActivity\" : attributeName,\n        backgroundColor: backgroundColor.toHexString(),\n        titleColor: titleColor.toHexString(),\n        subtitleColor: subtitleColor.toHexStringWithAlpha(),\n        progressViewTint: progressViewTint.toHexString(),\n        progressViewLabelColor: progressViewLabelColor.toHexString(),\n        deepLinkUrl: deepLinkUrl.isEmpty ? nil : deepLinkUrl,\n        timerType: timerType,\n        padding: useCustomPadding ? nil : uniformPadding,\n        paddingDetails: paddingDetails,\n        imagePosition: imagePosition,\n        imageWidth: (!imageName.isEmpty || !dynamicIslandImageName.isEmpty) ? imageWidth : nil,\n        imageHeight: (!imageName.isEmpty || !dynamicIslandImageName.isEmpty) ? imageHeight : nil\n      )\n    }\n\n    var alert: LiveActivityAlert?\n    if includeAlert && eventType == .start {\n      alert = LiveActivityAlert(\n        title: alertTitle.isEmpty ? nil : alertTitle,\n        body: alertBody.isEmpty ? nil : alertBody,\n        sound: alertSound.isEmpty ? nil : alertSound\n      )\n    }\n\n    let aps = LiveActivityAPS(\n      timestamp: Int(Date().addingTimeInterval(5).timeIntervalSince1970),\n      event: eventType,\n      contentState: contentState,\n      attributesType: eventType == .start ? attributesType : nil,\n      attributes: attributes,\n      alert: alert,\n      dismissalDate: eventType == .end ? Int(Date().addingTimeInterval(5).timeIntervalSince1970) : nil,\n      relevanceScore: nil,\n      staleDate: nil\n    )\n\n    return LiveActivityAPNsPayload(aps: aps)\n  }\n\n  // MARK: - Send\n  func send() {\n    guard canSend else { return }\n    isSending = true\n\n    let payload = buildPayload()\n\n    APNsService.shared.sendLiveActivityPush(\n      payload: payload,\n      token: deviceToken,\n      configuration: apnsConfiguration\n    ) { [weak self] result in\n      DispatchQueue.main.async {\n        guard let self else { return }\n        self.isSending = false\n        switch result {\n        case .success(let response):\n          self.lastResponse = response\n          self.showToastMessage(response.summary, type: .success)\n        case .failure(let error):\n          if case APNsService.APNsError.requestFailed(let response) = error {\n            self.lastResponse = response\n          }\n          self.showToastMessage(error.localizedDescription, type: .error)\n        }\n      }\n    }\n  }\n\n  // MARK: - JSON Export/Import\n  func exportJSON() -> String {\n    let payload = buildPayload()\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]\n\n    guard let data = try? encoder.encode(payload),\n          let json = String(data: data, encoding: .utf8) else {\n      return \"{}\"\n    }\n    return json\n  }\n\n  func importJSON(_ jsonString: String) -> Bool {\n    guard let data = jsonString.data(using: .utf8) else { return false }\n\n    do {\n      let payload = try JSONDecoder().decode(LiveActivityAPNsPayload.self, from: data)\n      applyPayload(payload)\n      return true\n    } catch {\n      showToastMessage(\"Invalid JSON: \\(error.localizedDescription)\", type: .error)\n      return false\n    }\n  }\n\n  private func applyPayload(_ payload: LiveActivityAPNsPayload) {\n    let aps = payload.aps\n    eventType = aps.event\n\n    // Content State\n    let cs = aps.contentState\n    contentTitle = cs.title\n    contentSubtitle = cs.subtitle ?? \"\"\n    if let p = cs.progress {\n      includeProgress = true\n      progress = p\n    } else {\n      includeProgress = false\n    }\n    if let timerEnd = cs.timerEndDateInMilliseconds {\n      includeTimerEnd = true\n      timerEndDate = Date(timeIntervalSince1970: timerEnd / 1000)\n    } else {\n      includeTimerEnd = false\n    }\n    imageName = cs.imageName ?? \"\"\n    dynamicIslandImageName = cs.dynamicIslandImageName ?? \"\"\n\n    // Attributes\n    if let attrs = aps.attributes {\n      attributeName = attrs.name\n      if let bg = attrs.backgroundColor { backgroundColor = Color(hex: bg) }\n      if let tc = attrs.titleColor { titleColor = Color(hex: tc) }\n      if let sc = attrs.subtitleColor { subtitleColor = Color(hex: sc) }\n      if let pt = attrs.progressViewTint { progressViewTint = Color(hex: pt) }\n      if let pl = attrs.progressViewLabelColor { progressViewLabelColor = Color(hex: pl) }\n      deepLinkUrl = attrs.deepLinkUrl ?? \"\"\n      timerType = attrs.timerType ?? \"digital\"\n      imagePosition = attrs.imagePosition ?? \"left\"\n      if let w = attrs.imageWidth { imageWidth = w }\n      if let h = attrs.imageHeight { imageHeight = h }\n      if let p = attrs.padding {\n        useCustomPadding = false\n        uniformPadding = p\n      }\n      if let pd = attrs.paddingDetails {\n        useCustomPadding = true\n        paddingTop = pd.top ?? 16\n        paddingBottom = pd.bottom ?? 16\n        paddingLeft = pd.left ?? 16\n        paddingRight = pd.right ?? 16\n      }\n    }\n\n    // Alert\n    if let alert = aps.alert {\n      includeAlert = true\n      alertTitle = alert.title ?? \"\"\n      alertBody = alert.body ?? \"\"\n      alertSound = alert.sound ?? \"default\"\n    } else {\n      includeAlert = false\n    }\n  }\n\n  // MARK: - Toast\n  private func showToastMessage(_ message: String, type: ToastType) {\n    toastMessage = message\n    toastType = type\n    showToast = true\n  }\n\n  // MARK: - File Picker\n  func selectP8File() {\n    config.selectP8File()\n  }\n\n  // MARK: - Clipboard\n  func pasteToken() {\n    if let clipboardString = NSPasteboard.general.string(forType: .string) {\n      let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines)\n      let hexCharacterSet = CharacterSet(charactersIn: \"0123456789abcdefABCDEF\")\n      if trimmed.unicodeScalars.allSatisfy({ hexCharacterSet.contains($0) }) && !trimmed.isEmpty {\n        deviceToken = trimmed\n      } else {\n        showToastMessage(\"Invalid token format. Expected hex string.\", type: .error)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/ViewModels/NativePushViewModel.swift",
    "content": "//\n//  NativePushViewModel.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n@Observable\nclass NativePushViewModel {\n  // MARK: - Shared APNs config\n  var config: APNsConfigStore = .shared\n\n  // MARK: - Tokens\n  var tokens: [String] = [\"\"]\n  var savedTokens: [SavedToken] = []\n\n  var allValidTokens: [String] {\n    savedTokens.filter(\\.isEnabled).map(\\.token) + tokens.filter { !$0.isEmpty }\n  }\n\n  // MARK: - Push fields\n  var pushType: NativePushType = .alert\n  var title: String = \"\"\n  var subtitle: String = \"\"\n  var body: String = \"\"\n  var sound: String = \"default\"\n  var badge: String = \"\"\n  var threadId: String = \"\"\n  var categoryId: String = \"\"\n  var interruptionLevel: NativeInterruptionLevel = .active\n  var imageUrl: String = \"\"\n  var mutableContent: Bool = false\n  var contentAvailable: Bool = false\n  var priority: Int = 10\n  var customData: [String: String] = [:]\n\n  // MARK: - UI State\n  var isSending: Bool = false\n  var showToast: Bool = false\n  var toastMessage: String = \"\"\n  var toastType: ToastType = .success\n  var showResponseSheet: Bool = false\n  var showCurlSheet: Bool = false\n  var lastResponse: APNsResponse?\n  var tokenToSave: TokenToSave?\n  var curlCommand: String = \"\"\n\n  // MARK: - Init\n  init() {\n    savedTokens = SavedTokenStore.nativePush.loadTokens()\n  }\n\n  // MARK: - Validation\n  var canSend: Bool {\n    !allValidTokens.isEmpty && config.apnsConfiguration(topicSuffix: nil).isValid && !isSending\n  }\n\n  // MARK: - Build Payload\n  func buildPayload() -> NativePushPayload {\n    var alert: NativeAlert?\n    if pushType == .alert {\n      let t = title.isEmpty ? nil : title\n      let s = subtitle.isEmpty ? nil : subtitle\n      let b = body.isEmpty ? nil : body\n      // Only include alert key if there is at least some visible content\n      if t != nil || s != nil || b != nil {\n        alert = NativeAlert(title: t, subtitle: s, body: b)\n      }\n    }\n\n    let effectiveMutableContent = mutableContent || !imageUrl.isEmpty\n    let aps = NativeAPS(\n      alert: alert,\n      badge: Int(badge),\n      sound: (pushType == .alert && !sound.isEmpty) ? sound : nil,\n      contentAvailable: contentAvailable ? 1 : nil,\n      mutableContent: effectiveMutableContent ? 1 : nil,\n      threadId: threadId.isEmpty ? nil : threadId,\n      category: categoryId.isEmpty ? nil : categoryId,\n      interruptionLevel: (pushType == .alert) ? interruptionLevel.rawValue : nil\n    )\n\n    return NativePushPayload(aps: aps, customData: customData, imageUrl: imageUrl.isEmpty ? nil : imageUrl)\n  }\n\n  // MARK: - Send\n  func send() {\n    guard canSend else { return }\n\n    if pushType == .alert && title.isEmpty && body.isEmpty {\n      showToastMessage(\"Alert notifications require at least a title or body.\", type: .error)\n      return\n    }\n\n    isSending = true\n\n    let validTokens = allValidTokens\n    let payload = buildPayload()\n    let configuration = config.apnsConfiguration(topicSuffix: nil)\n\n    let group = DispatchGroup()\n    var responses: [APNsResponse] = []\n    var firstError: Error?\n\n    for token in validTokens {\n      group.enter()\n      APNsService.shared.sendNativePush(\n        payload: payload,\n        token: token,\n        pushType: pushType,\n        priority: priority,\n        configuration: configuration\n      ) { result in\n        switch result {\n        case .success(let response):\n          responses.append(response)\n        case .failure(let error):\n          if firstError == nil { firstError = error }\n          if case APNsService.APNsError.requestFailed(let response) = error {\n            responses.append(response)\n          }\n        }\n        group.leave()\n      }\n    }\n\n    group.notify(queue: .main) { [weak self] in\n      guard let self else { return }\n      self.isSending = false\n      self.lastResponse = responses.last\n      if let error = firstError {\n        self.showToastMessage(error.localizedDescription, type: .error)\n      } else {\n        let msg = responses.count == 1\n          ? responses[0].summary\n          : \"\\(responses.count) pushes sent\"\n        self.showToastMessage(msg, type: .success)\n      }\n    }\n  }\n\n  // MARK: - cURL\n  func generateCurlCommand() -> String {\n    let configuration = config.apnsConfiguration(topicSuffix: nil)\n    let jwt: String\n    if let p8Contents = configuration.p8Contents,\n       let token = try? JWTSigner.generateToken(\n         teamId: configuration.teamId,\n         keyId: configuration.keyId,\n         p8Contents: p8Contents\n       ) {\n      jwt = token\n    } else {\n      jwt = \"<JWT>\"\n    }\n\n    let cleanToken = (allValidTokens.first ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n    let payload = buildPayload()\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]\n    let bodyString: String\n    if let data = try? encoder.encode(payload),\n       let str = String(data: data, encoding: .utf8) {\n      bodyString = str\n    } else {\n      bodyString = \"{}\"\n    }\n\n    var lines: [String] = []\n    lines.append(\"# Note: JWT tokens expire after 1 hour.\")\n    if allValidTokens.count > 1 {\n      lines.append(\"# cURL shown for the first token. Repeat for each additional token.\")\n    }\n    lines.append(\"curl --http2 -X POST \\\\\")\n    lines.append(\"  https://\\(configuration.hostname)/3/device/\\(cleanToken) \\\\\")\n    lines.append(\"  -H \\\"authorization: bearer \\(jwt)\\\" \\\\\")\n    lines.append(\"  -H \\\"apns-topic: \\(configuration.topic)\\\" \\\\\")\n    lines.append(\"  -H \\\"apns-push-type: \\(pushType.rawValue)\\\" \\\\\")\n    lines.append(\"  -H \\\"apns-priority: \\(priority)\\\" \\\\\")\n    lines.append(\"  -H \\\"content-type: application/json\\\" \\\\\")\n    lines.append(\"  -d '\\(bodyString)'\")\n\n    return lines.joined(separator: \"\\n\")\n  }\n\n  // MARK: - Clipboard\n  func pasteToken(at index: Int) {\n    guard let clipboardString = NSPasteboard.general.string(forType: .string) else { return }\n    let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines)\n    let hexCharacterSet = CharacterSet(charactersIn: \"0123456789abcdefABCDEF\")\n    if trimmed.unicodeScalars.allSatisfy({ hexCharacterSet.contains($0) }) && !trimmed.isEmpty {\n      if index < tokens.count {\n        tokens[index] = trimmed\n      }\n    } else {\n      showToastMessage(\"Invalid token format. Expected hex string.\", type: .error)\n    }\n  }\n\n  // MARK: - Toast\n  func showToastMessage(_ message: String, type: ToastType) {\n    toastMessage = message\n    toastType = type\n    showToast = true\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/APNsConfigurationView.swift",
    "content": "//\n//  APNsConfigurationView.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\nstruct APNsConfigurationView: View {\n  @Bindable var config: APNsConfigStore\n  @State private var isExpanded: Bool = true\n\n  private var isValid: Bool {\n    !config.teamId.isEmpty && !config.keyId.isEmpty && !config.bundleId.isEmpty && config.hasP8Key\n  }\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Button(action: { withAnimation { isExpanded.toggle() } }) {\n        HStack {\n          Text(\"APNs Configuration\")\n            .font(.subheadline)\n            .fontWeight(.medium)\n          Spacer()\n          Circle()\n            .fill(isValid ? Color.green : Color.orange)\n            .frame(width: 8, height: 8)\n          Image(systemName: isExpanded ? \"chevron.up\" : \"chevron.down\")\n            .foregroundColor(.secondary)\n        }\n      }\n      .buttonStyle(.plain)\n\n      if isExpanded {\n        VStack(alignment: .leading, spacing: 8) {\n          HStack {\n            Text(\"Team ID:\")\n              .frame(width: 70, alignment: .leading)\n            TextField(\"e.g. A1B2C3D4E5\", text: $config.teamId)\n              .textFieldStyle(.roundedBorder)\n          }\n\n          HStack {\n            Text(\"Key ID:\")\n              .frame(width: 70, alignment: .leading)\n            TextField(\"e.g. ABCDE12345\", text: $config.keyId)\n              .textFieldStyle(.roundedBorder)\n          }\n\n          HStack {\n            Text(\"Bundle ID:\")\n              .frame(width: 70, alignment: .leading)\n            TextField(\"e.g. com.example.app\", text: $config.bundleId)\n              .textFieldStyle(.roundedBorder)\n          }\n\n          HStack {\n            Text(\".p8 Key:\")\n              .frame(width: 70, alignment: .leading)\n            if let name = config.p8FileName, config.hasP8Key {\n              Text(name)\n                .foregroundColor(.secondary)\n                .lineLimit(1)\n                .truncationMode(.middle)\n            } else {\n              Text(\"No file selected\")\n                .foregroundColor(.secondary)\n            }\n            Spacer()\n            Button(\"Browse...\") {\n              config.selectP8File()\n            }\n            .controlSize(.small)\n          }\n\n          HStack {\n            Text(\"Environment:\")\n            Picker(\"\", selection: $config.environment) {\n              Text(\"Sandbox\").tag(APNsEnvironment.sandbox)\n              Text(\"Production\").tag(APNsEnvironment.production)\n            }\n            .pickerStyle(.segmented)\n          }\n        }\n        .padding(.leading, 4)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/APNsCurlCommandView.swift",
    "content": "//\n//  APNsCurlCommandView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n/// Sheet that displays a copyable APNs curl command with a pre-generated JWT.\nstruct APNsCurlCommandView: View {\n  let curlCommand: String\n  @Environment(\\.dismiss) private var dismiss\n  @State private var copied = false\n\n  var body: some View {\n    VStack(spacing: 12) {\n      HStack {\n        Text(\"cURL Command\")\n          .font(.headline)\n        Spacer()\n        Button(\"Close\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      ScrollView {\n        Text(curlCommand)\n          .font(.system(.body, design: .monospaced))\n          .textSelection(.enabled)\n          .frame(maxWidth: .infinity, alignment: .leading)\n          .padding(8)\n      }\n      .background(Color(nsColor: .controlBackgroundColor))\n      .cornerRadius(8)\n      .overlay(\n        RoundedRectangle(cornerRadius: 8)\n          .stroke(Color.secondary.opacity(0.2))\n      )\n\n      HStack {\n        Button(copied ? \"Copied!\" : \"Copy\") {\n          NSPasteboard.general.clearContents()\n          NSPasteboard.general.setString(curlCommand, forType: .string)\n          copied = true\n          DispatchQueue.main.asyncAfter(deadline: .now() + 2) {\n            copied = false\n          }\n        }\n        .help(\"Copy curl command to clipboard\")\n\n        Spacer()\n\n        Text(\"JWT expires after 1 hour\")\n          .font(.caption)\n          .foregroundColor(.secondary)\n      }\n    }\n    .padding()\n    .frame(minWidth: 450, minHeight: 300)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/APNsResponseDetailView.swift",
    "content": "//\n//  APNsResponseDetailView.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\n/// Sheet that displays the full APNs response diagnostics after a send attempt.\nstruct APNsResponseDetailView: View {\n  let response: APNsResponse?\n  @Environment(\\.dismiss) private var dismiss\n\n  var body: some View {\n    VStack(spacing: 12) {\n      HStack {\n        Text(\"APNs Response\")\n          .font(.headline)\n        Spacer()\n        Button(\"Close\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      if let response {\n        VStack(alignment: .leading, spacing: 0) {\n          // Status header\n          HStack(spacing: 8) {\n            Image(systemName: response.isSuccess ? \"checkmark.circle.fill\" : \"xmark.circle.fill\")\n              .foregroundColor(response.isSuccess ? .green : .red)\n              .font(.title2)\n            Text(response.isSuccess ? \"Push Accepted\" : \"Push Rejected\")\n              .font(.title3)\n              .fontWeight(.semibold)\n            Spacer()\n          }\n          .padding(.bottom, 12)\n\n          // Diagnostic rows\n          Group {\n            ResponseRow(label: \"Status\", value: \"\\(response.statusCode)\")\n            if let reason = response.reason {\n              ResponseRow(label: \"Reason\", value: reason, isError: true)\n            }\n            ResponseRow(label: \"Event\", value: response.event)\n            ResponseRow(label: \"Environment\", value: \"\\(response.environment.rawValue.capitalized)\")\n            ResponseRow(label: \"Hostname\", value: response.hostname)\n            ResponseRow(label: \"Topic\", value: response.topic)\n            if let attributesType = response.attributesType {\n              ResponseRow(label: \"Attributes Type\", value: attributesType, isHighlighted: true)\n            }\n            ResponseRow(label: \"Timestamp\", value: \"\\(response.timestamp) (Unix sec)\")\n            if let apnsId = response.apnsId {\n              ResponseRow(label: \"apns-id\", value: apnsId)\n            }\n            if let apnsUniqueId = response.apnsUniqueId {\n              ResponseRow(label: \"apns-unique-id\", value: apnsUniqueId)\n            }\n          }\n        }\n        .padding()\n        .background(Color(nsColor: .controlBackgroundColor))\n        .cornerRadius(8)\n        .overlay(\n          RoundedRectangle(cornerRadius: 8)\n            .stroke(Color.secondary.opacity(0.2))\n        )\n\n        // Troubleshooting tips for common errors\n        if let reason = response.reason {\n          troubleshootingTip(for: reason)\n        }\n\n        // Warn about silent drops on 200 with start event\n        if response.isSuccess && response.event == \"start\" {\n          successButNoActivityTip(attributesType: response.attributesType)\n        }\n\n        HStack {\n          Button(\"Copy Diagnostics\") {\n            NSPasteboard.general.clearContents()\n            NSPasteboard.general.setString(response.diagnosticDetails, forType: .string)\n          }\n          .help(\"Copy full diagnostic info to clipboard\")\n          Spacer()\n        }\n      } else {\n        Text(\"No response yet. Send a push first.\")\n          .foregroundColor(.secondary)\n      }\n\n      Spacer()\n    }\n    .padding()\n    .frame(minWidth: 420, minHeight: 300)\n  }\n\n  @ViewBuilder\n  private func troubleshootingTip(for reason: String) -> some View {\n    let tip: String? = switch reason {\n    case \"BadDeviceToken\":\n      \"The token is invalid. Check: wrong environment (sandbox vs production), token is stale, or token format is incorrect.\"\n    case \"DeviceTokenNotForTopic\":\n      \"The device token doesn't match the topic (bundle ID). Verify your Bundle ID is correct and the token was generated for this app.\"\n    case \"Unregistered\":\n      \"The token is no longer valid. The device may have uninstalled the app or the token has expired.\"\n    case \"TopicDisallowed\":\n      \"Push notifications are not allowed for this topic. Check your provisioning profile and entitlements.\"\n    case \"InvalidProviderToken\":\n      \"The JWT token is invalid. Verify your Team ID, Key ID, and .p8 key file.\"\n    case \"ExpiredProviderToken\":\n      \"The JWT token has expired. It will be refreshed on the next send.\"\n    case \"MissingTopic\":\n      \"The apns-topic header is missing. This is a QuickPush bug — please report it.\"\n    default:\n      nil\n    }\n\n    if let tip {\n      TipBox(icon: \"lightbulb.fill\", color: .yellow, text: tip)\n    }\n  }\n\n  private func successButNoActivityTip(attributesType: String?) -> some View {\n    let tip = Self.buildSuccessTip(attributesType: attributesType)\n    return TipBox(\n      icon: \"exclamationmark.triangle.fill\",\n      color: .orange,\n      text: tip\n    )\n  }\n\n  private static func buildSuccessTip(attributesType: String?) -> String {\n    let attrType = attributesType ?? \"unknown\"\n    let needsModulePrefix = !attrType.contains(\".\")\n\n    var lines = \"\"\"\n    APNs returned 200 but the Live Activity didn't appear? Common causes:\n\n    1. **attributes-type mismatch** — iOS expects the module-qualified name.\n       Current value: \"\\(attrType)\"\n    \"\"\"\n    if needsModulePrefix {\n      lines += \"\\n   Try: \\\"YourWidgetTarget.\\(attrType)\\\"\"\n    }\n    lines += \"\"\"\n\n    2. **Timestamp too far from \"now\"** — must be current Unix seconds.\n\n    3. **Token/environment mismatch** — sandbox token with production, or vice versa.\n\n    To find the exact attributes-type, add this to your iOS app:\n    `print(String(reflecting: YourAttributes.self))`\n    \"\"\"\n    return lines\n  }\n}\n\n/// Styled tip box used for troubleshooting hints.\nprivate struct TipBox: View {\n  let icon: String\n  let color: Color\n  let text: String\n\n  var body: some View {\n    HStack(alignment: .top, spacing: 8) {\n      Image(systemName: icon)\n        .foregroundColor(color)\n      // Use .init() to enable Markdown rendering in the string\n      Text(.init(text))\n        .font(.callout)\n        .foregroundColor(.secondary)\n    }\n    .padding(10)\n    .background(color.opacity(0.1))\n    .cornerRadius(6)\n  }\n}\n\n/// A single label–value row in the response detail view.\nprivate struct ResponseRow: View {\n  let label: String\n  let value: String\n  var isError: Bool = false\n  var isHighlighted: Bool = false\n\n  var body: some View {\n    HStack(alignment: .top) {\n      Text(label)\n        .foregroundColor(.secondary)\n        .frame(width: 110, alignment: .trailing)\n      Text(value)\n        .fontWeight((isError || isHighlighted) ? .semibold : .regular)\n        .foregroundColor(isError ? .red : isHighlighted ? .orange : .primary)\n        .textSelection(.enabled)\n      Spacer()\n    }\n    .padding(.vertical, 3)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/APNsView.swift",
    "content": "//\n//  APNsView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\nstruct APNsView: View {\n  var isActive: Bool = true\n  @Environment(WindowManager.self) var windowManager\n  @State private var viewModel = NativePushViewModel()\n\n  var body: some View {\n    VStack(spacing: 16) {\n      // Title bar\n      HStack {\n        Text(\"APNs\")\n          .font(.headline)\n        Spacer()\n        if viewModel.lastResponse != nil {\n          Button {\n            viewModel.showResponseSheet = true\n          } label: {\n            HStack(spacing: 4) {\n              Circle()\n                .fill(viewModel.lastResponse?.isSuccess == true ? Color.green : Color.red)\n                .frame(width: 6, height: 6)\n              Text(\"Response\")\n            }\n          }\n          .controlSize(.small)\n        }\n        Button(\"cURL\") {\n          viewModel.curlCommand = viewModel.generateCurlCommand()\n          viewModel.showCurlSheet = true\n        }\n        .controlSize(.small)\n        .disabled(!viewModel.canSend)\n        Button {\n          viewModel.send()\n        } label: {\n          HStack(spacing: 4) {\n            Text(\"Send\")\n            HStack(spacing: 1) {\n              Image(systemName: \"command\")\n              Image(systemName: \"return\")\n            }\n            .font(.caption2)\n            .opacity(0.7)\n          }\n        }\n        .applying { view in\n          if isActive {\n            view.keyboardShortcut(.return, modifiers: .command)\n          } else {\n            view\n          }\n        }\n        .applying { view in\n          if #available(macOS 26.0, *) {\n            view.buttonStyle(.glassProminent)\n          } else {\n            view.buttonStyle(.borderedProminent)\n          }\n        }\n        .disabled(!viewModel.canSend)\n      }\n\n      ScrollView(.vertical, showsIndicators: false) {\n        VStack(alignment: .leading, spacing: 16) {\n          // Token Section\n          VStack(alignment: .leading, spacing: 8) {\n            Text(\"Device Token:\")\n              .font(.subheadline)\n\n            // Saved tokens\n            ForEach(viewModel.savedTokens) { saved in\n              SavedTokenRowView(\n                savedToken: saved,\n                onToggle: {\n                  if let index = viewModel.savedTokens.firstIndex(where: { $0.id == saved.id }) {\n                    viewModel.savedTokens[index].isEnabled.toggle()\n                    SavedTokenStore.nativePush.saveTokens(viewModel.savedTokens)\n                  }\n                },\n                onCopy: {\n                  NSPasteboard.general.clearContents()\n                  NSPasteboard.general.setString(saved.token, forType: .string)\n                  viewModel.showToastMessage(\"Token copied to clipboard\", type: .success)\n                },\n                onRemove: {\n                  SavedTokenStore.nativePush.removeToken(id: saved.id)\n                  viewModel.savedTokens.removeAll { $0.id == saved.id }\n                }\n              )\n            }\n\n            // Unsaved token rows\n            ForEach(viewModel.tokens.indices, id: \\.self) { index in\n              HStack(spacing: 8) {\n                TextField(\"Hex device token\", text: $viewModel.tokens[index])\n                  .textFieldStyle(.roundedBorder)\n\n                Button(action: { viewModel.pasteToken(at: index) }) {\n                  Image(systemName: \"doc.on.clipboard\")\n                    .foregroundColor(.secondary)\n                }\n                .buttonStyle(.plain)\n                .focusable(false)\n                .help(\"Paste hex token from clipboard\")\n\n                Button(action: {\n                  viewModel.tokenToSave = TokenToSave(index: index, token: viewModel.tokens[index])\n                }) {\n                  Image(systemName: \"square.and.arrow.down\")\n                    .foregroundColor(.secondary)\n                }\n                .buttonStyle(.plain)\n                .focusable(false)\n                .disabled(viewModel.tokens[index].trimmingCharacters(in: .whitespaces).isEmpty)\n                .help(\"Save this token for future sessions\")\n\n                if viewModel.tokens.count > 1 || !viewModel.savedTokens.isEmpty {\n                  Button(action: { viewModel.tokens.remove(at: index) }) {\n                    Image(systemName: \"minus.circle.fill\")\n                      .foregroundColor(.red)\n                  }\n                  .buttonStyle(.plain)\n                  .focusable(false)\n                }\n              }\n            }\n\n            Button(action: { viewModel.tokens.append(\"\") }) {\n              HStack {\n                Image(systemName: \"plus.circle.fill\")\n                Text(\"Add Token\")\n              }\n            }\n            .buttonStyle(.borderless)\n            .padding(.top, 5)\n          }\n\n          // APNs Configuration\n          APNsConfigurationView(config: viewModel.config)\n\n          Divider()\n\n          // Push Type Picker\n          HStack {\n            Text(\"Push Type:\")\n            Picker(\"\", selection: $viewModel.pushType) {\n              Text(\"Alert\").tag(NativePushType.alert)\n              Text(\"Background\").tag(NativePushType.background)\n            }\n            .pickerStyle(.segmented)\n          }\n          .onChange(of: viewModel.pushType) { _, newType in\n            viewModel.priority = newType == .background ? 5 : 10\n          }\n\n          // Alert Fields\n          if viewModel.pushType == .alert {\n            VStack(alignment: .leading, spacing: 8) {\n              InputField(label: \"Title\", text: $viewModel.title, helpText: \"Title of the notification\")\n              InputField(label: \"Subtitle\", text: $viewModel.subtitle, helpText: \"Secondary line below the title\")\n              InputField(label: \"Body\", text: $viewModel.body, helpText: \"Main message content\")\n              InputField(label: \"Sound\", text: $viewModel.sound, helpText: \"Sound name. Use \\\"default\\\" for the system sound, or provide a custom sound file name.\")\n              InputField(label: \"Badge\", text: $viewModel.badge, helpText: \"Number to display on the app icon badge\")\n              InputField(\n                label: \"Image URL\",\n                text: $viewModel.imageUrl,\n                helpText: \"URL of an image to display in the notification. Requires a Notification Service Extension in the app. Automatically enables Mutable Content. Injected as body._richContent.image in the payload. Learn how to create an NSE: https://codewithbeto.dev/rnCourse/expoNotificationsExtension\"\n              )\n            }\n          }\n\n          // Advanced\n          DisclosureGroup(\"Advanced\") {\n            VStack(alignment: .leading, spacing: 8) {\n              InputField(label: \"Thread ID\", text: $viewModel.threadId, helpText: \"Groups related notifications in the notification center\")\n              InputField(label: \"Category\", text: $viewModel.categoryId, helpText: \"Notification category for interactive actions\")\n\n              if viewModel.pushType == .alert {\n                HStack {\n                  Text(\"Interruption:\")\n                  Picker(\"\", selection: $viewModel.interruptionLevel) {\n                    ForEach(NativeInterruptionLevel.allCases, id: \\.self) { level in\n                      Text(level.displayName).tag(level)\n                    }\n                  }\n                  .pickerStyle(.menu)\n                  HelpButton(helpText: \"Controls delivery timing:\\n• Active — default, plays sound and lights screen\\n• Passive — delivered quietly, no sound or badge\\n• Time Sensitive — breaks through Focus modes\\n• Critical — bypasses mute and Focus (requires entitlement)\")\n                }\n\n                Toggle(\"Mutable Content\", isOn: $viewModel.mutableContent)\n                  .help(\"Allows a Notification Service Extension to modify the payload before display\")\n              }\n\n              Toggle(\"Content Available\", isOn: $viewModel.contentAvailable)\n                .help(\"Wakes the app in the background to process the notification silently\")\n\n              HStack {\n                Text(\"Priority:\")\n                Picker(\"\", selection: $viewModel.priority) {\n                  Text(\"High (10)\").tag(10)\n                  Text(\"Normal (5)\").tag(5)\n                }\n                .pickerStyle(.segmented)\n                HelpButton(helpText: \"Priority 10 = immediate delivery (wakes device). Priority 5 = normal/background delivery. Background push type requires priority 5.\")\n              }\n            }\n            .padding(.top, 8)\n          }\n\n          // Custom Data\n          KeyValueInputView(data: $viewModel.customData)\n        }\n      }\n    }\n    .padding(.horizontal)\n    .padding(.top)\n    .overlay(\n      ToastView(message: viewModel.toastMessage, type: viewModel.toastType, isPresented: $viewModel.showToast)\n        .animation(.easeInOut, value: viewModel.showToast)\n    )\n    .adaptivePresentation(isPresented: $viewModel.showResponseSheet, isPinned: windowManager.isPinned) {\n      APNsResponseDetailView(response: viewModel.lastResponse)\n    }\n    .adaptivePresentation(isPresented: $viewModel.showCurlSheet, isPinned: windowManager.isPinned) {\n      APNsCurlCommandView(curlCommand: viewModel.curlCommand)\n    }\n    .adaptivePresentation(item: $viewModel.tokenToSave, isPinned: windowManager.isPinned) { item in\n      SaveTokenSheet(\n        token: item.token,\n        onSave: { label in\n          let savedToken = SavedToken(label: label, token: item.token)\n          SavedTokenStore.nativePush.addToken(savedToken)\n          viewModel.savedTokens.append(savedToken)\n          if item.index < viewModel.tokens.count {\n            viewModel.tokens.remove(at: item.index)\n          }\n          if viewModel.tokens.isEmpty {\n            viewModel.tokens.append(\"\")\n          }\n        },\n        warningText: \"APNs device tokens may change when you reinstall the app or on OS updates.\"\n      )\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/ColorPickerField.swift",
    "content": "//\n//  ColorPickerField.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\nstruct ColorPickerField: View {\n  let label: String\n  @Binding var color: Color\n\n  var body: some View {\n    HStack {\n      Text(\"\\(label):\")\n      Spacer()\n      Text(color.toHexString())\n        .font(.system(.body, design: .monospaced))\n        .foregroundColor(.secondary)\n      ColorPicker(\"\", selection: $color, supportsOpacity: label.contains(\"Subtitle\"))\n        .labelsHidden()\n        .frame(width: 30)\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/ExpoCurlCommandView.swift",
    "content": "//\n//  ExpoCurlCommandView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n/// Sheet that displays a copyable curl command for the current Expo push notification.\nstruct ExpoCurlCommandView: View {\n  let notification: PushNotification\n  let accessToken: String?\n  @Environment(\\.dismiss) private var dismiss\n  @State private var copied = false\n\n  private var curlCommand: String {\n    buildCurlCommand()\n  }\n\n  var body: some View {\n    VStack(spacing: 12) {\n      HStack {\n        Text(\"cURL Command\")\n          .font(.headline)\n        Spacer()\n        Button(\"Close\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      ScrollView {\n        Text(curlCommand)\n          .font(.system(.body, design: .monospaced))\n          .textSelection(.enabled)\n          .frame(maxWidth: .infinity, alignment: .leading)\n          .padding(8)\n      }\n      .background(Color(nsColor: .controlBackgroundColor))\n      .cornerRadius(8)\n      .overlay(\n        RoundedRectangle(cornerRadius: 8)\n          .stroke(Color.secondary.opacity(0.2))\n      )\n\n      HStack {\n        Button(copied ? \"Copied!\" : \"Copy\") {\n          NSPasteboard.general.clearContents()\n          NSPasteboard.general.setString(curlCommand, forType: .string)\n          copied = true\n          DispatchQueue.main.asyncAfter(deadline: .now() + 2) {\n            copied = false\n          }\n        }\n        .help(\"Copy curl command to clipboard\")\n\n        Spacer()\n      }\n    }\n    .padding()\n    .frame(minWidth: 450, minHeight: 300)\n  }\n\n  // MARK: - Build cURL\n\n  private func buildCurlCommand() -> String {\n    var lines: [String] = []\n    lines.append(\"curl -X POST https://exp.host/--/api/v2/push/send \\\\\")\n    lines.append(\"  -H \\\"Content-Type: application/json\\\" \\\\\")\n    lines.append(\"  -H \\\"Accept: application/json\\\" \\\\\")\n    lines.append(\"  -H \\\"Host: exp.host\\\" \\\\\")\n\n    if let token = accessToken, !token.isEmpty {\n      lines.append(\"  -H \\\"Authorization: Bearer \\(token)\\\" \\\\\")\n    }\n\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]\n    if let jsonData = try? encoder.encode(notification),\n       let jsonString = String(data: jsonData, encoding: .utf8) {\n      lines.append(\"  -d '\\(jsonString)'\")\n    }\n\n    return lines.joined(separator: \"\\n\")\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/ExpoReceiptView.swift",
    "content": "//\n//  ExpoReceiptView.swift\n//  QuickPush\n//\n//  Created by beto on 2/20/26.\n//\n\nimport SwiftUI\n\nstruct ExpoReceiptView: View {\n  let initialTicketIds: [String]\n  let accessToken: String?\n  @Environment(\\.dismiss) private var dismiss\n\n  @State private var ids: [String]\n  @State private var isLoading: Bool = false\n  @State private var result: PushNotificationService.ReceiptResult?\n  @State private var errorMessage: String?\n\n  init(ticketIds: [String], accessToken: String?) {\n    self.initialTicketIds = ticketIds\n    self.accessToken = accessToken\n    _ids = State(initialValue: ticketIds.isEmpty ? [\"\"] : ticketIds)\n  }\n\n  var body: some View {\n    VStack(spacing: 12) {\n      // Header\n      HStack {\n        Text(\"Check Receipts\")\n          .font(.headline)\n        Spacer()\n        Button(\"Close\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      // IDs input list\n      VStack(alignment: .leading, spacing: 8) {\n        Text(\"Ticket IDs:\")\n          .font(.subheadline)\n\n        ForEach(ids.indices, id: \\.self) { index in\n          HStack(spacing: 8) {\n            TextField(\"Ticket ID (e.g. abc123...)\", text: $ids[index])\n              .textFieldStyle(RoundedBorderTextFieldStyle())\n              .font(.system(.caption, design: .monospaced))\n\n            Button(action: {\n              if let clip = NSPasteboard.general.string(forType: .string) {\n                ids[index] = clip.trimmingCharacters(in: .whitespacesAndNewlines)\n              }\n            }) {\n              Image(systemName: \"doc.on.clipboard\")\n                .foregroundColor(.secondary)\n            }\n            .buttonStyle(.plain)\n            .help(\"Paste from clipboard\")\n\n            if ids.count > 1 {\n              Button(action: { ids.remove(at: index) }) {\n                Image(systemName: \"minus.circle.fill\")\n                  .foregroundColor(.red)\n              }\n              .buttonStyle(.plain)\n            }\n          }\n        }\n\n        Button(action: { ids.append(\"\") }) {\n          HStack {\n            Image(systemName: \"plus.circle.fill\")\n            Text(\"Add ID\")\n          }\n        }\n        .buttonStyle(.borderless)\n      }\n\n      // Check button + error\n      HStack {\n        if let errorMessage {\n          Text(errorMessage)\n            .font(.caption)\n            .foregroundColor(.red)\n        }\n        Spacer()\n        Button {\n          checkReceipts()\n        } label: {\n          if isLoading {\n            HStack(spacing: 6) {\n              ProgressView()\n                .scaleEffect(0.6)\n                .frame(width: 14, height: 14)\n              Text(\"Checking…\")\n            }\n          } else {\n            Text(\"Check Receipts\")\n          }\n        }\n        .buttonStyle(.borderedProminent)\n        .disabled(validIds.isEmpty || isLoading)\n      }\n\n      Divider()\n\n      // Results\n      if let result {\n        resultsSection(result)\n      } else {\n        Text(\"Enter ticket IDs and tap \\\"Check Receipts\\\".\")\n          .font(.callout)\n          .foregroundColor(.secondary)\n          .frame(maxWidth: .infinity, alignment: .leading)\n      }\n\n      Spacer()\n    }\n    .padding()\n    .frame(minWidth: 460, minHeight: 340)\n  }\n\n  // MARK: - Results\n\n  @ViewBuilder\n  private func resultsSection(_ result: PushNotificationService.ReceiptResult) -> some View {\n    ScrollView(.vertical, showsIndicators: false) {\n      VStack(alignment: .leading, spacing: 10) {\n        // API-level errors\n        if let errors = result.response.errors, !errors.isEmpty {\n          VStack(alignment: .leading, spacing: 6) {\n            Label(\"API Error\", systemImage: \"xmark.circle.fill\")\n              .foregroundColor(.red)\n              .font(.subheadline.weight(.semibold))\n\n            ForEach(Array(errors.enumerated()), id: \\.offset) { _, err in\n              VStack(alignment: .leading, spacing: 2) {\n                Text(err.code)\n                  .font(.system(.caption, design: .monospaced))\n                  .fontWeight(.semibold)\n                  .foregroundColor(.red)\n                Text(err.message)\n                  .font(.caption)\n                  .foregroundColor(.secondary)\n              }\n              .padding(8)\n              .frame(maxWidth: .infinity, alignment: .leading)\n              .background(Color.red.opacity(0.08))\n              .cornerRadius(6)\n            }\n          }\n        }\n\n        // Per-receipt results\n        if let receipts = result.response.data, !receipts.isEmpty {\n          ForEach(receipts.sorted(by: { $0.key < $1.key }), id: \\.key) { id, receipt in\n            receiptRow(id: id, receipt: receipt)\n          }\n        } else if result.response.errors == nil {\n          Text(\"No receipts returned for the given IDs. They may be expired or invalid.\")\n            .font(.callout)\n            .foregroundColor(.secondary)\n        }\n\n        // Raw JSON disclosure\n        if let raw = result.rawJSON, !raw.isEmpty {\n          DisclosureGroup(\"Raw JSON\") {\n            ScrollView {\n              Text(raw)\n                .font(.system(.caption, design: .monospaced))\n                .textSelection(.enabled)\n                .frame(maxWidth: .infinity, alignment: .leading)\n            }\n            .frame(maxHeight: 140)\n            .padding(8)\n            .background(Color(nsColor: .controlBackgroundColor))\n            .cornerRadius(6)\n          }\n        }\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func receiptRow(id: String, receipt: ReceiptResponse.PushReceipt) -> some View {\n    let isOk = receipt.status == \"ok\"\n    let errorCode = receipt.details?.error\n\n    VStack(alignment: .leading, spacing: 6) {\n      // Status badge + ID\n      HStack(spacing: 6) {\n        Circle()\n          .fill(isOk ? Color.green : Color.red)\n          .frame(width: 8, height: 8)\n        Text(receipt.status)\n          .font(.system(.caption, design: .monospaced))\n          .fontWeight(.semibold)\n          .foregroundColor(isOk ? .green : .red)\n        Spacer()\n        Text(id)\n          .font(.system(.caption2, design: .monospaced))\n          .foregroundColor(.secondary)\n          .textSelection(.enabled)\n          .lineLimit(1)\n          .truncationMode(.middle)\n      }\n\n      // \"ok\" informational note\n      if isOk {\n        Label(\n          \"\\\"ok\\\" means FCM/APNs accepted the notification — not that the device displayed it.\",\n          systemImage: \"info.circle\"\n        )\n        .font(.caption)\n        .foregroundColor(.secondary)\n      }\n\n      // Error message\n      if let message = receipt.message {\n        Text(message)\n          .font(.caption)\n          .foregroundColor(.red)\n          .textSelection(.enabled)\n      }\n\n      // Error code + actionable guidance\n      if let code = errorCode {\n        VStack(alignment: .leading, spacing: 4) {\n          HStack(spacing: 4) {\n            Text(\"Error code:\")\n              .font(.caption)\n              .foregroundColor(.secondary)\n            Text(code)\n              .font(.system(.caption, design: .monospaced))\n              .fontWeight(.semibold)\n              .foregroundColor(.orange)\n          }\n          Text(guidance(for: code))\n            .font(.caption)\n            .foregroundColor(.secondary)\n            .fixedSize(horizontal: false, vertical: true)\n        }\n        .padding(6)\n        .frame(maxWidth: .infinity, alignment: .leading)\n        .background(Color.orange.opacity(0.08))\n        .cornerRadius(6)\n      }\n    }\n    .padding(10)\n    .frame(maxWidth: .infinity, alignment: .leading)\n    .background(isOk ? Color.green.opacity(0.07) : Color.red.opacity(0.07))\n    .cornerRadius(8)\n  }\n\n  // MARK: - Helpers\n\n  private var validIds: [String] {\n    ids.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }\n  }\n\n  private func checkReceipts() {\n    errorMessage = nil\n    isLoading = true\n    result = nil\n\n    PushNotificationService.shared.checkReceipts(\n      ids: validIds,\n      accessToken: accessToken\n    ) { outcome in\n      DispatchQueue.main.async {\n        isLoading = false\n        switch outcome {\n        case .success(let receiptResult):\n          result = receiptResult\n        case .failure(let error):\n          errorMessage = error.localizedDescription\n        }\n      }\n    }\n  }\n\n  private func guidance(for errorCode: String) -> String {\n    switch errorCode {\n    case \"DeviceNotRegistered\":\n      return \"Remove this token — the device has opted out or uninstalled the app.\"\n    case \"MessageTooBig\":\n      return \"Reduce the notification payload size.\"\n    case \"MessageRateExceeded\":\n      return \"Back off — too many messages have been sent to this device recently.\"\n    case \"MismatchSenderId\":\n      return \"This token belongs to a different FCM sender ID. Check your push credentials.\"\n    case \"InvalidCredentials\":\n      return \"Your push credentials are invalid. Reconfigure them in the EAS Dashboard.\"\n    default:\n      return \"Check the Expo push notifications documentation for details on this error.\"\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/ExpoResponseDetailView.swift",
    "content": "//\n//  ExpoResponseDetailView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n/// Sheet that displays the full Expo Push API response in a developer-friendly way.\nstruct ExpoResponseDetailView: View {\n  let response: PushResponse?\n  let httpStatusCode: Int?\n  let rawJSON: String?\n  @Environment(\\.dismiss) private var dismiss\n\n  var body: some View {\n    VStack(spacing: 12) {\n      HStack {\n        Text(\"Response\")\n          .font(.headline)\n        Spacer()\n        Button(\"Close\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      if let response {\n        // Top-level API errors (e.g. UNAUTHORIZED)\n        if let errors = response.errors, !errors.isEmpty {\n          apiErrorsSection(errors)\n        }\n\n        // Per-ticket results\n        if let tickets = response.data, !tickets.isEmpty {\n          ticketsSection(tickets)\n        }\n\n        // Raw JSON\n        if let rawJSON, !rawJSON.isEmpty {\n          rawJSONSection(rawJSON)\n        }\n\n        HStack {\n          Button(\"Copy Response\") {\n            NSPasteboard.general.clearContents()\n            NSPasteboard.general.setString(rawJSON ?? \"\", forType: .string)\n          }\n          .help(\"Copy raw JSON response to clipboard\")\n          .disabled(rawJSON == nil)\n          Spacer()\n        }\n      } else {\n        Text(\"No response yet. Send a push first.\")\n          .foregroundColor(.secondary)\n      }\n\n      Spacer()\n    }\n    .padding()\n    .frame(minWidth: 420, minHeight: 300)\n  }\n\n  // MARK: - Sections\n\n  @ViewBuilder\n  private func apiErrorsSection(_ errors: [PushResponse.PushError]) -> some View {\n    VStack(alignment: .leading, spacing: 8) {\n      HStack(spacing: 8) {\n        Image(systemName: \"xmark.circle.fill\")\n          .foregroundColor(.red)\n          .font(.title2)\n        Text(\"API Error\")\n          .font(.title3)\n          .fontWeight(.semibold)\n        if let statusCode = httpStatusCode {\n          Text(\"(\\(statusCode))\")\n            .foregroundColor(.secondary)\n        }\n        Spacer()\n      }\n\n      ForEach(Array(errors.enumerated()), id: \\.offset) { _, error in\n        VStack(alignment: .leading, spacing: 4) {\n          HStack {\n            Text(error.code)\n              .font(.system(.body, design: .monospaced))\n              .fontWeight(.semibold)\n              .foregroundColor(.red)\n            if error.isTransient == true {\n              Text(\"(transient)\")\n                .font(.caption)\n                .foregroundColor(.orange)\n            }\n          }\n          Text(error.message)\n            .font(.callout)\n            .foregroundColor(.secondary)\n            .textSelection(.enabled)\n        }\n        .padding(10)\n        .frame(maxWidth: .infinity, alignment: .leading)\n        .background(Color.red.opacity(0.08))\n        .cornerRadius(6)\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func ticketsSection(_ tickets: [PushResponse.PushTicket]) -> some View {\n    VStack(alignment: .leading, spacing: 8) {\n      let successCount = tickets.filter { $0.status == \"ok\" }.count\n      let errorCount = tickets.filter { $0.status == \"error\" }.count\n\n      HStack(spacing: 8) {\n        Image(systemName: errorCount == 0 ? \"checkmark.circle.fill\" : \"exclamationmark.triangle.fill\")\n          .foregroundColor(errorCount == 0 ? .green : .orange)\n          .font(.title2)\n        Text(errorCount == 0 ? \"All Delivered\" : \"\\(successCount) OK, \\(errorCount) Failed\")\n          .font(.title3)\n          .fontWeight(.semibold)\n        if let statusCode = httpStatusCode {\n          Text(\"(\\(statusCode))\")\n            .foregroundColor(.secondary)\n        }\n        Spacer()\n      }\n\n      ForEach(Array(tickets.enumerated()), id: \\.offset) { index, ticket in\n        VStack(alignment: .leading, spacing: 4) {\n          HStack {\n            Text(\"Ticket \\(index + 1)\")\n              .font(.subheadline)\n              .fontWeight(.medium)\n            Circle()\n              .fill(ticket.status == \"ok\" ? Color.green : Color.red)\n              .frame(width: 8, height: 8)\n            Text(ticket.status)\n              .font(.system(.caption, design: .monospaced))\n            Spacer()\n          }\n\n          if let id = ticket.id {\n            HStack(spacing: 4) {\n              Text(\"ID:\")\n                .foregroundColor(.secondary)\n                .font(.caption)\n              Text(id)\n                .font(.system(.caption, design: .monospaced))\n                .textSelection(.enabled)\n            }\n          }\n\n          if let message = ticket.message {\n            Text(message)\n              .font(.callout)\n              .foregroundColor(.red)\n              .textSelection(.enabled)\n          }\n\n          if let details = ticket.details {\n            ForEach(Array(details.sorted(by: { $0.key < $1.key })), id: \\.key) { key, value in\n              HStack(spacing: 4) {\n                Text(\"\\(key):\")\n                  .foregroundColor(.secondary)\n                  .font(.caption)\n                Text(value)\n                  .font(.system(.caption, design: .monospaced))\n                  .textSelection(.enabled)\n              }\n            }\n          }\n        }\n        .padding(10)\n        .frame(maxWidth: .infinity, alignment: .leading)\n        .background(ticket.status == \"ok\" ? Color.green.opacity(0.08) : Color.red.opacity(0.08))\n        .cornerRadius(6)\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func rawJSONSection(_ json: String) -> some View {\n    DisclosureGroup(\"Raw JSON\") {\n      ScrollView {\n        Text(json)\n          .font(.system(.caption, design: .monospaced))\n          .textSelection(.enabled)\n          .frame(maxWidth: .infinity, alignment: .leading)\n      }\n      .frame(maxHeight: 150)\n      .padding(8)\n      .background(Color(nsColor: .controlBackgroundColor))\n      .cornerRadius(6)\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/FCMConfigurationView.swift",
    "content": "//\n//  FCMConfigurationView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\nstruct FCMConfigurationView: View {\n  @Bindable var config: FCMConfigStore\n\n  private var isValid: Bool {\n    !config.projectId.isEmpty && !config.clientEmail.isEmpty && config.hasServiceAccount\n  }\n\n  @State private var isExpanded: Bool = false\n\n  init(config: FCMConfigStore) {\n    self.config = config\n    // Start collapsed if already configured, expanded if not yet set up\n    _isExpanded = State(initialValue: !config.hasServiceAccount)\n  }\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Button(action: { withAnimation { isExpanded.toggle() } }) {\n        HStack {\n          Text(\"FCM Configuration\")\n            .font(.subheadline)\n            .fontWeight(.medium)\n          Spacer()\n          Circle()\n            .fill(isValid ? Color.green : Color.orange)\n            .frame(width: 8, height: 8)\n          Image(systemName: isExpanded ? \"chevron.up\" : \"chevron.down\")\n            .foregroundColor(.secondary)\n        }\n      }\n      .buttonStyle(.plain)\n\n      if isExpanded {\n        VStack(alignment: .leading, spacing: 8) {\n          HStack {\n            Text(\"Service Account:\")\n              .frame(width: 110, alignment: .leading)\n            if let name = config.serviceAccountFilename, config.hasServiceAccount {\n              Text(name)\n                .foregroundColor(.secondary)\n                .lineLimit(1)\n                .truncationMode(.middle)\n            } else {\n              Text(\"No file selected\")\n                .foregroundColor(.secondary)\n            }\n            Spacer()\n            Button(\"Browse...\") {\n              config.selectServiceAccountFile()\n            }\n            .controlSize(.small)\n          }\n\n          if let error = config.fileError {\n            HStack(alignment: .top, spacing: 6) {\n              Image(systemName: \"exclamationmark.triangle.fill\")\n                .foregroundColor(.orange)\n                .font(.caption)\n              Text(error)\n                .font(.caption)\n                .foregroundColor(.orange)\n                .fixedSize(horizontal: false, vertical: true)\n            }\n            .padding(8)\n            .background(Color.orange.opacity(0.1))\n            .cornerRadius(6)\n          }\n\n          HStack {\n            Text(\"Project ID:\")\n              .frame(width: 110, alignment: .leading)\n            TextField(\"e.g. my-app-12345\", text: $config.projectId)\n              .textFieldStyle(.roundedBorder)\n          }\n\n          HStack {\n            Text(\"Client Email:\")\n              .frame(width: 110, alignment: .leading)\n            TextField(\"service-account@project.iam.gserviceaccount.com\", text: $config.clientEmail)\n              .textFieldStyle(.roundedBorder)\n              .foregroundColor(.secondary)\n          }\n        }\n        .padding(.leading, 4)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/FCMCurlCommandView.swift",
    "content": "//\n//  FCMCurlCommandView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n/// Sheet that displays a copyable FCM curl command.\nstruct FCMCurlCommandView: View {\n  let curlCommand: String\n  @Environment(\\.dismiss) private var dismiss\n  @State private var copied = false\n\n  var body: some View {\n    VStack(spacing: 12) {\n      HStack {\n        Text(\"cURL Command\")\n          .font(.headline)\n        Spacer()\n        Button(\"Close\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      ScrollView {\n        Text(curlCommand)\n          .font(.system(.body, design: .monospaced))\n          .textSelection(.enabled)\n          .frame(maxWidth: .infinity, alignment: .leading)\n          .padding(8)\n      }\n      .background(Color(nsColor: .controlBackgroundColor))\n      .cornerRadius(8)\n      .overlay(\n        RoundedRectangle(cornerRadius: 8)\n          .stroke(Color.secondary.opacity(0.2))\n      )\n\n      HStack {\n        Button(copied ? \"Copied!\" : \"Copy\") {\n          NSPasteboard.general.clearContents()\n          NSPasteboard.general.setString(curlCommand, forType: .string)\n          copied = true\n          DispatchQueue.main.asyncAfter(deadline: .now() + 2) {\n            copied = false\n          }\n        }\n        .help(\"Copy curl command to clipboard\")\n\n        Spacer()\n\n        Text(\"Replace <ACCESS_TOKEN> with a valid OAuth 2.0 bearer token\")\n          .font(.caption)\n          .foregroundColor(.secondary)\n      }\n    }\n    .padding()\n    .frame(minWidth: 500, minHeight: 300)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/FCMResponseDetailView.swift",
    "content": "//\n//  FCMResponseDetailView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n/// Sheet that displays the full FCM response diagnostics after a send attempt.\nstruct FCMResponseDetailView: View {\n  let response: FCMResponse?\n  @Environment(\\.dismiss) private var dismiss\n\n  var body: some View {\n    VStack(spacing: 12) {\n      HStack {\n        Text(\"FCM Response\")\n          .font(.headline)\n        Spacer()\n        Button(\"Close\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      if let response {\n        VStack(alignment: .leading, spacing: 0) {\n          // Status header\n          HStack(spacing: 8) {\n            Image(systemName: response.isSuccess ? \"checkmark.circle.fill\" : \"xmark.circle.fill\")\n              .foregroundColor(response.isSuccess ? .green : .red)\n              .font(.title2)\n            Text(response.isSuccess ? \"Push Accepted\" : \"Push Rejected\")\n              .font(.title3)\n              .fontWeight(.semibold)\n            Spacer()\n          }\n          .padding(.bottom, 12)\n\n          Group {\n            FCMResponseRow(label: \"Status\", value: \"\\(response.statusCode)\")\n            if let messageId = response.messageId {\n              FCMResponseRow(label: \"Message ID\", value: messageId)\n            }\n            if let errorCode = response.errorCode {\n              FCMResponseRow(label: \"Error Code\", value: errorCode, isError: true)\n            }\n            if let errorMessage = response.errorMessage {\n              FCMResponseRow(label: \"Error Message\", value: errorMessage, isError: true)\n            }\n            FCMResponseRow(label: \"Project ID\", value: response.projectId)\n            FCMResponseRow(label: \"Timestamp\", value: \"\\(response.timestamp) (Unix sec)\")\n          }\n        }\n        .padding()\n        .background(Color(nsColor: .controlBackgroundColor))\n        .cornerRadius(8)\n        .overlay(\n          RoundedRectangle(cornerRadius: 8)\n            .stroke(Color.secondary.opacity(0.2))\n        )\n\n        // Troubleshooting tips\n        if let errorCode = response.errorCode {\n          fcmTroubleshootingTip(for: errorCode)\n        } else if let errorMessage = response.errorMessage, !response.isSuccess {\n          fcmTroubleshootingTip(for: errorMessage)\n        }\n\n        HStack {\n          Button(\"Copy Diagnostics\") {\n            NSPasteboard.general.clearContents()\n            NSPasteboard.general.setString(response.diagnosticDetails, forType: .string)\n          }\n          .help(\"Copy full diagnostic info to clipboard\")\n          Spacer()\n        }\n      } else {\n        Text(\"No response yet. Send a push first.\")\n          .foregroundColor(.secondary)\n      }\n\n      Spacer()\n    }\n    .padding()\n    .frame(minWidth: 420, minHeight: 300)\n  }\n\n  @ViewBuilder\n  private func fcmTroubleshootingTip(for code: String) -> some View {\n    let tip: String? = switch code {\n    case \"INVALID_ARGUMENT\":\n      \"The request payload is invalid. Check that the FCM registration token is correct and the message payload is well-formed.\"\n    case \"NOT_FOUND\", \"UNREGISTERED\":\n      \"The FCM registration token is no longer valid. The device may have uninstalled the app or the token has expired. Remove this token from your records.\"\n    case \"SENDER_ID_MISMATCH\":\n      \"The token was registered with a different sender (project). Ensure you are using the correct Firebase project and service account.\"\n    case \"QUOTA_EXCEEDED\":\n      \"Sending rate exceeded. Slow down your message sending rate and retry with exponential backoff.\"\n    case \"UNAVAILABLE\":\n      \"The FCM service is temporarily unavailable. Retry with exponential backoff.\"\n    case \"INTERNAL\":\n      \"An internal error occurred on the FCM server. Retry with exponential backoff.\"\n    default:\n      nil\n    }\n\n    if let tip {\n      FCMTipBox(icon: \"lightbulb.fill\", color: .yellow, text: tip)\n    }\n  }\n}\n\nprivate struct FCMTipBox: View {\n  let icon: String\n  let color: Color\n  let text: String\n\n  var body: some View {\n    HStack(alignment: .top, spacing: 8) {\n      Image(systemName: icon)\n        .foregroundColor(color)\n      Text(.init(text))\n        .font(.callout)\n        .foregroundColor(.secondary)\n    }\n    .padding(10)\n    .background(color.opacity(0.1))\n    .cornerRadius(6)\n  }\n}\n\nprivate struct FCMResponseRow: View {\n  let label: String\n  let value: String\n  var isError: Bool = false\n\n  var body: some View {\n    HStack(alignment: .top) {\n      Text(label)\n        .foregroundColor(.secondary)\n        .frame(width: 110, alignment: .trailing)\n      Text(value)\n        .fontWeight(isError ? .semibold : .regular)\n        .foregroundColor(isError ? .red : .primary)\n        .textSelection(.enabled)\n      Spacer()\n    }\n    .padding(.vertical, 3)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/FCMView.swift",
    "content": "//\n//  FCMView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\nimport AppKit\n\nstruct FCMView: View {\n  var isActive: Bool = true\n  @Environment(WindowManager.self) var windowManager\n  @State private var viewModel = FCMViewModel()\n\n  var body: some View {\n    VStack(spacing: 16) {\n      // Title bar\n      HStack {\n        Text(\"FCM\")\n          .font(.headline)\n        Spacer()\n        if viewModel.lastResponse != nil {\n          Button {\n            viewModel.showResponseSheet = true\n          } label: {\n            HStack(spacing: 4) {\n              Circle()\n                .fill(viewModel.lastResponse?.isSuccess == true ? Color.green : Color.red)\n                .frame(width: 6, height: 6)\n              Text(\"Response\")\n            }\n          }\n          .controlSize(.small)\n        }\n        Button(\"cURL\") {\n          viewModel.curlCommand = viewModel.generateCurlCommand()\n          viewModel.showCurlSheet = true\n        }\n        .controlSize(.small)\n        .disabled(!viewModel.canSend)\n        Button {\n          viewModel.send()\n        } label: {\n          HStack(spacing: 4) {\n            Text(\"Send\")\n            HStack(spacing: 1) {\n              Image(systemName: \"command\")\n              Image(systemName: \"return\")\n            }\n            .font(.caption2)\n            .opacity(0.7)\n          }\n        }\n        .applying { view in\n          if isActive {\n            view.keyboardShortcut(.return, modifiers: .command)\n          } else {\n            view\n          }\n        }\n        .applying { view in\n          if #available(macOS 26.0, *) {\n            view.buttonStyle(.glassProminent)\n          } else {\n            view.buttonStyle(.borderedProminent)\n          }\n        }\n        .disabled(!viewModel.canSend)\n      }\n\n      ScrollView(.vertical, showsIndicators: false) {\n        VStack(alignment: .leading, spacing: 16) {\n          // Token Section\n          VStack(alignment: .leading, spacing: 8) {\n            Text(\"FCM Registration Token:\")\n              .font(.subheadline)\n\n            // Saved tokens\n            ForEach(viewModel.savedTokens) { saved in\n              SavedTokenRowView(\n                savedToken: saved,\n                onToggle: {\n                  if let index = viewModel.savedTokens.firstIndex(where: { $0.id == saved.id }) {\n                    viewModel.savedTokens[index].isEnabled.toggle()\n                    SavedTokenStore.fcm.saveTokens(viewModel.savedTokens)\n                  }\n                },\n                onCopy: {\n                  NSPasteboard.general.clearContents()\n                  NSPasteboard.general.setString(saved.token, forType: .string)\n                  viewModel.showToastMessage(\"Token copied to clipboard\", type: .success)\n                },\n                onRemove: {\n                  SavedTokenStore.fcm.removeToken(id: saved.id)\n                  viewModel.savedTokens.removeAll { $0.id == saved.id }\n                }\n              )\n            }\n\n            // Unsaved token rows\n            ForEach(viewModel.tokens.indices, id: \\.self) { index in\n              HStack(spacing: 8) {\n                TextField(\"FCM registration token\", text: $viewModel.tokens[index])\n                  .textFieldStyle(.roundedBorder)\n\n                Button(action: { viewModel.pasteToken(at: index) }) {\n                  Image(systemName: \"doc.on.clipboard\")\n                    .foregroundColor(.secondary)\n                }\n                .buttonStyle(.plain)\n                .focusable(false)\n                .help(\"Paste token from clipboard\")\n\n                Button(action: {\n                  viewModel.tokenToSave = TokenToSave(index: index, token: viewModel.tokens[index])\n                }) {\n                  Image(systemName: \"square.and.arrow.down\")\n                    .foregroundColor(.secondary)\n                }\n                .buttonStyle(.plain)\n                .focusable(false)\n                .disabled(viewModel.tokens[index].trimmingCharacters(in: .whitespaces).isEmpty)\n                .help(\"Save this token for future sessions\")\n\n                if viewModel.tokens.count > 1 || !viewModel.savedTokens.isEmpty {\n                  Button(action: { viewModel.tokens.remove(at: index) }) {\n                    Image(systemName: \"minus.circle.fill\")\n                      .foregroundColor(.red)\n                  }\n                  .buttonStyle(.plain)\n                  .focusable(false)\n                }\n              }\n            }\n\n            Button(action: { viewModel.tokens.append(\"\") }) {\n              HStack {\n                Image(systemName: \"plus.circle.fill\")\n                Text(\"Add Token\")\n              }\n            }\n            .buttonStyle(.borderless)\n            .padding(.top, 5)\n          }\n\n          // FCM Configuration\n          FCMConfigurationView(config: viewModel.config)\n\n          Divider()\n\n          // Message Type Picker\n          HStack {\n            Text(\"Message Type:\")\n            Picker(\"\", selection: $viewModel.messageType) {\n              Text(\"Notification\").tag(FCMMessageType.notification)\n              Text(\"Data\").tag(FCMMessageType.data)\n            }\n            .pickerStyle(.segmented)\n            HelpButton(helpText: \"Notification — displays a visible notification on the device.\\nData — delivers a data payload to the app without showing a notification UI.\")\n          }\n\n          // Notification Fields\n          if viewModel.messageType == .notification {\n            VStack(alignment: .leading, spacing: 8) {\n              InputField(label: \"Title\", text: $viewModel.title, helpText: \"Title of the notification\")\n              InputField(label: \"Body\", text: $viewModel.body, helpText: \"Main message content\")\n              InputField(label: \"Image URL\", text: $viewModel.imageUrl, helpText: \"URL of an image to display in the notification\")\n\n              Text(\"Android\")\n                .font(.subheadline)\n                .fontWeight(.medium)\n                .padding(.top, 4)\n              InputField(label: \"Channel ID\", text: $viewModel.channelId, helpText: \"Android notification channel ID. The app must create this channel. Use \\\"default\\\" for the default channel.\")\n              InputField(label: \"Sound\", text: $viewModel.sound, helpText: \"Sound to play. Use \\\"default\\\" for the device default sound.\")\n              // Color row: hex input + presets + color picker\n              HStack(spacing: 6) {\n                Text(\"Color:\")\n                TextField(\"e.g. FF5733\", text: $viewModel.color)\n                  .textFieldStyle(.roundedBorder)\n                ForEach([\"FF3B30\", \"FF9500\", \"007AFF\"], id: \\.self) { preset in\n                  Circle()\n                    .fill(Color(hex: preset))\n                    .frame(width: 18, height: 18)\n                    .overlay(\n                      Circle()\n                        .stroke(\n                          viewModel.color.uppercased() == preset\n                            ? Color.primary.opacity(0.7) : Color.clear,\n                          lineWidth: 2\n                        )\n                        .padding(-2)\n                    )\n                    .onTapGesture { viewModel.color = preset }\n                }\n                FCMColorWell(hex: $viewModel.color)\n                  .frame(width: 22, height: 22)\n                  .padding(.leading, 4)\n                HelpButton(helpText: \"Hex color that tints the notification's small icon in the status bar (e.g. FF5733 — no # prefix). This affects the icon accent color, not the notification background. Visibility depends on Android version and device.\")\n              }\n            }\n          }\n\n          // Priority\n          HStack {\n            Text(\"Priority:\")\n            Picker(\"\", selection: $viewModel.priority) {\n              Text(\"HIGH\").tag(\"HIGH\")\n              Text(\"NORMAL\").tag(\"NORMAL\")\n            }\n            .pickerStyle(.segmented)\n            HelpButton(helpText: \"HIGH — wakes the device immediately (use for user-visible notifications).\\nNORMAL — may be delayed for battery optimization.\")\n          }\n\n          // Custom Data\n          KeyValueInputView(data: $viewModel.customData)\n        }\n      }\n    }\n    .padding(.horizontal)\n    .padding(.top)\n    .overlay(\n      ToastView(message: viewModel.toastMessage, type: viewModel.toastType, isPresented: $viewModel.showToast)\n        .animation(.easeInOut, value: viewModel.showToast)\n    )\n    .adaptivePresentation(isPresented: $viewModel.showResponseSheet, isPinned: windowManager.isPinned) {\n      FCMResponseDetailView(response: viewModel.lastResponse)\n    }\n    .adaptivePresentation(isPresented: $viewModel.showCurlSheet, isPinned: windowManager.isPinned) {\n      FCMCurlCommandView(curlCommand: viewModel.curlCommand)\n    }\n    .adaptivePresentation(item: $viewModel.tokenToSave, isPinned: windowManager.isPinned) { item in\n      SaveTokenSheet(\n        token: item.token,\n        onSave: { label in\n          let savedToken = SavedToken(label: label, token: item.token)\n          SavedTokenStore.fcm.addToken(savedToken)\n          viewModel.savedTokens.append(savedToken)\n          if item.index < viewModel.tokens.count {\n            viewModel.tokens.remove(at: item.index)\n          }\n          if viewModel.tokens.isEmpty {\n            viewModel.tokens.append(\"\")\n          }\n        },\n        warningText: \"FCM registration tokens may change when the user reinstalls the app or clears app data.\"\n      )\n    }\n  }\n}\n\n// MARK: - Native color well (renders as a square swatch, opens system color panel)\n\nprivate struct FCMColorWell: NSViewRepresentable {\n  @Binding var hex: String\n\n  func makeNSView(context: Context) -> NSColorWell {\n    let well = NSColorWell()\n    well.color = NSColor(Color(hex: hex))\n    well.target = context.coordinator\n    well.action = #selector(Coordinator.colorChanged(_:))\n    return well\n  }\n\n  func updateNSView(_ well: NSColorWell, context: Context) {\n    // Only update when the hex actually differs to avoid infinite loops\n    if Color(nsColor: well.color).toHexString() != hex.uppercased() {\n      well.color = NSColor(Color(hex: hex))\n    }\n  }\n\n  func makeCoordinator() -> Coordinator { Coordinator(hex: $hex) }\n\n  final class Coordinator: NSObject {\n    @Binding var hex: String\n    init(hex: Binding<String>) { _hex = hex }\n\n    @objc func colorChanged(_ sender: NSColorWell) {\n      hex = Color(nsColor: sender.color).toHexString()\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/FooterView.swift",
    "content": "//\n//  FooterView.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\nimport LaunchAtLogin\n\n/// Footer shown at the bottom of the app with launch-at-login toggle,\n/// pin/unpin button, quit button, and attribution.\nstruct FooterView: View {\n  @Environment(WindowManager.self) var windowManager\n  @State private var showCopied = false\n\n  private var appVersion: String {\n    Bundle.main.infoDictionary?[\"CFBundleShortVersionString\"] as? String ?? \"–\"\n  }\n\n  private let appStoreURL = URL(string: \"https://apple.co/4tvT4wF\")!\n\n  var body: some View {\n    VStack(spacing: 6) {\n      // Main row: launch at login, utility icons, pin\n      HStack(spacing: 12) {\n        LaunchAtLogin.Toggle()\n\n        Spacer()\n\n        Button {\n          NSPasteboard.general.clearContents()\n          NSPasteboard.general.setString(appStoreURL.absoluteString, forType: .string)\n          showCopied = true\n          DispatchQueue.main.asyncAfter(deadline: .now() + 2) {\n            showCopied = false\n          }\n        } label: {\n          Image(systemName: showCopied ? \"checkmark\" : \"square.and.arrow.up\")\n            .animation(.easeInOut, value: showCopied)\n        }\n        .help(\"Share QuickPush — copies App Store link to clipboard\")\n\n        Link(destination: URL(string: \"https://github.com/betomoedano/quick-push\")!) {\n          Image(systemName: \"book.closed\")\n        }\n        .help(\"Open documentation on GitHub\")\n\n        Button {\n          NSWorkspace.shared.open(appStoreURL)\n        } label: {\n          Image(systemName: \"arrow.triangle.2.circlepath\")\n        }\n        .help(\"Check for updates on the App Store\")\n\n        Button {\n          windowManager.togglePin()\n        } label: {\n          Image(systemName: windowManager.isPinned ? \"pin.slash.fill\" : \"pin.fill\")\n        }\n        .help(windowManager.isPinned ? \"Unpin Window\" : \"Pin Window (⌘⇧P)\")\n      }\n      .buttonStyle(.borderless)\n      .imageScale(.medium)\n      .foregroundStyle(.secondary)\n\n      // Bottom row: version left, attribution right\n      HStack {\n        Text(\"v\\(appVersion)\")\n          .foregroundStyle(.tertiary)\n        Spacer()\n        HStack(spacing: 0) {\n          Text(\"Made with ❤️ by \")\n          Link(\"codewithbeto.dev\", destination: URL(string: \"https://codewithbeto.dev\")!)\n            .foregroundColor(.blue)\n        }\n      }\n      .font(.caption)\n    }\n    .padding(.horizontal)\n    .padding(.vertical, 8)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/JSONImportExportView.swift",
    "content": "//\n//  JSONImportExportView.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\nstruct JSONImportExportView: View {\n  @Bindable var viewModel: LiveActivityViewModel\n  @State private var jsonText: String = \"\"\n  @Environment(\\.dismiss) private var dismiss\n\n  var body: some View {\n    VStack(spacing: 12) {\n      HStack {\n        Text(\"JSON Payload\")\n          .font(.headline)\n        Spacer()\n        Button(\"Close\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      TextEditor(text: $jsonText)\n        .font(.system(.body, design: .monospaced))\n        .frame(minHeight: 250)\n        .border(Color.secondary.opacity(0.3))\n\n      HStack {\n        Button(\"Export\") {\n          jsonText = viewModel.exportJSON()\n        }\n        .help(\"Generate JSON from current form values\")\n\n        Button(\"Copy\") {\n          NSPasteboard.general.clearContents()\n          NSPasteboard.general.setString(jsonText, forType: .string)\n        }\n        .help(\"Copy JSON to clipboard\")\n        .disabled(jsonText.isEmpty)\n\n        Spacer()\n\n        Button(\"Paste\") {\n          if let clipboardString = NSPasteboard.general.string(forType: .string) {\n            jsonText = clipboardString\n          }\n        }\n        .help(\"Paste JSON from clipboard\")\n\n        Button(\"Import\") {\n          if viewModel.importJSON(jsonText) {\n            dismiss()\n          }\n        }\n        .help(\"Apply JSON to form fields\")\n        .disabled(jsonText.isEmpty)\n      }\n    }\n    .padding()\n    .frame(minWidth: 400, minHeight: 350)\n    .onAppear {\n      jsonText = viewModel.exportJSON()\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/KeyValueInputView.swift",
    "content": "//\n//  KeyValueInputView.swift\n//  QuickPush\n//\n//  Created by beto on 2/20/25.\n//\n\nimport SwiftUI\n\nstruct KeyValueInputView: View {\n    @Binding var data: [String: String]\n    @State private var editingKeys: [String: String] = [:] // Temporary key storage\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            HStack {\n                Text(\"Custom Data:\")\n                    .font(.subheadline)\n                HelpButton(helpText: \"Add custom JSON data to be sent with the notification. Up to 4KB total payload size.\")\n            }\n            \n            if data.isEmpty {\n                Text(\"No custom data\")\n                    .foregroundColor(.secondary)\n                    .font(.subheadline)\n                    .padding(.vertical, 4)\n            }\n            \n            ForEach(Array(data.keys), id: \\.self) { key in\n                HStack(spacing: 8) {\n                    // Key Input\n                    TextField(\"Key\", text: Binding(\n                        get: { editingKeys[key] ?? key },\n                        set: { newKey in\n                            let sanitizedKey = sanitizeKey(newKey)\n                            editingKeys[key] = sanitizedKey\n                        }\n                    ))\n                    .textFieldStyle(RoundedBorderTextFieldStyle())\n                    .frame(width: 120)\n                    .onSubmit {\n                        finalizeKeyEdit(oldKey: key)\n                    }\n\n                    Text(\":\")\n                        .foregroundColor(.secondary)\n                    \n                    // Value Input\n                    TextField(\"Value\", text: Binding(\n                        get: { data[key] ?? \"\" },\n                        set: { newValue in data[key] = newValue }\n                    ))\n                    .textFieldStyle(RoundedBorderTextFieldStyle())\n\n                    // Remove Button\n                    Button(action: { removeKey(key) }) {\n                        Image(systemName: \"minus.circle.fill\")\n                            .foregroundColor(.red)\n                    }\n                    .buttonStyle(PlainButtonStyle())\n                    .help(\"Remove this key-value pair\")\n                }\n            }\n            \n            // Add Button\n            Button(action: { addNewKeyValue() }) {\n                HStack {\n                    Image(systemName: \"plus.circle.fill\")\n                    Text(\"Add Key-Value Pair\")\n                }\n            }\n            .buttonStyle(.borderless)\n            .disabled(data.count >= 10) // Prevent too many items\n            .help(data.count >= 10 ? \"Maximum number of custom data pairs reached\" : \"Add a new key-value pair\")\n            .padding(.top, 4)\n        }\n        .padding(.vertical, 4)\n    }\n    \n    // MARK: - Helper Functions\n    \n    /// Sanitizes a key: lowercase, replaces spaces with \"-\", removes invalid characters\n    private func sanitizeKey(_ key: String) -> String {\n        let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: \"-\"))\n        return key\n            .lowercased()\n            .replacingOccurrences(of: \" \", with: \"-\") // Replace spaces with \"-\"\n            .components(separatedBy: allowedCharacters.inverted) // Remove special characters\n            .joined()\n    }\n\n    /// Finalizes key edit: prevents empty or duplicate keys\n    private func finalizeKeyEdit(oldKey: String) {\n        if let newKey = editingKeys[oldKey]?.trimmingCharacters(in: .whitespacesAndNewlines),\n           !newKey.isEmpty,\n           newKey != oldKey,\n           !data.keys.contains(newKey) { // Prevent duplicate keys\n            if let value = data.removeValue(forKey: oldKey) {\n                data[newKey] = value\n            }\n        }\n        editingKeys[oldKey] = nil // Clear temp key storage\n    }\n\n    /// Adds a new key-value pair with a default key\n    private func addNewKeyValue() {\n        guard data.count < 10 else { return } // Safety check\n        \n        let baseKey = \"key\"\n        var newKey = baseKey\n        var counter = 1\n\n        while data.keys.contains(newKey) {\n            newKey = \"\\(baseKey)\\(counter)\"\n            counter += 1\n        }\n\n        data[newKey] = \"\"\n        editingKeys[newKey] = newKey\n    }\n\n    /// Removes a key-value pair\n    private func removeKey(_ key: String) {\n        data.removeValue(forKey: key)\n        editingKeys.removeValue(forKey: key)\n    }\n}\n"
  },
  {
    "path": "QuickPush/Views/LiveActivityAlertSection.swift",
    "content": "//\n//  LiveActivityAlertSection.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\nstruct LiveActivityAlertSection: View {\n  @Bindable var viewModel: LiveActivityViewModel\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Toggle(\"Include Alert\", isOn: $viewModel.includeAlert)\n\n      if viewModel.includeAlert {\n        VStack(alignment: .leading, spacing: 8) {\n          HStack {\n            Text(\"Alert Title:\")\n            TextField(\"Alert title\", text: $viewModel.alertTitle)\n              .textFieldStyle(.roundedBorder)\n          }\n\n          HStack {\n            Text(\"Alert Body:\")\n            TextField(\"Alert body\", text: $viewModel.alertBody)\n              .textFieldStyle(.roundedBorder)\n          }\n\n          HStack {\n            Text(\"Sound:\")\n            TextField(\"default\", text: $viewModel.alertSound)\n              .textFieldStyle(.roundedBorder)\n          }\n        }\n        .padding(.leading, 16)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/LiveActivityAttributesSection.swift",
    "content": "//\n//  LiveActivityAttributesSection.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\nstruct LiveActivityAttributesSection: View {\n  @Bindable var viewModel: LiveActivityViewModel\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Text(\"Attributes\")\n        .font(.subheadline)\n        .fontWeight(.medium)\n\n      HStack {\n        Text(\"Name:\")\n        TextField(\"Attribute name\", text: $viewModel.attributeName)\n          .textFieldStyle(.roundedBorder)\n      }\n\n      // Color Pickers\n      ColorPickerField(label: \"Background\", color: $viewModel.backgroundColor)\n      ColorPickerField(label: \"Title Color\", color: $viewModel.titleColor)\n      ColorPickerField(label: \"Subtitle Color\", color: $viewModel.subtitleColor)\n      ColorPickerField(label: \"Progress Tint\", color: $viewModel.progressViewTint)\n      ColorPickerField(label: \"Progress Label\", color: $viewModel.progressViewLabelColor)\n\n      HStack {\n        Text(\"Deep Link URL:\")\n        TextField(\"e.g. /dashboard\", text: $viewModel.deepLinkUrl)\n          .textFieldStyle(.roundedBorder)\n      }\n\n      // Timer Type\n      HStack {\n        Text(\"Timer Type:\")\n        Picker(\"\", selection: $viewModel.timerType) {\n          Text(\"Digital\").tag(\"digital\")\n          Text(\"Circular\").tag(\"circular\")\n        }\n        .pickerStyle(.segmented)\n      }\n\n      // Image Position\n      HStack {\n        Text(\"Image Position:\")\n        Picker(\"\", selection: $viewModel.imagePosition) {\n          Text(\"Left\").tag(\"left\")\n          Text(\"Right\").tag(\"right\")\n        }\n        .pickerStyle(.segmented)\n      }\n\n      // Image Size\n      HStack {\n        Text(\"Image Size:\")\n        TextField(\"W\", value: $viewModel.imageWidth, format: .number)\n          .textFieldStyle(.roundedBorder)\n          .frame(width: 60)\n        Text(\"x\")\n        TextField(\"H\", value: $viewModel.imageHeight, format: .number)\n          .textFieldStyle(.roundedBorder)\n          .frame(width: 60)\n      }\n\n      // Padding\n      Toggle(\"Custom Padding\", isOn: $viewModel.useCustomPadding)\n      if viewModel.useCustomPadding {\n        HStack {\n          Text(\"T:\")\n          TextField(\"\", value: $viewModel.paddingTop, format: .number)\n            .textFieldStyle(.roundedBorder)\n            .frame(width: 45)\n          Text(\"B:\")\n          TextField(\"\", value: $viewModel.paddingBottom, format: .number)\n            .textFieldStyle(.roundedBorder)\n            .frame(width: 45)\n          Text(\"L:\")\n          TextField(\"\", value: $viewModel.paddingLeft, format: .number)\n            .textFieldStyle(.roundedBorder)\n            .frame(width: 45)\n          Text(\"R:\")\n          TextField(\"\", value: $viewModel.paddingRight, format: .number)\n            .textFieldStyle(.roundedBorder)\n            .frame(width: 45)\n        }\n        .padding(.leading, 16)\n      } else {\n        HStack {\n          Text(\"Padding:\")\n          TextField(\"\", value: $viewModel.uniformPadding, format: .number)\n            .textFieldStyle(.roundedBorder)\n            .frame(width: 60)\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/LiveActivityContentStateSection.swift",
    "content": "//\n//  LiveActivityContentStateSection.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\nstruct LiveActivityContentStateSection: View {\n  @Bindable var viewModel: LiveActivityViewModel\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Text(\"Content State\")\n        .font(.subheadline)\n        .fontWeight(.medium)\n\n      HStack {\n        Text(\"Title:\")\n        TextField(\"Activity title\", text: $viewModel.contentTitle)\n          .textFieldStyle(.roundedBorder)\n      }\n\n      HStack {\n        Text(\"Subtitle:\")\n        TextField(\"Activity subtitle\", text: $viewModel.contentSubtitle)\n          .textFieldStyle(.roundedBorder)\n      }\n\n      // Progress\n      Toggle(\"Include Progress\", isOn: $viewModel.includeProgress)\n      if viewModel.includeProgress {\n        HStack {\n          Text(\"Progress:\")\n          Slider(value: $viewModel.progress, in: 0...1, step: 0.01)\n          Text(\"\\(Int(viewModel.progress * 100))%\")\n            .frame(width: 40, alignment: .trailing)\n            .font(.system(.body, design: .monospaced))\n        }\n        .padding(.leading, 16)\n      }\n\n      // Timer End Date\n      Toggle(\"Include Timer End Date\", isOn: $viewModel.includeTimerEnd)\n      if viewModel.includeTimerEnd {\n        HStack {\n          Text(\"Timer End:\")\n          DatePicker(\"\", selection: $viewModel.timerEndDate)\n            .labelsHidden()\n        }\n        .padding(.leading, 16)\n      }\n\n      HStack {\n        Text(\"Image Name:\")\n        TextField(\"Optional image name\", text: $viewModel.imageName)\n          .textFieldStyle(.roundedBorder)\n      }\n\n      HStack {\n        Text(\"DI Image:\")\n        TextField(\"Dynamic Island image\", text: $viewModel.dynamicIslandImageName)\n          .textFieldStyle(.roundedBorder)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/LiveActivityView.swift",
    "content": "//\n//  LiveActivityView.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\nstruct LiveActivityView: View {\n  var isActive: Bool = true\n  @Environment(WindowManager.self) var windowManager\n  @State private var viewModel = LiveActivityViewModel()\n\n  var body: some View {\n    VStack(spacing: 16) {\n      // Title bar\n      HStack {\n        Text(\"Live Activity\")\n          .font(.headline)\n        Spacer()\n        if viewModel.lastResponse != nil {\n          Button {\n            viewModel.showResponseSheet = true\n          } label: {\n            HStack(spacing: 4) {\n              Circle()\n                .fill(viewModel.lastResponse?.isSuccess == true ? Color.green : Color.red)\n                .frame(width: 6, height: 6)\n              Text(\"Response\")\n            }\n          }\n          .controlSize(.small)\n        }\n        Button(\"JSON\") {\n          viewModel.showJSONSheet = true\n        }\n        .controlSize(.small)\n        Button {\n          viewModel.send()\n        } label: {\n          HStack(spacing: 4) {\n            Text(\"Send\")\n            HStack(spacing: 1) {\n              Image(systemName: \"command\")\n              Image(systemName: \"return\")\n            }\n            .font(.caption2)\n            .opacity(0.7)\n          }\n        }\n        .applying { view in\n          if isActive {\n            view.keyboardShortcut(.return, modifiers: .command)\n          } else {\n            view\n          }\n        }\n        .applying { view in\n          if #available(macOS 26.0, *) {\n            view.buttonStyle(.glassProminent)\n          } else {\n            view.buttonStyle(.borderedProminent)\n          }\n        }\n        .disabled(!viewModel.canSend)\n      }\n\n      ScrollView(.vertical, showsIndicators: false) {\n        VStack(alignment: .leading, spacing: 16) {\n          // Event Type Picker\n          HStack {\n            Text(\"Event:\")\n            Picker(\"\", selection: $viewModel.eventType) {\n              Text(\"Start\").tag(LiveActivityEvent.start)\n              Text(\"Update\").tag(LiveActivityEvent.update)\n              Text(\"End\").tag(LiveActivityEvent.end)\n            }\n            .pickerStyle(.segmented)\n          }\n\n          // Token Field\n          VStack(alignment: .leading, spacing: 8) {\n            Text(\"\\(viewModel.tokenLabel):\")\n              .font(.subheadline)\n            HStack {\n              TextField(\"Hex device token\", text: $viewModel.deviceToken)\n                .textFieldStyle(.roundedBorder)\n              Button(action: { viewModel.pasteToken() }) {\n                Image(systemName: \"doc.on.clipboard\")\n                  .foregroundColor(.secondary)\n              }\n              .buttonStyle(.plain)\n              .help(\"Paste hex token from clipboard\")\n            }\n          }\n\n          Divider()\n\n          // APNs Configuration\n          APNsConfigurationView(config: viewModel.config)\n\n          HStack {\n            Text(\"Attributes Type:\")\n              .frame(width: 95, alignment: .leading)\n            TextField(\"e.g. MyWidget.LiveActivityAttributes\", text: $viewModel.attributesType)\n              .textFieldStyle(.roundedBorder)\n            HelpButton(helpText: \"Must match the fully module-qualified Swift type name of your ActivityAttributes struct. Run this in your iOS app to get the exact value:\\n\\nprint(String(reflecting: LiveActivityAttributes.self))\\n\\nCommon formats:\\n• MyWidgetExtension.LiveActivityAttributes\\n• LiveActivityAttributes\\n\\nA mismatch causes iOS to silently drop the push even if APNs returns 200.\")\n          }\n\n          Divider()\n\n          // Content State\n          LiveActivityContentStateSection(viewModel: viewModel)\n\n          // Attributes (Start only)\n          if viewModel.eventType == .start {\n            Divider()\n            LiveActivityAttributesSection(viewModel: viewModel)\n          }\n\n          // Alert (Start only)\n          if viewModel.eventType == .start {\n            Divider()\n            LiveActivityAlertSection(viewModel: viewModel)\n          }\n        }\n      }\n    }\n    .padding(.horizontal)\n    .padding(.top)\n    .overlay(\n      ToastView(message: viewModel.toastMessage, type: viewModel.toastType, isPresented: $viewModel.showToast)\n        .animation(.easeInOut, value: viewModel.showToast)\n    )\n    .adaptivePresentation(isPresented: $viewModel.showJSONSheet, isPinned: windowManager.isPinned) {\n      JSONImportExportView(viewModel: viewModel)\n    }\n    .adaptivePresentation(isPresented: $viewModel.showResponseSheet, isPinned: windowManager.isPinned) {\n      APNsResponseDetailView(response: viewModel.lastResponse)\n    }\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/MainContentView.swift",
    "content": "//\n//  MainContentView.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\n/// Shared root view used by both the MenuBarExtra popover and the floating panel.\nstruct MainContentView: View {\n  @Environment(WindowManager.self) var windowManager\n\n  var body: some View {\n    VStack {\n      ContentView()\n      Divider()\n      FooterView()\n    }\n    .frame(minWidth: 570)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/PushNotificationView.swift",
    "content": "//\n//  PushNotificationView.swift\n//  QuickPush\n//\n//  Created by beto on 2/8/26.\n//\n\nimport SwiftUI\n\nstruct PushNotificationView: View {\n  var isActive: Bool = true\n  @Environment(WindowManager.self) var windowManager\n  @State private var tokens: [String] = [\"\"]\n  @State private var accessToken: String = \"\"\n  @State private var title: String = \"\"\n  @State private var notificationBody: String = \"\"\n  @State private var sound: String = \"default\"\n  @State private var priority: PushNotification.Priority = .default\n  @State private var ttl: String = \"\"\n  @State private var expiration: String = \"\"\n  @State private var data: [String: String] = [:]\n  @State private var showTitleError: Bool = false\n\n  // Advanced fields\n  @State private var showAdvancedSettings: Bool = true\n  @State private var subtitle: String = \"\"\n  @State private var badge: String = \"\"\n  @State private var interruptionLevel: PushNotification.InterruptionLevel = .active\n  @State private var channelId: String = \"\"\n  @State private var categoryId: String = \"\"\n  @State private var mutableContent: Bool = false\n  @State private var contentAvailable: Bool = false\n  @State private var imageUrl: String = \"\"\n\n  // Toast notification state\n  @State private var showToast: Bool = false\n  @State private var toastMessage: String = \"\"\n  @State private var toastType: ToastType = .success\n\n  // Response & cURL sheet state\n  @State private var lastResponse: PushResponse?\n  @State private var lastHttpStatusCode: Int?\n  @State private var lastRawJSON: String?\n  @State private var showResponseSheet: Bool = false\n  @State private var showCurlSheet: Bool = false\n  @State private var showReceiptSheet: Bool = false\n\n  // Saved tokens state\n  @State private var savedTokens: [SavedToken] = []\n  @State private var tokenToSave: TokenToSave? = nil\n\n  var body: some View {\n    VStack {\n      // Title and Send Button\n      HStack {\n        Text(\"Expo Notification\")\n          .font(.headline)\n        Spacer()\n        if lastResponse != nil {\n          Button {\n            showResponseSheet = true\n          } label: {\n            HStack(spacing: 4) {\n              Circle()\n                .fill(responseHasErrors ? Color.red : Color.green)\n                .frame(width: 6, height: 6)\n              Text(\"Response\")\n            }\n          }\n          .controlSize(.small)\n        }\n        if !lastTicketIds.isEmpty {\n          Button(\"Receipt\") {\n            showReceiptSheet = true\n          }\n          .controlSize(.small)\n        }\n        Button(\"cURL\") {\n          showCurlSheet = true\n        }\n        .controlSize(.small)\n        .disabled(allValidTokens.isEmpty || title.isEmpty)\n        Button {\n          sendPushNotification()\n        } label: {\n          HStack(spacing: 4) {\n            Text(\"Send Push\")\n            HStack(spacing: 1) {\n              Image(systemName: \"command\")\n              Image(systemName: \"return\")\n            }\n            .font(.caption2)\n            .opacity(0.7)\n          }\n        }\n        .applying { view in\n          if isActive {\n            view.keyboardShortcut(.return, modifiers: .command)\n          } else {\n            view\n          }\n        }\n        .applying { view in\n          if #available(macOS 26.0, *) {\n            view.buttonStyle(.glassProminent)\n          } else {\n            view.buttonStyle(.borderedProminent)\n          }\n        }\n        .disabled(allValidTokens.isEmpty)\n      }\n\n      // Main Content\n      ScrollView(.vertical, showsIndicators: false) {\n        VStack(alignment: .leading, spacing: 12) {\n          // Basic Fields Section\n          VStack(alignment: .leading, spacing: 12) {\n            // Tokens Section\n            Text(\"Expo Push Tokens:\")\n              .font(.subheadline)\n\n            // Saved tokens (compact display)\n            ForEach(savedTokens) { saved in\n              SavedTokenRowView(\n                savedToken: saved,\n                onToggle: {\n                  if let index = savedTokens.firstIndex(where: { $0.id == saved.id }) {\n                    savedTokens[index].isEnabled.toggle()\n                    SavedTokenStore.shared.saveTokens(savedTokens)\n                  }\n                },\n                onCopy: {\n                  NSPasteboard.general.clearContents()\n                  NSPasteboard.general.setString(saved.token, forType: .string)\n                  showToastNotification(message: \"Token copied to clipboard\", type: .success)\n                },\n                onRemove: {\n                  SavedTokenStore.shared.removeToken(id: saved.id)\n                  savedTokens.removeAll { $0.id == saved.id }\n                }\n              )\n            }\n\n            // Unsaved token rows (text fields)\n            ForEach(tokens.indices, id: \\.self) { index in\n              HStack(spacing: 8) {\n                TextField(\"e.g. ExponentPushToken[N1QHiEF4mnLGP8HeQrj9AR]\", text: $tokens[index])\n                  .textFieldStyle(RoundedBorderTextFieldStyle())\n                  .padding(.leading, 4)\n\n                // Paste from clipboard\n                Button(action: {\n                  if let clipboardString = NSPasteboard.general.string(forType: .string) {\n                    let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines)\n                    if trimmed.count <= 41 && trimmed.starts(with: \"ExponentPushToken[\") && trimmed.hasSuffix(\"]\") {\n                      tokens[index] = trimmed\n                    } else {\n                      showToastNotification(message: \"Token should be in format: ExponentPushToken[xxxxxxxxxxxxxxxxxxxxx]\", type: .error)\n                    }\n                  }\n                }) {\n                  Image(systemName: \"doc.on.clipboard\")\n                    .foregroundColor(.secondary)\n                }\n                .buttonStyle(.plain)\n                .help(\"Paste from clipboard (ExponentPushToken[xxxxxxxxxxxxxxxxxxxxx])\")\n\n                // Save token for future sessions\n                Button(action: {\n                  tokenToSave = TokenToSave(index: index, token: tokens[index])\n                }) {\n                  Image(systemName: \"square.and.arrow.down\")\n                    .foregroundColor(.secondary)\n                }\n                .buttonStyle(.plain)\n                .disabled(tokens[index].trimmingCharacters(in: .whitespaces).isEmpty)\n                .help(\"Save this token for future sessions\")\n\n                // Delete token row\n                if tokens.count > 1 || !savedTokens.isEmpty {\n                  Button(action: { tokens.remove(at: index) }) {\n                    Image(systemName: \"minus.circle.fill\")\n                      .foregroundColor(.red)\n                  }\n                  .buttonStyle(PlainButtonStyle())\n                }\n              }\n            }\n\n            Button(action: { tokens.append(\"\") }) {\n              HStack {\n                Image(systemName: \"plus.circle.fill\")\n                Text(\"Add Token\")\n              }\n            }\n            .buttonStyle(.borderless)\n            .padding(.top, 5)\n\n            // Access Token Section\n            VStack(alignment: .leading, spacing: 8) {\n              HStack {\n                Text(\"Access Token (Optional):\")\n                  .font(.subheadline)\n                HelpButton(helpText: \"Enhanced push security token. Required if you've enabled push security in your EAS Dashboard.\")\n              }\n\n              HStack {\n                SecureField(\"Access token for enhanced security\", text: $accessToken)\n                  .textFieldStyle(RoundedBorderTextFieldStyle())\n                  .padding(.leading, 4)\n\n                Button(action: {\n                  if let clipboardString = NSPasteboard.general.string(forType: .string) {\n                    let trimmed = clipboardString.trimmingCharacters(in: .whitespacesAndNewlines)\n                    accessToken = trimmed\n                  }\n                }) {\n                  Image(systemName: \"doc.on.clipboard\")\n                    .foregroundColor(.secondary)\n                }\n                .buttonStyle(.plain)\n                .help(\"Paste access token from clipboard\")\n              }\n            }\n\n            Divider()\n\n            // Basic Notification Fields\n            VStack(alignment: .leading, spacing: 8) {\n              InputField(label: \"Title\", text: $title, helpText: \"Title of the notification\", isRequired: true, showError: showTitleError)\n              InputField(label: \"Body\", text: $notificationBody, helpText: \"Message content displayed in the notification\")\n\n              // Priority Picker\n              HStack {\n                Text(\"Priority:\")\n                Picker(\"\", selection: $priority) {\n                  Text(\"Default\").tag(PushNotification.Priority.default)\n                  Text(\"Normal\").tag(PushNotification.Priority.normal)\n                  Text(\"High\").tag(PushNotification.Priority.high)\n                }\n                .pickerStyle(SegmentedPickerStyle())\n\n                HelpButton(helpText: \"Affects delivery timing. 'High' wakes sleeping devices.\")\n              }\n\n              Divider()\n              KeyValueInputView(data: $data)\n            }\n          }\n\n          // Advanced Settings Toggle\n          Button(action: { showAdvancedSettings.toggle() }) {\n            HStack {\n              Text(\"Advanced Settings\")\n                .font(.subheadline)\n              Spacer()\n              Image(systemName: showAdvancedSettings ? \"chevron.up\" : \"chevron.down\")\n            }\n          }\n          .buttonStyle(.borderless)\n\n          if showAdvancedSettings {\n            VStack(alignment: .leading, spacing: 12) {\n              // iOS Specific Settings\n              Group {\n                Text(\"iOS Specific\")\n                  .font(.subheadline)\n                  .foregroundColor(.secondary)\n\n                InputField(label: \"Sound\", text: $sound, helpText: \"Specify 'default' or custom sound name (iOS only)\")\n                InputField(label: \"Subtitle\", text: $subtitle, helpText: \"Additional text below the title (iOS only)\")\n                InputField(label: \"Badge\", text: $badge, helpText: \"Number to display on app icon (iOS only)\")\n\n                // Interruption Level Picker\n                HStack {\n                  Text(\"Interruption Level:\")\n                  Picker(\"\", selection: $interruptionLevel) {\n                    Text(\"Active\").tag(PushNotification.InterruptionLevel.active)\n                    Text(\"Critical\").tag(PushNotification.InterruptionLevel.critical)\n                    Text(\"Passive\").tag(PushNotification.InterruptionLevel.passive)\n                    Text(\"Time Sensitive\").tag(PushNotification.InterruptionLevel.timeSensitive)\n                  }\n                  .pickerStyle(MenuPickerStyle())\n\n                  HelpButton(helpText: \"Controls the delivery timing and importance of the notification\")\n                }\n\n                Toggle(\"Mutable Content\", isOn: $mutableContent)\n                  .help(\"Allows notification content modification by the app\")\n\n                Toggle(\"Content Available\", isOn: $contentAvailable)\n                  .help(\"Triggers background fetch on delivery\")\n              }\n\n              Divider()\n\n              // Android Specific Settings\n              Group {\n                Text(\"Android Specific\")\n                  .font(.subheadline)\n                  .foregroundColor(.secondary)\n\n                InputField(label: \"Channel ID\", text: $channelId, helpText: \"Android notification channel identifier\")\n              }\n\n              Divider()\n\n              // Common Advanced Settings\n              Group {\n                Text(\"Common Settings\")\n                  .font(.subheadline)\n                  .foregroundColor(.secondary)\n\n                InputField(label: \"Image (richContent)\", text: $imageUrl, helpText: \"URL of image to display in rich notification. Android shows it out of the box. iOS requires a Notification Service Extension — learn how in this free lesson: https://codewithbeto.dev/rnCourse/expoNotificationsExtension See also https://github.com/expo/expo/pull/36202\")\n                InputField(label: \"Category ID\", text: $categoryId, helpText: \"Notification category for interactive notifications\")\n                InputField(label: \"TTL\", text: $ttl, helpText: \"Time-to-live in seconds\")\n                InputField(label: \"Expiration\", text: $expiration, helpText: \"Unix timestamp for expiration\")\n              }\n            }\n          }\n        }\n      }\n    }\n    .padding(.horizontal)\n    .padding(.top)\n    .overlay(\n      ToastView(message: toastMessage, type: toastType, isPresented: $showToast)\n        .animation(.easeInOut, value: showToast)\n    )\n    .adaptivePresentation(isPresented: $showResponseSheet, isPinned: windowManager.isPinned) {\n      ExpoResponseDetailView(\n        response: lastResponse,\n        httpStatusCode: lastHttpStatusCode,\n        rawJSON: lastRawJSON\n      )\n    }\n    .adaptivePresentation(isPresented: $showCurlSheet, isPinned: windowManager.isPinned) {\n      ExpoCurlCommandView(\n        notification: buildNotification(),\n        accessToken: accessToken.isEmpty ? nil : accessToken\n      )\n    }\n    .adaptivePresentation(isPresented: $showReceiptSheet, isPinned: windowManager.isPinned) {\n      ExpoReceiptView(\n        ticketIds: lastTicketIds,\n        accessToken: accessToken.isEmpty ? nil : accessToken\n      )\n    }\n    .adaptivePresentation(item: $tokenToSave, isPinned: windowManager.isPinned) { item in\n      SaveTokenSheet(token: item.token) { label in\n        let savedToken = SavedToken(label: label, token: item.token)\n        SavedTokenStore.shared.addToken(savedToken)\n        savedTokens.append(savedToken)\n\n        // Move from unsaved to saved\n        if item.index < tokens.count {\n          tokens.remove(at: item.index)\n        }\n        if tokens.isEmpty {\n          tokens.append(\"\")\n        }\n      }\n    }\n    .onAppear {\n      savedTokens = SavedTokenStore.shared.loadTokens()\n    }\n  }\n\n  /// All valid tokens from both saved (enabled only) and unsaved sources.\n  private var allValidTokens: [String] {\n    savedTokens.filter(\\.isEnabled).map(\\.token) + tokens.filter { !$0.isEmpty }\n  }\n\n  /// Ticket IDs from the last successful send (status == \"ok\" tickets only).\n  private var lastTicketIds: [String] {\n    lastResponse?.data?.compactMap { $0.id } ?? []\n  }\n\n  /// Whether the last response contains errors (API-level or per-ticket).\n  private var responseHasErrors: Bool {\n    guard let response = lastResponse else { return false }\n    if let errors = response.errors, !errors.isEmpty { return true }\n    if let tickets = response.data, tickets.contains(where: { $0.status == \"error\" }) { return true }\n    return false\n  }\n\n  /// Build a `PushNotification` from the current form values.\n  private func buildNotification() -> PushNotification {\n    let validTokens = allValidTokens\n    let richContent: PushNotification.RichContent? = imageUrl.isEmpty ? nil : PushNotification.RichContent(image: imageUrl)\n\n    return PushNotification(\n      to: validTokens.isEmpty ? [\"ExponentPushToken[...]\"] : validTokens,\n      title: title.isEmpty ? \"Title\" : title,\n      body: notificationBody.isEmpty ? \" \" : notificationBody,\n      data: data.isEmpty ? nil : data,\n      ttl: Int(ttl),\n      expiration: Int(expiration),\n      priority: priority,\n      subtitle: subtitle.isEmpty ? nil : subtitle,\n      sound: sound.isEmpty ? nil : sound,\n      badge: Int(badge),\n      interruptionLevel: interruptionLevel,\n      channelId: channelId.isEmpty ? nil : channelId,\n      categoryId: categoryId.isEmpty ? nil : categoryId,\n      mutableContent: mutableContent,\n      contentAvailable: contentAvailable,\n      richContent: richContent\n    )\n  }\n\n  private func sendPushNotification() {\n    let validTokens = allValidTokens\n\n    guard !validTokens.isEmpty, !title.isEmpty else {\n      showTitleError = title.isEmpty\n      if title.isEmpty {\n        showToastNotification(message: \"Title is required\", type: .error)\n      }\n      return\n    }\n\n    showTitleError = false\n\n    let notification = buildNotification()\n\n    PushNotificationService.shared.sendPushNotification(\n      notification: notification,\n      accessToken: accessToken.isEmpty ? nil : accessToken\n    ) { result in\n      DispatchQueue.main.async {\n        switch result {\n        case .success(let sendResult):\n          lastResponse = sendResult.response\n          lastHttpStatusCode = sendResult.httpStatusCode\n          lastRawJSON = sendResult.rawJSON\n          print(\"Push sent successfully: \\(sendResult.response)\")\n          showToastNotification(message: \"Push notification sent successfully!\", type: .success)\n        case .failure(let error):\n          print(\"Failed to send push: \\(error.localizedDescription)\")\n          showToastNotification(message: error.localizedDescription, type: .error)\n        }\n      }\n    }\n  }\n\n  private func showToastNotification(message: String, type: ToastType) {\n    toastMessage = message\n    toastType = type\n    showToast = true\n  }\n}\n\n"
  },
  {
    "path": "QuickPush/Views/SaveTokenSheet.swift",
    "content": "//\n//  SaveTokenSheet.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n/// Modal sheet for saving a push token with a user-defined label.\nstruct SaveTokenSheet: View {\n  let token: String\n  let onSave: (String) -> Void\n  var warningText: String = \"Expo push tokens may change when you reinstall the app on your device.\"\n  @Environment(\\.dismiss) private var dismiss\n  @State private var label: String = \"\"\n\n  var body: some View {\n    VStack(spacing: 16) {\n      // Header\n      HStack {\n        Text(\"Save Token\")\n          .font(.headline)\n        Spacer()\n        Button(\"Cancel\") { dismiss() }\n          .keyboardShortcut(.cancelAction)\n      }\n\n      // Explanatory text\n      Text(\"Save this token for future sessions so you don't have to paste it again.\")\n        .font(.callout)\n        .foregroundColor(.secondary)\n        .frame(maxWidth: .infinity, alignment: .leading)\n\n      // Token preview (read-only)\n      Text(token)\n        .font(.system(.caption, design: .monospaced))\n        .foregroundColor(.secondary)\n        .textSelection(.enabled)\n        .frame(maxWidth: .infinity, alignment: .center)\n        .padding(10)\n        .background(Color(nsColor: .controlBackgroundColor))\n        .overlay(\n          RoundedRectangle(cornerRadius: 6)\n            .stroke(Color.secondary.opacity(0.2))\n        )\n        .cornerRadius(6)\n\n      // Label input\n      VStack(alignment: .leading, spacing: 4) {\n        Text(\"Label:\")\n          .font(.subheadline)\n        TextField(\"e.g. iPhone 17 Pro, Android Pixel\", text: $label)\n          .textFieldStyle(.roundedBorder)\n      }\n\n      // Warning note\n      HStack(alignment: .top, spacing: 6) {\n        Image(systemName: \"exclamationmark.triangle.fill\")\n          .foregroundColor(.orange)\n          .font(.caption)\n        Text(warningText)\n          .font(.caption)\n          .foregroundColor(.secondary)\n      }\n      .frame(maxWidth: .infinity, alignment: .leading)\n\n      // Action buttons\n      HStack {\n        Spacer()\n        Button(\"Save\") {\n          onSave(label.trimmingCharacters(in: .whitespaces))\n          dismiss()\n        }\n        .buttonStyle(.borderedProminent)\n        .disabled(label.trimmingCharacters(in: .whitespaces).isEmpty)\n        .keyboardShortcut(.defaultAction)\n      }\n    }\n    .padding()\n    .frame(width: 400)\n  }\n}\n"
  },
  {
    "path": "QuickPush/Views/SavedTokenRowView.swift",
    "content": "//\n//  SavedTokenRowView.swift\n//  QuickPush\n//\n//  Created by beto on 2/19/26.\n//\n\nimport SwiftUI\n\n/// Compact row display for a saved push token, showing label and token.\nstruct SavedTokenRowView: View {\n  let savedToken: SavedToken\n  let onToggle: () -> Void\n  let onCopy: () -> Void\n  let onRemove: () -> Void\n\n  var body: some View {\n    HStack(spacing: 8) {\n      // Enable/disable toggle\n      Button(action: onToggle) {\n        Image(systemName: savedToken.isEnabled ? \"checkmark.circle.fill\" : \"circle\")\n          .foregroundColor(savedToken.isEnabled ? .accentColor : .secondary)\n          .font(.system(size: 14))\n      }\n      .buttonStyle(.plain)\n      .help(savedToken.isEnabled ? \"Disable this token\" : \"Enable this token\")\n\n      VStack(alignment: .leading, spacing: 2) {\n        Text(savedToken.label)\n          .font(.subheadline)\n          .fontWeight(.medium)\n          .lineLimit(1)\n        Text(savedToken.token)\n          .font(.system(.caption2, design: .monospaced))\n          .foregroundColor(.secondary)\n          .lineLimit(1)\n          .truncationMode(.middle)\n      }\n\n      Spacer()\n\n      // Copy token button\n      Button(action: onCopy) {\n        Image(systemName: \"doc.on.doc\")\n          .foregroundColor(.secondary)\n      }\n      .buttonStyle(.plain)\n      .help(\"Copy token to clipboard\")\n\n      // Remove from saved button\n      Button(action: onRemove) {\n        Image(systemName: \"bookmark.slash.fill\")\n          .foregroundColor(.orange)\n      }\n      .buttonStyle(.plain)\n      .help(\"Remove from saved tokens\")\n    }\n    .padding(.vertical, 4)\n    .padding(.horizontal, 8)\n    .background(Color(nsColor: .controlBackgroundColor).opacity(0.5))\n    .cornerRadius(6)\n    .opacity(savedToken.isEnabled ? 1 : 0.5)\n  }\n}\n"
  },
  {
    "path": "QuickPush.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 77;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\tCAF9CB4F2D6824C800E87790 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = CAF9CB4E2D6824C800E87790 /* LaunchAtLogin */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXFileReference section */\n\t\tCAF9CB2E2D67FE0400E87790 /* QuickPush.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickPush.app; sourceTree = BUILT_PRODUCTS_DIR; };\n/* End PBXFileReference section */\n\n/* Begin PBXFileSystemSynchronizedRootGroup section */\n\t\tCAF9CB302D67FE0400E87790 /* QuickPush */ = {\n\t\t\tisa = PBXFileSystemSynchronizedRootGroup;\n\t\t\tpath = QuickPush;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXFileSystemSynchronizedRootGroup section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\tCAF9CB2B2D67FE0400E87790 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tCAF9CB4F2D6824C800E87790 /* LaunchAtLogin in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\tCAF9CB252D67FE0400E87790 = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCAF9CB302D67FE0400E87790 /* QuickPush */,\n\t\t\t\tCAF9CB2F2D67FE0400E87790 /* Products */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tCAF9CB2F2D67FE0400E87790 /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCAF9CB2E2D67FE0400E87790 /* QuickPush.app */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\tCAF9CB2D2D67FE0400E87790 /* QuickPush */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = CAF9CB3D2D67FE0500E87790 /* Build configuration list for PBXNativeTarget \"QuickPush\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tCAF9CB2A2D67FE0400E87790 /* Sources */,\n\t\t\t\tCAF9CB2B2D67FE0400E87790 /* Frameworks */,\n\t\t\t\tCAF9CB2C2D67FE0400E87790 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tfileSystemSynchronizedGroups = (\n\t\t\t\tCAF9CB302D67FE0400E87790 /* QuickPush */,\n\t\t\t);\n\t\t\tname = QuickPush;\n\t\t\tpackageProductDependencies = (\n\t\t\t\tCAF9CB4E2D6824C800E87790 /* LaunchAtLogin */,\n\t\t\t);\n\t\t\tproductName = QuickPush;\n\t\t\tproductReference = CAF9CB2E2D67FE0400E87790 /* QuickPush.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\tCAF9CB262D67FE0400E87790 /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = 1;\n\t\t\t\tLastSwiftUpdateCheck = 1610;\n\t\t\t\tLastUpgradeCheck = 1610;\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\tCAF9CB2D2D67FE0400E87790 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 16.1;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = CAF9CB292D67FE0400E87790 /* Build configuration list for PBXProject \"QuickPush\" */;\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = CAF9CB252D67FE0400E87790;\n\t\t\tminimizedProjectReferenceProxies = 1;\n\t\t\tpackageReferences = (\n\t\t\t\tCAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference \"LaunchAtLogin-Modern\" */,\n\t\t\t);\n\t\t\tpreferredProjectObjectVersion = 77;\n\t\t\tproductRefGroup = CAF9CB2F2D67FE0400E87790 /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\tCAF9CB2D2D67FE0400E87790 /* QuickPush */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\tCAF9CB2C2D67FE0400E87790 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\tCAF9CB2A2D67FE0400E87790 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin XCBuildConfiguration section */\n\t\tCAF9CB3B2D67FE0500E87790 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++20\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu17;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tLOCALIZATION_PREFERS_STRING_CATALOGS = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.1;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = \"DEBUG $(inherited)\";\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tCAF9CB3C2D67FE0500E87790 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++20\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu17;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tLOCALIZATION_PREFERS_STRING_CATALOGS = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.1;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tCAF9CB3E2D67FE0500E87790 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = QuickPushIcon;\n\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;\n\t\t\t\tASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = QuickPush/QuickPush.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = 5;\n\t\t\t\tDEVELOPMENT_ASSET_PATHS = \"\\\"QuickPush/Preview Content\\\"\";\n\t\t\t\tDEVELOPMENT_TEAM = T2A8YY9YDW;\n\t\t\t\tENABLE_HARDENED_RUNTIME = YES;\n\t\t\t\tENABLE_PREVIEWS = YES;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.developer-tools\";\n\t\t\t\tINFOPLIST_KEY_LSUIElement = YES;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 14.6;\n\t\t\t\tMARKETING_VERSION = 2.0.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = dev.codewithbeto.QuickPush;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tCAF9CB3F2D67FE0500E87790 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = QuickPushIcon;\n\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;\n\t\t\t\tASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = QuickPush/QuickPush.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = 5;\n\t\t\t\tDEVELOPMENT_ASSET_PATHS = \"\\\"QuickPush/Preview Content\\\"\";\n\t\t\t\tDEVELOPMENT_TEAM = T2A8YY9YDW;\n\t\t\t\tENABLE_HARDENED_RUNTIME = YES;\n\t\t\t\tENABLE_PREVIEWS = YES;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_KEY_LSApplicationCategoryType = \"public.app-category.developer-tools\";\n\t\t\t\tINFOPLIST_KEY_LSUIElement = YES;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 14.6;\n\t\t\t\tMARKETING_VERSION = 2.0.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = dev.codewithbeto.QuickPush;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\tCAF9CB292D67FE0400E87790 /* Build configuration list for PBXProject \"QuickPush\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCAF9CB3B2D67FE0500E87790 /* Debug */,\n\t\t\t\tCAF9CB3C2D67FE0500E87790 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tCAF9CB3D2D67FE0500E87790 /* Build configuration list for PBXNativeTarget \"QuickPush\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCAF9CB3E2D67FE0500E87790 /* Debug */,\n\t\t\t\tCAF9CB3F2D67FE0500E87790 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\n/* Begin XCRemoteSwiftPackageReference section */\n\t\tCAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference \"LaunchAtLogin-Modern\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/sindresorhus/LaunchAtLogin-Modern.git\";\n\t\t\trequirement = {\n\t\t\t\tbranch = main;\n\t\t\t\tkind = branch;\n\t\t\t};\n\t\t};\n/* End XCRemoteSwiftPackageReference section */\n\n/* Begin XCSwiftPackageProductDependency section */\n\t\tCAF9CB4E2D6824C800E87790 /* LaunchAtLogin */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = CAF9CB4D2D6824C800E87790 /* XCRemoteSwiftPackageReference \"LaunchAtLogin-Modern\" */;\n\t\t\tproductName = LaunchAtLogin;\n\t\t};\n/* End XCSwiftPackageProductDependency section */\n\t};\n\trootObject = CAF9CB262D67FE0400E87790 /* Project object */;\n}\n"
  },
  {
    "path": "QuickPush.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "QuickPush.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved",
    "content": "{\n  \"originHash\" : \"50c271549071e9a517f8a4da8966700ae5e4b29169d39bb1ae8c64c11eb330cd\",\n  \"pins\" : [\n    {\n      \"identity\" : \"launchatlogin-modern\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/sindresorhus/LaunchAtLogin-Modern.git\",\n      \"state\" : {\n        \"branch\" : \"main\",\n        \"revision\" : \"a04ec1c363be3627734f6dad757d82f5d4fa8fcc\"\n      }\n    }\n  ],\n  \"version\" : 3\n}\n"
  },
  {
    "path": "QuickPush.xcodeproj/xcuserdata/beto.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Bucket\n   uuid = \"72746176-1CCB-456A-9A1D-8E6B67F9D05C\"\n   type = \"1\"\n   version = \"2.0\">\n</Bucket>\n"
  },
  {
    "path": "QuickPush.xcodeproj/xcuserdata/beto.xcuserdatad/xcschemes/xcschememanagement.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>SchemeUserState</key>\n\t<dict>\n\t\t<key>QuickPush.xcscheme_^#shared#^_</key>\n\t\t<dict>\n\t\t\t<key>orderHint</key>\n\t\t\t<integer>0</integer>\n\t\t</dict>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <picture >\n    <source height=\"96\" media=\"(prefers-color-scheme: dark)\" srcset=\"./.github/resources/icon-white.png\">\n    <img height=\"96\" alt=\"QuickPush\" src=\"./.github/resources/icon-dark.png\">\n  </picture>\n  <h1 align=\"center\">QuickPush Tool</h1>\n</p>\n\n<p align=\"center\">A lightweight macOS menu bar utility for quickly testing Expo push notifications, Live Activity updates, native APNs, and FCM (Android) pushes</p>\n\n<p align=\"center\">\n  <a href=\"https://apps.apple.com/us/app/quickpush-tool/id6758917536\">\n    <picture>\n      <source media=\"(prefers-color-scheme: dark)\" srcset=\"./.github/resources/Download_on_the_Mac_App_Store_Badge_US-UK_RGB_wht_092917.svg\">\n      <img alt=\"Download on the Mac App Store\" src=\"./.github/resources/Download_on_the_Mac_App_Store_Badge_US-UK_RGB_blk_092917.svg\" height=\"48\">\n    </picture>\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://youtu.be/dB-gkYdTi3o\">📺 Watch it in action</a>\n</p>\n\n### Features\n\n- Send test push notifications to your Expo apps directly from the menu bar\n- **Send Live Activity pushes** (start, update, end) directly to APNs\n- Simple and intuitive interface for quick testing\n- Easy configuration of notification payload and options\n- **Rich content (image) support** for Android notifications via `richContent`\n- Advanced push notification features (priority, interruption level, TTL, and more)\n- Platform-specific settings for iOS and Android\n- Color pickers, progress sliders, and date pickers for Live Activity content\n- JSON import/export for Live Activity payloads\n- APNs JWT authentication with .p8 key files (no third-party dependencies)\n- **Native APNs push** directly to raw device tokens — full payload control, image URL support\n- **Native FCM push** directly to Firebase Cloud Messaging HTTP v1 API — service account auth, no SDKs required\n\n## 🛠️ Installation\n\n<a href=\"https://apps.apple.com/us/app/quickpush-tool/id6758917536\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"./.github/resources/Download_on_the_Mac_App_Store_Badge_US-UK_RGB_wht_092917.svg\">\n    <img alt=\"Download on the Mac App Store\" src=\"./.github/resources/Download_on_the_Mac_App_Store_Badge_US-UK_RGB_blk_092917.svg\" height=\"48\">\n  </picture>\n</a>\n\nThe easiest way to get QuickPush is from the [Mac App Store](https://apps.apple.com/us/app/quickpush-tool/id6758917536). One-time purchase, automatic updates, no setup required.\n\n#### Build from source\n\nQuickPush is open source. If you prefer, you can clone the repo and build it locally with Xcode:\n\n```bash\ngit clone https://github.com/betomoedano/quick-push.git\nopen quick-push/QuickPush.xcodeproj\n```\n\nRequires Xcode 16+ and macOS 14.6+.\n\n## 🔴 Live Activity Setup\n\nTo send Live Activity push notifications, QuickPush communicates directly with Apple's APNs (Apple Push Notification service) using JWT-based authentication. You'll need a few things from your Apple Developer account before you can start.\n\n### 1. Get your Team ID\n\nYour Team ID is a 10-character string that identifies your Apple Developer team.\n\n1. Go to [Apple Developer Account](https://developer.apple.com/account)\n2. Sign in with your Apple ID\n3. Your **Team ID** is displayed under **Membership Details**\n\n### 2. Create an APNs Authentication Key (.p8 file)\n\nThe .p8 key file is used to sign JWT tokens that authenticate your requests with APNs. You only need to create this once — it works for all your apps.\n\n1. Go to [Certificates, Identifiers & Profiles > Keys](https://developer.apple.com/account/resources/authkeys/list)\n2. Click the **+** button to create a new key\n3. Give it a name (e.g. \"QuickPush APNs Key\")\n4. Check **Apple Push Notifications service (APNs)**\n5. Click **Continue**, then **Register**\n6. **Download the .p8 file** — you can only download it once, so save it somewhere safe\n7. Note the **Key ID** shown on this page (10-character string, e.g. `ABC123DEF4`)\n\n> **Important:** Apple only lets you download the .p8 file once. If you lose it, you'll need to create a new key.\n\nFor more details, see Apple's documentation: [Establishing a Token-Based Connection to APNs](https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns)\n\n### 3. Find your Bundle ID\n\nThe Bundle ID is the unique identifier for your app (e.g. `com.yourcompany.yourapp`).\n\n1. Go to [Certificates, Identifiers & Profiles > Identifiers](https://developer.apple.com/account/resources/identifiers/list)\n2. Find your app in the list\n3. The **Bundle ID** is shown next to each app\n\nThis must match the bundle identifier of the app that registered the Live Activity.\n\n### 4. Configure QuickPush\n\n1. Open QuickPush from your menu bar\n2. Switch to the **Live Activity** tab\n3. Expand the **APNs Configuration** section\n4. Enter your **Team ID**, **Key ID**, and **Bundle ID**\n5. Click **Browse...** to select your `.p8` key file\n6. Choose **Sandbox** (for development/TestFlight builds) or **Production** (for App Store builds)\n\n> Your configuration is saved automatically and persists between sessions.\n\n### 5. Send a Live Activity Push\n\n1. Choose an event type:\n   - **Start** — creates a new Live Activity on the device (requires a push-to-start token)\n   - **Update** — updates an existing Live Activity (requires an activity token)\n   - **End** — ends an existing Live Activity (requires an activity token)\n2. Paste the device token (hex string) from your app\n3. Fill in the content state fields (title, subtitle, progress, etc.)\n4. For **Start** events, configure the attributes (colors, layout options)\n5. Click **Send**\n\n### Sandbox vs Production\n\n| Environment | When to use | APNs hostname |\n|---|---|---|\n| **Sandbox** | Development builds, TestFlight | `api.sandbox.push.apple.com` |\n| **Production** | App Store releases | `api.push.apple.com` |\n\nIf you're testing on a device with a development or TestFlight build, use **Sandbox**. If your app is installed from the App Store, use **Production**.\n\n### Getting Device Tokens\n\nYour app needs to provide the device token for Live Activities. Depending on the event type:\n\n- **Push-to-start token**: Obtained via `Activity.pushToStartToken` — use this for **Start** events\n- **Activity token**: Obtained via `activity.pushToken` after a Live Activity has been started — use this for **Update** and **End** events\n\nThese are raw APNs hex tokens (not Expo push tokens). See the [Expo LiveActivity documentation](https://docs.expo.dev/versions/latest/sdk/live-activity/) for implementation details with `expo-live-activity`.\n\n### JSON Import/Export\n\nClick the **JSON** button in the Live Activity tab to:\n\n- **Export** the current form as a JSON payload (useful for debugging or sharing)\n- **Import** a JSON payload to populate the form fields (useful for quickly loading saved payloads)\n\n## 📡 APNs Tab\n\nThe **APNs** tab lets you send native iOS push notifications directly to Apple's Push Notification service — no Expo push token required. It's useful for testing notifications on devices where you only have the raw APNs device token, or when you need full control over the APNs payload.\n\n### Device Tokens\n\nAPNs device tokens are raw hex strings (64 hex characters), different from Expo push tokens (`ExponentPushToken[...]`). Your app receives this token from the system:\n\n```swift\n// Swift (iOS)\nfunc application(_ application: UIApplication,\n                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {\n    let token = deviceToken.map { String(format: \"%02x\", $0) }.joined()\n    print(\"APNs token:\", token)\n}\n```\n\nTokens can be saved across sessions using the save button next to the token field.\n\n### Payload Fields\n\n| Field | Description |\n|---|---|\n| **Title / Subtitle / Body** | Standard alert text |\n| **Sound** | `default` or a custom sound file name bundled in the app |\n| **Badge** | Number shown on the app icon |\n| **Image URL** | URL of an image to attach to the notification (see below) |\n| **Thread ID** | Groups related notifications together in the notification center |\n| **Category** | Enables interactive notification actions registered in the app |\n| **Interruption Level** | Controls delivery timing (Active, Passive, Time Sensitive, Critical) |\n| **Mutable Content** | Allows a Notification Service Extension to modify the payload before display |\n| **Content Available** | Wakes the app in the background to process the notification silently |\n| **Priority** | `10` = immediate, `5` = normal/background |\n\n### Image URL (via Notification Service Extension)\n\nAPNs does not have a built-in image field. To display an image in a notification, the iOS app must include a **Notification Service Extension** — a small app extension that intercepts the push before it's shown, downloads the image, and attaches it as a media attachment.\n\nThe Image URL field in QuickPush injects the URL into the payload under a custom key path:\n\n```json\n{\n  \"aps\": { \"alert\": { \"title\": \"Hello\" }, \"mutable-content\": 1 },\n  \"body\": { \"_richContent\": { \"image\": \"https://example.com/photo.jpg\" } }\n}\n```\n\nYour Notification Service Extension would read it like this:\n\n```swift\n// In NotificationService.swift\nlet imageUrl = (request.content.userInfo[\"body\"] as? [String: Any])\n    .flatMap { $0[\"_richContent\"] as? [String: Any] }\n    .flatMap { $0[\"image\"] as? String }\n    .flatMap { URL(string: $0) }\n```\n\n> **Note:** The exact key path (`body._richContent.image`) is the convention QuickPush uses. The key name your app listens for depends entirely on what the Notification Service Extension in that app is coded to read. If you're testing against an app that uses a different key, use the **Custom Data** section to inject the key manually instead.\n\nFilling in the Image URL field automatically enables **Mutable Content** in the payload, which is required for the Notification Service Extension to be invoked.\n\n## 🤖 FCM Tab\n\nThe **FCM** tab lets you send native Android push notifications directly to Firebase Cloud Messaging's HTTP v1 API — no Expo push token required. It authenticates using a Firebase service account, signs OAuth 2.0 tokens locally with RS256 (no third-party SDKs), and supports both notification and data-only messages.\n\n### 1. Create a Firebase Project\n\nIf you don't already have one:\n\n1. Go to the [Firebase Console](https://console.firebase.google.com/)\n2. Click **Add project** and follow the setup wizard\n3. Once created, add your Android app to the project (**Project settings → Your apps → Add app**)\n\n### 2. Generate a Service Account Key\n\nQuickPush authenticates with FCM using a **service account JSON** file. This is the same credential type used by Firebase Admin SDKs.\n\n1. In the [Firebase Console](https://console.firebase.google.com/), open your project\n2. Go to **Project settings** (gear icon) → **Service accounts** tab\n3. Click **Generate new private key**\n4. Confirm by clicking **Generate key** — a `.json` file will download\n\nThe JSON file contains your project ID, client email, and private key. QuickPush reads all three from the file automatically when you select it.\n\n> **Keep this file secure.** Anyone with it can send push notifications to all users of your app. Don't commit it to version control.\n\n### 3. Find your FCM Registration Token\n\nThe FCM registration token identifies a specific app install on a device. It's not the same as an Expo push token.\n\n**React Native / Expo (using `@react-native-firebase/messaging`):**\n\n```js\nimport messaging from '@react-native-firebase/messaging';\n\nconst token = await messaging().getToken();\nconsole.log('FCM token:', token);\n```\n\n**React Native / Expo (using `expo-notifications`):**\n\n```js\nimport * as Notifications from 'expo-notifications';\n\nconst { data: token } = await Notifications.getDevicePushTokenAsync();\n// On Android this returns the raw FCM registration token\nconsole.log('FCM token:', token);\n```\n\nFCM tokens are long base64url strings (~163+ characters). They can change when the user reinstalls the app or clears app data.\n\n### 4. Configure QuickPush\n\n1. Open QuickPush from your menu bar\n2. Switch to the **FCM** tab (⌘4)\n3. Expand the **FCM Configuration** section\n4. Click **Browse...** and select your downloaded service account `.json` file\n5. **Project ID** and **Client Email** are auto-filled from the file — verify they look correct\n\n> Your configuration is saved automatically and persists between sessions. The service account JSON contents are stored in macOS user defaults (local to your machine, not synced).\n\n### 5. Send a Push\n\n1. Paste your FCM registration token into the token field (or use the save button to store it for future sessions)\n2. Choose the **Message Type**:\n   - **Notification** — displays a visible notification on the device\n   - **Data** — delivers a silent data payload to the app; no notification UI is shown\n3. Fill in the notification fields (Title, Body, Image URL, Channel ID, Sound, Color)\n4. Optionally add **Custom Data** key-value pairs — these are delivered in the message's `data` block\n5. Click **Send** (or ⌘↵)\n\n### Message Types\n\n| Type | Description | When to use |\n|---|---|---|\n| **Notification** | Shows a visible notification with title, body, and optional image | User-facing alerts |\n| **Data** | Silent payload delivered to the app; no system UI | Background processing, in-app messages |\n\n### Notification Fields\n\n| Field | Description |\n|---|---|\n| **Title** | Title of the notification |\n| **Body** | Main message content |\n| **Image URL** | URL of an image displayed in the expanded notification |\n| **Channel ID** | Android notification channel the app must have pre-created (defaults to `default`) |\n| **Sound** | Sound to play — use `default` for the device default |\n| **Color** | Accent color for the notification icon, in hex format (e.g. `FF5733`) |\n\n### Priority\n\n| Value | Description |\n|---|---|\n| **HIGH** | Wakes the device immediately — use for user-visible notifications |\n| **NORMAL** | May be delayed for battery optimization — suitable for non-urgent data messages |\n\n### FCM Error Codes\n\n| Code | Meaning |\n|---|---|\n| `INVALID_ARGUMENT` | The request payload is malformed or the token format is wrong |\n| `NOT_FOUND` / `UNREGISTERED` | The token is no longer valid; the app was uninstalled or the token expired |\n| `SENDER_ID_MISMATCH` | The token was registered with a different Firebase project |\n| `QUOTA_EXCEEDED` | Sending rate too high; retry with exponential backoff |\n| `UNAVAILABLE` | FCM service temporarily unavailable; retry with exponential backoff |\n| `INTERNAL` | Internal FCM server error; retry with exponential backoff |\n\n### Getting a cURL Command\n\nClick the **cURL** button to generate a ready-to-run curl command for the current configuration. Because the OAuth access token requires an async exchange with Google's servers, the cURL output uses an `<ACCESS_TOKEN>` placeholder. To fill it in:\n\n```bash\n# Using the Google Cloud CLI\ngcloud auth print-access-token\n\n# Or using the service account directly\ngcloud auth activate-service-account --key-file=/path/to/service-account.json\ngcloud auth print-access-token\n```\n\nReplace `<ACCESS_TOKEN>` in the copied curl command with the token output.\n\n### Saved Tokens\n\nClick the **save** icon (↓) next to any token to store it with a label. Saved tokens persist across app restarts and appear at the top of the token list with a toggle to include or exclude them from sends.\n\n## 🖼️ Rich Content (Image Notifications)\n\nQuickPush supports the `richContent` field, allowing you to attach an image to your push notifications.\n\n1. Open the **Push Notification** tab\n2. Expand **Advanced Settings**\n3. In the **Common Settings** section, find the **Image (richContent)** field\n4. Paste the URL of the image you want to display\n\n### Platform behavior\n\n| Platform | Support |\n|---|---|\n| **Android** | Works out of the box — the image will display in the notification |\n| **iOS** | Requires a [Notification Service Extension](https://github.com/expo/expo/pull/36202) target in your app to process and display the image |\n\n> **Tip:** The help tooltip for this field includes a clickable link to the Expo PR with an iOS implementation example.\n\n## 📸 Screenshots\n\n<img width=\"504\" alt=\"QuickPush Push Notification\" src=\"https://github.com/user-attachments/assets/d5900db9-1f88-4dd8-b33a-3f5873c4c3b7\" />\n\n<img width=\"495\" alt=\"QuickPush Advanced Settings\" src=\"https://github.com/user-attachments/assets/bda2d077-0b77-432e-aa8f-10db145b4751\" />\n\n## License\n\nMIT\n\n---\n\n<p align=\"center\">\n  Made with ❤️ by <a href=\"https://codewithbeto.dev\">codewithbeto.dev</a>\n</p>\n"
  }
]