From 6643ba72c8dcf2f24ef0ae8d027c3bad309325fe Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 5 Nov 2026 10:05:39 +0000 Subject: [PATCH] new app store assets ready for relese ui updates --- Tarnax/App.swift | 24 +++++++++++++++++++----- Tarnax/Core.swift | 26 +++++++++++++++++++------- Tarnax/Details.swift | 2 +- Tarnax/Settings.swift | 4 ++-- Tests/ContractTests.swift | 31 +++++++++++++++++++++++++------ docs/VALIDATION.md | 12 ++++++++++++ 6 files changed, 78 insertions(+), 21 deletions(-) diff --git a/Tarnax/App.swift b/Tarnax/App.swift index d3cc072..658004e 100644 --- a/Tarnax/App.swift +++ b/Tarnax/App.swift @@ -141,16 +141,20 @@ struct ConnectionView: View { struct TorrentRow: View { @EnvironmentObject private var model: AppModel let torrent: Torrent + var statusOverride: TorrentStatus? = nil @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { VStack(alignment: .leading, spacing: 10) { (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout(alignment: .top))) { - Image(systemName: torrent.complete ? "checkmark.circle.fill" : "arrow.down.circle.fill").foregroundStyle(model.color(for: torrent.status)).font(.title2) + Image(systemName: torrent.complete ? "checkmark.circle.fill" : "arrow.down.circle.fill").foregroundStyle(model.color(for: statusOverride ?? torrent.status)).font(.title2) Text(torrent.name).font(.headline).lineLimit(dynamicTypeSize.isAccessibilitySize ? nil : 2) if !dynamicTypeSize.isAccessibilitySize { Spacer() } Text(torrent.progress, format: .percent.precision(.fractionLength(1))).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary) } - ProgressView(value: torrent.complete ? 1 : min(max(torrent.progress, 0), 1)).tint(model.color(for: torrent.status)).accessibilityLabel("Download progress") + if let position = torrent.queuePosition { + Text("Queue #\(position)").font(.caption).foregroundStyle(.secondary) + } + ProgressView(value: torrent.complete ? 1 : min(max(torrent.progress, 0), 1)).tint(model.color(for: statusOverride ?? torrent.status)).accessibilityLabel("Download progress") (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout())) { Label(bytes(torrent.dlspeed) + "/s", systemImage: "arrow.down").accessibilityLabel("Download speed").accessibilityValue(bytes(torrent.dlspeed) + " per second") Label(bytes(torrent.upspeed) + "/s", systemImage: "arrow.up").accessibilityLabel("Upload speed").accessibilityValue(bytes(torrent.upspeed) + " per second") @@ -166,7 +170,7 @@ struct LibraryView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var search = "" @State private var filter = "All" - @State private var sort = "Name" + @AppStorage("torrentLibrarySort") private var sort = "Queue order" let filters = ["All", "Downloading", "Seeding", "Completed", "Paused", "Stalled", "Queued", "Error"] var filtered: [Torrent] { model.torrents.filter { t in @@ -187,6 +191,12 @@ struct LibraryView: View { case "Progress": return $0.progress > $1.progress case "Speed": return $0.dlspeed > $1.dlspeed case "Size": return $0.size > $1.size + case "Queue order": + let left = $0.queuePosition ?? Int.max + let right = $1.queuePosition ?? Int.max + if left != right { return left < right } + let nameOrder = $0.name.localizedStandardCompare($1.name) + return nameOrder == .orderedSame ? $0.hash < $1.hash : nameOrder == .orderedAscending case "ETA": return $0.eta < $1.eta default: return $0.name.localizedStandardCompare($1.name) == .orderedAscending } @@ -206,6 +216,10 @@ struct LibraryView: View { } } else { Picker("Status", selection: $filter) { ForEach(filters, id: \.self) { Text($0) } } + if sort == "Queue order" { + Text("Server queue order · unnumbered torrents appear last. Queueing must be enabled in qBittorrent for positions to be available.") + .font(.caption).foregroundStyle(.secondary) + } DisclosureGroup("Status colours") { ForEach(TorrentStatus.allCases) { status in Label { Text(status.title) } icon: { @@ -219,7 +233,7 @@ struct LibraryView: View { ForEach(filtered) { torrent in NavigationLink(value: torrent) { VStack(alignment: .leading) { - TorrentRow(torrent: torrent) + TorrentRow(torrent: torrent, statusOverride: !home && filter == "Completed" ? .completed : nil) if model.pendingActions.contains(torrent.hash) { ProgressView("Applying action…") } } } @@ -237,7 +251,7 @@ struct LibraryView: View { .navigationTitle(home ? "Downloading" : "Library") .searchable(text: $search, prompt: "Name, category or tag") .refreshable { await model.refresh() } - .toolbar { Menu { Picker("Sort by", selection: $sort) { ForEach(["Name", "Progress", "Speed", "Size", "ETA"], id: \.self) { Text($0) } } } label: { Label("Sort", systemImage: "arrow.up.arrow.down") } } + .toolbar { Menu { Picker("Sort by", selection: $sort) { ForEach(["Queue order", "Name", "Progress", "Speed", "Size", "ETA"], id: \.self) { Text($0) } } } label: { Label("Sort", systemImage: "arrow.up.arrow.down") } } .navigationDestination(for: Torrent.self) { DetailView(torrent: $0) } } func metric(_ title: String, _ value: String, _ icon: String) -> some View { diff --git a/Tarnax/Core.swift b/Tarnax/Core.swift index df6b4df..896fef0 100644 --- a/Tarnax/Core.swift +++ b/Tarnax/Core.swift @@ -41,8 +41,11 @@ struct Torrent: Decodable, Identifiable, Hashable { var hash: String; var name: String; var state: String var progress: Double; var size: Int64; var dlspeed: Int64; var upspeed: Int64; var eta: Int64 var ratio: Double; var category: String; var tags: String + var priority: Int? = nil + var queuePosition: Int? { guard let priority, priority > 0 else { return nil }; return priority } var id: String { hash } - var complete: Bool { progress >= 1 || ["uploading", "stalledUP", "forcedUP", "queuedUP", "pausedUP", "stoppedUP"].contains(state) } + // Completion must reflect measured progress, never an inferred state suffix. + var complete: Bool { progress.isFinite && progress >= 1 } var status: TorrentStatus { if ["error", "missingFiles"].contains(state) { return .error } if state.hasPrefix("stalled") { return .stalled } @@ -320,7 +323,7 @@ struct AppLogEntry: Codable, Identifiable { } @MainActor final class AppModel: ObservableObject { - let logs = AppLogStore() + let logs: AppLogStore @Published private(set) var statusColors: [String: SavedStatusColor] = { guard let data = UserDefaults.standard.data(forKey: "torrentStatusColors"), let colors = try? JSONDecoder().decode([String: SavedStatusColor].self, from: data) else { return [:] } @@ -365,7 +368,7 @@ struct AppLogEntry: Codable, Identifiable { @Published private(set) var preferences: [String: Any] = [:] @Published private(set) var preferencesLoading = false private var preferencesTask: Task? - private var refreshing = false + private var refreshTask: Task? var client: Transport? var historyStore = HistoryStore() @Published var historyError: String? @@ -374,8 +377,9 @@ struct AppLogEntry: Codable, Identifiable { private let notificationPresenter = CompletionNotificationPresenter() @Published var completionNotifications = UserDefaults.standard.bool(forKey: "completionNotifications") private var observedCompletion: [String: Bool] = [:] - init() { - logs.record("App launched.") + init(logs: AppLogStore? = nil) { + self.logs = logs ?? AppLogStore() + self.logs.record("App launched.") #if os(iOS) UNUserNotificationCenter.current().delegate = notificationPresenter #endif @@ -478,8 +482,16 @@ struct AppLogEntry: Codable, Identifiable { } catch is CancellationError {} catch let e as URLError where e.code == .cancelled {} catch { if connectionAttempt == attempt { logs.failure(error, context: "Connection"); self.error = error.localizedDescription } } } func refresh() async { - guard let client, connected, !locked, !refreshing else { return } - refreshing = true; defer { refreshing = false } + if let task = refreshTask { await task.value; return } + guard connected, !locked else { return } + // Pull-to-refresh waits for in-flight polling instead of returning immediately. + let task = Task { @MainActor in await self.fetchSnapshot() } + refreshTask = task + await task.value + refreshTask = nil + } + private func fetchSnapshot() async { + guard let client, connected, !locked else { return } do { async let list: [Torrent] = client.get("torrents/info") async let info = client.object("sync/maindata") diff --git a/Tarnax/Details.swift b/Tarnax/Details.swift index 59c84bc..1fdf5a9 100644 --- a/Tarnax/Details.swift +++ b/Tarnax/Details.swift @@ -44,7 +44,7 @@ struct DetailView: View { } } }.navigationTitle(live.name).navigationBarTitleDisplayMode(.inline) - .refreshable { await load() } + .refreshable { await model.refresh(); await load() } .task { while !Task.isCancelled { await load(); do { try await Task.sleep(for: .seconds(5)) } catch { break } } } .confirmationDialog("Delete this torrent?", isPresented: $deleting, titleVisibility: .visible) { Button("Remove torrent only", role: .destructive) { Task { await remove(false) } } diff --git a/Tarnax/Settings.swift b/Tarnax/Settings.swift index 36a4c50..9ba2395 100644 --- a/Tarnax/Settings.swift +++ b/Tarnax/Settings.swift @@ -50,7 +50,7 @@ struct SettingsView: View { NavigationLink { List(keys, id: \.self) { key in NavigationLink { PreferenceEditor(key: key, original: preferences[key]!) { await load() } } label: { VStack(alignment: .leading) { Text(key.replacingOccurrences(of: "_", with: " ").capitalized); Text(key).font(.caption2).foregroundStyle(.secondary); Text(StatFormat.display(preferences[key]!, key: key, preference: true)).font(.subheadline).foregroundStyle(.secondary) } - } }.navigationTitle(group) + } }.navigationTitle(group).refreshable { await load() } } label: { LabeledContent(group, value: "\(keys.count)") } } } @@ -69,7 +69,7 @@ struct SettingsView: View { Link("qBittorrent Web API documentation", destination: URL(string: "https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-5.0)")!) } }.navigationTitle("Settings").searchable(text: $search, prompt: "Find a preference key") - .task { await load() }.refreshable { await load() } + .task { await load() }.refreshable { await model.refresh(); await load() } .confirmationDialog("Forget credentials on this device?", isPresented: $disconnect, titleVisibility: .visible) { Button("Forget and disconnect", role: .destructive) { model.disconnect(forget: true) } } } func load() async { diff --git a/Tests/ContractTests.swift b/Tests/ContractTests.swift index 98afa81..b80b773 100644 --- a/Tests/ContractTests.swift +++ b/Tests/ContractTests.swift @@ -30,6 +30,9 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { return data } @MainActor static func main() async throws { + let testLogURL = URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent("contract-logs-" + UUID().uuidString + ".json") + defer { try? FileManager.default.removeItem(at: testLogURL) } + for server in ["http://example.test", "https://u:p@example.test", "https://example.test/?password=x"] { do { _ = try Transport(Credentials(server: server)); fatalError("Insecure URL accepted") } catch { check(true, "Reject unsafe URL: \(server)") } } @@ -89,7 +92,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { transport.session.configuration.httpCookieStorage?.setCookie(cookie) let another = try Transport(Credentials(server: "https://example.test")) check(another.session.configuration.httpCookieStorage?.cookies?.contains(where: { $0.name == "SID" }) != true, "Cookie jar isolated from other connections") - let model = AppModel() + let model = AppModel(logs: AppLogStore(url: testLogURL)) model.client = transport; model.connected = true; model.locked = false let gate = DispatchSemaphore(value: 0) var preferenceRequests = 0 @@ -132,7 +135,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { check(model.error == nil, "Retired session cannot raise a global alert") model.disconnect() check(model.preferences.isEmpty && !model.preferencesLoading, "Disconnect clears session preferences") - let startup = AppModel() + let startup = AppModel(logs: AppLogStore(url: testLogURL)) startup.credentials = Credentials(server: "https://example.test") startup.locked = false check(!startup.canRestoreSession, "Default server without a saved user does not auto-login") @@ -148,13 +151,13 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { startup.error = nil await startup.restoreSessionIfNeeded() check(startup.error == nil, "Failed automatic login is not retried on view updates") - let disconnected = AppModel() + let disconnected = AppModel(logs: AppLogStore(url: testLogURL)) disconnected.locked = false disconnected.credentials = Credentials(server: "http://invalid.test", username: "test-user") disconnected.disconnect() await disconnected.restoreSessionIfNeeded() check(disconnected.error == nil && !disconnected.connected, "Explicit disconnect suppresses automatic login for this launch") - let protection = AppModel() + let protection = AppModel(logs: AppLogStore(url: testLogURL)) protection.locked = true; protection.biometric = true var authCalls = 0 protection.authenticate = { _ in authCalls += 1; return true } @@ -198,7 +201,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { } await protection.updateScene(.active) check(!protection.locked, "Face ID success while system prompt is inactive unlocks the app") - let presentation = AppModel() + let presentation = AppModel(logs: AppLogStore(url: testLogURL)) presentation.biometric = true; presentation.locked = true check(!presentation.unlockNeedsRetry, "Fresh protected launch shows branding instead of lock fallback") presentation.authenticate = { _ in @@ -236,7 +239,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { check(dense.samples.count == TransferHistory.maximumSamples, "History storage remains bounded during frequent refreshes") check(model.transferHistory.samples.isEmpty, "Disconnected session has no previous server history") let historyTransport = try Transport(Credentials(server: "https://example.test"), configuration: config) - let liveHistory = AppModel() + let liveHistory = AppModel(logs: AppLogStore(url: testLogURL)) liveHistory.historyStore = HistoryStore(url: URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent("test-history.json")) defer { try? liveHistory.historyStore.clear() } liveHistory.client = historyTransport; liveHistory.connected = true; liveHistory.locked = false @@ -270,6 +273,22 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { check(StatFormat.display(120, key: "max_seeding_time", preference: true) == "2 hr", "Seeding preferences convert API minutes correctly") check(StatFormat.display(42, key: "total_peers") == "42", "Unit metadata leaves counters untouched") check(StatFormat.display(1024, key: "dl_info_speed").hasSuffix("/s"), "Speeds retain per-second units") + let queueJSON = "[{\"hash\":\"queued\",\"name\":\"Queued torrent\",\"state\":\"queuedDL\",\"progress\":0.5,\"size\":100,\"dlspeed\":0,\"upspeed\":0,\"eta\":10,\"ratio\":0,\"category\":\"\",\"tags\":\"\",\"priority\":3}]" + let queue = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.utf8)) + check(queue.first?.queuePosition == 3, "Queue position decodes from server priority") + let unnumbered = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.replacingOccurrences(of: "3}", with: "-1}").utf8)) + check(unnumbered.first?.queuePosition == nil, "Unnumbered server priority is not presented as a queue position") + let noPriority = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.replacingOccurrences(of: ",\"priority\":3", with: "").utf8)) + check(noPriority.first?.queuePosition == nil, "Older servers without priority still decode") + var completionFixture = queue[0] + for state in ["uploading", "stalledUP", "forcedUP", "queuedUP", "pausedUP", "stoppedUP"] { + completionFixture.state = state; completionFixture.progress = 0.75 + check(!completionFixture.complete, "Incomplete progress is excluded even for \(state)") + } + completionFixture.progress = 1 + check(completionFixture.complete, "Full measured progress is completed") + completionFixture.progress = 0.9999 + check(!completionFixture.complete, "Rounded near-100-percent progress is not completed") another.close(); transport.close(); print("\(checks) contract checks passed.") } } diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 773a320..2035bb7 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -119,3 +119,15 @@ Settings → Status colours now provides native colour pickers for downloading, Added Settings → Logs with newest-first timestamped activity/error entries and a confirmed Clear logs action that deletes the saved archive. Logs restore across launches, retain at most 1,000 entries and group adjacent identical events within a minute. Connection, preference loading, server actions and completion notification events are recorded. Reportable network failures include safe error codes; decoding failures are identified without saving payloads. Credentials, request fields, response bodies, server URLs and torrent names are excluded. Storage failures are shown in Logs. Privacy text updated. Unsigned generic iOS Debug build succeeded. Standalone executable log checks passed for persistence/reload, duplicate grouping, capacity, privacy of network error userInfo, permanent clear/reload and failed-write feedback. The log checks are included in Tests/run.sh. Build/test temporary files are under ~/Developer/Scratch/Tarnax. Device UI verification remains outstanding. + +### Queue ordering and pull-to-refresh — 5 November 2026 + +Library defaults to persisted Queue order sorting, using qBittorrent priority positions and putting unnumbered entries last with deterministic name/hash ordering. Rows display queue positions. Queueing must be enabled server-side for numbered priorities. Missing priority remains compatible with older server responses. Home still filters only actively downloading states. + +Pull-to-refresh now awaits any in-flight dashboard refresh instead of silently skipping it. Dashboard requests are session-owned and coalesced. Detail refresh updates the main torrent snapshot as well as properties/files/trackers/peers; Settings refresh updates dashboard and preferences, with pull-to-refresh also available in preference groups. Server already refreshes dashboard, transfer and build information. Unsigned generic iOS Debug build succeeded; 94 executable contracts, standalone log checks and three profile checks passed. Temporary files/logs remain under ~/Developer/Scratch/Tarnax. + +Production readiness: this update has not undergone signed Release validation, physical-device/live-server acceptance or installation. Completion notifications still require foreground observation; immediate suspended/killed-app delivery requires server push and is not implemented. + +### 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.