@expo-harmony/config-plugins 55.0.10-harmony.1
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/README.md +64 -0
- package/build/index.js +49 -0
- package/package.json +62 -0
- package/src/config.ts +79 -0
- package/src/errors.ts +21 -0
- package/src/files.ts +79 -0
- package/src/index.ts +71 -0
- package/src/mods.ts +596 -0
- package/src/nativeInputs.ts +113 -0
- package/src/normalizeConfig.ts +417 -0
- package/src/ownership.ts +147 -0
- package/src/paths.ts +209 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExpoConfigWithHarmony,
|
|
3
|
+
HarmonyConfig,
|
|
4
|
+
HarmonyDeviceType,
|
|
5
|
+
HarmonyPermission,
|
|
6
|
+
HarmonySkill,
|
|
7
|
+
} from './config';
|
|
8
|
+
import { HarmonyConfigPluginError } from './errors';
|
|
9
|
+
|
|
10
|
+
type HarmonyExpoConfig = ExpoConfigWithHarmony;
|
|
11
|
+
|
|
12
|
+
interface NormalizedHarmonyConfig {
|
|
13
|
+
abiFilters: string[];
|
|
14
|
+
abilityName: string;
|
|
15
|
+
backgroundColor: string;
|
|
16
|
+
bundleName: string;
|
|
17
|
+
compatibleSdkVersionString: string;
|
|
18
|
+
deviceTypes: NonNullable<HarmonyConfig['deviceTypes']>;
|
|
19
|
+
icon?: string;
|
|
20
|
+
label: string;
|
|
21
|
+
moduleName: string;
|
|
22
|
+
nativeOrientation: Exclude<NonNullable<HarmonyConfig['orientation']>, 'default'> | 'unspecified';
|
|
23
|
+
permissions: NonNullable<HarmonyConfig['permissions']>;
|
|
24
|
+
productName: string;
|
|
25
|
+
querySchemes: string[];
|
|
26
|
+
signingConfigFile?: string;
|
|
27
|
+
skills: NonNullable<HarmonyConfig['skills']>;
|
|
28
|
+
targetApiVersion: number;
|
|
29
|
+
targetSdkVersionString: string;
|
|
30
|
+
vendor: string;
|
|
31
|
+
versionCode: number;
|
|
32
|
+
versionName: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BundleNamePattern = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*){2,}$/;
|
|
36
|
+
const IdentifierPattern = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
37
|
+
const ColorPattern = /^(#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{8})$/;
|
|
38
|
+
const ValidOrientations = new Set([
|
|
39
|
+
'default',
|
|
40
|
+
'portrait',
|
|
41
|
+
'landscape',
|
|
42
|
+
'portrait_inverted',
|
|
43
|
+
'landscape_inverted',
|
|
44
|
+
'auto_rotation',
|
|
45
|
+
]);
|
|
46
|
+
const ValidDeviceTypes = new Set<HarmonyDeviceType>(['phone', 'tablet', '2in1']);
|
|
47
|
+
const SupportedMinimumHarmonyApi = 13;
|
|
48
|
+
const DefaultHarmonyCompatibleApi = 23;
|
|
49
|
+
const DefaultHarmonyTargetApi = 24;
|
|
50
|
+
const HarmonySdkVersions = new Map([
|
|
51
|
+
[13, '5.0.1(13)'],
|
|
52
|
+
[20, '6.0.0(20)'],
|
|
53
|
+
[23, '6.1.0(23)'],
|
|
54
|
+
[24, '6.1.1(24)'],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
class HarmonyConfigError extends HarmonyConfigPluginError {
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super('ERR_HARMONY_CONFIG_INVALID', message, { operation: 'normalize-config' });
|
|
60
|
+
this.name = 'HarmonyConfigError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readPositiveInteger(value: unknown, field: string, fallback: number): number {
|
|
65
|
+
const result = value === undefined ? fallback : value;
|
|
66
|
+
|
|
67
|
+
if (typeof result !== 'number' || !Number.isSafeInteger(result) || result <= 0) {
|
|
68
|
+
throw new HarmonyConfigError(`harmony.${field} must be a positive integer.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result as number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readString(value: unknown, field: string, fallback?: string): string {
|
|
75
|
+
const result = value === undefined ? fallback : value;
|
|
76
|
+
|
|
77
|
+
if (typeof result !== 'string' || !result.trim()) {
|
|
78
|
+
throw new HarmonyConfigError(`${field} must be a non-empty string.`);
|
|
79
|
+
}
|
|
80
|
+
if (/\0|[\r\n]/.test(result)) {
|
|
81
|
+
throw new HarmonyConfigError(`${field} must not contain control characters.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeColor(value: unknown, field: string, fallback: string): string {
|
|
88
|
+
const color = value === undefined ? fallback : value;
|
|
89
|
+
|
|
90
|
+
if (typeof color !== 'string' || !ColorPattern.test(color)) {
|
|
91
|
+
throw new HarmonyConfigError(`${field} must be #RRGGBB or #RRGGBBAA.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const result = color.toUpperCase();
|
|
95
|
+
|
|
96
|
+
return result.length === 9
|
|
97
|
+
? `#${result.slice(7, 9)}${result.slice(1, 7)}`
|
|
98
|
+
: result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseSdkApi(value: unknown, field: string): number | null {
|
|
102
|
+
if (value === undefined) return null;
|
|
103
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value;
|
|
104
|
+
if (typeof value !== 'string') {
|
|
105
|
+
throw new HarmonyConfigError(`harmony.${field} must be a positive API number or an SDK label.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const match = /\((\d+)\)$/u.exec(value.trim());
|
|
109
|
+
const api = Number(match?.[1]);
|
|
110
|
+
|
|
111
|
+
if (!match || !Number.isSafeInteger(api) || api <= 0) {
|
|
112
|
+
throw new HarmonyConfigError(
|
|
113
|
+
`harmony.${field} must end with its API level in parentheses, for example "6.0.0(20)".`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return api;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveSdkVersion(api: number, field: string, value?: unknown): string {
|
|
121
|
+
if (value !== undefined) {
|
|
122
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
|
|
123
|
+
if (value !== api) {
|
|
124
|
+
throw new HarmonyConfigError(
|
|
125
|
+
`harmony.${field} is API ${value}, but the configured API level is ${api}.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
const version = readString(value, `harmony.${field}`);
|
|
130
|
+
const versionApi = parseSdkApi(version, field);
|
|
131
|
+
|
|
132
|
+
if (versionApi !== api) {
|
|
133
|
+
throw new HarmonyConfigError(
|
|
134
|
+
`harmony.${field} describes API ${versionApi}, but the configured API level is ${api}.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return version;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const version = HarmonySdkVersions.get(api);
|
|
143
|
+
|
|
144
|
+
if (!version) {
|
|
145
|
+
throw new HarmonyConfigError(
|
|
146
|
+
`Harmony API ${api} has no built-in SDK label. Set harmony.${field} explicitly so new SDK releases do not require a package update.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return version;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeStringArray<T extends string = string>(
|
|
154
|
+
value: unknown,
|
|
155
|
+
field: string,
|
|
156
|
+
allowed?: ReadonlySet<T>
|
|
157
|
+
): T[] {
|
|
158
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
159
|
+
throw new HarmonyConfigError(`${field} must be a non-empty array.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const result: T[] = [];
|
|
163
|
+
for (const item of value) {
|
|
164
|
+
if (typeof item !== 'string' || !item || (allowed && !allowed.has(item as T))) {
|
|
165
|
+
throw new HarmonyConfigError(`Invalid ${field} entry: ${item}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!result.includes(item as T)) result.push(item as T);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizePermission(permission: HarmonyPermission) {
|
|
175
|
+
if (!permission || typeof permission !== 'object') {
|
|
176
|
+
throw new HarmonyConfigError('harmony.permissions entries must be objects.');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const name = readString(permission.name, 'harmony.permissions[].name');
|
|
180
|
+
|
|
181
|
+
if (!/^ohos\.permission\.[A-Z0-9_]+$/.test(name)) {
|
|
182
|
+
throw new HarmonyConfigError(`Invalid Harmony permission: ${name}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result: {
|
|
186
|
+
name: string;
|
|
187
|
+
reason?: string;
|
|
188
|
+
usedScene?: { abilities?: string[]; when: 'always' | 'inuse' };
|
|
189
|
+
} = { name };
|
|
190
|
+
|
|
191
|
+
if (permission.reason !== undefined) {
|
|
192
|
+
result.reason = readString(permission.reason, 'harmony.permissions[].reason');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (permission.usedScene !== undefined) {
|
|
196
|
+
if (!permission.usedScene || typeof permission.usedScene !== 'object') {
|
|
197
|
+
throw new HarmonyConfigError('permission.usedScene must be an object.');
|
|
198
|
+
}
|
|
199
|
+
const when: 'always' | 'inuse' = permission.usedScene.when === undefined
|
|
200
|
+
? 'inuse'
|
|
201
|
+
: permission.usedScene.when;
|
|
202
|
+
if (!['inuse', 'always'].includes(when)) {
|
|
203
|
+
throw new HarmonyConfigError('permission.usedScene.when must be inuse or always.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const abilities = permission.usedScene.abilities === undefined
|
|
207
|
+
? undefined
|
|
208
|
+
: normalizeStringArray(permission.usedScene.abilities, 'permission.usedScene.abilities');
|
|
209
|
+
|
|
210
|
+
result.usedScene = { ...(abilities ? { abilities } : {}), when };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeSkill(skill: HarmonySkill) {
|
|
217
|
+
if (!skill || typeof skill !== 'object') {
|
|
218
|
+
throw new HarmonyConfigError('harmony.skills entries must be objects.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const result: {
|
|
222
|
+
actions?: string[];
|
|
223
|
+
entities?: string[];
|
|
224
|
+
uris?: Array<Record<string, string>>;
|
|
225
|
+
} = {};
|
|
226
|
+
|
|
227
|
+
for (const field of ['entities', 'actions'] as const) {
|
|
228
|
+
if (skill[field] !== undefined) result[field] = normalizeStringArray(skill[field], `harmony.skills[].${field}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (skill.uris !== undefined) {
|
|
232
|
+
if (!Array.isArray(skill.uris)) {
|
|
233
|
+
throw new HarmonyConfigError('harmony.skills[].uris must be an array.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
result.uris = skill.uris.map((uri) => {
|
|
237
|
+
if (!uri || typeof uri !== 'object' || Array.isArray(uri)) {
|
|
238
|
+
throw new HarmonyConfigError('Harmony skill URI must be an object.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const result: Record<string, string> = {};
|
|
242
|
+
for (const [key, value] of Object.entries(uri)) {
|
|
243
|
+
result[key] = readString(value, `harmony.skills[].uris[].${key}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return result;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (Object.keys(result).length === 0) {
|
|
251
|
+
throw new HarmonyConfigError('Harmony skill must contain entities, actions, or uris.');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function readExpoSchemes(config: HarmonyExpoConfig) {
|
|
258
|
+
const values = Array.isArray(config.scheme) ? config.scheme : config.scheme ? [config.scheme] : [];
|
|
259
|
+
return values.map(value => readString(value, 'scheme'));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeHarmonyConfig(config: HarmonyExpoConfig): NormalizedHarmonyConfig {
|
|
263
|
+
const harmony = config.harmony;
|
|
264
|
+
|
|
265
|
+
if (!harmony || typeof harmony !== 'object' || Array.isArray(harmony)) {
|
|
266
|
+
throw new HarmonyConfigError('Expo config must contain a harmony object.');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const bundleName = readString(harmony.bundleName, 'harmony.bundleName');
|
|
270
|
+
|
|
271
|
+
if (!BundleNamePattern.test(bundleName)) {
|
|
272
|
+
throw new HarmonyConfigError(
|
|
273
|
+
'harmony.bundleName must contain at least three valid dot-separated segments.'
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const moduleName = readString(harmony.moduleName, 'harmony.moduleName', 'entry');
|
|
278
|
+
const abilityName = readString(harmony.abilityName, 'harmony.abilityName', 'EntryAbility');
|
|
279
|
+
|
|
280
|
+
if (!IdentifierPattern.test(moduleName)) {
|
|
281
|
+
throw new HarmonyConfigError('harmony.moduleName must be a valid Harmony identifier.');
|
|
282
|
+
}
|
|
283
|
+
if (!IdentifierPattern.test(abilityName)) {
|
|
284
|
+
throw new HarmonyConfigError('harmony.abilityName must be a valid Harmony identifier.');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const versionName = readString(harmony.versionName, 'harmony.versionName', config.version || '1.0.0');
|
|
288
|
+
const versionCode = readPositiveInteger(harmony.versionCode, 'versionCode', 1);
|
|
289
|
+
const targetSdkApi = parseSdkApi(harmony.targetSdkVersion, 'targetSdkVersion');
|
|
290
|
+
const targetApi = readPositiveInteger(
|
|
291
|
+
harmony.targetApiVersion,
|
|
292
|
+
'targetApiVersion',
|
|
293
|
+
targetSdkApi ?? DefaultHarmonyTargetApi
|
|
294
|
+
);
|
|
295
|
+
const compatibleSdkApi = parseSdkApi(harmony.compatibleSdkVersion, 'compatibleSdkVersion');
|
|
296
|
+
const compatibleApi = readPositiveInteger(
|
|
297
|
+
compatibleSdkApi ?? undefined,
|
|
298
|
+
'compatibleSdkVersion',
|
|
299
|
+
DefaultHarmonyCompatibleApi
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (compatibleApi < SupportedMinimumHarmonyApi) {
|
|
303
|
+
throw new HarmonyConfigError(
|
|
304
|
+
`Harmony compatible API must be ${SupportedMinimumHarmonyApi} or newer.`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (compatibleApi > targetApi) {
|
|
308
|
+
throw new HarmonyConfigError('Harmony compatibleSdkVersion cannot exceed targetApiVersion.');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const orientation = harmony.orientation || config.orientation || 'default';
|
|
312
|
+
|
|
313
|
+
if (!ValidOrientations.has(orientation)) {
|
|
314
|
+
throw new HarmonyConfigError(`Unsupported Harmony orientation: ${orientation}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const style = harmony.userInterfaceStyle || config.userInterfaceStyle || 'light';
|
|
318
|
+
|
|
319
|
+
if (!['light', 'dark', 'automatic'].includes(style)) {
|
|
320
|
+
throw new HarmonyConfigError(`Unsupported Harmony UI style: ${style}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const background = normalizeColor(
|
|
324
|
+
harmony.backgroundColor || config.backgroundColor,
|
|
325
|
+
'harmony.backgroundColor',
|
|
326
|
+
'#FFFFFF'
|
|
327
|
+
);
|
|
328
|
+
const engine = harmony.jsEngine || 'hermes';
|
|
329
|
+
|
|
330
|
+
if (engine !== 'hermes') {
|
|
331
|
+
throw new HarmonyConfigError('Expo Harmony currently supports only the Hermes JavaScript engine.');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const abis = harmony.abiFilters === undefined
|
|
335
|
+
? ['arm64-v8a', 'x86_64']
|
|
336
|
+
: normalizeStringArray(harmony.abiFilters, 'harmony.abiFilters');
|
|
337
|
+
|
|
338
|
+
for (const abi of abis) {
|
|
339
|
+
if (!/^[A-Za-z0-9_-]+$/.test(abi)) {
|
|
340
|
+
throw new HarmonyConfigError(`Invalid Harmony ABI filter: ${abi}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const permissions = (harmony.permissions || []).map(normalizePermission);
|
|
345
|
+
|
|
346
|
+
if (!permissions.some(permission => permission.name === 'ohos.permission.INTERNET')) {
|
|
347
|
+
permissions.unshift({ name: 'ohos.permission.INTERNET' });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const schemes = readExpoSchemes(config);
|
|
351
|
+
let skills = (harmony.skills || []).map(normalizeSkill);
|
|
352
|
+
|
|
353
|
+
for (const scheme of schemes) {
|
|
354
|
+
skills.push({
|
|
355
|
+
actions: ['ohos.want.action.viewData'],
|
|
356
|
+
entities: ['entity.system.browsable'],
|
|
357
|
+
uris: [{ scheme }],
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
skills = [...new Map(skills.map(skill => [JSON.stringify(skill), skill])).values()];
|
|
362
|
+
const queries = [...new Set([
|
|
363
|
+
'http',
|
|
364
|
+
'https',
|
|
365
|
+
'tel',
|
|
366
|
+
'sms',
|
|
367
|
+
...schemes,
|
|
368
|
+
...(harmony.querySchemes === undefined
|
|
369
|
+
? []
|
|
370
|
+
: normalizeStringArray(harmony.querySchemes, 'harmony.querySchemes')),
|
|
371
|
+
])];
|
|
372
|
+
|
|
373
|
+
const signing = harmony.signingConfigFile === undefined
|
|
374
|
+
? undefined
|
|
375
|
+
: readString(harmony.signingConfigFile, 'harmony.signingConfigFile');
|
|
376
|
+
const devices: HarmonyDeviceType[] = harmony.deviceTypes === undefined
|
|
377
|
+
? ['phone', 'tablet']
|
|
378
|
+
: normalizeStringArray<HarmonyDeviceType>(
|
|
379
|
+
harmony.deviceTypes,
|
|
380
|
+
'harmony.deviceTypes',
|
|
381
|
+
ValidDeviceTypes
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
return Object.freeze({
|
|
385
|
+
abiFilters: abis,
|
|
386
|
+
abilityName,
|
|
387
|
+
backgroundColor: background,
|
|
388
|
+
bundleName,
|
|
389
|
+
compatibleSdkVersionString: resolveSdkVersion(
|
|
390
|
+
compatibleApi,
|
|
391
|
+
'compatibleSdkVersion',
|
|
392
|
+
harmony.compatibleSdkVersion
|
|
393
|
+
),
|
|
394
|
+
deviceTypes: devices,
|
|
395
|
+
icon: harmony.icon || config.icon,
|
|
396
|
+
label: readString(harmony.label, 'harmony.label', config.name),
|
|
397
|
+
moduleName,
|
|
398
|
+
nativeOrientation: orientation === 'default' ? 'unspecified' : orientation,
|
|
399
|
+
permissions,
|
|
400
|
+
productName: readString(harmony.productName, 'harmony.productName', 'default'),
|
|
401
|
+
querySchemes: queries,
|
|
402
|
+
signingConfigFile: signing,
|
|
403
|
+
skills,
|
|
404
|
+
targetApiVersion: targetApi,
|
|
405
|
+
targetSdkVersionString: resolveSdkVersion(
|
|
406
|
+
targetApi,
|
|
407
|
+
'targetSdkVersion',
|
|
408
|
+
harmony.targetSdkVersion
|
|
409
|
+
),
|
|
410
|
+
vendor: readString(harmony.vendor, 'harmony.vendor', 'expo-harmony'),
|
|
411
|
+
versionCode,
|
|
412
|
+
versionName,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export { HarmonySdkVersions, normalizeHarmonyConfig };
|
|
417
|
+
export type { HarmonyExpoConfig, NormalizedHarmonyConfig };
|
package/src/ownership.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ExpoConfig } from '@expo/config-types';
|
|
2
|
+
|
|
3
|
+
export interface HarmonyConfigPluginOwnership {
|
|
4
|
+
readonly owner: string;
|
|
5
|
+
readonly ability?: Readonly<Partial<{
|
|
6
|
+
startWindowBackground: string;
|
|
7
|
+
startWindowIcon: string;
|
|
8
|
+
}>>;
|
|
9
|
+
readonly resources?: Readonly<Partial<{
|
|
10
|
+
colors: Readonly<Partial<Record<'entry' | 'entryDark', readonly string[]>>>;
|
|
11
|
+
media: Readonly<Partial<Record<'app' | 'entry' | 'entryDark', readonly string[]>>>;
|
|
12
|
+
strings: Readonly<Partial<Record<'app' | 'entry', readonly string[]>>>;
|
|
13
|
+
}>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ResourceScopes = {
|
|
17
|
+
colors: new Set(['entry', 'entryDark']),
|
|
18
|
+
media: new Set(['app', 'entry', 'entryDark']),
|
|
19
|
+
strings: new Set(['app', 'entry']),
|
|
20
|
+
} as const;
|
|
21
|
+
const AbilityFields = new Set(['startWindowBackground', 'startWindowIcon']);
|
|
22
|
+
const ResourceName = /^[A-Za-z0-9_.-]+$/u;
|
|
23
|
+
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeStringList(value: unknown, field: string): string[] {
|
|
29
|
+
if (!Array.isArray(value)) {
|
|
30
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: ${field} must be an array.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const items: string[] = [];
|
|
34
|
+
for (const item of value) {
|
|
35
|
+
if (typeof item !== 'string' || !item || !ResourceName.test(item) || item.includes('..')) {
|
|
36
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: ${field} contains an invalid resource name.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!items.includes(item)) items.push(item);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return items.sort((left, right) => left.localeCompare(right, 'en'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeResources(value: unknown): NonNullable<HarmonyConfigPluginOwnership['resources']> {
|
|
46
|
+
if (value === undefined) return {};
|
|
47
|
+
if (!isRecord(value)) {
|
|
48
|
+
throw new TypeError('Invalid Harmony config-plugin ownership: resources must be an object.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const resources: Record<string, Record<string, string[]>> = {};
|
|
52
|
+
for (const kind of Object.keys(value).sort()) {
|
|
53
|
+
if (!Object.hasOwn(ResourceScopes, kind)) {
|
|
54
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: resources.${kind} is not supported.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const scopes = value[kind];
|
|
58
|
+
if (!isRecord(scopes)) {
|
|
59
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: resources.${kind} must be an object.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const allowed = ResourceScopes[kind as keyof typeof ResourceScopes];
|
|
63
|
+
resources[kind] = {};
|
|
64
|
+
|
|
65
|
+
for (const scope of Object.keys(scopes).sort()) {
|
|
66
|
+
if (!allowed.has(scope)) {
|
|
67
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: resources.${kind}.${scope} is not a supported scope.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
resources[kind][scope] = normalizeStringList(scopes[scope], `resources.${kind}.${scope}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return resources as NonNullable<HarmonyConfigPluginOwnership['resources']>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeAbility(value: unknown): NonNullable<HarmonyConfigPluginOwnership['ability']> {
|
|
78
|
+
if (value === undefined) return {};
|
|
79
|
+
if (!isRecord(value)) {
|
|
80
|
+
throw new TypeError('Invalid Harmony config-plugin ownership: ability must be an object.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ability: Record<string, string> = {};
|
|
84
|
+
for (const field of Object.keys(value).sort()) {
|
|
85
|
+
if (!AbilityFields.has(field)) {
|
|
86
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: ability.${field} is not supported.`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof value[field] !== 'string' || !value[field]) {
|
|
89
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: ability.${field} must be a non-empty string.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
ability[field] = value[field];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return ability;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function normalizeHarmonyConfigPlugins(value: unknown): readonly HarmonyConfigPluginOwnership[] {
|
|
99
|
+
if (value === undefined) return [];
|
|
100
|
+
if (!Array.isArray(value)) {
|
|
101
|
+
throw new TypeError('Invalid Harmony config-plugin ownership: the plugin list must be an array.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const owners = new Set<string>();
|
|
105
|
+
const plugins = value.map((plugin: unknown, index) => {
|
|
106
|
+
if (!isRecord(plugin)) {
|
|
107
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: plugin ${index} must be an object.`);
|
|
108
|
+
}
|
|
109
|
+
if (typeof plugin.owner !== 'string' || !plugin.owner.trim()) {
|
|
110
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: plugin ${index}.owner must be a non-empty string.`);
|
|
111
|
+
}
|
|
112
|
+
if (owners.has(plugin.owner)) {
|
|
113
|
+
throw new TypeError(`Invalid Harmony config-plugin ownership: owner ${plugin.owner} is duplicated.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
owners.add(plugin.owner);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ability: normalizeAbility(plugin.ability),
|
|
120
|
+
owner: plugin.owner,
|
|
121
|
+
resources: normalizeResources(plugin.resources),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return plugins.sort((left, right) => left.owner.localeCompare(right.owner, 'en'));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function registerHarmonyConfigPlugin(
|
|
129
|
+
config: ExpoConfig,
|
|
130
|
+
owner: string,
|
|
131
|
+
claims: Omit<HarmonyConfigPluginOwnership, 'owner'> = {}
|
|
132
|
+
): ExpoConfig {
|
|
133
|
+
const plugin = normalizeHarmonyConfigPlugins([{ ...claims, owner }])[0];
|
|
134
|
+
|
|
135
|
+
config._internal ??= {};
|
|
136
|
+
const plugins = normalizeHarmonyConfigPlugins(config._internal.harmonyConfigPlugins);
|
|
137
|
+
config._internal.harmonyConfigPlugins = normalizeHarmonyConfigPlugins([
|
|
138
|
+
...plugins.filter(item => item.owner !== owner),
|
|
139
|
+
plugin,
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
return config;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getHarmonyConfigPlugins(config: ExpoConfig): readonly HarmonyConfigPluginOwnership[] {
|
|
146
|
+
return normalizeHarmonyConfigPlugins(config?._internal?.harmonyConfigPlugins);
|
|
147
|
+
}
|