new app store assets ready for relese
This commit is contained in:
Executable
+55
@@ -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
|
||||
Executable
+38
@@ -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.')
|
||||
Executable
+25
@@ -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.')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user