commit 21334a1e5e80e905fd16f1fc245c519df0befd53 Author: Mark Gregory Date: Wed Nov 4 14:59:42 2026 +0000 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d4685f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +DerivedData/ +build/ +xcuserdata/ +*.xcuserstate +*.ipa +*.xcarchive diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bbbb90 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Tarnax + +A native SwiftUI iPhone and iPad remote for qBittorrent. Downloads and files stay on your server. The default connection address is `https://torrent.absdev.net`. No credentials are included. + +## Build and run + +1. Unzip this folder and open **Tarnax.xcodeproj** in Xcode. Select the shared **Tarnax** scheme. +2. Requires iOS/iPadOS 17 or later and an Xcode version with the iOS 17+ SDK. The delivered source was compiled using Xcode 27.2 and its iOS device SDK. +3. Under target **Signing & Capabilities**, select your Apple development team and change the bundle identifier if necessary. The default is `net.absdev.Tarnax`. +4. Select an iPhone/iPad simulator or your connected device and Run. A development-signed device build requires an Apple signing identity. The ZIP is source, not an installable IPA. +5. Enter your qBittorrent Web UI username and password in the app. If your HTTPS proxy also uses HTTP Basic authentication, enter its separate credentials in the optional proxy fields. + +No third-party packages, API keys, generated dependencies or backend service are required. Face ID requires a physical compatible device; device passcode authentication is also supported. + +Command-line unsigned device build: + +```sh +xcodebuild -project Tarnax.xcodeproj -scheme Tarnax \ + -configuration Debug -sdk iphoneos -destination 'generic/platform=iOS' \ + -derivedDataPath /tmp/TarnaxBuild CODE_SIGNING_ALLOWED=NO build +``` + +For a simulator, use `-sdk iphonesimulator -destination 'generic/platform=iOS Simulator'` with an installed simulator runtime. + +## Connection and reverse proxy setup + +Enable qBittorrent's Web UI and configure its username/password. Target qBittorrent 4.3+ or 5.x Web API v2. The app detects the application and API versions and uses `stop/start` on 5.x and `pause/resume` on 4.x. Individual APIs and preferences remain subject to your installed version and build configuration. + +Use a valid HTTPS certificate trusted by iOS. Certificate verification and App Transport Security are enabled; the app does not accept arbitrary self-signed certificates. For a private CA, install and trust the CA using your organization's normal device configuration process. + +Route `/api/v2/` to the qBittorrent Web UI. A proxy subpath is supported: for `https://example.net/qbit`, the app requests `/qbit/api/v2/...`. Configure your proxy to strip that prefix if the upstream expects `/api/v2/...`. Avoid redirects on API routes. Login must return qBittorrent's `Ok.` response and an SID cookie rather than an HTML sign-in page. + +The app supplies Origin and Referer for the public URL. Keep qBittorrent CSRF and host validation protections enabled. Configure the public hostname in qBittorrent's allowed domains and preserve the original host/protocol in the reverse proxy as required by your deployment. Pass Set-Cookie and Cookie headers through unchanged, with an externally usable cookie path/domain and Secure cookies for HTTPS. Forward `Host`, `X-Forwarded-Host` and `X-Forwarded-Proto: https` as appropriate for your proxy setup. + +- **401:** Check the separate proxy Basic credentials. Interactive SSO, Cloudflare Access sign-in flows and client certificate authentication are not implemented. +- **403:** A session may have expired, host/CSRF checks may reject the request, or qBittorrent may have banned the IP after failed logins. Use Settings → Disconnect / change server to reconnect; check the server before repeatedly retrying passwords. +- **404:** Check the reverse proxy prefix and API route. An individual operation may be unavailable in that qBittorrent version. +- **TLS errors:** Correct the certificate chain, hostname, or device trust configuration. The app deliberately has no insecure certificate override. + +## Features + +- **Home:** Only `downloading`, `forcedDL`, `metaDL` and `forcedMetaDL`. Seeding, completed, paused/stopped, stalled, queued, checking and error states stay in Library. Live progress, speeds, ETA, transferred size, totals and free disk space. +- **Library:** Full list, name/category/tag search, status filtering and name/progress/speed/size/ETA sorting. Pull to refresh and context-menu pause/resume. +- **Add:** Magnet links or a Files picker for `.torrent` files (up to 25 MB), save path, category, tags and paused state. Files are uploaded as multipart data, not interpreted locally. +- **Manage:** Start/stop, force start/normal queue, recheck, reannounce, queue order, rename, move storage, category/tags, per-torrent speed limits, automatic management, add peers, sequential mode, first/last-piece priority and file rename. Delete confirmation offers remove-only or permanent server-file deletion. +- **Details:** Live overview, every returned generic property, files with skip/normal/high/maximum priorities, peers with their returned information, and tracker status/add/edit/remove. +- **Server:** qBittorrent/API versions, build information, global speeds/totals/limits, connection state, statistics and alternative speed mode. +- **Settings:** Every preference returned by `/app/preferences`, grouped and searchable. Boolean switches, typed numeric validation, secure input for secret keys, text values and structured JSON editing for complex values. Writes send only the edited key to `/app/setPreferences`; confirmation precedes each change and a read-back checks the result. Network/Web UI/path changes can disconnect the app or move data. Settings use qBittorrent API units; consult the reference before changing unfamiliar advanced keys. +- **Security:** Keychain credentials, HTTPS, private ephemeral SID cookie session, optional Face ID/device authentication when returning from background, light/dark appearance and adaptive iPad sidebar navigation. + +RSS-related preferences returned by qBittorrent are editable. Dedicated RSS feed/rule management and server-side search plugin management are not included. Search refers to searching your torrent library. qBittorrent does not expose a separate historical download database; completion timestamps and other available history fields appear in torrent properties. The client is a remote control and does not download torrent contents to the iPhone/iPad. + +## Refresh, errors and privacy + +The library and server summary refresh every 3 seconds while the app is active and unlocked; detail panels refresh every 5 seconds. iOS background execution is not requested, so live updates pause in the background. Long network operations have a 25-second timeout. Errors are visible; manual pull-to-refresh retries reads. Mutations are never automatically retried, avoiding duplicated uploads or destructive operations. Expired sessions require reconnecting. + +Credentials are stored in a device-only Keychain item accessible while unlocked. SID cookies live only in the private session and are discarded on disconnect. Secrets are not placed in UserDefaults or logs. Only the app-lock preference is in UserDefaults. The app rejects HTTP and URLs containing userinfo/query credentials, and rejects redirects to another origin or to HTTP. All server settings, peers and torrent names come from your server; there is no analytics service. + +Use Settings → Forget saved credentials to remove the Keychain item and disconnect. iOS can retain Keychain items across app reinstalls; remove credentials explicitly when retiring a device. App authentication uses Face ID/Touch ID when available and allows the device passcode fallback. It protects the interface, while Keychain data uses the device's existing unlock protection. + +## Project layout + +- `Tarnax/App.swift`: app lifecycle, connection, navigation, library and add form. +- `Tarnax/Core.swift`: models, transport, private session, Keychain and app state. +- `Tarnax/Details.swift`: torrent details and management. +- `Tarnax/Settings.swift`: server statistics and schema-driven preference editing. +- `Tarnax/Info.plist`, `Assets.xcassets`: iPhone/iPad configuration, Face ID purpose and app icon. +- `Tests/ContractTests.swift`, `Tests/run.sh`: executable macOS contract checks using a mocked URL loading protocol; no server or real credentials required. + +Run contract checks on a Mac with Xcode selected: + +```sh +./Tests/run.sh +``` + +The harness tests unsafe URL rejection, proxy subpaths, form encoding (including Unicode and punctuation), CSRF headers, optional Basic auth, GET queries, empty file decoding, login failure, safe HTTP errors, multipart uploads and the exact Home state policy. It compiles the actual transport/model source, not a duplicate implementation. + +See VALIDATION.md for results and manual acceptance checks. No live account login or device UI test was performed during packaging. + +API reference: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-5.0) + +## Tarnax startup and branding + +Tarnax automatically signs in once per launch when a saved server and username are available in Keychain. App protection must be unlocked first. Failed sign-in returns to the connection form; explicit disconnect prevents automatic sign-in again during the same launch. + +The native launch storyboard and connecting view share the Tarnax orbital mark. AppIcon uses an opaque 1024×1024 universal iOS icon; Xcode generates the required device sizes. + +## Build, test and direct-install IPA + +Use `scripts/test.sh`, `scripts/build.sh --release` and `scripts/release.sh`. See [scripts/README.md](scripts/README.md) for signing requirements, export methods, provisioning controls and output locations. Direct-install IPAs are limited to provisioned devices. diff --git a/Tarnax.xcodeproj/project.pbxproj b/Tarnax.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4ed63ac --- /dev/null +++ b/Tarnax.xcodeproj/project.pbxproj @@ -0,0 +1,285 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 000000000000000000000014 /* Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00000000000000000000000A /* Core.swift */; }; + 000000000000000000000015 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00000000000000000000000B /* App.swift */; }; + 000000000000000000000016 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00000000000000000000000C /* Details.swift */; }; + 000000000000000000000017 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00000000000000000000000D /* Settings.swift */; }; + 000000000000000000000033 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000032 /* Assets.xcassets */; }; + 000000000000000000000035 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000034 /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 000000000000000000000006 /* Tarnax.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = Tarnax.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 00000000000000000000000A /* Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Core.swift; sourceTree = ""; }; + 00000000000000000000000B /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + 00000000000000000000000C /* Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Details.swift; sourceTree = ""; }; + 00000000000000000000000D /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; + 000000000000000000000032 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 000000000000000000000034 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 000000000000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 000000000000000000000002 = { + isa = PBXGroup; + children = ( + 000000000000000000000003 /* Tarnax */, + 000000000000000000000004 /* Products */, + ); + sourceTree = ""; + }; + 000000000000000000000003 /* Tarnax */ = { + isa = PBXGroup; + children = ( + 000000000000000000000032 /* Assets.xcassets */, + 000000000000000000000034 /* LaunchScreen.storyboard */, + 00000000000000000000000A /* Core.swift */, + 00000000000000000000000B /* App.swift */, + 00000000000000000000000C /* Details.swift */, + 00000000000000000000000D /* Settings.swift */, + ); + path = Tarnax; + sourceTree = ""; + }; + 000000000000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + 000000000000000000000006 /* Tarnax.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 000000000000000000000005 /* Tarnax */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000000000029 /* Build configuration list for PBXNativeTarget "Tarnax" */; + buildPhases = ( + 000000000000000000000007 /* Sources */, + 000000000000000000000008 /* Frameworks */, + 000000000000000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Tarnax; + productName = Tarnax; + productReference = 000000000000000000000006 /* Tarnax.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 000000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 2720; + }; + buildConfigurationList = 000000000000000000000028 /* Build configuration list for PBXProject "Tarnax" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 000000000000000000000002; + productRefGroup = 000000000000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 000000000000000000000005 /* Tarnax */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 000000000000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 000000000000000000000033 /* Assets.xcassets in Resources */, + 000000000000000000000035 /* LaunchScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 000000000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 000000000000000000000014 /* Core.swift in Sources */, + 000000000000000000000015 /* App.swift in Sources */, + 000000000000000000000016 /* Details.swift in Sources */, + 000000000000000000000017 /* Settings.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 00000000000000000000002A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 00000000000000000000002B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 00000000000000000000002C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Tarnax/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Tranax "; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.Tarnax; + PRODUCT_NAME = Tarnax; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 00000000000000000000002D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Tarnax/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Tranax "; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.Tarnax; + PRODUCT_NAME = Tarnax; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 000000000000000000000028 /* Build configuration list for PBXProject "Tarnax" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00000000000000000000002A /* Debug */, + 00000000000000000000002B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 000000000000000000000029 /* Build configuration list for PBXNativeTarget "Tarnax" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 00000000000000000000002C /* Debug */, + 00000000000000000000002D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 000000000000000000000001 /* Project object */; +} diff --git a/Tarnax.xcodeproj/xcshareddata/xcschemes/Tarnax.xcscheme b/Tarnax.xcodeproj/xcshareddata/xcschemes/Tarnax.xcscheme new file mode 100644 index 0000000..ce49e41 --- /dev/null +++ b/Tarnax.xcodeproj/xcshareddata/xcschemes/Tarnax.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tarnax/App.swift b/Tarnax/App.swift new file mode 100644 index 0000000..8e1c0e2 --- /dev/null +++ b/Tarnax/App.swift @@ -0,0 +1,271 @@ +import SwiftUI +import UniformTypeIdentifiers + +@main struct TarnaxApp: App { + @StateObject private var model = AppModel() + @Environment(\.scenePhase) private var phase + var body: some Scene { + WindowGroup { + RootView().environmentObject(model).tint(.indigo) + .onChange(of: phase) { _, value in + if value == .background && model.biometric { model.locked = true } + } + } + } +} +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 { + ContentUnavailableView { Label("Tarnax is locked", systemImage: "lock.shield") } actions: { + Button("Unlock") { Task { await model.unlock() } }.buttonStyle(.borderedProminent) + } + } 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(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() } + } + } + .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 body: some View { + ZStack { + Color(uiColor: .systemGroupedBackground).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(.primary) + Text("Your downloads. Your server.").font(.subheadline).foregroundStyle(.secondary) + } + ProgressView("Connecting securely…").font(.body) + .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 { + let torrent: Torrent + @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.progress >= 1 ? "checkmark.circle.fill" : "arrow.down.circle.fill").foregroundStyle(torrent.progress >= 1 ? .green : .indigo).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) + } + ProgressView(value: min(max(torrent.progress, 0), 1)).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" + @State private var sort = "Name" + 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.progress >= 1 + 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 "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) + 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) } } + } + Section(home ? "Actively downloading · \(filtered.count)" : "Torrents · \(filtered.count)") { + ForEach(filtered) { torrent in + NavigationLink(value: torrent) { TorrentRow(torrent: torrent) } + .contextMenu { + Button("Resume", systemImage: "play") { Task { await model.action("resume", hash: torrent.hash) } } + Button("Pause", systemImage: "pause") { Task { await model.action("pause", hash: 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 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(["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()) } + } +} +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 } + } +} diff --git a/Tarnax/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tarnax/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b6ce25e --- /dev/null +++ b/Tarnax/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "TarnaxIcon.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Tarnax/Assets.xcassets/AppIcon.appiconset/TarnaxIcon.png b/Tarnax/Assets.xcassets/AppIcon.appiconset/TarnaxIcon.png new file mode 100644 index 0000000..9c6fd63 Binary files /dev/null and b/Tarnax/Assets.xcassets/AppIcon.appiconset/TarnaxIcon.png differ diff --git a/Tarnax/Assets.xcassets/Contents.json b/Tarnax/Assets.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/Tarnax/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Tarnax/Assets.xcassets/TarnaxBrand.imageset/Contents.json b/Tarnax/Assets.xcassets/TarnaxBrand.imageset/Contents.json new file mode 100644 index 0000000..e6f5d2b --- /dev/null +++ b/Tarnax/Assets.xcassets/TarnaxBrand.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "TarnaxBrand.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Tarnax/Assets.xcassets/TarnaxBrand.imageset/TarnaxBrand.png b/Tarnax/Assets.xcassets/TarnaxBrand.imageset/TarnaxBrand.png new file mode 100644 index 0000000..9c6fd63 Binary files /dev/null and b/Tarnax/Assets.xcassets/TarnaxBrand.imageset/TarnaxBrand.png differ diff --git a/Tarnax/Core.swift b/Tarnax/Core.swift new file mode 100644 index 0000000..d49fccd --- /dev/null +++ b/Tarnax/Core.swift @@ -0,0 +1,264 @@ +import Foundation +import SwiftUI +import Security +import LocalAuthentication + +struct Credentials: Codable { + var server = "https://torrent.absdev.net" + 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 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 eta(_ value: Int64) -> String { + guard value >= 0 && value < 8_640_000 else { return "∞" } + return value < 3600 ? "\(value / 60)m" : "\(value / 3600)h \((value % 3600) / 60)m" +} + +// 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() } +} + +@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 var locked = UserDefaults.standard.bool(forKey: "biometricLock") + @Published var biometric = UserDefaults.standard.bool(forKey: "biometricLock") + @Published private(set) var preferences: [String: Any] = [:] + @Published private(set) var preferencesLoading = false + private var preferencesTask: Task? + private var refreshing = false + var client: Transport? + + // 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) && (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() + 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 } + torrents = nextTorrents + stats = nextInfo["server_state"] as? [String: Any] ?? [:] + lastRefresh = Date() + } catch { report(error, from: client) } + } + func perform(_ endpoint: String, _ fields: [String: String]) async { + guard let client else { return } + do { _ = try await client.request(endpoint, fields); await refresh() } catch { report(error, from: client) } + } + func action(_ action: String, hash: String) async { + 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 + await perform("torrents/" + endpoint, ["hashes": hash]) + } + func unlock() async { + do { + let context = LAContext() + guard try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Tarnax") else { return } + locked = false + } catch { 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() + client?.close(); client = nil; connected = false; torrents = []; stats = [:] + if forget { Vault.clear(); credentials = Credentials() } + } +} diff --git a/Tarnax/Details.swift b/Tarnax/Details.swift new file mode 100644 index 0000000..d24ab2e --- /dev/null +++ b/Tarnax/Details.swift @@ -0,0 +1,182 @@ +import SwiftUI + +struct DetailView: View { + @EnvironmentObject var model: AppModel + let torrent: Torrent + @Environment(\.dismiss) var dismiss + @State private var files: [TorrentFile] = [] + @State private var properties: [String: Any] = [:] + @State private var trackers: [[String: Any]] = [] + @State private var peers: [String: Any] = [:] + @State private var tab = "Overview" + @State private var deleting = false + @State private var editor: EditAction? + @State private var value = "" + @State private var secondValue = "" + @State private var localError: String? + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + var live: Torrent { model.torrents.first { $0.hash == torrent.hash } ?? torrent } + var body: some View { + List { + Section { TorrentRow(torrent: live) } + Section { + (dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading)) : AnyLayout(HStackLayout())) { + Button("Resume", systemImage: "play.fill") { Task { await model.action("resume", hash: torrent.hash) } }.frame(minHeight: 44) + if !dynamicTypeSize.isAccessibilitySize { Spacer() } + Button("Pause", systemImage: "pause.fill") { Task { await model.action("pause", hash: torrent.hash) } }.frame(minHeight: 44) + if !dynamicTypeSize.isAccessibilitySize { Spacer() } + Menu("More", systemImage: "ellipsis.circle") { controls }.frame(minWidth: 44, minHeight: 44) + }.buttonStyle(.bordered).controlSize(.regular) + } + Picker("Details", selection: $tab) { ForEach(["Overview", "Files", "Peers", "Trackers"], id: \.self) { Text($0) } }.pickerStyle(.menu) + if let localError { Section { Text(localError).foregroundStyle(.red); Button("Retry") { Task { await load() } } } } + switch tab { + case "Files": fileSection + case "Peers": peerSection + case "Trackers": trackerSection + default: + Section("Transfer") { + LabeledContent("Share ratio", value: live.ratio.formatted(.number.precision(.fractionLength(2)))) + LabeledContent("Category", value: live.category.isEmpty ? "None" : live.category) + LabeledContent("Tags", value: live.tags.isEmpty ? "None" : live.tags) + KeyValues(values: properties) + } + } + }.navigationTitle(live.name).navigationBarTitleDisplayMode(.inline) + .refreshable { await load() } + .task { while !Task.isCancelled { await load(); do { try await Task.sleep(for: .seconds(5)) } catch { break } } } + .confirmationDialog("Delete this torrent?", isPresented: $deleting, titleVisibility: .visible) { + Button("Remove torrent only", role: .destructive) { Task { await remove(false) } } + Button("Delete torrent and server files", role: .destructive) { Task { await remove(true) } } + } message: { Text("Deleting server files cannot be undone.") } + .sheet(item: $editor) { action in + NavigationStack { Form { + TextField(action.field, text: $value, axis: .vertical).textInputAutocapitalization(.never).autocorrectionDisabled() + if action.second != nil { TextField(action.second!, text: $secondValue).textInputAutocapitalization(.never).autocorrectionDisabled() } + if let help = action.help { Text(help).font(.caption).foregroundStyle(.secondary) } + Button("Apply") { Task { + var fields = [action.field: value] + fields[action.singleHash ? "hash" : "hashes"] = torrent.hash + if let second = action.second { fields[second] = secondValue } + await model.perform("torrents/" + action.endpoint, fields) + if model.error == nil { editor = nil; await load() } + } } + }.navigationTitle(action.title).toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { editor = nil } } } } + } + } + var controls: some View { + Group { + Button("Force start") { Task { await model.perform("torrents/setForceStart", ["hashes": torrent.hash, "value": "true"]) } } + Button("Return to normal queue") { Task { await model.perform("torrents/setForceStart", ["hashes": torrent.hash, "value": "false"]) } } + Button("Recheck") { Task { await model.action("recheck", hash: torrent.hash) } } + Button("Reannounce") { Task { await model.action("reannounce", hash: torrent.hash) } } + Menu("Queue priority") { + ForEach(["topPrio", "increasePrio", "decreasePrio", "bottomPrio"], id: \.self) { endpoint in Button(["topPrio":"Move to top", "increasePrio":"Move up", "decreasePrio":"Move down", "bottomPrio":"Move to bottom"][endpoint]!) { Task { await model.action(endpoint, hash: torrent.hash) } } } + } + Menu("Edit") { ForEach(EditAction.management) { action in Button(action.title) { value = ""; secondValue = ""; editor = action } } } + Button("Toggle sequential download") { Task { await model.action("toggleSequentialDownload", hash: torrent.hash) } } + Button("Toggle first / last piece priority") { Task { await model.action("toggleFirstLastPiecePrio", hash: torrent.hash) } } + Button("Delete…", role: .destructive) { deleting = true } + } + } + var fileSection: some View { + Section("Files · choose priority") { + ForEach(files) { file in + VStack(alignment: .leading, spacing: 6) { + Text(file.name).font(.subheadline) + ProgressView(value: file.progress) + HStack { + Text(bytes(file.size)).font(.caption).foregroundStyle(.secondary) + Spacer() + Menu(priorityName(file.priority)) { + ForEach([0, 1, 6, 7], id: \.self) { priority in + Button(priorityName(priority)) { Task { + await model.perform("torrents/filePrio", ["hash": torrent.hash, "id": String(file.index), "priority": String(priority)]); await load() + } } + } + }.font(.caption) + } + }.padding(.vertical, 4) + } + } + } + var peerSection: some View { + Section("Connected peers · \(peers.count)") { + ForEach(peers.keys.sorted(), id: \.self) { key in + if let peer = peers[key] as? [String: Any] { + DisclosureGroup(key) { KeyValues(values: peer) } + } + } + if peers.isEmpty { Text("No peers connected").foregroundStyle(.secondary) } + } + } + var trackerSection: some View { + Section("Trackers") { + ForEach(Array(trackers.enumerated()), id: \.offset) { _, tracker in + DisclosureGroup(tracker["url"] as? String ?? "Tracker") { + KeyValues(values: tracker) + if let url = tracker["url"] as? String, url.contains("://") { + Button("Edit tracker") { value = url; secondValue = ""; editor = EditAction(title: "Edit tracker", endpoint: "editTracker", field: "origUrl", second: "newUrl", singleHash: true) } + Button("Remove tracker", role: .destructive) { Task { await model.perform("torrents/removeTrackers", ["hash": torrent.hash, "urls": url]); await load() } } + } + } + } + Button("Add trackers") { value = ""; editor = EditAction(title: "Add trackers", endpoint: "addTrackers", field: "urls", singleHash: true, help: "One tracker URL per line.") } + } + } + func priorityName(_ value: Int) -> String { [0:"Skip", 1:"Normal", 6:"High", 7:"Maximum"][value] ?? "\(value)" } + func load() async { + guard let client = model.client else { return } + do { + properties = try await client.object("torrents/properties?hash=" + torrent.hash) + files = try await client.get("torrents/files?hash=" + torrent.hash) + trackers = (try JSONSerialization.jsonObject(with: await client.request("torrents/trackers?hash=" + torrent.hash))) as? [[String: Any]] ?? [] + peers = try await client.object("sync/torrentPeers?hash=" + torrent.hash)["peers"] as? [String: Any] ?? [:] + localError = nil + } catch { if model.shouldReport(error, from: client) { localError = error.localizedDescription } } + } + func remove(_ files: Bool) async { + await model.perform("torrents/delete", ["hashes": torrent.hash, "deleteFiles": files ? "true" : "false"]) + if model.error == nil { dismiss() } + } +} +struct EditAction: Identifiable { + var title: String; var endpoint: String; var field: String + var second: String? = nil; var singleHash = false; var help: String? = nil + var id: String { endpoint } + static let management = [ + EditAction(title: "Rename", endpoint: "rename", field: "name", singleHash: true), + EditAction(title: "Move location", endpoint: "setLocation", field: "location", help: "Absolute directory on the server. This moves server files."), + EditAction(title: "Set category", endpoint: "setCategory", field: "category"), + EditAction(title: "Add tags", endpoint: "addTags", field: "tags", help: "Comma-separated tags."), + EditAction(title: "Remove tags", endpoint: "removeTags", field: "tags"), + EditAction(title: "Download limit", endpoint: "setDownloadLimit", field: "limit", help: "Bytes per second. 0 means unlimited."), + EditAction(title: "Upload limit", endpoint: "setUploadLimit", field: "limit", help: "Bytes per second. 0 means unlimited."), + EditAction(title: "Automatic management", endpoint: "setAutoManagement", field: "enable", help: "Enter true or false."), + EditAction(title: "Add peers", endpoint: "addPeers", field: "peers", help: "IP:port entries separated by |."), + EditAction(title: "Rename file", endpoint: "renameFile", field: "oldPath", second: "newPath", singleHash: true) + ] +} +struct KeyValues: View { + let values: [String: Any] + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + var body: some View { + ForEach(values.keys.sorted(), id: \.self) { key in + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 6) { + Text(key.replacingOccurrences(of: "_", with: " ").capitalized) + Text(display(values[key]!)).foregroundStyle(.secondary).textSelection(.enabled) + }.accessibilityElement(children: .combine) + } else { + LabeledContent(key.replacingOccurrences(of: "_", with: " ").capitalized) { + Text(display(values[key]!)).textSelection(.enabled).multilineTextAlignment(.trailing).font(.body.monospacedDigit()) + } + } + } + } + func display(_ value: Any) -> String { + if let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { return number.boolValue ? "On" : "Off" } + return String(describing: value) + } +} +import CoreFoundation diff --git a/Tarnax/Info.plist b/Tarnax/Info.plist new file mode 100644 index 0000000..474b529 --- /dev/null +++ b/Tarnax/Info.plist @@ -0,0 +1,44 @@ + + + + + CFBundleDisplayName + Tarnax + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + Tarnax + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSFaceIDUsageDescription + Unlock your torrent server connection. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UILaunchStoryboardName + LaunchScreen + + diff --git a/Tarnax/LaunchScreen.storyboard b/Tarnax/LaunchScreen.storyboard new file mode 100644 index 0000000..d6641f1 --- /dev/null +++ b/Tarnax/LaunchScreen.storyboard @@ -0,0 +1,6 @@ + + + + + + diff --git a/Tarnax/Settings.swift b/Tarnax/Settings.swift new file mode 100644 index 0000000..a378baf --- /dev/null +++ b/Tarnax/Settings.swift @@ -0,0 +1,134 @@ +import SwiftUI +import CoreFoundation + +struct ServerView: View { + @EnvironmentObject var model: AppModel + @State private var transfer: [String: Any] = [:] + @State private var build: [String: Any] = [:] + var body: some View { + List { + Section("Connection") { + LabeledContent("Server", value: model.credentials.server) + LabeledContent("qBittorrent", value: model.version) + LabeledContent("Web API", value: model.apiVersion) + LabeledContent("Session", value: "Authenticated · HTTPS") + } + Section("Bandwidth") { + Button("Toggle alternative speed limits") { Task { await model.perform("transfer/toggleSpeedLimitsMode", [:]); await load() } } + KeyValues(values: transfer) + } + Section("Server statistics") { KeyValues(values: model.stats) } + Section("Build information") { KeyValues(values: build) } + }.navigationTitle("Server").task { await load() }.refreshable { await model.refresh(); await load() } + } + func load() async { + guard let client = model.client else { return } + do { transfer = try await client.object("transfer/info"); build = try await client.object("app/buildInfo") } catch { model.report(error, from: client) } + } +} +struct SettingsView: View { + @EnvironmentObject var model: AppModel + private var preferences: [String: Any] { model.preferences } + @State private var search = "" + private var loading: Bool { model.preferencesLoading } + @State private var disconnect = false + let groups = ["Downloads", "Connection & proxy", "Bandwidth", "BitTorrent", "Queueing & seeding", "RSS", "Web UI", "Advanced"] + var body: some View { + List { + Section("On this device") { + Toggle("Face ID / device authentication", isOn: Binding(get: { model.biometric }, set: { enabled in Task { await model.enableLock(enabled) } })) + Text("Requires authentication when returning after the app enters the background. Device passcode is available as a fallback.").font(.caption).foregroundStyle(.secondary) + } + Section { + ForEach(groups, id: \.self) { group in + let keys = preferences.keys.filter { category($0) == group && (search.isEmpty || $0.localizedCaseInsensitiveContains(search)) }.sorted() + if !keys.isEmpty { + NavigationLink { + List(keys, id: \.self) { key in NavigationLink { PreferenceEditor(key: key, original: preferences[key]!) { await load() } } label: { + VStack(alignment: .leading) { Text(key.replacingOccurrences(of: "_", with: " ").capitalized); Text(key).font(.caption2).foregroundStyle(.secondary) } + } }.navigationTitle(group) + } label: { LabeledContent(group, value: "\(keys.count)") } + } + } + if loading { ProgressView("Loading server preferences…") } + } header: { Text("Server preferences") } footer: { Text("All preferences returned by this server are available. Values use the API’s native units (typically bytes/s, seconds or minutes). Unsupported or read-only changes are reported by the server or verified by re-reading. Password values may be masked by qBittorrent.") } + Section("Connection") { + Button("Reload preferences") { Task { await load() } } + Button("Disconnect / change server") { model.disconnect() } + Button("Forget saved credentials", role: .destructive) { disconnect = true } + } + Section("API reference") { + Link("qBittorrent Web API documentation", destination: URL(string: "https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-5.0)")!) + } + }.navigationTitle("Settings").searchable(text: $search, prompt: "Find a preference key") + .task { await load() }.refreshable { await load() } + .confirmationDialog("Forget credentials on this device?", isPresented: $disconnect, titleVisibility: .visible) { Button("Forget and disconnect", role: .destructive) { model.disconnect(forget: true) } } + } + func load() async { + await model.loadPreferences() + } + func category(_ key: String) -> String { + if key.contains("web_ui") || key.contains("auth") || key.contains("csrf") { return "Web UI" } + if key.contains("rss") { return "RSS" } + if key.contains("queue") || key.contains("ratio") || key.contains("seeding") || key.contains("max_active") || key.contains("slow_torrent") { return "Queueing & seeding" } + if key.contains("proxy") || key.contains("port") || key.contains("connection") || key.contains("upnp") || key.contains("interface") { return "Connection & proxy" } + if key.contains("limit") || key.contains("scheduler") || key.contains("schedule") { return "Bandwidth" } + if key.contains("dht") || key.contains("pex") || key.contains("lsd") || key.contains("encryption") || key.contains("bittorrent") { return "BitTorrent" } + if key.contains("path") || key.contains("save") || key.contains("download") || key.contains("tmm") || key.contains("prealloc") || key.contains("incomplete") || key.contains("torrent_added") { return "Downloads" } + return "Advanced" + } +} +struct PreferenceEditor: View { + @EnvironmentObject var model: AppModel + @Environment(\.dismiss) var dismiss + let key: String + let original: Any + let reload: () async -> Void + @State private var text = "" + @State private var boolean = false + @State private var confirm = false + @State private var saving = false + @State private var feedback: String? + var isBool: Bool { if let n = original as? NSNumber { return CFGetTypeID(n) == CFBooleanGetTypeID() }; return false } + var isNumber: Bool { original is NSNumber && !isBool } + var isComplex: Bool { original is [Any] || original is [String: Any] || original is NSNull } + var sensitive: Bool { key.contains("password") || key.contains("secret") || key.contains("token") } + var body: some View { + Form { + Section(key) { + if isBool { Toggle("Enabled", isOn: $boolean) } + else if sensitive { SecureField("New value", text: $text) } + else { TextField(isComplex ? "JSON value" : "Value", text: $text, axis: .vertical).textInputAutocapitalization(.never).autocorrectionDisabled() } + Text(isBool ? "Boolean" : isNumber ? "Number · API units" : isComplex ? "JSON · preserve the returned structure" : "Text").font(.caption).foregroundStyle(.secondary) + } + Section { + Button(saving ? "Saving…" : "Save to server") { confirm = true }.disabled(saving) + } footer: { Text("This edits qBittorrent on the server immediately. Web UI, authentication, network and path changes can interrupt access or move data. Review the exact API key and value before applying.") } + if let feedback { Text(feedback).foregroundStyle(.secondary) } + }.navigationTitle("Edit preference").navigationBarTitleDisplayMode(.inline) + .onAppear { + boolean = (original as? NSNumber)?.boolValue ?? false + if isComplex, let data = try? JSONSerialization.data(withJSONObject: original, options: [.prettyPrinted, .sortedKeys, .fragmentsAllowed]) { text = String(decoding: data, as: UTF8.self) } + else { text = sensitive ? "" : String(describing: original) } + } + .confirmationDialog("Apply \(key) to the server?", isPresented: $confirm, titleVisibility: .visible) { Button("Apply change", role: .destructive) { Task { await save() } } } + } + func save() async { + guard let client = model.client else { return }; saving = true; defer { saving = false } + do { + let value: Any + if isBool { value = boolean } + else if isNumber { + guard let data = text.data(using: .utf8), let n = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID() else { throw RemoteError.message("Enter a valid JSON number.") }; value = n + } else if isComplex { value = try JSONSerialization.jsonObject(with: Data(text.utf8), options: [.fragmentsAllowed]) } + else { value = text } + let payload = try JSONSerialization.data(withJSONObject: [key: value], options: [.sortedKeys]) + _ = try await client.request("app/setPreferences", ["json": String(decoding: payload, as: UTF8.self)]) + let actual = try await client.object("app/preferences") + if sensitive { feedback = "Change submitted. Secret values may be masked; verify by reconnecting if needed." } + else if let result = actual[key], String(describing: result) == String(describing: value) { feedback = "Saved and verified." } + else { feedback = "The server returned a different value. It may have normalized, rejected, or not support this change. Reload preferences to inspect it." } + await reload() + } catch { model.report(error, from: client) } + } +} diff --git a/Tests/ContractTests.swift b/Tests/ContractTests.swift new file mode 100644 index 0000000..f58be73 --- /dev/null +++ b/Tests/ContractTests.swift @@ -0,0 +1,157 @@ +import Foundation + +final class MockProtocol: URLProtocol, @unchecked Sendable { + static var handler: ((URLRequest) throws -> (Int, String))! + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + do { + let (status, body) = try Self.handler(request) + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)); client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + override func stopLoading() {} +} +@main struct ContractTests { + static var checks = 0 + static func check(_ condition: Bool, _ name: String) { + precondition(condition, name); checks += 1; print("PASS: \(name)") + } + static func body(_ request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open(); defer { stream.close() } + var data = Data(); var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; data.append(buffer, count: count) } + return data + } + @MainActor static func main() async throws { + for server in ["http://example.test", "https://u:p@example.test", "https://example.test/?password=x"] { + do { _ = try Transport(Credentials(server: server)); fatalError("Insecure URL accepted") } catch { check(true, "Reject unsafe URL: \(server)") } + } + let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [MockProtocol.self] + let transport = try Transport(Credentials(server: "https://example.test/qbit", username: "user+name", password: "p&= +雪", proxyUser: "proxy", proxyPassword: "test-only"), configuration: config) + MockProtocol.handler = { request in + check(request.url!.path == "/qbit/api/v2/auth/login", "Preserve reverse proxy subpath") + check(request.httpMethod == "POST", "Login uses POST") + check(request.value(forHTTPHeaderField: "Origin") == "https://example.test", "Same origin CSRF header") + check(request.value(forHTTPHeaderField: "Referer") == "https://example.test/qbit/", "Referer includes base path") + check(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Basic ") == true, "Optional proxy authentication") + let encoded = String(decoding: body(request), as: UTF8.self) + var c = URLComponents(); c.percentEncodedQuery = encoded + check(c.queryItems?.first(where: { $0.name == "password" })?.value == "p&= +雪", "Password form encoding round trip") + return (200, "Ok.") + } + try await transport.login() + MockProtocol.handler = { request in + check(request.url!.path == "/qbit/api/v2/torrents/files", "Query stays out of URL path") + check(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value == "abcdef", "Torrent hash query preserved") + return (200, "[]") + } + let files: [TorrentFile] = try await transport.get("torrents/files?hash=abcdef") + check(files.isEmpty, "Decode empty file list") + MockProtocol.handler = { _ in (200, "Fails.") } + do { try await transport.login(); fatalError("Rejected login accepted") } catch { check(true, "Reject HTTP 200 login failure") } + for status in [401, 403, 404, 500] { + MockProtocol.handler = { _ in (status, "private response must not appear") } + do { _ = try await transport.request("app/version"); fatalError("HTTP error accepted") } + catch { check(!error.localizedDescription.contains("private response"), "HTTP \(status) produces safe actionable error") } + } + MockProtocol.handler = { request in + let payload = String(decoding: body(request), as: UTF8.self) + check(request.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("multipart/form-data; boundary=") == true, "Multipart boundary") + check(payload.contains("name=\"torrents\"; filename=\"sample.torrent\""), "Correct torrent upload field") + check(payload.contains("torrent-content"), "Torrent bytes included") + check(payload.contains("name=\"savepath\""), "Upload options included") + return (200, "Ok.") + } + _ = try await transport.request("torrents/add", ["savepath":"/downloads"], multipart: (Data("torrent-content".utf8), "sample.torrent")) + let sample = #"{"hash":"abc","name":"Example","state":"downloading","progress":0.5,"size":1000,"dlspeed":100,"upspeed":10,"eta":5,"ratio":0.1,"category":"","tags":""}"# + var t = try JSONDecoder().decode(Torrent.self, from: Data(sample.utf8)) + for state in ["downloading", "forcedDL", "metaDL", "forcedMetaDL"] { t.state = state; check(t.active, "Home includes \(state)") } + for state in ["uploading", "stalledDL", "queuedDL", "pausedDL", "stoppedDL", "error", "checkingDL"] { t.state = state; check(!t.active, "Home excludes \(state)") } + let originalURL = URL(string: "https://example.test/qbit/api/v2/auth/login")! + let redirect = HTTPURLResponse(url: originalURL, statusCode: 307, httpVersion: nil, headerFields: nil)! + let task = transport.session.dataTask(with: originalURL) + for target in ["http://example.test/api/v2/auth/login", "https://attacker.test/login", "https://example.test:8443/login"] { + var accepted = true + transport.urlSession(transport.session, task: task, willPerformHTTPRedirection: redirect, newRequest: URLRequest(url: URL(string: target)!)) { accepted = $0 != nil } + check(!accepted, "Reject unsafe redirect: \(target)") + } + var accepted = false + transport.urlSession(transport.session, task: task, willPerformHTTPRedirection: redirect, newRequest: URLRequest(url: originalURL)) { accepted = $0 != nil } + check(accepted, "Allow same-origin HTTPS redirect") + let cookie = HTTPCookie(properties: [.domain: "example.test", .path: "/", .name: "SID", .value: "mock-session-only", .secure: "TRUE"])! + transport.session.configuration.httpCookieStorage?.setCookie(cookie) + let another = try Transport(Credentials(server: "https://example.test")) + check(another.session.configuration.httpCookieStorage?.cookies?.contains(where: { $0.name == "SID" }) != true, "Cookie jar isolated from other connections") + let model = AppModel() + model.client = transport; model.connected = true; model.locked = false + let gate = DispatchSemaphore(value: 0) + var preferenceRequests = 0 + MockProtocol.handler = { _ in + preferenceRequests += 1 + gate.wait() + return (200, "{\"save_path\":\"/downloads\"}") + } + let first = Task { await model.loadPreferences() } + while !model.preferencesLoading { await Task.yield() } + let second = Task { await model.loadPreferences() } + await Task.yield() + first.cancel() // Simulate SwiftUI removing the original Settings view. + gate.signal() + await first.value; await second.value + check(preferenceRequests == 1, "Concurrent Settings loads share one request") + check(model.preferences["save_path"] as? String == "/downloads", "View cancellation preserves session-owned preference load") + check(!model.preferencesLoading && model.error == nil, "Cancelled view leaves no spinner or alert") + for failure in [URLError(.cancelled), URLError(.timedOut)] { + MockProtocol.handler = { _ in throw failure } + await model.loadPreferences() + check((model.error != nil) == (failure.code == .timedOut), "Only expected cancellation is silent: \(failure.code)") + check(model.preferences["save_path"] as? String == "/downloads", "Failed reload retains preferences") + } + model.error = nil + check(!model.shouldReport(CancellationError(), from: transport), "Swift task cancellation is silent") + let disconnectGate = DispatchSemaphore(value: 0) + MockProtocol.handler = { _ in + disconnectGate.wait() + return (200, "{\"save_path\":\"/stale\"}") + } + let pending = Task { await model.loadPreferences() } + while !model.preferencesLoading { await Task.yield() } + model.disconnect() + disconnectGate.signal() + await pending.value + check(model.preferences.isEmpty && model.error == nil, "In-flight disconnect cannot restore stale preferences or alert") + model.client = another + model.report(URLError(.networkConnectionLost), from: transport) + check(model.error == nil, "Retired session cannot raise a global alert") + model.disconnect() + check(model.preferences.isEmpty && !model.preferencesLoading, "Disconnect clears session preferences") + let startup = AppModel() + startup.credentials = Credentials(server: "https://example.test") + startup.locked = false + check(!startup.canRestoreSession, "Default server without a saved user does not auto-login") + await startup.restoreSessionIfNeeded() + check(!startup.busy && startup.error == nil, "Fresh installation stays on connection form") + startup.credentials = Credentials(server: "http://invalid.test", username: "test-user") + startup.locked = true + await startup.restoreSessionIfNeeded() + check(startup.error == nil && !startup.restoringSession, "App lock prevents automatic network connection") + startup.locked = false + await startup.restoreSessionIfNeeded() + check(startup.error != nil && !startup.connected && !startup.restoringSession, "Failed automatic login returns to connection form") + startup.error = nil + await startup.restoreSessionIfNeeded() + check(startup.error == nil, "Failed automatic login is not retried on view updates") + let disconnected = AppModel() + disconnected.locked = false + disconnected.credentials = Credentials(server: "http://invalid.test", username: "test-user") + disconnected.disconnect() + await disconnected.restoreSessionIfNeeded() + check(disconnected.error == nil && !disconnected.connected, "Explicit disconnect suppresses automatic login for this launch") + another.close(); transport.close(); print("\(checks) contract checks passed.") + } +} diff --git a/Tests/run.sh b/Tests/run.sh new file mode 100755 index 0000000..e48b74a --- /dev/null +++ b/Tests/run.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +BUILD_DIR="$(mktemp -d)" +trap 'rm -rf "$BUILD_DIR"' EXIT +xcrun swiftc -parse-as-library -module-cache-path "$BUILD_DIR/cache" Tarnax/Core.swift Tests/ContractTests.swift -o "$BUILD_DIR/ContractTests" +"$BUILD_DIR/ContractTests" diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..62054ca --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,72 @@ +# Validation — 4 November 2026 + +## Passed + +- Xcode 27.2 (27B52), unsigned generic iOS device Debug build: **BUILD SUCCEEDED**. +- Final unsigned generic iOS device Release build, including app icon: **BUILD SUCCEEDED**. +- 37 executable transport/model contract checks: **passed**. Includes unsafe URL and redirect rejection, same-origin redirect acceptance, cookie jar isolation, proxy subpaths, Origin/Referer, Basic proxy auth, form encoding, HTTP/login failures, multipart file uploads, query construction, decoding and Home state filtering. +- Info.plist and project.pbxproj validated with plutil. +- Shared scheme, source references, asset catalog, icon and README present; ZIP integrity and required file checks passed. + +Xcode's only build warning was “Metadata extraction skipped, no AppIntents.framework dependency found.” This app does not define App Intents. + +## Not verified here + +- Live authentication or operations against torrent.absdev.net: no production credentials supplied or embedded. +- Runtime UI on iPhone/iPad, accessibility layout, Face ID, passcode fallback or a physical device. +- Signed installation, App Store validation or deployment. Select your development team and provisioning in Xcode. +- Every preference on every qBittorrent version. Preferences are discovered at runtime; unsupported/read-only or normalized values are reported using server response and read-back. + +Simulator services were unavailable in the initial environment. Compilation used the real iOS device SDK; the executable contract harness ran on macOS with mocked network responses, not a simulator or live server. + +## Suggested device acceptance checks + +1. Connect with the Web UI credentials, and separate proxy Basic credentials if needed. Confirm the server/API version. +2. Compare the Library with qBittorrent Web UI. Verify Home excludes completed, paused, queued, checking and stalled entries. +3. Add a lawful test magnet and a small .torrent file. Verify progress, speed, ETA and server save path. +4. Pause/resume it; inspect files, skip a file, change a priority, and inspect peers/trackers. Test tracker editing only on a disposable torrent. +5. Change a harmless preference, confirm read-back in the app and Web UI, then restore the original value. +6. Test alternative limits and per-torrent limits. Queue-order controls require queueing enabled on the server. +7. Remove the disposable torrent without files, then test permanent deletion only with expendable server data. +8. Enable app protection; background and reopen the app. Test successful authentication, cancellation and passcode fallback. +9. Rotate iPhone and iPad; check sidebar navigation, text sizes, dark/light modes and Files import. +10. Test offline refresh, incorrect proxy credentials, expired session and reconnect. Confirm no operation is duplicated by an automatic mutation retry. + +## Settings cancellation fix — 4 November 2026 + +- Moved preference request ownership from the SwiftUI view task to AppModel. Concurrent initial/reload callers await one session-owned task; cancelling a transient view does not cancel that request. Failed reloads retain the current preferences. +- Disconnect/reconnect cancels the preference task and clears its cache. Identity checks prevent retired sessions from publishing preferences, dashboard snapshots, or global network errors. +- Added cancellation checks around transport work, prevented overlapping dashboard refreshes, and commit dashboard responses together. Polling remains owned by RootView and does not invalidate the session or preference requests. +- Settings, Server, preference save, torrent actions, and detail loading ignore explicit CancellationError / URLError.cancelled signals. Active-session HTTP, decoding, timeout, and other network failures remain visible. No automatic login or mutation retries were added. +- Existing interface preserved. +- Unsigned generic iOS device Debug build: BUILD SUCCEEDED. Restricted compiler execution initially prevented SwiftUI macro expansion; rerunning with access to Xcode compiler services succeeded. Only App Intents metadata warning remained. +- 48 mocked macOS contract checks passed, including view cancellation, concurrent preference loads, failed reload retention, visible timeouts, explicit task cancellation, retired-session errors, and disconnect during loading. +- Intermittent navigation behavior on a physical device and live-server authentication were not exercised. + +Changed files: Tarnax/Core.swift, Tarnax/Settings.swift, Tarnax/Details.swift, Tests/ContractTests.swift, VALIDATION.md. + +## Tarnax rename — 4 November 2026 + +Renamed the Home Screen display name, bundle name, in-app headings and alerts, unlock prompt, Xcode target/product and shared scheme to Tarnax. Updated README instructions. The initial branding pass retained the old project paths and installation identity; the complete rename below supersedes that choice. + +Unsigned generic iOS device Debug build with scheme Tarnax: BUILD SUCCEEDED. Verified built app display name Tarnax and bundle identifier net.absdev.Tarnax. Info.plist and project.pbxproj pass plutil validation. + +## Launch branding and automatic sign-in — 4 November 2026 + +Added TarnaxIcon.png, TarnaxBrand.imageset, LaunchScreen.storyboard and a matching SwiftUI connecting view. Updated Info.plist and Xcode resources. Icon verified as opaque RGB, 1024×1024. Compiled storyboard rendered in a temporary iPhone simulator preview; icon also inspected on Home Screen. + +Saved server credentials trigger one automatic sign-in per launch after unlocking. Fresh installs remain on the connection form; failures allow manual retry. Disconnect suppresses automatic reconnection for the launch and invalidates pending connection results. Credentials continue to use the existing Keychain identity. + +54 executable contract checks passed, including new automatic sign-in eligibility, app lock, failed connection cleanup, no repeated retry, and explicit disconnect checks. Unsigned device and simulator Debug builds succeeded. Successful live-server authentication and physical-device launch timing were not tested. + +## Complete project rename — 4 November 2026 + +Renamed the enclosing folder to Tarnax, the project to Tarnax.xcodeproj and the source directory to Tarnax. Updated shared scheme container references, build paths, test runner, project labels, multipart boundary and documentation. Bundle identifier and Keychain service are now net.absdev.Tarnax. This supersedes the earlier decision to retain installation identifiers: iOS treats this as a new app and saved credentials must be entered once. Automatic sign-in then uses the new Keychain entry. + +Complete rename verification: unsigned generic iOS Debug build succeeded; all 54 contract checks passed; built app identifier verified as net.absdev.Tarnax; no old app-name references remain in project files. Stale Xcode window state was backed up outside the project so Xcode can regenerate paths. + +## Build scripts and Apple UI guidance — 4 November 2026 + +Added scripts/build.sh, scripts/test.sh, scripts/release.sh and scripts/README.md. Verified unsigned Release build, script help/syntax, and all 54 contract checks. The release script creates timestamped archives/exports, retains logs, verifies signatures and embedded registered-device provisioning, and writes a SHA-256 checksum. Existing local assets successfully created a signed Release archive, but Ad Hoc export failed because no matching profile for net.absdev.Tarnax was installed. Account provisioning updates require explicit user approval after automatic review rejected that step. + +UI changes: compact tab navigation with individual stacks, regular-width sidebar, scene-persisted selection, appearance-adaptive system launch screen, semantic colors and Dynamic Type in the connecting view, large-text transfer layouts, spoken metric labels, larger action targets, native cancellation placement and upload dismissal protection. The branded static launch design is superseded by Apple's recommended minimal launch treatment; branding remains in the icon and connecting view. No network or credential fixture was added to production code. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d437423 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,29 @@ +# Tarnax build and release + +Run from the project folder (scripts also work from other working directories): + +```sh +./scripts/test.sh # Plist/script validation, contract checks, unsigned Debug build +./scripts/test.sh --contracts-only # Fast validation and contract checks +./scripts/build.sh # Unsigned Debug device build +./scripts/build.sh --release # Unsigned optimized Release device build +./scripts/build.sh --release --signed # Signed build using existing provisioning +./scripts/release.sh # Release archive + registered-device distribution IPA +./scripts/release.sh --method debugging # Release archive + development-signed device IPA +``` + +Requirements: macOS, Xcode selected with xcode-select, Xcode command-line tools and Python 3. Signed builds require the relevant certificate/private key, a provisioning profile for `net.absdev.Tarnax`, and the configured team. The default team is `39R6U3437D`; override it with `DEVELOPMENT_TEAM` if necessary. + +The release configuration is optimized for both export methods. `release-testing` requires distribution signing and an ad hoc profile listing the recipient devices. `debugging` uses development signing and requires Developer Mode on recipient devices. Neither is an unrestricted public-install IPA. Install the exported IPA on a provisioned device with Xcode or Apple Configurator. Distribution to arbitrary public users requires a supported Apple distribution channel. + +Provisioning updates are disabled by default. If you explicitly want Xcode to contact Apple and create/update signing certificates, app identifiers or profiles, add `--allow-provisioning-updates`. Sign in to the appropriate account in Xcode first. This flag does not upload the app to App Store Connect; export destination is local. + +Outputs: + +- Builds: `build/Debug/` or `build/Release/`, including `build.log` and DerivedData. +- Releases: a unique folder under `build/releases/`, containing `Tarnax.xcarchive`, `ExportOptions.plist`, logs, and `export/Tarnax.ipa` with a SHA-256 checksum. +- Override roots with `BUILD_OUTPUT_DIR` or `RELEASE_OUTPUT_DIR`. + +Release export fails on any archive/export/signature/profile verification error. No unsigned ZIP is passed off as an installable IPA. Profiles include device identifiers: share only the intended IPA with testers and keep the remaining release diagnostics private. + +The current contract harness exercises mocked transport and model behavior. It does not sign in to a live server or exercise the physical-device UI. This project has no XCTest target; the script compiles and runs the existing executable contract harness. diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..12ab391 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIGURATION=Debug +SIGNED=NO +while (($#)); do + case "$1" in + --release) CONFIGURATION=Release ;; + --signed) SIGNED=YES ;; + -h|--help) echo 'Usage: scripts/build.sh [--release] [--signed]'; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac + shift +done +OUTPUT="${BUILD_OUTPUT_DIR:-$ROOT/build/$CONFIGURATION}" +mkdir -p "$OUTPUT" +xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax \ + -configuration "$CONFIGURATION" -destination 'generic/platform=iOS' \ + -derivedDataPath "$OUTPUT/DerivedData" CODE_SIGNING_ALLOWED="$SIGNED" \ + build 2>&1 | tee "$OUTPUT/build.log" +echo "App: $OUTPUT/DerivedData/Build/Products/$CONFIGURATION-iphoneos/Tarnax.app" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..ad8794f --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +METHOD=release-testing +PROVISIONING=() +while (($#)); do + case "$1" in + --method) + [[ $# -ge 2 ]] || { echo '--method needs a value' >&2; exit 2; } + METHOD="$2"; shift ;; + --allow-provisioning-updates) PROVISIONING=(-allowProvisioningUpdates) ;; + -h|--help) + echo 'Usage: scripts/release.sh [--method release-testing|debugging] [--allow-provisioning-updates]' + echo 'Always archives Release configuration. Default: registered-device distribution IPA.' + exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac + shift +done +case "$METHOD" in release-testing|debugging) ;; *) echo 'Supported methods: release-testing, debugging' >&2; exit 2 ;; esac +TEAM="${DEVELOPMENT_TEAM:-39R6U3437D}" +BASE="${RELEASE_OUTPUT_DIR:-$ROOT/build/releases}" +mkdir -p "$BASE" +OUTPUT="$(mktemp -d "$BASE/$(date +%Y%m%d-%H%M%S)-$METHOD.XXXXXX")" +ARCHIVE="$OUTPUT/Tarnax.xcarchive" +EXPORT="$OUTPUT/export" +echo "Release output: $OUTPUT" +python3 - "$OUTPUT/ExportOptions.plist" "$METHOD" "$TEAM" <<'PY' +import plistlib, sys +with open(sys.argv[1], 'wb') as f: + plistlib.dump({'method':sys.argv[2], 'teamID':sys.argv[3], 'signingStyle':'automatic', + 'destination':'export', 'stripSwiftSymbols':True, 'thinning':'', + 'manageAppVersionAndBuildNumber':False}, f) +PY +xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax \ + -configuration Release -destination 'generic/platform=iOS' \ + -derivedDataPath "$OUTPUT/DerivedData" -archivePath "$ARCHIVE" \ + DEVELOPMENT_TEAM="$TEAM" CODE_SIGN_STYLE=Automatic \ + ${PROVISIONING[@]+"${PROVISIONING[@]}"} archive 2>&1 | tee "$OUTPUT/archive.log" +xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \ + -exportOptionsPlist "$OUTPUT/ExportOptions.plist" \ + ${PROVISIONING[@]+"${PROVISIONING[@]}"} 2>&1 | tee "$OUTPUT/export.log" +IPA="$EXPORT/Tarnax.ipa" +[[ -f "$IPA" ]] || { echo 'Export finished without Tarnax.ipa' >&2; exit 1; } +VERIFY="$(mktemp -d "$OUTPUT/verify.XXXXXX")" +trap 'rm -rf "$VERIFY"' EXIT +unzip -q "$IPA" -d "$VERIFY" +APP="$VERIFY/Payload/Tarnax.app" +codesign --verify --deep --strict "$APP" +[[ -f "$APP/embedded.mobileprovision" ]] || { echo 'No embedded provisioning profile' >&2; exit 1; } +security cms -D -i "$APP/embedded.mobileprovision" > "$VERIFY/profile.plist" +python3 - "$APP/Info.plist" "$VERIFY/profile.plist" "$METHOD" <<'PY' +import plistlib, sys, datetime +with open(sys.argv[1],'rb') as f: app=plistlib.load(f) +with open(sys.argv[2],'rb') as f: profile=plistlib.load(f) +assert app['CFBundleIdentifier']=='net.absdev.Tarnax', 'Unexpected app identifier' +assert profile.get('ProvisionedDevices'), 'Profile does not include registered devices' +assert profile['ExpirationDate']>datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'Profile expired' +assert profile['Entitlements']['application-identifier'].endswith('.'+app['CFBundleIdentifier']), 'Profile/app mismatch' +if sys.argv[3]=='release-testing': + assert not profile['Entitlements'].get('get-task-allow',False), 'Distribution IPA has debugging enabled' +print('Verified:', app['CFBundleDisplayName'], app['CFBundleShortVersionString'], 'build', app['CFBundleVersion']) +print('Registered devices:',len(profile['ProvisionedDevices'])) +print('Profile expiry:',profile['ExpirationDate'].isoformat()+'Z') +PY +(cd "$EXPORT" && shasum -a 256 Tarnax.ipa > Tarnax.ipa.sha256) +echo "Verified IPA: $IPA" +echo "SHA-256: $EXPORT/Tarnax.ipa.sha256" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..6e1b963 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONTRACTS_ONLY=NO +case "${1:-}" in + --contracts-only) CONTRACTS_ONLY=YES ;; + '') ;; + -h|--help) echo 'Usage: scripts/test.sh [--contracts-only]'; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; +esac +if (($# > 1)); then echo 'Too many arguments' >&2; exit 2; fi +cd "$ROOT" +plutil -lint Tarnax/Info.plist Tarnax.xcodeproj/project.pbxproj +for script in scripts/*.sh Tests/run.sh; do bash -n "$script"; done +bash Tests/run.sh +if [[ "$CONTRACTS_ONLY" == NO ]]; then bash scripts/build.sh; fi +echo 'All requested checks passed.'