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,509 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { parsePlist } from '../parsers/plist.js';
|
|
5
|
+
import type {
|
|
6
|
+
Analyzer,
|
|
7
|
+
AnalysisResult,
|
|
8
|
+
AnalyzerOptions,
|
|
9
|
+
Issue,
|
|
10
|
+
XcodeProject,
|
|
11
|
+
} from '../types/index.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Required App Store icon size (1024x1024)
|
|
15
|
+
*/
|
|
16
|
+
const APP_STORE_ICON_SIZE = '1024x1024';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Required iPhone icon sizes (points, with scales)
|
|
20
|
+
*/
|
|
21
|
+
const REQUIRED_IPHONE_ICONS = [
|
|
22
|
+
{ size: '20x20', scales: ['2x', '3x'] },
|
|
23
|
+
{ size: '29x29', scales: ['2x', '3x'] },
|
|
24
|
+
{ size: '38x38', scales: ['2x', '3x'] },
|
|
25
|
+
{ size: '40x40', scales: ['2x', '3x'] },
|
|
26
|
+
{ size: '60x60', scales: ['2x', '3x'] },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Required iPad icon sizes
|
|
31
|
+
*/
|
|
32
|
+
const REQUIRED_IPAD_ICONS = [
|
|
33
|
+
{ size: '20x20', scales: ['1x', '2x'] },
|
|
34
|
+
{ size: '29x29', scales: ['1x', '2x'] },
|
|
35
|
+
{ size: '40x40', scales: ['1x', '2x'] },
|
|
36
|
+
{ size: '76x76', scales: ['1x', '2x'] },
|
|
37
|
+
{ size: '83.5x83.5', scales: ['2x'] },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* All four iPad orientations required
|
|
42
|
+
*/
|
|
43
|
+
const IPAD_ORIENTATIONS = [
|
|
44
|
+
'UIInterfaceOrientationPortrait',
|
|
45
|
+
'UIInterfaceOrientationPortraitUpsideDown',
|
|
46
|
+
'UIInterfaceOrientationLandscapeLeft',
|
|
47
|
+
'UIInterfaceOrientationLandscapeRight',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Placeholder text patterns to detect in storyboard/xib files
|
|
52
|
+
*/
|
|
53
|
+
const PLACEHOLDER_PATTERNS = [
|
|
54
|
+
/\blorem\s+ipsum\b/gi,
|
|
55
|
+
/\bplaceholder\b/gi,
|
|
56
|
+
/\bsample\s+text\b/gi,
|
|
57
|
+
/\bdummy\s+text\b/gi,
|
|
58
|
+
/text="Label"\s/g,
|
|
59
|
+
/title="Button"\s/g,
|
|
60
|
+
/text="Title"\s/g,
|
|
61
|
+
/text="Subtitle"\s/g,
|
|
62
|
+
/text="Description"\s/g,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
interface AppIconContentsImage {
|
|
66
|
+
size?: string;
|
|
67
|
+
scale?: string;
|
|
68
|
+
filename?: string;
|
|
69
|
+
idiom?: string;
|
|
70
|
+
platform?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* UI/UX Compliance analyzer
|
|
75
|
+
*/
|
|
76
|
+
export class UIUXAnalyzer implements Analyzer {
|
|
77
|
+
name = 'UI/UX Compliance';
|
|
78
|
+
description = 'Checks UI/UX requirements for App Store compliance';
|
|
79
|
+
|
|
80
|
+
async analyze(project: XcodeProject, options: AnalyzerOptions): Promise<AnalysisResult> {
|
|
81
|
+
const startTime = Date.now();
|
|
82
|
+
const issues: Issue[] = [];
|
|
83
|
+
|
|
84
|
+
const targets = options.targetName
|
|
85
|
+
? project.targets.filter((t) => t.name === options.targetName)
|
|
86
|
+
: project.targets.filter((t) => t.type === 'application');
|
|
87
|
+
|
|
88
|
+
const target = targets[0];
|
|
89
|
+
if (!target) {
|
|
90
|
+
return {
|
|
91
|
+
analyzer: this.name,
|
|
92
|
+
passed: true,
|
|
93
|
+
issues: [{
|
|
94
|
+
id: 'uiux-no-target',
|
|
95
|
+
title: 'No app target found',
|
|
96
|
+
description: 'Could not find an application target to analyze.',
|
|
97
|
+
severity: 'info',
|
|
98
|
+
category: 'ui-ux',
|
|
99
|
+
}],
|
|
100
|
+
duration: Date.now() - startTime,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const basePath = options.basePath;
|
|
105
|
+
|
|
106
|
+
// Check launch screen
|
|
107
|
+
await this.checkLaunchScreen(basePath, target.infoPlistPath, issues);
|
|
108
|
+
|
|
109
|
+
// Check app icons
|
|
110
|
+
await this.checkAppIcons(basePath, issues);
|
|
111
|
+
|
|
112
|
+
// Check iPad support
|
|
113
|
+
await this.checkIPadSupport(basePath, target.infoPlistPath, issues);
|
|
114
|
+
|
|
115
|
+
// Check for placeholder text in storyboards/xibs
|
|
116
|
+
await this.checkPlaceholderText(basePath, issues);
|
|
117
|
+
|
|
118
|
+
// Check accessibility basics
|
|
119
|
+
await this.checkAccessibility(basePath, issues);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
analyzer: this.name,
|
|
123
|
+
passed: issues.filter((i) => i.severity === 'error').length === 0,
|
|
124
|
+
issues,
|
|
125
|
+
duration: Date.now() - startTime,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate project from a direct path
|
|
131
|
+
*/
|
|
132
|
+
async validateProject(projectPath: string): Promise<AnalysisResult> {
|
|
133
|
+
const startTime = Date.now();
|
|
134
|
+
const issues: Issue[] = [];
|
|
135
|
+
|
|
136
|
+
const basePath = projectPath.endsWith('.xcodeproj') || projectPath.endsWith('.xcworkspace')
|
|
137
|
+
? path.dirname(projectPath)
|
|
138
|
+
: projectPath;
|
|
139
|
+
|
|
140
|
+
// Find Info.plist
|
|
141
|
+
const plistFiles = await fg(['**/Info.plist'], {
|
|
142
|
+
cwd: basePath,
|
|
143
|
+
absolute: true,
|
|
144
|
+
ignore: ['**/Pods/**', '**/build/**', '**/DerivedData/**', '**/Tests/**'],
|
|
145
|
+
});
|
|
146
|
+
const infoPlistPath = plistFiles[0];
|
|
147
|
+
|
|
148
|
+
await this.checkLaunchScreen(basePath, infoPlistPath, issues);
|
|
149
|
+
await this.checkAppIcons(basePath, issues);
|
|
150
|
+
await this.checkIPadSupport(basePath, infoPlistPath, issues);
|
|
151
|
+
await this.checkPlaceholderText(basePath, issues);
|
|
152
|
+
await this.checkAccessibility(basePath, issues);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
analyzer: this.name,
|
|
156
|
+
passed: issues.filter((i) => i.severity === 'error').length === 0,
|
|
157
|
+
issues,
|
|
158
|
+
duration: Date.now() - startTime,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check for launch screen configuration
|
|
164
|
+
*/
|
|
165
|
+
private async checkLaunchScreen(
|
|
166
|
+
basePath: string,
|
|
167
|
+
infoPlistPath: string | undefined,
|
|
168
|
+
issues: Issue[]
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
let hasLaunchScreen = false;
|
|
171
|
+
|
|
172
|
+
// Check Info.plist for UILaunchStoryboardName
|
|
173
|
+
if (infoPlistPath) {
|
|
174
|
+
try {
|
|
175
|
+
const plistPath = path.isAbsolute(infoPlistPath)
|
|
176
|
+
? infoPlistPath
|
|
177
|
+
: path.join(basePath, infoPlistPath);
|
|
178
|
+
const plist = await parsePlist(plistPath);
|
|
179
|
+
if (plist['UILaunchStoryboardName']) {
|
|
180
|
+
hasLaunchScreen = true;
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Plist read failed, check for files instead
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check for LaunchScreen storyboard file
|
|
188
|
+
if (!hasLaunchScreen) {
|
|
189
|
+
const launchScreenFiles = await fg(
|
|
190
|
+
['**/LaunchScreen.storyboard', '**/Launch Screen.storyboard', '**/LaunchScreen.xib'],
|
|
191
|
+
{ cwd: basePath, absolute: true }
|
|
192
|
+
);
|
|
193
|
+
if (launchScreenFiles.length > 0) {
|
|
194
|
+
hasLaunchScreen = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!hasLaunchScreen) {
|
|
199
|
+
issues.push({
|
|
200
|
+
id: 'uiux-no-launch-screen',
|
|
201
|
+
title: 'Missing launch screen',
|
|
202
|
+
description:
|
|
203
|
+
'No launch screen storyboard found. Apps must include a launch screen storyboard.',
|
|
204
|
+
severity: 'error',
|
|
205
|
+
category: 'ui-ux',
|
|
206
|
+
guideline: 'Guideline 4.6 - Launch Screen',
|
|
207
|
+
suggestion:
|
|
208
|
+
'Add a LaunchScreen.storyboard and set UILaunchStoryboardName in Info.plist.',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Check app icon configuration
|
|
215
|
+
*/
|
|
216
|
+
private async checkAppIcons(basePath: string, issues: Issue[]): Promise<void> {
|
|
217
|
+
// Find AppIcon asset catalog
|
|
218
|
+
const iconSets = await fg(['**/AppIcon.appiconset/Contents.json'], {
|
|
219
|
+
cwd: basePath,
|
|
220
|
+
absolute: true,
|
|
221
|
+
ignore: ['**/Pods/**', '**/build/**', '**/DerivedData/**'],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (iconSets.length === 0) {
|
|
225
|
+
issues.push({
|
|
226
|
+
id: 'uiux-no-app-icon',
|
|
227
|
+
title: 'Missing app icon asset catalog',
|
|
228
|
+
description:
|
|
229
|
+
'No AppIcon.appiconset found. Apps must include an app icon in an asset catalog.',
|
|
230
|
+
severity: 'error',
|
|
231
|
+
category: 'ui-ux',
|
|
232
|
+
guideline: 'Guideline 4.0 - App Icons',
|
|
233
|
+
suggestion:
|
|
234
|
+
'Add AppIcon.appiconset to your Assets.xcassets with all required icon sizes.',
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const contentsJson = await fs.readFile(iconSets[0]!, 'utf-8');
|
|
241
|
+
const contents = JSON.parse(contentsJson) as { images?: AppIconContentsImage[] };
|
|
242
|
+
const images = contents.images ?? [];
|
|
243
|
+
|
|
244
|
+
// Check for 1024x1024 App Store icon
|
|
245
|
+
const hasAppStoreIcon = images.some(
|
|
246
|
+
(img) =>
|
|
247
|
+
img.size === APP_STORE_ICON_SIZE &&
|
|
248
|
+
img.filename
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (!hasAppStoreIcon) {
|
|
252
|
+
issues.push({
|
|
253
|
+
id: 'uiux-missing-appstore-icon',
|
|
254
|
+
title: 'Missing App Store icon (1024x1024)',
|
|
255
|
+
description:
|
|
256
|
+
'The 1024x1024 App Store icon is required for submission.',
|
|
257
|
+
severity: 'error',
|
|
258
|
+
filePath: iconSets[0],
|
|
259
|
+
category: 'ui-ux',
|
|
260
|
+
guideline: 'Guideline 4.0 - App Icons',
|
|
261
|
+
suggestion:
|
|
262
|
+
'Add a 1024x1024 PNG icon to your AppIcon asset catalog.',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check for missing iPhone icons
|
|
267
|
+
for (const required of REQUIRED_IPHONE_ICONS) {
|
|
268
|
+
for (const scale of required.scales) {
|
|
269
|
+
const hasIcon = images.some(
|
|
270
|
+
(img) =>
|
|
271
|
+
img.size === required.size &&
|
|
272
|
+
img.scale === scale &&
|
|
273
|
+
img.filename &&
|
|
274
|
+
(img.idiom === 'iphone' || img.idiom === 'universal')
|
|
275
|
+
);
|
|
276
|
+
if (!hasIcon) {
|
|
277
|
+
issues.push({
|
|
278
|
+
id: 'uiux-missing-iphone-icon',
|
|
279
|
+
title: `Missing iPhone icon: ${required.size}@${scale}`,
|
|
280
|
+
description: `iPhone icon size ${required.size} at ${scale} is not configured.`,
|
|
281
|
+
severity: 'warning',
|
|
282
|
+
filePath: iconSets[0],
|
|
283
|
+
category: 'ui-ux',
|
|
284
|
+
suggestion: `Add ${required.size}@${scale} icon to your AppIcon asset catalog.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
issues.push({
|
|
291
|
+
id: 'uiux-invalid-icon-contents',
|
|
292
|
+
title: 'Invalid AppIcon Contents.json',
|
|
293
|
+
description: 'Could not parse the AppIcon.appiconset/Contents.json file.',
|
|
294
|
+
severity: 'warning',
|
|
295
|
+
filePath: iconSets[0],
|
|
296
|
+
category: 'ui-ux',
|
|
297
|
+
suggestion: 'Regenerate the asset catalog through Xcode.',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Check iPad support requirements
|
|
304
|
+
*/
|
|
305
|
+
private async checkIPadSupport(
|
|
306
|
+
basePath: string,
|
|
307
|
+
infoPlistPath: string | undefined,
|
|
308
|
+
issues: Issue[]
|
|
309
|
+
): Promise<void> {
|
|
310
|
+
if (!infoPlistPath) return;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const plistPath = path.isAbsolute(infoPlistPath)
|
|
314
|
+
? infoPlistPath
|
|
315
|
+
: path.join(basePath, infoPlistPath);
|
|
316
|
+
const plist = await parsePlist(plistPath);
|
|
317
|
+
|
|
318
|
+
// Check if app supports iPad
|
|
319
|
+
const deviceFamily = plist['UIDeviceFamily'] as number[] | undefined;
|
|
320
|
+
const supportsIPad = deviceFamily?.includes(2);
|
|
321
|
+
|
|
322
|
+
if (!supportsIPad) return;
|
|
323
|
+
|
|
324
|
+
// iPad apps must support all 4 orientations
|
|
325
|
+
const ipadOrientations =
|
|
326
|
+
(plist['UISupportedInterfaceOrientations~ipad'] as string[]) ??
|
|
327
|
+
(plist['UISupportedInterfaceOrientations'] as string[]) ??
|
|
328
|
+
[];
|
|
329
|
+
|
|
330
|
+
const missingOrientations = IPAD_ORIENTATIONS.filter(
|
|
331
|
+
(o) => !ipadOrientations.includes(o)
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
if (missingOrientations.length > 0) {
|
|
335
|
+
issues.push({
|
|
336
|
+
id: 'uiux-ipad-missing-orientations',
|
|
337
|
+
title: 'iPad missing required orientations',
|
|
338
|
+
description: `iPad apps must support all 4 interface orientations. Missing: ${missingOrientations.join(', ')}`,
|
|
339
|
+
severity: 'error',
|
|
340
|
+
filePath: plistPath,
|
|
341
|
+
category: 'ui-ux',
|
|
342
|
+
guideline: 'Guideline 2.4.1 - Hardware Compatibility',
|
|
343
|
+
suggestion:
|
|
344
|
+
'Add all 4 UISupportedInterfaceOrientations for iPad in Info.plist.',
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Check for iPad icon sizes
|
|
349
|
+
const iconSets = await fg(['**/AppIcon.appiconset/Contents.json'], {
|
|
350
|
+
cwd: basePath,
|
|
351
|
+
absolute: true,
|
|
352
|
+
ignore: ['**/Pods/**', '**/build/**', '**/DerivedData/**'],
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (iconSets.length > 0) {
|
|
356
|
+
try {
|
|
357
|
+
const contentsJson = await fs.readFile(iconSets[0]!, 'utf-8');
|
|
358
|
+
const contents = JSON.parse(contentsJson) as { images?: AppIconContentsImage[] };
|
|
359
|
+
const images = contents.images ?? [];
|
|
360
|
+
|
|
361
|
+
for (const required of REQUIRED_IPAD_ICONS) {
|
|
362
|
+
for (const scale of required.scales) {
|
|
363
|
+
const hasIcon = images.some(
|
|
364
|
+
(img) =>
|
|
365
|
+
img.size === required.size &&
|
|
366
|
+
img.scale === scale &&
|
|
367
|
+
img.filename &&
|
|
368
|
+
(img.idiom === 'ipad' || img.idiom === 'universal')
|
|
369
|
+
);
|
|
370
|
+
if (!hasIcon) {
|
|
371
|
+
issues.push({
|
|
372
|
+
id: 'uiux-missing-ipad-icon',
|
|
373
|
+
title: `Missing iPad icon: ${required.size}@${scale}`,
|
|
374
|
+
description: `iPad icon size ${required.size} at ${scale} is not configured.`,
|
|
375
|
+
severity: 'warning',
|
|
376
|
+
filePath: iconSets[0],
|
|
377
|
+
category: 'ui-ux',
|
|
378
|
+
suggestion: `Add ${required.size}@${scale} iPad icon to your AppIcon asset catalog.`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
// Already handled above
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
// Plist read failed
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Check for placeholder text in storyboard/xib files
|
|
394
|
+
*/
|
|
395
|
+
private async checkPlaceholderText(basePath: string, issues: Issue[]): Promise<void> {
|
|
396
|
+
const storyboardFiles = await fg(['**/*.storyboard', '**/*.xib'], {
|
|
397
|
+
cwd: basePath,
|
|
398
|
+
absolute: true,
|
|
399
|
+
ignore: ['**/Pods/**', '**/build/**', '**/DerivedData/**', '**/LaunchScreen.storyboard'],
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
for (const file of storyboardFiles) {
|
|
403
|
+
try {
|
|
404
|
+
const content = await fs.readFile(file, 'utf-8');
|
|
405
|
+
|
|
406
|
+
for (const pattern of PLACEHOLDER_PATTERNS) {
|
|
407
|
+
pattern.lastIndex = 0;
|
|
408
|
+
|
|
409
|
+
let match: RegExpExecArray | null;
|
|
410
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
411
|
+
const lineNumber = content.substring(0, match.index).split('\n').length;
|
|
412
|
+
|
|
413
|
+
issues.push({
|
|
414
|
+
id: 'uiux-placeholder-text',
|
|
415
|
+
title: 'Placeholder text in UI',
|
|
416
|
+
description: `Placeholder or default text detected in storyboard/xib.\n\nFound: \`${match[0].trim()}\``,
|
|
417
|
+
severity: 'warning',
|
|
418
|
+
filePath: file,
|
|
419
|
+
lineNumber,
|
|
420
|
+
category: 'ui-ux',
|
|
421
|
+
guideline: 'Guideline 2.3 - Accurate Metadata',
|
|
422
|
+
suggestion: 'Replace placeholder text with actual content before submission.',
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Limit per pattern per file
|
|
426
|
+
const count = issues.filter(
|
|
427
|
+
(i) => i.id === 'uiux-placeholder-text' && i.filePath === file
|
|
428
|
+
).length;
|
|
429
|
+
if (count >= 5) break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
// Skip files that can't be read
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Check basic accessibility support
|
|
440
|
+
*/
|
|
441
|
+
private async checkAccessibility(basePath: string, issues: Issue[]): Promise<void> {
|
|
442
|
+
const sourceFiles = await fg(['**/*.swift'], {
|
|
443
|
+
cwd: basePath,
|
|
444
|
+
absolute: true,
|
|
445
|
+
ignore: [
|
|
446
|
+
'**/Pods/**',
|
|
447
|
+
'**/Carthage/**',
|
|
448
|
+
'**/build/**',
|
|
449
|
+
'**/DerivedData/**',
|
|
450
|
+
'**/Tests/**',
|
|
451
|
+
'**/UITests/**',
|
|
452
|
+
],
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
let hasImages = false;
|
|
456
|
+
let hasAccessibilityLabels = false;
|
|
457
|
+
let hasDynamicType = false;
|
|
458
|
+
let hasHardcodedFontSizes = false;
|
|
459
|
+
|
|
460
|
+
for (const file of sourceFiles) {
|
|
461
|
+
try {
|
|
462
|
+
const content = await fs.readFile(file, 'utf-8');
|
|
463
|
+
|
|
464
|
+
if (/UIImage\(|UIImageView\(|Image\(/.test(content)) {
|
|
465
|
+
hasImages = true;
|
|
466
|
+
}
|
|
467
|
+
if (/accessibilityLabel|\.accessibility\(label:|isAccessibilityElement/.test(content)) {
|
|
468
|
+
hasAccessibilityLabels = true;
|
|
469
|
+
}
|
|
470
|
+
if (/UIFont\.TextStyle|\.preferredFont|UIFontMetrics|\.dynamicTypeSize/.test(content)) {
|
|
471
|
+
hasDynamicType = true;
|
|
472
|
+
}
|
|
473
|
+
if (/UIFont\.systemFont\(ofSize:\s*\d|UIFont\(name:.+size:\s*\d/.test(content)) {
|
|
474
|
+
hasHardcodedFontSizes = true;
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
// Skip
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (hasImages && !hasAccessibilityLabels) {
|
|
482
|
+
issues.push({
|
|
483
|
+
id: 'uiux-no-accessibility-labels',
|
|
484
|
+
title: 'No accessibility labels found',
|
|
485
|
+
description:
|
|
486
|
+
'The app uses images but no accessibility labels were detected. Screen readers need labels to describe UI elements.',
|
|
487
|
+
severity: 'warning',
|
|
488
|
+
category: 'ui-ux',
|
|
489
|
+
guideline: 'Guideline 2.5.1 - Accessibility',
|
|
490
|
+
suggestion:
|
|
491
|
+
'Add accessibilityLabel to images and interactive elements for VoiceOver support.',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (hasHardcodedFontSizes && !hasDynamicType) {
|
|
496
|
+
issues.push({
|
|
497
|
+
id: 'uiux-no-dynamic-type',
|
|
498
|
+
title: 'No Dynamic Type support detected',
|
|
499
|
+
description:
|
|
500
|
+
'The app uses hardcoded font sizes but no Dynamic Type support was found. Users with accessibility needs rely on Dynamic Type.',
|
|
501
|
+
severity: 'info',
|
|
502
|
+
category: 'ui-ux',
|
|
503
|
+
guideline: 'Guideline 2.5.1 - Accessibility',
|
|
504
|
+
suggestion:
|
|
505
|
+
'Use UIFont.preferredFont(forTextStyle:) or UIFontMetrics to support Dynamic Type.',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
package/src/asc/auth.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Store Connect JWT Authentication
|
|
3
|
+
*
|
|
4
|
+
* Generates and manages JWT tokens for ASC API authentication.
|
|
5
|
+
* Uses Node.js built-in crypto module with ES256 algorithm.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as crypto from 'crypto';
|
|
9
|
+
import * as fs from 'fs/promises';
|
|
10
|
+
import { ASCAuthError, ASCCredentialsNotConfiguredError } from './errors.js';
|
|
11
|
+
|
|
12
|
+
interface TokenCache {
|
|
13
|
+
token: string;
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ASCCredentials {
|
|
18
|
+
keyId: string;
|
|
19
|
+
issuerId: string;
|
|
20
|
+
privateKey: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Token validity duration in seconds (15 minutes, ASC max is 20)
|
|
25
|
+
*/
|
|
26
|
+
const TOKEN_VALIDITY_SECONDS = 15 * 60;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Refresh threshold in seconds (refresh when < 2 minutes remaining)
|
|
30
|
+
*/
|
|
31
|
+
const REFRESH_THRESHOLD_SECONDS = 2 * 60;
|
|
32
|
+
|
|
33
|
+
let tokenCache: TokenCache | null = null;
|
|
34
|
+
let cachedCredentials: ASCCredentials | null = null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Base64url encode (RFC 4648)
|
|
38
|
+
*/
|
|
39
|
+
function base64urlEncode(data: Buffer | string): string {
|
|
40
|
+
const buffer = typeof data === 'string' ? Buffer.from(data) : data;
|
|
41
|
+
return buffer.toString('base64url');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create JWT header
|
|
46
|
+
*/
|
|
47
|
+
function createHeader(keyId: string): string {
|
|
48
|
+
const header = {
|
|
49
|
+
alg: 'ES256',
|
|
50
|
+
kid: keyId,
|
|
51
|
+
typ: 'JWT',
|
|
52
|
+
};
|
|
53
|
+
return base64urlEncode(JSON.stringify(header));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create JWT payload
|
|
58
|
+
*/
|
|
59
|
+
function createPayload(issuerId: string): string {
|
|
60
|
+
const now = Math.floor(Date.now() / 1000);
|
|
61
|
+
const payload = {
|
|
62
|
+
iss: issuerId,
|
|
63
|
+
iat: now,
|
|
64
|
+
exp: now + TOKEN_VALIDITY_SECONDS,
|
|
65
|
+
aud: 'appstoreconnect-v1',
|
|
66
|
+
};
|
|
67
|
+
return base64urlEncode(JSON.stringify(payload));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Sign the JWT using ES256
|
|
72
|
+
*/
|
|
73
|
+
function signJWT(header: string, payload: string, privateKey: string): string {
|
|
74
|
+
const signingInput = `${header}.${payload}`;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const sign = crypto.createSign('SHA256');
|
|
78
|
+
sign.update(signingInput);
|
|
79
|
+
sign.end();
|
|
80
|
+
|
|
81
|
+
// Sign and get DER-encoded signature
|
|
82
|
+
const derSignature = sign.sign({
|
|
83
|
+
key: privateKey,
|
|
84
|
+
dsaEncoding: 'ieee-p1363', // Get raw r||s format instead of DER
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return base64urlEncode(derSignature);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new ASCAuthError(
|
|
90
|
+
`Failed to sign JWT: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Load credentials from environment variables
|
|
97
|
+
*/
|
|
98
|
+
async function loadCredentials(): Promise<ASCCredentials> {
|
|
99
|
+
if (cachedCredentials) {
|
|
100
|
+
return cachedCredentials;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const keyId = process.env['ASC_KEY_ID'];
|
|
104
|
+
const issuerId = process.env['ASC_ISSUER_ID'];
|
|
105
|
+
const privateKeyPath = process.env['ASC_PRIVATE_KEY_PATH'];
|
|
106
|
+
const privateKeyEnv = process.env['ASC_PRIVATE_KEY'];
|
|
107
|
+
|
|
108
|
+
if (!keyId || !issuerId) {
|
|
109
|
+
throw new ASCCredentialsNotConfiguredError();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let privateKey: string;
|
|
113
|
+
|
|
114
|
+
if (privateKeyEnv) {
|
|
115
|
+
// Use inline private key from environment
|
|
116
|
+
privateKey = privateKeyEnv;
|
|
117
|
+
// Handle escaped newlines
|
|
118
|
+
if (!privateKey.includes('\n') && privateKey.includes('\\n')) {
|
|
119
|
+
privateKey = privateKey.replace(/\\n/g, '\n');
|
|
120
|
+
}
|
|
121
|
+
} else if (privateKeyPath) {
|
|
122
|
+
// Load from file
|
|
123
|
+
try {
|
|
124
|
+
privateKey = await fs.readFile(privateKeyPath, 'utf-8');
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new ASCAuthError(
|
|
127
|
+
`Failed to read private key from ${privateKeyPath}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
throw new ASCCredentialsNotConfiguredError();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Validate the private key format
|
|
135
|
+
if (!privateKey.includes('-----BEGIN PRIVATE KEY-----')) {
|
|
136
|
+
throw new ASCAuthError(
|
|
137
|
+
'Invalid private key format. Expected PEM format starting with "-----BEGIN PRIVATE KEY-----"'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
cachedCredentials = { keyId, issuerId, privateKey };
|
|
142
|
+
return cachedCredentials;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Generate a new JWT token
|
|
147
|
+
*/
|
|
148
|
+
async function generateToken(): Promise<string> {
|
|
149
|
+
const credentials = await loadCredentials();
|
|
150
|
+
|
|
151
|
+
const header = createHeader(credentials.keyId);
|
|
152
|
+
const payload = createPayload(credentials.issuerId);
|
|
153
|
+
const signature = signJWT(header, payload, credentials.privateKey);
|
|
154
|
+
|
|
155
|
+
return `${header}.${payload}.${signature}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check if the cached token needs refresh
|
|
160
|
+
*/
|
|
161
|
+
function needsRefresh(): boolean {
|
|
162
|
+
if (!tokenCache) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const timeRemaining = tokenCache.expiresAt - now;
|
|
168
|
+
return timeRemaining < REFRESH_THRESHOLD_SECONDS * 1000;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get a valid JWT token, generating or refreshing if needed
|
|
173
|
+
*/
|
|
174
|
+
export async function getToken(): Promise<string> {
|
|
175
|
+
if (needsRefresh()) {
|
|
176
|
+
const token = await generateToken();
|
|
177
|
+
tokenCache = {
|
|
178
|
+
token,
|
|
179
|
+
expiresAt: Date.now() + TOKEN_VALIDITY_SECONDS * 1000,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return tokenCache!.token;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Clear the token cache (useful for testing or credential changes)
|
|
188
|
+
*/
|
|
189
|
+
export function clearTokenCache(): void {
|
|
190
|
+
tokenCache = null;
|
|
191
|
+
cachedCredentials = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check if credentials are currently configured
|
|
196
|
+
*/
|
|
197
|
+
export function hasCredentials(): boolean {
|
|
198
|
+
const keyId = process.env['ASC_KEY_ID'];
|
|
199
|
+
const issuerId = process.env['ASC_ISSUER_ID'];
|
|
200
|
+
const privateKeyPath = process.env['ASC_PRIVATE_KEY_PATH'];
|
|
201
|
+
const privateKey = process.env['ASC_PRIVATE_KEY'];
|
|
202
|
+
|
|
203
|
+
return !!(keyId && issuerId && (privateKeyPath ?? privateKey));
|
|
204
|
+
}
|