shipstores 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shipstores/__init__.py +3 -0
- shipstores/apple.py +222 -0
- shipstores/apple_console.py +132 -0
- shipstores/apple_review.py +149 -0
- shipstores/browser.py +296 -0
- shipstores/config.py +101 -0
- shipstores/data_safety.py +129 -0
- shipstores/eas.py +107 -0
- shipstores/play.py +276 -0
- shipstores/play_console.py +361 -0
- shipstores/server.py +2090 -0
- shipstores/signing.py +105 -0
- shipstores-0.1.0.dist-info/METADATA +205 -0
- shipstores-0.1.0.dist-info/RECORD +17 -0
- shipstores-0.1.0.dist-info/WHEEL +4 -0
- shipstores-0.1.0.dist-info/entry_points.txt +2 -0
- shipstores-0.1.0.dist-info/licenses/LICENSE +21 -0
shipstores/__init__.py
ADDED
shipstores/apple.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""App Store Connect API client (JWT ES256)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from typing import Callable, TypeVar
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import jwt
|
|
15
|
+
|
|
16
|
+
from .config import AppleConfig, load_apple
|
|
17
|
+
|
|
18
|
+
BASE_URL = "https://api.appstoreconnect.apple.com"
|
|
19
|
+
TOKEN_TTL_SECONDS = 900 # Apple's maximum lifetime for API tokens
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AppleError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
RETRY_TENTATIVAS = 5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def with_retry(fn: Callable[[], T], what: str, tentativas: int = RETRY_TENTATIVAS) -> T:
|
|
31
|
+
"""Retry a network call with exponential backoff.
|
|
32
|
+
|
|
33
|
+
Screenshot uploads send ~1 MB files to Apple's CDN, and a connection dropped
|
|
34
|
+
mid-transfer is common. Without this, a single network failure leaves the
|
|
35
|
+
screenshot set half-uploaded.
|
|
36
|
+
"""
|
|
37
|
+
for n in range(1, tentativas + 1):
|
|
38
|
+
try:
|
|
39
|
+
return fn()
|
|
40
|
+
except (httpx.TransportError, httpx.HTTPStatusError) as exc:
|
|
41
|
+
if n == tentativas:
|
|
42
|
+
raise AppleError(f"{what} failed after {tentativas} attempts: {exc}") from exc
|
|
43
|
+
time.sleep(2**n)
|
|
44
|
+
raise AppleError(f"{what}: unreachable")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _token(cfg: AppleConfig) -> str:
|
|
48
|
+
now = int(time.time())
|
|
49
|
+
return jwt.encode(
|
|
50
|
+
{
|
|
51
|
+
"iss": cfg.issuer_id,
|
|
52
|
+
"iat": now,
|
|
53
|
+
"exp": now + TOKEN_TTL_SECONDS,
|
|
54
|
+
"aud": "appstoreconnect-v1",
|
|
55
|
+
},
|
|
56
|
+
cfg.private_key,
|
|
57
|
+
algorithm="ES256",
|
|
58
|
+
headers={"kid": cfg.key_id, "typ": "JWT"},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def request(
|
|
63
|
+
method: str,
|
|
64
|
+
path: str,
|
|
65
|
+
*,
|
|
66
|
+
params: dict[str, Any] | None = None,
|
|
67
|
+
json: dict[str, Any] | None = None,
|
|
68
|
+
cfg: AppleConfig | None = None,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
cfg = cfg or load_apple()
|
|
71
|
+
|
|
72
|
+
def chamar() -> httpx.Response:
|
|
73
|
+
resp = httpx.request(
|
|
74
|
+
method,
|
|
75
|
+
f"{BASE_URL}{path}",
|
|
76
|
+
headers={"Authorization": f"Bearer {_token(cfg)}"},
|
|
77
|
+
params=params,
|
|
78
|
+
json=json,
|
|
79
|
+
timeout=120,
|
|
80
|
+
)
|
|
81
|
+
# 5xx means instability on Apple's side: worth retrying. 4xx is a usage error.
|
|
82
|
+
if resp.status_code >= 500:
|
|
83
|
+
resp.raise_for_status()
|
|
84
|
+
return resp
|
|
85
|
+
|
|
86
|
+
resp = with_retry(chamar, f"ASC {method} {path}")
|
|
87
|
+
if resp.status_code >= 400:
|
|
88
|
+
detail = resp.text[:800]
|
|
89
|
+
raise AppleError(f"ASC {method} {path} -> HTTP {resp.status_code}: {detail}")
|
|
90
|
+
return resp.json() if resp.content else {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def upload_asset(
|
|
94
|
+
resource: str,
|
|
95
|
+
file_path: Path,
|
|
96
|
+
relationships: dict[str, Any],
|
|
97
|
+
*,
|
|
98
|
+
cfg: AppleConfig | None = None,
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Upload a file to App Store Connect and return the id of the created resource.
|
|
101
|
+
|
|
102
|
+
App Store Connect uploads happen in three steps: reserve the asset, send the
|
|
103
|
+
bytes to the URLs it returns, and commit with the checksum. Works for any
|
|
104
|
+
resource that follows this contract (version screenshots, subscription
|
|
105
|
+
review screenshots). Every step is retried — ~1 MB files sent to Apple's
|
|
106
|
+
CDN fail often.
|
|
107
|
+
"""
|
|
108
|
+
cfg = cfg or load_apple()
|
|
109
|
+
conteudo = file_path.read_bytes()
|
|
110
|
+
nome = file_path.name
|
|
111
|
+
|
|
112
|
+
def reservar() -> dict[str, Any]:
|
|
113
|
+
return request(
|
|
114
|
+
"POST",
|
|
115
|
+
f"/v1/{resource}",
|
|
116
|
+
json={
|
|
117
|
+
"data": {
|
|
118
|
+
"type": resource,
|
|
119
|
+
"attributes": {"fileName": nome, "fileSize": len(conteudo)},
|
|
120
|
+
"relationships": relationships,
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
cfg=cfg,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
reserva = reservar()
|
|
127
|
+
asset_id = reserva["data"]["id"]
|
|
128
|
+
|
|
129
|
+
for operacao in reserva["data"]["attributes"]["uploadOperations"]:
|
|
130
|
+
trecho = conteudo[operacao["offset"] : operacao["offset"] + operacao["length"]]
|
|
131
|
+
|
|
132
|
+
def enviar(op: dict[str, Any] = operacao, bloco: bytes = trecho) -> None:
|
|
133
|
+
resposta = httpx.request(
|
|
134
|
+
op["method"],
|
|
135
|
+
op["url"],
|
|
136
|
+
headers={h["name"]: h["value"] for h in op.get("requestHeaders", [])},
|
|
137
|
+
content=bloco,
|
|
138
|
+
timeout=600,
|
|
139
|
+
)
|
|
140
|
+
resposta.raise_for_status()
|
|
141
|
+
|
|
142
|
+
with_retry(enviar, f"upload {nome}")
|
|
143
|
+
|
|
144
|
+
def confirmar() -> None:
|
|
145
|
+
try:
|
|
146
|
+
request(
|
|
147
|
+
"PATCH",
|
|
148
|
+
f"/v1/{resource}/{asset_id}",
|
|
149
|
+
json={
|
|
150
|
+
"data": {
|
|
151
|
+
"type": resource,
|
|
152
|
+
"id": asset_id,
|
|
153
|
+
"attributes": {
|
|
154
|
+
"uploaded": True,
|
|
155
|
+
"sourceFileChecksum": hashlib.md5(conteudo).hexdigest(),
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
cfg=cfg,
|
|
160
|
+
)
|
|
161
|
+
except AppleError as exc:
|
|
162
|
+
# This PATCH is not idempotent: if the response was lost and the retry
|
|
163
|
+
# resent it, ASC answers 409 ("can't be re-committed"). When the
|
|
164
|
+
# asset is already COMPLETE, the upload succeeded — not an error.
|
|
165
|
+
if "HTTP 409" not in str(exc):
|
|
166
|
+
raise
|
|
167
|
+
estado = request("GET", f"/v1/{resource}/{asset_id}", cfg=cfg)
|
|
168
|
+
entrega = estado["data"]["attributes"].get("assetDeliveryState") or {}
|
|
169
|
+
if entrega.get("state") != "COMPLETE":
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
confirmar()
|
|
173
|
+
return asset_id
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def set_order(resource: str, set_id: str, child: str, ids: list[str]) -> None:
|
|
177
|
+
"""Pin the display order of a set (screenshots, previews).
|
|
178
|
+
|
|
179
|
+
Without this the order falls back to creation order, which Apple does not guarantee.
|
|
180
|
+
"""
|
|
181
|
+
cfg = load_apple()
|
|
182
|
+
|
|
183
|
+
def chamar() -> httpx.Response:
|
|
184
|
+
resp = httpx.patch(
|
|
185
|
+
f"{BASE_URL}/v1/{resource}/{set_id}/relationships/{child}",
|
|
186
|
+
headers={"Authorization": f"Bearer {_token(cfg)}"},
|
|
187
|
+
json={"data": [{"type": child, "id": i} for i in ids]},
|
|
188
|
+
timeout=120,
|
|
189
|
+
)
|
|
190
|
+
if resp.status_code >= 500:
|
|
191
|
+
resp.raise_for_status()
|
|
192
|
+
return resp
|
|
193
|
+
|
|
194
|
+
resp = with_retry(chamar, f"reorder {resource}")
|
|
195
|
+
if resp.status_code >= 400:
|
|
196
|
+
raise AppleError(f"reordering failed: HTTP {resp.status_code} {resp.text[:300]}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def upload_binary(ipa_path: Path, platform: str, cfg: AppleConfig) -> str:
|
|
200
|
+
"""Upload an .ipa via `xcrun altool`. Returns the command's combined output.
|
|
201
|
+
|
|
202
|
+
altool does not accept a key path as an argument, but it honors
|
|
203
|
+
API_PRIVATE_KEYS_DIR: point it straight at the .p8's folder, no copying or linking.
|
|
204
|
+
"""
|
|
205
|
+
env = {**os.environ, "API_PRIVATE_KEYS_DIR": str(cfg.private_key_path.parent)}
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
[
|
|
208
|
+
"xcrun", "altool", "--upload-app",
|
|
209
|
+
"-f", str(ipa_path),
|
|
210
|
+
"-t", platform,
|
|
211
|
+
"--apiKey", cfg.key_id,
|
|
212
|
+
"--apiIssuer", cfg.issuer_id,
|
|
213
|
+
],
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
timeout=3600,
|
|
217
|
+
env=env,
|
|
218
|
+
)
|
|
219
|
+
output = f"{result.stdout}\n{result.stderr}".strip()
|
|
220
|
+
if result.returncode != 0:
|
|
221
|
+
raise AppleError(f"altool failed (exit {result.returncode}):\n{output}")
|
|
222
|
+
return output
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""App settings that only exist in the console's internal API (/iris).
|
|
2
|
+
|
|
3
|
+
Territory availability and "App Privacy" have no usable public endpoint: setting
|
|
4
|
+
availability through the public API answers 409 even with every territory in the
|
|
5
|
+
payload, and privacy does not exist there at all. The web console uses /iris with
|
|
6
|
+
the cookie session, so these operations run as fetch calls inside an App Store
|
|
7
|
+
Connect page in the dedicated Chrome.
|
|
8
|
+
|
|
9
|
+
Rules Apple does not document (validated against a production app):
|
|
10
|
+
- appAvailabilities requires ALL territories in `included`, each with `available`.
|
|
11
|
+
- appDataUsages: category, purpose and protection go in the SAME record. Separate
|
|
12
|
+
records (purpose only, protection only) are accepted one by one, but publishing
|
|
13
|
+
fails with "An app data usage is missing a category/purpose or data protection type".
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
from . import browser
|
|
21
|
+
|
|
22
|
+
ASC = "https://appstoreconnect.apple.com"
|
|
23
|
+
|
|
24
|
+
DATA_CATEGORIES = (
|
|
25
|
+
"PAYMENT_INFORMATION", "CREDIT_AND_FRAUD", "OTHER_FINANCIAL_INFO", "PRECISE_LOCATION",
|
|
26
|
+
"COARSE_LOCATION", "SENSITIVE_INFO", "PHYSICAL_ADDRESS", "EMAIL_ADDRESS", "NAME",
|
|
27
|
+
"PHONE_NUMBER", "OTHER_CONTACT_INFO", "CONTACTS", "EMAILS_OR_TEXT_MESSAGES",
|
|
28
|
+
"PHOTOS_OR_VIDEOS", "AUDIO", "GAMEPLAY_CONTENT", "CUSTOMER_SUPPORT", "OTHER_USER_CONTENT",
|
|
29
|
+
"BROWSING_HISTORY", "SEARCH_HISTORY", "USER_ID", "DEVICE_ID", "PURCHASE_HISTORY",
|
|
30
|
+
"PRODUCT_INTERACTION", "ADVERTISING_DATA", "OTHER_USAGE_DATA", "CRASH_DATA",
|
|
31
|
+
"PERFORMANCE_DATA", "OTHER_DIAGNOSTIC_DATA", "OTHER_DATA", "HEALTH", "FITNESS",
|
|
32
|
+
"ENVIRONMENTAL_SCANNING", "HANDS", "HEAD_MOVEMENT",
|
|
33
|
+
)
|
|
34
|
+
DATA_PURPOSES = (
|
|
35
|
+
"THIRD_PARTY_ADVERTISING", "DEVELOPERS_ADVERTISING", "ANALYTICS",
|
|
36
|
+
"PRODUCT_PERSONALIZATION", "APP_FUNCTIONALITY", "OTHER_PURPOSES",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_iris(app_id: str, body_js: str, timeout: int = 300) -> dict:
|
|
41
|
+
"""Run `body_js` (the body of an async function that returns an object) in a console page."""
|
|
42
|
+
script = f"""
|
|
43
|
+
import time
|
|
44
|
+
new_tab({f"{ASC}/apps/{app_id}/distribution"!r})
|
|
45
|
+
wait_for_load()
|
|
46
|
+
time.sleep(3)
|
|
47
|
+
js(r\"\"\"window.__sp='running';(async()=>{{
|
|
48
|
+
const H={{'Content-Type':'application/json'}};
|
|
49
|
+
const req=async (method,path,body)=>{{const r=await fetch(path,{{method,credentials:'include',headers:H,body:body?JSON.stringify(body):undefined}});
|
|
50
|
+
const t=await r.text(); return {{status:r.status, json:(()=>{{try{{return JSON.parse(t)}}catch(e){{return null}}}})(), text:t.slice(0,600)}}}};
|
|
51
|
+
const all=async (path)=>{{let out=[],p=path; while(p){{const r=await req('GET',p); if(r.status!==200) throw new Error(path+' -> '+r.status+' '+r.text);
|
|
52
|
+
out=out.concat(r.json.data); p=r.json.links&&r.json.links.next? r.json.links.next.replace(/^https:\\/\\/[^/]+/,''): null;}} return out}};
|
|
53
|
+
const result=await (async()=>{{ {body_js} }})();
|
|
54
|
+
window.__sp=JSON.stringify(result);
|
|
55
|
+
}})().catch(e=>window.__sp=JSON.stringify({{error:String(e)}})); 'ok'\"\"\")
|
|
56
|
+
for _ in range({timeout // 2}):
|
|
57
|
+
v = js("String(window.__sp)")
|
|
58
|
+
if v != 'running': break
|
|
59
|
+
time.sleep(2)
|
|
60
|
+
print('__SP__' + v)
|
|
61
|
+
"""
|
|
62
|
+
out = browser.run_harness(script, timeout=timeout + 60)
|
|
63
|
+
payload = out.split("__SP__", 1)[-1].strip()
|
|
64
|
+
try:
|
|
65
|
+
result = json.loads(payload)
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
raise browser.BrowserError(f"Unexpected response from the console: {payload[:300]}") from exc
|
|
68
|
+
if isinstance(result, dict) and result.get("error"):
|
|
69
|
+
raise browser.BrowserError(result["error"])
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_availability(app_id: str, territories: list[str], available_in_new: bool) -> dict:
|
|
74
|
+
"""Make the app available only in the given territories (3-letter ISO codes)."""
|
|
75
|
+
wanted = json.dumps(sorted(set(t.upper() for t in territories)))
|
|
76
|
+
body = f"""
|
|
77
|
+
const want={wanted};
|
|
78
|
+
const terr=(await all('/iris/v1/territories?limit=200')).map(t=>t.id);
|
|
79
|
+
const missing=want.filter(t=>!terr.includes(t)); if(missing.length) throw new Error('unknown territory: '+missing.join(','));
|
|
80
|
+
const rel=terr.map(t=>({{type:'territoryAvailabilities',id:'${{'+t+'}}'}}));
|
|
81
|
+
const inc=terr.map(t=>({{type:'territoryAvailabilities',id:'${{'+t+'}}',attributes:{{available:want.includes(t)}},relationships:{{territory:{{data:{{type:'territories',id:t}}}}}}}}));
|
|
82
|
+
const r=await req('POST','/iris/v2/appAvailabilities',{{data:{{type:'appAvailabilities',attributes:{{availableInNewTerritories:{json.dumps(available_in_new)}}},
|
|
83
|
+
relationships:{{app:{{data:{{type:'apps',id:'{app_id}'}}}},territoryAvailabilities:{{data:rel}}}}}},included:inc}});
|
|
84
|
+
if(r.status>=300) throw new Error('appAvailabilities -> '+r.status+' '+r.text);
|
|
85
|
+
const now=(await all('/iris/v2/appAvailabilities/{app_id}/territoryAvailabilities?limit=200&include=territory'))
|
|
86
|
+
.filter(d=>d.attributes.available).map(d=>(d.relationships.territory.data||{{}}).id);
|
|
87
|
+
return {{total_territories:terr.length, available:now}};
|
|
88
|
+
"""
|
|
89
|
+
return _run_iris(app_id, body)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def set_privacy(app_id: str, usages: list[dict]) -> dict:
|
|
93
|
+
"""Replace the whole privacy declaration and publish it.
|
|
94
|
+
|
|
95
|
+
usages: [{"category": "NAME", "purposes": ["APP_FUNCTIONALITY"], "linked": true,
|
|
96
|
+
"tracking": false}, ...]. An empty list declares "we do not collect data".
|
|
97
|
+
"""
|
|
98
|
+
records = []
|
|
99
|
+
for u in usages:
|
|
100
|
+
cat = u["category"].upper()
|
|
101
|
+
if cat not in DATA_CATEGORIES:
|
|
102
|
+
raise ValueError(f"Invalid category: {cat}. Options: {', '.join(DATA_CATEGORIES)}")
|
|
103
|
+
purposes = [p.upper() for p in u.get("purposes") or []]
|
|
104
|
+
bad = [p for p in purposes if p not in DATA_PURPOSES]
|
|
105
|
+
if not purposes or bad:
|
|
106
|
+
raise ValueError(f"{cat}: invalid purposes {bad or '(none)'}. Options: {', '.join(DATA_PURPOSES)}")
|
|
107
|
+
protections = ["DATA_LINKED_TO_YOU" if u.get("linked", True) else "DATA_NOT_LINKED_TO_YOU"]
|
|
108
|
+
if u.get("tracking"):
|
|
109
|
+
protections.append("DATA_USED_TO_TRACK_YOU")
|
|
110
|
+
for purpose in purposes:
|
|
111
|
+
for protection in protections:
|
|
112
|
+
records.append({"category": cat, "purpose": purpose, "protection": protection})
|
|
113
|
+
plan = json.dumps(records)
|
|
114
|
+
body = f"""
|
|
115
|
+
const plan={plan};
|
|
116
|
+
const old=await all('/iris/v1/apps/{app_id}/dataUsages?limit=200');
|
|
117
|
+
for (const d of old) {{ const r=await req('DELETE','/iris/v1/appDataUsages/'+d.id); if(r.status>=300) throw new Error('DELETE '+d.id+' -> '+r.status); }}
|
|
118
|
+
const created=[];
|
|
119
|
+
const recs = plan.length? plan : [{{protection:'DATA_NOT_COLLECTED'}}];
|
|
120
|
+
for (const x of recs) {{
|
|
121
|
+
const rel={{app:{{data:{{type:'apps',id:'{app_id}'}}}},dataProtection:{{data:{{type:'appDataUsageDataProtections',id:x.protection}}}}}};
|
|
122
|
+
if (x.category) rel.category={{data:{{type:'appDataUsageCategories',id:x.category}}}};
|
|
123
|
+
if (x.purpose) rel.purpose={{data:{{type:'appDataUsagePurposes',id:x.purpose}}}};
|
|
124
|
+
const r=await req('POST','/iris/v1/appDataUsages',{{data:{{type:'appDataUsages',relationships:rel}}}});
|
|
125
|
+
if (r.status!==201) throw new Error('appDataUsages '+(x.category||'')+' -> '+r.status+' '+r.text);
|
|
126
|
+
created.push((x.category||'NOT_COLLECTED')+':'+(x.purpose||'')+':'+x.protection);
|
|
127
|
+
}}
|
|
128
|
+
const p=await req('PATCH','/iris/v1/appDataUsagesPublishState/{app_id}',{{data:{{type:'appDataUsagesPublishState',id:'{app_id}',attributes:{{published:true}}}}}});
|
|
129
|
+
if (p.status>=300) throw new Error('publish -> '+p.status+' '+p.text);
|
|
130
|
+
return {{removed:old.length, created:created, published:true}};
|
|
131
|
+
"""
|
|
132
|
+
return _run_iris(app_id, body)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""App Store Resolution Center: read the rejection and reply to App Review.
|
|
2
|
+
|
|
3
|
+
Apple's public API exposes neither the review messages nor replies to them. The
|
|
4
|
+
web console uses the internal /iris API, which accepts the dedicated Chrome's
|
|
5
|
+
cookie session. Reading is done with fetch inside the page; replying goes through
|
|
6
|
+
the console's own "Reply to App Review" box, because the attachment goes through
|
|
7
|
+
an upload that only the UI knows how to assemble.
|
|
8
|
+
|
|
9
|
+
Details learned by trial and error:
|
|
10
|
+
- The harness js() gives up after 5s; the fetch calls run in an IIFE that writes
|
|
11
|
+
to window, and the script polls for the result.
|
|
12
|
+
- The reply textarea only enables the "Reply" button after real typing: setting
|
|
13
|
+
.value is not enough, so the script types and deletes a space via CDP.
|
|
14
|
+
- Attachments go through DOM.setFileInputFiles and show "Processing..." before
|
|
15
|
+
appearing in the list; sending before that drops the file.
|
|
16
|
+
- iPhone video is recorded in HEVC and weighs ~100 MB; converted to H.264 at
|
|
17
|
+
1920px it is ~8 MB and opens in any player.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import tempfile
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from . import browser
|
|
29
|
+
|
|
30
|
+
ASC = "https://appstoreconnect.apple.com"
|
|
31
|
+
|
|
32
|
+
# Console UI labels, matched literally against the page text (pt-BR and en-US)
|
|
33
|
+
REPLY_OPEN = ("Responda à equipe de revisão de apps", "Reply to App Review")
|
|
34
|
+
REPLY_SEND = ("Responder", "Reply")
|
|
35
|
+
PROCESSING = ("Processando", "Processing")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_messages(app_id: str) -> list[dict]:
|
|
39
|
+
"""The app's Resolution Center messages, oldest first."""
|
|
40
|
+
script = f"""
|
|
41
|
+
import time, json
|
|
42
|
+
new_tab({f"{ASC}/apps/{app_id}/distribution"!r})
|
|
43
|
+
wait_for_load()
|
|
44
|
+
time.sleep(3)
|
|
45
|
+
js(r\"\"\"window.__sp='running';(async()=>{{
|
|
46
|
+
const g=async p=>{{const r=await fetch(p,{{credentials:'include'}}); return r.ok? r.json(): null}};
|
|
47
|
+
const th=await g('/iris/v1/apps/{app_id}/resolutionCenterThreads');
|
|
48
|
+
const out=[];
|
|
49
|
+
for (const t of ((th&&th.data)||[])) {{
|
|
50
|
+
const m=await g('/iris/v1/resolutionCenterThreads/'+t.id+'/resolutionCenterMessages?include=rejections&limit=50');
|
|
51
|
+
const rej=((m&&m.included)||[]).filter(i=>i.type&&i.type.indexOf('ejection')>=0).map(i=>i.attributes.reasons||[]).flat();
|
|
52
|
+
for (const x of ((m&&m.data)||[])) out.push({{thread:t.id, date:x.attributes.createdDate, sender:x.attributes.fromActor||null,
|
|
53
|
+
text:(x.attributes.messageBody||'').replace(/<br\\s*\\/?>/g,' ').replace(/<[^>]+>/g,'').trim(), reasons:rej}});
|
|
54
|
+
}}
|
|
55
|
+
window.__sp=JSON.stringify(out.sort((a,b)=>a.date<b.date?-1:1));
|
|
56
|
+
}})().catch(e=>window.__sp='ERROR '+e); 'ok'\"\"\")
|
|
57
|
+
for _ in range(40):
|
|
58
|
+
v = js("String(window.__sp)")
|
|
59
|
+
if v != 'running': break
|
|
60
|
+
time.sleep(1.5)
|
|
61
|
+
print('__SP__' + v)
|
|
62
|
+
"""
|
|
63
|
+
out = browser.run_harness(script, timeout=180)
|
|
64
|
+
payload = out.split("__SP__", 1)[-1].strip()
|
|
65
|
+
if payload.startswith("ERROR") or not payload.startswith("["):
|
|
66
|
+
raise browser.BrowserError(f"Could not read the Resolution Center: {payload[:300]}")
|
|
67
|
+
return json.loads(payload)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def prepare_attachment(path: Path) -> Path:
|
|
71
|
+
"""Convert HEVC video to H.264 at 1920px (smaller and universal); anything else passes through unchanged."""
|
|
72
|
+
if path.suffix.lower() not in (".mp4", ".mov", ".m4v"):
|
|
73
|
+
return path
|
|
74
|
+
if not (shutil.which("ffprobe") and shutil.which("ffmpeg")):
|
|
75
|
+
return path
|
|
76
|
+
codec = subprocess.run(
|
|
77
|
+
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name",
|
|
78
|
+
"-of", "csv=p=0", str(path)],
|
|
79
|
+
capture_output=True, text=True,
|
|
80
|
+
).stdout.strip()
|
|
81
|
+
if codec != "hevc":
|
|
82
|
+
return path
|
|
83
|
+
out = Path(tempfile.mkdtemp(prefix="sp-review-")) / f"{path.stem}.mp4"
|
|
84
|
+
subprocess.run(
|
|
85
|
+
["ffmpeg", "-v", "error", "-y", "-i", str(path), "-vf", "scale=-2:1920", "-c:v", "libx264",
|
|
86
|
+
"-preset", "medium", "-crf", "26", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "96k",
|
|
87
|
+
"-movflags", "+faststart", str(out)],
|
|
88
|
+
check=True,
|
|
89
|
+
)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reply(app_id: str, submission_id: str, text: str, attachments: list[Path]) -> str:
|
|
94
|
+
"""Reply to App Review through the console box and verify the message landed in the thread."""
|
|
95
|
+
script = f"""
|
|
96
|
+
import time, json
|
|
97
|
+
TEXT = {json.dumps(text)}
|
|
98
|
+
FILES = {json.dumps([str(p) for p in attachments])}
|
|
99
|
+
OPEN = {json.dumps(list(REPLY_OPEN))}
|
|
100
|
+
SEND = {json.dumps(list(REPLY_SEND))}
|
|
101
|
+
BUSY = {json.dumps(list(PROCESSING))}
|
|
102
|
+
new_tab({f"{ASC}/apps/{app_id}/distribution/reviewsubmissions/details/{submission_id}"!r})
|
|
103
|
+
wait_for_load()
|
|
104
|
+
for _ in range(15):
|
|
105
|
+
time.sleep(2)
|
|
106
|
+
if js("[...document.querySelectorAll('button')].some(b=>%s.includes(b.innerText.trim()))" % json.dumps(OPEN)): break
|
|
107
|
+
else:
|
|
108
|
+
raise SystemExit('ERROR: the reply button did not appear (does the submission have an open issue?)')
|
|
109
|
+
js("[...document.querySelectorAll('button')].find(b=>%s.includes(b.innerText.trim())).click()" % json.dumps(OPEN))
|
|
110
|
+
time.sleep(3)
|
|
111
|
+
ok = js(\"\"\"(()=>{{const t=document.querySelector('[role=dialog] textarea'); if(!t) return false;
|
|
112
|
+
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(t, %s);
|
|
113
|
+
t.dispatchEvent(new Event('input',{{bubbles:true}})); t.focus(); t.setSelectionRange(t.value.length,t.value.length); return true}})()\"\"\" % json.dumps(TEXT))
|
|
114
|
+
if not ok:
|
|
115
|
+
raise SystemExit('ERROR: the reply box did not open (the console may not have rendered in headless mode; try SHIPSTORES_HEADLESS=0)')
|
|
116
|
+
cdp("Input.insertText", text=" ")
|
|
117
|
+
cdp("Input.dispatchKeyEvent", type="keyDown", key="Backspace", code="Backspace", windowsVirtualKeyCode=8)
|
|
118
|
+
cdp("Input.dispatchKeyEvent", type="keyUp", key="Backspace", code="Backspace", windowsVirtualKeyCode=8)
|
|
119
|
+
for f in FILES:
|
|
120
|
+
doc = cdp("DOM.getDocument", depth=-1, pierce=True)
|
|
121
|
+
ids = cdp("DOM.querySelectorAll", nodeId=doc["root"]["nodeId"], selector="[role=dialog] input[type=file]")["nodeIds"]
|
|
122
|
+
if not ids:
|
|
123
|
+
raise SystemExit('ERROR: attachment input not found')
|
|
124
|
+
cdp("DOM.setFileInputFiles", nodeId=ids[-1], files=[f])
|
|
125
|
+
name = f.rsplit('/', 1)[-1]
|
|
126
|
+
for _ in range(90):
|
|
127
|
+
time.sleep(2)
|
|
128
|
+
d = js("(document.querySelector('[role=dialog]')||{{}}).innerText||''")
|
|
129
|
+
if name in d and not any(b in d for b in BUSY): break
|
|
130
|
+
else:
|
|
131
|
+
raise SystemExit('ERROR: attachment did not finish processing: ' + name)
|
|
132
|
+
# "Mensagens"/"Messages" is console UI text, matched literally (pt-BR / en-US)
|
|
133
|
+
COUNT = r"(()=>{{const m=document.body.innerText.match(/(?:Mensagens|Messages) \\((\\d+)\\)/); return m? Number(m[1]): -1}})()"
|
|
134
|
+
before = js(COUNT)
|
|
135
|
+
sent = js("(()=>{{const b=[...document.querySelectorAll('[role=dialog] button')].filter(b=>%s.includes(b.innerText.trim())).pop(); if(!b) return 'no button'; if(b.disabled) return 'disabled'; b.click(); return 'sent'}})()" % json.dumps(SEND))
|
|
136
|
+
if sent != 'sent':
|
|
137
|
+
raise SystemExit('ERROR: not sent (' + sent + ')')
|
|
138
|
+
for _ in range(15):
|
|
139
|
+
time.sleep(2)
|
|
140
|
+
if js(COUNT) > before: break
|
|
141
|
+
else:
|
|
142
|
+
raise SystemExit('ERROR: the message did not appear in the thread after sending')
|
|
143
|
+
print('__SP__ok')
|
|
144
|
+
"""
|
|
145
|
+
out = browser.run_harness(script, timeout=600)
|
|
146
|
+
if "__SP__ok" not in out:
|
|
147
|
+
erro = next((l for l in out.splitlines() if l.startswith("ERROR")), out[-500:])
|
|
148
|
+
raise browser.BrowserError(erro)
|
|
149
|
+
return "sent"
|