Add push-equivalent notifications via background polling; bump to 3.6
Build iOS App / Build .ipa and publish source (push) Successful in 31s

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.
This commit is contained in:
Hermes committed 2026-08-24 08:18:29 +00:00
1 parent 6ed50ecc4a
commit bae531f850
8 files changed
+241 -4

No files matched your search

@@ -0,0 +1,129 @@
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"
}
}
}