new app store assets ready for relese

This commit is contained in:
Mark Gregory
2026-11-04 16:09:22 +00:00
parent 1a50eb762d
commit c217b0dfc8
36 changed files with 372 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT/scripts/common.sh"
PROVISIONING=()
case "${1:-}" in
--allow-provisioning-updates) PROVISIONING=(-allowProvisioningUpdates) ;;
'') ;;
-h|--help) echo 'Usage: appstore/tools/archive.sh [--allow-provisioning-updates]'; echo 'Creates an App Store archive and locally exports an IPA. Never uploads.'; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
[[ $# -le 1 ]] || { echo 'Too many arguments' >&2; exit 2; }
OUTPUT="$(mktemp -d "$ROOT/appstore/build/$(date +%Y%m%d-%H%M%S).XXXXXX")"
DERIVED="$(mktemp -d "$TARNAX_SCRATCH_ROOT/appstore-derived.XXXXXX")"
echo "Output: $OUTPUT"
xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax -configuration Release \
-destination 'generic/platform=iOS' -derivedDataPath "$DERIVED" \
-archivePath "$OUTPUT/Tarnax.xcarchive" \
${PROVISIONING[@]+"${PROVISIONING[@]}"} archive 2>&1 | tee "$OUTPUT/archive.log"
APP="$OUTPUT/Tarnax.xcarchive/Products/Applications/Tarnax.app"
[[ -f "$APP/PrivacyInfo.xcprivacy" ]] || { echo 'Privacy manifest missing from archive' >&2; exit 1; }
plutil -lint "$APP/PrivacyInfo.xcprivacy"
xcrun nm -m "$APP/Tarnax" > "$DERIVED/symbols.txt"
if grep -E 'weak external.*Foundation4DateV2(ge|le)oiy' "$DERIVED/symbols.txt"; then
echo 'Incompatible Foundation Date comparison entry point detected' >&2; exit 1
fi
if grep -E 'StoreCapture|CaptureFixture' "$DERIVED/symbols.txt"; then
echo 'Screenshot fixture leaked into production archive' >&2; exit 1
fi
xcodebuild -exportArchive -archivePath "$OUTPUT/Tarnax.xcarchive" \
-exportPath "$OUTPUT/export" -exportOptionsPlist "$ROOT/appstore/build/ExportOptions-AppStore.plist" \
${PROVISIONING[@]+"${PROVISIONING[@]}"} 2>&1 | tee "$OUTPUT/export.log"
[[ -f "$OUTPUT/export/Tarnax.ipa" ]] || { echo 'IPA export missing' >&2; exit 1; }
VERIFY="$(mktemp -d "$TARNAX_SCRATCH_ROOT/appstore-verify.XXXXXX")"
trap 'rm -rf "$VERIFY"' EXIT
unzip -q "$OUTPUT/export/Tarnax.ipa" -d "$VERIFY"
codesign --verify --deep --strict "$VERIFY/Payload/Tarnax.app"
security cms -D -i "$VERIFY/Payload/Tarnax.app/embedded.mobileprovision" > "$VERIFY/profile.plist"
python3 - "$VERIFY" "$ROOT/appstore/build/latest.json" "$OUTPUT" <<'PY'
from pathlib import Path
import json,plistlib,sys,hashlib,datetime
root=Path(sys.argv[1]);app=root/'Payload/Tarnax.app'
with (app/'Info.plist').open('rb') as f:info=plistlib.load(f)
with (root/'profile.plist').open('rb') as f:profile=plistlib.load(f)
assert info['CFBundleIdentifier']=='net.absdev.Tarnax'
assert not profile.get('ProvisionedDevices'), 'Device-limited profile is not App Store distribution'
assert not profile.get('ProvisionsAllDevices'), 'Enterprise profile is not App Store distribution'
assert not profile['Entitlements'].get('get-task-allow',False), 'Debugging enabled'
assert profile['ExpirationDate']>datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
output=Path(sys.argv[3]);ipa=output/'export/Tarnax.ipa'
checksum=hashlib.sha256(ipa.read_bytes()).hexdigest()
(output/'export/Tarnax.ipa.sha256').write_text(checksum+' Tarnax.ipa\n')
Path(sys.argv[2]).write_text(json.dumps({'version':info['CFBundleShortVersionString'],'build':info['CFBundleVersion'],'archive':str(output/'Tarnax.xcarchive'),'ipa':str(ipa),'sha256':checksum,'distribution':'app-store-connect','uploaded':False},indent=2)+'\n')
print('Verified App Store IPA:',ipa)
PY
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Build static, tracker-free support and policy pages ready for HTTPS hosting."""
from pathlib import Path
import html,re
ROOT=Path(__file__).resolve().parents[1]
SITE=ROOT/'website'
def markdown(text):
blocks=[];inlist=False
for line in text.splitlines():
if not line.strip():
if inlist:blocks.append('</ul>');inlist=False
continue
line=html.escape(line)
line=re.sub(r'(https://[^\s<]+)',r'<a href="\1">\1</a>',line)
if line.startswith('# '):blocks.append('<h1>'+line[2:]+'</h1>')
elif line.startswith('## '):blocks.append('<h2>'+line[3:]+'</h2>')
elif line.startswith('- '):
if not inlist:blocks.append('<ul>');inlist=True
blocks.append('<li>'+line[2:]+'</li>')
else:blocks.append('<p>'+line+'</p>')
if inlist:blocks.append('</ul>')
return '\n'.join(blocks)
def page(title,content):
return '''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Tarnax — native remote control for your qBittorrent server. Support and privacy information."><title>'''+html.escape(title)+''' · Tarnax</title><link rel="icon" href="assets/favicon.png"><link rel="apple-touch-icon" href="assets/apple-touch-icon.png"><link rel="stylesheet" href="style.css"></head><body><header><a class="brand" href="index.html"><img src="assets/icon.png" alt="" width="44" height="44">Tarnax</a><nav aria-label="Main navigation"><a href="support.html">Support</a><a href="privacy.html">Privacy</a><a href="terms.html">Usage</a></nav></header><main>'''+content+'''</main><footer>© 2026 Mark Gregory · <a href="mailto:mark.gregory@abbeysoftware.com">mark.gregory@abbeysoftware.com</a><p>Independent software. Not affiliated with or endorsed by the qBittorrent project.</p></footer></body></html>'''
SITE.mkdir(exist_ok=True)
SITE.joinpath('style.css').write_text('''*{box-sizing:border-box}html{color-scheme:dark}body{margin:0;background:#070b1b;color:#edf1fc;font:18px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header,main,footer{max-width:1060px;margin:auto;padding:30px clamp(20px,5vw,64px)}header{display:flex;justify-content:space-between;align-items:center;gap:20px;border-bottom:1px solid #242e4a}.brand{display:flex;align-items:center;gap:14px;color:white;font-size:25px;font-weight:700;text-decoration:none}.brand img{border-radius:10px}nav{display:flex;gap:24px}a{color:#a9bdff;text-underline-offset:4px}a:hover{color:#fff}a:focus-visible{outline:3px solid #25c7ce;outline-offset:6px}h1{font-size:clamp(36px,6vw,64px);line-height:1.12;letter-spacing:-1.8px;margin:30px 0}h2{font-size:26px;line-height:1.3;margin-top:45px}p,li{max-width:76ch;color:#c8d2e8}.eyebrow{color:#67e3dd;font-size:14px;font-weight:700;letter-spacing:2px;text-transform:uppercase}.hero{padding:30px 0 50px}.hero img{width:140px;border-radius:32px}.hero strong{color:#c0c9ff}.button{display:inline-block;background:#7763ff;color:white;text-decoration:none;padding:12px 23px;border-radius:12px;font-weight:650}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}.card{background:#111a31;border:1px solid #253253;border-radius:20px;padding:25px}.card h2{margin-top:0}.note{padding:20px 25px;border-left:3px solid #20bdc4;background:#101b2d}footer{font-size:14px;border-top:1px solid #242e4a;margin-top:40px}footer p{font-size:13px}details{border-bottom:1px solid #253253;padding:20px 0}summary{cursor:pointer;font-weight:650;color:white}@media(max-width:600px){header{align-items:flex-start;flex-direction:column}nav{font-size:16px}body{font-size:17px}}''')
home='''<section class="hero"><img src="assets/icon.png" alt="Tarnax app icon"><p class="eyebrow">Remote for qBittorrent</p><h1>Your server.<br><strong>Your control.</strong></h1><p>A native iPhone and iPad remote for your own qBittorrent server. Follow live transfers, organise your library and manage the details from wherever you are.</p><a class="button" href="support.html">Get support</a></section><section class="cards"><article class="card"><h2>See the flow</h2><p>Colourful upload and download graphs, live speeds and the server space you need to know about.</p></article><article class="card"><h2>Manage the details</h2><p>Search and filter your transfers, control file priorities and edit your server’s preferences.</p></article><article class="card"><h2>Your connection</h2><p>Trusted HTTPS, device Keychain credentials and optional Face ID or device authentication.</p></article></section><p class="note">Requires your own qBittorrent server with its Web UI API enabled and a trusted HTTPS connection. Torrent contents stay on your server. Use only content you are authorised to access and share.</p>'''
SITE.joinpath('index.html').write_text(page('Your server. Your control.',home))
support='''<h1>How can we help?</h1><p>For Tarnax support, email <a href="mailto:mark.gregory@abbeysoftware.com">mark.gregory@abbeysoftware.com</a>. Please include the Tarnax version/build, iPhone or iPad model, iOS version, qBittorrent version, and what happened.</p><p class="note">Never send passwords, private magnet links or sensitive server contents. Redact identifying information from screenshots.</p><h2>Quick answers</h2>'''
faqs=[('What do I need?','An existing qBittorrent server, Web UI API access, valid credentials and a trusted HTTPS endpoint. Tarnax does not provide the server, content catalogue or a VPN.'),('Why does my connection fail?','Check the HTTPS hostname, certificate chain and reverse-proxy subpath. HTTP 401 usually means proxy authentication; HTTP 403 may indicate session expiry, access rules or server protection. Use Disconnect/change server to reconnect after correcting the configuration.'),('Does Tarnax download to my phone?','No. It sends commands and selected .torrent files to your server. Torrent contents remain on that server.'),('Why does the graph start empty?','History records transfer samples while Tarnax is active and unlocked. It is session history, not a download-history database. Wait for a few updates; background pauses appear as gaps.'),('How do I retry Face ID?','Normal unlocking happens automatically. If authentication is cancelled or interrupted, tap Unlock to retry using Face ID or the device passcode fallback. Check that the app is allowed to use Face ID in iOS Settings.'),('How do I remove saved credentials?','Open Tarnax Settings and choose Forget saved credentials. iOS may retain Keychain data after deleting an app, so use this action before uninstalling if you want to remove the saved connection.'),('Can I edit every server option?','Tarnax exposes preferences returned by your server. Available settings and commands depend on qBittorrent’s version and configuration. Network, authentication and storage changes may interrupt access or move data.'),('Why don’t transfers update in the background?','This version refreshes while the app is active and unlocked. iOS background execution is not requested.'),('Where is the privacy policy?','Open Privacy in the navigation above, or Settings → Privacy policy inside Tarnax.')]
for q,a in faqs:support+='<details><summary>'+html.escape(q)+'</summary><p>'+html.escape(a)+'</p></details>'
SITE.joinpath('support.html').write_text(page('Support',support))
privacy=markdown((ROOT/'legal/privacy-policy.md').read_text())+'<h2>This website</h2><p>These supplied pages contain no analytics scripts, advertising or cookies. The hosting provider you use may process technical request logs for operating and securing the site. Confirm the final hosting arrangement before publishing this policy.</p>'
SITE.joinpath('privacy.html').write_text(page('Privacy',privacy))
SITE.joinpath('terms.html').write_text(page('Usage',markdown((ROOT/'legal/terms-of-use.md').read_text())))
print('Built four static support/marketing/policy pages; no publication performed.')
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Create 24-second previews solely from actual simulator screen-recording footage."""
from pathlib import Path
import json,subprocess,os
ROOT=Path(__file__).resolve().parents[1]
SCRATCH=Path(os.environ.get('TARNAX_SCRATCH_ROOT',str(Path.home()/'Developer/Scratch/Tarnax')))
SCENES=['01-home','02-library','03-details','04-files','05-settings','06-add']
metadata=[]
for device,size in [('iphone-6.9',(886,1920)),('ipad-13',(1200,1600))]:
w,h=size
work=SCRATCH/'appstore-video'/device
encoded=[]
for scene in SCENES:
src=work/(scene+'.mov');out=work/(scene+'-encoded.mp4')
vf=f'tpad=stop_mode=clone:stop_duration=4,scale={w}:{h}:force_original_aspect_ratio=decrease:force_divisible_by=2,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=black,fps=30,setsar=1,format=yuv420p'
cmd=['ffmpeg','-y','-v','error','-ss','0','-i',str(src),'-f','lavfi','-i','anullsrc=r=48000:cl=stereo','-t','4','-vf',vf,'-c:v','libx264','-preset','fast','-profile:v','high','-level:v','4.0','-b:v','10M','-maxrate','12M','-bufsize','20M','-pix_fmt','yuv420p','-color_primaries','bt709','-color_trc','bt709','-colorspace','bt709','-c:a','aac','-b:a','256k','-ar','48000','-ac','2','-movflags','+faststart',str(out)]
subprocess.run(cmd,check=True);encoded.append(out)
playlist=work/'concat.txt';playlist.write_text(''.join("file '"+str(p).replace("'","'\\''")+"'\n" for p in encoded))
dst=ROOT/f'previews/Tarnax-{device}-preview.mp4'
subprocess.run(['ffmpeg','-y','-v','error','-f','concat','-safe','0','-i',str(playlist),'-c','copy','-movflags','+faststart',str(dst)],check=True)
poster=ROOT/f'previews/Tarnax-{device}-poster.png'
subprocess.run(['ffmpeg','-y','-v','error','-ss','1','-i',str(dst),'-frames:v','1',str(poster)],check=True)
metadata.append({'file':str(dst.relative_to(ROOT)),'width':w,'height':h,'duration_seconds':24,'fps':30,'codec':'H.264 High Level 4.0','audio':'AAC stereo 48kHz, silent','poster_frame_seconds':1,'footage':'Actual app UI captured in a simulator with fictitious sample data; no marketing artwork inserted.'})
(ROOT/'previews/preview-info.json').write_text(json.dumps(metadata,indent=2)+'\n')
print('Created iPhone and iPad 24-second App Preview files.')
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Validate local release assets; use --submission-ready to require owner setup too."""
from pathlib import Path
from PIL import Image,ImageStat
import json,subprocess,plistlib,hashlib,sys
R=Path(__file__).resolve().parents[1]
errors=[];checks=[]
def check(ok,label):
(checks if ok else errors).append(label)
for locale in ['en-GB','en-US']:
for name,limit in [('name',30),('subtitle',30),('promotional_text',170),('description',4000),('keywords',100)]:
value=(R/f'metadata/{locale}/{name}.txt').read_text().strip()
check(bool(value) and (len(value.encode()) if name=='keywords' else len(value))<=limit,f'{locale}/{name}: length within {limit}')
manifest=json.loads((R/'asset-manifest.json').read_text())
check(len(manifest['screenshots'])==12,'12 upload screenshots')
for item in manifest['screenshots']:
for name in [item['file'],item['source']]:
im=Image.open(R/name)
check(im.size==(item['width'],item['height']) and (im.mode=='RGB' or ('A' in im.getbands() and im.getchannel('A').getextrema()==(255,255))),f'{name}: dimensions and opacity')
check(max(ImageStat.Stat(im.resize((100,100))).stddev)>15,f'{name}: nonblank image')
im=Image.open(R/'icons/Tarnax-AppStore-1024.png')
check(im.size==(1024,1024) and im.mode=='RGB','App Store icon: opaque 1024 square')
for device,size in [('iphone-6.9',(886,1920)),('ipad-13',(1200,1600))]:
file=R/f'previews/Tarnax-{device}-preview.mp4'
if not file.exists(): check(False,f'{device}: preview exists');continue
data=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_streams','-show_format','-of','json',str(file)]))
video=next((s for s in data['streams'] if s['codec_type']=='video'),{})
audio=next((s for s in data['streams'] if s['codec_type']=='audio'),{})
check((video.get('width'),video.get('height'))==size and video.get('codec_name')=='h264' and video.get('level',999)<=40,f'{device}: video format')
check(15<=float(data['format']['duration'])<=30 and file.stat().st_size<500_000_000,f'{device}: duration and size')
check(audio.get('codec_name')=='aac' and audio.get('channels')==2 and audio.get('sample_rate')=='48000',f'{device}: audio format')
a=plistlib.loads((R/'legal/PrivacyInfo.xcprivacy').read_bytes())
check(a==plistlib.loads((R.parent/'Tarnax/PrivacyInfo.xcprivacy').read_bytes()),'Privacy manifest matches shipping source')
for name in ['index.html','support.html','privacy.html','terms.html','style.css']:
check((R/'website'/name).exists(),f'Website {name}')
config=json.loads((R/'release-config.json').read_text())
owner=[key for key in ['support_url','privacy_policy_url','price','territories'] if config[key] is None]
if not config['review_contact']['phone']:owner.append('review contact phone')
if not config['review_server']['https_url']:owner.append('dedicated reviewer server and credentials')
if not (R/'build/latest.json').exists():owner.append('App Store signed IPA export')
owner+=['Confirm naming rights, privacy answers, age rating, agreements and final physical-device QA']
report={'asset_checks_passed':len(checks),'asset_errors':errors,'submission_ready':not(errors or owner),'owner_actions':owner}
(R/'validation-report.json').write_text(json.dumps(report,indent=2)+'\n')
(R/'validation-report.md').write_text('# Release asset validation\n\n'+f'{len(checks)} checks passed. {len(errors)} asset errors.\n\n'+ '\n'.join('- '+v for v in errors)+'\n\n## Owner actions before submission\n\n'+'\n'.join('- '+v for v in owner)+'\n')
files=sorted(p for p in R.rglob('*') if p.is_file() and 'build' not in p.relative_to(R).parts and p.name not in ['SHA256SUMS'] and '__pycache__' not in p.parts)
(R/'SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest()+' '+str(p.relative_to(R))+'\n' for p in files))
print(json.dumps(report,indent=2))
sys.exit(1 if errors else 2 if '--submission-ready' in sys.argv and owner else 0)