@expo-harmony/config-plugins 55.0.10-harmony.2 → 55.0.10-harmony.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.
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 +52 -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 +36 -31
  20. package/build/mods.js +114 -86
  21. package/build/normalizeConfig.d.ts +10 -3
  22. package/build/normalizeConfig.js +41 -47
  23. package/build/ownership.d.ts +4 -3
  24. package/build/ownership.js +17 -0
  25. package/build/paths.d.ts +12 -10
  26. package/build/paths.js +31 -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 +19 -11
  41. package/src/internal.ts +3 -0
  42. package/src/manifest.ts +151 -0
  43. package/src/mods.ts +170 -129
  44. package/src/nativeInputs.ts +0 -1
  45. package/src/normalizeConfig.ts +51 -59
  46. package/src/ownership.ts +25 -5
  47. package/src/paths.ts +38 -46
  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 = 13;
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
  }
@@ -359,6 +350,7 @@ function normalizeHarmonyConfig(config: HarmonyExpoConfig): NormalizedHarmonyCon
359
350
  }
360
351
 
361
352
  skills = [...new Map(skills.map(skill => [JSON.stringify(skill), skill])).values()];
353
+
362
354
  const queries = [...new Set([
363
355
  'http',
364
356
  'https',
@@ -378,40 +370,40 @@ function normalizeHarmonyConfig(config: HarmonyExpoConfig): NormalizedHarmonyCon
378
370
  : normalizeStringArray<HarmonyDeviceType>(
379
371
  harmony.deviceTypes,
380
372
  'harmony.deviceTypes',
381
- ValidDeviceTypes
373
+ DeviceTypes
382
374
  );
383
375
 
384
376
  return Object.freeze({
385
377
  abiFilters: abis,
386
- abilityName,
378
+ abilityName: ability,
387
379
  backgroundColor: background,
388
- bundleName,
380
+ bundleName: bundle,
389
381
  compatibleSdkVersionString: resolveSdkVersion(
390
- compatibleApi,
382
+ compatible,
391
383
  'compatibleSdkVersion',
392
384
  harmony.compatibleSdkVersion
393
385
  ),
394
386
  deviceTypes: devices,
395
387
  icon: harmony.icon || config.icon,
396
388
  label: readString(harmony.label, 'harmony.label', config.name),
397
- moduleName,
389
+ moduleName: module,
398
390
  nativeOrientation: orientation === 'default' ? 'unspecified' : orientation,
399
391
  permissions,
400
392
  productName: readString(harmony.productName, 'harmony.productName', 'default'),
401
393
  querySchemes: queries,
402
394
  signingConfigFile: signing,
403
395
  skills,
404
- targetApiVersion: targetApi,
396
+ targetApiVersion: target,
405
397
  targetSdkVersionString: resolveSdkVersion(
406
- targetApi,
398
+ target,
407
399
  'targetSdkVersion',
408
400
  harmony.targetSdkVersion
409
401
  ),
410
402
  vendor: readString(harmony.vendor, 'harmony.vendor', 'expo-harmony'),
411
- versionCode,
412
- versionName,
403
+ versionCode: code,
404
+ versionName: version,
413
405
  });
414
406
  }
415
407
 
416
- export { HarmonySdkVersions, normalizeHarmonyConfig };
408
+ export { normalizeHarmonyConfig };
417
409
  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,7 +8,6 @@ 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;
@@ -44,12 +43,12 @@ export interface HarmonyPathsApi {
44
43
  readonly RESOURCE_PATHS: HarmonyResourcePaths;
45
44
  assertNoExternalSymlink(root: string, target: string): Promise<void>;
46
45
  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;
46
+ resolveHarmonyPath(directory: string, relative: string): Promise<string>;
47
+ resolveProjectPath(root: string, name: keyof HarmonyProjectPaths): Promise<string>;
48
+ toPosixRelative(root: string, target: string): string;
50
49
  }
51
50
 
52
- const ManagedPaths: HarmonyManagedPaths = Object.freeze({
51
+ const ManagedPaths: HarmonyManagedPaths & { readonly nativeInputsStamp: string } = Object.freeze({
53
52
  appJson: 'AppScope/app.json5',
54
53
  projectBuildProfile: 'build-profile.json5',
55
54
  rootOhPackage: 'oh-package.json5',
@@ -74,8 +73,7 @@ const ProjectPaths: HarmonyProjectPaths = Object.freeze({
74
73
  });
75
74
 
76
75
  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.
76
+ // Match React Native CLI lookup precedence; new projects use the first path.
79
77
  reactNativeConfig: Object.freeze([
80
78
  'react-native.config.js',
81
79
  'react-native.config.cjs',
@@ -102,41 +100,40 @@ const ResourcePaths: HarmonyResourcePaths = Object.freeze({
102
100
 
103
101
  export function isInside(root: string, target: string): boolean {
104
102
  const relative = path.relative(root, target);
105
-
106
103
  return relative === ''
107
104
  || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
108
105
  }
109
106
 
110
107
  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;
108
+ const absolute = path.resolve(root);
109
+ const canonical = await fs.promises.realpath(absolute).catch(() => absolute);
110
+ const relative = path.relative(absolute, target);
111
+ let current = absolute;
115
112
 
116
113
  for (const segment of relative.split(path.sep).filter(Boolean)) {
117
- cursor = path.join(cursor, segment);
114
+ current = path.join(current, segment);
118
115
 
119
116
  let stat: fs.Stats;
120
117
  try {
121
- stat = await fs.promises.lstat(cursor);
118
+ stat = await fs.promises.lstat(current);
122
119
  } catch (cause) {
123
120
  if ((cause as NodeJS.ErrnoException).code === 'ENOENT') break;
124
121
 
125
122
  throw new HarmonyConfigPluginError(
126
123
  'ERR_HARMONY_PATH_INVALID',
127
- `Cannot inspect managed Harmony path ${cursor}: ${(cause as Error).message}`,
128
- { cause, file: cursor, operation: 'resolve-path' }
124
+ `Cannot inspect managed Harmony path ${current}: ${(cause as Error).message}`,
125
+ { cause, file: current, operation: 'resolve-path' }
129
126
  );
130
127
  }
131
128
 
132
129
  if (stat.isSymbolicLink()) {
133
- const real = await fs.promises.realpath(cursor);
130
+ const real = await fs.promises.realpath(current);
134
131
 
135
- if (!isInside(realRoot, real)) {
132
+ if (!isInside(canonical, real)) {
136
133
  throw new HarmonyConfigPluginError(
137
134
  'ERR_HARMONY_PATH_ESCAPE',
138
- `Refusing to follow a symlink outside the Harmony project: ${cursor}`,
139
- { file: cursor, operation: 'resolve-path' }
135
+ `Refusing to follow a symlink outside the Harmony project: ${current}`,
136
+ { file: current, operation: 'resolve-path' }
140
137
  );
141
138
  }
142
139
  }
@@ -144,24 +141,24 @@ export async function assertNoExternalSymlink(root: string, target: string): Pro
144
141
  }
145
142
 
146
143
  export async function resolveHarmonyPath(
147
- platformProjectRoot: string,
148
- relativePath: string
144
+ directory: string,
145
+ relative: string
149
146
  ): Promise<string> {
150
- if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath)) {
147
+ if (typeof relative !== 'string' || !relative || path.isAbsolute(relative)) {
151
148
  throw new HarmonyConfigPluginError(
152
149
  'ERR_HARMONY_PATH_ESCAPE',
153
- `Invalid managed path: ${relativePath}`,
150
+ `Invalid managed path: ${relative}`,
154
151
  { operation: 'resolve-path' }
155
152
  );
156
153
  }
157
154
 
158
- const root = path.resolve(platformProjectRoot);
159
- const target = path.resolve(root, relativePath);
155
+ const root = path.resolve(directory);
156
+ const target = path.resolve(root, relative);
160
157
 
161
158
  if (!isInside(root, target)) {
162
159
  throw new HarmonyConfigPluginError(
163
160
  'ERR_HARMONY_PATH_ESCAPE',
164
- `Managed path escapes the Harmony project: ${relativePath}`,
161
+ `Managed path escapes the Harmony project: ${relative}`,
165
162
  { file: target, operation: 'resolve-path' }
166
163
  );
167
164
  }
@@ -172,30 +169,25 @@ export async function resolveHarmonyPath(
172
169
  }
173
170
 
174
171
  export async function resolveProjectPath(
175
- projectRoot: string,
176
- modName: keyof HarmonyProjectPaths
172
+ root: string,
173
+ name: keyof HarmonyProjectPaths
177
174
  ): 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));
181
-
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
- );
175
+ const candidates = ProjectPathCandidates[name] || [ProjectPaths[name]];
176
+ for (const candidate of candidates) {
177
+ if (fs.existsSync(path.join(root, candidate))) return resolveHarmonyPath(root, candidate);
188
178
  }
189
179
 
190
- return existing[0] || resolved[0];
180
+ return resolveHarmonyPath(root, candidates[0]);
191
181
  }
192
182
 
193
- export function toPosixRelative(projectRoot: string, target: string): string {
194
- return path.relative(projectRoot, target).split(path.sep).join('/');
183
+ export function toPosixRelative(root: string, target: string): string {
184
+ return path.relative(root, target).split(path.sep).join('/');
195
185
  }
196
186
 
197
- export const HarmonyPaths: HarmonyPathsApi = {
198
- HARMONY_PATHS: ManagedPaths,
187
+ const { nativeInputsStamp: NativeInputsStampPath, ...PublicManagedPaths } = ManagedPaths;
188
+
189
+ export const HarmonyPaths: Readonly<HarmonyPathsApi> = Object.freeze({
190
+ HARMONY_PATHS: Object.freeze(PublicManagedPaths),
199
191
  PROJECT_PATH_CANDIDATES: ProjectPathCandidates,
200
192
  PROJECT_PATHS: ProjectPaths,
201
193
  RESOURCE_PATHS: ResourcePaths,
@@ -204,6 +196,6 @@ export const HarmonyPaths: HarmonyPathsApi = {
204
196
  resolveHarmonyPath,
205
197
  resolveProjectPath,
206
198
  toPosixRelative,
207
- };
199
+ });
208
200
 
209
- export { ManagedPaths, ProjectPathCandidates, ProjectPaths, ResourcePaths };
201
+ 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>>;
@@ -0,0 +1,65 @@
1
+ import { HarmonyConfigPluginError } from './errors';
2
+
3
+ export interface HarmonyResourceItem extends Record<string, unknown> {
4
+ name: string;
5
+ value: string;
6
+ }
7
+
8
+ export interface HarmonyResourceFile extends Record<string, unknown> {
9
+ string?: HarmonyResourceItem[];
10
+ color?: HarmonyResourceItem[];
11
+ }
12
+
13
+ function setItem(file: HarmonyResourceFile, kind: 'string' | 'color', item: HarmonyResourceItem): HarmonyResourceFile {
14
+ if (!item || typeof item.name !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(item.name)
15
+ || item.name.includes('..') || typeof item.value !== 'string') {
16
+ throw new HarmonyConfigPluginError('ERR_HARMONY_RESOURCE_INVALID', `Invalid Harmony ${kind} resource.`, { operation: 'edit-resource' });
17
+ }
18
+
19
+ const items = readItems(file, kind);
20
+ const existing = items.find(value => value?.name === item.name);
21
+ file[kind] = [...items.filter(value => value?.name !== item.name), { ...existing, ...item }];
22
+
23
+ return file;
24
+ }
25
+
26
+ function readItems(file: HarmonyResourceFile, kind: 'string' | 'color'): HarmonyResourceItem[] {
27
+ if (file[kind] !== undefined && !Array.isArray(file[kind])) {
28
+ throw new HarmonyConfigPluginError('ERR_HARMONY_RESOURCE_INVALID', `Harmony ${kind} resources must be an array.`, { operation: 'edit-resource' });
29
+ }
30
+
31
+ return file[kind] ?? [];
32
+ }
33
+
34
+ export function setString(file: HarmonyResourceFile, item: HarmonyResourceItem): HarmonyResourceFile {
35
+ return setItem(file, 'string', item);
36
+ }
37
+
38
+ export function removeString(file: HarmonyResourceFile, name: string): HarmonyResourceFile {
39
+ const items = readItems(file, 'string');
40
+ if (file.string) file.string = items.filter(item => item?.name !== name);
41
+ return file;
42
+ }
43
+
44
+ export function setColor(file: HarmonyResourceFile, item: HarmonyResourceItem): HarmonyResourceFile {
45
+ return setItem(file, 'color', item);
46
+ }
47
+
48
+ export function removeColor(file: HarmonyResourceFile, name: string): HarmonyResourceFile {
49
+ const items = readItems(file, 'color');
50
+ if (file.color) file.color = items.filter(item => item?.name !== name);
51
+ return file;
52
+ }
53
+
54
+ export function toArgb(value: string | number): string {
55
+ let hex: string;
56
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 0xffffffff) {
57
+ hex = value.toString(16).padStart(8, '0');
58
+ } else if (typeof value === 'string' && /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(value)) {
59
+ hex = value.slice(1);
60
+ } else {
61
+ throw new HarmonyConfigPluginError('ERR_HARMONY_CONFIG_INVALID', 'Color must be #RRGGBB, #RRGGBBAA or an unsigned RGBA integer.', { operation: 'convert-color' });
62
+ }
63
+
64
+ return `#${hex.length === 8 ? hex.slice(6, 8) + hex.slice(0, 6) : hex}`.toUpperCase();
65
+ }