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 = ["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 = ["max_seeding_time", "max_inactive_seeding_time", "rss_refresh_interval", "save_resume_data_interval"] static let sizes: Set = ["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 = ["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(_ 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? private var refreshing = false var client: Transport? var historyStore = HistoryStore() @Published var historyError: String? @Published var actionFeedback: String? @Published private(set) var pendingActions: Set = [] 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() } } }