Files
Tarnax/Tarnax/App.swift
T

414 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Charts
import UniformTypeIdentifiers
@main struct TarnaxApp: App {
@StateObject private var model = AppModel()
var body: some Scene {
WindowGroup {
RootView().environmentObject(model).tint(.indigo)
}
}
}
struct RootView: View {
@EnvironmentObject var model: AppModel
@SceneStorage("selectedSection") private var selected = "Home"
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var add = false
@Environment(\.scenePhase) var phase
let pages = ["Home", "Library", "Server", "Settings"]
let icons = ["Home": "arrow.down.circle.fill", "Library": "square.stack.fill", "Server": "server.rack", "Settings": "gearshape.fill"]
var body: some View {
Group {
if model.locked {
if model.unlockNeedsRetry {
ContentUnavailableView { Label("Unlock Tarnax", systemImage: "lock.shield") } description: {
Text(model.unlockStatus ?? "Use Face ID or your device passcode to continue.")
} actions: {
Button("Unlock") { Task { await model.unlock() } }.buttonStyle(.borderedProminent)
}
} else {
TarnaxSplashView(status: "Unlocking securely…")
}
} else if model.restoringSession {
TarnaxSplashView()
} else if !model.connected {
NavigationStack { ConnectionView() }
} else {
Group {
if horizontalSizeClass == .compact {
TabView(selection: $selected) {
ForEach(pages, id: \.self) { page in
NavigationStack { section(page) }
.tabItem { Label(page, systemImage: icons[page]!) }
.tag(page)
}
}
} else {
NavigationSplitView {
List(pages, id: \.self, selection: Binding<String?>(get: { selected }, set: { if let page = $0 { selected = page } })) { page in
Label(page, systemImage: icons[page]!)
}
.navigationTitle("Tarnax")
.safeAreaInset(edge: .bottom) {
Label("qBittorrent \(model.version)", systemImage: "checkmark.shield")
.font(.caption).foregroundStyle(.secondary).padding()
}
} detail: {
NavigationStack { section(selected) }.id(selected)
}
}
}
.sheet(isPresented: $add) { AddView() }
}
}
.onChange(of: phase, initial: true) { _, value in
Task { await model.updateScene(value) }
}
.task(id: model.locked) { await model.restoreSessionIfNeeded() }
.alert("Tarnax", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") }
.task(id: "\(phase)-\(model.connected)-\(model.locked)") {
guard phase == .active, model.connected, !model.locked else { return }
while !Task.isCancelled {
await model.refresh()
do { try await Task.sleep(for: .seconds(3)) } catch { break }
}
}
}
@ViewBuilder private func section(_ page: String) -> some View {
Group {
switch page {
case "Library": LibraryView(home: false)
case "Server": ServerView()
case "Settings": SettingsView()
default: LibraryView(home: true)
}
}
.toolbar {
if page == "Home" || page == "Library" {
ToolbarItem(placement: .primaryAction) {
Button("Add torrent", systemImage: "plus") { add = true }
}
}
}
}
}
struct TarnaxSplashView: View {
var status: LocalizedStringKey = "Connecting securely…"
var body: some View {
ZStack {
Color(red: 0, green: 0, blue: 0.055).ignoresSafeArea()
ScrollView { VStack(spacing: 24) {
Image("TarnaxBrand").resizable().scaledToFit().frame(width: 152, height: 152)
.clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous))
.accessibilityHidden(true)
VStack(spacing: 10) {
Text("Tarnax").font(.largeTitle.bold()).foregroundStyle(.white)
Text("Your downloads. Your server.").font(.subheadline).foregroundStyle(.white.opacity(0.75))
}
ProgressView(status).font(.body).tint(.white).foregroundStyle(.white)
.padding(.top, 12)
}.padding(32).frame(maxWidth: .infinity) }.defaultScrollAnchor(.center)
}
}
}
struct ConnectionView: View {
@EnvironmentObject var model: AppModel
var body: some View {
Form {
Section {
Label("Tarnax", systemImage: "arrow.down.circle.fill").font(.largeTitle.bold()).padding(.vertical)
Text("Your downloads. Your server.").foregroundStyle(.secondary)
}
Section("qBittorrent Web UI") {
TextField("HTTPS server URL", text: $model.credentials.server).textContentType(.URL).keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
TextField("Username", text: $model.credentials.username).textContentType(.username).textInputAutocapitalization(.never).autocorrectionDisabled()
SecureField("Password", text: $model.credentials.password).textContentType(.password)
}
Section("Reverse proxy Basic authentication · optional") {
TextField("Proxy username", text: $model.credentials.proxyUser).textInputAutocapitalization(.never).autocorrectionDisabled()
SecureField("Proxy password", text: $model.credentials.proxyPassword)
}
Section {
Button { Task { await model.connect() } } label: {
HStack { Spacer(); if model.busy { ProgressView() }; Text(model.busy ? "Connecting…" : "Connect securely").bold(); Spacer() }.frame(minHeight: 44)
}.disabled(model.busy || model.credentials.username.isEmpty)
} footer: { Text("Credentials are stored only in this device’s Keychain. Use the public HTTPS address, including any proxy subpath. Trusted TLS certificates are required.") }
}.navigationTitle("Connect").frame(maxWidth: 760)
}
}
struct TorrentRow: View {
@EnvironmentObject private var model: AppModel
let torrent: Torrent
var statusOverride: TorrentStatus? = nil
@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(model.color(for: statusOverride ?? 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)
}
if let position = torrent.queuePosition {
Text("Queue #\(position)").font(.caption).foregroundStyle(.secondary)
}
ProgressView(value: torrent.complete ? 1 : min(max(torrent.progress, 0), 1)).tint(model.color(for: statusOverride ?? 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")
if !dynamicTypeSize.isAccessibilitySize { Spacer() }; Text(eta(torrent.eta)).accessibilityLabel("Time remaining").accessibilityValue(eta(torrent.eta))
}.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout())) { Text(torrent.state); if !dynamicTypeSize.isAccessibilitySize { Spacer() }; Text(bytes(Int64(Double(torrent.size) * torrent.progress)) + " / " + bytes(torrent.size)) }.font(.caption2).foregroundStyle(.secondary)
}.padding(.vertical, 8)
}
}
struct LibraryView: View {
@EnvironmentObject var model: AppModel
let home: Bool
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var search = ""
@State private var filter = "All"
@AppStorage("torrentLibrarySort") private var sort = "Queue order"
let filters = ["All", "Downloading", "Seeding", "Completed", "Paused", "Stalled", "Queued", "Error"]
var filtered: [Torrent] {
model.torrents.filter { t in
let matches: Bool
switch filter {
case "Downloading": matches = t.active
case "Seeding": matches = ["uploading", "stalledUP", "forcedUP"].contains(t.state)
case "Completed": matches = t.complete
case "Paused": matches = t.state.hasPrefix("paused") || t.state.hasPrefix("stopped")
case "Stalled": matches = t.state.hasPrefix("stalled")
case "Queued": matches = t.state.hasPrefix("queued")
case "Error": matches = ["error", "missingFiles"].contains(t.state)
default: matches = true
}
return (home ? t.active : matches) && (search.isEmpty || (t.name + " " + t.category + " " + t.tags).localizedCaseInsensitiveContains(search))
}.sorted {
switch sort {
case "Progress": return $0.progress > $1.progress
case "Speed": return $0.dlspeed > $1.dlspeed
case "Size": return $0.size > $1.size
case "Queue order":
let left = $0.queuePosition ?? Int.max
let right = $1.queuePosition ?? Int.max
if left != right { return left < right }
let nameOrder = $0.name.localizedStandardCompare($1.name)
return nameOrder == .orderedSame ? $0.hash < $1.hash : nameOrder == .orderedAscending
case "ETA": return $0.eta < $1.eta
default: return $0.name.localizedStandardCompare($1.name) == .orderedAscending
}
}
}
var body: some View {
List {
if home {
Section {
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 16)) : AnyLayout(HStackLayout())) {
metric("Download", bytes((model.stats["dl_info_speed"] as? NSNumber)?.int64Value ?? 0) + "/s", "arrow.down")
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 {
Picker("Status", selection: $filter) { ForEach(filters, id: \.self) { Text($0) } }
if sort == "Queue order" {
Text("Server queue order · unnumbered torrents appear last. Queueing must be enabled in qBittorrent for positions to be available.")
.font(.caption).foregroundStyle(.secondary)
}
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
NavigationLink(value: torrent) {
VStack(alignment: .leading) {
TorrentRow(torrent: torrent, statusOverride: !home && filter == "Completed" ? .completed : nil)
if model.pendingActions.contains(torrent.hash) { ProgressView("Applying action…") }
}
}
.contextMenu {
Button("Resume", systemImage: "play") { Task { await model.action("resume", hash: torrent.hash) } }.disabled(!torrent.paused || model.pendingActions.contains(torrent.hash))
Button("Pause", systemImage: "pause") { Task { await model.action("pause", hash: torrent.hash) } }.disabled(torrent.paused || model.pendingActions.contains(torrent.hash))
}
}
if filtered.isEmpty { ContentUnavailableView(home ? "No active downloads" : "No matching torrents", systemImage: home ? "checkmark.circle" : "magnifyingglass", description: Text(home ? "Queued, paused, stalled and completed torrents are in Library." : "Try another search or filter.")) }
}
if let feedback = model.actionFeedback { Text(feedback).font(.callout).accessibilityAddTraits(.updatesFrequently) }
if let error = model.historyError { Text(error).foregroundStyle(.red) }
if let date = model.lastRefresh { Text("Updated \(date.formatted(date: .omitted, time: .standard)) · refreshes every 3 seconds").font(.caption2).foregroundStyle(.secondary) }
}
.navigationTitle(home ? "Downloading" : "Library")
.searchable(text: $search, prompt: "Name, category or tag")
.refreshable { await model.refresh() }
.toolbar { Menu { Picker("Sort by", selection: $sort) { ForEach(["Queue order", "Name", "Progress", "Speed", "Size", "ETA"], id: \.self) { Text($0) } } } label: { Label("Sort", systemImage: "arrow.up.arrow.down") } }
.navigationDestination(for: Torrent.self) { DetailView(torrent: $0) }
}
func metric(_ title: String, _ value: String, _ icon: String) -> some View {
VStack(alignment: .leading, spacing: 5) { Label(title, systemImage: icon).font(.caption).foregroundStyle(.secondary); Text(value).font(.title3.bold().monospacedDigit()).foregroundStyle(title == "Download" ? Color.blue : Color.teal) }
}
}
struct AddView: View {
@EnvironmentObject var model: AppModel
@Environment(\.dismiss) var dismiss
@State private var magnet = ""
@State private var path = ""
@State private var category = ""
@State private var tags = ""
@State private var paused = false
@State private var importing = false
@State private var file: Data?
@State private var filename = ""
@State private var busy = false
var body: some View {
NavigationStack {
Form {
Section("Magnet link") { TextField("magnet:?xt=urn:btih:…", text: $magnet, axis: .vertical).textInputAutocapitalization(.never).autocorrectionDisabled() }
Section("Or a torrent file") { Button(filename.isEmpty ? "Choose .torrent file" : filename) { importing = true } }
Section("Download options") {
TextField("Save path · server default if empty", text: $path).textInputAutocapitalization(.never).autocorrectionDisabled()
TextField("Category", text: $category); TextField("Tags · comma separated", text: $tags)
Toggle("Add paused", isOn: $paused)
}
Button(busy ? "Adding…" : "Add torrent") { Task { await add() } }.disabled(busy || (file == nil && !magnet.hasPrefix("magnet:?")))
}.navigationTitle("Add Torrent").toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
.interactiveDismissDisabled(busy)
.fileImporter(isPresented: $importing, allowedContentTypes: [UTType(filenameExtension: "torrent") ?? .data]) { result in
do {
let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } }
guard url.pathExtension.lowercased() == "torrent" else { throw RemoteError.message("Choose a .torrent file.") }
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
guard size <= 25_000_000 else { throw RemoteError.message("Torrent file exceeds 25 MB.") }
file = try Data(contentsOf: url); filename = url.lastPathComponent
} catch { model.error = error.localizedDescription }
}
}
}
func add() async {
guard let client = model.client else { return }; busy = true; defer { busy = false }
var fields = ["category": category, "tags": tags, "paused": paused ? "true" : "false", "stopped": paused ? "true" : "false"]
if !path.isEmpty { fields["savepath"] = path }
if file == nil { fields["urls"] = magnet }
do {
let data = try await client.request("torrents/add", fields, multipart: file.map { ($0, filename) })
let response = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
guard response.isEmpty || response == "Ok." else { throw RemoteError.message("Server rejected the torrent: \(response.prefix(120))") }
await model.refresh(); dismiss()
} catch { model.error = error.localizedDescription }
}
}
struct TransferHistoryChart: View {
let history: TransferHistory
let updatedAt: Date?
@State private var minutes = 1440
@State private var selection: Date?
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
private var end: Date { Date() }
private var start: Date { end.addingTimeInterval(-Double(minutes * 60)) }
private var samples: [TransferSample] { history.samples(from: start, through: 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)
Text("24h").tag(1440)
}.pickerStyle(.segmented).frame(maxWidth: 270)
}
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) : AnyLayout(HStackLayout(spacing: 20))) {
Label("Download", systemImage: "arrow.down.circle.fill").foregroundStyle(.blue)
Label("Upload · dashed", systemImage: "arrow.up.circle.fill").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
AreaMark(x: .value("Time", sample.date), yStart: .value("Baseline", 0.0), yEnd: .value("Download speed", Double(sample.download)), series: .value("Area", "Download-fill-\(sample.segment)"))
.foregroundStyle(LinearGradient(colors: [Color.indigo.opacity(0.35), Color.blue.opacity(0.03)], startPoint: .top, endPoint: .bottom))
.interpolationMethod(.monotone)
AreaMark(x: .value("Time", sample.date), yStart: .value("Baseline", 0.0), yEnd: .value("Upload speed", Double(sample.upload)), series: .value("Area", "Upload-fill-\(sample.segment)"))
.foregroundStyle(LinearGradient(colors: [Color.teal.opacity(0.28), Color.mint.opacity(0.02)], startPoint: .top, endPoint: .bottom))
.interpolationMethod(.monotone)
LineMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download)), series: .value("Series", "Download-\(sample.segment)"))
.foregroundStyle(LinearGradient(colors: [.blue, .indigo, .purple], startPoint: .leading, endPoint: .trailing))
.lineStyle(StrokeStyle(lineWidth: 3, lineCap: .round)).interpolationMethod(.monotone)
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.5, lineCap: .round, dash: [5, 4])).interpolationMethod(.monotone)
if samples.count == 1 || sample.id == samples.last?.id || sample.id == selectedSample?.id {
PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download))).foregroundStyle(.purple).symbolSize(28)
PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.upload))).foregroundStyle(.teal).symbolSize(28)
}
}
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)
}
}
}
}
.chartPlotStyle { plot in
plot.background(LinearGradient(colors: [Color.indigo.opacity(0.06), Color.teal.opacity(0.02)], startPoint: .topLeading, endPoint: .bottomTrailing))
}
.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("24-hour local history · gaps indicate no measurements")
.font(.caption).foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 8)
.onChange(of: minutes) { _, _ in selection = nil }
}
}