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.
Files changed (205) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/actions/ios-review/action.yml +106 -0
  3. package/.github/workflows/ci.yml +103 -0
  4. package/.github/workflows/publish.yml +57 -0
  5. package/CHANGELOG.md +66 -0
  6. package/CONTRIBUTING.md +175 -0
  7. package/LICENSE +21 -0
  8. package/README.md +205 -0
  9. package/bitrise/step.sh +128 -0
  10. package/bitrise/step.yml +101 -0
  11. package/dist/analyzer.d.ts.map +1 -0
  12. package/dist/analyzers/asc-iap.d.ts.map +1 -0
  13. package/dist/analyzers/asc-metadata.d.ts.map +1 -0
  14. package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
  15. package/dist/analyzers/asc-version.d.ts.map +1 -0
  16. package/dist/analyzers/code-scanner.d.ts.map +1 -0
  17. package/dist/analyzers/deprecated-api.d.ts.map +1 -0
  18. package/dist/analyzers/entitlements.d.ts.map +1 -0
  19. package/dist/analyzers/index.d.ts.map +1 -0
  20. package/dist/analyzers/info-plist.d.ts.map +1 -0
  21. package/dist/analyzers/privacy.d.ts.map +1 -0
  22. package/dist/analyzers/private-api.d.ts.map +1 -0
  23. package/dist/analyzers/security.d.ts.map +1 -0
  24. package/dist/analyzers/ui-ux.d.ts.map +1 -0
  25. package/dist/asc/auth.d.ts.map +1 -0
  26. package/dist/asc/client.d.ts.map +1 -0
  27. package/dist/asc/endpoints/apps.d.ts.map +1 -0
  28. package/dist/asc/endpoints/iap.d.ts.map +1 -0
  29. package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
  30. package/dist/asc/endpoints/versions.d.ts.map +1 -0
  31. package/dist/asc/errors.d.ts.map +1 -0
  32. package/dist/asc/index.d.ts.map +1 -0
  33. package/dist/asc/types.d.ts.map +1 -0
  34. package/dist/badge/generator.d.ts.map +1 -0
  35. package/dist/badge/index.d.ts.map +1 -0
  36. package/dist/badge/types.d.ts.map +1 -0
  37. package/dist/cache/file-cache.d.ts.map +1 -0
  38. package/dist/cache/index.d.ts.map +1 -0
  39. package/dist/cache/types.d.ts.map +1 -0
  40. package/dist/cli/commands/help.d.ts.map +1 -0
  41. package/dist/cli/commands/scan.d.ts.map +1 -0
  42. package/dist/cli/commands/version.d.ts.map +1 -0
  43. package/dist/cli/index.d.ts.map +1 -0
  44. package/dist/cli/types.d.ts.map +1 -0
  45. package/dist/git/diff.d.ts.map +1 -0
  46. package/dist/git/index.d.ts.map +1 -0
  47. package/dist/git/types.d.ts.map +1 -0
  48. package/dist/guidelines/database.d.ts.map +1 -0
  49. package/dist/guidelines/index.d.ts.map +1 -0
  50. package/dist/guidelines/matcher.d.ts.map +1 -0
  51. package/dist/guidelines/types.d.ts.map +1 -0
  52. package/dist/history/comparator.d.ts.map +1 -0
  53. package/dist/history/index.d.ts.map +1 -0
  54. package/dist/history/store.d.ts.map +1 -0
  55. package/dist/history/types.d.ts.map +1 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +994 -0
  58. package/dist/parsers/index.d.ts.map +1 -0
  59. package/dist/parsers/plist.d.ts.map +1 -0
  60. package/dist/parsers/xcodeproj.d.ts.map +1 -0
  61. package/dist/progress/index.d.ts.map +1 -0
  62. package/dist/progress/reporter.d.ts.map +1 -0
  63. package/dist/progress/types.d.ts.map +1 -0
  64. package/dist/reports/html.d.ts.map +1 -0
  65. package/dist/reports/index.d.ts.map +1 -0
  66. package/dist/reports/json.d.ts.map +1 -0
  67. package/dist/reports/markdown.d.ts.map +1 -0
  68. package/dist/reports/types.d.ts.map +1 -0
  69. package/dist/rules/engine.d.ts.map +1 -0
  70. package/dist/rules/index.d.ts.map +1 -0
  71. package/dist/rules/loader.d.ts.map +1 -0
  72. package/dist/rules/types.d.ts.map +1 -0
  73. package/dist/types/index.d.ts.map +1 -0
  74. package/docs/ANALYZERS.md +237 -0
  75. package/docs/API.md +308 -0
  76. package/docs/BADGES.md +130 -0
  77. package/docs/CI_CD.md +283 -0
  78. package/docs/CLI.md +140 -0
  79. package/docs/REPORTS.md +212 -0
  80. package/docs/ROADMAP.md +267 -0
  81. package/docs/RULES.md +182 -0
  82. package/docs/SECURITY.md +89 -0
  83. package/docs/TROUBLESHOOTING.md +227 -0
  84. package/docs/tutorials/ASC_SETUP.md +188 -0
  85. package/docs/tutorials/CI_INTEGRATION.md +292 -0
  86. package/docs/tutorials/CUSTOM_RULES.md +291 -0
  87. package/docs/tutorials/GETTING_STARTED.md +226 -0
  88. package/docs/video-scripts/01-introduction.md +106 -0
  89. package/docs/video-scripts/02-cli-usage.md +120 -0
  90. package/docs/video-scripts/03-ci-integration.md +198 -0
  91. package/eslint.config.js +33 -0
  92. package/examples/.ios-review-rules.json +82 -0
  93. package/examples/bitrise-workflow.yml +129 -0
  94. package/examples/fastlane-lane.rb +71 -0
  95. package/examples/github-action.yml +147 -0
  96. package/fastlane/Fastfile.example +114 -0
  97. package/fastlane/README.md +99 -0
  98. package/jest.config.js +36 -0
  99. package/package.json +65 -0
  100. package/scripts/benchmark.ts +112 -0
  101. package/scripts/debug-parser.ts +37 -0
  102. package/scripts/debug-pbxproj.ts +36 -0
  103. package/scripts/debug-specific.ts +47 -0
  104. package/scripts/test-analyze.ts +67 -0
  105. package/scripts/xcode-cloud-review.sh +167 -0
  106. package/src/analyzer.ts +227 -0
  107. package/src/analyzers/asc-iap.ts +300 -0
  108. package/src/analyzers/asc-metadata.ts +326 -0
  109. package/src/analyzers/asc-screenshots.ts +310 -0
  110. package/src/analyzers/asc-version.ts +368 -0
  111. package/src/analyzers/code-scanner.ts +408 -0
  112. package/src/analyzers/deprecated-api.ts +390 -0
  113. package/src/analyzers/entitlements.ts +345 -0
  114. package/src/analyzers/index.ts +12 -0
  115. package/src/analyzers/info-plist.ts +409 -0
  116. package/src/analyzers/privacy.ts +376 -0
  117. package/src/analyzers/private-api.ts +377 -0
  118. package/src/analyzers/security.ts +327 -0
  119. package/src/analyzers/ui-ux.ts +509 -0
  120. package/src/asc/auth.ts +204 -0
  121. package/src/asc/client.ts +258 -0
  122. package/src/asc/endpoints/apps.ts +115 -0
  123. package/src/asc/endpoints/iap.ts +171 -0
  124. package/src/asc/endpoints/screenshots.ts +164 -0
  125. package/src/asc/endpoints/versions.ts +174 -0
  126. package/src/asc/errors.ts +109 -0
  127. package/src/asc/index.ts +108 -0
  128. package/src/asc/types.ts +369 -0
  129. package/src/badge/generator.ts +48 -0
  130. package/src/badge/index.ts +2 -0
  131. package/src/badge/types.ts +5 -0
  132. package/src/cache/file-cache.ts +75 -0
  133. package/src/cache/index.ts +2 -0
  134. package/src/cache/types.ts +10 -0
  135. package/src/cli/commands/help.ts +41 -0
  136. package/src/cli/commands/scan.ts +44 -0
  137. package/src/cli/commands/version.ts +12 -0
  138. package/src/cli/index.ts +92 -0
  139. package/src/cli/types.ts +17 -0
  140. package/src/git/diff.ts +21 -0
  141. package/src/git/index.ts +2 -0
  142. package/src/git/types.ts +5 -0
  143. package/src/guidelines/database.ts +344 -0
  144. package/src/guidelines/index.ts +4 -0
  145. package/src/guidelines/matcher.ts +84 -0
  146. package/src/guidelines/types.ts +28 -0
  147. package/src/history/comparator.ts +114 -0
  148. package/src/history/index.ts +3 -0
  149. package/src/history/store.ts +135 -0
  150. package/src/history/types.ts +40 -0
  151. package/src/index.ts +1113 -0
  152. package/src/parsers/index.ts +3 -0
  153. package/src/parsers/plist.ts +253 -0
  154. package/src/parsers/xcodeproj.ts +265 -0
  155. package/src/progress/index.ts +2 -0
  156. package/src/progress/reporter.ts +65 -0
  157. package/src/progress/types.ts +9 -0
  158. package/src/reports/html.ts +322 -0
  159. package/src/reports/index.ts +20 -0
  160. package/src/reports/json.ts +92 -0
  161. package/src/reports/markdown.ts +187 -0
  162. package/src/reports/types.ts +26 -0
  163. package/src/rules/engine.ts +121 -0
  164. package/src/rules/index.ts +3 -0
  165. package/src/rules/loader.ts +83 -0
  166. package/src/rules/types.ts +25 -0
  167. package/src/types/index.ts +247 -0
  168. package/tests/analyzer.test.ts +142 -0
  169. package/tests/analyzers/asc-iap.test.ts +228 -0
  170. package/tests/analyzers/asc-metadata.test.ts +210 -0
  171. package/tests/analyzers/asc-screenshots.test.ts +135 -0
  172. package/tests/analyzers/asc-version.test.ts +259 -0
  173. package/tests/analyzers/code-scanner.test.ts +745 -0
  174. package/tests/analyzers/deprecated-api.test.ts +286 -0
  175. package/tests/analyzers/entitlements.test.ts +411 -0
  176. package/tests/analyzers/info-plist.test.ts +148 -0
  177. package/tests/analyzers/privacy.test.ts +623 -0
  178. package/tests/analyzers/private-api.test.ts +255 -0
  179. package/tests/analyzers/security.test.ts +300 -0
  180. package/tests/analyzers/ui-ux.test.ts +357 -0
  181. package/tests/asc/auth.test.ts +189 -0
  182. package/tests/asc/client.test.ts +207 -0
  183. package/tests/asc/endpoints.test.ts +1359 -0
  184. package/tests/badge/generator.test.ts +73 -0
  185. package/tests/cache/file-cache.test.ts +124 -0
  186. package/tests/cli/cli-index.test.ts +510 -0
  187. package/tests/cli/commands.test.ts +67 -0
  188. package/tests/cli/scan.test.ts +152 -0
  189. package/tests/git/diff.test.ts +69 -0
  190. package/tests/guidelines/matcher.test.ts +209 -0
  191. package/tests/history/comparator.test.ts +272 -0
  192. package/tests/history/store.test.ts +200 -0
  193. package/tests/integration/cli.test.ts +95 -0
  194. package/tests/integration/e2e.test.ts +130 -0
  195. package/tests/parsers/plist.test.ts +240 -0
  196. package/tests/parsers/xcodeproj.test.ts +289 -0
  197. package/tests/progress/reporter.test.ts +117 -0
  198. package/tests/reports/html.test.ts +176 -0
  199. package/tests/reports/json.test.ts +235 -0
  200. package/tests/reports/markdown.test.ts +196 -0
  201. package/tests/rules/engine.test.ts +229 -0
  202. package/tests/rules/loader.test.ts +187 -0
  203. package/tests/setup.ts +15 -0
  204. package/tsconfig.json +27 -0
  205. 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
+ }
@@ -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
+ }