now with notifications

This commit is contained in:
Mark Gregory
2026-11-05 10:42:10 +00:00
parent 6643ba72c8
commit 35e1d44697
4 changed files with 121 additions and 22 deletions
+61 -20
View File
@@ -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<String> = []
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()
+32 -2
View File
@@ -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")
}
}
+20
View File
@@ -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.")
}
}
+8
View File
@@ -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.