From 35e1d44697eeef5457f889a4dfb268e222c71681 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 5 Nov 2026 10:42:10 +0000 Subject: [PATCH] now with notifications --- Tarnax/Core.swift | 81 +++++++++++++++++++++++++++++---------- Tarnax/Settings.swift | 34 +++++++++++++++- Tests/ContractTests.swift | 20 ++++++++++ docs/VALIDATION.md | 8 ++++ 4 files changed, 121 insertions(+), 22 deletions(-) diff --git a/Tarnax/Core.swift b/Tarnax/Core.swift index 896fef0..525b96f 100644 --- a/Tarnax/Core.swift +++ b/Tarnax/Core.swift @@ -269,6 +269,36 @@ struct HistoryStore { func clear() throws { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } } } +enum DownloadNotificationType: String, CaseIterable, Identifiable { + case started, completed + var id: String { rawValue } + var title: String { self == .started ? "Download started" : "Download complete" } + var preferenceKey: String { self == .started ? "startNotifications" : "completionNotifications" } +} +struct DownloadNotificationEvent { + let type: DownloadNotificationType + let torrent: Torrent +} +struct DownloadNotificationTracker { + private var previous: [String: (started: Bool, completed: Bool)] = [:] + private var initialized = false + mutating func observe(_ torrents: [Torrent]) -> [DownloadNotificationEvent] { + var events: [DownloadNotificationEvent] = [] + var next: [String: (started: Bool, completed: Bool)] = [:] + for torrent in torrents { + let started = !torrent.complete && ["downloading", "forcedDL"].contains(torrent.state) + let old = previous[torrent.hash] + if initialized { + if started && old?.started != true { events.append(DownloadNotificationEvent(type: .started, torrent: torrent)) } + if torrent.complete && old?.completed == false { events.append(DownloadNotificationEvent(type: .completed, torrent: torrent)) } + } + next[torrent.hash] = (started, torrent.complete) + } + previous = next; initialized = true + return events + } +} + struct AppLogEntry: Codable, Identifiable { var id = UUID() var date: Date @@ -376,7 +406,8 @@ struct AppLogEntry: Codable, Identifiable { @Published private(set) var pendingActions: Set = [] private let notificationPresenter = CompletionNotificationPresenter() @Published var completionNotifications = UserDefaults.standard.bool(forKey: "completionNotifications") - private var observedCompletion: [String: Bool] = [:] + @Published var startNotifications = UserDefaults.standard.bool(forKey: "startNotifications") + private var notificationTracker = DownloadNotificationTracker() init(logs: AppLogStore? = nil) { self.logs = logs ?? AppLogStore() self.logs.record("App launched.") @@ -386,28 +417,38 @@ struct AppLogEntry: Codable, Identifiable { do { transferHistory = try historyStore.load(for: credentials) } catch { historyError = "Could not restore transfer history: \(error.localizedDescription)" } } - func setCompletionNotifications(_ enabled: Bool) async { + func notificationEnabled(_ type: DownloadNotificationType) -> Bool { + type == .started ? startNotifications : completionNotifications + } + func setNotification(_ type: DownloadNotificationType, enabled: Bool) async { #if canImport(UserNotifications) do { - completionNotifications = enabled ? try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) : false - logs.record(completionNotifications ? "Completion notifications enabled." : "Completion notifications disabled.") - UserDefaults.standard.set(completionNotifications, forKey: "completionNotifications") - if enabled && !completionNotifications { error = "Notifications are disabled. Allow notifications for Tarnax in iOS Settings." } - } catch { self.error = error.localizedDescription } + let allowed = enabled ? try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) : false + if type == .started { startNotifications = allowed } else { completionNotifications = allowed } + UserDefaults.standard.set(allowed, forKey: type.preferenceKey) + logs.record(type.title + " notifications " + (allowed ? "enabled." : "disabled.")) + if enabled && !allowed { error = "Notifications are disabled. Allow notifications for Tarnax in iOS Settings." } + } catch { logs.failure(error, context: "Notification permission"); self.error = error.localizedDescription } #endif } - private func observeCompletions(_ next: [Torrent]) async { - for torrent in next { - if completionNotifications, observedCompletion[torrent.hash] == false, torrent.complete { - #if canImport(UserNotifications) - let content = UNMutableNotificationContent() - content.title = "Download complete"; content.body = torrent.name; content.sound = .default - do { try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: "complete-" + torrent.hash, content: content, trigger: nil)); logs.record("Download completion notification delivered.") } - catch { self.error = "Could not deliver completion notification: \(error.localizedDescription)" } - #endif + private func observeDownloadNotifications(_ next: [Torrent], from source: Transport) async { + let events = notificationTracker.observe(next) + for event in events { + guard client === source else { return } + guard notificationEnabled(event.type) else { continue } + #if canImport(UserNotifications) + let content = UNMutableNotificationContent() + content.title = event.type.title; content.body = event.torrent.name; content.sound = .default + do { + try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: event.type.rawValue + "-" + event.torrent.hash, content: content, trigger: nil)) + logs.record(event.type.title + " notification submitted.") + } catch { + guard client === source else { return } + logs.failure(error, context: "Notification delivery") + self.error = "Could not deliver notification: \(error.localizedDescription)" } + #endif } - observedCompletion = Dictionary(uniqueKeysWithValues: next.map { ($0.hash, $0.complete) }) } // Only cancellation signals and work belonging to a retired session are silent. @@ -474,7 +515,7 @@ struct AppLogEntry: Codable, Identifiable { clearPreferences() do { transferHistory = try historyStore.load(for: connectionCredentials) } catch { historyError = "Could not restore transfer history: \(error.localizedDescription)" } - observedCompletion = [:] + notificationTracker = DownloadNotificationTracker() client?.close(); client = next; installed = true version = String(decoding: v, as: UTF8.self); apiVersion = String(decoding: a, as: UTF8.self) logs.record("Connected to server.") @@ -498,7 +539,7 @@ struct AppLogEntry: Codable, Identifiable { let (nextTorrents, nextInfo) = try await (list, info) try Task.checkCancellation() guard self.client === client else { return } - await observeCompletions(nextTorrents) + await observeDownloadNotifications(nextTorrents, from: client) guard self.client === client else { return } torrents = nextTorrents stats = nextInfo["server_state"] as? [String: Any] ?? [:] @@ -607,7 +648,7 @@ struct AppLogEntry: Codable, Identifiable { clearPreferences() transferHistory = TransferHistory() client?.close(); client = nil; connected = false; torrents = []; stats = [:] - observedCompletion = [:] + notificationTracker = DownloadNotificationTracker() if forget { do { try historyStore.clear() } catch { self.error = "Could not remove transfer history: \(error.localizedDescription)" } Vault.clear(); credentials = Credentials() diff --git a/Tarnax/Settings.swift b/Tarnax/Settings.swift index 9ba2395..ac9f085 100644 --- a/Tarnax/Settings.swift +++ b/Tarnax/Settings.swift @@ -38,8 +38,7 @@ struct SettingsView: View { Section("On this device") { NavigationLink("Logs") { LogsView(logs: model.logs) } NavigationLink("Status colours") { StatusColorsView() } - Toggle("Download completion notifications", isOn: Binding(get: { model.completionNotifications }, set: { enabled in Task { await model.setCompletionNotifications(enabled) } })) - Text("Alerts are based on observed completions. While the app is suspended, completion is detected at the next foreground refresh.").font(.caption).foregroundStyle(.secondary) + NavigationLink("Notifications") { NotificationSettingsView() } Toggle("Face ID / device authentication", isOn: Binding(get: { model.biometric }, set: { enabled in Task { await model.enableLock(enabled) } })) Text("Requires authentication when returning after the app enters the background. Device passcode is available as a fallback.").font(.caption).foregroundStyle(.secondary) } @@ -223,3 +222,34 @@ struct LogsView: View { } message: { Text("This removes all saved log entries from this device.") } } } + +struct NotificationSettingsView: View { + @EnvironmentObject private var model: AppModel + @State private var requesting = false + var body: some View { + Form { + Section { + ForEach(DownloadNotificationType.allCases) { type in + Toggle(type.title, isOn: Binding( + get: { model.notificationEnabled(type) }, + set: { enabled in + requesting = true + Task { + await model.setNotification(type, enabled: enabled) + requesting = false + } + } + )).disabled(requesting) + } + if requesting { ProgressView("Updating notification permission…") } + } header: { Text("Notification types") } footer: { + Text("Choose either or both. Choices are saved between launches. Started alerts include downloads starting or resuming from the queue or paused state; metadata lookup alone does not alert. Existing downloads do not trigger alerts on connection.") + } + Section { + Text("Alerts rely on changes observed while connected. When the app is suspended, changes are detected on the next foreground refresh. Immediate alerts while closed require server push.") + .font(.footnote).foregroundStyle(.secondary) + Link("Open iOS notification settings", destination: URL(string: "app-settings:")!) + } + }.navigationTitle("Notifications") + } +} diff --git a/Tests/ContractTests.swift b/Tests/ContractTests.swift index b80b773..84e5dc0 100644 --- a/Tests/ContractTests.swift +++ b/Tests/ContractTests.swift @@ -289,6 +289,26 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { check(completionFixture.complete, "Full measured progress is completed") completionFixture.progress = 0.9999 check(!completionFixture.complete, "Rounded near-100-percent progress is not completed") + var notificationTracker = DownloadNotificationTracker() + var eventTorrent = queue[0] + eventTorrent.state = "queuedDL"; eventTorrent.progress = 0 + check(notificationTracker.observe([eventTorrent]).isEmpty, "Initial notification snapshot is silent") + eventTorrent.state = "metaDL" + check(notificationTracker.observe([eventTorrent]).isEmpty, "Metadata lookup does not notify download start") + eventTorrent.state = "downloading" + check(notificationTracker.observe([eventTorrent]).map(\.type) == [.started], "Queued torrent entering downloading emits a start event") + check(notificationTracker.observe([eventTorrent]).isEmpty, "Unchanged polling never repeats start notifications") + eventTorrent.state = "pausedDL" + check(notificationTracker.observe([eventTorrent]).isEmpty, "Pausing emits no download notification") + eventTorrent.state = "forcedDL" + check(notificationTracker.observe([eventTorrent]).map(\.type) == [.started], "Resuming emits a new start event") + eventTorrent.progress = 1; eventTorrent.state = "uploading" + check(notificationTracker.observe([eventTorrent]).map(\.type) == [.completed], "Completion emits only a completion event") + check(notificationTracker.observe([eventTorrent]).isEmpty, "Unchanged completion does not repeat") + var reconnectTracker = DownloadNotificationTracker() + check(reconnectTracker.observe([eventTorrent]).isEmpty, "Reconnect does not alert existing completed torrents") + eventTorrent.hash = "new-download"; eventTorrent.progress = 0; eventTorrent.state = "downloading" + check(reconnectTracker.observe([eventTorrent]).map(\.type) == [.started], "Newly added active download alerts after baseline") another.close(); transport.close(); print("\(checks) contract checks passed.") } } diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 2035bb7..55315a3 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -131,3 +131,11 @@ Production readiness: this update has not undergone signed Release validation, p ### Completed filter and selected colour correction — 5 November 2026 Completion now requires finite progress >= 1 instead of assuming UP states are completed. This keeps incomplete or rounded-near-100% entries out of Completed and avoids falsely filling their bars. Rows displayed in Library → Completed explicitly use the user's saved Completed colour for icon and progress; other Library filters/detail rows retain state-specific colours. Regression checks cover six upload-related states with incomplete progress, exact completion and near-completion. Unsigned iOS Debug build succeeded; 102 contract checks, log checks and three profile checks passed. Temporary files are under ~/Developer/Scratch/Tarnax. No device installation performed. + +### Configurable start/completion notifications — 5 November 2026 + +Settings → Notifications now independently enables Download started and Download complete, persisted in UserDefaults. Existing completion preference is retained; started notifications default off. System permission is requested when enabling a type; denied permission remains visible and a link opens iOS app settings. Permission changes temporarily disable the switches. + +A dedicated transition tracker suppresses the first connection snapshot and duplicate polls, emits starts for newly active downloads or queued/paused downloads entering downloading/forcedDL, excludes metadata lookup and emits completion only after observed incomplete progress reaches completion. Session replacement/disconnect resets the tracker. Observation continues with notification types disabled so enabling a switch does not replay old transitions. Delivery checks session identity and logs fixed event descriptions without torrent names. Immediate suspended/killed-app alerts still require server push; transitions occurring entirely between polls cannot be reconstructed. + +Unsigned iOS Debug build succeeded; 112 contract checks (including ten notification transition checks), log checks and three profile checks passed. Actual start-alert delivery and notification settings interaction on a device remain to be checked. All temporary files/logs are under ~/Developer/Scratch/Tarnax. No new installation performed.