import Foundation import UserNotifications /// Polls the monitored repos and fires local notifications when something /// changes since the last poll: /// - a PR that wasn't in the open set → "New PR" /// - a PR's aggregate CI status changed → "CI status changed" /// /// Local notifications need no Apple push entitlement, so this is the only /// viable push-equivalent for a sideloaded app. It's driven by an iOS /// background task (~15 min, opportunistic — Apple controls the timing). @MainActor final class NotificationService { static let shared = NotificationService() private init() {} /// Run one poll. Called from the background refresh task (and optionally /// from the app after a foreground inbox load). Returns the number of /// notifications fired (for logging/tests). @discardableResult func poll() async -> Int { guard SettingsStore.shared.isConfigured, SettingsStore.shared.notificationsEnabled else { return 0 } guard let client = SettingsStore.shared.client() else { return 0 } // Ask for permission on first run. _ = await requestPermissionIfNeeded() do { let repos = try await client.repos() let monitored = SettingsStore.shared.monitoredRepos let targets = monitored.isEmpty ? repos : repos.filter { monitored.contains($0.fullName) } var state = NotificationState.load() var fired = 0 for repo in targets { let prs = (try? await client.pullRequests(owner: repo.owner.login, repo: repo.name)) ?? [] for pr in prs { let prID = "\(repo.fullName)#\(pr.number)" // New PR not yet announced. if !state.announcedNewPRs.contains(prID) { state.announcedNewPRs.insert(prID) fire(title: "New PR", body: "\(pr.title) — \(repo.fullName)") fired += 1 continue } // CI status change. let ci = await ciStatus(for: pr, repo: repo, client: client) let ciKey = ci.rawValue if let prev = state.ciStatusByPR[prID], prev != ciKey { state.ciStatusByPR[prID] = ciKey fire(title: "CI \(ci.label)", body: "\(pr.title) — \(repo.fullName)") fired += 1 } else { state.ciStatusByPR[prID] = ciKey } } // Drop PRs that are no longer open (merged/closed). let open = Set(prs.map { "\(repo.fullName)#\($0.number)" }) for id in state.announcedNewPRs { if id.hasPrefix(repo.fullName + "#") && !open.contains(id) { state.announcedNewPRs.remove(id) state.ciStatusByPR.removeValue(forKey: id) } } } state.save() return fired } catch { return 0 } } private func ciStatus(for pr: PullRequest, repo: Repository, client: GiteaClient) async -> CIStatus { guard let sha = pr.head?.sha, !sha.isEmpty else { return .unknown } let runs = (try? await client.runs(owner: repo.owner.login, repo: repo.name, sha: sha)) ?? [] return CIStatus.from(runs: runs) } private func fire(title: String, body: String) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) } private func requestPermissionIfNeeded() async -> Bool { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() switch settings.authorizationStatus { case .notDetermined: return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false default: return settings.authorizationStatus == .authorized } } } private extension CIStatus { var rawValue: String { switch self { case .running: return "running" case .success: return "success" case .failure: return "failure" case .cancelled: return "cancelled" case .unknown: return "unknown" } } var label: String { switch self { case .running: return "running" case .success: return "passed" case .failure: return "failed" case .cancelled: return "cancelled" case .unknown: return "updated" } } }