new app store assets ready for relese

This commit is contained in:
Mark Gregory
2026-11-04 15:49:26 +00:00
parent 280a459377
commit fd42aad1de
68 changed files with 757 additions and 10 deletions
+78
View File
@@ -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() {}
}
+42
View File
@@ -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
+30
View File
@@ -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')
+108
View File
@@ -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.')