updates for lock screen
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/.
|
||||
|
||||
Reference in New Issue
Block a user