495 lines
27 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|