@symbiote-native/expo-modules-link 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,10 +15,16 @@ project more than once.
15
15
 
16
16
  ## Setup (once per app)
17
17
 
18
+ ```bash
19
+ npm install @symbiote-native/expo-modules-link
20
+ ```
21
+
22
+ Then wire the `postinstall` hook so every install re-runs the linker:
23
+
18
24
  ```json
19
25
  {
20
26
  "dependencies": {
21
- "@symbiote-native/expo-modules-link": "^0.2.0"
27
+ "@symbiote-native/expo-modules-link": "^0.1.0"
22
28
  },
23
29
  "scripts": {
24
30
  "postinstall": "symbiote-expo-link"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/expo-modules-link",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Native-module registration for apps using expo-modules-core wrapper packages (sensors, local-auth, haptics, ...). One app-level postinstall scans node_modules for every installed package's native-link.json and regenerates the app's app/build.gradle and MainApplication.kt registration blocks, so a new native module never needs a hand-edit.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,7 +25,10 @@
25
25
  },
26
26
  "files": [
27
27
  "src",
28
- "bin"
28
+ "bin",
29
+ "!src/**/*.test.*",
30
+ "!src/**/*.spec.*",
31
+ "!src/**/*.detox.*"
29
32
  ],
30
33
  "publishConfig": {
31
34
  "access": "public"
@@ -1,421 +0,0 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
- const fs = require('node:fs');
6
- const os = require('node:os');
7
- const path = require('node:path');
8
-
9
- const {
10
- linkApp,
11
- collectManifests,
12
- patchBuildGradle,
13
- patchMainApplication,
14
- patchAndroidManifest,
15
- patchInfoPlist,
16
- } = require('./index.cjs');
17
-
18
- // Trimmed to exercise every anchor: the react-android dependency line, the last import, the
19
- // ModulesProvider mapOf, and the closing </dict>.
20
- const BUILD_GRADLE_FIXTURE = `dependencies {
21
- implementation("com.facebook.react:react-android")
22
-
23
- if (hermesEnabled.toBoolean()) {
24
- implementation("com.facebook.react:hermes-android")
25
- }
26
- }
27
- `;
28
-
29
- const MAIN_APPLICATION_FIXTURE = `package com.canaryexpo
30
-
31
- import android.app.Application
32
- import expo.modules.kotlin.ModulesProvider
33
- import expo.modules.kotlin.modules.Module
34
-
35
- private class ExpoModulesProvider : ModulesProvider {
36
- override fun getModulesMap(): Map<Class<out Module>, String?> = mapOf(
37
- )
38
- }
39
- `;
40
-
41
- const INFO_PLIST_FIXTURE = `<?xml version="1.0" encoding="UTF-8"?>
42
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
43
- <plist version="1.0">
44
- <dict>
45
- <key>CFBundleDisplayName</key>
46
- <string>CanaryExpo</string>
47
- <key>NSAppTransportSecurity</key>
48
- <dict>
49
- <key>NSAllowsArbitraryLoads</key>
50
- <false/>
51
- </dict>
52
- </dict>
53
- </plist>
54
- `;
55
-
56
- // The "${usesCleartextTraffic}" attribute is RN's own template and deliberately kept: it is why
57
- // the opening tag is scanned for an unquoted ">" instead of matched with a regex.
58
- const ANDROID_MANIFEST_FIXTURE = `<manifest xmlns:android="http://schemas.android.com/apk/res/android">
59
- <uses-permission android:name="android.permission.INTERNET" />
60
-
61
- <application
62
- android:name=".MainApplication"
63
- android:allowBackup="false"
64
- android:usesCleartextTraffic="\${usesCleartextTraffic}">
65
- <activity android:name=".MainActivity" android:exported="true" />
66
- </application>
67
- </manifest>
68
- `;
69
-
70
- const LOCAL_AUTH_MANIFEST = {
71
- android: {
72
- gradleProjectName: 'expo-local-authentication',
73
- modules: [
74
- {
75
- importPath: 'expo.modules.localauthentication.LocalAuthenticationModule',
76
- className: 'LocalAuthenticationModule',
77
- nativeName: 'ExpoLocalAuthentication',
78
- },
79
- ],
80
- },
81
- ios: { infoPlistKeys: { NSFaceIDUsageDescription: 'CanaryExpo uses Face ID to demo local-auth.' } },
82
- };
83
-
84
- const SENSORS_MANIFEST = {
85
- android: {
86
- gradleProjectName: 'expo-sensors',
87
- modules: [
88
- {
89
- importPath: 'expo.modules.sensors.modules.AccelerometerModule',
90
- className: 'AccelerometerModule',
91
- nativeName: 'ExponentAccelerometer',
92
- },
93
- {
94
- importPath: 'expo.modules.sensors.modules.BarometerModule',
95
- className: 'BarometerModule',
96
- nativeName: 'ExpoBarometer',
97
- },
98
- ],
99
- },
100
- };
101
-
102
- const SECURE_STORE_MANIFEST = {
103
- android: {
104
- gradleProjectName: 'expo-secure-store',
105
- modules: [
106
- {
107
- importPath: 'expo.modules.securestore.SecureStoreModule',
108
- className: 'SecureStoreModule',
109
- nativeName: 'ExpoSecureStore',
110
- },
111
- ],
112
- manifestApplicationAttributes: {
113
- 'android:dataExtractionRules': '@xml/secure_store_data_extraction_rules',
114
- 'android:fullBackupContent': '@xml/secure_store_backup_rules',
115
- },
116
- },
117
- };
118
-
119
- const GRADLE_PATH = ['android', 'app', 'build.gradle'];
120
- const MAIN_APP_PATH = ['android', 'app', 'src', 'main', 'java', 'com', 'canaryexpo', 'MainApplication.kt'];
121
- const ANDROID_MANIFEST_PATH = ['android', 'app', 'src', 'main', 'AndroidManifest.xml'];
122
- const PLIST_PATH = ['ios', 'CanaryExpo', 'Info.plist'];
123
-
124
- function makeAppRoot() {
125
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'symbiote-expo-link-test-'));
126
- fs.mkdirSync(path.join(root, ...MAIN_APP_PATH.slice(0, -1)), { recursive: true });
127
- fs.writeFileSync(path.join(root, ...GRADLE_PATH), BUILD_GRADLE_FIXTURE);
128
- fs.writeFileSync(path.join(root, ...MAIN_APP_PATH), MAIN_APPLICATION_FIXTURE);
129
- fs.writeFileSync(path.join(root, ...ANDROID_MANIFEST_PATH), ANDROID_MANIFEST_FIXTURE);
130
- fs.mkdirSync(path.join(root, ...PLIST_PATH.slice(0, -1)), { recursive: true });
131
- fs.writeFileSync(path.join(root, ...PLIST_PATH), INFO_PLIST_FIXTURE);
132
- return root;
133
- }
134
-
135
- // Materialises a manifest the way a package manager would, so collectManifests and linkApp run
136
- // against a real directory layout rather than a stubbed list.
137
- function installPackage(appRoot, packageName, manifest) {
138
- const packageDir = path.join(appRoot, 'node_modules', ...packageName.split('/'));
139
- fs.mkdirSync(packageDir, { recursive: true });
140
- fs.writeFileSync(path.join(packageDir, 'native-link.json'), JSON.stringify(manifest));
141
- return packageDir;
142
- }
143
-
144
- function read(appRoot, segments) {
145
- return fs.readFileSync(path.join(appRoot, ...segments), 'utf8');
146
- }
147
-
148
- function entriesOf(...manifests) {
149
- return manifests.map((manifest, i) => ({ packageName: `pkg-${i}`, manifest }));
150
- }
151
-
152
- test('collectManifests finds scoped and unscoped packages, sorted, ignoring the rest', () => {
153
- const appRoot = makeAppRoot();
154
- installPackage(appRoot, '@symbiote-native/sensors', SENSORS_MANIFEST);
155
- installPackage(appRoot, '@symbiote-native/local-auth', LOCAL_AUTH_MANIFEST);
156
- installPackage(appRoot, 'legacy-native-wrapper', SENSORS_MANIFEST);
157
- fs.mkdirSync(path.join(appRoot, 'node_modules', 'react'), { recursive: true });
158
- fs.mkdirSync(path.join(appRoot, 'node_modules', '.bin'), { recursive: true });
159
-
160
- const found = collectManifests(appRoot);
161
-
162
- assert.deepEqual(
163
- found.map((entry) => entry.packageName),
164
- ['@symbiote-native/local-auth', '@symbiote-native/sensors', 'legacy-native-wrapper'],
165
- 'sorted by package name, and packages without a native-link.json are skipped',
166
- );
167
- });
168
-
169
- test('collectManifests skips a package whose manifest is malformed instead of throwing', () => {
170
- const appRoot = makeAppRoot();
171
- const brokenDir = installPackage(appRoot, '@symbiote-native/broken', SENSORS_MANIFEST);
172
- fs.writeFileSync(path.join(brokenDir, 'native-link.json'), '{ not json');
173
- installPackage(appRoot, '@symbiote-native/local-auth', LOCAL_AUTH_MANIFEST);
174
-
175
- const found = collectManifests(appRoot);
176
-
177
- assert.deepEqual(found.map((entry) => entry.packageName), ['@symbiote-native/local-auth']);
178
- });
179
-
180
- test('patchBuildGradle generates a sorted dependency region and is byte-stable on re-run', () => {
181
- const appRoot = makeAppRoot();
182
- const entries = entriesOf(SENSORS_MANIFEST, LOCAL_AUTH_MANIFEST);
183
-
184
- patchBuildGradle(appRoot, entries);
185
- const afterFirst = read(appRoot, GRADLE_PATH);
186
-
187
- assert.match(afterFirst, /implementation project\(':expo-local-authentication'\)/);
188
- assert.match(afterFirst, /implementation project\(':expo-sensors'\)/);
189
- assert.ok(
190
- afterFirst.indexOf('expo-local-authentication') < afterFirst.indexOf("':expo-sensors'"),
191
- 'entries are emitted in sorted order regardless of manifest order',
192
- );
193
- assert.match(afterFirst, /hermesEnabled\.toBoolean\(\)/, 'must not touch unrelated existing content');
194
-
195
- patchBuildGradle(appRoot, entries);
196
- assert.equal(read(appRoot, GRADLE_PATH), afterFirst, 're-running must be a no-op');
197
- });
198
-
199
- // The point of owning a region instead of appending: a leftover `implementation project(...)`
200
- // for an uninstalled package fails the Gradle build.
201
- test('patchBuildGradle drops the entry of a package that is no longer installed', () => {
202
- const appRoot = makeAppRoot();
203
-
204
- patchBuildGradle(appRoot, entriesOf(LOCAL_AUTH_MANIFEST, SENSORS_MANIFEST));
205
- assert.match(read(appRoot, GRADLE_PATH), /expo-sensors/);
206
-
207
- patchBuildGradle(appRoot, entriesOf(LOCAL_AUTH_MANIFEST));
208
- const after = read(appRoot, GRADLE_PATH);
209
-
210
- assert.doesNotMatch(after, /expo-sensors/, 'the removed package must disappear from the region');
211
- assert.match(after, /expo-local-authentication/, 'the remaining package stays');
212
- });
213
-
214
- test('patchMainApplication generates both regions, sorted, and is byte-stable on re-run', () => {
215
- const appRoot = makeAppRoot();
216
- const entries = entriesOf(SENSORS_MANIFEST, LOCAL_AUTH_MANIFEST);
217
-
218
- patchMainApplication(appRoot, entries);
219
- const afterFirst = read(appRoot, MAIN_APP_PATH);
220
-
221
- assert.match(afterFirst, /^import expo\.modules\.localauthentication\.LocalAuthenticationModule$/m);
222
- assert.match(afterFirst, /^import expo\.modules\.sensors\.modules\.AccelerometerModule$/m);
223
- assert.match(afterFirst, /AccelerometerModule::class\.java to "ExponentAccelerometer",/);
224
- assert.match(afterFirst, /LocalAuthenticationModule::class\.java to "ExpoLocalAuthentication",/);
225
- assert.match(afterFirst, /import expo\.modules\.kotlin\.ModulesProvider/, 'pre-existing imports survive');
226
- assert.ok(
227
- afterFirst.indexOf('AccelerometerModule::class') < afterFirst.indexOf('BarometerModule::class'),
228
- 'map entries are sorted',
229
- );
230
-
231
- patchMainApplication(appRoot, entries);
232
- assert.equal(read(appRoot, MAIN_APP_PATH), afterFirst, 're-running must be a no-op');
233
- });
234
-
235
- test('patchMainApplication drops the import and map entry of an uninstalled package', () => {
236
- const appRoot = makeAppRoot();
237
-
238
- patchMainApplication(appRoot, entriesOf(LOCAL_AUTH_MANIFEST, SENSORS_MANIFEST));
239
- assert.match(read(appRoot, MAIN_APP_PATH), /AccelerometerModule/);
240
-
241
- patchMainApplication(appRoot, entriesOf(LOCAL_AUTH_MANIFEST));
242
- const after = read(appRoot, MAIN_APP_PATH);
243
-
244
- assert.doesNotMatch(after, /AccelerometerModule/);
245
- assert.match(after, /LocalAuthenticationModule::class\.java to "ExpoLocalAuthentication",/);
246
- });
247
-
248
- // The marker pair is the contract: everything outside it belongs to the developer.
249
- test('patchMainApplication leaves hand-written code above and below the regions untouched', () => {
250
- const appRoot = makeAppRoot();
251
- const filePath = path.join(appRoot, ...MAIN_APP_PATH);
252
- fs.writeFileSync(
253
- filePath,
254
- MAIN_APPLICATION_FIXTURE.replace(
255
- ' override fun getModulesMap',
256
- ' private val handWritten = "keep me"\n\n override fun getModulesMap',
257
- ).replace('import android.app.Application', 'import android.app.Application\nimport com.example.HandPicked'),
258
- );
259
-
260
- patchMainApplication(appRoot, entriesOf(LOCAL_AUTH_MANIFEST));
261
- patchMainApplication(appRoot, entriesOf(LOCAL_AUTH_MANIFEST, SENSORS_MANIFEST));
262
- const content = fs.readFileSync(filePath, 'utf8');
263
-
264
- assert.match(content, /private val handWritten = "keep me"/);
265
- assert.match(content, /^import com\.example\.HandPicked$/m);
266
- });
267
-
268
- test('a BEGIN marker with its END deleted by hand is refused, not guessed at', () => {
269
- const appRoot = makeAppRoot();
270
- patchBuildGradle(appRoot, entriesOf(LOCAL_AUTH_MANIFEST));
271
-
272
- const gradlePath = path.join(appRoot, ...GRADLE_PATH);
273
- const mutilated = fs
274
- .readFileSync(gradlePath, 'utf8')
275
- .split('\n')
276
- .filter((line) => !line.includes('SYMBIOTE-EXPO-LINK:END'))
277
- .join('\n');
278
- fs.writeFileSync(gradlePath, mutilated);
279
-
280
- patchBuildGradle(appRoot, entriesOf(LOCAL_AUTH_MANIFEST, SENSORS_MANIFEST));
281
-
282
- assert.equal(fs.readFileSync(gradlePath, 'utf8'), mutilated, 'must not touch a file with a broken region');
283
- });
284
-
285
- test('patchAndroidManifest adds the application attributes once, keeping the tag intact', () => {
286
- const appRoot = makeAppRoot();
287
- const entries = entriesOf(SECURE_STORE_MANIFEST, LOCAL_AUTH_MANIFEST);
288
-
289
- patchAndroidManifest(appRoot, entries);
290
- const afterFirst = read(appRoot, ANDROID_MANIFEST_PATH);
291
-
292
- assert.match(afterFirst, /android:fullBackupContent="@xml\/secure_store_backup_rules"/);
293
- assert.match(afterFirst, /android:dataExtractionRules="@xml\/secure_store_data_extraction_rules"/);
294
- assert.match(afterFirst, /android:usesCleartextTraffic="\$\{usesCleartextTraffic\}"/, 'existing attributes survive');
295
- assert.match(afterFirst, /<activity android:name="\.MainActivity"/, 'the element body is untouched');
296
- assert.match(afterFirst, /^ android:fullBackupContent=/m, "follows the tag's own indentation");
297
-
298
- patchAndroidManifest(appRoot, entries);
299
- assert.equal(read(appRoot, ANDROID_MANIFEST_PATH), afterFirst, 're-running must be a no-op');
300
- });
301
-
302
- // Same policy as a permission description: the app's own backup rules outrank a package default,
303
- // because overwriting them would silently change what the app backs up.
304
- test('patchAndroidManifest keeps an attribute the app already set', () => {
305
- const appRoot = makeAppRoot();
306
- const manifestPath = path.join(appRoot, ...ANDROID_MANIFEST_PATH);
307
- fs.writeFileSync(
308
- manifestPath,
309
- ANDROID_MANIFEST_FIXTURE.replace('android:allowBackup="false"', 'android:fullBackupContent="@xml/my_own_rules"'),
310
- );
311
-
312
- patchAndroidManifest(appRoot, entriesOf(SECURE_STORE_MANIFEST));
313
- const content = fs.readFileSync(manifestPath, 'utf8');
314
-
315
- assert.match(content, /android:fullBackupContent="@xml\/my_own_rules"/);
316
- assert.doesNotMatch(content, /secure_store_backup_rules/);
317
- assert.match(content, /android:dataExtractionRules="@xml\/secure_store_data_extraction_rules"/, 'the other attribute still lands');
318
- });
319
-
320
- test('patchAndroidManifest escapes a quote in a value instead of closing it early', () => {
321
- const appRoot = makeAppRoot();
322
-
323
- patchAndroidManifest(appRoot, entriesOf({
324
- android: { manifestApplicationAttributes: { 'android:label': 'The "Best" App' } },
325
- }));
326
-
327
- assert.match(read(appRoot, ANDROID_MANIFEST_PATH), /android:label="The &quot;Best&quot; App"/);
328
- });
329
-
330
- test('patchInfoPlist inserts each permission string once, inside the outer dict', () => {
331
- const appRoot = makeAppRoot();
332
- const entries = entriesOf(LOCAL_AUTH_MANIFEST, {
333
- ios: { infoPlistKeys: { NSMotionUsageDescription: 'reads motion data' } },
334
- });
335
-
336
- patchInfoPlist(appRoot, entries);
337
- const afterFirst = read(appRoot, PLIST_PATH);
338
-
339
- assert.match(afterFirst, /<key>NSFaceIDUsageDescription<\/key>/);
340
- assert.match(afterFirst, /<key>NSMotionUsageDescription<\/key>/);
341
- assert.match(afterFirst, /<key>CFBundleDisplayName<\/key>/, 'pre-existing keys survive');
342
- assert.ok(
343
- afterFirst.indexOf('NSFaceIDUsageDescription') > afterFirst.indexOf('NSAllowsArbitraryLoads'),
344
- 'lands in the OUTER dict, after the nested NSAppTransportSecurity dict closes',
345
- );
346
-
347
- patchInfoPlist(appRoot, entries);
348
- assert.equal(read(appRoot, PLIST_PATH), afterFirst, 're-running must be a no-op');
349
- });
350
-
351
- // An unescaped `&` or `<` produces a plist no parser will read, and it surfaces as an opaque
352
- // build error far from its cause.
353
- test('patchInfoPlist escapes XML metacharacters in a description', () => {
354
- const appRoot = makeAppRoot();
355
-
356
- patchInfoPlist(appRoot, entriesOf({
357
- ios: { infoPlistKeys: { NSCameraUsageDescription: 'Scan R&D badges <fast> & often' } },
358
- }));
359
- const content = read(appRoot, PLIST_PATH);
360
-
361
- assert.match(content, /<string>Scan R&amp;D badges &lt;fast&gt; &amp; often<\/string>/);
362
- assert.doesNotMatch(content, /<string>Scan R&D/, 'the raw ampersand must not survive');
363
- });
364
-
365
- // Deliberate policy, not an oversight: a permission description is user-facing App Store copy,
366
- // so a hand-edit outranks a package default. Drift is reported rather than silently applied.
367
- test('patchInfoPlist keeps a hand-edited description instead of overwriting it', () => {
368
- const appRoot = makeAppRoot();
369
- const plistPath = path.join(appRoot, ...PLIST_PATH);
370
-
371
- patchInfoPlist(appRoot, entriesOf(LOCAL_AUTH_MANIFEST));
372
- fs.writeFileSync(
373
- plistPath,
374
- fs.readFileSync(plistPath, 'utf8').replace(
375
- '<string>CanaryExpo uses Face ID to demo local-auth.</string>',
376
- '<string>Hand-written copy the store approved.</string>',
377
- ),
378
- );
379
-
380
- patchInfoPlist(appRoot, entriesOf(LOCAL_AUTH_MANIFEST));
381
- const content = fs.readFileSync(plistPath, 'utf8');
382
-
383
- assert.match(content, /<string>Hand-written copy the store approved\.<\/string>/);
384
- assert.doesNotMatch(content, /demo local-auth/);
385
- });
386
-
387
- test('linkApp wires every installed package end to end from one scan', () => {
388
- const appRoot = makeAppRoot();
389
- installPackage(appRoot, '@symbiote-native/local-auth', LOCAL_AUTH_MANIFEST);
390
- installPackage(appRoot, '@symbiote-native/sensors', SENSORS_MANIFEST);
391
- installPackage(appRoot, '@symbiote-native/secure-store', SECURE_STORE_MANIFEST);
392
-
393
- const touched = [GRADLE_PATH, MAIN_APP_PATH, ANDROID_MANIFEST_PATH, PLIST_PATH];
394
- linkApp(appRoot);
395
-
396
- assert.match(read(appRoot, GRADLE_PATH), /implementation project\(':expo-sensors'\)/);
397
- assert.match(read(appRoot, MAIN_APP_PATH), /BarometerModule::class\.java to "ExpoBarometer",/);
398
- assert.match(read(appRoot, ANDROID_MANIFEST_PATH), /android:fullBackupContent="@xml\/secure_store_backup_rules"/);
399
- assert.match(read(appRoot, PLIST_PATH), /<key>NSFaceIDUsageDescription<\/key>/);
400
-
401
- const snapshot = touched.map((segments) => read(appRoot, segments));
402
- linkApp(appRoot);
403
- assert.deepEqual(
404
- touched.map((segments) => read(appRoot, segments)),
405
- snapshot,
406
- 'a second full run changes nothing',
407
- );
408
- });
409
-
410
- test('linkApp on an app with no linkable packages leaves empty regions, not junk', () => {
411
- const appRoot = makeAppRoot();
412
- fs.mkdirSync(path.join(appRoot, 'node_modules'), { recursive: true });
413
-
414
- linkApp(appRoot);
415
- const gradle = read(appRoot, GRADLE_PATH);
416
-
417
- assert.match(gradle, /SYMBIOTE-EXPO-LINK:BEGIN DEPENDENCIES/);
418
- assert.match(gradle, /SYMBIOTE-EXPO-LINK:END DEPENDENCIES/);
419
- assert.doesNotMatch(gradle, /implementation project/);
420
- assert.match(gradle, /hermesEnabled\.toBoolean\(\)/);
421
- });