deeplink-parity 0.1.0 → 0.2.1
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 +19 -6
- 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. */
|
|
@@ -30,12 +29,26 @@ function collectIntentFilters(node, out) {
|
|
|
30
29
|
collectIntentFilters(child, out);
|
|
31
30
|
}
|
|
32
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* A build type or flavor can append to the base id with `applicationIdSuffix`, and the
|
|
34
|
+
* dev variant is usually what a dev domain's assetlinks file names. Suffixes are applied
|
|
35
|
+
* to every base id so that both `com.example` and `com.example.dev` count as ours.
|
|
36
|
+
*/
|
|
33
37
|
function applicationIds(gradleSources) {
|
|
34
|
-
const
|
|
38
|
+
const bases = new Set();
|
|
39
|
+
const suffixes = new Set();
|
|
35
40
|
for (const src of gradleSources) {
|
|
36
|
-
for (const m of src.matchAll(
|
|
37
|
-
|
|
41
|
+
for (const m of src.matchAll(/\bapplicationId\s*(?:=|\s)\s*["']([^"']+)["']/g)) {
|
|
42
|
+
bases.add(m[1]);
|
|
38
43
|
}
|
|
44
|
+
for (const m of src.matchAll(/\bapplicationIdSuffix\s*(?:=|\s)\s*["']([^"']+)["']/g)) {
|
|
45
|
+
suffixes.add(m[1]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const ids = new Set(bases);
|
|
49
|
+
for (const base of bases) {
|
|
50
|
+
for (const suffix of suffixes)
|
|
51
|
+
ids.add(`${base}${suffix}`);
|
|
39
52
|
}
|
|
40
53
|
return [...ids];
|
|
41
54
|
}
|
|
@@ -123,7 +136,7 @@ export async function discoverAndroid(root) {
|
|
|
123
136
|
if (hosts.size === 0 && unresolved.length === 0)
|
|
124
137
|
continue;
|
|
125
138
|
apps.push({
|
|
126
|
-
manifestPath:
|
|
139
|
+
manifestPath: displayPath(manifestPath),
|
|
127
140
|
packageIds,
|
|
128
141
|
hosts: [...hosts.values()],
|
|
129
142
|
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