diff --git a/Tarnax/App.swift b/Tarnax/App.swift index 0b4c0fb..d3cc072 100644 --- a/Tarnax/App.swift +++ b/Tarnax/App.swift @@ -139,17 +139,18 @@ struct ConnectionView: View { } } struct TorrentRow: View { + @EnvironmentObject private var model: AppModel let torrent: Torrent @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(torrent.statusColor).font(.title2) + Image(systemName: torrent.complete ? "checkmark.circle.fill" : "arrow.down.circle.fill").foregroundStyle(model.color(for: 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(torrent.statusColor).accessibilityLabel("Download progress") + ProgressView(value: torrent.complete ? 1 : min(max(torrent.progress, 0), 1)).tint(model.color(for: 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") @@ -205,6 +206,14 @@ struct LibraryView: View { } } else { Picker("Status", selection: $filter) { ForEach(filters, id: \.self) { Text($0) } } + DisclosureGroup("Status colours") { + ForEach(TorrentStatus.allCases) { status in + Label { Text(status.title) } icon: { + Image(systemName: "circle.fill").foregroundStyle(model.color(for: status)) + } + } + NavigationLink("Customise colours") { StatusColorsView() } + }.font(.subheadline) } Section(home ? "Actively downloading · \(filtered.count)" : "Torrents · \(filtered.count)") { ForEach(filtered) { torrent in diff --git a/Tarnax/Core.swift b/Tarnax/Core.swift index 28c1b19..df6b4df 100644 --- a/Tarnax/Core.swift +++ b/Tarnax/Core.swift @@ -43,16 +43,43 @@ struct Torrent: Decodable, Identifiable, Hashable { var ratio: Double; var category: String; var tags: String var id: String { hash } var complete: Bool { progress >= 1 || ["uploading", "stalledUP", "forcedUP", "queuedUP", "pausedUP", "stoppedUP"].contains(state) } - var statusColor: Color { - if ["error", "missingFiles"].contains(state) || state.hasPrefix("stalled") { return .red } - if paused { return .pink } - if state.hasPrefix("queued") { return .orange } - if ["uploading", "forcedUP"].contains(state) { return .purple } - return complete ? .green : .yellow + var status: TorrentStatus { + if ["error", "missingFiles"].contains(state) { return .error } + if state.hasPrefix("stalled") { return .stalled } + if paused { return .paused } + if state.hasPrefix("queued") { return .queued } + if ["uploading", "forcedUP"].contains(state) { return .seeding } + return complete ? .completed : .downloading } var paused: Bool { state.hasPrefix("paused") || state.hasPrefix("stopped") } var active: Bool { ["downloading", "forcedDL", "metaDL", "forcedMetaDL"].contains(state) } } +enum TorrentStatus: String, CaseIterable, Identifiable { + case downloading, seeding, stalled, paused, queued, error, completed + var id: String { rawValue } + var title: String { rawValue.capitalized } + var defaultColor: Color { + switch self { + case .downloading: .yellow + case .seeding: .purple + case .stalled, .error: .red + case .paused: .pink + case .queued: .orange + case .completed: .green + } + } +} +struct SavedStatusColor: Codable { + let red: Double + let green: Double + let blue: Double + var color: Color { Color(.sRGB, red: red, green: green, blue: blue, opacity: 1) } + init(_ color: Color) { + let resolved = color.resolve(in: EnvironmentValues()) + red = Double(resolved.red); green = Double(resolved.green); blue = Double(resolved.blue) + } +} + struct TorrentFile: Decodable, Identifiable { var index: Int; var name: String; var size: Int64; var progress: Double; var priority: Int var id: Int { index } @@ -239,7 +266,77 @@ struct HistoryStore { func clear() throws { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } } } +struct AppLogEntry: Codable, Identifiable { + var id = UUID() + var date: Date + let level: String + let message: String + var count = 1 +} +@MainActor final class AppLogStore: ObservableObject { + static let capacity = 1000 + @Published private(set) var entries: [AppLogEntry] = [] + @Published private(set) var storageError: String? + private let url: URL + init(url: URL = URL.applicationSupportDirectory.appendingPathComponent("Tarnax/activity-log.json")) { + self.url = url + do { + if FileManager.default.fileExists(atPath: url.path) { + entries = Array(try JSONDecoder().decode([AppLogEntry].self, from: Data(contentsOf: url)).suffix(Self.capacity)) + } + } catch { storageError = "Saved logs could not be read. Clear logs to reset them." } + } + func record(_ message: String, level: String = "Info", at date: Date = Date()) { + // Messages are fixed app events: never pass request fields, URLs or server response bodies. + if let last = entries.last, last.message == message, last.level == level, date.timeIntervalSince(last.date) < 60 { + entries[entries.count - 1].count += 1 + entries[entries.count - 1].date = date + } else { + entries.append(AppLogEntry(date: date, level: level, message: message)) + if entries.count > Self.capacity { entries.removeFirst(entries.count - Self.capacity) } + } + save() + } + func failure(_ error: Error, context: String) { + let detail: String + if let network = error as? URLError { detail = "Network error (\(network.code.rawValue))." } + else if error is DecodingError { detail = "Response decoding failed." } + else { detail = "Operation failed." } + record(context + ": " + detail, level: "Error") + } + private func save() { + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(entries).write(to: url, options: .atomic) + storageError = nil + } catch { storageError = "Logs could not be saved on this device." } + } + func clear() { + do { + if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } + entries = []; storageError = nil + } catch { storageError = "Logs could not be cleared. Please try again." } + } +} + @MainActor final class AppModel: ObservableObject { + 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 [:] } + return colors + }() + func color(for status: TorrentStatus) -> Color { + statusColors[status.rawValue]?.color ?? status.defaultColor + } + func setColor(_ color: Color, for status: TorrentStatus) { + statusColors[status.rawValue] = SavedStatusColor(color) + if let data = try? JSONEncoder().encode(statusColors) { UserDefaults.standard.set(data, forKey: "torrentStatusColors") } + } + func resetStatusColors() { + statusColors = [:] + UserDefaults.standard.removeObject(forKey: "torrentStatusColors") + } @Published var credentials = Vault.load() @Published var connected = false @Published var busy = false @@ -278,6 +375,7 @@ struct HistoryStore { @Published var completionNotifications = UserDefaults.standard.bool(forKey: "completionNotifications") private var observedCompletion: [String: Bool] = [:] init() { + logs.record("App launched.") #if os(iOS) UNUserNotificationCenter.current().delegate = notificationPresenter #endif @@ -288,6 +386,7 @@ struct HistoryStore { #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 } @@ -299,7 +398,7 @@ struct HistoryStore { #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)) } + 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 } @@ -313,7 +412,7 @@ struct HistoryStore { return !(error is CancellationError) && !(Task.isCancelled && (error as? URLError)?.code == .cancelled) } func report(_ error: Error, from source: Transport) { - if shouldReport(error, from: source) { self.error = error.localizedDescription } + if shouldReport(error, from: source) { logs.failure(error, context: "Server request"); self.error = error.localizedDescription } } private func clearPreferences() { preferencesTask?.cancel(); preferencesTask = nil @@ -336,6 +435,7 @@ struct HistoryStore { try Task.checkCancellation() guard self.client === client else { return } self.preferences = values + self.logs.record("Server preferences loaded.") } catch { self.report(error, from: client) } } preferencesTask = task @@ -373,8 +473,9 @@ struct HistoryStore { observedCompletion = [:] 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.") connected = true; await refresh() - } catch is CancellationError {} catch let e as URLError where e.code == .cancelled {} catch { if connectionAttempt == attempt { self.error = error.localizedDescription } } + } 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 } @@ -408,10 +509,11 @@ struct HistoryStore { do { _ = try await client.request(endpoint, fields) guard self.client === client else { return false } + logs.record("Server action completed: " + endpoint.split(separator: "?")[0]) actionFeedback = "Action completed." await refresh(); return true } catch { - if shouldReport(error, from: client) { actionFeedback = "Action failed: " + error.localizedDescription } + if shouldReport(error, from: client) { logs.failure(error, context: "Server action"); actionFeedback = "Action failed: " + error.localizedDescription } report(error, from: client); return false } } @@ -487,6 +589,7 @@ struct HistoryStore { UserDefaults.standard.set(biometric, forKey: "biometricLock") } func disconnect(forget: Bool = false) { + logs.record(forget ? "Disconnected and forgot saved credentials." : "Disconnected from server.") attemptedAutomaticLogin = true connectionAttempt = UUID() clearPreferences() diff --git a/Tarnax/Details.swift b/Tarnax/Details.swift index f6b69be..59c84bc 100644 --- a/Tarnax/Details.swift +++ b/Tarnax/Details.swift @@ -102,7 +102,7 @@ struct DetailView: View { ForEach(files) { file in VStack(alignment: .leading, spacing: 6) { Text(file.name).font(.subheadline) - ProgressView(value: file.progress).tint(file.progress >= 1 ? .green : .yellow) + ProgressView(value: file.progress).tint(model.color(for: file.progress >= 1 ? .completed : .downloading)) HStack { Text(bytes(file.size)).font(.caption).foregroundStyle(.secondary) Spacer() diff --git a/Tarnax/Settings.swift b/Tarnax/Settings.swift index 85b2212..36a4c50 100644 --- a/Tarnax/Settings.swift +++ b/Tarnax/Settings.swift @@ -36,6 +36,8 @@ struct SettingsView: View { var body: some View { List { 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) Toggle("Face ID / device authentication", isOn: Binding(get: { model.biometric }, set: { enabled in Task { await model.enableLock(enabled) } })) @@ -147,7 +149,7 @@ struct PrivacyPolicyView: View { Text("Tarnax communicates directly with the HTTPS server you configure. Server addresses, sign-in credentials, torrent requests and management commands are sent to that server. Its operator may keep logs and has its own privacy practices.") } Section("On this device") { - Text("Connection credentials are stored in this device’s Keychain. App-lock preferences and navigation state are stored locally. Timestamped transfer history is stored locally for a rolling 24 hours and removed when you forget credentials. Session cookies use a private, temporary network session.") + Text("Connection credentials are stored in this device’s Keychain. App-lock preferences, status colours, activity logs and navigation state are stored locally. Logs can be removed in Settings → Logs. Timestamped transfer history is stored locally for a rolling 24 hours and removed when you forget credentials. Session cookies use a private, temporary network session.") } Section("No tracking") { Text("Tarnax includes no advertising, tracking SDKs or third-party analytics. It does not send your torrent library or credentials to a developer-operated service unless you choose such a server yourself.") @@ -165,3 +167,59 @@ struct PrivacyPolicyView: View { }.navigationTitle("Privacy policy").navigationBarTitleDisplayMode(.inline) } } + +struct StatusColorsView: View { + @EnvironmentObject private var model: AppModel + var body: some View { + Form { + Section { + ForEach(TorrentStatus.allCases) { status in + ColorPicker(status.title, selection: Binding( + get: { model.color(for: status) }, + set: { model.setColor($0, for: status) } + ), supportsOpacity: false) + } + } footer: { + Text("Colours apply immediately to torrent icons, progress bars and the Library key. Your choices are saved on this device between launches.") + } + Section { + Button("Restore default colours") { model.resetStatusColors() } + } + }.navigationTitle("Status colours") + } +} + +struct LogsView: View { + @ObservedObject var logs: AppLogStore + @State private var confirmClear = false + var body: some View { + List { + Section { + Text("Activity and errors are saved on this device between launches. The latest 1,000 entries are kept; repeated events within a minute are grouped. Credentials, request contents and torrent names are excluded.") + .font(.footnote).foregroundStyle(.secondary) + if let error = logs.storageError { Text(error).foregroundStyle(.red) } + Button("Clear logs", role: .destructive) { confirmClear = true } + .disabled(logs.entries.isEmpty && logs.storageError == nil) + } + if logs.entries.isEmpty { + ContentUnavailableView("No logs", systemImage: "doc.text", description: Text("New activity and errors will appear here.")) + } else { + Section("Latest first · \(logs.entries.count) entries") { + ForEach(logs.entries.reversed()) { entry in + VStack(alignment: .leading, spacing: 6) { + Text(entry.date, format: .dateTime.year().month().day().hour().minute().second()) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + Label(entry.level, systemImage: entry.level == "Error" ? "exclamationmark.triangle" : "info.circle") + .font(.caption.bold()).foregroundStyle(entry.level == "Error" ? Color.red : Color.secondary) + Text(entry.message).textSelection(.enabled) + if entry.count > 1 { Text("Repeated \(entry.count) times").font(.caption).foregroundStyle(.secondary) } + }.padding(.vertical, 4).accessibilityElement(children: .combine) + } + } + } + }.navigationTitle("Logs") + .confirmationDialog("Permanently clear saved logs?", isPresented: $confirmClear, titleVisibility: .visible) { + Button("Clear all logs", role: .destructive) { logs.clear() } + } message: { Text("This removes all saved log entries from this device.") } + } +} diff --git a/Tests/LogTests.swift b/Tests/LogTests.swift new file mode 100644 index 0000000..ab41ca4 --- /dev/null +++ b/Tests/LogTests.swift @@ -0,0 +1,33 @@ +import Foundation + +@main struct LogTests { + @MainActor static func main() throws { + let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent("log-tests-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent("activity.json") + let first = AppLogStore(url: url) + precondition(first.entries.isEmpty) + let time = Date() + first.record("Connected.", at: time) + first.record("Connected.", at: time.addingTimeInterval(1)) + precondition(first.entries.count == 1 && first.entries[0].count == 2) + let restored = AppLogStore(url: url) + precondition(restored.entries.count == 1 && restored.entries[0].count == 2) + let privateURL = URL(string: "https://user:secret@example.test/private?token=secret")! + restored.failure(URLError(.timedOut, userInfo: [NSURLErrorFailingURLErrorKey: privateURL]), context: "Server request") + let archive = try String(contentsOf: url, encoding: .utf8) + precondition(!archive.contains("secret") && !archive.contains("example.test")) + for index in 0...1000 { restored.record("Event \(index)") } + precondition(restored.entries.count == AppLogStore.capacity) + precondition(AppLogStore(url: url).entries.count == AppLogStore.capacity) + restored.clear() + precondition(restored.entries.isEmpty && !FileManager.default.fileExists(atPath: url.path)) + precondition(AppLogStore(url: url).entries.isEmpty) + let blocker = root.appendingPathComponent("blocked") + try Data("blocked".utf8).write(to: blocker) + let failed = AppLogStore(url: blocker.appendingPathComponent("log.json")) + failed.record("Cannot save.") + precondition(failed.storageError != nil) + print("Log persistence, grouping, capacity, privacy, clear/reload and storage failure checks passed.") + } +} diff --git a/Tests/run.sh b/Tests/run.sh index 57e0ae4..e23af28 100755 --- a/Tests/run.sh +++ b/Tests/run.sh @@ -6,3 +6,6 @@ BUILD_DIR="$(mktemp -d "$TARNAX_SCRATCH_ROOT/contracts.XXXXXX")" trap 'rm -rf "$BUILD_DIR"' EXIT xcrun swiftc -parse-as-library -module-cache-path "$BUILD_DIR/cache" Tarnax/Core.swift Tests/ContractTests.swift -o "$BUILD_DIR/ContractTests" "$BUILD_DIR/ContractTests" + +xcrun swiftc -parse-as-library -module-cache-path "$BUILD_DIR/cache" Tarnax/Core.swift Tests/LogTests.swift -o "$BUILD_DIR/LogTests" +"$BUILD_DIR/LogTests" diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index a9fbe00..773a320 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -109,3 +109,13 @@ Final verification: unsigned generic iOS device Debug build **BUILD SUCCEEDED**; ### Status colours and detail controls — 5 November 2026 Shared torrent icons and bars now use yellow for downloads, purple for active seeding, red for stalled/error/missing files, pink for paused/stopped, orange for queued, and green for other completed transfers. Status-specific colours take priority over completion. Detail Resume/Pause/More share one button style with equal widths, 44-point minimum touch height, smaller subheadline labels and pressed feedback. Accessibility text sizes stack the controls vertically. Unsigned generic iOS Debug build succeeded; physical-device visual verification remains outstanding. Temporary build files and log are under ~/Developer/Scratch/Tarnax. + +### Saved status colour customisation — 5 November 2026 + +Settings → Status colours now provides native colour pickers for downloading, seeding, stalled, paused, queued, error and completed statuses, plus Restore default colours. RGB selections are encoded in device UserDefaults and restored on model initialization. Changes immediately update shared torrent row icons/bars, file progress bars and the Library legend. Stalled and error have independent colour choices. The Library legend also links directly to the editor. Unsigned generic iOS Debug build succeeded; diff whitespace validation passed. Picker interaction and relaunch on a physical device remain unverified. Temporary build files/log are under ~/Developer/Scratch/Tarnax. + +### Persistent local logs — 5 November 2026 + +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.