updates for lock screen

This commit is contained in:
Mark Gregory
2026-11-04 15:25:14 +00:00
parent 66adec42e3
commit b16c56baab
5 changed files with 170 additions and 2 deletions
+95
View File
@@ -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 }
}
}
+35 -1
View File
@@ -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() }
}
+1 -1
View File
@@ -15,7 +15,7 @@
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>4</string>
<string>5</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSFaceIDUsageDescription</key>
+31
View File
@@ -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.")
}
}
+8
View File
@@ -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/.