ios-app-review-plugin 1.0.0
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.
- package/.claude/settings.local.json +42 -0
- package/.github/actions/ios-review/action.yml +106 -0
- package/.github/workflows/ci.yml +103 -0
- package/.github/workflows/publish.yml +57 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +175 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bitrise/step.sh +128 -0
- package/bitrise/step.yml +101 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzers/asc-iap.d.ts.map +1 -0
- package/dist/analyzers/asc-metadata.d.ts.map +1 -0
- package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
- package/dist/analyzers/asc-version.d.ts.map +1 -0
- package/dist/analyzers/code-scanner.d.ts.map +1 -0
- package/dist/analyzers/deprecated-api.d.ts.map +1 -0
- package/dist/analyzers/entitlements.d.ts.map +1 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/info-plist.d.ts.map +1 -0
- package/dist/analyzers/privacy.d.ts.map +1 -0
- package/dist/analyzers/private-api.d.ts.map +1 -0
- package/dist/analyzers/security.d.ts.map +1 -0
- package/dist/analyzers/ui-ux.d.ts.map +1 -0
- package/dist/asc/auth.d.ts.map +1 -0
- package/dist/asc/client.d.ts.map +1 -0
- package/dist/asc/endpoints/apps.d.ts.map +1 -0
- package/dist/asc/endpoints/iap.d.ts.map +1 -0
- package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
- package/dist/asc/endpoints/versions.d.ts.map +1 -0
- package/dist/asc/errors.d.ts.map +1 -0
- package/dist/asc/index.d.ts.map +1 -0
- package/dist/asc/types.d.ts.map +1 -0
- package/dist/badge/generator.d.ts.map +1 -0
- package/dist/badge/index.d.ts.map +1 -0
- package/dist/badge/types.d.ts.map +1 -0
- package/dist/cache/file-cache.d.ts.map +1 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/scan.d.ts.map +1 -0
- package/dist/cli/commands/version.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/git/diff.d.ts.map +1 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/types.d.ts.map +1 -0
- package/dist/guidelines/database.d.ts.map +1 -0
- package/dist/guidelines/index.d.ts.map +1 -0
- package/dist/guidelines/matcher.d.ts.map +1 -0
- package/dist/guidelines/types.d.ts.map +1 -0
- package/dist/history/comparator.d.ts.map +1 -0
- package/dist/history/index.d.ts.map +1 -0
- package/dist/history/store.d.ts.map +1 -0
- package/dist/history/types.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +994 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/plist.d.ts.map +1 -0
- package/dist/parsers/xcodeproj.d.ts.map +1 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/reporter.d.ts.map +1 -0
- package/dist/progress/types.d.ts.map +1 -0
- package/dist/reports/html.d.ts.map +1 -0
- package/dist/reports/index.d.ts.map +1 -0
- package/dist/reports/json.d.ts.map +1 -0
- package/dist/reports/markdown.d.ts.map +1 -0
- package/dist/reports/types.d.ts.map +1 -0
- package/dist/rules/engine.d.ts.map +1 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/loader.d.ts.map +1 -0
- package/dist/rules/types.d.ts.map +1 -0
- package/dist/types/index.d.ts.map +1 -0
- package/docs/ANALYZERS.md +237 -0
- package/docs/API.md +308 -0
- package/docs/BADGES.md +130 -0
- package/docs/CI_CD.md +283 -0
- package/docs/CLI.md +140 -0
- package/docs/REPORTS.md +212 -0
- package/docs/ROADMAP.md +267 -0
- package/docs/RULES.md +182 -0
- package/docs/SECURITY.md +89 -0
- package/docs/TROUBLESHOOTING.md +227 -0
- package/docs/tutorials/ASC_SETUP.md +188 -0
- package/docs/tutorials/CI_INTEGRATION.md +292 -0
- package/docs/tutorials/CUSTOM_RULES.md +291 -0
- package/docs/tutorials/GETTING_STARTED.md +226 -0
- package/docs/video-scripts/01-introduction.md +106 -0
- package/docs/video-scripts/02-cli-usage.md +120 -0
- package/docs/video-scripts/03-ci-integration.md +198 -0
- package/eslint.config.js +33 -0
- package/examples/.ios-review-rules.json +82 -0
- package/examples/bitrise-workflow.yml +129 -0
- package/examples/fastlane-lane.rb +71 -0
- package/examples/github-action.yml +147 -0
- package/fastlane/Fastfile.example +114 -0
- package/fastlane/README.md +99 -0
- package/jest.config.js +36 -0
- package/package.json +65 -0
- package/scripts/benchmark.ts +112 -0
- package/scripts/debug-parser.ts +37 -0
- package/scripts/debug-pbxproj.ts +36 -0
- package/scripts/debug-specific.ts +47 -0
- package/scripts/test-analyze.ts +67 -0
- package/scripts/xcode-cloud-review.sh +167 -0
- package/src/analyzer.ts +227 -0
- package/src/analyzers/asc-iap.ts +300 -0
- package/src/analyzers/asc-metadata.ts +326 -0
- package/src/analyzers/asc-screenshots.ts +310 -0
- package/src/analyzers/asc-version.ts +368 -0
- package/src/analyzers/code-scanner.ts +408 -0
- package/src/analyzers/deprecated-api.ts +390 -0
- package/src/analyzers/entitlements.ts +345 -0
- package/src/analyzers/index.ts +12 -0
- package/src/analyzers/info-plist.ts +409 -0
- package/src/analyzers/privacy.ts +376 -0
- package/src/analyzers/private-api.ts +377 -0
- package/src/analyzers/security.ts +327 -0
- package/src/analyzers/ui-ux.ts +509 -0
- package/src/asc/auth.ts +204 -0
- package/src/asc/client.ts +258 -0
- package/src/asc/endpoints/apps.ts +115 -0
- package/src/asc/endpoints/iap.ts +171 -0
- package/src/asc/endpoints/screenshots.ts +164 -0
- package/src/asc/endpoints/versions.ts +174 -0
- package/src/asc/errors.ts +109 -0
- package/src/asc/index.ts +108 -0
- package/src/asc/types.ts +369 -0
- package/src/badge/generator.ts +48 -0
- package/src/badge/index.ts +2 -0
- package/src/badge/types.ts +5 -0
- package/src/cache/file-cache.ts +75 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/types.ts +10 -0
- package/src/cli/commands/help.ts +41 -0
- package/src/cli/commands/scan.ts +44 -0
- package/src/cli/commands/version.ts +12 -0
- package/src/cli/index.ts +92 -0
- package/src/cli/types.ts +17 -0
- package/src/git/diff.ts +21 -0
- package/src/git/index.ts +2 -0
- package/src/git/types.ts +5 -0
- package/src/guidelines/database.ts +344 -0
- package/src/guidelines/index.ts +4 -0
- package/src/guidelines/matcher.ts +84 -0
- package/src/guidelines/types.ts +28 -0
- package/src/history/comparator.ts +114 -0
- package/src/history/index.ts +3 -0
- package/src/history/store.ts +135 -0
- package/src/history/types.ts +40 -0
- package/src/index.ts +1113 -0
- package/src/parsers/index.ts +3 -0
- package/src/parsers/plist.ts +253 -0
- package/src/parsers/xcodeproj.ts +265 -0
- package/src/progress/index.ts +2 -0
- package/src/progress/reporter.ts +65 -0
- package/src/progress/types.ts +9 -0
- package/src/reports/html.ts +322 -0
- package/src/reports/index.ts +20 -0
- package/src/reports/json.ts +92 -0
- package/src/reports/markdown.ts +187 -0
- package/src/reports/types.ts +26 -0
- package/src/rules/engine.ts +121 -0
- package/src/rules/index.ts +3 -0
- package/src/rules/loader.ts +83 -0
- package/src/rules/types.ts +25 -0
- package/src/types/index.ts +247 -0
- package/tests/analyzer.test.ts +142 -0
- package/tests/analyzers/asc-iap.test.ts +228 -0
- package/tests/analyzers/asc-metadata.test.ts +210 -0
- package/tests/analyzers/asc-screenshots.test.ts +135 -0
- package/tests/analyzers/asc-version.test.ts +259 -0
- package/tests/analyzers/code-scanner.test.ts +745 -0
- package/tests/analyzers/deprecated-api.test.ts +286 -0
- package/tests/analyzers/entitlements.test.ts +411 -0
- package/tests/analyzers/info-plist.test.ts +148 -0
- package/tests/analyzers/privacy.test.ts +623 -0
- package/tests/analyzers/private-api.test.ts +255 -0
- package/tests/analyzers/security.test.ts +300 -0
- package/tests/analyzers/ui-ux.test.ts +357 -0
- package/tests/asc/auth.test.ts +189 -0
- package/tests/asc/client.test.ts +207 -0
- package/tests/asc/endpoints.test.ts +1359 -0
- package/tests/badge/generator.test.ts +73 -0
- package/tests/cache/file-cache.test.ts +124 -0
- package/tests/cli/cli-index.test.ts +510 -0
- package/tests/cli/commands.test.ts +67 -0
- package/tests/cli/scan.test.ts +152 -0
- package/tests/git/diff.test.ts +69 -0
- package/tests/guidelines/matcher.test.ts +209 -0
- package/tests/history/comparator.test.ts +272 -0
- package/tests/history/store.test.ts +200 -0
- package/tests/integration/cli.test.ts +95 -0
- package/tests/integration/e2e.test.ts +130 -0
- package/tests/parsers/plist.test.ts +240 -0
- package/tests/parsers/xcodeproj.test.ts +289 -0
- package/tests/progress/reporter.test.ts +117 -0
- package/tests/reports/html.test.ts +176 -0
- package/tests/reports/json.test.ts +235 -0
- package/tests/reports/markdown.test.ts +196 -0
- package/tests/rules/engine.test.ts +229 -0
- package/tests/rules/loader.test.ts +187 -0
- package/tests/setup.ts +15 -0
- package/tsconfig.json +27 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Store Connect API Client
|
|
3
|
+
*
|
|
4
|
+
* Handles HTTP requests with rate limiting, retries, and pagination.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getToken } from './auth.js';
|
|
8
|
+
import {
|
|
9
|
+
ASCAPIError,
|
|
10
|
+
ASCAuthError,
|
|
11
|
+
ASCRateLimitError,
|
|
12
|
+
} from './errors.js';
|
|
13
|
+
import type {
|
|
14
|
+
ASCListResponse,
|
|
15
|
+
ASCErrorResponse,
|
|
16
|
+
ASCResource,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
const BASE_URL = 'https://api.appstoreconnect.apple.com/v1';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rate limiter state
|
|
23
|
+
*/
|
|
24
|
+
interface RateLimiterState {
|
|
25
|
+
tokens: number;
|
|
26
|
+
lastRefill: number;
|
|
27
|
+
retryAfter?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RATE_LIMIT_TOKENS = 500; // 500 requests per hour
|
|
31
|
+
const RATE_LIMIT_REFILL_INTERVAL = 60 * 60 * 1000; // 1 hour in ms
|
|
32
|
+
|
|
33
|
+
let rateLimiter: RateLimiterState = {
|
|
34
|
+
tokens: RATE_LIMIT_TOKENS,
|
|
35
|
+
lastRefill: Date.now(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Retry configuration
|
|
40
|
+
*/
|
|
41
|
+
const MAX_RETRIES = 3;
|
|
42
|
+
const INITIAL_RETRY_DELAY = 1000; // 1 second
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check and consume a rate limit token
|
|
46
|
+
*/
|
|
47
|
+
function consumeRateLimitToken(): boolean {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const timeSinceRefill = now - rateLimiter.lastRefill;
|
|
50
|
+
|
|
51
|
+
// Refill tokens if an hour has passed
|
|
52
|
+
if (timeSinceRefill >= RATE_LIMIT_REFILL_INTERVAL) {
|
|
53
|
+
rateLimiter.tokens = RATE_LIMIT_TOKENS;
|
|
54
|
+
rateLimiter.lastRefill = now;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check if we need to wait for retry-after
|
|
58
|
+
if (rateLimiter.retryAfter && now < rateLimiter.retryAfter) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (rateLimiter.tokens > 0) {
|
|
63
|
+
rateLimiter.tokens--;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Set retry-after from response header
|
|
72
|
+
*/
|
|
73
|
+
function setRetryAfter(seconds: number): void {
|
|
74
|
+
rateLimiter.retryAfter = Date.now() + seconds * 1000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Sleep for a given number of milliseconds
|
|
79
|
+
*/
|
|
80
|
+
function sleep(ms: number): Promise<void> {
|
|
81
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse error response
|
|
86
|
+
*/
|
|
87
|
+
async function parseErrorResponse(response: Response): Promise<ASCErrorResponse | null> {
|
|
88
|
+
try {
|
|
89
|
+
const text = await response.text();
|
|
90
|
+
if (text) {
|
|
91
|
+
return JSON.parse(text) as ASCErrorResponse;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Ignore parse errors
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Make an authenticated request to the ASC API
|
|
101
|
+
*/
|
|
102
|
+
export async function request<T>(
|
|
103
|
+
path: string,
|
|
104
|
+
options: RequestInit = {}
|
|
105
|
+
): Promise<T> {
|
|
106
|
+
let lastError: Error | null = null;
|
|
107
|
+
|
|
108
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
109
|
+
// Check rate limit
|
|
110
|
+
if (!consumeRateLimitToken()) {
|
|
111
|
+
const waitTime = rateLimiter.retryAfter
|
|
112
|
+
? rateLimiter.retryAfter - Date.now()
|
|
113
|
+
: RATE_LIMIT_REFILL_INTERVAL - (Date.now() - rateLimiter.lastRefill);
|
|
114
|
+
|
|
115
|
+
throw new ASCRateLimitError(Math.ceil(waitTime / 1000));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const token = await getToken();
|
|
120
|
+
|
|
121
|
+
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
|
122
|
+
|
|
123
|
+
const response = await fetch(url, {
|
|
124
|
+
...options,
|
|
125
|
+
headers: {
|
|
126
|
+
Authorization: `Bearer ${token}`,
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
...options.headers,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Handle rate limiting
|
|
133
|
+
if (response.status === 429) {
|
|
134
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
135
|
+
const seconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
|
136
|
+
setRetryAfter(seconds);
|
|
137
|
+
|
|
138
|
+
if (attempt < MAX_RETRIES) {
|
|
139
|
+
await sleep(seconds * 1000);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new ASCRateLimitError(seconds);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Handle auth errors
|
|
147
|
+
if (response.status === 401 || response.status === 403) {
|
|
148
|
+
const errorResponse = await parseErrorResponse(response);
|
|
149
|
+
throw new ASCAuthError(
|
|
150
|
+
errorResponse?.errors?.[0]?.detail ?? 'Authentication failed'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Handle server errors with retry
|
|
155
|
+
if (response.status >= 500 && attempt < MAX_RETRIES) {
|
|
156
|
+
lastError = new ASCAPIError(`Server error: ${response.status}`, response.status);
|
|
157
|
+
await sleep(INITIAL_RETRY_DELAY * Math.pow(2, attempt));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Handle other errors
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const errorResponse = await parseErrorResponse(response);
|
|
164
|
+
throw ASCAPIError.fromResponse(response.status, errorResponse?.errors);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Parse successful response
|
|
168
|
+
const data = await response.json();
|
|
169
|
+
return data as T;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof ASCAPIError || error instanceof ASCAuthError || error instanceof ASCRateLimitError) {
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
176
|
+
|
|
177
|
+
// Retry on network errors
|
|
178
|
+
if (attempt < MAX_RETRIES) {
|
|
179
|
+
await sleep(INITIAL_RETRY_DELAY * Math.pow(2, attempt));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw lastError ?? new ASCAPIError('Request failed after retries', 0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Make a GET request
|
|
190
|
+
*/
|
|
191
|
+
export async function get<T>(
|
|
192
|
+
path: string,
|
|
193
|
+
params?: Record<string, string | number | boolean | undefined>
|
|
194
|
+
): Promise<T> {
|
|
195
|
+
let url = path;
|
|
196
|
+
|
|
197
|
+
if (params) {
|
|
198
|
+
const searchParams = new URLSearchParams();
|
|
199
|
+
for (const [key, value] of Object.entries(params)) {
|
|
200
|
+
if (value !== undefined) {
|
|
201
|
+
searchParams.append(key, String(value));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const queryString = searchParams.toString();
|
|
205
|
+
if (queryString) {
|
|
206
|
+
url += (url.includes('?') ? '&' : '?') + queryString;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return request<T>(url, { method: 'GET' });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetch all pages of a paginated endpoint
|
|
215
|
+
*/
|
|
216
|
+
export async function getAllPages<T extends ASCResource>(
|
|
217
|
+
path: string,
|
|
218
|
+
params?: Record<string, string | number | boolean | undefined>,
|
|
219
|
+
maxPages = 10
|
|
220
|
+
): Promise<T[]> {
|
|
221
|
+
const allData: T[] = [];
|
|
222
|
+
let nextUrl: string | undefined = path;
|
|
223
|
+
let pageCount = 0;
|
|
224
|
+
|
|
225
|
+
// Build initial URL with params
|
|
226
|
+
if (params) {
|
|
227
|
+
const searchParams = new URLSearchParams();
|
|
228
|
+
for (const [key, value] of Object.entries(params)) {
|
|
229
|
+
if (value !== undefined) {
|
|
230
|
+
searchParams.append(key, String(value));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const queryString = searchParams.toString();
|
|
234
|
+
if (queryString) {
|
|
235
|
+
nextUrl += (nextUrl.includes('?') ? '&' : '?') + queryString;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
while (nextUrl && pageCount < maxPages) {
|
|
240
|
+
const pageResponse: ASCListResponse<T> = await get<ASCListResponse<T>>(nextUrl);
|
|
241
|
+
allData.push(...pageResponse.data);
|
|
242
|
+
|
|
243
|
+
nextUrl = pageResponse.links?.next;
|
|
244
|
+
pageCount++;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return allData;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Reset rate limiter (useful for testing)
|
|
252
|
+
*/
|
|
253
|
+
export function resetRateLimiter(): void {
|
|
254
|
+
rateLimiter = {
|
|
255
|
+
tokens: RATE_LIMIT_TOKENS,
|
|
256
|
+
lastRefill: Date.now(),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Store Connect App Endpoints
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { get, getAllPages } from '../client.js';
|
|
6
|
+
import { ASCAppNotFoundError } from '../errors.js';
|
|
7
|
+
import type {
|
|
8
|
+
ASCResponse,
|
|
9
|
+
ASCListResponse,
|
|
10
|
+
App,
|
|
11
|
+
AppInfo,
|
|
12
|
+
AppInfoLocalization,
|
|
13
|
+
} from '../types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get an app by its bundle ID
|
|
17
|
+
*/
|
|
18
|
+
export async function getAppByBundleId(bundleId: string): Promise<App> {
|
|
19
|
+
const response = await get<ASCListResponse<App>>('/apps', {
|
|
20
|
+
'filter[bundleId]': bundleId,
|
|
21
|
+
'fields[apps]': 'name,bundleId,sku,primaryLocale,contentRightsDeclaration,isOrEverWasMadeForKids,availableInNewTerritories',
|
|
22
|
+
limit: 1,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const app = response.data[0];
|
|
26
|
+
if (!app) {
|
|
27
|
+
throw new ASCAppNotFoundError(bundleId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get an app by its ASC ID
|
|
35
|
+
*/
|
|
36
|
+
export async function getAppById(appId: string): Promise<App> {
|
|
37
|
+
const response = await get<ASCResponse<App>>(`/apps/${appId}`, {
|
|
38
|
+
'fields[apps]': 'name,bundleId,sku,primaryLocale,contentRightsDeclaration,isOrEverWasMadeForKids,availableInNewTerritories',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return response.data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get app info records for an app
|
|
46
|
+
*/
|
|
47
|
+
export async function getAppInfos(appId: string): Promise<AppInfo[]> {
|
|
48
|
+
return getAllPages<AppInfo>(`/apps/${appId}/appInfos`, {
|
|
49
|
+
'fields[appInfos]': 'appStoreState,appStoreAgeRating,brazilAgeRating,kidsAgeBand',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the current app info (non-released/editable version)
|
|
55
|
+
*/
|
|
56
|
+
export async function getCurrentAppInfo(appId: string): Promise<AppInfo | undefined> {
|
|
57
|
+
const appInfos = await getAppInfos(appId);
|
|
58
|
+
|
|
59
|
+
// Find the app info that is not yet released (editable)
|
|
60
|
+
// States that indicate editable: PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW, WAITING_FOR_REVIEW, IN_REVIEW
|
|
61
|
+
const editableStates = [
|
|
62
|
+
'PREPARE_FOR_SUBMISSION',
|
|
63
|
+
'READY_FOR_REVIEW',
|
|
64
|
+
'WAITING_FOR_REVIEW',
|
|
65
|
+
'IN_REVIEW',
|
|
66
|
+
'PENDING_DEVELOPER_RELEASE',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const currentInfo = appInfos.find((info) =>
|
|
70
|
+
editableStates.includes(info.attributes.appStoreState)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return currentInfo ?? appInfos[0];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get app info localizations for an app info record
|
|
78
|
+
*/
|
|
79
|
+
export async function getAppInfoLocalizations(appInfoId: string): Promise<AppInfoLocalization[]> {
|
|
80
|
+
return getAllPages<AppInfoLocalization>(`/appInfos/${appInfoId}/appInfoLocalizations`, {
|
|
81
|
+
'fields[appInfoLocalizations]': 'locale,name,subtitle,privacyPolicyUrl,privacyChoicesUrl,privacyPolicyText',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get a specific app info localization by locale
|
|
87
|
+
*/
|
|
88
|
+
export async function getAppInfoLocalization(
|
|
89
|
+
appInfoId: string,
|
|
90
|
+
locale: string
|
|
91
|
+
): Promise<AppInfoLocalization | undefined> {
|
|
92
|
+
const localizations = await getAppInfoLocalizations(appInfoId);
|
|
93
|
+
return localizations.find((loc) => loc.attributes.locale === locale);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get app with all related info in a single structure
|
|
98
|
+
*/
|
|
99
|
+
export interface AppWithInfo {
|
|
100
|
+
app: App;
|
|
101
|
+
appInfo: AppInfo | undefined;
|
|
102
|
+
localizations: AppInfoLocalization[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function getAppWithInfo(bundleId: string): Promise<AppWithInfo> {
|
|
106
|
+
const app = await getAppByBundleId(bundleId);
|
|
107
|
+
const appInfo = await getCurrentAppInfo(app.id);
|
|
108
|
+
|
|
109
|
+
let localizations: AppInfoLocalization[] = [];
|
|
110
|
+
if (appInfo) {
|
|
111
|
+
localizations = await getAppInfoLocalizations(appInfo.id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { app, appInfo, localizations };
|
|
115
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Store Connect In-App Purchase Endpoints
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { get, getAllPages } from '../client.js';
|
|
6
|
+
import type {
|
|
7
|
+
ASCResponse,
|
|
8
|
+
InAppPurchase,
|
|
9
|
+
InAppPurchaseLocalization,
|
|
10
|
+
InAppPurchaseAppStoreReviewScreenshot,
|
|
11
|
+
IAPState,
|
|
12
|
+
} from '../types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get all in-app purchases for an app
|
|
16
|
+
*/
|
|
17
|
+
export async function getInAppPurchases(appId: string): Promise<InAppPurchase[]> {
|
|
18
|
+
return getAllPages<InAppPurchase>(`/apps/${appId}/inAppPurchasesV2`, {
|
|
19
|
+
'fields[inAppPurchases]': 'name,productId,inAppPurchaseType,state,reviewNote,familySharable,contentHosting',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get a specific in-app purchase by ID
|
|
25
|
+
*/
|
|
26
|
+
export async function getInAppPurchaseById(iapId: string): Promise<InAppPurchase> {
|
|
27
|
+
const response = await get<ASCResponse<InAppPurchase>>(`/inAppPurchasesV2/${iapId}`, {
|
|
28
|
+
'fields[inAppPurchases]': 'name,productId,inAppPurchaseType,state,reviewNote,familySharable,contentHosting',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return response.data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get localizations for an in-app purchase
|
|
36
|
+
*/
|
|
37
|
+
export async function getIAPLocalizations(iapId: string): Promise<InAppPurchaseLocalization[]> {
|
|
38
|
+
return getAllPages<InAppPurchaseLocalization>(
|
|
39
|
+
`/inAppPurchasesV2/${iapId}/inAppPurchaseLocalizations`,
|
|
40
|
+
{
|
|
41
|
+
'fields[inAppPurchaseLocalizations]': 'locale,name,description',
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get review screenshot for an IAP
|
|
48
|
+
*/
|
|
49
|
+
export async function getIAPReviewScreenshot(
|
|
50
|
+
iapId: string
|
|
51
|
+
): Promise<InAppPurchaseAppStoreReviewScreenshot | undefined> {
|
|
52
|
+
try {
|
|
53
|
+
const response = await get<ASCResponse<InAppPurchaseAppStoreReviewScreenshot>>(
|
|
54
|
+
`/inAppPurchasesV2/${iapId}/appStoreReviewScreenshot`,
|
|
55
|
+
{
|
|
56
|
+
'fields[inAppPurchaseAppStoreReviewScreenshots]': 'fileSize,fileName,assetDeliveryState,imageAsset',
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
return response.data;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* IAP validation result
|
|
67
|
+
*/
|
|
68
|
+
export interface IAPValidation {
|
|
69
|
+
iap: InAppPurchase;
|
|
70
|
+
localizations: InAppPurchaseLocalization[];
|
|
71
|
+
reviewScreenshot?: InAppPurchaseAppStoreReviewScreenshot | undefined;
|
|
72
|
+
issues: string[];
|
|
73
|
+
isReadyForSubmission: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* States that indicate IAP is ready or nearly ready for submission
|
|
78
|
+
*/
|
|
79
|
+
const READY_STATES: IAPState[] = ['READY_TO_SUBMIT', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'APPROVED'];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* States that require action
|
|
83
|
+
*/
|
|
84
|
+
const ACTION_REQUIRED_STATES: IAPState[] = ['MISSING_METADATA', 'DEVELOPER_ACTION_NEEDED', 'REJECTED'];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate an in-app purchase
|
|
88
|
+
*/
|
|
89
|
+
export async function validateIAP(iapId: string): Promise<IAPValidation> {
|
|
90
|
+
const [iap, localizations, reviewScreenshot] = await Promise.all([
|
|
91
|
+
getInAppPurchaseById(iapId),
|
|
92
|
+
getIAPLocalizations(iapId),
|
|
93
|
+
getIAPReviewScreenshot(iapId),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const issues: string[] = [];
|
|
97
|
+
|
|
98
|
+
// Check state
|
|
99
|
+
if (ACTION_REQUIRED_STATES.includes(iap.attributes.state)) {
|
|
100
|
+
issues.push(`IAP state requires action: ${iap.attributes.state}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check localizations
|
|
104
|
+
if (localizations.length === 0) {
|
|
105
|
+
issues.push('No localizations configured');
|
|
106
|
+
} else {
|
|
107
|
+
// Check for missing names/descriptions
|
|
108
|
+
for (const loc of localizations) {
|
|
109
|
+
if (!loc.attributes.name) {
|
|
110
|
+
issues.push(`Missing name for locale: ${loc.attributes.locale}`);
|
|
111
|
+
}
|
|
112
|
+
if (!loc.attributes.description) {
|
|
113
|
+
issues.push(`Missing description for locale: ${loc.attributes.locale}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check review screenshot (required for consumable/non-consumable)
|
|
119
|
+
const requiresScreenshot = ['CONSUMABLE', 'NON_CONSUMABLE'].includes(iap.attributes.inAppPurchaseType);
|
|
120
|
+
if (requiresScreenshot && !reviewScreenshot) {
|
|
121
|
+
issues.push('Review screenshot required but not uploaded');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check screenshot processing state
|
|
125
|
+
if (reviewScreenshot?.attributes.assetDeliveryState?.state === 'FAILED') {
|
|
126
|
+
issues.push('Review screenshot failed to process');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
iap,
|
|
131
|
+
localizations,
|
|
132
|
+
reviewScreenshot,
|
|
133
|
+
issues,
|
|
134
|
+
isReadyForSubmission: issues.length === 0 && READY_STATES.includes(iap.attributes.state),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validate all IAPs for an app
|
|
140
|
+
*/
|
|
141
|
+
export async function validateAllIAPs(appId: string): Promise<IAPValidation[]> {
|
|
142
|
+
const iaps = await getInAppPurchases(appId);
|
|
143
|
+
const validations: IAPValidation[] = [];
|
|
144
|
+
|
|
145
|
+
for (const iap of iaps) {
|
|
146
|
+
const validation = await validateIAP(iap.id);
|
|
147
|
+
validations.push(validation);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return validations;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get IAP state description
|
|
155
|
+
*/
|
|
156
|
+
export function getIAPStateDescription(state: IAPState): string {
|
|
157
|
+
const descriptions: Record<IAPState, string> = {
|
|
158
|
+
MISSING_METADATA: 'Missing required metadata',
|
|
159
|
+
READY_TO_SUBMIT: 'Ready to submit with next app version',
|
|
160
|
+
WAITING_FOR_REVIEW: 'Waiting for App Store review',
|
|
161
|
+
IN_REVIEW: 'Currently in review',
|
|
162
|
+
DEVELOPER_ACTION_NEEDED: 'Requires developer action',
|
|
163
|
+
PENDING_BINARY_APPROVAL: 'Pending binary approval',
|
|
164
|
+
APPROVED: 'Approved and active',
|
|
165
|
+
DEVELOPER_REMOVED_FROM_SALE: 'Removed from sale by developer',
|
|
166
|
+
REMOVED_FROM_SALE: 'Removed from sale',
|
|
167
|
+
REJECTED: 'Rejected by App Store review',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return descriptions[state] ?? state;
|
|
171
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Store Connect Screenshot Endpoints
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getAllPages } from '../client.js';
|
|
6
|
+
import type {
|
|
7
|
+
AppScreenshotSet,
|
|
8
|
+
AppScreenshot,
|
|
9
|
+
ScreenshotDisplayType,
|
|
10
|
+
} from '../types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get all screenshot sets for a version localization
|
|
14
|
+
*/
|
|
15
|
+
export async function getScreenshotSets(
|
|
16
|
+
versionLocalizationId: string
|
|
17
|
+
): Promise<AppScreenshotSet[]> {
|
|
18
|
+
return getAllPages<AppScreenshotSet>(
|
|
19
|
+
`/appStoreVersionLocalizations/${versionLocalizationId}/appScreenshotSets`,
|
|
20
|
+
{
|
|
21
|
+
'fields[appScreenshotSets]': 'screenshotDisplayType',
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get screenshots in a screenshot set
|
|
28
|
+
*/
|
|
29
|
+
export async function getScreenshots(screenshotSetId: string): Promise<AppScreenshot[]> {
|
|
30
|
+
return getAllPages<AppScreenshot>(
|
|
31
|
+
`/appScreenshotSets/${screenshotSetId}/appScreenshots`,
|
|
32
|
+
{
|
|
33
|
+
'fields[appScreenshots]': 'fileSize,fileName,sourceFileChecksum,imageAsset,assetToken,assetType,assetDeliveryState',
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get screenshot sets with their screenshots
|
|
40
|
+
*/
|
|
41
|
+
export interface ScreenshotSetWithScreenshots {
|
|
42
|
+
set: AppScreenshotSet;
|
|
43
|
+
screenshots: AppScreenshot[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function getScreenshotSetsWithScreenshots(
|
|
47
|
+
versionLocalizationId: string
|
|
48
|
+
): Promise<ScreenshotSetWithScreenshots[]> {
|
|
49
|
+
const sets = await getScreenshotSets(versionLocalizationId);
|
|
50
|
+
|
|
51
|
+
const results: ScreenshotSetWithScreenshots[] = [];
|
|
52
|
+
|
|
53
|
+
for (const set of sets) {
|
|
54
|
+
const screenshots = await getScreenshots(set.id);
|
|
55
|
+
results.push({ set, screenshots });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Required screenshot display types for iOS submission
|
|
63
|
+
*/
|
|
64
|
+
export const REQUIRED_IPHONE_DISPLAY_TYPES: ScreenshotDisplayType[] = [
|
|
65
|
+
'APP_IPHONE_67', // iPhone 14 Pro Max (6.7")
|
|
66
|
+
'APP_IPHONE_65', // iPhone 11 Pro Max (6.5")
|
|
67
|
+
'APP_IPHONE_55', // iPhone 8 Plus (5.5")
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export const REQUIRED_IPAD_DISPLAY_TYPES: ScreenshotDisplayType[] = [
|
|
71
|
+
'APP_IPAD_PRO_3GEN_129', // iPad Pro 12.9" (3rd gen)
|
|
72
|
+
'APP_IPAD_PRO_129', // iPad Pro 12.9" (2nd gen)
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* All commonly required display types
|
|
77
|
+
*/
|
|
78
|
+
export const COMMONLY_REQUIRED_DISPLAY_TYPES: ScreenshotDisplayType[] = [
|
|
79
|
+
...REQUIRED_IPHONE_DISPLAY_TYPES,
|
|
80
|
+
...REQUIRED_IPAD_DISPLAY_TYPES,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get display type description for human-readable output
|
|
85
|
+
*/
|
|
86
|
+
export function getDisplayTypeDescription(displayType: ScreenshotDisplayType): string {
|
|
87
|
+
const descriptions: Record<ScreenshotDisplayType, string> = {
|
|
88
|
+
APP_IPHONE_67: 'iPhone 6.7" (14 Pro Max, 15 Pro Max)',
|
|
89
|
+
APP_IPHONE_65: 'iPhone 6.5" (11 Pro Max, XS Max)',
|
|
90
|
+
APP_IPHONE_61: 'iPhone 6.1" (14/15, 14/15 Pro)',
|
|
91
|
+
APP_IPHONE_58: 'iPhone 5.8" (X, XS, 11 Pro)',
|
|
92
|
+
APP_IPHONE_55: 'iPhone 5.5" (6 Plus, 7 Plus, 8 Plus)',
|
|
93
|
+
APP_IPHONE_47: 'iPhone 4.7" (6, 7, 8, SE)',
|
|
94
|
+
APP_IPHONE_40: 'iPhone 4" (5, 5s, SE 1st gen)',
|
|
95
|
+
APP_IPHONE_35: 'iPhone 3.5" (4s and earlier)',
|
|
96
|
+
APP_IPAD_PRO_3GEN_129: 'iPad Pro 12.9" (3rd gen+)',
|
|
97
|
+
APP_IPAD_PRO_3GEN_11: 'iPad Pro 11"',
|
|
98
|
+
APP_IPAD_PRO_129: 'iPad Pro 12.9" (2nd gen)',
|
|
99
|
+
APP_IPAD_105: 'iPad 10.5"',
|
|
100
|
+
APP_IPAD_97: 'iPad 9.7"',
|
|
101
|
+
APP_WATCH_ULTRA: 'Apple Watch Ultra',
|
|
102
|
+
APP_WATCH_SERIES_7: 'Apple Watch Series 7+',
|
|
103
|
+
APP_WATCH_SERIES_4: 'Apple Watch Series 4-6',
|
|
104
|
+
APP_WATCH_SERIES_3: 'Apple Watch Series 3',
|
|
105
|
+
APP_DESKTOP: 'Mac Desktop',
|
|
106
|
+
APP_APPLE_TV: 'Apple TV',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return descriptions[displayType] ?? displayType;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check screenshot validity
|
|
114
|
+
*/
|
|
115
|
+
export interface ScreenshotValidation {
|
|
116
|
+
displayType: ScreenshotDisplayType;
|
|
117
|
+
count: number;
|
|
118
|
+
isValid: boolean;
|
|
119
|
+
hasProcessingErrors: boolean;
|
|
120
|
+
processingState: string[];
|
|
121
|
+
issues: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function validateScreenshotSet(
|
|
125
|
+
set: AppScreenshotSet,
|
|
126
|
+
screenshots: AppScreenshot[]
|
|
127
|
+
): ScreenshotValidation {
|
|
128
|
+
const issues: string[] = [];
|
|
129
|
+
const processingState: string[] = [];
|
|
130
|
+
let hasProcessingErrors = false;
|
|
131
|
+
|
|
132
|
+
// Check count (1-10 required)
|
|
133
|
+
if (screenshots.length === 0) {
|
|
134
|
+
issues.push('No screenshots uploaded');
|
|
135
|
+
} else if (screenshots.length > 10) {
|
|
136
|
+
issues.push(`Too many screenshots (${screenshots.length}/10 max)`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check processing state
|
|
140
|
+
for (const screenshot of screenshots) {
|
|
141
|
+
const state = screenshot.attributes.assetDeliveryState?.state;
|
|
142
|
+
if (state) {
|
|
143
|
+
processingState.push(state);
|
|
144
|
+
if (state === 'FAILED') {
|
|
145
|
+
hasProcessingErrors = true;
|
|
146
|
+
const errors = screenshot.attributes.assetDeliveryState?.errors;
|
|
147
|
+
if (errors && errors.length > 0) {
|
|
148
|
+
issues.push(`Screenshot "${screenshot.attributes.fileName}" failed: ${errors[0]!.description}`);
|
|
149
|
+
} else {
|
|
150
|
+
issues.push(`Screenshot "${screenshot.attributes.fileName}" failed to process`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
displayType: set.attributes.screenshotDisplayType,
|
|
158
|
+
count: screenshots.length,
|
|
159
|
+
isValid: issues.length === 0,
|
|
160
|
+
hasProcessingErrors,
|
|
161
|
+
processingState,
|
|
162
|
+
issues,
|
|
163
|
+
};
|
|
164
|
+
}
|