now with notifications
This commit is contained in:
+61
-20
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user