Files
ios-gitea-prs/GiteaPR/Notifications/NotificationService.swift
T
Hermes bae531f850
Build iOS App / Build .ipa and publish source (push) Successful in 31s
Add push-equivalent notifications via background polling; bump to 3.6
Sideloaded apps can't use APNs (no push entitlement without Apple signing),
so deliver notifications via an iOS BGAppRefreshTask that polls the monitored
repos every ~15 min and fires local notifications (no Apple push needed):
- new PRs that appear in the open set
- CI status changes on existing PRs

Persistence: NotificationState tracks announced PR ids + last-known CI status
so we only fire on actual changes, not every poll. AppDelegate registers the
background task and reschedules the next one. Info.plist gets UIBackgroundModes
fetch + the BGTask identifier. Settings gains a Notifications toggle; also
poll after a foreground inbox load so state seeds and changes are caught
promptly. Bump to 3.6.
2026-08-24 08:18:29 +00:00

130 lines
5.0 KiB
Swift

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"
}
}
}