diff --git a/Tarnax/App.swift b/Tarnax/App.swift
index cca04f6..fb07c4f 100644
--- a/Tarnax/App.swift
+++ b/Tarnax/App.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import Charts
import UniformTypeIdentifiers
@main struct TarnaxApp: App {
@@ -199,6 +200,7 @@ struct LibraryView: View {
if !dynamicTypeSize.isAccessibilitySize { Spacer() }
metric("Upload", bytes((model.stats["up_info_speed"] as? NSNumber)?.int64Value ?? 0) + "/s", "arrow.up")
}.padding(.vertical, 8)
+ TransferHistoryChart(history: model.transferHistory, updatedAt: model.lastRefresh)
LabeledContent("Free space", value: bytes((model.stats["free_space_on_disk"] as? NSNumber)?.int64Value ?? 0))
}
} else {
@@ -275,3 +277,96 @@ struct AddView: View {
} catch { model.error = error.localizedDescription }
}
}
+
+struct TransferHistoryChart: View {
+ let history: TransferHistory
+ let updatedAt: Date?
+ @State private var minutes = 5
+ @State private var selection: Date?
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
+
+ private var end: Date { updatedAt ?? Date() }
+ private var start: Date { end.addingTimeInterval(-Double(minutes * 60)) }
+ private var samples: [TransferSample] { history.samples.filter { $0.date >= start && $0.date <= end } }
+ private var selectedSample: TransferSample? {
+ guard let selection else { return nil }
+ return samples.min { abs($0.date.timeIntervalSince(selection)) < abs($1.date.timeIntervalSince(selection)) }
+ }
+ private var maximum: Double {
+ max(1024, Double(samples.map { max($0.download, $0.upload) }.max() ?? 0) * 1.15)
+ }
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) : AnyLayout(HStackLayout())) {
+ Text("Transfer history").font(.subheadline.weight(.semibold))
+ if !dynamicTypeSize.isAccessibilitySize { Spacer() }
+ Picker("History duration", selection: $minutes) {
+ Text("1m").tag(1)
+ Text("5m").tag(5)
+ Text("15m").tag(15)
+ }.pickerStyle(.segmented).frame(maxWidth: 190)
+ }
+ (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) : AnyLayout(HStackLayout(spacing: 20))) {
+ Label("Download", systemImage: "arrow.down").foregroundStyle(.indigo)
+ Label("Upload · dashed", systemImage: "arrow.up").foregroundStyle(.teal)
+ }.font(.caption)
+ if samples.isEmpty {
+ VStack(spacing: 8) {
+ Image(systemName: "chart.xyaxis.line").font(.title2).foregroundStyle(.secondary)
+ Text("Waiting for transfer history").font(.subheadline)
+ Text("The graph fills as speeds update.").font(.caption).foregroundStyle(.secondary)
+ }.frame(maxWidth: .infinity, minHeight: 160)
+ } else {
+ Chart {
+ ForEach(samples) { sample in
+ LineMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download)), series: .value("Series", "Download-\(sample.segment)"))
+ .foregroundStyle(.indigo).lineStyle(StrokeStyle(lineWidth: 2.5))
+ LineMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.upload)), series: .value("Series", "Upload-\(sample.segment)"))
+ .foregroundStyle(.teal).lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 4]))
+ if samples.count == 1 {
+ PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download))).foregroundStyle(.indigo)
+ PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.upload))).foregroundStyle(.teal)
+ }
+ }
+ if let selectedSample {
+ RuleMark(x: .value("Selected time", selectedSample.date)).foregroundStyle(.secondary.opacity(0.5))
+ }
+ }
+ .chartXScale(domain: start...end)
+ .chartYScale(domain: 0...maximum)
+ .chartXAxis {
+ AxisMarks(values: .automatic(desiredCount: 3)) { value in
+ AxisGridLine().foregroundStyle(.secondary.opacity(0.12))
+ AxisValueLabel(format: minutes == 1 ? Date.FormatStyle.dateTime.minute().second() : Date.FormatStyle.dateTime.hour().minute())
+ }
+ }
+ .chartYAxis {
+ AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { value in
+ AxisGridLine().foregroundStyle(.secondary.opacity(0.15))
+ AxisValueLabel {
+ if let speed = value.as(Double.self) {
+ Text(bytes(Int64(min(max(0, speed), Double(Int64.max / 2)))) + "/s").font(.caption2)
+ }
+ }
+ }
+ }
+ .chartLegend(.hidden)
+ .chartXSelection(value: $selection)
+ .frame(height: dynamicTypeSize.isAccessibilitySize ? 240 : 170)
+ .accessibilityLabel("Upload and download speed history")
+ .accessibilityHint("Explore the chart to hear recorded transfer speeds.")
+ if let sample = selectedSample {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(sample.date, format: .dateTime.hour().minute().second())
+ Text("Download \(bytes(sample.download))/s · Upload \(bytes(sample.upload))/s")
+ }.font(.caption.monospacedDigit()).accessibilityElement(children: .combine)
+ } else {
+ Text("Updates every 3 seconds · this session")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(.vertical, 8)
+ .onChange(of: minutes) { _, _ in selection = nil }
+ }
+}
diff --git a/Tarnax/Core.swift b/Tarnax/Core.swift
index 799cdbb..c947621 100644
--- a/Tarnax/Core.swift
+++ b/Tarnax/Core.swift
@@ -127,6 +127,32 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func close() { session.invalidateAndCancel() }
}
+struct TransferSample: Identifiable {
+ let id = UUID()
+ let date: Date
+ let download: Int64
+ let upload: Int64
+ let segment: Int
+}
+struct TransferHistory {
+ static let retention: TimeInterval = 15 * 60
+ static let maximumSamples = 600
+ private(set) var samples: [TransferSample] = []
+ private var segment = 0
+
+ mutating func record(download: Int64, upload: Int64, at date: Date = Date()) {
+ if let last = samples.last {
+ guard date >= last.date else { return }
+ if date.timeIntervalSince(last.date) > 10 { segment += 1 }
+ // Extra action/manual refreshes must not inflate the history buffer.
+ if date.timeIntervalSince(last.date) < 1 { samples.removeLast() }
+ }
+ samples.append(TransferSample(date: date, download: max(0, download), upload: max(0, upload), segment: segment))
+ samples.removeAll { $0.date < date.addingTimeInterval(-Self.retention) }
+ if samples.count > Self.maximumSamples { samples.removeFirst(samples.count - Self.maximumSamples) }
+ }
+}
+
@MainActor final class AppModel: ObservableObject {
@Published var credentials = Vault.load()
@Published var connected = false
@@ -140,6 +166,7 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
@Published var apiVersion = ""
@Published var error: String?
@Published var lastRefresh: Date?
+ @Published private(set) var transferHistory = TransferHistory()
@Published var locked = UserDefaults.standard.bool(forKey: "biometricLock")
@Published var biometric = UserDefaults.standard.bool(forKey: "biometricLock")
@Published private(set) var authenticating = false
@@ -219,6 +246,7 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
guard connectionAttempt == attempt else { return }
try Vault.save(connectionCredentials)
clearPreferences()
+ transferHistory = TransferHistory()
client?.close(); client = next; installed = true
version = String(decoding: v, as: UTF8.self); apiVersion = String(decoding: a, as: UTF8.self)
connected = true; await refresh()
@@ -235,7 +263,12 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
guard self.client === client else { return }
torrents = nextTorrents
stats = nextInfo["server_state"] as? [String: Any] ?? [:]
- lastRefresh = Date()
+ let refreshedAt = Date()
+ if let download = stats["dl_info_speed"] as? NSNumber,
+ let upload = stats["up_info_speed"] as? NSNumber {
+ transferHistory.record(download: download.int64Value, upload: upload.int64Value, at: refreshedAt)
+ }
+ lastRefresh = refreshedAt
} catch { report(error, from: client) }
}
func perform(_ endpoint: String, _ fields: [String: String]) async {
@@ -317,6 +350,7 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
attemptedAutomaticLogin = true
connectionAttempt = UUID()
clearPreferences()
+ transferHistory = TransferHistory()
client?.close(); client = nil; connected = false; torrents = []; stats = [:]
if forget { Vault.clear(); credentials = Credentials() }
}
diff --git a/Tarnax/Info.plist b/Tarnax/Info.plist
index 796156c..09f9039 100644
--- a/Tarnax/Info.plist
+++ b/Tarnax/Info.plist
@@ -15,7 +15,7 @@
CFBundleShortVersionString
1.0
CFBundleVersion
- 4
+ 5
LSRequiresIPhoneOS
NSFaceIDUsageDescription
diff --git a/Tests/ContractTests.swift b/Tests/ContractTests.swift
index 5d4b5ba..cd0fc51 100644
--- a/Tests/ContractTests.swift
+++ b/Tests/ContractTests.swift
@@ -218,6 +218,37 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
presentation.authenticate = { _ in false }
await presentation.updateScene(.active)
check(presentation.locked && presentation.unlockNeedsRetry, "Incomplete authentication offers a retry instead of a permanent spinner")
+ var history = TransferHistory()
+ let origin = Date(timeIntervalSince1970: 10000)
+ history.record(download: 100, upload: 20, at: origin)
+ history.record(download: 200, upload: 30, at: origin.addingTimeInterval(3))
+ check(history.samples.count == 2 && history.samples.last?.download == 200, "History records actual upload and download speed samples")
+ history.record(download: -1, upload: -5, at: origin.addingTimeInterval(3.5))
+ check(history.samples.count == 2 && history.samples.last?.download == 0 && history.samples.last?.upload == 0, "Rapid refreshes coalesce and negative speeds clamp to zero")
+ history.record(download: 99, upload: 99, at: origin)
+ check(history.samples.count == 2, "Out-of-order speed samples cannot corrupt the timeline")
+ history.record(download: 300, upload: 30, at: origin.addingTimeInterval(30))
+ check(history.samples.last?.segment == 1, "Background and network gaps start a new chart segment")
+ history.record(download: 400, upload: 40, at: origin.addingTimeInterval(1000))
+ check(history.samples.count == 1, "History expires samples older than fifteen minutes")
+ var dense = TransferHistory()
+ for second in 0...700 { dense.record(download: 1, upload: 2, at: origin.addingTimeInterval(Double(second))) }
+ 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()
+ liveHistory.client = historyTransport; liveHistory.connected = true; liveHistory.locked = false
+ MockProtocol.handler = { request in
+ if request.url!.path.hasSuffix("torrents/info") { return (200, "[]") }
+ return (200, "{\"server_state\":{\"dl_info_speed\":1234,\"up_info_speed\":567}}")
+ }
+ await liveHistory.refresh()
+ check(liveHistory.transferHistory.samples.last?.download == 1234 && liveHistory.transferHistory.samples.last?.upload == 567, "Successful dashboard refresh records both speed series")
+ MockProtocol.handler = { _ in throw URLError(.notConnectedToInternet) }
+ await liveHistory.refresh()
+ check(liveHistory.transferHistory.samples.count == 1, "Failed refresh never fabricates a speed sample")
+ liveHistory.disconnect()
+ check(liveHistory.transferHistory.samples.isEmpty, "Disconnect removes actual collected history")
another.close(); transport.close(); print("\(checks) contract checks passed.")
}
}
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
index 693a46d..ebf874e 100644
--- a/docs/VALIDATION.md
+++ b/docs/VALIDATION.md
@@ -74,3 +74,11 @@ UI changes: compact tab navigation with individual stacks, regular-width sidebar
Final UI verification: production Release build succeeded; 54 contract checks passed. A separate sample-data simulator preview was inspected in light appearance and dark appearance at the largest accessibility text size. Full VoiceOver and physical-device testing remain outstanding. Ad Hoc IPA export remains blocked on the missing profile pending approval for account provisioning updates.
Approved IPA release: release-testing export succeeded with provisioning updates enabled. Tarnax 1.0 build 1; verified code signature, bundle identifier, profile application identifier, profile expiry 2027-09-03T11:17:13Z and four provisioned devices. Export contains IPA and SHA-256 checksum.
+
+## Face ID and deployment follow-up — 4 November 2026
+
+Build 3 installed and launched on the paired iPhone; user confirmed Face ID unlocks successfully. Authentication now observes the scene lifecycle from RootView and accepts a successful evaluation during the system prompt's inactive phase, while rejecting results from a previous background lock cycle.
+
+Build 4 hides the lock fallback during normal launch and Face ID evaluation, showing the dark Tarnax branded view instead. Cancelled, failed or incomplete authentication offers Unlock; retry immediately returns to branding. No protected content is shown before authentication. 71 executable contract checks and profile validation tests passed; signed Release built, installed and launched successfully on the iPhone.
+
+Deploy script validates exact and wildcard Apple App IDs, checks device registration and expiry, and separates installation success from launch failure on a locked phone. Script-controlled temporary files and DerivedData now use ~/Developer/Scratch/Tarnax. Final build products remain under build/.