@expo-harmony/config-plugins 55.0.10-harmony.2 → 55.0.10-harmony.4

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 (50) hide show
  1. package/README.md +203 -11
  2. package/build/compiler.d.ts +7 -0
  3. package/build/compiler.js +12 -0
  4. package/build/composition.d.ts +24 -0
  5. package/build/composition.js +11 -0
  6. package/build/config.d.ts +2 -15
  7. package/build/customFiles.d.ts +15 -0
  8. package/build/customFiles.js +108 -0
  9. package/build/files.d.ts +1 -1
  10. package/build/files.js +2 -2
  11. package/build/generatedFiles.d.ts +15 -0
  12. package/build/generatedFiles.js +70 -0
  13. package/build/index.d.ts +20 -5
  14. package/build/index.js +53 -6
  15. package/build/internal.d.ts +3 -0
  16. package/build/internal.js +13 -0
  17. package/build/manifest.d.ts +38 -0
  18. package/build/manifest.js +71 -0
  19. package/build/mods.d.ts +37 -31
  20. package/build/mods.js +118 -86
  21. package/build/normalizeConfig.d.ts +10 -3
  22. package/build/normalizeConfig.js +45 -47
  23. package/build/ownership.d.ts +4 -3
  24. package/build/ownership.js +17 -0
  25. package/build/paths.d.ts +13 -10
  26. package/build/paths.js +32 -31
  27. package/build/permissions.d.ts +12 -0
  28. package/build/permissions.js +60 -0
  29. package/build/pluginTypes.d.ts +15 -0
  30. package/build/pluginTypes.js +2 -0
  31. package/build/resources.d.ts +13 -0
  32. package/build/resources.js +55 -0
  33. package/package.json +9 -9
  34. package/src/compiler.ts +21 -0
  35. package/src/composition.ts +45 -0
  36. package/src/config.ts +3 -19
  37. package/src/customFiles.ts +142 -0
  38. package/src/files.ts +3 -4
  39. package/src/generatedFiles.ts +85 -0
  40. package/src/index.ts +20 -11
  41. package/src/internal.ts +3 -0
  42. package/src/manifest.ts +151 -0
  43. package/src/mods.ts +180 -128
  44. package/src/nativeInputs.ts +0 -1
  45. package/src/normalizeConfig.ts +56 -59
  46. package/src/ownership.ts +25 -5
  47. package/src/paths.ts +41 -45
  48. package/src/permissions.ts +68 -0
  49. package/src/pluginTypes.ts +24 -0
  50. package/src/resources.ts +65 -0
@@ -6,6 +6,7 @@ import type {
6
6
  HarmonySkill,
7
7
  } from './config';
8
8
  import { HarmonyConfigPluginError } from './errors';
9
+ import { toArgb } from './resources';
9
10
 
10
11
  type HarmonyExpoConfig = ExpoConfigWithHarmony;
11
12
 
@@ -32,10 +33,10 @@ interface NormalizedHarmonyConfig {
32
33
  versionName: string;
33
34
  }
34
35
 
35
- const BundleNamePattern = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*){2,}$/;
36
+ const BundlePattern = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*){2,}$/;
36
37
  const IdentifierPattern = /^[A-Za-z][A-Za-z0-9_]*$/;
