deeplink-parity 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/dist/cli.js +18 -7
- package/dist/discover/android.js +2 -3
- package/dist/discover/ios.js +23 -6
- package/dist/discover/walk.js +40 -7
- package/dist/run.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# deeplink-parity
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/deeplink-parity)
|
|
4
|
+
[](https://github.com/camosss/deeplink-parity/actions/workflows/ci.yml)
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
3
9
|
Checks that what your app **declares** about deep links matches what is actually **hosted** — on both iOS and Android, from one command.
|
|
4
10
|
|
|
5
11
|
```bash
|
|
6
|
-
|
|
12
|
+
# iOS and Android usually live in separate repositories — pass both
|
|
13
|
+
npx deeplink-parity ./my-app-ios ./my-app-android
|
|
7
14
|
```
|
|
8
15
|
|
|
9
16
|
```
|
|
@@ -70,13 +77,26 @@ No configuration file. Domains are discovered from your app, then the matching w
|
|
|
70
77
|
## Usage
|
|
71
78
|
|
|
72
79
|
```bash
|
|
73
|
-
npx deeplink-parity [path] [options]
|
|
80
|
+
npx deeplink-parity [path...] [options]
|
|
74
81
|
|
|
75
82
|
--sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
|
|
76
83
|
--well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
|
|
77
84
|
--json Machine-readable output
|
|
78
85
|
```
|
|
79
86
|
|
|
87
|
+
Pass one path per checkout. A monorepo holding both platforms works as a single path,
|
|
88
|
+
two repositories work as two paths, and a directory of symlinks to each checkout works
|
|
89
|
+
too — the scan follows them.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx deeplink-parity . # one repo
|
|
93
|
+
npx deeplink-parity ../app-ios ../app-android # two repos
|
|
94
|
+
npx deeplink-parity . --sha256 "AB:CD:…" # include the fingerprint check
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The Android signing fingerprint comes from Play Console → App integrity → App signing.
|
|
98
|
+
Without it the fingerprint check is skipped and reported as such.
|
|
99
|
+
|
|
80
100
|
Exits `1` when there is at least one error, so it drops into CI unchanged.
|
|
81
101
|
|
|
82
102
|
### Run it on a schedule — this is the point
|
|
@@ -139,4 +159,4 @@ values, and unresolvable references.
|
|
|
139
159
|
|
|
140
160
|
## License
|
|
141
161
|
|
|
142
|
-
MIT
|
|
162
|
+
`deeplink-parity` is released under an MIT license. See [License](LICENSE) for more information.
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,12 @@ const USAGE = `deeplink-parity — check that what your app declares about deep
|
|
|
7
7
|
matches what is actually hosted, across iOS and Android.
|
|
8
8
|
|
|
9
9
|
Usage
|
|
10
|
-
deeplink-parity [path] [options]
|
|
10
|
+
deeplink-parity [path...] [options]
|
|
11
|
+
|
|
12
|
+
Pass one path per checkout. iOS and Android usually live in separate
|
|
13
|
+
repositories, and comparing them is the point:
|
|
14
|
+
|
|
15
|
+
deeplink-parity ./my-app-ios ./my-app-android
|
|
11
16
|
|
|
12
17
|
Options
|
|
13
18
|
--sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
|
|
@@ -17,7 +22,7 @@ Options
|
|
|
17
22
|
`;
|
|
18
23
|
function parseArgs(argv) {
|
|
19
24
|
const args = argv.slice(2);
|
|
20
|
-
|
|
25
|
+
const roots = [];
|
|
21
26
|
let json = false;
|
|
22
27
|
let help = false;
|
|
23
28
|
let sha256;
|
|
@@ -33,20 +38,26 @@ function parseArgs(argv) {
|
|
|
33
38
|
else if (arg === '--well-known')
|
|
34
39
|
wellKnown = args[++i];
|
|
35
40
|
else if (!arg.startsWith('-'))
|
|
36
|
-
|
|
41
|
+
roots.push(arg);
|
|
37
42
|
}
|
|
38
|
-
return {
|
|
43
|
+
return {
|
|
44
|
+
roots: (roots.length ? roots : ['.']).map((r) => resolve(r)),
|
|
45
|
+
json,
|
|
46
|
+
help,
|
|
47
|
+
sha256,
|
|
48
|
+
wellKnown,
|
|
49
|
+
};
|
|
39
50
|
}
|
|
40
51
|
async function main() {
|
|
41
|
-
const {
|
|
52
|
+
const { roots, json, help, sha256, wellKnown } = parseArgs(process.argv);
|
|
42
53
|
if (help) {
|
|
43
54
|
console.log(USAGE);
|
|
44
55
|
return;
|
|
45
56
|
}
|
|
46
57
|
const source = wellKnown ? localSource(resolve(wellKnown)) : networkSource();
|
|
47
|
-
const result = await run({
|
|
58
|
+
const result = await run({ roots, source, sha256 });
|
|
48
59
|
if (result.iosApps.length === 0 && result.androidApps.length === 0) {
|
|
49
|
-
console.error(`No app configuration declaring deep links was found in ${
|
|
60
|
+
console.error(`No app configuration declaring deep links was found in ${roots.join(', ')}`);
|
|
50
61
|
console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
|
|
51
62
|
process.exit(2);
|
|
52
63
|
}
|
package/dist/discover/android.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { relative } from 'node:path';
|
|
3
2
|
import { XMLParser } from 'fast-xml-parser';
|
|
4
3
|
import { emptyIndex, indexProperties, indexResValues, indexStrings, resolveRef, } from '../resolve/androidResources.js';
|
|
5
|
-
import { walk } from './walk.js';
|
|
4
|
+
import { displayPath, walk } from './walk.js';
|
|
6
5
|
const VIEW_ACTION = 'android.intent.action.VIEW';
|
|
7
6
|
const BROWSABLE = 'android.intent.category.BROWSABLE';
|
|
8
7
|
/** fast-xml-parser hands back a single object when an element occurs once. */
|
|
@@ -123,7 +122,7 @@ export async function discoverAndroid(root) {
|
|
|
123
122
|
if (hosts.size === 0 && unresolved.length === 0)
|
|
124
123
|
continue;
|
|
125
124
|
apps.push({
|
|
126
|
-
manifestPath:
|
|
125
|
+
manifestPath: displayPath(manifestPath),
|
|
127
126
|
packageIds,
|
|
128
127
|
hosts: [...hosts.values()],
|
|
129
128
|
unresolved,
|
package/dist/discover/ios.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { relative } from 'node:path';
|
|
3
2
|
import plist from 'plist';
|
|
4
|
-
import { walk } from './walk.js';
|
|
3
|
+
import { displayPath, walk } from './walk.js';
|
|
5
4
|
/**
|
|
6
5
|
* Associated Domains entries look like `applinks:example.com`. A domain may carry
|
|
7
6
|
* query-parameter options (`?mode=developer`) which are not part of the host.
|
|
@@ -52,6 +51,23 @@ function signingByEntitlements(pbxproj) {
|
|
|
52
51
|
}
|
|
53
52
|
return map;
|
|
54
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Pick the target whose recorded entitlements path is the longest tail of this file's
|
|
56
|
+
* path. Longest wins so that `Widget/Widget.entitlements` never matches the app when
|
|
57
|
+
* `App/App.entitlements` is also on file.
|
|
58
|
+
*/
|
|
59
|
+
function matchSigning(path, signing) {
|
|
60
|
+
let best = {};
|
|
61
|
+
let bestLength = -1;
|
|
62
|
+
for (const [key, value] of signing) {
|
|
63
|
+
const tail = key.startsWith('/') ? key : `/${key}`;
|
|
64
|
+
if (path.endsWith(tail) && key.length > bestLength) {
|
|
65
|
+
best = value;
|
|
66
|
+
bestLength = key.length;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
55
71
|
export async function discoverIos(root) {
|
|
56
72
|
const entitlementFiles = await walk(root, (n) => n.endsWith('.entitlements'));
|
|
57
73
|
if (entitlementFiles.length === 0)
|
|
@@ -77,11 +93,12 @@ export async function discoverIos(root) {
|
|
|
77
93
|
const domains = parseAssociatedDomains(dict['com.apple.developer.associated-domains']);
|
|
78
94
|
if (domains.length === 0)
|
|
79
95
|
continue;
|
|
80
|
-
// pbxproj records the entitlements path relative to SOURCE_ROOT
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
// pbxproj records the entitlements path relative to SOURCE_ROOT, which is not
|
|
97
|
+
// necessarily the directory being scanned — match on the tail so that pointing at
|
|
98
|
+
// a repository root or at a subdirectory both resolve the same target.
|
|
99
|
+
const target = matchSigning(path, signing);
|
|
83
100
|
apps.push({
|
|
84
|
-
entitlementsPath:
|
|
101
|
+
entitlementsPath: displayPath(path),
|
|
85
102
|
domains,
|
|
86
103
|
bundleId: target.bundleId,
|
|
87
104
|
teamId: target.teamId,
|
package/dist/discover/walk.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import { readdir } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { readdir, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Paths are shown relative to the working directory rather than to the scanned root,
|
|
5
|
+
* so that a run covering two checkouts stays unambiguous.
|
|
6
|
+
*/
|
|
7
|
+
export function displayPath(absolute) {
|
|
8
|
+
const rel = relative(process.cwd(), absolute);
|
|
9
|
+
return rel.startsWith('..') ? absolute : rel;
|
|
10
|
+
}
|
|
3
11
|
const SKIP_DIRS = new Set([
|
|
4
12
|
'node_modules',
|
|
5
13
|
'Pods',
|
|
@@ -12,8 +20,23 @@ const SKIP_DIRS = new Set([
|
|
|
12
20
|
'.gradle',
|
|
13
21
|
'.idea',
|
|
14
22
|
]);
|
|
15
|
-
/**
|
|
16
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Depth-first file scan that skips dependency and build output directories.
|
|
25
|
+
*
|
|
26
|
+
* Symlinked directories are followed, so a folder of links to several checkouts works
|
|
27
|
+
* as a single root. Real paths are tracked to stop a link cycle from recursing forever.
|
|
28
|
+
*/
|
|
29
|
+
export async function walk(dir, match, out = [], seen = new Set()) {
|
|
30
|
+
let here;
|
|
31
|
+
try {
|
|
32
|
+
here = await realpath(dir);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
if (seen.has(here))
|
|
38
|
+
return out;
|
|
39
|
+
seen.add(here);
|
|
17
40
|
let entries;
|
|
18
41
|
try {
|
|
19
42
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -22,14 +45,24 @@ export async function walk(dir, match, out = []) {
|
|
|
22
45
|
return out;
|
|
23
46
|
}
|
|
24
47
|
for (const entry of entries) {
|
|
25
|
-
|
|
48
|
+
const path = join(dir, entry.name);
|
|
49
|
+
let isDir = entry.isDirectory();
|
|
50
|
+
if (entry.isSymbolicLink()) {
|
|
51
|
+
try {
|
|
52
|
+
isDir = (await stat(path)).isDirectory();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue; // broken link
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (isDir) {
|
|
26
59
|
if (SKIP_DIRS.has(entry.name))
|
|
27
60
|
continue;
|
|
28
61
|
// .xcodeproj is a directory but holds project.pbxproj
|
|
29
|
-
await walk(
|
|
62
|
+
await walk(path, match, out, seen);
|
|
30
63
|
}
|
|
31
64
|
else if (match(entry.name)) {
|
|
32
|
-
out.push(
|
|
65
|
+
out.push(path);
|
|
33
66
|
}
|
|
34
67
|
}
|
|
35
68
|
return out;
|
package/dist/run.js
CHANGED
|
@@ -7,8 +7,10 @@ import { isWildcardDomain, wildcardFinding } from './rules/wildcard.js';
|
|
|
7
7
|
function emptyView() {
|
|
8
8
|
return { domains: new Set(), paths: new Map() };
|
|
9
9
|
}
|
|
10
|
-
export async function run({
|
|
11
|
-
const
|
|
10
|
+
export async function run({ roots, source, sha256 }) {
|
|
11
|
+
const discovered = await Promise.all(roots.map(async (root) => Promise.all([discoverIos(root), discoverAndroid(root)])));
|
|
12
|
+
const iosApps = discovered.flatMap(([ios]) => ios);
|
|
13
|
+
const androidApps = discovered.flatMap(([, android]) => android);
|
|
12
14
|
const findings = [];
|
|
13
15
|
const ios = emptyView();
|
|
14
16
|
const android = emptyView();
|
package/package.json
CHANGED