new app store assets ready for relese ui updates

This commit is contained in:
Mark Gregory
2026-11-05 09:43:06 +00:00
parent e68a6598fc
commit 6e89965c38
7 changed files with 230 additions and 14 deletions
+11 -2
View File
@@ -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
+113 -10
View File
@@ -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()
+1 -1
View File
@@ -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()
+59 -1
View File
@@ -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.") }
}
}
+33
View File
@@ -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.")
}
}
+3
View File
@@ -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"
+10
View File
@@ -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.