Files
Vorrania/ios/Sources/VorraniaApp.swift
Scarriffle 2d7cfa7fcb iOS: gescannter Einzelstück-Link öffnet wirklich das Stück
Universal Link oeffnete die App, sprang aber nicht aufs Stueck. Jetzt robuster:
- Kaltstart wird abgefangen (AppDelegate application(_:continue:) + pending-URL),
  zusaetzlich zu den SwiftUI-Hooks.
- Beim Link wird auf das Server-Profil gewechselt, dessen Adresse zum Host passt
  (sonst wuerde die UID am falschen Server gesucht).
- Schlaegt das Aufloesen fehl (nicht angemeldet, UID unbekannt), erscheint jetzt
  eine Meldung statt stiller Nichtreaktion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:24:05 +02:00

198 lines
8.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UIKit
import BackgroundTasks
import UserNotifications
/// Zielbildschirm, der über Home-Screen-Shortcut, URL oder eine getippte
/// Benachrichtigung angesprungen wird.
enum Route: String {
case checkin
case checkout
case expiring
case lookup
case assign
}
final class Router: ObservableObject {
@Published var route: Route?
/// Über einen Universal Link (/i/<UID>) zu öffnendes Einzelstück.
@Published var openItemUid: String?
func handle(shortcut type: String) {
if type.hasSuffix("checkin") { route = .checkin }
if type.hasSuffix("checkout") { route = .checkout }
}
/// Getippte Ablauf-Benachrichtigung: auf den betroffenen Server wechseln und
/// Bald ablaufend" oeffnen.
func handle(expiryProfile raw: String) {
if let id = UUID(uuidString: raw) { Session.shared.switchTo(id) }
route = .expiring
}
/// vorrania://checkin bzw. vorrania://checkout oder ein Universal Link
/// (https:///i/<UID>), der das Einzelstück direkt öffnet.
func handle(url: URL) {
if url.scheme == "vorrania" {
let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased()
if let route = Route(rawValue: target) { self.route = route }
return
}
let parts = url.pathComponents // z.B. ["/", "i", "ABC123"]
if let idx = parts.firstIndex(of: "i"), idx + 1 < parts.count {
// Auf den Server aus dem Link wechseln, damit die UID dort gesucht wird.
Session.shared.switchToProfile(matching: url)
openItemUid = parts[idx + 1].uppercased()
}
}
}
@main
struct VorraniaApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var session = Session.shared
@StateObject private var router = Router()
@StateObject private var display = DisplaySettings.shared
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(session)
.environmentObject(router)
.environmentObject(display)
.onOpenURL { router.handle(url: $0) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { router.handle(url: url) }
}
.onAppear {
if let type = AppDelegate.pendingShortcut {
router.handle(shortcut: type)
AppDelegate.pendingShortcut = nil
}
// Kaltstart aus einer getippten Benachrichtigung.
if let profile = AppDelegate.pendingExpiryProfile {
router.handle(expiryProfile: profile)
AppDelegate.pendingExpiryProfile = nil
}
// Kaltstart aus einem gescannten Universal Link.
if let url = AppDelegate.pendingURL {
router.handle(url: url)
AppDelegate.pendingURL = nil
}
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
if let type = note.object as? String { router.handle(shortcut: type) }
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.expiryTapNotification)) { note in
if let profile = note.object as? String { router.handle(expiryProfile: profile) }
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.urlNotification)) { note in
if let url = note.object as? URL { router.handle(url: url) }
}
}
// Im Vordergrund den Meldungsplan auffrischen und den naechsten
// Hintergrundlauf einplanen.
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
Task { await NotificationScheduler.shared.rescheduleAll() }
case .background:
AppDelegate.scheduleBackgroundRefresh()
default:
break
}
}
}
}
/// Nimmt Home-Screen-Quick-Actions und getippte Benachrichtigungen entgegen
/// (auch beim Kaltstart) und treibt die Hintergrund-Aktualisierung.
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
static let shortcutNotification = Notification.Name("VorraniaShortcut")
static let expiryTapNotification = Notification.Name("VorraniaExpiryTap")
static let urlNotification = Notification.Name("VorraniaOpenURL")
static var pendingShortcut: String?
static var pendingExpiryProfile: String?
static var pendingURL: URL?
static let refreshTaskID = "com.scarriffle.vorrania.refresh"
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
AppDelegate.pendingShortcut = item.type
}
UNUserNotificationCenter.current().delegate = self
// Der Handler muss registriert sein, bevor das Starten abgeschlossen ist.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: AppDelegate.refreshTaskID, using: nil
) { task in
AppDelegate.handleBackgroundRefresh(task as? BGAppRefreshTask)
}
return true
}
// Bewusst die Variante mit Completion-Handler: Bei der async-Fassung muesste
// das nicht-Sendable UIApplicationShortcutItem ueber eine Actor-Grenze
// gereicht werden (Warnung heute, Fehler im Swift-6-Sprachmodus).
func application(_ application: UIApplication,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void) {
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
completionHandler(true)
}
// Universal Link (/i/<UID>) auch beim Kaltstart, falls die SwiftUI-Hooks
// ihn nicht mitbekommen.
func application(_ application: UIApplication, continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return false }
AppDelegate.pendingURL = url
NotificationCenter.default.post(name: AppDelegate.urlNotification, object: url)
return true
}
// MARK: - Benachrichtigungen
/// Auch im Vordergrund als Banner zeigen - sonst bliebe eine faellige
/// Erinnerung unbemerkt, solange die App offen ist.
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async
-> UNNotificationPresentationOptions {
[.banner, .sound]
}
/// Antippen: auf den betroffenen Server wechseln und Bald ablaufend" oeffnen.
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
guard let profile = response.notification.request.content.userInfo["profileID"] as? String
else { return }
AppDelegate.pendingExpiryProfile = profile
NotificationCenter.default.post(name: AppDelegate.expiryTapNotification, object: profile)
}
// MARK: - Hintergrund-Aktualisierung
static func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
// Fruehestens in ein paar Stunden - iOS entscheidet den genauen Zeitpunkt.
request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 3600)
try? BGTaskScheduler.shared.submit(request)
}
private static func handleBackgroundRefresh(_ task: BGAppRefreshTask?) {
guard let task else { return }
// Gleich den naechsten Lauf einplanen, sonst gibt es nur einen.
scheduleBackgroundRefresh()
let arbeit = Task { @MainActor in
await NotificationScheduler.shared.rescheduleAll()
task.setTaskCompleted(success: true)
}
task.expirationHandler = { arbeit.cancel() }
}
}