Files
Tarnax/Tarnax/Core.swift
T

495 lines
27 KiB
Swift

import Foundation
import CoreFoundation
import SwiftUI
import Security
import LocalAuthentication
#if canImport(UserNotifications)
import UserNotifications
#endif
struct Credentials: Codable {
var server = ""
var username = ""
var password = ""
var proxyUser = ""
var proxyPassword = ""
}
enum Vault {
static let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "net.absdev.Tarnax", kSecAttrAccount as String: "connection"]
static func load() -> Credentials {
var q = query; q[kSecReturnData as String] = true
var result: CFTypeRef?
guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return Credentials() }
return (try? JSONDecoder().decode(Credentials.self, from: data)) ?? Credentials()
}
static func save(_ value: Credentials) throws {
let data = try JSONEncoder().encode(value)
let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if status == errSecItemNotFound {
var q = query; q[kSecValueData as String] = data; q[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
guard SecItemAdd(q as CFDictionary, nil) == errSecSuccess else { throw RemoteError.message("Could not save credentials to Keychain.") }; return
}
guard status == errSecSuccess else { throw RemoteError.message("Keychain update failed (\(status)).") }
}
static func clear() { SecItemDelete(query as CFDictionary) }
}
enum RemoteError: LocalizedError {
case message(String)
var errorDescription: String? { if case .message(let text) = self { return text }; return nil }
}
struct Torrent: Decodable, Identifiable, Hashable {
var hash: String; var name: String; var state: String
var progress: Double; var size: Int64; var dlspeed: Int64; var upspeed: Int64; var eta: Int64
var ratio: Double; var category: String; var tags: String
var id: String { hash }
var complete: Bool { progress >= 1 || ["uploading", "stalledUP", "forcedUP", "queuedUP", "pausedUP", "stoppedUP"].contains(state) }
var paused: Bool { state.hasPrefix("paused") || state.hasPrefix("stopped") }
var active: Bool { ["downloading", "forcedDL", "metaDL", "forcedMetaDL"].contains(state) }
}
struct TorrentFile: Decodable, Identifiable {
var index: Int; var name: String; var size: Int64; var progress: Double; var priority: Int
var id: Int { index }
}
func bytes(_ value: Int64) -> String { ByteCountFormatter.string(fromByteCount: value, countStyle: .binary) }
func duration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds >= 0 else { return "Unlimited" }
if seconds < 60 { return seconds.formatted(.number.precision(.fractionLength(0...1))) + " sec" }
if seconds < 3600 { return (seconds / 60).formatted(.number.precision(.fractionLength(0...1))) + " min" }
return (seconds / 3600).formatted(.number.precision(.fractionLength(0...1))) + " hr"
}
func eta(_ value: Int64) -> String {
guard value >= 0 && value < 8_640_000 else { return "∞" }
return duration(Double(value))
}
// Explicit API unit metadata: unknown fields retain their raw value.
enum StatFormat {
static let seconds: Set<String> = ["eta", "time_active", "seeding_time", "dl_time", "up_time", "uptime", "average_time_queue", "disk_cache_ttl", "peer_turnover_interval", "request_timeout", "web_ui_session_timeout", "web_ui_ban_duration", "announce_interval", "reannounce", "slow_torrent_inactive_timer", "stop_tracker_timeout"]
static let minutes: Set<String> = ["max_seeding_time", "max_inactive_seeding_time", "rss_refresh_interval", "save_resume_data_interval"]
static let sizes: Set<String> = ["size", "total_size", "total_wasted", "total_uploaded", "total_downloaded", "total_uploaded_session", "total_downloaded_session", "total_buffers_size", "free_space_on_disk", "dl_info_data", "up_info_data", "alltime_dl", "alltime_ul", "piece_size", "total_size", "amount_left", "completed"]
static let speeds: Set<String> = ["dl_info_speed", "up_info_speed", "dl_rate_limit", "up_rate_limit", "dl_limit", "up_limit", "alt_dl_limit", "alt_up_limit", "dlspeed", "upspeed", "dl_speed", "up_speed", "dl_speed_avg", "up_speed_avg"]
static func display(_ value: Any, key: String, preference: Bool = false) -> String {
guard let number = value as? NSNumber else { return String(describing: value) }
if CFGetTypeID(number) == CFBooleanGetTypeID() { return number.boolValue ? "On" : "Off" }
let n = number.doubleValue
if n < 0 { return "\(number) (server default / unlimited)" }
if key == "refresh_interval" { return duration(n / 1000) }
if seconds.contains(key) { return duration(n) }
if minutes.contains(key) { return duration(n * 60) }
if preference && key == "disk_cache" { return bytes(Int64(min(n, Double(Int64.max / 1_048_576))) * 1_048_576) }
if preference && ["slow_torrent_dl_rate_threshold", "slow_torrent_ul_rate_threshold"].contains(key) { return bytes(Int64(min(n, Double(Int64.max / 1024))) * 1024) + "/s" }
if speeds.contains(key) { return n == 0 && key.contains("limit") ? "Unlimited" : bytes(number.int64Value) + "/s" }
if sizes.contains(key) { return bytes(number.int64Value) }
return number.stringValue
}
static func unit(_ key: String) -> String {
if key == "refresh_interval" { return "milliseconds" }
if seconds.contains(key) { return "seconds" }
if minutes.contains(key) { return "minutes" }
if key == "disk_cache" { return "MiB" }
if key.hasPrefix("slow_torrent_") && key.hasSuffix("rate_threshold") { return "KiB/s" }
if speeds.contains(key) { return "bytes/s" }
if sizes.contains(key) { return "bytes" }
return "API units"
}
}
// Private ephemeral cookie jar: sessions never enter shared storage or backups.
final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
let base: URL
let credentials: Credentials
let configuration: URLSessionConfiguration
lazy var session: URLSession = {
let config = configuration
config.timeoutIntervalForRequest = 25
config.httpCookieAcceptPolicy = .always
config.urlCache = nil
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
init(_ credentials: Credentials, configuration: URLSessionConfiguration = .ephemeral) throws {
guard let url = URL(string: credentials.server), url.scheme == "https", url.host != nil, url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { throw RemoteError.message("Enter an HTTPS server URL, optionally with a proxy subpath, without credentials or query parameters.") }
self.base = url; self.credentials = credentials; self.configuration = configuration
}
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
// Never forward a login body, Basic header or session to another origin or HTTP.
guard request.url?.scheme == base.scheme, request.url?.host == base.host, request.url?.port == base.port else { completionHandler(nil); return }
completionHandler(request)
}
func request(_ endpoint: String, _ fields: [String: String]? = nil, multipart: (Data, String)? = nil) async throws -> Data {
try Task.checkCancellation()
var url = base
let parts = endpoint.split(separator: "?", maxSplits: 1).map(String.init)
for component in ("api/v2/" + parts[0]).split(separator: "/") { url.appendPathComponent(String(component)) }
if parts.count == 2 { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!; components.percentEncodedQuery = parts[1]; url = components.url! }
var req = URLRequest(url: url)
let origin = "https://" + (base.host ?? "") + (base.port.map { ":\($0)" } ?? "")
req.setValue(origin, forHTTPHeaderField: "Origin")
req.setValue(base.absoluteString + (base.absoluteString.hasSuffix("/") ? "" : "/"), forHTTPHeaderField: "Referer")
if !credentials.proxyUser.isEmpty {
req.setValue("Basic " + Data("\(credentials.proxyUser):\(credentials.proxyPassword)".utf8).base64EncodedString(), forHTTPHeaderField: "Authorization")
}
if let fields {
req.httpMethod = "POST"
if let (file, filename) = multipart {
let boundary = "Tarnax-" + UUID().uuidString
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
func append(_ s: String) { body.append(Data(s.utf8)) }
for (key, value) in fields { append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n") }
let safe = filename.replacingOccurrences(of: "\"", with: "").replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
append("--\(boundary)\r\nContent-Disposition: form-data; name=\"torrents\"; filename=\"\(safe)\"\r\nContent-Type: application/x-bittorrent\r\n\r\n")
body.append(file); append("\r\n--\(boundary)--\r\n"); req.httpBody = body
} else {
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var c = URLComponents(); c.queryItems = fields.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) }
req.httpBody = c.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B").data(using: .utf8)
}
}
let (data, response) = try await session.data(for: req)
try Task.checkCancellation()
guard let http = response as? HTTPURLResponse else { throw RemoteError.message("Invalid server response.") }
guard (200..<300).contains(http.statusCode) else {
let reason: String
switch http.statusCode {
case 401: reason = "Reverse proxy authentication required. Check the optional proxy credentials."
case 403: reason = "Session expired, access denied, or IP banned. Reconnect and check qBittorrent host/CSRF settings."
case 404: reason = "API endpoint unavailable. Check the server subpath and qBittorrent version."
default: reason = "Server returned HTTP \(http.statusCode)."
}
throw RemoteError.message(reason)
}
return data
}
func get<T: Decodable>(_ endpoint: String, as: T.Type = T.self) async throws -> T { try JSONDecoder().decode(T.self, from: await request(endpoint)) }
func object(_ endpoint: String) async throws -> [String: Any] {
guard let result = try JSONSerialization.jsonObject(with: await request(endpoint)) as? [String: Any] else { throw RemoteError.message("Expected a JSON object. Check your reverse proxy route.") }; return result
}
func login() async throws {
let data = try await request("auth/login", ["username": credentials.username, "password": credentials.password])
guard String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) == "Ok." else { throw RemoteError.message("Login rejected. Check your qBittorrent username and password; a proxy login page may also be intercepting the API.") }
}
func close() { session.invalidateAndCancel() }
}
struct TransferSample: Identifiable, Codable {
var id = UUID()
let date: Date
let download: Int64
let upload: Int64
let segment: Int
}
struct TransferHistory: Codable {
static let retention: TimeInterval = 24 * 60 * 60
static let maximumSamples = 86_401
private(set) var samples: [TransferSample] = []
private var segment = 0
mutating func prune(at now: Date = Date()) {
samples.removeAll { now.timeIntervalSince($0.date) > Self.retention || $0.date.timeIntervalSince(now) > 60 }
segment = samples.last?.segment ?? 0
}
func samples(from start: Date, through end: Date) -> [TransferSample] {
let lower = start.timeIntervalSinceReferenceDate
let upper = end.timeIntervalSinceReferenceDate
// Avoid SDK-new Date >= / <= entry points missing from some iOS betas.
return samples.filter {
let time = $0.date.timeIntervalSinceReferenceDate
return time >= lower && time <= upper
}
}
mutating func record(download: Int64, upload: Int64, at date: Date = Date()) {
if let last = samples.last {
guard date.timeIntervalSinceReferenceDate >= last.date.timeIntervalSinceReferenceDate 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))
while let first = samples.first, date.timeIntervalSince(first.date) > Self.retention { samples.removeFirst() }
if samples.count > Self.maximumSamples { samples.removeFirst(samples.count - Self.maximumSamples) }
}
}
final class CompletionNotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
[.banner, .sound, .list]
}
}
struct HistoryStore {
struct Archive: Codable { var server: String; var user: String; var history: TransferHistory }
var url: URL = URL.applicationSupportDirectory.appendingPathComponent("Tarnax/transfer-history.json")
func load(for credentials: Credentials, at now: Date = Date()) throws -> TransferHistory {
guard FileManager.default.fileExists(atPath: url.path) else { return TransferHistory() }
let archive = try JSONDecoder().decode(Archive.self, from: Data(contentsOf: url))
guard archive.server == credentials.server, archive.user == credentials.username else { return TransferHistory() }
var history = archive.history; history.prune(at: now); return history
}
func save(_ history: TransferHistory, for credentials: Credentials) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try JSONEncoder().encode(Archive(server: credentials.server, user: credentials.username, history: history)).write(to: url, options: .atomic)
}
func clear() throws { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } }
}
@MainActor final class AppModel: ObservableObject {
@Published var credentials = Vault.load()
@Published var connected = false
@Published var busy = false
@Published private(set) var restoringSession = false
private var attemptedAutomaticLogin = false
private var connectionAttempt = UUID()
@Published var torrents: [Torrent] = []
@Published var stats: [String: Any] = [:]
@Published var version = ""
@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
@Published private(set) var unlockStatus: String?
@Published private(set) var unlockNeedsRetry = false
private var automaticUnlockAttempted = false
private var foreground = false
private var lockCycle = 0
private var unlockContext: LAContext?
var authenticate: (LAContext) async throws -> Bool = { context in
try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Tarnax")
}
@Published private(set) var preferences: [String: Any] = [:]
@Published private(set) var preferencesLoading = false
private var preferencesTask: Task<Void, Never>?
private var refreshing = false
var client: Transport?
var historyStore = HistoryStore()
@Published var historyError: String?
@Published var actionFeedback: String?
@Published private(set) var pendingActions: Set<String> = []
private let notificationPresenter = CompletionNotificationPresenter()
@Published var completionNotifications = UserDefaults.standard.bool(forKey: "completionNotifications")
private var observedCompletion: [String: Bool] = [:]
init() {
#if os(iOS)
UNUserNotificationCenter.current().delegate = notificationPresenter
#endif
do { transferHistory = try historyStore.load(for: credentials) }
catch { historyError = "Could not restore transfer history: \(error.localizedDescription)" }
}
func setCompletionNotifications(_ enabled: Bool) async {
#if canImport(UserNotifications)
do {
completionNotifications = enabled ? try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) : false
UserDefaults.standard.set(completionNotifications, forKey: "completionNotifications")
if enabled && !completionNotifications { error = "Notifications are disabled. Allow notifications for Tarnax in iOS Settings." }
} catch { self.error = error.localizedDescription }
#endif
}
private func observeCompletions(_ next: [Torrent]) async {
for torrent in next {
if completionNotifications, observedCompletion[torrent.hash] == false, torrent.complete {
#if canImport(UserNotifications)
let content = UNMutableNotificationContent()
content.title = "Download complete"; content.body = torrent.name; content.sound = .default
do { try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: "complete-" + torrent.hash, content: content, trigger: nil)) }
catch { self.error = "Could not deliver completion notification: \(error.localizedDescription)" }
#endif
}
}
observedCompletion = Dictionary(uniqueKeysWithValues: next.map { ($0.hash, $0.complete) })
}
// Only cancellation signals and work belonging to a retired session are silent.
func shouldReport(_ error: Error, from source: Transport) -> Bool {
guard client === source else { return false }
return !(error is CancellationError) && !(Task.isCancelled && (error as? URLError)?.code == .cancelled)
}
func report(_ error: Error, from source: Transport) {
if shouldReport(error, from: source) { self.error = error.localizedDescription }
}
private func clearPreferences() {
preferencesTask?.cancel(); preferencesTask = nil
preferences = [:]; preferencesLoading = false
}
func loadPreferences() async {
guard let client, connected else { return }
if let task = preferencesTask { await task.value; return }
preferencesLoading = true
// Session-owned work survives SwiftUI cancelling a transient Settings view.
let task = Task { @MainActor in
defer {
if self.client === client {
self.preferencesLoading = false
self.preferencesTask = nil
}
}
do {
let values = try await client.object("app/preferences")
try Task.checkCancellation()
guard self.client === client else { return }
self.preferences = values
} catch { self.report(error, from: client) }
}
preferencesTask = task
await task.value
}
var canRestoreSession: Bool {
!credentials.server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!credentials.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
func restoreSessionIfNeeded() async {
guard !locked, !connected, !busy, !attemptedAutomaticLogin, canRestoreSession else { return }
attemptedAutomaticLogin = true
restoringSession = true
defer { restoringSession = false }
await connect()
}
func connect() async {
guard !busy else { return }
let attempt = UUID(); connectionAttempt = attempt
let connectionCredentials = credentials
busy = true; defer { busy = false }
do {
let next = try Transport(connectionCredentials)
var installed = false
defer { if !installed { next.close() } }
try await next.login()
let v = try await next.request("app/version")
let a = try await next.request("app/webapiVersion")
try Task.checkCancellation()
guard connectionAttempt == attempt else { return }
try Vault.save(connectionCredentials)
clearPreferences()
do { transferHistory = try historyStore.load(for: connectionCredentials) }
catch { historyError = "Could not restore transfer history: \(error.localizedDescription)" }
observedCompletion = [:]
client?.close(); client = next; installed = true
version = String(decoding: v, as: UTF8.self); apiVersion = String(decoding: a, as: UTF8.self)
connected = true; await refresh()
} catch is CancellationError {} catch let e as URLError where e.code == .cancelled {} catch { if connectionAttempt == attempt { self.error = error.localizedDescription } }
}
func refresh() async {
guard let client, connected, !locked, !refreshing else { return }
refreshing = true; defer { refreshing = false }
do {
async let list: [Torrent] = client.get("torrents/info")
async let info = client.object("sync/maindata")
let (nextTorrents, nextInfo) = try await (list, info)
try Task.checkCancellation()
guard self.client === client else { return }
await observeCompletions(nextTorrents)
guard self.client === client else { return }
torrents = nextTorrents
stats = nextInfo["server_state"] as? [String: Any] ?? [:]
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)
}
do { try historyStore.save(transferHistory, for: client.credentials); historyError = nil }
catch { historyError = "Could not save transfer history: \(error.localizedDescription)" }
lastRefresh = refreshedAt
} catch { report(error, from: client) }
}
@discardableResult func perform(_ endpoint: String, _ fields: [String: String]) async -> Bool {
guard let client, connected, !locked else { return false }
let key = fields["hashes"] ?? fields["hash"] ?? endpoint
guard !pendingActions.contains(key) else { return false }
pendingActions.insert(key); actionFeedback = nil
defer { pendingActions.remove(key) }
do {
_ = try await client.request(endpoint, fields)
guard self.client === client else { return false }
actionFeedback = "Action completed."
await refresh(); return true
} catch {
if shouldReport(error, from: client) { actionFeedback = "Action failed: " + error.localizedDescription }
report(error, from: client); return false
}
}
@discardableResult func action(_ action: String, hash: String) async -> Bool {
let modern = version.replacingOccurrences(of: "v", with: "").compare("5.0", options: .numeric) != .orderedAscending
let endpoint = action == "pause" ? (modern ? "stop" : "pause") : action == "resume" ? (modern ? "start" : "resume") : action
return await perform("torrents/" + endpoint, ["hashes": hash])
}
func updateScene(_ phase: ScenePhase) async {
foreground = phase == .active
if phase == .background && biometric {
lockCycle += 1
automaticUnlockAttempted = false
locked = true
unlockNeedsRetry = false
unlockContext?.invalidate()
}
if phase == .active { await unlockAutomaticallyIfNeeded() }
}
private func unlockAutomaticallyIfNeeded() async {
guard foreground, locked, biometric, !authenticating, !automaticUnlockAttempted else { return }
automaticUnlockAttempted = true
await unlock()
}
func unlock() async {
guard locked, !authenticating else { return }
authenticating = true
unlockNeedsRetry = false
unlockStatus = "Authenticate with Face ID or your device passcode…"
let cycle = lockCycle
let context = LAContext()
unlockContext = context
defer {
authenticating = false
unlockContext = nil
// A previous prompt can finish after a new background/foreground cycle.
if cycle != lockCycle && foreground {
Task { await self.unlockAutomaticallyIfNeeded() }
}
}
do {
let success = try await authenticate(context)
guard cycle == lockCycle else { return }
guard success else {
unlockNeedsRetry = true
unlockStatus = "Authentication did not complete. Tap Unlock to try again."
return
}
locked = false
unlockStatus = nil
} catch {
guard cycle == lockCycle else { return }
unlockNeedsRetry = true
let failure = error as NSError
let expected = failure.domain == LAError.errorDomain &&
[LAError.userCancel.rawValue, LAError.appCancel.rawValue, LAError.systemCancel.rawValue].contains(failure.code)
if expected || error is CancellationError {
unlockStatus = "Authentication was cancelled or interrupted. Tap Unlock to try again."
} else {
unlockStatus = error.localizedDescription
self.error = error.localizedDescription
}
}
}
func enableLock(_ enabled: Bool) async {
if enabled {
do {
let context = LAContext()
guard try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Enable app protection") else { return }
biometric = true
} catch { self.error = error.localizedDescription; biometric = false }
} else { biometric = false }
UserDefaults.standard.set(biometric, forKey: "biometricLock")
}
func disconnect(forget: Bool = false) {
attemptedAutomaticLogin = true
connectionAttempt = UUID()
clearPreferences()
transferHistory = TransferHistory()
client?.close(); client = nil; connected = false; torrents = []; stats = [:]
observedCompletion = [:]
if forget {
do { try historyStore.clear() } catch { self.error = "Could not remove transfer history: \(error.localizedDescription)" }
Vault.clear(); credentials = Credentials()
}
}
}