37
38
  const ColorPattern = /^(#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{8})$/;
38
- const ValidOrientations = new Set([
39
+ const Orientations = new Set([
39
40
  'default',
40
41
  'portrait',
41
42
  'landscape',
@@ -43,13 +44,14 @@ const ValidOrientations = new Set([
43
44
  'landscape_inverted',
44
45
  'auto_rotation',
45
46
  ]);
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([
47
+ const DeviceTypes = new Set<HarmonyDeviceType>(['phone', 'tablet', '2in1']);
48
+ const MinimumApi = 20;
49
+ const TargetApi = 24;
50
+ const SdkVersions = new Map([
51
51
  [13, '5.0.1(13)'],
52
+ [14, '5.0.2(14)'],
52
53
  [20, '6.0.0(20)'],
54
+ [21, '6.0.1(21)'],
53
55
  [23, '6.1.0(23)'],
54
56
  [24, '6.1.1(24)'],
55
57
  ]);
@@ -91,11 +93,7 @@ function normalizeColor(value: unknown, field: string, fallback: string): string
91
93
  throw new HarmonyConfigError(`${field} must be #RRGGBB or #RRGGBBAA.`);
92
94
  }
93
95
 
94
- const result = color.toUpperCase();
95
-
96
- return result.length === 9
97
- ? `#${result.slice(7, 9)}${result.slice(1, 7)}`
98
- : result;
96
+ return toArgb(color);
99
97
  }
100
98
 
101
99
  function parseSdkApi(value: unknown, field: string): number | null {
@@ -127,11 +125,11 @@ function resolveSdkVersion(api: number, field: string, value?: unknown): string
127
125
  }
128
126
  } else {
129
127
  const version = readString(value, `harmony.${field}`);
130
- const versionApi = parseSdkApi(version, field);
128
+ const parsed = parseSdkApi(version, field);
131
129
 
132
- if (versionApi !== api) {
130
+ if (parsed !== api) {
133
131
  throw new HarmonyConfigError(
134
- `harmony.${field} describes API ${versionApi}, but the configured API level is ${api}.`
132
+ `harmony.${field} describes API ${parsed}, but the configured API level is ${api}.`
135
133
  );
136
134
  }
137
135
 
@@ -139,7 +137,7 @@ function resolveSdkVersion(api: number, field: string, value?: unknown): string
139
137
  }
140
138
  }
141
139
 
142
- const version = HarmonySdkVersions.get(api);
140
+ const version = SdkVersions.get(api);
143
141
 
144
142
  if (!version) {
145
143
  throw new HarmonyConfigError(
@@ -171,7 +169,7 @@ function normalizeStringArray<T extends string = string>(
171
169
  return result;
172
170
  }
173
171
 
174
- function normalizePermission(permission: HarmonyPermission) {
172
+ export function normalizePermission(permission: HarmonyPermission) {
175
173
  if (!permission || typeof permission !== 'object') {
176
174
  throw new HarmonyConfigError('harmony.permissions entries must be objects.');
177
175
  }
@@ -196,6 +194,7 @@ function normalizePermission(permission: HarmonyPermission) {
196
194
  if (!permission.usedScene || typeof permission.usedScene !== 'object') {
197
195
  throw new HarmonyConfigError('permission.usedScene must be an object.');
198
196
  }
197
+
199
198
  const when: 'always' | 'inuse' = permission.usedScene.when === undefined
200
199
  ? 'inuse'
201
200
  : permission.usedScene.when;
@@ -221,7 +220,7 @@ function normalizeSkill(skill: HarmonySkill) {
221
220
  const result: {
222
221
  actions?: string[];
223
222
  entities?: string[];
224
- uris?: Array<Record<string, string>>;
223
+ uris?: HarmonySkill['uris'];
225
224
  } = {};
226
225
 
227
226
  for (const field of ['entities', 'actions'] as const) {
@@ -238,9 +237,11 @@ function normalizeSkill(skill: HarmonySkill) {
238
237
  throw new HarmonyConfigError('Harmony skill URI must be an object.');
239
238
  }
240
239
 
241
- const result: Record<string, string> = {};
240
+ const result: NonNullable<HarmonySkill['uris']>[number] = {};
242
241
  for (const [key, value] of Object.entries(uri)) {
243
- result[key] = readString(value, `harmony.skills[].uris[].${key}`);
242
+ result[key] = typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))
243
+ ? value
244
+ : readString(value, `harmony.skills[].uris[].${key}`);
244
245
  }
245
246
 
246
247
  return result;
@@ -266,67 +267,57 @@ function normalizeHarmonyConfig(config: HarmonyExpoConfig): NormalizedHarmonyCon
266
267
  throw new HarmonyConfigError('Expo config must contain a harmony object.');
267
268
  }
268
269
 
269
- const bundleName = readString(harmony.bundleName, 'harmony.bundleName');
270
-
271
- if (!BundleNamePattern.test(bundleName)) {
270
+ const bundle = readString(harmony.bundleName, 'harmony.bundleName');
271
+ if (!BundlePattern.test(bundle)) {
272
272
  throw new HarmonyConfigError(
273
273
  'harmony.bundleName must contain at least three valid dot-separated segments.'
274
274
  );
275
275
  }
276
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)) {
277
+ const module = readString(harmony.moduleName, 'harmony.moduleName', 'entry');
278
+ const ability = readString(harmony.abilityName, 'harmony.abilityName', 'EntryAbility');
279
+ if (!IdentifierPattern.test(module)) {
281
280
  throw new HarmonyConfigError('harmony.moduleName must be a valid Harmony identifier.');
282
281
  }
283
- if (!IdentifierPattern.test(abilityName)) {
282
+ if (!IdentifierPattern.test(ability)) {
284
283
  throw new HarmonyConfigError('harmony.abilityName must be a valid Harmony identifier.');
285
284
  }
286
285
 
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(
286
+ const version = readString(harmony.versionName, 'harmony.versionName', config.version || '1.0.0');
287
+ const code = readPositiveInteger(harmony.versionCode, 'versionCode', 1);
288
+
289
+ const target = readPositiveInteger(
291
290
  harmony.targetApiVersion,
292
291
  'targetApiVersion',
293
- targetSdkApi ?? DefaultHarmonyTargetApi
292
+ parseSdkApi(harmony.targetSdkVersion, 'targetSdkVersion') ?? TargetApi
294
293
  );
295
- const compatibleSdkApi = parseSdkApi(harmony.compatibleSdkVersion, 'compatibleSdkVersion');
296
- const compatibleApi = readPositiveInteger(
297
- compatibleSdkApi ?? undefined,
294
+ const compatible = readPositiveInteger(
295
+ parseSdkApi(harmony.compatibleSdkVersion, 'compatibleSdkVersion') ?? undefined,
298
296
  'compatibleSdkVersion',
299
- DefaultHarmonyCompatibleApi
297
+ MinimumApi
300
298
  );
301
299
 
302
- if (compatibleApi < SupportedMinimumHarmonyApi) {
300
+ if (compatible < MinimumApi) {
303
301
  throw new HarmonyConfigError(
304
- `Harmony compatible API must be ${SupportedMinimumHarmonyApi} or newer.`
302
+ `Harmony compatible API must be ${MinimumApi} or newer.`
305
303
  );
306
304
  }
307
- if (compatibleApi > targetApi) {
305
+ if (compatible > target) {
308
306
  throw new HarmonyConfigError('Harmony compatibleSdkVersion cannot exceed targetApiVersion.');
309
307
  }
310
308
 
311
309
  const orientation = harmony.orientation || config.orientation || 'default';
312
-
313
- if (!ValidOrientations.has(orientation)) {
310
+ if (!Orientations.has(orientation)) {
314
311
  throw new HarmonyConfigError(`Unsupported Harmony orientation: ${orientation}`);
315
312
  }
316
313
 
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
314
  const background = normalizeColor(
324
315
  harmony.backgroundColor || config.backgroundColor,
325
316
  'harmony.backgroundColor',
326
317
  '#FFFFFF'
327
318
  );
328
- const engine = harmony.jsEngine || 'hermes';
329
319
 
320
+ const engine = harmony.jsEngine || 'hermes';
330
321
  if (engine !== 'hermes') {
331
322
  throw new HarmonyConfigError('Expo Harmony currently supports only the Hermes JavaScript engine.');
332
323
  }
@@ -347,6 +338,11 @@ function normalizeHarmonyConfig(config: HarmonyExpoConfig): NormalizedHarmonyCon
347
338
  permissions.unshift({ name: 'ohos.permission.INTERNET' });
348
339
  }
349
340
 
341
+ if (config.updates?.checkAutomatically === 'WIFI_ONLY'
342
+ && !permissions.some(permission => permission.name === 'ohos.permission.GET_NETWORK_INFO')) {
343
+ permissions.push({ name: 'ohos.permission.GET_NETWORK_INFO' });
344
+ }
345
+
350
346
  const schemes = readExpoSchemes(config);
351
347
  let skills = (harmony.skills || []).map(normalizeSkill);
352
348
 
@@ -359,6 +355,7 @@ function normalizeHarmonyConfig(config: HarmonyExpoConfig): NormalizedHarmonyCon
359
355
  }
360
356
 
361
357
  skills = [...new Map(skills.map(skill => [JSON.stringify(skill), skill])).values()];
358
+
362
359
  const queries = [...new Set([
363
360
  'http',
364
361
  'https',
@@ -378,40 +375,40 @@ function normalizeHarmonyConfig(config: HarmonyExpoConfig): NormalizedHarmonyCon
378
375
  : normalizeStringArray<HarmonyDeviceType>(
379
376
  harmony.deviceTypes,
380
377
  'harmony.deviceTypes',
381
- ValidDeviceTypes
378
+ DeviceTypes
382
379
  );
383
380
 
384
381
  return Object.freeze({
385
382
  abiFilters: abis,
386
- abilityName,
383
+ abilityName: ability,
387
384
  backgroundColor: background,
388
- bundleName,
385
+ bundleName: bundle,
389
386
  compatibleSdkVersionString: resolveSdkVersion(
390
- compatibleApi,
387
+ compatible,
391
388
  'compatibleSdkVersion',
392
389
  harmony.compatibleSdkVersion
393
390
  ),
394
391
  deviceTypes: devices,
395
392
  icon: harmony.icon || config.icon,
396
393
  label: readString(harmony.label, 'harmony.label', config.name),
397
- moduleName,
394
+ moduleName: module,
398
395
  nativeOrientation: orientation === 'default' ? 'unspecified' : orientation,
399
396
  permissions,
400
397
  productName: readString(harmony.productName, 'harmony.productName', 'default'),
401
398
  querySchemes: queries,
402
399
  signingConfigFile: signing,
403
400
  skills,
404
- targetApiVersion: targetApi,
401
+ targetApiVersion: target,
405
402
  targetSdkVersionString: resolveSdkVersion(
406
- targetApi,
403
+ target,
407
404
  'targetSdkVersion',
408
405
  harmony.targetSdkVersion
409
406
  ),
410
407
  vendor: readString(harmony.vendor, 'harmony.vendor', 'expo-harmony'),
411
- versionCode,
412
- versionName,
408
+ versionCode: code,
409
+ versionName: version,
413
410
  });
414
411
  }
415
412
 
416
- export { HarmonySdkVersions, normalizeHarmonyConfig };
413
+ export { normalizeHarmonyConfig };
417
414
  export type { HarmonyExpoConfig, NormalizedHarmonyConfig };
package/src/ownership.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { ExpoConfig } from '@expo/config-types';
1
+ import type { ExpoConfigWithHarmony } from './config';
2
2
 
3
3
  export interface HarmonyConfigPluginOwnership {
4
4
  readonly owner: string;
5
+ readonly files?: readonly string[];
5
6
  readonly ability?: Readonly<Partial<{
6
7
  startWindowBackground: string;
7
8
  startWindowIcon: string;
@@ -115,7 +116,19 @@ export function normalizeHarmonyConfigPlugins(value: unknown): readonly HarmonyC
115
116
 
116
117
  owners.add(plugin.owner);
117
118
 
119
+ let files: string[] | undefined;
120
+ if (plugin.files !== undefined) {
121
+ if (!Array.isArray(plugin.files) || plugin.files.some(file => typeof file !== 'string'
122
+ || !file || file.includes('\\') || file.includes('\0') || /^[A-Za-z]:/.test(file)
123
+ || file.split('/').some(part => !part || part === '.' || part === '..'))) {
124
+ throw new TypeError(`Invalid Harmony config-plugin ownership: ${plugin.owner}.files must contain relative file paths.`);
125
+ }
126
+
127
+ files = [...new Set(plugin.files as string[])].sort();
128
+ }
129
+
118
130
  return {
131
+ ...(files === undefined ? {} : { files }),
119
132
  ability: normalizeAbility(plugin.ability),
120
133
  owner: plugin.owner,
121
134
  resources: normalizeResources(plugin.resources),
@@ -125,15 +138,22 @@ export function normalizeHarmonyConfigPlugins(value: unknown): readonly HarmonyC
125
138
  return plugins.sort((left, right) => left.owner.localeCompare(right.owner, 'en'));
126
139
  }
127
140
 
128
- export function registerHarmonyConfigPlugin(
129
- config: ExpoConfig,
141
+ export function registerHarmonyConfigPlugin<Config extends ExpoConfigWithHarmony>(
142
+ config: Config,
130
143
  owner: string,
131
144
  claims: Omit<HarmonyConfigPluginOwnership, 'owner'> = {}
132
- ): ExpoConfig {
145
+ ): Config {
133
146
  const plugin = normalizeHarmonyConfigPlugins([{ ...claims, owner }])[0];
134
147
 
135
148
  config._internal ??= {};
136
149
  const plugins = normalizeHarmonyConfigPlugins(config._internal.harmonyConfigPlugins);
150
+ for (const other of plugins) {
151
+ if (other.owner === owner) continue;
152
+
153
+ const duplicate = plugin.files?.find(file => other.files?.includes(file));
154
+ if (duplicate) throw new TypeError(`Harmony file ${duplicate} is claimed by both ${other.owner} and ${owner}.`);
155
+ }
156
+
137
157
  config._internal.harmonyConfigPlugins = normalizeHarmonyConfigPlugins([
138
158
  ...plugins.filter(item => item.owner !== owner),
139
159
  plugin,
@@ -142,6 +162,6 @@ export function registerHarmonyConfigPlugin(
142
162
  return config;
143
163
  }
144
164
 
145
- export function getHarmonyConfigPlugins(config: ExpoConfig): readonly HarmonyConfigPluginOwnership[] {
165
+ export function getHarmonyConfigPlugins(config: ExpoConfigWithHarmony): readonly HarmonyConfigPluginOwnership[] {
146
166
  return normalizeHarmonyConfigPlugins(config?._internal?.harmonyConfigPlugins);
147
167
  }
package/src/paths.ts CHANGED
@@ -8,13 +8,13 @@ export interface HarmonyManagedPaths {
8
8
  readonly projectBuildProfile: string;
9
9
  readonly rootOhPackage: string;
10
10
  readonly rootHvigor: string;
11
- readonly nativeInputsStamp: string;
12
11
  readonly hvigorConfig: string;
13
12
  readonly entryBuildProfile: string;
14
13
  readonly entryOhPackage: string;
15
14
  readonly entryHvigor: string;
16
15
  readonly moduleJson: string;
17
16
  readonly profiles: string;
17
+ readonly abilityStage: string;
18
18
  readonly entryAbility: string;
19
19
  readonly indexPage: string;
20
20
  readonly worker: string;
@@ -44,12 +44,12 @@ export interface HarmonyPathsApi {
44
44
  readonly RESOURCE_PATHS: HarmonyResourcePaths;
45
45
  assertNoExternalSymlink(root: string, target: string): Promise<void>;
46
46
  isInside(root: string, target: string): boolean;
47
- resolveHarmonyPath(platformProjectRoot: string, relativePath: string): Promise<string>;
48
- resolveProjectPath(projectRoot: string, modName: keyof HarmonyProjectPaths): Promise<string>;
49
- toPosixRelative(projectRoot: string, target: string): string;
47
+ resolveHarmonyPath(directory: string, relative: string): Promise<string>;
48
+ resolveProjectPath(root: string, name: keyof HarmonyProjectPaths): Promise<string>;
49
+ toPosixRelative(root: string, target: string): string;
50
50
  }
51
51
 
52
- const ManagedPaths: HarmonyManagedPaths = Object.freeze({
52
+ const ManagedPaths: HarmonyManagedPaths & { readonly nativeInputsStamp: string } = Object.freeze({
53
53
  appJson: 'AppScope/app.json5',
54
54
  projectBuildProfile: 'build-profile.json5',
55
55
  rootOhPackage: 'oh-package.json5',
@@ -61,6 +61,7 @@ const ManagedPaths: HarmonyManagedPaths = Object.freeze({
61
61
  entryHvigor: 'entry/hvigorfile.ts',
62
62
  moduleJson: 'entry/src/main/module.json5',
63
63
  profiles: 'entry/src/main/resources/base/profile/main_pages.json',
64
+ abilityStage: 'entry/src/main/ets/abilitystage/EntryAbilityStage.ets',
64
65
  entryAbility: 'entry/src/main/ets/entryability/EntryAbility.ets',
65
66
  indexPage: 'entry/src/main/ets/pages/Index.ets',
66
67
  worker: 'entry/src/main/ets/workers/RNOHWorker.ets',
@@ -74,8 +75,7 @@ const ProjectPaths: HarmonyProjectPaths = Object.freeze({
74
75
  });
75
76
 
76
77
  const ProjectPathCandidates: HarmonyProjectPathCandidates = Object.freeze({
77
- // Keep this order aligned with @react-native-community/cli-config's async
78
- // search places. The first entry is also the file created for new projects.
78
+ // Match React Native CLI lookup precedence; new projects use the first path.
79
79
  reactNativeConfig: Object.freeze([
80
80
  'react-native.config.js',
81
81
  'react-native.config.cjs',
@@ -102,41 +102,41 @@ const ResourcePaths: HarmonyResourcePaths = Object.freeze({
102
102
 
103
103
  export function isInside(root: string, target: string): boolean {
104
104
  const relative = path.relative(root, target);
105
-
106
105
  return relative === ''
107
106
  || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
108
107
  }
109
108
 
110
109
  export async function assertNoExternalSymlink(root: string, target: string): Promise<void> {
111
- const absoluteRoot = path.resolve(root);
112
- const realRoot = await fs.promises.realpath(absoluteRoot).catch(() => absoluteRoot);
113
- const relative = path.relative(absoluteRoot, target);
114
- let cursor = absoluteRoot;
110
+ const absolute = path.resolve(root);
111
+ const canonical = await fs.promises.realpath(absolute).catch(() => absolute);
112
+ const relative = path.relative(absolute, target);
113
+ let current = absolute;
115
114
 
116
115
  for (const segment of relative.split(path.sep).filter(Boolean)) {
117
- cursor = path.join(cursor, segment);
116
+ current = path.join(current, segment);
118
117
 
119
118
  let stat: fs.Stats;
119
+
120
120
  try {
121
- stat = await fs.promises.lstat(cursor);
121
+ stat = await fs.promises.lstat(current);
122
122
  } catch (cause) {
123
123
  if ((cause as NodeJS.ErrnoException).code === 'ENOENT') break;
124
124
 
125
125
  throw new HarmonyConfigPluginError(
126
126
  'ERR_HARMONY_PATH_INVALID',
127
- `Cannot inspect managed Harmony path ${cursor}: ${(cause as Error).message}`,
128
- { cause, file: cursor, operation: 'resolve-path' }
127
+ `Cannot inspect managed Harmony path ${current}: ${(cause as Error).message}`,
128
+ { cause, file: current, operation: 'resolve-path' }
129
129
  );
130
130
  }
131
131
 
132
132
  if (stat.isSymbolicLink()) {
133
- const real = await fs.promises.realpath(cursor);
133
+ const real = await fs.promises.realpath(current);
134
134
 
135
- if (!isInside(realRoot, real)) {
135
+ if (!isInside(canonical, real)) {
136
136
  throw new HarmonyConfigPluginError(
137
137
  'ERR_HARMONY_PATH_ESCAPE',
138
- `Refusing to follow a symlink outside the Harmony project: ${cursor}`,
139
- { file: cursor, operation: 'resolve-path' }
138
+ `Refusing to follow a symlink outside the Harmony project: ${current}`,
139
+ { file: current, operation: 'resolve-path' }
140
140
  );
141
141
  }
142
142
  }
@@ -144,24 +144,24 @@ export async function assertNoExternalSymlink(root: string, target: string): Pro
144
144
  }
145
145
 
146
146
  export async function resolveHarmonyPath(
147
- platformProjectRoot: string,
148
- relativePath: string
147
+ directory: string,
148
+ relative: string
149
149
  ): Promise<string> {
150
- if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath)) {
150
+ if (typeof relative !== 'string' || !relative || path.isAbsolute(relative)) {
151
151
  throw new HarmonyConfigPluginError(
152
152
  'ERR_HARMONY_PATH_ESCAPE',
153
- `Invalid managed path: ${relativePath}`,
153
+ `Invalid managed path: ${relative}`,
154
154
  { operation: 'resolve-path' }
155
155
  );
156
156
  }
157
157
 
158
- const root = path.resolve(platformProjectRoot);
159
- const target = path.resolve(root, relativePath);
158
+ const root = path.resolve(directory);
159
+ const target = path.resolve(root, relative);
160
160
 
161
161
  if (!isInside(root, target)) {
162
162
  throw new HarmonyConfigPluginError(
163
163
  'ERR_HARMONY_PATH_ESCAPE',
164
- `Managed path escapes the Harmony project: ${relativePath}`,
164
+ `Managed path escapes the Harmony project: ${relative}`,
165
165
  { file: target, operation: 'resolve-path' }
166
166
  );
167
167
  }
@@ -172,30 +172,26 @@ export async function resolveHarmonyPath(
172
172
  }
173
173
 
174
174
  export async function resolveProjectPath(
175
- projectRoot: string,
176
- modName: keyof HarmonyProjectPaths
175
+ root: string,
176
+ name: keyof HarmonyProjectPaths
177
177
  ): Promise<string> {
178
- const candidates = ProjectPathCandidates[modName] || [ProjectPaths[modName]];
179
- const resolved = await Promise.all(candidates.map(candidate => resolveHarmonyPath(projectRoot, candidate)));
180
- const existing = resolved.filter(file => fs.existsSync(file));
178
+ const candidates = ProjectPathCandidates[name] || [ProjectPaths[name]];
181
179
 
182
- if (existing.length > 1) {
183
- throw new HarmonyConfigPluginError(
184
- 'ERR_HARMONY_CONFIG_INVALID',
185
- `Multiple files provide harmony.${modName}: ${existing.map(file => path.basename(file)).join(', ')}`,
186
- { file: existing[0], operation: `harmony.${modName}.read` }
187
- );
180
+ for (const candidate of candidates) {
181
+ if (fs.existsSync(path.join(root, candidate))) return resolveHarmonyPath(root, candidate);
188
182
  }
189
183
 
190
- return existing[0] || resolved[0];
184
+ return resolveHarmonyPath(root, candidates[0]);
191
185
  }
192
186
 
193
- export function toPosixRelative(projectRoot: string, target: string): string {
194
- return path.relative(projectRoot, target).split(path.sep).join('/');
187
+ export function toPosixRelative(root: string, target: string): string {
188
+ return path.relative(root, target).split(path.sep).join('/');
195
189
  }
196
190
 
197
- export const HarmonyPaths: HarmonyPathsApi = {
198
- HARMONY_PATHS: ManagedPaths,
191
+ const { nativeInputsStamp: NativeInputsStampPath, ...PublicManagedPaths } = ManagedPaths;
192
+
193
+ export const HarmonyPaths: Readonly<HarmonyPathsApi> = Object.freeze({
194
+ HARMONY_PATHS: Object.freeze(PublicManagedPaths),
199
195
  PROJECT_PATH_CANDIDATES: ProjectPathCandidates,
200
196
  PROJECT_PATHS: ProjectPaths,
201
197
  RESOURCE_PATHS: ResourcePaths,
@@ -204,6 +200,6 @@ export const HarmonyPaths: HarmonyPathsApi = {
204
200
  resolveHarmonyPath,
205
201
  resolveProjectPath,
206
202
  toPosixRelative,
207
- };
203
+ });
208
204
 
209
- export { ManagedPaths, ProjectPathCandidates, ProjectPaths, ResourcePaths };
205
+ export { ManagedPaths, NativeInputsStampPath, ProjectPathCandidates, ProjectPaths, ResourcePaths };
@@ -0,0 +1,68 @@
1
+ import type { HarmonyPermission } from './config';
2
+ import { getModuleOrThrow, type HarmonyModuleJson } from './manifest';
3
+ import { withModuleJson } from './mods';
4
+ import { normalizePermission } from './normalizeConfig';
5
+ import type { HarmonyConfigPlugin } from './pluginTypes';
6
+
7
+ export function mergePermissions(
8
+ current: readonly HarmonyPermission[],
9
+ additions: readonly HarmonyPermission[]
10
+ ): HarmonyPermission[] {
11
+ const result = new Map<string, HarmonyPermission>();
12
+ for (const input of [...current, ...additions]) {
13
+ const permission = normalizePermission(input);
14
+ const existing = result.get(permission.name);
15
+ const scene = existing?.usedScene || permission.usedScene;
16
+ const abilities = [...new Set([
17
+ ...(existing?.usedScene?.abilities ?? []),
18
+ ...(permission.usedScene?.abilities ?? []),
19
+ ])];
20
+ result.set(permission.name, {
21
+ ...existing,
22
+ ...permission,
23
+ ...(scene
24
+ ? {
25
+ usedScene: {
26
+ ...(abilities.length ? { abilities } : {}),
27
+ when: existing?.usedScene?.when === 'always' || permission.usedScene?.when === 'always' ? 'always' : 'inuse',
28
+ },
29
+ }
30
+ : {}),
31
+ });
32
+ }
33
+
34
+ return [...result.values()];
35
+ }
36
+
37
+ export function ensurePermissions(
38
+ manifest: HarmonyModuleJson,
39
+ permissions: readonly HarmonyPermission[]
40
+ ): HarmonyModuleJson {
41
+ const module = getModuleOrThrow(manifest);
42
+ module.requestPermissions = mergePermissions(module.requestPermissions ?? [], permissions);
43
+ return manifest;
44
+ }
45
+
46
+ export function removePermissions(manifest: HarmonyModuleJson, names: readonly string[]): HarmonyModuleJson {
47
+ const module = getModuleOrThrow(manifest);
48
+ if (module.requestPermissions) {
49
+ module.requestPermissions = module.requestPermissions.filter(permission => !names.includes(permission.name));
50
+ }
51
+
52
+ return manifest;
53
+ }
54
+
55
+ export const withHarmonyPermissions: HarmonyConfigPlugin<readonly HarmonyPermission[]> = (config, permissions) => {
56
+ const additions = mergePermissions([], permissions);
57
+ if (config.harmony) {
58
+ config.harmony = { ...config.harmony, permissions: mergePermissions(config.harmony.permissions ?? [], additions) };
59
+ }
60
+
61
+ return withModuleJson(config, (mod) => {
62
+ if (mod.modResults.module === undefined) mod.modResults.module = {};
63
+ ensurePermissions(mod.modResults, additions);
64
+ return mod;
65
+ });
66
+ };
67
+
68
+ export const HarmonyPermissions = Object.freeze({ ensurePermissions, mergePermissions, removePermissions });
@@ -0,0 +1,24 @@
1
+ import type { ExportedConfigWithProps } from '@expo/config-plugins';
2
+
3
+ import type { ExpoConfigWithHarmony, ExpoHarmonyPlatform, HarmonyConfig } from './config';
4
+
5
+ export type HarmonyConfigPlugin<Props = void> = <Config extends ExpoConfigWithHarmony>(
6
+ config: Config,
7
+ props: Props
8
+ ) => Config;
9
+
10
+ export type HarmonyModConfig<T> = Omit<ExportedConfigWithProps<T>, 'platforms' | 'modRawConfig' | 'modRequest'> & {
11
+ platforms?: ExpoHarmonyPlatform[];
12
+ harmony?: HarmonyConfig;
13
+ modRawConfig: ExpoConfigWithHarmony;
14
+ modRequest: Omit<ExportedConfigWithProps<T>['modRequest'], 'platform' | 'nextMod'> & {
15
+ platform: 'harmony';
16
+ nextMod?: HarmonyModAction<T>;
17
+ modFile?: string;
18
+ modFileExists?: boolean;
19
+ };
20
+ };
21
+
22
+ export type HarmonyModAction<T = unknown> = (
23
+ config: HarmonyModConfig<T>
24
+ ) => HarmonyModConfig<T> | Promise<HarmonyModConfig<T>>;