@umutcansu/react-native-pinvault 2.4.1 → 2.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.0 — 2026-10-12
4
+
5
+ ### Fixed
6
+
7
+ - **Expo: apps with their own Android backup rules build again, with
8
+ PinVault's excludes kept.** An app or plugin (`expo-secure-store`) that sets
9
+ `fullBackupContent` / `dataExtractionRules` failed the manifest merge
10
+ against the library's rules. At the end of prebuild the plugin now copies
11
+ those rules to `res/xml/pinvault_merged_<name>.xml` with PinVault's excludes
12
+ added (only where the app's includes reach) and points the manifest at the
13
+ copy with `tools:replace`; rules it cannot find stop prebuild. Built with
14
+ Expo SDK 54 + `expo-secure-store`, Android release.
15
+
16
+ ### Changed
17
+
18
+ - Depends on PinVault 2.5.0 (Maven Central, Swift package tag `v2.5.0`).
19
+
20
+ ## 2.4.2 — 2026-10-10
21
+
22
+ ### Added
23
+
24
+ - **Expo config plugin** (`app.plugin.js`): at `expo prebuild` it sets the
25
+ iOS deployment target to 16.0, writes the Info.plist keys
26
+ (`faceIDPermission`, `backgroundUpdates`), adds
27
+ `PinVaultBridge.registerBackgroundTask()` to a Swift `AppDelegate`, copies
28
+ the native security file into Android's assets and the iOS app bundle
29
+ (`nativeSecurityFile`), and writes the two native opt-outs only when asked
30
+ (`allowNoNativeSecurityFile`, `pinReactNativeNetworking: false`). Built and
31
+ run with Expo SDK 54 (React Native 0.81), Android and iOS release builds.
32
+
33
+ ### Changed
34
+
35
+ - **Native libraries 2.4.2:** release builds refuse `allowUnsigned()` /
36
+ `allowUnpinnedConfigApi()` unless the block also calls
37
+ `allowRelaxationsInRelease()`. The bridge calls it only where the native
38
+ security file itself allows the relaxation (`allowUnsigned` /
39
+ `allowUnpinned`), so the native file stays the only place that decides;
40
+ JS cannot turn it on.
41
+
3
42
  ## 2.4.1 — 2026-10-10
4
43
 
5
44
  ### Security
package/README.md CHANGED
@@ -29,9 +29,9 @@ npm install @umutcansu/react-native-pinvault
29
29
  cd ios && pod install
30
30
  ```
31
31
 
32
- The native libraries come with it, at the package's own version: npm 2.4.0
33
- uses `io.github.umutcansu:pinvault:2.4.0` from Maven Central and the Swift
34
- package at tag `v2.4.0`. The New Architecture must stay on (React Native's
32
+ The native libraries come with it, at the package's own version: npm 2.5.0
33
+ uses `io.github.umutcansu:pinvault:2.5.0` from Maven Central and the Swift
34
+ package at tag `v2.5.0`. The New Architecture must stay on (React Native's
35
35
  default since 0.76).
36
36
 
37
37
  **iOS.** The pod depends on the PinVault Swift package through React Native's
@@ -65,6 +65,55 @@ hooks before `Application.onCreate` runs (see [Networking](#networking)).
65
65
  **Release builds.** Put the trust anchors into the app itself, not only into
66
66
  the JS bundle: [Native security file](#native-security-file).
67
67
 
68
+ ### Expo
69
+
70
+ A native module: it runs in a development build or `expo prebuild`, not in
71
+ Expo Go. The package's config plugin does the steps above at prebuild — iOS
72
+ deployment target 16.0 (`Podfile.properties.json` and the Xcode project),
73
+ the Info.plist keys, the background task in `AppDelegate.swift`, and the
74
+ native security file copied into Android's assets and the iOS app bundle:
75
+
76
+ ```bash
77
+ npx expo install @umutcansu/react-native-pinvault
78
+ ```
79
+
80
+ ```json
81
+ {
82
+ "expo": {
83
+ "plugins": [
84
+ ["@umutcansu/react-native-pinvault", {
85
+ "nativeSecurityFile": "./pinvault_security.json",
86
+ "faceIDPermission": "Unlock your protected files",
87
+ "backgroundUpdates": true
88
+ }]
89
+ ]
90
+ }
91
+ }
92
+ ```
93
+
94
+ | Option | Does |
95
+ |---|---|
96
+ | `nativeSecurityFile` | path (from the project root) of your `pinvault_security.json`; checked to be a JSON object at prebuild |
97
+ | `faceIDPermission` | `NSFaceIDUsageDescription`, for vault files behind the screen lock (`userAuth`) |
98
+ | `backgroundUpdates` | `BGTaskSchedulerPermittedIdentifiers`, `UIBackgroundModes` = `fetch` and `PinVaultBridge.registerBackgroundTask()` in a Swift `AppDelegate` (Expo SDK 53+), for `schedulePeriodicUpdates` |
99
+ | `allowNoNativeSecurityFile` | `true` writes the opt-out of the file (Android meta-data, Info.plist): release builds then take anchors from JS — not recommended |
100
+ | `pinReactNativeNetworking` | `false` removes the networking provider and writes `PinVaultPinReactNativeNetworking` = NO: RN's own fetch / WebSocket stay unpinned |
101
+
102
+ Unknown options and wrong types stop prebuild. Tested with Expo SDK 54
103
+ (React Native 0.81) on Android and iOS, release builds.
104
+
105
+ **Backup rules.** The Android library keeps its stores out of cloud backup
106
+ and device transfer with its own `fullBackupContent` / `dataExtractionRules`.
107
+ An app (or a plugin such as `expo-secure-store`) that sets its own rules used
108
+ to fail the manifest merge; replacing them by hand would drop PinVault's
109
+ excludes. At the end of prebuild the plugin copies each such rules
110
+ file (from the app or a library under `node_modules`) to
111
+ `res/xml/pinvault_merged_<name>.xml` with PinVault's excludes added, and
112
+ points the manifest at the copy with `tools:replace`. Rules it cannot find stop
113
+ prebuild. A bare React Native app with its own rules adds the
114
+ `<exclude>` lines of the library's `pinvault_backup_rules.xml` /
115
+ `pinvault_data_extraction_rules.xml` to them and uses `tools:replace`.
116
+
68
117
  ## Quick start
69
118
 
70
119
  ```ts
@@ -216,7 +265,10 @@ parsed as strictly; a broken file rejects `start` with `E_INVALID_CONFIG`
216
265
  a block that only names itself rejects `start` — otherwise the bundle would
217
266
  supply the pins and keys;
218
267
  - `allowUnsigned`, `allowUnpinnedConfigApi` and `allowServerGeneratedKey` from
219
- JS are refused unless the file allows them for that block;
268
+ JS are refused unless the file allows them for that block; a release build
269
+ of the native libraries (2.4.2+) refuses the first two unless the block also
270
+ says `allowRelaxationsInRelease()`, which the bridge adds only where the file
271
+ allows the relaxation;
220
272
  - a block's `url`, `enrollmentUrl`, `renewalUrl`, `tokenHosts` and
221
273
  `clientCertHosts` are fixed where the file gives them (where the block talks to, and who gets its token and identity),
222
274
  and `attestation: true` / `proofOfPossession: true` there cannot be turned off from JS;
@@ -4,7 +4,7 @@ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
4
 
5
5
  # PinVault for iOS is a Swift package (Package.swift at the repository root,
6
6
  # product "PinVault"). React Native's spm_dependency adds it to the Pods project:
7
- # - consumers: the git URL at the tag of this package's version (v2.4.1);
7
+ # - consumers: the git URL at the tag of this package's version (v2.5.0);
8
8
  # - development: PINVAULT_IOS_PACKAGE_PATH = a PinVault checkout (the sample's
9
9
  # Podfile sets it to this repository), the counterpart of pinvault.localPath.
10
10
  pinvault_url = "https://github.com/umutcansu/PinVault.git"
@@ -9,7 +9,7 @@ buildscript {
9
9
  kotlinVersion: "2.2.0",
10
10
  minSdkVersion: 24,
11
11
  compileSdkVersion: 36,
12
- pinvaultVersion: "2.4.1",
12
+ pinvaultVersion: "2.5.0",
13
13
  ]
14
14
  ext.pinvaultRnExt = { prop ->
15
15
  rootProject.ext.has(prop) ? rootProject.ext.get(prop) : PinVaultRN[prop]
@@ -321,6 +321,8 @@ internal object ConfigParser {
321
321
  if (sec.allowUnsigned) this.allowUnsigned()
322
322
  sec.serverScope?.let { this.serverScope(it) }
323
323
  if (sec.allowUnpinned) this.allowUnpinnedConfigApi()
324
+ // The native security file allowed them for this block: the library keeps them in a release build too.
325
+ if (native != null && (sec.allowUnsigned || sec.allowUnpinned)) this.allowRelaxationsInRelease()
324
326
  if (sec.allowServerKey) this.allowServerGeneratedKey()
325
327
  sec.clientCaPins?.let { this.clientCaPins(it) }
326
328
  maxLifetime?.let { this.maxClientCertLifetimeDays(it) }
package/app.plugin.js ADDED
@@ -0,0 +1,2 @@
1
+ // Expo config plugin: see plugin/withPinVault.js and the README's "Expo" section.
2
+ module.exports = require('./plugin/withPinVault');
@@ -346,6 +346,8 @@ public enum ConfigParser {
346
346
  if sec.allowUnsigned { api.allowUnsigned() }
347
347
  if let v = sec.serverScope { api.serverScope(v) }
348
348
  if sec.allowUnpinned { api.allowUnpinnedConfigApi() }
349
+ // The native security file allowed them for this block: the library keeps them in a release build too.
350
+ if native != nil, sec.allowUnsigned || sec.allowUnpinned { api.allowRelaxationsInRelease() }
349
351
  if sec.allowServerKey { api.allowServerGeneratedKey() }
350
352
  if let v = sec.clientCaPins { api.clientCaPins(v) }
351
353
  if let maxLifetime { api.maxClientCertLifetimeDays(maxLifetime) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umutcansu/react-native-pinvault",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "PinVault for React Native: dynamic certificate pinning with signed remote configs, mTLS enrollment and signed vault files, as a thin bridge over the native PinVault libraries",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -10,7 +10,8 @@
10
10
  "types": "./lib/typescript/src/index.d.ts",
11
11
  "default": "./lib/module/index.js"
12
12
  },
13
- "./package.json": "./package.json"
13
+ "./package.json": "./package.json",
14
+ "./app.plugin.js": "./app.plugin.js"
14
15
  },
15
16
  "files": [
16
17
  "src",
@@ -23,6 +24,8 @@
23
24
  "ios/*.swift",
24
25
  "ios/Core",
25
26
  "*.podspec",
27
+ "app.plugin.js",
28
+ "plugin",
26
29
  "README.md",
27
30
  "CHANGELOG.md",
28
31
  "LICENSE",
@@ -43,7 +46,8 @@
43
46
  "mtls",
44
47
  "security",
45
48
  "ios",
46
- "android"
49
+ "android",
50
+ "expo"
47
51
  ],
48
52
  "repository": {
49
53
  "type": "git",
@@ -65,6 +69,7 @@
65
69
  "react-native": ">=0.81.0"
66
70
  },
67
71
  "devDependencies": {
72
+ "@expo/config-plugins": "^54.0.5",
68
73
  "@jest/globals": "^29.7.0",
69
74
  "@react-native/babel-preset": "0.87.1",
70
75
  "@react-native/jest-preset": "0.87.1",
@@ -0,0 +1,399 @@
1
+ // Expo config plugin for @umutcansu/react-native-pinvault: does at
2
+ // `expo prebuild` what the README's Install and "Native security file"
3
+ // sections ask of a bare React Native app.
4
+ //
5
+ // plugins: [["@umutcansu/react-native-pinvault", {
6
+ // nativeSecurityFile: "./pinvault_security.json", // → Android assets + iOS app bundle
7
+ // faceIDPermission: "Unlock your protected files", // NSFaceIDUsageDescription (userAuth vault files)
8
+ // backgroundUpdates: true, // schedulePeriodicUpdates on iOS
9
+ // allowNoNativeSecurityFile: false, // release builds without the file (not recommended)
10
+ // pinReactNativeNetworking: true // false = RN's own networking stays unpinned
11
+ // }]]
12
+ //
13
+ // Every option is optional. Without `nativeSecurityFile` a release build
14
+ // refuses a config with Config APIs or static pins (README, "Native security
15
+ // file") unless `allowNoNativeSecurityFile` is true.
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ let configPlugins;
20
+ try {
21
+ configPlugins = require('expo/config-plugins');
22
+ } catch (e) {
23
+ configPlugins = require('@expo/config-plugins');
24
+ }
25
+ const {
26
+ AndroidConfig,
27
+ IOSConfig,
28
+ withAndroidManifest,
29
+ withAppDelegate,
30
+ withDangerousMod,
31
+ withInfoPlist,
32
+ withFinalizedMod,
33
+ withPodfileProperties,
34
+ withXcodeProject,
35
+ createRunOncePlugin,
36
+ XML,
37
+ } = configPlugins;
38
+
39
+ const pkg = require('../package.json');
40
+
41
+ const SECURITY_FILE = 'pinvault_security.json';
42
+ const BG_TASK_ID = 'io.github.umutcansu.pinvault.refresh';
43
+ const MIN_IOS = '16.0';
44
+ const ALLOW_NO_FILE_META = 'io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE';
45
+ const NETWORKING_PROVIDER = 'io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer';
46
+ // PinVault's own backup rules (pinvault/src/main/res/xml): what must never
47
+ // reach a backup or a device transfer. The test checks this list against them.
48
+ const BACKUP_EXCLUDES = [
49
+ ['sharedpref', 'pinvault_secure_config.xml'],
50
+ ['sharedpref', 'pinvault_secure_client_cert.xml'],
51
+ ['sharedpref', 'pinvault_secure_signing_keys.xml'],
52
+ ['sharedpref', 'pinvault_secure_vault_files.xml'],
53
+ ['sharedpref', 'pinvault_vault_file_versions.xml'],
54
+ ['file', 'vault_files'],
55
+ ['sharedpref', 'pinvault_client_cert.xml'],
56
+ ['sharedpref', 'ssl_cert_config.xml'],
57
+ ['sharedpref', 'ssl_cert_config_default.xml'],
58
+ ['sharedpref', 'ssl_cert_config_default-tls.xml'],
59
+ ['sharedpref', 'ssl_cert_config_secure-mtls.xml'],
60
+ ['sharedpref', 'pinvault_signing_keys.xml'],
61
+ ['sharedpref', 'pinvault_vault_files.xml'],
62
+ ];
63
+ const OWN_RULES = {
64
+ 'android:fullBackupContent': '@xml/pinvault_backup_rules',
65
+ 'android:dataExtractionRules': '@xml/pinvault_data_extraction_rules',
66
+ };
67
+ const MERGED_PREFIX = 'pinvault_merged_';
68
+ const MERGED_FROM = /<!-- pinvault-merged-from: @xml\/([A-Za-z0-9_]+) -->/;
69
+ const KNOWN_OPTIONS = [
70
+ 'nativeSecurityFile',
71
+ 'faceIDPermission',
72
+ 'backgroundUpdates',
73
+ 'allowNoNativeSecurityFile',
74
+ 'pinReactNativeNetworking',
75
+ ];
76
+
77
+ function checkOptions(options) {
78
+ const props = options || {};
79
+ const unknown = Object.keys(props).filter((k) => !KNOWN_OPTIONS.includes(k));
80
+ if (unknown.length > 0) {
81
+ throw new Error(`[${pkg.name}] unknown plugin option(s): ${unknown.join(', ')} (known: ${KNOWN_OPTIONS.join(', ')})`);
82
+ }
83
+ for (const k of ['backgroundUpdates', 'allowNoNativeSecurityFile', 'pinReactNativeNetworking']) {
84
+ if (props[k] !== undefined && typeof props[k] !== 'boolean') throw new Error(`[${pkg.name}] ${k} must be true or false`);
85
+ }
86
+ for (const k of ['nativeSecurityFile', 'faceIDPermission']) {
87
+ if (props[k] !== undefined && (typeof props[k] !== 'string' || props[k].trim() === '')) {
88
+ throw new Error(`[${pkg.name}] ${k} must be a non-empty string`);
89
+ }
90
+ }
91
+ return props;
92
+ }
93
+
94
+ /** "15.1" < "16.0": dotted numeric versions. */
95
+ function versionLess(a, b) {
96
+ const x = String(a).split('.').map(Number);
97
+ const y = String(b).split('.').map(Number);
98
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
99
+ const d = (x[i] || 0) - (y[i] || 0);
100
+ if (d !== 0) return d < 0;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ /** The project's security file, read and parsed (a JSON error stops prebuild, not the app at launch). */
106
+ function readSecurityFile(projectRoot, relative) {
107
+ const source = path.resolve(projectRoot, relative);
108
+ if (!fs.existsSync(source)) throw new Error(`[${pkg.name}] nativeSecurityFile not found: ${source}`);
109
+ const text = fs.readFileSync(source, 'utf8');
110
+ try {
111
+ const parsed = JSON.parse(text);
112
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not a JSON object');
113
+ } catch (e) {
114
+ throw new Error(`[${pkg.name}] nativeSecurityFile ${source} is not a JSON object: ${e.message}`);
115
+ }
116
+ return text;
117
+ }
118
+
119
+ /**
120
+ * AppDelegate.swift: `import RNPinVault` and the background task registration
121
+ * as the first line of didFinishLaunchingWithOptions. Idempotent.
122
+ */
123
+ function addBackgroundTaskToAppDelegate(contents, language) {
124
+ if (language !== 'swift') {
125
+ throw new Error(`[${pkg.name}] backgroundUpdates needs a Swift AppDelegate (Expo SDK 53+); add the call by hand: README, "Install"`);
126
+ }
127
+ if (contents.includes('PinVaultBridge.registerBackgroundTask()')) return contents;
128
+ let out = contents;
129
+ if (!/^import RNPinVault$/m.test(out)) {
130
+ const imports = out.match(/^import .+$/gm);
131
+ if (!imports) throw new Error(`[${pkg.name}] AppDelegate.swift has no import lines`);
132
+ const last = imports[imports.length - 1];
133
+ out = out.replace(last, `${last}\nimport RNPinVault`);
134
+ }
135
+ const launch = /(didFinishLaunchingWithOptions launchOptions:[^{]*\{\n)/;
136
+ if (!launch.test(out)) {
137
+ throw new Error(`[${pkg.name}] application(_:didFinishLaunchingWithOptions:) not found in AppDelegate.swift`);
138
+ }
139
+ return out.replace(
140
+ launch,
141
+ '$1 // @umutcansu/react-native-pinvault: schedulePeriodicUpdates (before launch ends)\n' +
142
+ ' _ = PinVaultBridge.registerBackgroundTask()\n'
143
+ );
144
+ }
145
+
146
+ /** Info.plist entries the options ask for. */
147
+ function applyInfoPlist(plist, props) {
148
+ const out = { ...plist };
149
+ if (props.faceIDPermission) out.NSFaceIDUsageDescription = props.faceIDPermission;
150
+ if (props.backgroundUpdates) {
151
+ const ids = Array.isArray(out.BGTaskSchedulerPermittedIdentifiers) ? [...out.BGTaskSchedulerPermittedIdentifiers] : [];
152
+ if (!ids.includes(BG_TASK_ID)) ids.push(BG_TASK_ID);
153
+ out.BGTaskSchedulerPermittedIdentifiers = ids;
154
+ const modes = Array.isArray(out.UIBackgroundModes) ? [...out.UIBackgroundModes] : [];
155
+ if (!modes.includes('fetch')) modes.push('fetch');
156
+ out.UIBackgroundModes = modes;
157
+ }
158
+ if (props.allowNoNativeSecurityFile === true) out.PinVaultAllowNoNativeSecurityFile = true;
159
+ else delete out.PinVaultAllowNoNativeSecurityFile;
160
+ if (props.pinReactNativeNetworking === false) out.PinVaultPinReactNativeNetworking = false;
161
+ else delete out.PinVaultPinReactNativeNetworking;
162
+ return out;
163
+ }
164
+
165
+ /** AndroidManifest: the allow-no-file meta-data and the networking provider opt-out. */
166
+ function applyAndroidManifest(manifest, props) {
167
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
168
+ if (props.allowNoNativeSecurityFile === true) {
169
+ AndroidConfig.Manifest.addMetaDataItemToMainApplication(app, ALLOW_NO_FILE_META, 'true');
170
+ } else {
171
+ AndroidConfig.Manifest.removeMetaDataItemFromMainApplication(app, ALLOW_NO_FILE_META);
172
+ }
173
+ app.provider = (app.provider || []).filter((p) => p.$['android:name'] !== NETWORKING_PROVIDER);
174
+ if (props.pinReactNativeNetworking === false) {
175
+ manifest.manifest.$['xmlns:tools'] = manifest.manifest.$['xmlns:tools'] || 'http://schemas.android.com/tools';
176
+ app.provider.push({
177
+ $: {
178
+ 'android:name': NETWORKING_PROVIDER,
179
+ 'android:authorities': '${applicationId}.pinvault-networking',
180
+ 'tools:node': 'remove',
181
+ },
182
+ });
183
+ }
184
+ return manifest;
185
+ }
186
+
187
+ /** Whether an `<include>` list (empty = everything) takes in [domain]/[file]. */
188
+ function included(includes, domain, file) {
189
+ if (includes.length === 0) return true;
190
+ return includes.some((i) => {
191
+ const d = i.$ && i.$.domain;
192
+ const p = String((i.$ && i.$.path) || '.').replace(/^\.\/?/, '').replace(/\/$/, '');
193
+ return d === domain && (p === '' || file === p || file.startsWith(`${p}/`));
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Adds PinVault's excludes to one rules element (a parsed `<full-backup-content>`
199
+ * or a section). Only where the element's includes reach: lint refuses an
200
+ * exclude outside every include (FullBackupContent), and such a file is not
201
+ * backed up anyway.
202
+ */
203
+ function addExcludes(node) {
204
+ const out = node && typeof node === 'object' ? node : {};
205
+ const includes = Array.isArray(out.include) ? out.include : [];
206
+ out.exclude = Array.isArray(out.exclude) ? out.exclude : [];
207
+ for (const [domain, file] of BACKUP_EXCLUDES) {
208
+ if (!included(includes, domain, file)) continue;
209
+ if (!out.exclude.some((e) => e.$ && e.$.domain === domain && e.$.path === file)) {
210
+ out.exclude.push({ $: { domain, path: file } });
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /**
217
+ * An app's backup rules (parsed with XML.parseXMLAsync) with PinVault's
218
+ * excludes added. Excludes win over includes, so an app that includes
219
+ * `sharedpref/.` still keeps PinVault's stores out. A data-extraction section
220
+ * the app does not write (= everything) gets one with PinVault's excludes.
221
+ */
222
+ function mergeBackupRules(xml) {
223
+ if (xml && 'full-backup-content' in xml) {
224
+ return { 'full-backup-content': addExcludes(xml['full-backup-content']) };
225
+ }
226
+ if (xml && 'data-extraction-rules' in xml) {
227
+ const root = xml['data-extraction-rules'] && typeof xml['data-extraction-rules'] === 'object' ? xml['data-extraction-rules'] : {};
228
+ for (const section of ['cloud-backup', 'device-transfer']) {
229
+ root[section] = (Array.isArray(root[section]) ? root[section] : [{}]).map(addExcludes);
230
+ }
231
+ if (Array.isArray(root['cross-platform-transfer'])) root['cross-platform-transfer'] = root['cross-platform-transfer'].map(addExcludes);
232
+ return { 'data-extraction-rules': root };
233
+ }
234
+ throw new Error(`[${pkg.name}] backup rules file has neither <full-backup-content> nor <data-extraction-rules>`);
235
+ }
236
+
237
+ /** `res/xml/<name>.xml` of the app, or of a library under node_modules (Expo modules ship theirs there). */
238
+ function findRulesFile(projectRoot, platformRoot, name) {
239
+ const file = path.join('android', 'src', 'main', 'res', 'xml', `${name}.xml`);
240
+ const candidates = [path.join(platformRoot, 'app', 'src', 'main', 'res', 'xml', `${name}.xml`)];
241
+ // node_modules of the project and its parents (monorepos hoist packages up).
242
+ for (let dir = path.resolve(projectRoot); ; dir = path.dirname(dir)) {
243
+ const modules = path.join(dir, 'node_modules');
244
+ if (fs.existsSync(modules)) {
245
+ for (const entry of fs.readdirSync(modules)) {
246
+ if (entry.startsWith('@')) {
247
+ for (const scoped of fs.readdirSync(path.join(modules, entry))) candidates.push(path.join(modules, entry, scoped, file));
248
+ } else {
249
+ candidates.push(path.join(modules, entry, file));
250
+ }
251
+ }
252
+ }
253
+ if (path.dirname(dir) === dir) break;
254
+ }
255
+ return candidates.find((c) => fs.existsSync(c));
256
+ }
257
+
258
+ /**
259
+ * AndroidManifest.xml on disk, after every other plugin: an app (or a plugin
260
+ * such as expo-secure-store) that sets its own `fullBackupContent` /
261
+ * `dataExtractionRules` clashed with PinVault's at the manifest merge, and a
262
+ * hand-written `tools:replace` would drop PinVault's excludes. Each such file
263
+ * is copied to `res/xml/pinvault_merged_<name>.xml` with PinVault's excludes
264
+ * added, and the manifest points at the copy with `tools:replace`. Idempotent:
265
+ * a rerun without --clean merges from the original again.
266
+ */
267
+ async function mergeAndroidBackupRules(projectRoot, platformRoot) {
268
+ const manifestPath = path.join(platformRoot, 'app', 'src', 'main', 'AndroidManifest.xml');
269
+ const manifest = await XML.readXMLAsync({ path: manifestPath });
270
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
271
+ const resDir = path.join(platformRoot, 'app', 'src', 'main', 'res', 'xml');
272
+ const replace = new Set(String(app.$['tools:replace'] || '').split(',').map((s) => s.trim()).filter(Boolean));
273
+ let changed = false;
274
+ for (const attr of Object.keys(OWN_RULES)) {
275
+ const value = app.$[attr];
276
+ if (value === undefined || value === OWN_RULES[attr]) continue;
277
+ // fullBackupContent="false": no full backup at all, stricter than PinVault's rules.
278
+ if (attr === 'android:fullBackupContent' && value === 'false') {
279
+ if (!replace.has(attr)) { replace.add(attr); changed = true; }
280
+ continue;
281
+ }
282
+ const ref = /^@xml\/([A-Za-z0-9_]+)$/.exec(value);
283
+ if (!ref) throw new Error(`[${pkg.name}] ${attr}="${value}" is not an @xml resource; merge PinVault's backup excludes by hand (README, "Expo")`);
284
+ let name = ref[1];
285
+ if (name.startsWith(MERGED_PREFIX)) {
286
+ const previous = path.join(resDir, `${name}.xml`);
287
+ const from = fs.existsSync(previous) ? MERGED_FROM.exec(fs.readFileSync(previous, 'utf8')) : null;
288
+ if (!from) throw new Error(`[${pkg.name}] ${previous} is missing or not ours; run expo prebuild --clean`);
289
+ name = from[1];
290
+ }
291
+ const source = findRulesFile(projectRoot, platformRoot, name);
292
+ if (!source) {
293
+ throw new Error(`[${pkg.name}] ${attr} points at @xml/${name}, which was not found in the app or node_modules; ` +
294
+ "PinVault's backup excludes cannot be merged into it (README, \"Expo\")");
295
+ }
296
+ const merged = mergeBackupRules(await XML.parseXMLAsync(fs.readFileSync(source, 'utf8')));
297
+ const out = `${MERGED_PREFIX}${name}`;
298
+ fs.mkdirSync(resDir, { recursive: true });
299
+ fs.writeFileSync(
300
+ path.join(resDir, `${out}.xml`),
301
+ '<?xml version="1.0" encoding="utf-8"?>\n' +
302
+ `<!-- pinvault-merged-from: @xml/${name} -->\n` +
303
+ `<!-- Written by ${pkg.name} at expo prebuild: these rules plus PinVault's excludes. -->\n` +
304
+ XML.format(merged) + '\n'
305
+ );
306
+ app.$[attr] = `@xml/${out}`;
307
+ replace.add(attr);
308
+ changed = true;
309
+ }
310
+ if (!changed) return false;
311
+ manifest.manifest.$['xmlns:tools'] = manifest.manifest.$['xmlns:tools'] || 'http://schemas.android.com/tools';
312
+ app.$['tools:replace'] = [...replace].join(',');
313
+ await XML.writeXMLAsync({ path: manifestPath, xml: manifest });
314
+ return true;
315
+ }
316
+
317
+ function withPinVault(config, options) {
318
+ const props = checkOptions(options);
319
+
320
+ // iOS 16: the PinVault Swift package's minimum.
321
+ config = withPodfileProperties(config, (c) => {
322
+ const current = c.modResults['ios.deploymentTarget'];
323
+ if (!current || versionLess(current, MIN_IOS)) c.modResults['ios.deploymentTarget'] = MIN_IOS;
324
+ return c;
325
+ });
326
+ config = withXcodeProject(config, (c) => {
327
+ const configurations = c.modResults.pbxXCBuildConfigurationSection();
328
+ for (const key of Object.keys(configurations)) {
329
+ const settings = configurations[key].buildSettings;
330
+ if (!settings || settings.IPHONEOS_DEPLOYMENT_TARGET === undefined) continue;
331
+ if (versionLess(String(settings.IPHONEOS_DEPLOYMENT_TARGET).replace(/"/g, ''), MIN_IOS)) {
332
+ settings.IPHONEOS_DEPLOYMENT_TARGET = MIN_IOS;
333
+ }
334
+ }
335
+ return c;
336
+ });
337
+
338
+ config = withInfoPlist(config, (c) => {
339
+ c.modResults = applyInfoPlist(c.modResults, props);
340
+ return c;
341
+ });
342
+ config = withAndroidManifest(config, (c) => {
343
+ c.modResults = applyAndroidManifest(c.modResults, props);
344
+ return c;
345
+ });
346
+
347
+ // Last of all: other plugins have written their backup rules by then.
348
+ config = withFinalizedMod(config, [
349
+ 'android',
350
+ async (c) => {
351
+ await mergeAndroidBackupRules(c.modRequest.projectRoot, c.modRequest.platformProjectRoot);
352
+ return c;
353
+ },
354
+ ]);
355
+
356
+ if (props.backgroundUpdates) {
357
+ config = withAppDelegate(config, (c) => {
358
+ c.modResults.contents = addBackgroundTaskToAppDelegate(c.modResults.contents, c.modResults.language);
359
+ return c;
360
+ });
361
+ }
362
+
363
+ if (props.nativeSecurityFile) {
364
+ // Android: assets (sealed by the APK signature; README, "Native security file").
365
+ config = withDangerousMod(config, [
366
+ 'android',
367
+ async (c) => {
368
+ const text = readSecurityFile(c.modRequest.projectRoot, props.nativeSecurityFile);
369
+ const assets = path.join(c.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets');
370
+ fs.mkdirSync(assets, { recursive: true });
371
+ fs.writeFileSync(path.join(assets, SECURITY_FILE), text);
372
+ return c;
373
+ },
374
+ ]);
375
+ // iOS: a resource of the app target (sealed by the code signature).
376
+ config = withXcodeProject(config, (c) => {
377
+ const text = readSecurityFile(c.modRequest.projectRoot, props.nativeSecurityFile);
378
+ const projectName = c.modRequest.projectName;
379
+ fs.writeFileSync(path.join(c.modRequest.platformProjectRoot, projectName, SECURITY_FILE), text);
380
+ const file = `${projectName}/${SECURITY_FILE}`;
381
+ if (!c.modResults.hasFile(file)) {
382
+ c.modResults = IOSConfig.XcodeUtils.addResourceFileToGroup({
383
+ filepath: file,
384
+ groupName: projectName,
385
+ project: c.modResults,
386
+ isBuildFile: true,
387
+ });
388
+ }
389
+ return c;
390
+ });
391
+ }
392
+ return config;
393
+ }
394
+
395
+ module.exports = createRunOncePlugin(withPinVault, pkg.name, pkg.version);
396
+ module.exports.internal = {
397
+ addBackgroundTaskToAppDelegate, applyInfoPlist, applyAndroidManifest, checkOptions, versionLess,
398
+ mergeBackupRules, mergeAndroidBackupRules, BACKUP_EXCLUDES,
399
+ };