Universal Links (Associated Domains) sind an eine feste Domain gebunden - fuer einen selbstgehosteten Dienst untauglich (jede Instanz hat eine andere URL) und die Domain stand im offenen Quellcode (Leak). Jetzt tragen Einzelstueck-QRs das Custom-Scheme vorrania://i/<UID>: Die normale Kamera-App oeffnet die App weiterhin, aber ohne Domain-Bindung und ohne Server im Code. applinks-Eintrag entfernt. Web: itemDeepLink()-Helfer, genutzt in Einzelstuecke-Liste/-Etiketten und im P-touch-Export. Lagerort-QRs bleiben unveraendert (https). iOS: Router erkennt vorrania://i/<UID> und oeffnet das Einzelstueck am aktiven Server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
9.3 KiB
Swift
212 lines
9.3 KiB
Swift
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 | checkout … (Aktionen) oder vorrania://i/<UID>
|
||
/// (Einzelstück – aus einem gescannten QR, auch über die normale Kamera-App).
|
||
///
|
||
/// Früher lief das Einzelstück über einen Universal Link auf eine feste Domain.
|
||
/// Das war für einen selbstgehosteten Dienst untauglich (jede Instanz hat eine
|
||
/// andere URL, und die Domain stünde im offenen Quellcode). Das Custom-Scheme
|
||
/// braucht keine Domain: Die Kamera-App öffnet den QR trotzdem, und der Code
|
||
/// verrät keinen Server.
|
||
func handle(url: URL) {
|
||
if url.scheme == "vorrania" {
|
||
// Host + Pfad zu Bestandteilen ohne Trenner: ["i","ABC123"] bzw. ["checkin"].
|
||
let comps = ([url.host].compactMap { $0 } + url.pathComponents)
|
||
.filter { $0 != "/" && !$0.isEmpty }
|
||
// Einzelstück: vorrania://i/<UID> – am aktuell aktiven Server öffnen.
|
||
if let i = comps.firstIndex(where: { $0.lowercased() == "i" }), i + 1 < comps.count {
|
||
openItemUid = comps[i + 1].uppercased()
|
||
return
|
||
}
|
||
if let route = comps.first.flatMap({ Route(rawValue: $0.lowercased()) }) { self.route = route }
|
||
return
|
||
}
|
||
// Alt-Fall: Universal Link (…/i/<UID>) – nur noch relevant, falls je wieder
|
||
// eine Associated Domain hinterlegt würde (dann Serverwechsel per Host).
|
||
let parts = url.pathComponents
|
||
if let idx = parts.firstIndex(of: "i"), idx + 1 < parts.count {
|
||
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() }
|
||
}
|
||
}
|