@mcamdali/sdkbump 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.de.md +251 -0
- package/README.es.md +252 -0
- package/README.fr.md +252 -0
- package/README.ja.md +250 -0
- package/README.ko.md +250 -0
- package/README.md +322 -2
- package/README.pt-BR.md +252 -0
- package/README.ru.md +252 -0
- package/README.tr.md +338 -0
- package/README.zh-CN.md +250 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +11 -0
- package/dist/cli/run.d.ts +29 -0
- package/dist/cli/run.js +111 -0
- package/dist/collect/app.d.ts +48 -0
- package/dist/collect/app.js +117 -0
- package/dist/collect/blank.d.ts +6 -0
- package/dist/collect/blank.js +75 -0
- package/dist/collect/pbxproj.d.ts +45 -0
- package/dist/collect/pbxproj.js +260 -0
- package/dist/collect/plist-binary.d.ts +2 -0
- package/dist/collect/plist-binary.js +123 -0
- package/dist/collect/plist-value.d.ts +15 -0
- package/dist/collect/plist-value.js +14 -0
- package/dist/collect/plist-xml.d.ts +2 -0
- package/dist/collect/plist-xml.js +195 -0
- package/dist/collect/plist.d.ts +6 -0
- package/dist/collect/plist.js +19 -0
- package/dist/collect/regions.d.ts +9 -0
- package/dist/collect/regions.js +108 -0
- package/dist/collect/sources.d.ts +45 -0
- package/dist/collect/sources.js +99 -0
- package/dist/core/files.d.ts +22 -0
- package/dist/core/files.js +47 -0
- package/dist/core/lines.d.ts +2 -0
- package/dist/core/lines.js +21 -0
- package/dist/core/versions.d.ts +18 -0
- package/dist/core/versions.js +63 -0
- package/dist/detect/project.d.ts +46 -0
- package/dist/detect/project.js +220 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +27 -0
- package/dist/model.d.ts +93 -0
- package/dist/model.js +2 -0
- package/dist/report/index.d.ts +12 -0
- package/dist/report/index.js +22 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +113 -0
- package/dist/report/text.d.ts +27 -0
- package/dist/report/text.js +63 -0
- package/dist/report/tty.d.ts +7 -0
- package/dist/report/tty.js +112 -0
- package/dist/rules/check.d.ts +11 -0
- package/dist/rules/check.js +253 -0
- package/dist/rules/data.d.ts +217 -0
- package/dist/rules/data.js +141 -0
- package/dist/rules/fixes.d.ts +28 -0
- package/dist/rules/fixes.js +161 -0
- package/dist/rules/patterns.d.ts +29 -0
- package/dist/rules/patterns.js +109 -0
- package/dist/rules/query.d.ts +16 -0
- package/dist/rules/query.js +50 -0
- package/dist/scan.d.ts +10 -0
- package/dist/scan.js +115 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +7 -0
- package/package.json +69 -4
- package/rules/ios27.json +613 -0
- package/rules/schema.json +449 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The rule data (rules/ios27.json): what each rule says, the fixes and workarounds sdkbump can
|
|
2
|
+
// suggest, and the source every statement rests on. Validated with zod when loaded; the JSON
|
|
3
|
+
// schema in rules/schema.json is generated from the same definition.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const Source = z
|
|
7
|
+
.object({
|
|
8
|
+
title: z.string().min(1),
|
|
9
|
+
url: z.url({ protocol: /^https$/ }),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
const Ecosystem = z.enum(['native', 'flutter', 'react-native', 'expo']);
|
|
13
|
+
/** The rules of this rule set. */
|
|
14
|
+
export const RULE_IDS = ['SCN001', 'SCN002', 'SCN003', 'SCN004'];
|
|
15
|
+
/** Severities, highest first. */
|
|
16
|
+
export const SEVERITIES = ['error', 'warning', 'info'];
|
|
17
|
+
const RuleIdSchema = z.enum(RULE_IDS);
|
|
18
|
+
const SeveritySchema = z.enum(SEVERITIES);
|
|
19
|
+
/** Every rule, hook, lookup, fix and workaround cites at least one source. */
|
|
20
|
+
const Sources = z.array(Source).min(1);
|
|
21
|
+
const Rule = z
|
|
22
|
+
.object({
|
|
23
|
+
id: RuleIdSchema,
|
|
24
|
+
severity: SeveritySchema,
|
|
25
|
+
title: z.string().min(1),
|
|
26
|
+
summary: z.string().min(1),
|
|
27
|
+
sources: Sources,
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
const Hook = z
|
|
31
|
+
.object({
|
|
32
|
+
id: z.string().regex(/^[a-z-]+$/),
|
|
33
|
+
kind: z.enum(['method', 'launch-option']),
|
|
34
|
+
swift: z.string().min(1),
|
|
35
|
+
objc: z.string().min(1),
|
|
36
|
+
replacement: z.string().min(1),
|
|
37
|
+
swiftUI: z.string().nullable(),
|
|
38
|
+
sources: Sources,
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
const WindowLookup = z
|
|
42
|
+
.object({
|
|
43
|
+
id: z.string().regex(/^[a-z-]+$/),
|
|
44
|
+
title: z.string().min(1),
|
|
45
|
+
api: z.string().min(1),
|
|
46
|
+
message: z.string().min(1),
|
|
47
|
+
sources: Sources,
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
const Fix = z
|
|
51
|
+
.object({
|
|
52
|
+
id: z.string().regex(/^[a-z0-9-]+$/),
|
|
53
|
+
ecosystems: z.array(Ecosystem).min(1),
|
|
54
|
+
title: z.string().min(1),
|
|
55
|
+
steps: z.array(z.string().min(1)).min(1),
|
|
56
|
+
guide: z.string().regex(/^docs\/guides\/[a-z0-9-]+\.md$/),
|
|
57
|
+
sources: Sources,
|
|
58
|
+
})
|
|
59
|
+
.strict();
|
|
60
|
+
const Workaround = z
|
|
61
|
+
.object({
|
|
62
|
+
package: z.string().min(1),
|
|
63
|
+
ecosystems: z.array(Ecosystem).min(1),
|
|
64
|
+
mitigates: z.array(RuleIdSchema).min(1),
|
|
65
|
+
summary: z.string().min(1),
|
|
66
|
+
limits: z.array(z.string().min(1)),
|
|
67
|
+
sources: Sources,
|
|
68
|
+
})
|
|
69
|
+
.strict();
|
|
70
|
+
const Version = z.string().regex(/^\d+\.\d+\.\d+$/);
|
|
71
|
+
const Sha256 = z.string().regex(/^[0-9a-f]{64}$/);
|
|
72
|
+
export const RuleDataSchema = z
|
|
73
|
+
.object({
|
|
74
|
+
$schema: z.string().optional(),
|
|
75
|
+
ruleSet: z.string().regex(/^[a-z0-9]+$/),
|
|
76
|
+
/** Date of the last change, YYYY.MM.DD. */
|
|
77
|
+
dataVersion: z.string().regex(/^\d{4}\.\d{2}\.\d{2}$/),
|
|
78
|
+
sdk: z.string().min(1),
|
|
79
|
+
rules: z.array(Rule).min(1),
|
|
80
|
+
hooks: z.array(Hook).min(1),
|
|
81
|
+
windowLookups: z.array(WindowLookup).min(1),
|
|
82
|
+
fixes: z.array(Fix).min(1),
|
|
83
|
+
workarounds: z.array(Workaround),
|
|
84
|
+
milestones: z
|
|
85
|
+
.object({
|
|
86
|
+
reactNativeScenes: Version,
|
|
87
|
+
expoSceneSupport: Version,
|
|
88
|
+
expoBuildProperties: Version,
|
|
89
|
+
expoScenesByDefault: Version,
|
|
90
|
+
flutterAutoMigration: Version,
|
|
91
|
+
})
|
|
92
|
+
.strict(),
|
|
93
|
+
/** Scene delegate base classes that pass scene events on to the app delegate. */
|
|
94
|
+
sceneForwarders: z.array(z.string().min(1)),
|
|
95
|
+
/** SHA-256 of the trimmed AppDelegate files Flutter's migrator recognises as unmodified. */
|
|
96
|
+
flutterTemplates: z
|
|
97
|
+
.object({
|
|
98
|
+
source: z.url({ protocol: /^https$/ }),
|
|
99
|
+
swift: z.array(Sha256).min(1),
|
|
100
|
+
objcHeader: Sha256,
|
|
101
|
+
objcImplementation: Sha256,
|
|
102
|
+
})
|
|
103
|
+
.strict(),
|
|
104
|
+
})
|
|
105
|
+
.strict()
|
|
106
|
+
.superRefine((data, context) => {
|
|
107
|
+
const unique = (label, ids) => {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
for (const id of ids) {
|
|
110
|
+
if (seen.has(id))
|
|
111
|
+
context.addIssue({ code: 'custom', message: `Duplicate ${label} id ${id}` });
|
|
112
|
+
seen.add(id);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
unique('rule', data.rules.map((rule) => rule.id));
|
|
116
|
+
unique('hook', data.hooks.map((hook) => hook.id));
|
|
117
|
+
unique('window lookup', data.windowLookups.map((lookup) => lookup.id));
|
|
118
|
+
unique('fix', data.fixes.map((fix) => fix.id));
|
|
119
|
+
});
|
|
120
|
+
export class RuleDataError extends Error {
|
|
121
|
+
name = 'RuleDataError';
|
|
122
|
+
}
|
|
123
|
+
/** Validates rule data; throws RuleDataError listing every problem. */
|
|
124
|
+
export function parseRuleData(value) {
|
|
125
|
+
const result = RuleDataSchema.safeParse(value);
|
|
126
|
+
if (result.success)
|
|
127
|
+
return result.data;
|
|
128
|
+
const problems = result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`);
|
|
129
|
+
throw new RuleDataError(`Invalid rule data:\n${problems.join('\n')}`);
|
|
130
|
+
}
|
|
131
|
+
let cached;
|
|
132
|
+
/** The bundled rules/ios27.json (one level above src/ and dist/). */
|
|
133
|
+
export function loadRuleData() {
|
|
134
|
+
cached ??= parseRuleData(JSON.parse(readFileSync(new URL('../../rules/ios27.json', import.meta.url), 'utf8')));
|
|
135
|
+
return cached;
|
|
136
|
+
}
|
|
137
|
+
/** JSON schema of the rule data, written to rules/schema.json. */
|
|
138
|
+
export function ruleDataJsonSchema() {
|
|
139
|
+
return z.toJSONSchema(RuleDataSchema, { io: 'input', unrepresentable: 'any' });
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=data.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type AppModel } from '../collect/app.js';
|
|
2
|
+
import type { FixAdvice, Mitigation } from '../model.js';
|
|
3
|
+
import type { HookDefinition, RuleData, RuleId } from './data.js';
|
|
4
|
+
/** Where guides are linked from reports. */
|
|
5
|
+
export declare const GUIDE_BASE = "https://github.com/muhittincamdali/sdkbump/blob/main/";
|
|
6
|
+
export interface Context {
|
|
7
|
+
app: AppModel;
|
|
8
|
+
data: RuleData;
|
|
9
|
+
/** Flutter auto-migration eligibility, computed before the rules run. */
|
|
10
|
+
flutter?: FlutterMigration;
|
|
11
|
+
}
|
|
12
|
+
export interface FlutterMigration {
|
|
13
|
+
eligible: boolean;
|
|
14
|
+
/** Why the Flutter tool will not migrate the app by itself. */
|
|
15
|
+
reasons: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare function fixById(data: RuleData, id: string): FixAdvice;
|
|
18
|
+
export declare function installedWorkaround(context: Context, rule: RuleId): Mitigation | null;
|
|
19
|
+
/** Mirrors the conditions of Flutter's UISceneMigration (flutter_tools, 3.41 and later). */
|
|
20
|
+
export declare function flutterMigration(app: AppModel, data: RuleData): Promise<FlutterMigration>;
|
|
21
|
+
export declare function bridgeAlternative(context: Context): FixAdvice[];
|
|
22
|
+
/** The fix for an app without scenes, by ecosystem and version. */
|
|
23
|
+
export declare function adoptScenesFix(context: Context): {
|
|
24
|
+
fix: FixAdvice;
|
|
25
|
+
alternatives: FixAdvice[];
|
|
26
|
+
};
|
|
27
|
+
export declare function expoFix(context: Context): FixAdvice;
|
|
28
|
+
export declare function hookFix(context: Context, hook: HookDefinition, swiftUI: boolean): FixAdvice;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Which fix applies to a finding: by ecosystem, version and (for Flutter) whether the Flutter tool
|
|
2
|
+
// can migrate the app by itself; and which installed workaround handles it at run time.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { flutterAppDelegatePaths } from '../collect/app.js';
|
|
5
|
+
import { readProjectText } from '../core/files.js';
|
|
6
|
+
import { atLeast, majorOf } from '../core/versions.js';
|
|
7
|
+
/** Where guides are linked from reports. */
|
|
8
|
+
export const GUIDE_BASE = 'https://github.com/muhittincamdali/sdkbump/blob/main/';
|
|
9
|
+
export function fixById(data, id) {
|
|
10
|
+
const fix = data.fixes.find((candidate) => candidate.id === id);
|
|
11
|
+
if (!fix)
|
|
12
|
+
throw new Error(`Rule data has no fix ${id}.`);
|
|
13
|
+
return toAdvice(fix);
|
|
14
|
+
}
|
|
15
|
+
const toAdvice = (fix) => ({
|
|
16
|
+
id: fix.id,
|
|
17
|
+
title: fix.title,
|
|
18
|
+
steps: fix.steps,
|
|
19
|
+
guide: GUIDE_BASE + fix.guide,
|
|
20
|
+
sources: fix.sources,
|
|
21
|
+
});
|
|
22
|
+
function workaroundAdvice(workaround) {
|
|
23
|
+
return {
|
|
24
|
+
id: `workaround-${workaround.package}`,
|
|
25
|
+
title: `Temporary: install ${workaround.package}`,
|
|
26
|
+
steps: [workaround.summary, ...workaround.limits.map((limit) => `Limit: ${limit}`)],
|
|
27
|
+
guide: `${GUIDE_BASE}docs/guides/react-native-expo.md`,
|
|
28
|
+
sources: workaround.sources,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function installedWorkaround(context, rule) {
|
|
32
|
+
const { project } = context.app;
|
|
33
|
+
for (const workaround of context.data.workarounds) {
|
|
34
|
+
const installed = project.packages.get(workaround.package);
|
|
35
|
+
if (!installed || !workaround.mitigates.includes(rule))
|
|
36
|
+
continue;
|
|
37
|
+
if (!workaround.ecosystems.includes(project.ecosystem))
|
|
38
|
+
continue;
|
|
39
|
+
return {
|
|
40
|
+
package: workaround.package,
|
|
41
|
+
version: installed.version,
|
|
42
|
+
summary: workaround.summary,
|
|
43
|
+
limits: workaround.limits,
|
|
44
|
+
sources: workaround.sources,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------------------------------------
|
|
50
|
+
// Fix selection
|
|
51
|
+
const sha256 = (text) => createHash('sha256').update(text.trim()).digest('hex');
|
|
52
|
+
/** Mirrors the conditions of Flutter's UISceneMigration (flutter_tools, 3.41 and later). */
|
|
53
|
+
export async function flutterMigration(app, data) {
|
|
54
|
+
const reasons = [];
|
|
55
|
+
const { project } = app;
|
|
56
|
+
if (project.flutter?.migrationDisabled) {
|
|
57
|
+
reasons.push('pubspec.yaml sets enable-uiscene-migration: false.');
|
|
58
|
+
}
|
|
59
|
+
const runner = app.targets.find((target) => target.name === 'Runner') ?? app.targets[0];
|
|
60
|
+
const storyboard = runner?.infoPlists[0]?.dict.UIMainStoryboardFile;
|
|
61
|
+
if (storyboard !== 'Main') {
|
|
62
|
+
reasons.push('Info.plist does not set UIMainStoryboardFile to Main.');
|
|
63
|
+
}
|
|
64
|
+
const paths = flutterAppDelegatePaths(project.iosDir ?? project.root);
|
|
65
|
+
const read = (path) => readProjectText(path, project.root);
|
|
66
|
+
const swift = await read(paths.swift);
|
|
67
|
+
if (swift !== null) {
|
|
68
|
+
if (!data.flutterTemplates.swift.includes(sha256(swift))) {
|
|
69
|
+
reasons.push('ios/Runner/AppDelegate.swift differs from the templates Flutter migrates.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const [header, implementation] = await Promise.all([
|
|
74
|
+
read(paths.objcHeader),
|
|
75
|
+
read(paths.objcImplementation),
|
|
76
|
+
]);
|
|
77
|
+
if (header === null ||
|
|
78
|
+
implementation === null ||
|
|
79
|
+
sha256(header) !== data.flutterTemplates.objcHeader ||
|
|
80
|
+
sha256(implementation) !== data.flutterTemplates.objcImplementation) {
|
|
81
|
+
reasons.push('ios/Runner/AppDelegate.h and .m differ from the template Flutter migrates.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { eligible: reasons.length === 0, reasons };
|
|
85
|
+
}
|
|
86
|
+
export function bridgeAlternative(context) {
|
|
87
|
+
const { project } = context.app;
|
|
88
|
+
if (project.ecosystem !== 'react-native' && project.ecosystem !== 'expo')
|
|
89
|
+
return [];
|
|
90
|
+
if (context.data.workarounds.some((workaround) => project.packages.has(workaround.package))) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const bridge = context.data.workarounds.find((workaround) => workaround.package === 'react-native-uiscene-bridge');
|
|
94
|
+
return bridge ? [workaroundAdvice(bridge)] : [];
|
|
95
|
+
}
|
|
96
|
+
/** The fix for an app without scenes, by ecosystem and version. */
|
|
97
|
+
export function adoptScenesFix(context) {
|
|
98
|
+
const { data, app } = context;
|
|
99
|
+
const { project } = app;
|
|
100
|
+
switch (project.ecosystem) {
|
|
101
|
+
case 'native':
|
|
102
|
+
return { fix: fixById(data, 'native-adopt-scenes'), alternatives: [] };
|
|
103
|
+
case 'flutter':
|
|
104
|
+
return context.flutter?.eligible
|
|
105
|
+
? {
|
|
106
|
+
fix: fixById(data, 'flutter-auto-migrate'),
|
|
107
|
+
alternatives: [fixById(data, 'flutter-manual-migration')],
|
|
108
|
+
}
|
|
109
|
+
: { fix: fixById(data, 'flutter-manual-migration'), alternatives: [] };
|
|
110
|
+
case 'react-native': {
|
|
111
|
+
const version = project.packages.get('react-native')?.version;
|
|
112
|
+
const upgraded = version && atLeast(version, data.milestones.reactNativeScenes);
|
|
113
|
+
return upgraded
|
|
114
|
+
? {
|
|
115
|
+
fix: fixById(data, 'rn-manual-scene-delegate'),
|
|
116
|
+
alternatives: bridgeAlternative(context),
|
|
117
|
+
}
|
|
118
|
+
: {
|
|
119
|
+
fix: fixById(data, 'rn-upgrade-088'),
|
|
120
|
+
alternatives: [
|
|
121
|
+
fixById(data, 'rn-manual-scene-delegate'),
|
|
122
|
+
...bridgeAlternative(context),
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case 'expo':
|
|
127
|
+
return { fix: expoFix(context), alternatives: bridgeAlternative(context) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function expoFix(context) {
|
|
131
|
+
const { data, app } = context;
|
|
132
|
+
const version = app.project.packages.get('expo')?.version ?? null;
|
|
133
|
+
const major = version ? majorOf(version) : null;
|
|
134
|
+
if (major !== null && major >= (majorOf(data.milestones.expoScenesByDefault) ?? 58)) {
|
|
135
|
+
return fixById(data, 'expo-prebuild');
|
|
136
|
+
}
|
|
137
|
+
if (major === (majorOf(data.milestones.expoSceneSupport) ?? 57)) {
|
|
138
|
+
return app.project.expoConfig?.enableSceneSupport === true && app.project.iosDir
|
|
139
|
+
? fixById(data, 'expo-prebuild')
|
|
140
|
+
: fixById(data, 'expo-57-scene-support');
|
|
141
|
+
}
|
|
142
|
+
return fixById(data, 'expo-upgrade');
|
|
143
|
+
}
|
|
144
|
+
export function hookFix(context, hook, swiftUI) {
|
|
145
|
+
const { data, app } = context;
|
|
146
|
+
switch (app.project.ecosystem) {
|
|
147
|
+
case 'flutter':
|
|
148
|
+
return fixById(data, 'flutter-scene-delegate-hooks');
|
|
149
|
+
case 'react-native':
|
|
150
|
+
return hook.id === 'open-url' || hook.id === 'continue-user-activity'
|
|
151
|
+
? fixById(data, 'rn-linking-scene')
|
|
152
|
+
: fixById(data, 'native-move-hook');
|
|
153
|
+
case 'expo':
|
|
154
|
+
return expoFix(context);
|
|
155
|
+
case 'native':
|
|
156
|
+
return swiftUI && hook.swiftUI
|
|
157
|
+
? fixById(data, 'swiftui-modifier')
|
|
158
|
+
: fixById(data, 'native-move-hook');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=fixes.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Language } from '../collect/sources.js';
|
|
2
|
+
import type { HookDefinition } from './data.js';
|
|
3
|
+
/**
|
|
4
|
+
* The declaration of an app delegate method, built from its names in the rule data:
|
|
5
|
+
* "application(_:open:options:)" matches `func application(_ app: UIApplication, open url: …`,
|
|
6
|
+
* "application:openURL:options:" matches `- (BOOL)application:(UIApplication *)app openURL:…`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function methodPattern(hook: HookDefinition, language: Language): RegExp;
|
|
9
|
+
/** A read of a launch option key: `launchOptions?[.url]`, `UIApplicationLaunchOptionsURLKey`. */
|
|
10
|
+
export declare function launchOptionPattern(hook: HookDefinition, language: Language): RegExp;
|
|
11
|
+
export declare function hookPattern(hook: HookDefinition, language: Language): RegExp;
|
|
12
|
+
/**
|
|
13
|
+
* What implementing each hook's replacement looks like in a scene delegate (UIKit) or anywhere in
|
|
14
|
+
* a SwiftUI app. A hook whose replacement is already there is not reported.
|
|
15
|
+
*/
|
|
16
|
+
export declare const REPLACEMENTS: Record<string, {
|
|
17
|
+
uikit: RegExp;
|
|
18
|
+
swiftUI: RegExp | null;
|
|
19
|
+
}>;
|
|
20
|
+
/** Window lookups through the application, by window lookup id. */
|
|
21
|
+
export declare const WINDOW_LOOKUPS: Record<string, {
|
|
22
|
+
swift: RegExp;
|
|
23
|
+
objc: RegExp;
|
|
24
|
+
inAppDelegateOnly: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
/** The app delegate creating a window of its own. */
|
|
27
|
+
export declare const WINDOW_CREATION: Record<Language, RegExp>;
|
|
28
|
+
/** An app delegate that supplies scene configurations in code. */
|
|
29
|
+
export declare const CONFIGURATION_FOR_CONNECTING: Record<Language, RegExp>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2
|
+
/**
|
|
3
|
+
* The declaration of an app delegate method, built from its names in the rule data:
|
|
4
|
+
* "application(_:open:options:)" matches `func application(_ app: UIApplication, open url: …`,
|
|
5
|
+
* "application:openURL:options:" matches `- (BOOL)application:(UIApplication *)app openURL:…`.
|
|
6
|
+
*/
|
|
7
|
+
export function methodPattern(hook, language) {
|
|
8
|
+
if (language === 'swift') {
|
|
9
|
+
const match = /^(\w+)\(([^)]*)\)$/.exec(hook.swift);
|
|
10
|
+
if (!match)
|
|
11
|
+
throw new Error(`Hook ${hook.id}: "${hook.swift}" is not a Swift method name.`);
|
|
12
|
+
const [, name = '', labels = ''] = match;
|
|
13
|
+
const second = labels.split(':')[1];
|
|
14
|
+
const tail = second
|
|
15
|
+
? `\\s*\\(\\s*_\\s+\\w+\\s*:[^,)]*,\\s*${escapeRegExp(second)}\\b`
|
|
16
|
+
: '\\s*\\(';
|
|
17
|
+
return new RegExp(`\\bfunc\\s+${escapeRegExp(name)}${tail}`, 'g');
|
|
18
|
+
}
|
|
19
|
+
const parts = hook.objc.split(':').filter(Boolean);
|
|
20
|
+
const [first = '', second] = parts;
|
|
21
|
+
const tail = second ? `\\s*:\\s*\\([^)]*\\)\\s*\\w+\\s+${escapeRegExp(second)}\\s*:` : '\\s*:';
|
|
22
|
+
return new RegExp(`^\\s*-\\s*\\([^)]*\\)\\s*${escapeRegExp(first)}${tail}`, 'gm');
|
|
23
|
+
}
|
|
24
|
+
/** A read of a launch option key: `launchOptions?[.url]`, `UIApplicationLaunchOptionsURLKey`. */
|
|
25
|
+
export function launchOptionPattern(hook, language) {
|
|
26
|
+
if (language === 'objc')
|
|
27
|
+
return new RegExp(`\\b${escapeRegExp(hook.objc)}\\b`, 'g');
|
|
28
|
+
const key = /\[\.(\w+)\]$/.exec(hook.swift)?.[1];
|
|
29
|
+
if (!key)
|
|
30
|
+
throw new Error(`Hook ${hook.id}: "${hook.swift}" is not a launch option key.`);
|
|
31
|
+
return new RegExp(`\\[\\s*(?:UIApplication\\s*\\.\\s*LaunchOptionsKey\\s*)?\\.\\s*${key}\\s*\\]|LaunchOptionsKey\\s*\\.\\s*${key}\\b`, 'g');
|
|
32
|
+
}
|
|
33
|
+
export function hookPattern(hook, language) {
|
|
34
|
+
return hook.kind === 'method'
|
|
35
|
+
? methodPattern(hook, language)
|
|
36
|
+
: launchOptionPattern(hook, language);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What implementing each hook's replacement looks like in a scene delegate (UIKit) or anywhere in
|
|
40
|
+
* a SwiftUI app. A hook whose replacement is already there is not reported.
|
|
41
|
+
*/
|
|
42
|
+
export const REPLACEMENTS = {
|
|
43
|
+
'open-url': { uikit: /\bopenURLContexts\b/, swiftUI: /\.onOpenURL\b/ },
|
|
44
|
+
'continue-user-activity': {
|
|
45
|
+
uikit: /\bcontinue\s+\w+\s*:\s*NSUserActivity\b|\bscene\s*:\s*\([^)]*\)\s*\w+\s+continueUserActivity\s*:/,
|
|
46
|
+
swiftUI: /\.onContinueUserActivity\b/,
|
|
47
|
+
},
|
|
48
|
+
'will-continue-user-activity': { uikit: /\bwillContinueUserActivityWithType\b/, swiftUI: null },
|
|
49
|
+
'fail-to-continue-user-activity': {
|
|
50
|
+
uikit: /\bdidFailToContinueUserActivityWithType\b/,
|
|
51
|
+
swiftUI: null,
|
|
52
|
+
},
|
|
53
|
+
'quick-action': {
|
|
54
|
+
uikit: /\bwindowScene\b[^{;]*\bperformAction(?:For|ForShortcutItem)\b/,
|
|
55
|
+
swiftUI: null,
|
|
56
|
+
},
|
|
57
|
+
'did-become-active': { uikit: /\bsceneDidBecomeActive\b/, swiftUI: /\bscenePhase\b/ },
|
|
58
|
+
'will-resign-active': { uikit: /\bsceneWillResignActive\b/, swiftUI: /\bscenePhase\b/ },
|
|
59
|
+
'did-enter-background': { uikit: /\bsceneDidEnterBackground\b/, swiftUI: /\bscenePhase\b/ },
|
|
60
|
+
'will-enter-foreground': { uikit: /\bsceneWillEnterForeground\b/, swiftUI: /\bscenePhase\b/ },
|
|
61
|
+
'launch-url': {
|
|
62
|
+
uikit: /\bconnectionOptions\s*\.\s*(?:urlContexts|URLContexts)\b/,
|
|
63
|
+
swiftUI: /\.onOpenURL\b/,
|
|
64
|
+
},
|
|
65
|
+
'launch-user-activity': {
|
|
66
|
+
uikit: /\bconnectionOptions\s*\.\s*userActivities\b/,
|
|
67
|
+
swiftUI: /\.onContinueUserActivity\b/,
|
|
68
|
+
},
|
|
69
|
+
'launch-shortcut-item': { uikit: /\bconnectionOptions\s*\.\s*shortcutItem\b/, swiftUI: null },
|
|
70
|
+
'launch-remote-notification': {
|
|
71
|
+
uikit: /\buserNotificationCenter\s*\(\s*_\s+\w+\s*:\s*UNUserNotificationCenter\s*,\s*didReceive\b|userNotificationCenter\s*:\s*\([^)]*\)\s*\w+\s+didReceiveNotificationResponse\s*:/,
|
|
72
|
+
swiftUI: null,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
/** Window lookups through the application, by window lookup id. */
|
|
76
|
+
export const WINDOW_LOOKUPS = {
|
|
77
|
+
'key-window': {
|
|
78
|
+
swift: /\bUIApplication\s*\.\s*shared\s*\.\s*keyWindow\b/g,
|
|
79
|
+
objc: /\[\s*UIApplication\s+sharedApplication\s*\]\s*(?:\.\s*keyWindow\b|keyWindow\s*\])|\[\s*\[\s*UIApplication\s+sharedApplication\s*\]\s+keyWindow\s*\]/g,
|
|
80
|
+
inAppDelegateOnly: false,
|
|
81
|
+
},
|
|
82
|
+
windows: {
|
|
83
|
+
swift: /\bUIApplication\s*\.\s*shared\s*\.\s*windows\b/g,
|
|
84
|
+
objc: /\[\s*UIApplication\s+sharedApplication\s*\]\s*\.\s*windows\b|\[\s*\[\s*UIApplication\s+sharedApplication\s*\]\s+windows\s*\]/g,
|
|
85
|
+
inAppDelegateOnly: false,
|
|
86
|
+
},
|
|
87
|
+
'delegate-window': {
|
|
88
|
+
swift: /\bUIApplication\s*\.\s*shared\s*\.\s*delegate\s*[?!]?\s*\.\s*window\b/g,
|
|
89
|
+
objc: /\[\s*\[\s*UIApplication\s+sharedApplication\s*\]\s+delegate\s*\]\s*\.\s*window\b|\[\s*UIApplication\s+sharedApplication\s*\]\s*\.\s*delegate\s*\.\s*window\b/g,
|
|
90
|
+
inAppDelegateOnly: false,
|
|
91
|
+
},
|
|
92
|
+
'app-delegate-window': {
|
|
93
|
+
// Reading the root view controller of the app delegate's own window; assigning it is SCN002.
|
|
94
|
+
swift: /(?<![\w.])(?:self\s*\.\s*)?window\s*[?!]\s*\.\s*rootViewController\b(?!\s*=[^=])/g,
|
|
95
|
+
objc: /\bself\s*\.\s*window\s*\.\s*rootViewController\b(?!\s*=[^=])|\[\s*self\.window\s+rootViewController\s*\]/g,
|
|
96
|
+
inAppDelegateOnly: true,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
/** The app delegate creating a window of its own. */
|
|
100
|
+
export const WINDOW_CREATION = {
|
|
101
|
+
swift: /\bUIWindow\s*\(\s*frame\s*:/g,
|
|
102
|
+
objc: /\[\s*\[\s*UIWindow\s+alloc\s*\]\s*initWithFrame\s*:/g,
|
|
103
|
+
};
|
|
104
|
+
/** An app delegate that supplies scene configurations in code. */
|
|
105
|
+
export const CONFIGURATION_FOR_CONNECTING = {
|
|
106
|
+
swift: /\bfunc\s+application\s*\([^)]*\bconfigurationForConnecting\b/,
|
|
107
|
+
objc: /\bconfigurationForConnectingSceneSession\s*:/,
|
|
108
|
+
};
|
|
109
|
+
//# sourceMappingURL=patterns.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type AppModel, type AppTarget } from '../collect/app.js';
|
|
2
|
+
import { type SourceFile } from '../collect/sources.js';
|
|
3
|
+
export interface Match {
|
|
4
|
+
file: SourceFile;
|
|
5
|
+
offset: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function matches(files: readonly SourceFile[], pattern: (file: SourceFile) => RegExp | null): Generator<{
|
|
8
|
+
file: SourceFile;
|
|
9
|
+
offset: number;
|
|
10
|
+
}, void, unknown>;
|
|
11
|
+
export declare function inAppDelegate(app: AppModel, match: Match): boolean;
|
|
12
|
+
export declare function sceneDelegateText(app: AppModel): string;
|
|
13
|
+
export declare const isSwiftUIApp: (app: AppModel) => boolean;
|
|
14
|
+
/** Whether a target gets a scene configuration from its Info.plist or the app delegate. */
|
|
15
|
+
export declare function adoptsScenes(app: AppModel, target: AppTarget): boolean;
|
|
16
|
+
export declare function appDelegateName(app: AppModel): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Questions the rules ask about an app's sources: where a pattern matches, whether a match is in
|
|
2
|
+
// an app delegate, and whether the app gets a scene configuration at all.
|
|
3
|
+
import { sceneManifest } from '../collect/app.js';
|
|
4
|
+
import { insideTypeNamed } from '../collect/sources.js';
|
|
5
|
+
import { CONFIGURATION_FOR_CONNECTING } from './patterns.js';
|
|
6
|
+
export function* matches(files, pattern) {
|
|
7
|
+
for (const file of files) {
|
|
8
|
+
const regex = pattern(file);
|
|
9
|
+
if (!regex)
|
|
10
|
+
continue;
|
|
11
|
+
const global = regex.global ? regex : new RegExp(regex.source, `${regex.flags}g`);
|
|
12
|
+
global.lastIndex = 0;
|
|
13
|
+
for (let match = global.exec(file.code); match; match = global.exec(file.code)) {
|
|
14
|
+
// Skip leading whitespace that a multiline pattern may have consumed.
|
|
15
|
+
const offset = match.index + (match[0].length - match[0].trimStart().length);
|
|
16
|
+
yield { file, offset };
|
|
17
|
+
if (match[0].length === 0)
|
|
18
|
+
global.lastIndex++;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function inAppDelegate(app, match) {
|
|
23
|
+
return insideTypeNamed(app.sources, match.file, match.offset, app.sources.appDelegates);
|
|
24
|
+
}
|
|
25
|
+
export function sceneDelegateText(app) {
|
|
26
|
+
return app.sources.regions
|
|
27
|
+
.filter((region) => app.sources.sceneDelegates.has(region.name))
|
|
28
|
+
.map((region) => region.file.code.slice(region.start, region.end))
|
|
29
|
+
.join('\n');
|
|
30
|
+
}
|
|
31
|
+
export const isSwiftUIApp = (app) => app.sources.swiftUIApps.some((region) => region.main);
|
|
32
|
+
function implementsConfigurationForConnecting(app) {
|
|
33
|
+
for (const match of matches(app.sources.files, (file) => CONFIGURATION_FOR_CONNECTING[file.language])) {
|
|
34
|
+
if (inAppDelegate(app, match))
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/** Whether a target gets a scene configuration from its Info.plist or the app delegate. */
|
|
40
|
+
export function adoptsScenes(app, target) {
|
|
41
|
+
if (isSwiftUIApp(app))
|
|
42
|
+
return true;
|
|
43
|
+
if (target.infoPlists.some((plist) => sceneManifest(plist).configured))
|
|
44
|
+
return true;
|
|
45
|
+
return implementsConfigurationForConnecting(app);
|
|
46
|
+
}
|
|
47
|
+
export function appDelegateName(app) {
|
|
48
|
+
return [...app.sources.appDelegates][0] ?? 'the app delegate';
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=query.js.map
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Finding, type Report, type Verdict } from './model.js';
|
|
2
|
+
import { type RuleData } from './rules/data.js';
|
|
3
|
+
export interface ScanOptions {
|
|
4
|
+
/** Rule data to use instead of the bundled rules/ios27.json (tests). */
|
|
5
|
+
data?: RuleData;
|
|
6
|
+
}
|
|
7
|
+
/** One sentence that answers "will it launch?" for the findings. */
|
|
8
|
+
export declare function verdictOf(findings: readonly Finding[], data: RuleData): Verdict;
|
|
9
|
+
/** Checks the project at `path` (a folder, or an .xcodeproj) and returns the report. */
|
|
10
|
+
export declare function scan(path: string, options?: ScanOptions): Promise<Report>;
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// scan(): detect the project, collect its app, run the rules and build the report.
|
|
2
|
+
import { relative, sep } from 'node:path';
|
|
3
|
+
import { collectApp } from './collect/app.js';
|
|
4
|
+
import { detectProject } from './detect/project.js';
|
|
5
|
+
import { SCHEMA_VERSION, } from './model.js';
|
|
6
|
+
import { adoptsScenes, checkApp } from './rules/check.js';
|
|
7
|
+
import { loadRuleData } from './rules/data.js';
|
|
8
|
+
import { VERSION } from './version.js';
|
|
9
|
+
const toPosix = (path) => path.split(sep).join('/');
|
|
10
|
+
function lifecycleOf(app, target) {
|
|
11
|
+
if (app.sources.swiftUIApps.some((region) => region.main))
|
|
12
|
+
return 'swiftui';
|
|
13
|
+
if (adoptsScenes(app, target))
|
|
14
|
+
return 'scenes';
|
|
15
|
+
return target.infoPlistSource === 'not-read' ? 'unknown' : 'app-delegate';
|
|
16
|
+
}
|
|
17
|
+
function versionsOf(app) {
|
|
18
|
+
const versions = [...app.project.packages.values()].map((entry) => ({
|
|
19
|
+
name: entry.name,
|
|
20
|
+
version: entry.version,
|
|
21
|
+
source: entry.source,
|
|
22
|
+
}));
|
|
23
|
+
if (app.project.flutter) {
|
|
24
|
+
versions.unshift({
|
|
25
|
+
name: 'flutter',
|
|
26
|
+
version: app.project.flutter.version,
|
|
27
|
+
source: app.project.flutter.version ? 'pinned' : 'not pinned',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return versions;
|
|
31
|
+
}
|
|
32
|
+
/** One sentence that answers "will it launch?" for the findings. */
|
|
33
|
+
export function verdictOf(findings, data) {
|
|
34
|
+
const open = findings.filter((item) => !item.mitigation);
|
|
35
|
+
const blocking = open.find((item) => item.rule === 'SCN001' && item.severity === 'error');
|
|
36
|
+
if (blocking) {
|
|
37
|
+
return { code: 'fails-to-launch', text: 'Fails to launch when built with the iOS 27 SDK.' };
|
|
38
|
+
}
|
|
39
|
+
const mitigated = findings.find((item) => item.rule === 'SCN001' && item.mitigation);
|
|
40
|
+
if (mitigated?.mitigation) {
|
|
41
|
+
return {
|
|
42
|
+
code: 'launches-through-workaround',
|
|
43
|
+
text: `Launches through ${mitigated.mitigation.package}, a run-time workaround; the app itself does not adopt scenes.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const unchecked = open.find((item) => item.rule === 'SCN001' && item.severity === 'info');
|
|
47
|
+
if (unchecked) {
|
|
48
|
+
return {
|
|
49
|
+
code: 'unknown',
|
|
50
|
+
text: `Launch not determined: sdkbump could not read the Info.plist of ${unchecked.target ?? 'the app'}.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const hasScenesAndOwnWindow = open.some((item) => item.rule === 'SCN002') && !findings.some((item) => item.rule === 'SCN001');
|
|
54
|
+
if (hasScenesAndOwnWindow) {
|
|
55
|
+
return {
|
|
56
|
+
code: 'black-screen',
|
|
57
|
+
text: 'Launches to a black screen: the app delegate creates a window that belongs to no scene.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (open.length > 0) {
|
|
61
|
+
return {
|
|
62
|
+
code: 'review',
|
|
63
|
+
text: `Launches; ${open.length} ${open.length === 1 ? 'finding' : 'findings'} to review.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
code: 'no-findings',
|
|
68
|
+
text: `No findings for the checks in ${data.ruleSet}@${data.dataVersion}.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Checks the project at `path` (a folder, or an .xcodeproj) and returns the report. */
|
|
72
|
+
export async function scan(path, options = {}) {
|
|
73
|
+
const data = options.data ?? loadRuleData();
|
|
74
|
+
const project = await detectProject(path);
|
|
75
|
+
const app = await collectApp(project);
|
|
76
|
+
const display = (absolute) => toPosix(relative(project.root, absolute)) || '.';
|
|
77
|
+
const findings = (await checkApp(app, data)).map(({ path: file, ...rest }) => ({
|
|
78
|
+
...rest,
|
|
79
|
+
file: display(file),
|
|
80
|
+
}));
|
|
81
|
+
const targets = app.targets.map((target) => ({
|
|
82
|
+
name: target.name,
|
|
83
|
+
lifecycle: lifecycleOf(app, target),
|
|
84
|
+
infoPlists: target.infoPlists.map((plist) => display(plist.path)),
|
|
85
|
+
infoPlistSource: target.infoPlistSource,
|
|
86
|
+
}));
|
|
87
|
+
const notes = app.notes.map((note) => note.split(`${project.root}${sep}`).join(''));
|
|
88
|
+
const installed = data.workarounds.filter((workaround) => project.packages.has(workaround.package));
|
|
89
|
+
if (installed.length > 1) {
|
|
90
|
+
notes.push(`${installed.map((workaround) => workaround.package).join(' and ')} are both installed; their READMEs say to use one or the other.`);
|
|
91
|
+
}
|
|
92
|
+
const count = (severity) => findings.filter((item) => item.severity === severity && !item.mitigation).length;
|
|
93
|
+
return {
|
|
94
|
+
schemaVersion: SCHEMA_VERSION,
|
|
95
|
+
tool: { name: 'sdkbump', version: VERSION },
|
|
96
|
+
rules: { ruleSet: data.ruleSet, dataVersion: data.dataVersion, sdk: data.sdk },
|
|
97
|
+
project: {
|
|
98
|
+
path,
|
|
99
|
+
name: project.name,
|
|
100
|
+
ecosystem: project.ecosystem,
|
|
101
|
+
versions: versionsOf(app),
|
|
102
|
+
targets,
|
|
103
|
+
},
|
|
104
|
+
verdict: verdictOf(findings, data),
|
|
105
|
+
findings,
|
|
106
|
+
notes,
|
|
107
|
+
summary: {
|
|
108
|
+
errors: count('error'),
|
|
109
|
+
warnings: count('warning'),
|
|
110
|
+
info: count('info'),
|
|
111
|
+
mitigated: findings.filter((item) => item.mitigation).length,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=scan.js.map
|