new app store assets ready for relese
This commit is contained in:
@@ -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() {}
|
||||
}
|
||||
Executable
+42
@@ -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
|
||||
Executable
+30
@@ -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')
|
||||
Executable
+108
@@ -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.')
|
||||
Reference in New Issue
Block a user