Add push-equivalent notifications via background polling; bump to 3.6
Build iOS App / Build .ipa and publish source (push) Successful in 31s
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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = []
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user