From b16c56baabfa13c6a873f1ff51794e1952ba7d5b Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 4 Nov 2026 15:25:14 +0000 Subject: [PATCH] updates for lock screen --- Tarnax/App.swift | 95 +++++++++++++++++++++++++++++++++++++++ Tarnax/Core.swift | 36 ++++++++++++++- Tarnax/Info.plist | 2 +- Tests/ContractTests.swift | 31 +++++++++++++ docs/VALIDATION.md | 8 ++++ 5 files changed, 170 insertions(+), 2 deletions(-) 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/.