@symbiote-native/expo-modules-link 0.1.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/LICENSE +21 -0
- package/README.md +154 -0
- package/bin/postinstall.cjs +15 -0
- package/package.json +36 -0
- package/src/index.cjs +438 -0
- package/src/index.d.ts +27 -0
- package/src/index.test.cjs +421 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 A. Prokopenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# @symbiote-native/expo-modules-link
|
|
2
|
+
|
|
3
|
+
Native-module registration for apps using SymbioteNative's expo-modules-core wrapper packages
|
|
4
|
+
(`@symbiote-native/sensors`, `@symbiote-native/local-auth`, `@symbiote-native/haptics`, ...).
|
|
5
|
+
|
|
6
|
+
## Why this exists
|
|
7
|
+
|
|
8
|
+
Every `expo-modules-core`-based package (see the `symbiote-expo-native-module` skill) needs
|
|
9
|
+
two Android registration steps that RN's own autolinking can't reach, because there's no
|
|
10
|
+
`expo` aggregator package generating them: an `implementation project(':expo-<pkg>')` line in
|
|
11
|
+
the app's `android/app/build.gradle`, and a `ModulesProvider` map entry (import + class-to-name
|
|
12
|
+
mapping) in its `MainApplication.kt`. Left as a manual step, a missed entry compiles cleanly
|
|
13
|
+
and only fails at **runtime** with `Cannot find native module '<Name>'` - this has bitten the
|
|
14
|
+
project more than once.
|
|
15
|
+
|
|
16
|
+
## Setup (once per app)
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@symbiote-native/expo-modules-link": "^0.2.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"postinstall": "symbiote-expo-link"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That's the whole integration. Every wrapper package you install afterwards is picked up
|
|
30
|
+
automatically on the next install - no per-package step. You can also run it by hand at any
|
|
31
|
+
time with `npx symbiote-expo-link`.
|
|
32
|
+
|
|
33
|
+
## What a wrapper package ships
|
|
34
|
+
|
|
35
|
+
Next to its `package.json`, a `native-link.json` manifest declaring what it needs registered.
|
|
36
|
+
It has no `postinstall` of its own - the manifest is passive data the aggregator reads.
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"android": {
|
|
41
|
+
"gradleProjectName": "expo-local-authentication",
|
|
42
|
+
"modules": [
|
|
43
|
+
{
|
|
44
|
+
"importPath": "expo.modules.localauthentication.LocalAuthenticationModule",
|
|
45
|
+
"className": "LocalAuthenticationModule",
|
|
46
|
+
"nativeName": "ExpoLocalAuthentication"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`nativeName` must match that module's own `definition() { Name("...") }` string exactly -
|
|
54
|
+
that's the key `requireNativeModule(...)` resolves by on the JS side.
|
|
55
|
+
|
|
56
|
+
A package whose Android side needs an attribute on the app's own `<application>` element adds
|
|
57
|
+
`android.manifestApplicationAttributes` (`secure-store` uses this for its Auto Backup rules -
|
|
58
|
+
without them, Android backs up encrypted entries whose Keystore keys it does not back up, so a
|
|
59
|
+
restore onto a new device produces unreadable values):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"android": {
|
|
64
|
+
"manifestApplicationAttributes": {
|
|
65
|
+
"android:fullBackupContent": "@xml/secure_store_backup_rules",
|
|
66
|
+
"android:dataExtractionRules": "@xml/secure_store_data_extraction_rules"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
One process, one pass: scan the app's `node_modules` for every installed package shipping a
|
|
75
|
+
`native-link.json`, sort them by package name, and regenerate the registration blocks from
|
|
76
|
+
that list. Each block is a region delimited by a marker pair, and the generator owns
|
|
77
|
+
everything between them:
|
|
78
|
+
|
|
79
|
+
```gradle
|
|
80
|
+
// SYMBIOTE-EXPO-LINK:BEGIN DEPENDENCIES (generated by symbiote-expo-link - do not edit, regenerated on every install)
|
|
81
|
+
implementation project(':expo-application')
|
|
82
|
+
implementation project(':expo-battery')
|
|
83
|
+
// SYMBIOTE-EXPO-LINK:END DEPENDENCIES
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Everything outside the markers is yours and is never touched. Files are written atomically
|
|
87
|
+
(temp file + rename), so an interrupted install can't leave a half-written `build.gradle`.
|
|
88
|
+
|
|
89
|
+
If the app isn't a React Native project (no `android/app/build.gradle`, no `ios/*/Info.plist`),
|
|
90
|
+
or an anchor is missing, it says so and exits cleanly - linking never fails an `npm install`.
|
|
91
|
+
|
|
92
|
+
## Why an aggregator, and not a per-package postinstall
|
|
93
|
+
|
|
94
|
+
The previous design had every wrapper package's own `postinstall` append its own line. Package
|
|
95
|
+
managers run sibling lifecycle scripts concurrently, so that meant N independent OS processes
|
|
96
|
+
doing read-modify-write on one shared file - two of them could read the same version before
|
|
97
|
+
either wrote back, and the later write silently dropped the earlier one's registration.
|
|
98
|
+
|
|
99
|
+
Fixing that needs cross-process mutual exclusion, and a correct file lock is not reachable from
|
|
100
|
+
plain `fs`: there is no compare-and-unlink and no `flock`, so "decide the holder is dead" and
|
|
101
|
+
"act on it" stay two separate steps with a window in between. Successive attempts narrowed that
|
|
102
|
+
window but never closed it - the last measured about one lost registration per hundred installs.
|
|
103
|
+
|
|
104
|
+
Removing the concurrency removed the problem outright, and took three long-standing annoyances
|
|
105
|
+
with it. Entries are sorted now, so committed native files stop churning with a different order
|
|
106
|
+
on every machine. Uninstall works, because a region is rewritten from the current truth while an
|
|
107
|
+
append-only patcher could only ever grow the list. And there are no `.lock` files left to leak,
|
|
108
|
+
gitignore, or clean up after a Ctrl-C.
|
|
109
|
+
|
|
110
|
+
This is also how Expo itself does it: `expo-modules-autolinking` scans the installed tree from
|
|
111
|
+
a single process rather than having each package register itself.
|
|
112
|
+
|
|
113
|
+
## iOS Info.plist permission strings
|
|
114
|
+
|
|
115
|
+
A package that needs a usage-description string (e.g. `NSFaceIDUsageDescription` for
|
|
116
|
+
`local-auth`, `NSMotionUsageDescription` for `sensors`, `NSUserTrackingUsageDescription` for
|
|
117
|
+
`tracking-transparency`) declares it in `native-link.json` under `ios.infoPlistKeys`. The value
|
|
118
|
+
is a **generic, app-agnostic default** - this tool has no config file to read a custom string
|
|
119
|
+
from, so it can never know your app's name or tone (never copy an example app's own demo
|
|
120
|
+
wording into a package's shipped default - this bit us once during development).
|
|
121
|
+
|
|
122
|
+
Unlike the Android blocks, Info.plist gets **no generated region**. Xcode rewrites this file
|
|
123
|
+
through its own plist serializer whenever target settings change, and that serializer drops XML
|
|
124
|
+
comments - losing an END marker would make the next run insert a second block and produce
|
|
125
|
+
duplicate keys, an invalid plist. So iOS stays additive: the presence of `<key>NAME</key>` is
|
|
126
|
+
itself the idempotency check.
|
|
127
|
+
|
|
128
|
+
That makes overriding a description free:
|
|
129
|
+
|
|
130
|
+
- Add the key to your `Info.plist` with your own wording **before** installing the package →
|
|
131
|
+
the default never gets inserted at all.
|
|
132
|
+
- Edit the generated string **any time after** → the key exists, so every future run leaves it
|
|
133
|
+
alone permanently.
|
|
134
|
+
|
|
135
|
+
When your wording and the package's default disagree, the run prints a one-line notice naming
|
|
136
|
+
both. It keeps your file as-is - the point is that drift is visible, not that it's corrected.
|
|
137
|
+
|
|
138
|
+
## Android manifest attributes
|
|
139
|
+
|
|
140
|
+
`android.manifestApplicationAttributes` is additive-only for the same reason Info.plist is, plus
|
|
141
|
+
one of its own: an attribute is unique per element by construction, so presence is a sufficient
|
|
142
|
+
idempotency check, and no XML comment can live inside a tag to delimit a region anyway. An
|
|
143
|
+
attribute the app already carries is left alone and reported, never overwritten - backup rules
|
|
144
|
+
decide what leaves the device, so the app's own value has to win.
|
|
145
|
+
|
|
146
|
+
## Scope
|
|
147
|
+
|
|
148
|
+
Android registration (`build.gradle` + `MainApplication.kt` + `<application>` attributes) plus
|
|
149
|
+
iOS Info.plist permission strings. iOS's own autolinking (`use_expo_modules!(exclude: [...])` in the app's Podfile)
|
|
150
|
+
already auto-discovers every installed expo-modules-core package with zero per-package Podfile
|
|
151
|
+
edits once wired - see the `symbiote-expo-native-module` skill. The one-time per-app native
|
|
152
|
+
bootstrap (Podfile monkey-patch, `SymbioteExpoModulesFactory`, bridging header) is still a
|
|
153
|
+
manual, one-time step per app - this package only automates the *recurring* per-package
|
|
154
|
+
registration that repeats on every new native module.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The APP's own `postinstall`, or `npx symbiote-expo-link` by hand. Not a per-dependency
|
|
5
|
+
// lifecycle script, so cwd is the app root. Package managers extract the whole tree before
|
|
6
|
+
// running any postinstall, so the scan sees every installed package.
|
|
7
|
+
const { linkApp } = require('../src/index.cjs');
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
linkApp();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
// Never fail a consumer's install over this: an unlinked module fails loudly at runtime with
|
|
13
|
+
// "Cannot find native module", whereas a non-zero exit here would break the whole install.
|
|
14
|
+
console.error('[symbiote-expo-link] failed to link native modules, continuing install:', error.message);
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@symbiote-native/expo-modules-link",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/OneEyed1366/symbiote-native.git",
|
|
9
|
+
"directory": "packages/expo-modules-link"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/OneEyed1366/symbiote-native/tree/master/packages/expo-modules-link#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/OneEyed1366/symbiote-native/issues"
|
|
14
|
+
},
|
|
15
|
+
"author": "Andrey Prokopenko <psevdoproger@gmail.com>",
|
|
16
|
+
"type": "commonjs",
|
|
17
|
+
"main": "src/index.cjs",
|
|
18
|
+
"module": "src/index.cjs",
|
|
19
|
+
"types": "src/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.cjs"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"symbiote-expo-link": "./bin/postinstall.cjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"bin"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test src/index.test.cjs"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
// One process per app: scan node_modules, regenerate the registration blocks from what's
|
|
7
|
+
// installed. Same approach as expo-modules-autolinking.
|
|
8
|
+
//
|
|
9
|
+
// Do NOT move this back into each wrapper package's own postinstall. Package managers run those
|
|
10
|
+
// concurrently, and N processes doing read-modify-write on one build.gradle silently lose
|
|
11
|
+
// entries; a correct lock is not reachable from plain `fs` (no compare-and-unlink, no flock).
|
|
12
|
+
// Full history in the README under "Why an aggregator".
|
|
13
|
+
const BEGIN_TAG = 'SYMBIOTE-EXPO-LINK:BEGIN';
|
|
14
|
+
const END_TAG = 'SYMBIOTE-EXPO-LINK:END';
|
|
15
|
+
const REGION_NOTE = 'generated by symbiote-expo-link - do not edit, regenerated on every install';
|
|
16
|
+
|
|
17
|
+
const REACT_ANDROID_ANCHOR = 'implementation("com.facebook.react:react-android")';
|
|
18
|
+
const MODULES_MAP_ANCHOR = 'override fun getModulesMap(): Map<Class<out Module>, String?> = mapOf(';
|
|
19
|
+
|
|
20
|
+
const MANIFEST_FILENAME = 'native-link.json';
|
|
21
|
+
|
|
22
|
+
function isDebug() {
|
|
23
|
+
return process.env.DEBUG === '1' || process.env.DEBUG === 'true' || globalThis.__SYMBIOTE_DEBUG__ === true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function dlog(...args) {
|
|
27
|
+
if (isDebug()) console.log('[symbiote-expo-link]', ...args);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Never gated, unlike dlog: an unreachable native file or a drifting description is something
|
|
31
|
+
// the developer has to act on, and the alternative is finding out at runtime.
|
|
32
|
+
function warn(message) {
|
|
33
|
+
console.warn(`[symbiote-expo-link] ${message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Ctrl-C mid-writeFileSync truncates the target in place, and these are committed Xcode/Gradle
|
|
37
|
+
// projects, not rebuildable scaffolding. Temp file + rename is atomic on POSIX (same filesystem,
|
|
38
|
+
// same directory): fully replaced or untouched.
|
|
39
|
+
function writeFileAtomic(filePath, content) {
|
|
40
|
+
const tmpPath = `${filePath}.symbiote-expo-link.${process.pid}.tmp`;
|
|
41
|
+
fs.writeFileSync(tmpPath, content);
|
|
42
|
+
fs.renameSync(tmpPath, filePath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// cwd is already the app root for an app-level postinstall. INIT_CWD (npm sets it to wherever
|
|
46
|
+
// the install was invoked) is the fallback for being run as a nested lifecycle script instead.
|
|
47
|
+
// Neither walks up, so running this from a subdirectory of the app finds nothing.
|
|
48
|
+
function findAppRoot() {
|
|
49
|
+
const candidates = [process.cwd(), process.env.INIT_CWD].filter(Boolean);
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
if (fs.existsSync(path.join(candidate, 'android', 'app', 'build.gradle'))) return candidate;
|
|
52
|
+
if (findInfoPlistFile(candidate)) return candidate;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The app's own Info.plist, never a Pod's/framework's/test target's - those all carry the same
|
|
58
|
+
// filename. RN's default template puts it at ios/<AppName>/Info.plist; excluding
|
|
59
|
+
// Pods/build/DerivedData/*Tests* leaves exactly that one in every app this package has seen.
|
|
60
|
+
function findInfoPlistFile(appRoot) {
|
|
61
|
+
const iosRoot = path.join(appRoot, 'ios');
|
|
62
|
+
if (!fs.existsSync(iosRoot)) return null;
|
|
63
|
+
const skip = /Pods|build|DerivedData|Tests/i;
|
|
64
|
+
const stack = [iosRoot];
|
|
65
|
+
while (stack.length > 0) {
|
|
66
|
+
const dir = stack.pop();
|
|
67
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
68
|
+
if (skip.test(entry.name)) continue;
|
|
69
|
+
const full = path.join(dir, entry.name);
|
|
70
|
+
if (entry.isDirectory()) stack.push(full);
|
|
71
|
+
else if (entry.name === 'Info.plist') return full;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findMainApplicationFile(appRoot) {
|
|
78
|
+
const javaRoot = path.join(appRoot, 'android', 'app', 'src', 'main', 'java');
|
|
79
|
+
if (!fs.existsSync(javaRoot)) return null;
|
|
80
|
+
const stack = [javaRoot];
|
|
81
|
+
while (stack.length > 0) {
|
|
82
|
+
const dir = stack.pop();
|
|
83
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
84
|
+
const full = path.join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) stack.push(full);
|
|
86
|
+
else if (entry.name === 'MainApplication.kt') return full;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readManifest(packageDir, packageName) {
|
|
93
|
+
const manifestPath = path.join(packageDir, MANIFEST_FILENAME);
|
|
94
|
+
// existsSync follows symlinks, which is what pnpm's node_modules and a workspace link both are.
|
|
95
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
96
|
+
try {
|
|
97
|
+
return { packageName, manifest: JSON.parse(fs.readFileSync(manifestPath, 'utf8')) };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
warn(`skipping ${packageName}: its ${MANIFEST_FILENAME} is not valid JSON (${error.message})`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Sorted by package name so the generated blocks come out byte-identical across machines:
|
|
105
|
+
// readdir order varies, and these blocks live in committed native files.
|
|
106
|
+
function collectManifests(appRoot) {
|
|
107
|
+
const modulesRoot = path.join(appRoot, 'node_modules');
|
|
108
|
+
if (!fs.existsSync(modulesRoot)) {
|
|
109
|
+
warn(`no node_modules in ${appRoot}, nothing to link`);
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const found = [];
|
|
114
|
+
for (const entry of fs.readdirSync(modulesRoot, { withFileTypes: true })) {
|
|
115
|
+
if (entry.name.startsWith('.')) continue;
|
|
116
|
+
|
|
117
|
+
if (entry.name.startsWith('@')) {
|
|
118
|
+
const scopeDir = path.join(modulesRoot, entry.name);
|
|
119
|
+
for (const scoped of fs.readdirSync(scopeDir, { withFileTypes: true })) {
|
|
120
|
+
const entryFound = readManifest(path.join(scopeDir, scoped.name), `${entry.name}/${scoped.name}`);
|
|
121
|
+
if (entryFound) found.push(entryFound);
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const entryFound = readManifest(path.join(modulesRoot, entry.name), entry.name);
|
|
127
|
+
if (entryFound) found.push(entryFound);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
found.sort((a, b) => (a.packageName < b.packageName ? -1 : a.packageName > b.packageName ? 1 : 0));
|
|
131
|
+
dlog(`found ${found.length} linkable package(s): ${found.map((f) => f.packageName).join(', ')}`);
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Owning a whole region rather than appending lines is what lets an uninstalled package's entry
|
|
136
|
+
// disappear. Returns null (already warned) when it can't write: a half-deleted region and a
|
|
137
|
+
// missing anchor need different fixes, so each reports its own cause.
|
|
138
|
+
function writeRegion({ filePath, content, name, indent, bodyLines, findAnchorIndex, anchorLabel }) {
|
|
139
|
+
const lines = content.split('\n');
|
|
140
|
+
const block = [
|
|
141
|
+
`${indent}// ${BEGIN_TAG} ${name} (${REGION_NOTE})`,
|
|
142
|
+
...bodyLines.map((line) => `${indent}${line}`),
|
|
143
|
+
`${indent}// ${END_TAG} ${name}`,
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const beginIndex = lines.findIndex((line) => line.includes(`${BEGIN_TAG} ${name}`));
|
|
147
|
+
if (beginIndex !== -1) {
|
|
148
|
+
const endIndex = lines.findIndex((line, i) => i > beginIndex && line.includes(`${END_TAG} ${name}`));
|
|
149
|
+
if (endIndex === -1) {
|
|
150
|
+
// Guessing where a half-deleted region ends would eat the developer's code.
|
|
151
|
+
warn(
|
|
152
|
+
`${filePath} has "${BEGIN_TAG} ${name}" but no matching "${END_TAG} ${name}". ` +
|
|
153
|
+
'Restore the end marker (or delete the whole block) and reinstall.',
|
|
154
|
+
);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
lines.splice(beginIndex, endIndex - beginIndex + 1, ...block);
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const anchorIndex = findAnchorIndex(lines);
|
|
162
|
+
if (anchorIndex === -1) {
|
|
163
|
+
warn(`cannot create the ${name} block in ${filePath}: ${anchorLabel}`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
lines.splice(anchorIndex + 1, 0, ...block);
|
|
167
|
+
return lines.join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function applyIfChanged(filePath, content, nextContent) {
|
|
171
|
+
if (nextContent === null || nextContent === content) return;
|
|
172
|
+
writeFileAtomic(filePath, nextContent);
|
|
173
|
+
dlog(`updated ${filePath}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function patchBuildGradle(appRoot, entries) {
|
|
177
|
+
const gradlePath = path.join(appRoot, 'android', 'app', 'build.gradle');
|
|
178
|
+
if (!fs.existsSync(gradlePath)) {
|
|
179
|
+
dlog('android/app/build.gradle not found, skipping');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const projectNames = entries
|
|
184
|
+
.filter((entry) => entry.manifest.android && entry.manifest.android.gradleProjectName)
|
|
185
|
+
.map((entry) => entry.manifest.android.gradleProjectName);
|
|
186
|
+
const deps = [...new Set(projectNames)].sort().map((name) => `implementation project(':${name}')`);
|
|
187
|
+
|
|
188
|
+
const content = fs.readFileSync(gradlePath, 'utf8');
|
|
189
|
+
const next = writeRegion({
|
|
190
|
+
filePath: gradlePath,
|
|
191
|
+
content,
|
|
192
|
+
name: 'DEPENDENCIES',
|
|
193
|
+
indent: ' ',
|
|
194
|
+
bodyLines: deps,
|
|
195
|
+
findAnchorIndex: (lines) => lines.findIndex((line) => line.includes(REACT_ANDROID_ANCHOR)),
|
|
196
|
+
anchorLabel: `no \`${REACT_ANDROID_ANCHOR}\` line to anchor below`,
|
|
197
|
+
});
|
|
198
|
+
applyIfChanged(gradlePath, content, next);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function androidModulesOf(entries) {
|
|
202
|
+
return entries.flatMap((entry) =>
|
|
203
|
+
entry.manifest.android && Array.isArray(entry.manifest.android.modules) ? entry.manifest.android.modules : [],
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function patchMainApplication(appRoot, entries) {
|
|
208
|
+
const filePath = findMainApplicationFile(appRoot);
|
|
209
|
+
if (!filePath) {
|
|
210
|
+
dlog('MainApplication.kt not found, skipping');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const modules = androidModulesOf(entries);
|
|
215
|
+
const imports = [...new Set(modules.map((module) => `import ${module.importPath}`))].sort();
|
|
216
|
+
const mapEntries = [...new Set(modules.map((module) => `${module.className}::class.java to "${module.nativeName}",`))].sort();
|
|
217
|
+
|
|
218
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
219
|
+
|
|
220
|
+
// Anchored below the LAST import: Kotlin requires imports above the first declaration, and
|
|
221
|
+
// going last leaves hand-written ones where the developer put them.
|
|
222
|
+
const withImports = writeRegion({
|
|
223
|
+
filePath,
|
|
224
|
+
content,
|
|
225
|
+
name: 'IMPORTS',
|
|
226
|
+
indent: '',
|
|
227
|
+
bodyLines: imports,
|
|
228
|
+
findAnchorIndex: (lines) => {
|
|
229
|
+
let lastImport = -1;
|
|
230
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
231
|
+
if (lines[i].trim().startsWith('import ')) lastImport = i;
|
|
232
|
+
}
|
|
233
|
+
return lastImport;
|
|
234
|
+
},
|
|
235
|
+
anchorLabel: 'no import statements to anchor below',
|
|
236
|
+
});
|
|
237
|
+
if (withImports === null) return;
|
|
238
|
+
|
|
239
|
+
const next = writeRegion({
|
|
240
|
+
filePath,
|
|
241
|
+
content: withImports,
|
|
242
|
+
name: 'MODULES-MAP',
|
|
243
|
+
indent: ' ',
|
|
244
|
+
bodyLines: mapEntries,
|
|
245
|
+
findAnchorIndex: (lines) => lines.findIndex((line) => line.includes(MODULES_MAP_ANCHOR)),
|
|
246
|
+
anchorLabel: 'no `getModulesMap()` declaration to anchor below',
|
|
247
|
+
});
|
|
248
|
+
applyIfChanged(filePath, content, next);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// An "&" or "<" in a description would otherwise produce a plist that no longer parses. A text
|
|
252
|
+
// node needs only these three; quotes and apostrophes are legal as-is.
|
|
253
|
+
function escapeXml(value) {
|
|
254
|
+
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Inside a double-quoted attribute the quote itself also has to go, or it closes the value early.
|
|
258
|
+
function escapeXmlAttribute(value) {
|
|
259
|
+
return escapeXml(value).replace(/"/g, '"');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The bounds of the `<application ...>` opening tag. Scanning for the first unquoted ">" rather
|
|
263
|
+
// than matching a regex: RN's own template puts "${usesCleartextTraffic}" in an attribute, and
|
|
264
|
+
// a manifest is free to put a ">" inside one.
|
|
265
|
+
function findApplicationTag(content) {
|
|
266
|
+
const match = /<application(?=[\s>/])/.exec(content);
|
|
267
|
+
if (!match) return null;
|
|
268
|
+
|
|
269
|
+
let quote = null;
|
|
270
|
+
for (let i = match.index; i < content.length; i += 1) {
|
|
271
|
+
const char = content[i];
|
|
272
|
+
if (quote !== null) {
|
|
273
|
+
if (char === quote) quote = null;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (char === '"' || char === "'") quote = char;
|
|
277
|
+
else if (char === '>') return { start: match.index, end: i };
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readXmlAttribute(tagText, name) {
|
|
283
|
+
const match = new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`).exec(tagText);
|
|
284
|
+
return match ? match[1] : null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Attributes on the app's own `<application>` element, for packages whose Android side needs one
|
|
288
|
+
// (secure-store's Auto Backup exclusion rules). Additive-only with the same reasoning as
|
|
289
|
+
// Info.plist: an XML attribute is unique per element by construction, so presence is a sufficient
|
|
290
|
+
// idempotency check, and no comment can live inside a tag to delimit a region anyway.
|
|
291
|
+
function patchAndroidManifest(appRoot, entries) {
|
|
292
|
+
const wanted = new Map();
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
const attributes = entry.manifest.android && entry.manifest.android.manifestApplicationAttributes;
|
|
295
|
+
if (!attributes) continue;
|
|
296
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
297
|
+
if (!wanted.has(name)) wanted.set(name, value);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (wanted.size === 0) return;
|
|
301
|
+
|
|
302
|
+
const manifestPath = path.join(appRoot, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
|
|
303
|
+
if (!fs.existsSync(manifestPath)) {
|
|
304
|
+
dlog('AndroidManifest.xml not found, skipping application attributes');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
309
|
+
let next = content;
|
|
310
|
+
|
|
311
|
+
for (const [name, value] of [...wanted].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
312
|
+
// A metacharacter would slip past readXmlAttribute and we'd append a duplicate attribute,
|
|
313
|
+
// which is an XML parse error rather than a bad value.
|
|
314
|
+
if (!/^[\w.:-]+$/.test(name)) {
|
|
315
|
+
warn(`skipping the manifest attribute "${name}": not a plain XML attribute name`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const tag = findApplicationTag(next);
|
|
320
|
+
if (!tag) {
|
|
321
|
+
warn(`${manifestPath} has no <application> element, skipping ${name}`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const tagText = next.slice(tag.start, tag.end);
|
|
326
|
+
const escaped = escapeXmlAttribute(value);
|
|
327
|
+
const existing = readXmlAttribute(tagText, name);
|
|
328
|
+
|
|
329
|
+
if (existing !== null) {
|
|
330
|
+
if (existing !== escaped) {
|
|
331
|
+
warn(
|
|
332
|
+
`${name} in ${manifestPath} reads "${existing}" but the package manifest says ` +
|
|
333
|
+
`"${value}". Keeping the file as-is; edit it by hand if you want the manifest's.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Follow the indentation of whichever attribute currently sits last, so the tag keeps the
|
|
340
|
+
// shape the developer (or the RN template) gave it.
|
|
341
|
+
const lastNewline = tagText.lastIndexOf('\n');
|
|
342
|
+
const separator = lastNewline === -1 ? ' ' : `\n${/^[ \t]*/.exec(tagText.slice(lastNewline + 1))[0]}`;
|
|
343
|
+
const insertAt = next[tag.end - 1] === '/' ? tag.end - 1 : tag.end;
|
|
344
|
+
next = `${next.slice(0, insertAt)}${separator}${name}="${escaped}"${next.slice(insertAt)}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
applyIfChanged(manifestPath, content, next);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function readPlistString(content, key) {
|
|
351
|
+
const keyIndex = content.indexOf(`<key>${key}</key>`);
|
|
352
|
+
if (keyIndex === -1) return null;
|
|
353
|
+
const match = /<string>([\s\S]*?)<\/string>/.exec(content.slice(keyIndex));
|
|
354
|
+
return match ? match[1] : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// No generated region here, unlike the Android files: Xcode rewrites Info.plist through its own
|
|
358
|
+
// serializer whenever target settings change, and that serializer drops XML comments. A lost END
|
|
359
|
+
// marker would make the next run insert a second block, producing duplicate keys and an invalid
|
|
360
|
+
// plist. So iOS stays additive, with `<key>NAME</key>` presence as the idempotency check.
|
|
361
|
+
//
|
|
362
|
+
// That means an existing key's VALUE is never overwritten. Intended: a description is
|
|
363
|
+
// user-facing App Store copy, so a hand-edit outranks a package default. Drift would then go
|
|
364
|
+
// unnoticed, hence the warning.
|
|
365
|
+
function patchInfoPlist(appRoot, entries) {
|
|
366
|
+
const wanted = new Map();
|
|
367
|
+
for (const entry of entries) {
|
|
368
|
+
const keys = entry.manifest.ios && entry.manifest.ios.infoPlistKeys;
|
|
369
|
+
if (!keys) continue;
|
|
370
|
+
for (const [key, description] of Object.entries(keys)) {
|
|
371
|
+
if (!wanted.has(key)) wanted.set(key, description);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (wanted.size === 0) return;
|
|
375
|
+
|
|
376
|
+
const plistPath = findInfoPlistFile(appRoot);
|
|
377
|
+
if (!plistPath) {
|
|
378
|
+
dlog('Info.plist not found, skipping iOS permission strings');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const content = fs.readFileSync(plistPath, 'utf8');
|
|
383
|
+
let next = content;
|
|
384
|
+
|
|
385
|
+
for (const [key, description] of [...wanted].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
386
|
+
const escaped = escapeXml(description);
|
|
387
|
+
const existing = readPlistString(next, key);
|
|
388
|
+
|
|
389
|
+
if (existing !== null) {
|
|
390
|
+
if (existing !== escaped) {
|
|
391
|
+
warn(
|
|
392
|
+
`${key} in ${plistPath} reads "${existing}" but the package manifest says ` +
|
|
393
|
+
`"${description}". Keeping the file as-is; edit it by hand if you want the manifest's.`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const plistCloseIndex = next.lastIndexOf('</plist>');
|
|
400
|
+
const dictCloseIndex = next.lastIndexOf('</dict>', plistCloseIndex === -1 ? undefined : plistCloseIndex);
|
|
401
|
+
if (dictCloseIndex === -1) {
|
|
402
|
+
warn(`${plistPath} has no closing </dict>, skipping ${key}`);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const block = `\t<key>${key}</key>\n\t<string>${escaped}</string>\n`;
|
|
407
|
+
next = next.slice(0, dictCloseIndex) + block + next.slice(dictCloseIndex);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
applyIfChanged(plistPath, content, next);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Safe to run any number of times, and after an uninstall.
|
|
414
|
+
function linkApp(explicitAppRoot) {
|
|
415
|
+
const appRoot = explicitAppRoot || findAppRoot();
|
|
416
|
+
if (!appRoot) {
|
|
417
|
+
warn('no React Native app here (no android/app/build.gradle, no ios/**/Info.plist), skipping');
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const entries = collectManifests(appRoot);
|
|
422
|
+
patchBuildGradle(appRoot, entries);
|
|
423
|
+
patchMainApplication(appRoot, entries);
|
|
424
|
+
patchAndroidManifest(appRoot, entries);
|
|
425
|
+
patchInfoPlist(appRoot, entries);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
module.exports = {
|
|
429
|
+
linkApp,
|
|
430
|
+
collectManifests,
|
|
431
|
+
findAppRoot,
|
|
432
|
+
findInfoPlistFile,
|
|
433
|
+
findMainApplicationFile,
|
|
434
|
+
patchBuildGradle,
|
|
435
|
+
patchMainApplication,
|
|
436
|
+
patchAndroidManifest,
|
|
437
|
+
patchInfoPlist,
|
|
438
|
+
};
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type ISymbioteExpoLinkAndroidModule = {
|
|
2
|
+
importPath: string;
|
|
3
|
+
className: string;
|
|
4
|
+
nativeName: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ISymbioteExpoLinkManifest = {
|
|
8
|
+
android?: {
|
|
9
|
+
gradleProjectName: string;
|
|
10
|
+
modules: ISymbioteExpoLinkAndroidModule[];
|
|
11
|
+
/** Attributes to set on the app's own `<application>` element, e.g. Auto Backup rules. */
|
|
12
|
+
manifestApplicationAttributes?: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
ios?: {
|
|
15
|
+
infoPlistKeys: Record<string, string>;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ISymbioteExpoLinkEntry = {
|
|
20
|
+
packageName: string;
|
|
21
|
+
manifest: ISymbioteExpoLinkManifest;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Scans the app's node_modules and regenerates every native registration block. */
|
|
25
|
+
export function linkApp(appRoot?: string): void;
|
|
26
|
+
export function collectManifests(appRoot: string): ISymbioteExpoLinkEntry[];
|
|
27
|
+
export function findAppRoot(): string | null;
|
|
@@ -0,0 +1,421 @@
|
|
|
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 "Best" 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&D badges <fast> & 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
|
+
});
|