new app store assets ready for relese
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
000000000000000000000037 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000036; };
|
||||
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 */; };
|
||||
@@ -16,6 +17,7 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
000000000000000000000036 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
00000000000000000000000B /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
|
||||
@@ -49,6 +51,7 @@
|
||||
children = (
|
||||
000000000000000000000032 /* Assets.xcassets */,
|
||||
000000000000000000000034 /* LaunchScreen.storyboard */,
|
||||
000000000000000000000036 /* PrivacyInfo.xcprivacy */,
|
||||
00000000000000000000000A /* Core.swift */,
|
||||
00000000000000000000000B /* App.swift */,
|
||||
00000000000000000000000C /* Details.swift */,
|
||||
@@ -119,6 +122,7 @@
|
||||
files = (
|
||||
000000000000000000000033 /* Assets.xcassets in Resources */,
|
||||
000000000000000000000035 /* LaunchScreen.storyboard in Resources */,
|
||||
000000000000000000000037 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -225,7 +225,7 @@ struct LibraryView: View {
|
||||
.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()) }
|
||||
VStack(alignment: .leading, spacing: 5) { Label(title, systemImage: icon).font(.caption).foregroundStyle(.secondary); Text(value).font(.title3.bold().monospacedDigit()).foregroundStyle(title == "Download" ? Color.blue : Color.teal) }
|
||||
}
|
||||
}
|
||||
struct AddView: View {
|
||||
@@ -307,8 +307,8 @@ struct TransferHistoryChart: View {
|
||||
}.pickerStyle(.segmented).frame(maxWidth: 190)
|
||||
}
|
||||
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) : AnyLayout(HStackLayout(spacing: 20))) {
|
||||
Label("Download", systemImage: "arrow.down").foregroundStyle(.indigo)
|
||||
Label("Upload · dashed", systemImage: "arrow.up").foregroundStyle(.teal)
|
||||
Label("Download", systemImage: "arrow.down.circle.fill").foregroundStyle(.blue)
|
||||
Label("Upload · dashed", systemImage: "arrow.up.circle.fill").foregroundStyle(.teal)
|
||||
}.font(.caption)
|
||||
if samples.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
@@ -319,13 +319,20 @@ struct TransferHistoryChart: View {
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(samples) { sample in
|
||||
AreaMark(x: .value("Time", sample.date), yStart: .value("Baseline", 0.0), yEnd: .value("Download speed", Double(sample.download)), series: .value("Area", "Download-fill-\(sample.segment)"))
|
||||
.foregroundStyle(LinearGradient(colors: [Color.indigo.opacity(0.35), Color.blue.opacity(0.03)], startPoint: .top, endPoint: .bottom))
|
||||
.interpolationMethod(.monotone)
|
||||
AreaMark(x: .value("Time", sample.date), yStart: .value("Baseline", 0.0), yEnd: .value("Upload speed", Double(sample.upload)), series: .value("Area", "Upload-fill-\(sample.segment)"))
|
||||
.foregroundStyle(LinearGradient(colors: [Color.teal.opacity(0.28), Color.mint.opacity(0.02)], startPoint: .top, endPoint: .bottom))
|
||||
.interpolationMethod(.monotone)
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download)), series: .value("Series", "Download-\(sample.segment)"))
|
||||
.foregroundStyle(.indigo).lineStyle(StrokeStyle(lineWidth: 2.5))
|
||||
.foregroundStyle(LinearGradient(colors: [.blue, .indigo, .purple], startPoint: .leading, endPoint: .trailing))
|
||||
.lineStyle(StrokeStyle(lineWidth: 3, lineCap: .round)).interpolationMethod(.monotone)
|
||||
LineMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.upload)), series: .value("Series", "Upload-\(sample.segment)"))
|
||||
.foregroundStyle(.teal).lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 4]))
|
||||
if samples.count == 1 {
|
||||
PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download))).foregroundStyle(.indigo)
|
||||
PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.upload))).foregroundStyle(.teal)
|
||||
.foregroundStyle(.teal).lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, dash: [5, 4])).interpolationMethod(.monotone)
|
||||
if samples.count == 1 || sample.id == samples.last?.id || sample.id == selectedSample?.id {
|
||||
PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download))).foregroundStyle(.purple).symbolSize(28)
|
||||
PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.upload))).foregroundStyle(.teal).symbolSize(28)
|
||||
}
|
||||
}
|
||||
if let selectedSample {
|
||||
@@ -350,6 +357,9 @@ struct TransferHistoryChart: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartPlotStyle { plot in
|
||||
plot.background(LinearGradient(colors: [Color.indigo.opacity(0.06), Color.teal.opacity(0.02)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.chartXSelection(value: $selection)
|
||||
.frame(height: dynamicTypeSize.isAccessibilitySize ? 240 : 170)
|
||||
|
||||
@@ -4,7 +4,7 @@ import Security
|
||||
import LocalAuthentication
|
||||
|
||||
struct Credentials: Codable {
|
||||
var server = "https://torrent.absdev.net"
|
||||
var server = ""
|
||||
var username = ""
|
||||
var password = ""
|
||||
var proxyUser = ""
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>6</string>
|
||||
<string>8</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
@@ -40,5 +40,7 @@
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -57,6 +57,10 @@ struct SettingsView: View {
|
||||
Button("Disconnect / change server") { model.disconnect() }
|
||||
Button("Forget saved credentials", role: .destructive) { disconnect = true }
|
||||
}
|
||||
Section("About Tarnax") {
|
||||
NavigationLink("Privacy policy") { PrivacyPolicyView() }
|
||||
Link("Contact support", destination: URL(string: "mailto:mark.gregory@abbeysoftware.com")!)
|
||||
}
|
||||
Section("API reference") {
|
||||
Link("qBittorrent Web API documentation", destination: URL(string: "https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-5.0)")!)
|
||||
}
|
||||
@@ -132,3 +136,29 @@ struct PreferenceEditor: View {
|
||||
} catch { model.report(error, from: client) }
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacyPolicyView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Your server, your data") {
|
||||
Text("Tarnax communicates directly with the HTTPS server you configure. Server addresses, sign-in credentials, torrent requests and management commands are sent to that server. Its operator may keep logs and has its own privacy practices.")
|
||||
}
|
||||
Section("On this device") {
|
||||
Text("Connection credentials are stored in this device’s Keychain. App-lock preferences and navigation state are stored locally. Transfer history is kept in memory for the current session. Session cookies use a private, temporary network session.")
|
||||
}
|
||||
Section("No tracking") {
|
||||
Text("Tarnax includes no advertising, tracking SDKs or third-party analytics. It does not send your torrent library or credentials to a developer-operated service unless you choose such a server yourself.")
|
||||
}
|
||||
Section("Device authentication") {
|
||||
Text("Face ID and passcode authentication are handled by Apple’s system services. Tarnax receives an authentication result, not your face data or passcode.")
|
||||
}
|
||||
Section("Your choices") {
|
||||
Text("Use Settings → Forget saved credentials to remove the saved Keychain credentials and disconnect. Server-side files, accounts and logs are managed by your server operator. Contacting support by email shares the information you choose to include; please do not send passwords.")
|
||||
}
|
||||
Section("Contact") {
|
||||
Link("mark.gregory@abbeysoftware.com", destination: URL(string: "mailto:mark.gregory@abbeysoftware.com")!)
|
||||
Text("Effective 4 November 2026").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}.navigationTitle("Privacy policy").navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Apple references checked on 4 November 2026
|
||||
|
||||
- Screenshot sizes, format and transparency: https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/
|
||||
- App preview duration, resolution and codecs: https://developer.apple.com/help/app-store-connect/reference/app-information/app-preview-specifications/
|
||||
- Product-page guidance: https://developer.apple.com/app-store/product-page/
|
||||
- Name/subtitle limits: https://developer.apple.com/help/app-store-connect/reference/app-information/app-information
|
||||
- Description/promotional text/keyword limits: https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information
|
||||
- App Privacy: https://developer.apple.com/app-store/app-privacy-details/
|
||||
- Required-reason APIs: https://developer.apple.com/documentation/technotes/tn3183-adding-required-reason-api-entries-to-your-privacy-manifest
|
||||
- Review access and metadata accuracy: https://developer.apple.com/app-store/review/guidelines/
|
||||
- Standard EULA: https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
|
||||
|
||||
Specifications change; recheck when uploading. This pack creates local files and does not publish, upload to App Store Connect or accept account agreements.
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"screenshots": [
|
||||
{
|
||||
"file": "screenshots/upload/iphone-6.9/01-home.png",
|
||||
"width": 1320,
|
||||
"height": 2868,
|
||||
"scene": "01-home",
|
||||
"appearance": "dark",
|
||||
"source": "screenshots/raw/iphone-6.9/01-home.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/iphone-6.9/02-library.png",
|
||||
"width": 1320,
|
||||
"height": 2868,
|
||||
"scene": "02-library",
|
||||
"appearance": "light",
|
||||
"source": "screenshots/raw/iphone-6.9/02-library.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/iphone-6.9/03-details.png",
|
||||
"width": 1320,
|
||||
"height": 2868,
|
||||
"scene": "03-details",
|
||||
"appearance": "light",
|
||||
"source": "screenshots/raw/iphone-6.9/03-details.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/iphone-6.9/04-files.png",
|
||||
"width": 1320,
|
||||
"height": 2868,
|
||||
"scene": "04-files",
|
||||
"appearance": "dark",
|
||||
"source": "screenshots/raw/iphone-6.9/04-files.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/iphone-6.9/05-settings.png",
|
||||
"width": 1320,
|
||||
"height": 2868,
|
||||
"scene": "05-settings",
|
||||
"appearance": "light",
|
||||
"source": "screenshots/raw/iphone-6.9/05-settings.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/iphone-6.9/06-add.png",
|
||||
"width": 1320,
|
||||
"height": 2868,
|
||||
"scene": "06-add",
|
||||
"appearance": "light",
|
||||
"source": "screenshots/raw/iphone-6.9/06-add.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/ipad-13/01-home.png",
|
||||
"width": 2064,
|
||||
"height": 2752,
|
||||
"scene": "01-home",
|
||||
"appearance": "dark",
|
||||
"source": "screenshots/raw/ipad-13/01-home.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/ipad-13/02-library.png",
|
||||
"width": 2064,
|
||||
"height": 2752,
|
||||
"scene": "02-library",
|
||||
"appearance": "light",
|
||||
"source": "screenshots/raw/ipad-13/02-library.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/ipad-13/03-details.png",
|
||||
"width": 2064,
|
||||
"height": 2752,
|
||||
"scene": "03-details",
|
||||
"appearance": "light",
|
||||
"source": "screenshots/raw/ipad-13/03-details.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/ipad-13/04-files.png",
|
||||
"width": 2064,
|
||||
"height": 2752,
|
||||
"scene": "04-files",
|
||||
"appearance": "dark",
|
||||
"source": "screenshots/raw/ipad-13/04-files.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/ipad-13/05-settings.png",
|
||||
"width": 2064,
|
||||
"height": 2752,
|
||||
"scene": "05-settings",
|
||||
"appearance": "light",
|
||||
"source": "screenshots/raw/ipad-13/05-settings.png"
|
||||
},
|
||||
{
|
||||
"file": "screenshots/upload/ipad-13/06-add.png",
|
||||
"width": 2064,
|
||||
"height": 2752,
|
||||
"scene": "06-add",
|
||||
"appearance": "light",
|
||||
"source": "screenshots/raw/ipad-13/06-add.png"
|
||||
}
|
||||
],
|
||||
"sample_data": true,
|
||||
"production_fixture_included": false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,41 @@
|
||||
# Tarnax privacy policy
|
||||
|
||||
Effective date: 4 November 2026
|
||||
|
||||
Tarnax is developed by Mark Gregory. Contact: mark.gregory@abbeysoftware.com.
|
||||
|
||||
## Your connection
|
||||
|
||||
Tarnax is a remote control for a qBittorrent server that you configure. It sends your server address, authentication information, API requests, torrent links or files that you choose to add, and management commands directly to that server over HTTPS. Reverse-proxy credentials, if configured, are sent to the proxy serving that connection. The server and proxy operators may retain access logs, IP addresses, account information or transfer data under their own policies. You are responsible for choosing a server you trust and having permission to use it.
|
||||
|
||||
Tarnax does not provide a hosted torrent service. It does not send your library or credentials to a developer-operated service unless you explicitly choose a server operated by the developer. The released app has no preset production server.
|
||||
|
||||
## Information on your device
|
||||
|
||||
Saved connection credentials are kept in the device Keychain with device-only, when-unlocked protection. The app-lock preference and navigation state are stored locally. Session cookies use a private temporary network session and are discarded when the connection closes. Transfer-history samples are held in memory for the current session and are cleared when the server connection is replaced or disconnected.
|
||||
|
||||
When you select a .torrent file, Tarnax reads it to upload it to your chosen server. Torrent contents remain on the server; Tarnax does not download those contents to your iPhone or iPad.
|
||||
|
||||
## Authentication
|
||||
|
||||
Face ID, Touch ID where supported, and device passcode authentication are handled by Apple's LocalAuthentication services. Tarnax receives an authentication result. It does not receive or store biometric templates, facial images or your device passcode.
|
||||
|
||||
## Tracking and analytics
|
||||
|
||||
Tarnax includes no advertising, tracking SDKs or third-party analytics services. It does not request advertising identifiers, contacts, photos, microphone, camera or location access. Apple may process App Store, payment or diagnostic information according to your Apple settings and Apple's policies. Information collected independently by your selected server is governed by that server operator.
|
||||
|
||||
## Support correspondence
|
||||
|
||||
If you email support, we receive the information you choose to send and use it to respond, investigate the issue and maintain the support correspondence. Email providers process those messages as part of delivering the service. Do not send passwords, private torrent links or sensitive server data. Ask us to delete support correspondence by emailing the address above; we will consider the request subject to applicable retention obligations and legitimate record-keeping needs.
|
||||
|
||||
## Your choices
|
||||
|
||||
Use Settings → Forget saved credentials to remove Tarnax's saved Keychain credentials and disconnect. Disconnecting also discards the session history. Deleting the app removes its local app data, but iOS may retain Keychain items; use the Forget action before deleting the app if you want to remove those credentials. Server-side accounts, files, logs and torrent data must be managed with your server operator.
|
||||
|
||||
## Children and changes
|
||||
|
||||
Tarnax is a general-purpose server utility and is not designed specifically for children. We do not knowingly seek personal information from children. This policy may be updated if the app or its data practices change. The effective date above identifies the current version.
|
||||
|
||||
## Contact
|
||||
|
||||
mark.gregory@abbeysoftware.com
|
||||
@@ -0,0 +1,13 @@
|
||||
# Tarnax usage information
|
||||
|
||||
Tarnax is an independent remote-control app for a user-configured qBittorrent server. It is not affiliated with or endorsed by the qBittorrent project, Marvel or any other software or entertainment brand.
|
||||
|
||||
You need permission to access the server and to access, download or share any content you manage through it. Tarnax does not provide a content catalogue, search service, hosted download account, VPN or cloud storage. Do not use it to infringe intellectual-property rights or other applicable laws.
|
||||
|
||||
Commands run on your server. Moving storage, changing server settings and deleting server files can have immediate effects. Keep backups and review destructive confirmations carefully. Feature availability depends on your server version and setup.
|
||||
|
||||
If distributed through Apple's App Store without a custom licence, Apple's Standard Licensed Application End User License Agreement applies, together with any mandatory local consumer rights. This usage page does not replace that agreement or limit rights that cannot lawfully be excluded.
|
||||
|
||||
Apple Standard EULA: https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
|
||||
|
||||
Support: mark.gregory@abbeysoftware.com
|
||||
@@ -0,0 +1,14 @@
|
||||
# Tarnax brand assets
|
||||
|
||||
Positioning: a focused, native remote control for your own qBittorrent server.
|
||||
|
||||
Primary line: **Your server. Your control.**
|
||||
Supporting line: **Remote control for qBittorrent.**
|
||||
|
||||
Palette: midnight navy #050919; indigo #684EFF; electric blue #1687FF; violet #B840EA; teal #00B6BE; off-white #F5F7FC. Use system colors within the app for readable native controls; marketing backgrounds use the navy palette.
|
||||
|
||||
The orbital T icon is the original AI-generated raster mark created for Tarnax. The 1024-pixel opaque PNG is the master supplied here. Derivative sizes are for web/press use, not a separate icon set to upload to App Store Connect. iOS applies its own icon mask. Do not bake a rounded outer corner into the store icon.
|
||||
|
||||
Marketing screenshots preserve the actual app screen inside a neutral rounded frame. All depicted transfers are fictional sample data representing lawful content. No third-party entertainment artwork or Marvel imagery is included. Social/press images are not App Store upload assets.
|
||||
|
||||
No App Store badge has been included because the app has not been released. Use Apple's official approved badge and your real store URL after availability is confirmed; do not fabricate a badge or an existing store listing.
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,30 @@
|
||||
Your qBittorrent server, comfortably in your pocket.
|
||||
|
||||
Tarnax is a native remote control for qBittorrent on iPhone and iPad. Follow downloads, organise your library and adjust your server without opening a desktop browser. Torrent contents stay on your server.
|
||||
|
||||
SEE WHAT’S HAPPENING
|
||||
• View live download and upload speeds, progress, time remaining and free server space.
|
||||
• Explore colourful transfer graphs covering the last 1, 5 or 15 minutes of your current session.
|
||||
• Keep active downloads on Home and browse all transfers in Library.
|
||||
|
||||
TAKE CONTROL
|
||||
• Add magnet links or upload .torrent files from Files.
|
||||
• Pause, resume, recheck and reannounce transfers.
|
||||
• Search, filter and sort your library.
|
||||
• Manage categories, tags, queue order and transfer limits.
|
||||
• Inspect files, set file priorities and review peers and trackers.
|
||||
• Browse and edit the preferences exposed by your server, with confirmation and read-back.
|
||||
|
||||
CONNECT WITH CONFIDENCE
|
||||
• Connect directly to your own HTTPS server.
|
||||
• Keep saved credentials in the device Keychain.
|
||||
• Use optional Face ID or device authentication.
|
||||
• Sign in automatically with your saved connection.
|
||||
• Use a native iPhone tab bar or the wider iPad sidebar, in light or dark appearance.
|
||||
|
||||
REQUIRES YOUR OWN SERVER
|
||||
Tarnax requires an existing qBittorrent server with its Web UI API enabled and a trusted HTTPS connection. It does not provide a torrent server, catalogue, search engine, VPN or cloud storage. Feature availability depends on your server version and configuration.
|
||||
|
||||
Live updates run while Tarnax is active and unlocked. Graph history is recorded during the current session; it is not a server-wide historical database. Torrent contents are not downloaded to your iPhone or iPad.
|
||||
|
||||
Use Tarnax only with content you are authorised to access and share. Tarnax is an independent app and is not affiliated with or endorsed by the qBittorrent project.
|
||||
@@ -0,0 +1 @@
|
||||
torrent,remote,server,downloads,magnet,seedbox,transfer,monitor,bandwidth,upload,files,queue
|
||||
@@ -0,0 +1 @@
|
||||
Tarnax
|
||||
@@ -0,0 +1 @@
|
||||
Your server. Your control. Follow live transfer graphs, organise your torrent library and manage downloads securely from iPhone and iPad.
|
||||
@@ -0,0 +1,3 @@
|
||||
Welcome to Tarnax.
|
||||
|
||||
Manage your qBittorrent server with a native iPhone and iPad interface, live transfer graphs, library controls, file priorities and secure saved connections.
|
||||
@@ -0,0 +1 @@
|
||||
Remote control for qBittorrent
|
||||
@@ -0,0 +1,30 @@
|
||||
Your qBittorrent server, comfortably in your pocket.
|
||||
|
||||
Tarnax is a native remote control for qBittorrent on iPhone and iPad. Follow downloads, organise your library and adjust your server without opening a desktop browser. Torrent contents stay on your server.
|
||||
|
||||
SEE WHAT’S HAPPENING
|
||||
• View live download and upload speeds, progress, time remaining and free server space.
|
||||
• Explore colourful transfer graphs covering the last 1, 5 or 15 minutes of your current session.
|
||||
• Keep active downloads on Home and browse all transfers in Library.
|
||||
|
||||
TAKE CONTROL
|
||||
• Add magnet links or upload .torrent files from Files.
|
||||
• Pause, resume, recheck and reannounce transfers.
|
||||
• Search, filter and sort your library.
|
||||
• Manage categories, tags, queue order and transfer limits.
|
||||
• Inspect files, set file priorities and review peers and trackers.
|
||||
• Browse and edit the preferences exposed by your server, with confirmation and read-back.
|
||||
|
||||
CONNECT WITH CONFIDENCE
|
||||
• Connect directly to your own HTTPS server.
|
||||
• Keep saved credentials in the device Keychain.
|
||||
• Use optional Face ID or device authentication.
|
||||
• Sign in automatically with your saved connection.
|
||||
• Use a native iPhone tab bar or the wider iPad sidebar, in light or dark appearance.
|
||||
|
||||
REQUIRES YOUR OWN SERVER
|
||||
Tarnax requires an existing qBittorrent server with its Web UI API enabled and a trusted HTTPS connection. It does not provide a torrent server, catalogue, search engine, VPN or cloud storage. Feature availability depends on your server version and configuration.
|
||||
|
||||
Live updates run while Tarnax is active and unlocked. Graph history is recorded during the current session; it is not a server-wide historical database. Torrent contents are not downloaded to your iPhone or iPad.
|
||||
|
||||
Use Tarnax only with content you are authorised to access and share. Tarnax is an independent app and is not affiliated with or endorsed by the qBittorrent project.
|
||||
@@ -0,0 +1 @@
|
||||
torrent,remote,server,downloads,magnet,seedbox,transfer,monitor,bandwidth,upload,files,queue
|
||||
@@ -0,0 +1 @@
|
||||
Tarnax
|
||||
@@ -0,0 +1 @@
|
||||
Your server. Your control. Follow live transfer graphs, organise your torrent library and manage downloads securely from iPhone and iPad.
|
||||
@@ -0,0 +1,3 @@
|
||||
Welcome to Tarnax.
|
||||
|
||||
Manage your qBittorrent server with a native iPhone and iPad interface, live transfer graphs, library controls, file priorities and secure saved connections.
|
||||
@@ -0,0 +1 @@
|
||||
Remote control for qBittorrent
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"app_name": "Tarnax",
|
||||
"bundle_id": "net.absdev.Tarnax",
|
||||
"version": "1.0",
|
||||
"build": "8",
|
||||
"sku_suggestion": "TARNAX-IOS-001",
|
||||
"primary_locale": "en-GB",
|
||||
"additional_locale": "en-US",
|
||||
"primary_category": "Utilities",
|
||||
"secondary_category": "Productivity",
|
||||
"copyright": "2026 Mark Gregory",
|
||||
"support_email": "mark.gregory@abbeysoftware.com",
|
||||
"support_url": null,
|
||||
"privacy_policy_url": null,
|
||||
"marketing_url": null,
|
||||
"price": null,
|
||||
"territories": null,
|
||||
"release_method_suggestion": "manual",
|
||||
"review_contact": {
|
||||
"first_name": "Mark",
|
||||
"last_name": "Gregory",
|
||||
"email": "mark.gregory@abbeysoftware.com",
|
||||
"phone": null
|
||||
},
|
||||
"review_server": {
|
||||
"https_url": null,
|
||||
"username": null,
|
||||
"password": null,
|
||||
"proxy_username": null,
|
||||
"proxy_password": null
|
||||
},
|
||||
"notes": "No secrets in this file. Enter review passwords directly in App Store Connect. Null fields require owner input before submission."
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Age rating, content rights and encryption worksheet
|
||||
|
||||
Complete Apple's current questionnaire in App Store Connect; the store calculates the rating. Do not invent a rating from this worksheet.
|
||||
|
||||
Current app features: server administration and monitoring; no ads, chat, social posting, gambling, contests, purchases, loot boxes, health advice or built-in public torrent search. It displays metadata supplied by the user's server. It does not display downloaded media contents. External documentation/support links do not provide an in-app unrestricted web browser.
|
||||
|
||||
Assess the questionnaire's user-generated-content and web-access wording against this private-server model. Do not claim the app is specifically designed for children or that it provides parental controls.
|
||||
|
||||
Content rights: only manage content the user is authorised to access/share. Review the third-party-content-rights declaration rather than blindly answering Yes or No. No licensed film/music artwork or third-party logos are used in the screenshot pack. Sample transfer names are fictional and describe open/public-domain content. qBittorrent is mentioned to identify compatibility; no affiliation is claimed.
|
||||
|
||||
Brand ownership: Tarnax is an existing Marvel fictional place name. Earlier naming searches are not trademark clearance. The release owner must settle commercial naming rights and similar marks before submission. The artwork is an original generated orbital T, with no Marvel imagery. The generated PNG is the raster master; no hand-drawn vector master is claimed.
|
||||
|
||||
Encryption audit: networking uses Apple's URLSession/HTTPS, Keychain and LocalAuthentication; no custom encryption protocol or third-party crypto SDK was found. ITSAppUsesNonExemptEncryption is set to false on that basis. The account owner must confirm export-compliance answers and update them if encryption use changes.
|
||||
|
||||
Business/account fields still require owner decisions: seller/trader status, legal/address details requested by Apple, tax/banking/agreements, price, distribution territories, content rights and any regional requirements. Do not put private identity documents into this pack.
|
||||
|
||||
Accessibility Nutrition Labels: the app uses Dynamic Type, VoiceOver labels, semantic colors, native controls and non-color graph distinctions. Claim only features verified with Apple's required testing. Full VoiceOver, Voice Control, contrast and physical-device testing have not all been completed; do not tick every accessibility feature automatically.
|
||||
@@ -0,0 +1,22 @@
|
||||
# App Privacy assessment
|
||||
|
||||
Proposed production label: **Data Not Collected**, subject to the owner confirming the final service arrangements and the statements below.
|
||||
|
||||
Code audit for this pack found no ad/tracking/analytics SDKs and no developer-hosted API. The user chooses an HTTPS server; credentials live in the device Keychain; lock and navigation settings stay local; transfer history stays in memory. The production default server is now blank.
|
||||
|
||||
This proposed answer assumes the developer does not collect and retain user data through a bundled/default backend, telemetry, support SDK or other integration. A user-configured server is controlled by that user or their chosen operator; its practices must be assessed separately if the developer also operates that service. Apple defines collection in terms of data transmitted off-device and retained beyond serving the request; merely transmitting an authentication request is not automatically the same as declaring retained analytics.
|
||||
|
||||
Support email is user-initiated external correspondence. The public policy explains it. Review Apple's optional-disclosure criteria against your actual handling rather than assuming all support collection is automatically exempt.
|
||||
|
||||
Before answering App Store Connect:
|
||||
- Confirm whether you or your partners operate any server used by ordinary users.
|
||||
- Confirm no analytics, crash-reporting or advertising SDK has been added since this audit.
|
||||
- Review any retained server access logs and account records if you operate the backend.
|
||||
- Include all third-party SDK behavior if dependencies are added.
|
||||
- Confirm the published privacy policy accurately describes support correspondence.
|
||||
|
||||
Bundled privacy manifest: no tracking, no declared collected-data types, UserDefaults required-reason API CA92.1 for app-local settings. This manifest is not a substitute for the App Store Connect privacy questionnaire.
|
||||
|
||||
Sources:
|
||||
https://developer.apple.com/app-store/app-privacy-details/
|
||||
https://developer.apple.com/documentation/technotes/tn3183-adding-required-reason-api-entries-to-your-privacy-manifest
|
||||
@@ -0,0 +1,28 @@
|
||||
# Tarnax 1.0 submission checklist
|
||||
|
||||
## Assets prepared by this pack
|
||||
- App Store icon and derivative brand assets.
|
||||
- Six iPhone and six iPad screenshots in raw and designed versions.
|
||||
- Device-footage app preview videos and poster frames.
|
||||
- English UK and US listing text with field-length validation.
|
||||
- Support email, ready-to-host support/privacy/usage pages, and an in-app privacy/support section.
|
||||
- Bundled privacy manifest and export-compliance declaration based on the audited app.
|
||||
- Review notes, access checklist, age/privacy/compliance worksheets.
|
||||
- App Store archive/export tooling; no upload or publication is performed.
|
||||
|
||||
## Owner actions before submission
|
||||
- [ ] Resolve Tarnax naming/trademark rights.
|
||||
- [ ] Choose the final public website URLs; publish website/ over HTTPS and test every link. Enter the support and privacy URLs in release-config.json and App Store Connect. A local HTML file is not a public policy URL.
|
||||
- [ ] Confirm the privacy-policy operator name, support handling and final App Privacy answers.
|
||||
- [ ] Provide a dedicated external reviewer server and login details; see reviewer-access-checklist.md.
|
||||
- [ ] Enter reviewer contact phone, pricing, territories, trader status and required legal/tax/banking details.
|
||||
- [ ] Create the App Store Connect app record for net.absdev.Tarnax; confirm Tarnax is available in the chosen locales.
|
||||
- [ ] Complete age-rating, content-rights, encryption and accessibility questionnaires accurately.
|
||||
- [ ] Validate the App Store export/archive in current production Xcode/App Store Connect. An SDK/beta runtime compatibility fix was needed during development; confirm the SDK used is accepted for store upload.
|
||||
- [ ] Upload the **App Store** build, not the direct-install Ad Hoc IPA. Wait for processing and inspect warnings.
|
||||
- [ ] Confirm screenshot appearance and cropping on Apple's upload preview, then select one coherent set per locale/device. Do not upload both raw and designed copies as duplicate screens.
|
||||
- [ ] Test build 8 on physical iPhone and iPad: fresh login, saved login, Face ID, cancellation/retry, background lock, graph, library, Files import, server preferences and offline recovery.
|
||||
- [ ] Confirm public support/privacy URLs are accessible without login.
|
||||
- [ ] Prefer manual release for the first version; submit for review when all blockers are cleared.
|
||||
|
||||
Run `python3 appstore/tools/validate.py` for local asset checks. The report distinguishes valid assets from outstanding owner/account requirements. It cannot guarantee App Review acceptance.
|
||||
@@ -0,0 +1,20 @@
|
||||
Tarnax is a native remote control for a user-configured qBittorrent server. It does not download torrent contents to the iOS device, provide a content catalogue, perform public torrent searching, or sell a hosted download service.
|
||||
|
||||
An existing qBittorrent Web UI API endpoint with trusted HTTPS is required. The developer must supply a dedicated, reachable reviewer server and credentials in App Store Connect's App Review Information fields. Do not submit this pack until those details have been supplied and tested from outside the local network.
|
||||
|
||||
Review procedure:
|
||||
1. Open Tarnax and enter the supplied HTTPS server address and Web UI credentials. Leave the optional proxy fields empty unless separate proxy credentials are supplied.
|
||||
2. Tap Connect securely.
|
||||
3. Home shows active downloads, transfer speeds and a live graph. History is recorded during this session; wait several refreshes to see new samples.
|
||||
4. Library contains all transfers. Search, filter, sort and open the dedicated sample transfer.
|
||||
5. Open Files to inspect progress and file priorities. Use a disposable sample if testing mutations.
|
||||
6. Add accepts a magnet link or a .torrent file and uploads the request/file to the server. The developer should provide a lawful sample and ensure it remains available throughout review.
|
||||
7. Settings exposes the preferences returned by the server, with confirmation before changes and read-back afterwards. Avoid altering the reviewer server's networking/authentication configuration during review.
|
||||
8. Optional Face ID/device authentication is in Settings. It uses the system policy, with passcode fallback. Normal authentication shows a branded waiting view; cancelled authentication offers Unlock.
|
||||
9. Disconnect/change server and Forget saved credentials are in Settings. Privacy policy and support contact are available there too.
|
||||
|
||||
Live updates pause while the app is backgrounded or locked. No background-download entitlement is requested. There are no ads, subscriptions or in-app purchases in this version.
|
||||
|
||||
Screenshots and preview videos use the actual app interface with fictional lawful transfer data in an isolated simulator fixture. That fixture is not included in the App Store app. The shipping binary has no baked-in demo credentials.
|
||||
|
||||
Review contact email: mark.gregory@abbeysoftware.com
|
||||
@@ -0,0 +1,12 @@
|
||||
# Reviewer access — complete before submitting
|
||||
|
||||
- [ ] Create a dedicated qBittorrent review account/server containing only disposable, lawful sample transfers. Do not expose your production library.
|
||||
- [ ] Provide a trusted HTTPS URL reachable from outside your network, with API v2 enabled.
|
||||
- [ ] Supply username and password directly in App Store Connect's App Review Information, not in this repository.
|
||||
- [ ] Supply proxy credentials separately only if needed. Avoid interactive browser-only SSO.
|
||||
- [ ] Provide a lawful magnet or .torrent sample that remains available, plus a sample small enough to test quickly.
|
||||
- [ ] Test login, Home, Library, details/files, adding, pause/resume and a harmless setting from an external network.
|
||||
- [ ] Keep the server running and the credentials valid throughout review.
|
||||
- [ ] Enter your reviewer contact telephone number in App Store Connect.
|
||||
|
||||
The screenshot fixture is local sample-data tooling, not an approved substitute for reviewer access to the shipping app. Apple requires full access and running backend services for review.
|
||||
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 243 KiB |
@@ -0,0 +1,78 @@
|
||||
// Used only in an isolated simulator copy by capture.sh. Never linked into the shipping app.
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor enum StoreCapture {
|
||||
static let screen: String = {
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
guard let i = args.firstIndex(of: "--capture"), args.indices.contains(i + 1) else { return "home" }
|
||||
return args[i + 1]
|
||||
}()
|
||||
static var section: String {
|
||||
switch screen { case "library", "details", "files": return "Library"; case "settings": return "Settings"; default: return "Home" }
|
||||
}
|
||||
static var path: [Torrent] { ["details", "files"].contains(screen) ? [torrents[0]] : [] }
|
||||
static let torrents = [
|
||||
Torrent(hash: "sample-open-media", name: "Open media collection", state: "downloading", progress: 0.64, size: 2_400_000_000, dlspeed: 4_800_000, upspeed: 680_000, eta: 180, ratio: 0.42, category: "Open media", tags: "Creative Commons"),
|
||||
Torrent(hash: "sample-audio", name: "Creative Commons audio", state: "downloading", progress: 0.28, size: 680_000_000, dlspeed: 1_600_000, upspeed: 140_000, eta: 306, ratio: 0.18, category: "Audio", tags: "Open licence"),
|
||||
Torrent(hash: "sample-toolkit", name: "Open source toolkit", state: "uploading", progress: 1, size: 1_800_000_000, dlspeed: 0, upspeed: 320_000, eta: 8_640_000, ratio: 2.16, category: "Software", tags: "Open source"),
|
||||
Torrent(hash: "sample-archive", name: "Public domain archive", state: "stoppedDL", progress: 0.81, size: 3_200_000_000, dlspeed: 0, upspeed: 0, eta: 8_640_000, ratio: 0.62, category: "Archive", tags: "Public domain"),
|
||||
Torrent(hash: "sample-course", name: "Open education resources", state: "queuedDL", progress: 0, size: 950_000_000, dlspeed: 0, upspeed: 0, eta: 8_640_000, ratio: 0, category: "Learning", tags: "Open licence")
|
||||
]
|
||||
static var history: TransferHistory {
|
||||
var h = TransferHistory()
|
||||
let now = Date()
|
||||
for i in 0...300 {
|
||||
let download = Int64(6_400_000 + 2_800_000 * sin(Double(i) / 11) + 900_000 * cos(Double(i) / 4))
|
||||
let upload = Int64(1_100_000 + 480_000 * cos(Double(i) / 15))
|
||||
h.record(download: download, upload: upload, at: now.addingTimeInterval(Double(i - 300) * 3))
|
||||
}
|
||||
return h
|
||||
}
|
||||
static let stats: [String: Any] = ["dl_info_speed": 6_400_000, "up_info_speed": 1_140_000, "free_space_on_disk": 842_000_000_000, "dl_info_data": 32_500_000_000, "up_info_data": 48_800_000_000, "connection_status": "connected", "dht_nodes": 284, "alltime_dl": 405_000_000_000, "alltime_ul": 620_000_000_000]
|
||||
static let preferences: [String: Any] = ["save_path": "/downloads/complete", "temp_path_enabled": true, "temp_path": "/downloads/incomplete", "preallocate_all": false, "listen_port": 6881, "upnp": false, "proxy_type": 0, "dl_limit": 0, "up_limit": 0, "alt_dl_limit": 2_000_000, "scheduler_enabled": false, "dht": true, "pex": true, "lsd": true, "max_ratio": 2.0, "queueing_enabled": true, "max_active_downloads": 4, "rss_processing_enabled": false, "web_ui_port": 8080, "web_ui_csrf_protection_enabled": true, "web_ui_host_header_validation_enabled": true, "async_io_threads": 10]
|
||||
static let files: [[String: Any]] = [
|
||||
["index": 0, "name": "Open media collection/Welcome.mp4", "size": 480_000_000, "progress": 1.0, "priority": 7],
|
||||
["index": 1, "name": "Open media collection/Chapter 01.mp4", "size": 720_000_000, "progress": 0.82, "priority": 6],
|
||||
["index": 2, "name": "Open media collection/Chapter 02.mp4", "size": 700_000_000, "progress": 0.48, "priority": 1],
|
||||
["index": 3, "name": "Open media collection/Chapter 03.mp4", "size": 499_980_000, "progress": 0.12, "priority": 1],
|
||||
["index": 4, "name": "Open media collection/LICENCE.txt", "size": 12_000, "progress": 1.0, "priority": 1],
|
||||
["index": 5, "name": "Open media collection/README.txt", "size": 8_000, "progress": 1.0, "priority": 1]
|
||||
]
|
||||
static func model() -> AppModel {
|
||||
let m = AppModel()
|
||||
m.credentials = Credentials(server: "https://example.invalid", username: "sample-user")
|
||||
m.biometric = false; m.locked = false; m.connected = true
|
||||
m.version = "5.1.2"; m.apiVersion = "2.11.4"; m.torrents = torrents
|
||||
m.stats = stats; m.lastRefresh = Date()
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [StoreCaptureProtocol.self]
|
||||
m.client = try! Transport(m.credentials, configuration: config)
|
||||
return m
|
||||
}
|
||||
}
|
||||
final class StoreCaptureProtocol: URLProtocol, @unchecked Sendable {
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
// Responses are local fixture data. There is no connection to a real torrent server.
|
||||
Task { @MainActor in
|
||||
let path = request.url!.path
|
||||
let object: Any
|
||||
if path.hasSuffix("app/preferences") { object = StoreCapture.preferences }
|
||||
else if path.hasSuffix("torrents/files") { object = StoreCapture.files }
|
||||
else if path.hasSuffix("torrents/properties") { object = ["save_path": "/downloads/complete", "total_size": 2_400_000_000, "pieces_num": 1145, "pieces_have": 733, "seeds": 42, "peers": 18, "time_elapsed": 540, "total_downloaded": 1_536_000_000, "total_uploaded": 645_120_000] }
|
||||
else if path.hasSuffix("torrents/trackers") { object = [["url": "udp://tracker.example.invalid:6969/announce", "status": 2, "num_peers": 18, "num_seeds": 42]] }
|
||||
else if path.hasSuffix("sync/torrentPeers") { object = ["peers": [:]] }
|
||||
else if path.hasSuffix("transfer/info") { object = StoreCapture.stats }
|
||||
else if path.hasSuffix("app/buildInfo") { object = ["qt": "6.8", "libtorrent": "2.0", "openssl": "3.4", "bitness": 64] }
|
||||
else if path.hasSuffix("sync/maindata") { object = ["server_state": StoreCapture.stats] }
|
||||
else { object = [] }
|
||||
let data = try! JSONSerialization.data(withJSONObject: object)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT/scripts/common.sh"
|
||||
CAPTURE="$TARNAX_SCRATCH_ROOT/appstore-capture"
|
||||
DERIVED="$TARNAX_SCRATCH_ROOT/appstore-capture-derived"
|
||||
python3 "$ROOT/appstore/tools/prepare_capture.py" "$CAPTURE"
|
||||
xcodebuild -project "$CAPTURE/Tarnax.xcodeproj" -scheme Tarnax -configuration Debug \
|
||||
-destination 'generic/platform=iOS Simulator' -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO build > "$TARNAX_SCRATCH_ROOT/appstore-capture-build.log" 2>&1
|
||||
APP="$DERIVED/Build/Products/Debug-iphonesimulator/Tarnax.app"
|
||||
BUNDLE=net.absdev.TarnaxStoreCapture
|
||||
PHONE="${APPSTORE_IPHONE:-AC08A512-DBE5-4209-9D65-CE5810F3C884}"
|
||||
TABLET="${APPSTORE_IPAD:-5F4168B7-8BB8-4867-B638-72E033754B5D}"
|
||||
for kind in iphone-6.9 ipad-13; do
|
||||
if [[ "$kind" == iphone-6.9 ]]; then DEVICE="$PHONE"; else DEVICE="$TABLET"; fi
|
||||
if ! xcrun simctl list devices booted | grep -q "$DEVICE"; then xcrun simctl boot "$DEVICE"; fi
|
||||
xcrun simctl bootstatus "$DEVICE" -b
|
||||
xcrun simctl install "$DEVICE" "$APP"
|
||||
xcrun simctl status_bar "$DEVICE" override --time '9:41' --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100
|
||||
xcrun simctl ui "$DEVICE" content_size large
|
||||
mkdir -p "$ROOT/appstore/screenshots/raw/$kind" "$TARNAX_SCRATCH_ROOT/appstore-video/$kind"
|
||||
n=0
|
||||
for screen in home library details files settings add; do
|
||||
n=$((n+1))
|
||||
if [[ "$screen" == home || "$screen" == files ]]; then MODE=dark; else MODE=light; fi
|
||||
xcrun simctl ui "$DEVICE" appearance "$MODE"
|
||||
xcrun simctl launch --terminate-running-process "$DEVICE" "$BUNDLE" --capture "$screen"
|
||||
sleep 3
|
||||
NAME="$(printf '%02d' "$n")-$screen"
|
||||
xcrun simctl io "$DEVICE" screenshot "$ROOT/appstore/screenshots/raw/$kind/$NAME.png"
|
||||
if [[ "${CAPTURE_VIDEO:-1}" == 1 ]]; then
|
||||
xcrun simctl io "$DEVICE" recordVideo --codec=h264 --force "$TARNAX_SCRATCH_ROOT/appstore-video/$kind/$NAME.mov" >/dev/null 2>&1 &
|
||||
RECORDER=$!
|
||||
sleep 5
|
||||
kill -INT "$RECORDER"
|
||||
wait "$RECORDER" || true
|
||||
fi
|
||||
done
|
||||
xcrun simctl status_bar "$DEVICE" clear
|
||||
xcrun simctl ui "$DEVICE" appearance light
|
||||
echo "Captured $kind"
|
||||
done
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create an isolated simulator project using the real UI and local fictitious data."""
|
||||
from pathlib import Path
|
||||
import shutil, sys
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
dest = Path(sys.argv[1]).resolve()
|
||||
if dest == root or root in dest.parents:
|
||||
raise SystemExit('Capture project must be outside the production project, in Scratch.')
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for name in ['Tarnax', 'Tarnax.xcodeproj']:
|
||||
shutil.copytree(root/name, dest/name, dirs_exist_ok=True)
|
||||
p = dest/'Tarnax/App.swift'
|
||||
s = p.read_text().replace('@StateObject private var model = AppModel()', '@StateObject private var model = StoreCapture.model()', 1)
|
||||
s = s.replace('@SceneStorage("selectedSection") private var selected = "Home"', '@State private var selected = StoreCapture.section\n @State private var capturePath = StoreCapture.path', 1)
|
||||
s = s.replace('@State private var add = false', '@State private var add = StoreCapture.screen == "add"', 1)
|
||||
s = s.replace('NavigationStack { section(page) }', 'NavigationStack(path: $capturePath) { section(page) }')
|
||||
s = s.replace('NavigationStack { section(selected) }', 'NavigationStack(path: $capturePath) { section(selected) }')
|
||||
s = s.replace('guard phase == .active, model.connected, !model.locked else { return }', 'guard false else { return }', 1)
|
||||
s += '\n' + (root/'appstore/tools/CaptureFixture.swift').read_text()
|
||||
p.write_text(s)
|
||||
p = dest/'Tarnax/Core.swift'
|
||||
s = p.read_text().replace('@Published private(set) var transferHistory = TransferHistory()', '@Published private(set) var transferHistory = StoreCapture.history', 1)
|
||||
s = s.replace('@Published private(set) var preferences: [String: Any] = [:]', '@Published private(set) var preferences: [String: Any] = StoreCapture.preferences', 1)
|
||||
p.write_text(s)
|
||||
p = dest/'Tarnax/Details.swift'
|
||||
s = p.read_text().replace('@State private var tab = "Overview"', '@State private var tab = StoreCapture.screen == "files" ? "Files" : "Overview"', 1)
|
||||
p.write_text(s)
|
||||
p = dest/'Tarnax.xcodeproj/project.pbxproj'
|
||||
p.write_text(p.read_text().replace('net.absdev.Tarnax', 'net.absdev.TarnaxStoreCapture'))
|
||||
print(dest/'Tarnax.xcodeproj')
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lay out App Store artwork around untouched real-app screenshots. Requires Pillow."""
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
import json, math, shutil
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FONT = Path('/System/Library/Fonts/Supplemental')
|
||||
def font(size, bold=False):
|
||||
return ImageFont.truetype(str(FONT / ('Arial Bold.ttf' if bold else 'Arial.ttf')), round(size))
|
||||
|
||||
def backdrop(w, h, accent):
|
||||
small = Image.new('RGB', (220, max(1, round(h*220/w))))
|
||||
pix = small.load()
|
||||
for y in range(small.height):
|
||||
for x in range(small.width):
|
||||
glow = math.exp(-(((x/small.width-.85)/.65)**2+((y/small.height-.32)/.35)**2))
|
||||
pix[x,y] = tuple(round(base+glow*(a-base)*.45) for base,a in zip((5,9,25),accent))
|
||||
return small.resize((w,h), Image.Resampling.BICUBIC)
|
||||
|
||||
def rounded_paste(canvas, screen, box):
|
||||
x,y,w,h=map(round,box)
|
||||
radius=round(w*.065)
|
||||
shadow=Image.new('RGBA',canvas.size)
|
||||
ImageDraw.Draw(shadow).rounded_rectangle((x-8,y+12,x+w+8,y+h+20),radius,fill=(0,0,0,180))
|
||||
shadow=shadow.filter(ImageFilter.GaussianBlur(max(12,w*.025)))
|
||||
canvas=Image.alpha_composite(canvas.convert('RGBA'),shadow)
|
||||
draw=ImageDraw.Draw(canvas)
|
||||
draw.rounded_rectangle((x-8,y-8,x+w+8,y+h+8),radius+8,fill=(92,106,149,255))
|
||||
screen=screen.convert('RGB').resize((w,h),Image.Resampling.LANCZOS)
|
||||
mask=Image.new('L',(w,h));ImageDraw.Draw(mask).rounded_rectangle((0,0,w-1,h-1),radius,fill=255)
|
||||
canvas.paste(screen,(x,y),mask)
|
||||
return canvas.convert('RGB')
|
||||
|
||||
SCENES=[
|
||||
('01-home','Your server.','At a glance.','Live speeds. Clear history. Total control.',(104,78,255)),
|
||||
('02-library','Every transfer.','Beautifully organised.','Search, filter and manage your whole library.',(0,170,184)),
|
||||
('03-details','Take control.','Keep the detail.','Pause, resume and inspect every transfer.',(60,113,255)),
|
||||
('04-files','Choose what','matters.','Set file priorities from wherever you are.',(0,180,170)),
|
||||
('05-settings','Your server.','Your settings.','Browse and adjust the options your server exposes.',(94,88,245)),
|
||||
('06-add','Add a link.','Let your server work.','Magnet links and .torrent files. In one place.',(154,74,230)),
|
||||
]
|
||||
icon=Image.open(ROOT/'icons/Tarnax-AppStore-1024.png').convert('RGB')
|
||||
for size in [512,256,180,128,64,32]:
|
||||
icon.resize((size,size),Image.Resampling.LANCZOS).save(ROOT/f'icons/Tarnax-{size}.png')
|
||||
manifest=[]
|
||||
for device in ['iphone-6.9','ipad-13']:
|
||||
for name,line1,line2,sub,accent in SCENES:
|
||||
src=ROOT/f'screenshots/raw/{device}/{name}.png'
|
||||
raw=Image.open(src).convert('RGB')
|
||||
w,h=raw.size
|
||||
canvas=backdrop(w,h,accent)
|
||||
d=ImageDraw.Draw(canvas)
|
||||
ipad=device=='ipad-13'
|
||||
pad=round(w*.075)
|
||||
logo=round(w*.045)
|
||||
canvas.paste(icon.resize((logo,logo),Image.Resampling.LANCZOS),(pad,round(h*.027)))
|
||||
d=ImageDraw.Draw(canvas)
|
||||
d.text((pad+logo+round(w*.015),round(h*.027)), 'Tarnax',font=font(w*.035,True),fill='white')
|
||||
title_size=w*(.068 if not ipad else .062)
|
||||
title_font=font(title_size,True)
|
||||
while max(d.textlength(line1,font=title_font),d.textlength(line2,font=title_font))>w-2*pad:
|
||||
title_size-=1;title_font=font(title_size,True)
|
||||
ty=round(h*.067)
|
||||
d.text((pad,ty),line1,font=title_font,fill='white')
|
||||
d.text((pad,ty+title_size*1.15),line2,font=title_font,fill=(170,185,255) if accent[2]>200 else (100,230,222))
|
||||
sub_font=font(w*.025)
|
||||
while d.textlength(sub,font=sub_font)>w-2*pad:sub_font=font(sub_font.size-1)
|
||||
d.text((pad,ty+title_size*2.55),sub,font=sub_font,fill=(202,211,233))
|
||||
sw=round(w*(.74 if ipad else .78));sh=round(sw*raw.height/raw.width)
|
||||
sy=round(h*(.225 if ipad else .185))
|
||||
if sy+sh>h*.968:
|
||||
sh=round(h*.968-sy);sw=round(sh*raw.width/raw.height)
|
||||
canvas=rounded_paste(canvas,raw,((w-sw)//2,sy,sw,sh))
|
||||
d=ImageDraw.Draw(canvas)
|
||||
footer='Requires your own qBittorrent server'
|
||||
ff=font(w*.018)
|
||||
d.text(((w-d.textlength(footer,font=ff))/2,h*.982),footer,font=ff,fill=(158,172,203),anchor='mm')
|
||||
dst=ROOT/f'screenshots/upload/{device}/{name}.png';canvas.save(dst,optimize=True)
|
||||
manifest.append({'file':str(dst.relative_to(ROOT)),'width':w,'height':h,'scene':name,'appearance':'dark' if name in ['01-home','04-files'] else 'light','source':str(src.relative_to(ROOT))})
|
||||
# Press/social assets, deliberately separate from store-upload screenshots.
|
||||
for name,w,h in [('social-landscape',1200,630),('social-square',1080,1080),('press-header',2400,1200)]:
|
||||
canvas=backdrop(w,h,(110,70,245));d=ImageDraw.Draw(canvas)
|
||||
pad=round(w*.07);size=round(w*.15)
|
||||
canvas.paste(icon.resize((size,size),Image.Resampling.LANCZOS),(pad,round(h*.11)))
|
||||
d=ImageDraw.Draw(canvas)
|
||||
d.text((pad,round(h*.11)+size+round(h*.055)),'Your server.',font=font(w*.068,True),fill='white')
|
||||
d.text((pad,round(h*.11)+size+round(h*.055)+w*.078),'Your control.',font=font(w*.068,True),fill=(169,183,255))
|
||||
d.text((pad,h*.86),'Tarnax · Remote for qBittorrent',font=font(w*.025),fill=(204,215,239))
|
||||
if w/h>1.6:
|
||||
screen=Image.open(ROOT/'screenshots/raw/iphone-6.9/01-home.png')
|
||||
sh=round(h*.85);sw=round(sh*screen.width/screen.height)
|
||||
canvas=rounded_paste(canvas,screen,(w-sw-pad,h*.075,sw,sh))
|
||||
canvas.save(ROOT/f'marketing/{name}.png',optimize=True)
|
||||
# Asset contact sheets for convenient visual QA.
|
||||
for device in ['iphone-6.9','ipad-13']:
|
||||
thumbs=[]
|
||||
for scene,*_ in SCENES:
|
||||
im=Image.open(ROOT/f'screenshots/upload/{device}/{scene}.png').convert('RGB');im.thumbnail((330,640));thumbs.append(im.copy())
|
||||
sheet=Image.new('RGB',(3*350,2*680),(15,20,38))
|
||||
for i,im in enumerate(thumbs):sheet.paste(im,(i%3*350+10,i//3*680+10))
|
||||
sheet.save(ROOT/f'marketing/contact-sheet-{device}.jpg',quality=92)
|
||||
(ROOT/'asset-manifest.json').write_text(json.dumps({'screenshots':manifest,'sample_data':True,'production_fixture_included':False},indent=2)+'\n')
|
||||
assets=ROOT/'website/assets';assets.mkdir(exist_ok=True)
|
||||
shutil.copy2(ROOT/'icons/Tarnax-180.png',assets/'apple-touch-icon.png')
|
||||
shutil.copy2(ROOT/'icons/Tarnax-32.png',assets/'favicon.png')
|
||||
shutil.copy2(ROOT/'icons/Tarnax-AppStore-1024.png',assets/'icon.png')
|
||||
shutil.copy2(ROOT/'marketing/social-landscape.png',assets/'social.png')
|
||||
print('Created',len(manifest),'upload screenshots, icon derivatives, marketing images and contact sheets.')
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -82,3 +82,11 @@ Build 3 installed and launched on the paired iPhone; user confirmed Face ID unlo
|
||||
Build 4 hides the lock fallback during normal launch and Face ID evaluation, showing the dark Tarnax branded view instead. Cancelled, failed or incomplete authentication offers Unlock; retry immediately returns to branding. No protected content is shown before authentication. 71 executable contract checks and profile validation tests passed; signed Release built, installed and launched successfully on the iPhone.
|
||||
|
||||
Deploy script validates exact and wildcard Apple App IDs, checks device registration and expiry, and separates installation success from launch failure on a locked phone. Script-controlled temporary files and DerivedData now use ~/Developer/Scratch/Tarnax. Final build products remain under build/.
|
||||
|
||||
## Live transfer history — 4 November 2026
|
||||
|
||||
Added bounded session history to dashboard refreshes, with 1-, 5- and 15-minute windows, separate upload/download series, chart selection and gaps after polling interruptions. History resets on connection replacement or disconnect; failed refreshes never fabricate samples. 83 contract checks passed, including timestamp boundary, retention, coalescing, capacity and refresh integration checks.
|
||||
|
||||
Build 5 crashed on the physical iPhone: the retrieved report and matching executable identified a null weak-linked Foundation Date >= call in chart filtering. Build 6 uses numeric timestamp comparisons instead; inspection confirmed no weak-linked Date >= or <= entry points remain. User confirmed Home and graph work on the iPhone after build 6 installation.
|
||||
|
||||
Build 7 adds blue/indigo/violet download strokes, teal dashed upload strokes, independent translucent gradient fills, endpoint/selection dots, a soft plot background and coordinated speed values. Signed Release build passed and binary compatibility inspection passed. Installed on the iPhone; automatic launch was blocked because the phone was locked. No new network or sampling behavior was introduced by the colour change.
|
||||
|
||||