@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 +39 -0
- package/README.md +56 -4
- package/RNPinVault.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +2 -0
- package/app.plugin.js +2 -0
- package/ios/Core/ConfigParser.swift +2 -0
- package/package.json +8 -3
- package/plugin/withPinVault.js +399 -0
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.
|
|
33
|
-
uses `io.github.umutcansu:pinvault:2.
|
|
34
|
-
package at tag `v2.
|
|
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;
|
package/RNPinVault.podspec
CHANGED
|
@@ -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.
|
|
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"
|
package/android/build.gradle
CHANGED
|
@@ -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
|
@@ -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.
|
|
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
|
+
};
|