diff --git a/GiteaPR.xcodeproj/project.pbxproj b/GiteaPR.xcodeproj/project.pbxproj index 0cb58c7..fcaf894 100644 --- a/GiteaPR.xcodeproj/project.pbxproj +++ b/GiteaPR.xcodeproj/project.pbxproj @@ -187,17 +187,19 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIBackgroundModes = "fetch"; + INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.bueso.giteapr.refresh"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.5; + MARKETING_VERSION = 3.6; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -211,17 +213,19 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIBackgroundModes = "fetch"; + INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.bueso.giteapr.refresh"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.5; + MARKETING_VERSION = 3.6; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/GiteaPR/AppDelegate.swift b/GiteaPR/AppDelegate.swift new file mode 100644 index 0000000..de2eb6b --- /dev/null +++ b/GiteaPR/AppDelegate.swift @@ -0,0 +1,50 @@ +import UIKit +import BackgroundTasks + +/// App delegate that schedules the background refresh task which drives the +/// push-equivalent notifications (polling the monitored repos). +/// +/// iOS runs BGAppRefreshTask opportunistically (~every 15 min at best, often +/// slower), and only when the app has been used recently and isn't force-quit. +final class AppDelegate: NSObject, UIApplicationDelegate { + static let taskIdentifier = "com.bueso.giteapr.refresh" + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + registerBackgroundTask() + return true + } + + private func registerBackgroundTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.taskIdentifier, + using: nil + ) { task in + // Handle the background refresh. + self.handle(task) + } + } + + private func handle(_ task: BGTask) { + // Reschedule the next refresh so the chain continues. + scheduleNextRefresh() + + let pollTask = Task { @MainActor in + await NotificationService.shared.poll() + } + task.expirationHandler = { + pollTask.cancel() + } + Task { + await pollTask.value + task.setTaskCompleted(success: true) + } + } + + /// Schedule the next background refresh (~15 min from now, earliest). + func scheduleNextRefresh() { + let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + try? BGTaskScheduler.shared.submit(request) + } +} diff --git a/GiteaPR/GiteaPRApp.swift b/GiteaPR/GiteaPRApp.swift index 2db8283..b350069 100644 --- a/GiteaPR/GiteaPRApp.swift +++ b/GiteaPR/GiteaPRApp.swift @@ -2,6 +2,7 @@ import SwiftUI @main struct GiteaPRApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var settings = SettingsStore.shared var body: some Scene { diff --git a/GiteaPR/Inbox/InboxViewModel.swift b/GiteaPR/Inbox/InboxViewModel.swift index 543f8f9..7af0308 100644 --- a/GiteaPR/Inbox/InboxViewModel.swift +++ b/GiteaPR/Inbox/InboxViewModel.swift @@ -91,6 +91,12 @@ final class InboxViewModel: ObservableObject { if rows.isEmpty && !failures.isEmpty { errorMessage = "Couldn't load any PRs (\(failures.count) repo(s) failed). Check the Logs screen." } + // When notifications are on, poll after a foreground load so state + // seeds and changes are caught promptly (not just on the ~15-min + // background task). + if SettingsStore.shared.notificationsEnabled { + Task { await NotificationService.shared.poll() } + } } catch { LogStore.shared.log(.error, "Inbox load failed: \(error.localizedDescription)") errorMessage = error.localizedDescription diff --git a/GiteaPR/Notifications/NotificationService.swift b/GiteaPR/Notifications/NotificationService.swift new file mode 100644 index 0000000..0540d92 --- /dev/null +++ b/GiteaPR/Notifications/NotificationService.swift @@ -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" + } + } +} diff --git a/GiteaPR/Notifications/NotificationState.swift b/GiteaPR/Notifications/NotificationState.swift new file mode 100644 index 0000000..fcba5c5 --- /dev/null +++ b/GiteaPR/Notifications/NotificationState.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Persisted "what we've already notified about", so the background poller +/// only fires a notification when something actually changes: +/// - a PR that wasn't there before → "new PR" +/// - a PR whose CI aggregate status changed → "CI status changed" +struct NotificationState: Codable { + /// PR ids (repo.fullName#number) we've already announced as new. + var announcedNewPRs: Set = [] + /// PR id → last notified CI status string ("success"/"failure"/"running"/etc). + var ciStatusByPR: [String: String] = [:] + + private static let url: URL = { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + return dir.appendingPathComponent("notification-state.json") + }() + + func save() { + guard let data = try? JSONEncoder().encode(self) else { return } + try? data.write(to: Self.url) + } + + static func load() -> NotificationState { + guard let data = try? Data(contentsOf: url), + let state = try? JSONDecoder().decode(NotificationState.self, from: data) else { + return NotificationState() + } + return state + } +} diff --git a/GiteaPR/Settings/SettingsStore.swift b/GiteaPR/Settings/SettingsStore.swift index e719585..5e77300 100644 --- a/GiteaPR/Settings/SettingsStore.swift +++ b/GiteaPR/Settings/SettingsStore.swift @@ -36,6 +36,11 @@ final class SettingsStore: ObservableObject { /// Set when a PR is merged, so the inbox knows to reload on next appearance /// (otherwise a just-merged PR could linger as stale in the list). @Published var inboxNeedsRefresh = false + /// Whether background polling for push-equivalent notifications is on. + @Published var notificationsEnabled: Bool { + didSet { UserDefaults.standard.set(notificationsEnabled, + forKey: "notificationsEnabled") } + } private init() { instanceURL = KeychainHelper.get("instanceURL") ?? "https://gitea.bueso.eu" @@ -44,6 +49,7 @@ final class SettingsStore: ObservableObject { mergeGateMode = MergeGateMode(rawValue: raw ?? "") ?? .warn deleteBranchAfterMerge = UserDefaults.standard.bool(forKey: "deleteBranchAfterMerge") monitoredRepos = UserDefaults.standard.stringArray(forKey: "monitoredRepos") ?? [] + notificationsEnabled = UserDefaults.standard.object(forKey: "notificationsEnabled") as? Bool ?? false } var isConfigured: Bool { diff --git a/GiteaPR/Settings/SettingsView.swift b/GiteaPR/Settings/SettingsView.swift index 1c3db75..2d5c817 100644 --- a/GiteaPR/Settings/SettingsView.swift +++ b/GiteaPR/Settings/SettingsView.swift @@ -7,6 +7,7 @@ struct SettingsView: View { @State private var mergeGateMode: MergeGateMode @State private var deleteBranchAfterMerge: Bool @State private var monitoredRepos: [String] + @State private var notificationsEnabled: Bool @State private var testResult: TestResult? @State private var testing = false @State private var showLogoutConfirm = false @@ -22,6 +23,7 @@ struct SettingsView: View { _mergeGateMode = State(initialValue: SettingsStore.shared.mergeGateMode) _deleteBranchAfterMerge = State(initialValue: SettingsStore.shared.deleteBranchAfterMerge) _monitoredRepos = State(initialValue: SettingsStore.shared.monitoredRepos) + _notificationsEnabled = State(initialValue: SettingsStore.shared.notificationsEnabled) } var body: some View { @@ -66,6 +68,14 @@ struct SettingsView: View { Text("Limit the landing page to specific repos to speed it up. Leave empty to monitor all accessible repos.") } + Section { + Toggle("Notifications", isOn: $notificationsEnabled) + } header: { + Text("Notifications") + } footer: { + Text("Check periodically for new PRs and CI status changes, then send a local notification. Best-effort: iOS schedules this opportunistically (~15 min) and only when the app was used recently.") + } + Section("Diagnostics") { NavigationLink("View logs") { LogsView() @@ -147,6 +157,7 @@ struct SettingsView: View { SettingsStore.shared.mergeGateMode = mergeGateMode SettingsStore.shared.deleteBranchAfterMerge = deleteBranchAfterMerge SettingsStore.shared.monitoredRepos = monitoredRepos + SettingsStore.shared.notificationsEnabled = notificationsEnabled dismiss() } }