Skip to content

Commit 13cccb7

Browse files
Protocol Handler support
1 parent 9ba50ce commit 13cccb7

File tree

11 files changed

+409
-2
lines changed

11 files changed

+409
-2
lines changed

packages/cli/README.md

+11
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ Fields:
392392
|webManifestUrl|string|false|Full URL to the PWA Web Manifest. Required for the application to be compatible with Chrome OS and Meta Quest devices.|
393393
|fullScopeUrl|string|false|The navigation scope that the browser considers to be within the app. If the user navigates outside the scope, it reverts to a normal web page inside a browser tab or window. Must be a full URL. Required and used only by Meta Quest devices.|
394394
|minSdkVersion|number|false|The minimum [Android API Level](https://developer.android.com/guide/topics/manifest/uses-sdk-element#ApiLevels) required for the application to run. Defaults to `23`, if `isMetaQuest` is `true`, and `19` otherwise.|
395+
|protocolHandlers|[ProtocolHandler](#protocolhandlers)[]|false|List of [Protocol Handlers](#protocolhandlers) supported by the app.|
395396

396397
### Features
397398

@@ -462,6 +463,16 @@ Information on the signature fingerprints for the application. Use to generate t
462463
|name|string|false|An optional name for the fingerprint.|
463464
|value|string|true|The SHA-256 value for the fingerprint.|
464465

466+
467+
### ProtocolHandlers
468+
469+
List of Protocol Handlers registered for the application. These entries may not exactly match what was originally in the webmanifest, because they have been normalized and validated using [these](https://wicg.github.io/manifest-incubations/#processing-the-protocol_handlers-member) rules. If a webmanifest entry is incorrect for any reason (invalid protocol, malformed target url, missing '%s' token) they will be ignored and a warning will be printed out. See [here](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/protocol_handlers) for more information about the Protocol Handler spec. The full list of supported protocols is [here](https://github.com/GoogleChromeLabs/bubblewrap/blob/main/packages/core/src/lib/types/ProtocolHandler.ts).
470+
471+
|Name|Type|Required|Description|
472+
|:--:|:--:|:------:|:---------:|
473+
|protocol|string|true|Data scheme to register (e.g. `bitcoin`, `irc`, `web+coffee`).|
474+
|url|string|true|Formula for converting a custom data scheme back to a http(s) link, must include '%s' and be the same origin as the web manifest file. Example: `https://test.com/?target=%s`|
475+
465476
## Manually setting up the Environment
466477

467478
### Get the Java Development Kit (JDK) 17.

packages/core/src/lib/TwaManifest.ts

+25
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {findSuitableIcon, generatePackageId, validateNotEmpty} from './util';
2222
import Color = require('color');
2323
import {ConsoleLog} from './Log';
2424
import {ShareTarget, WebManifestIcon, WebManifestJson} from './types/WebManifest';
25+
import {processProtocolHandlers, ProtocolHandler} from './types/ProtocolHandler';
2526
import {ShortcutInfo} from './ShortcutInfo';
2627
import {AppsFlyerConfig} from './features/AppsFlyerFeature';
2728
import {LocationDelegationConfig} from './features/LocationDelegationFeature';
@@ -169,6 +170,7 @@ export class TwaManifest {
169170
serviceAccountJsonFile: string | undefined;
170171
additionalTrustedOrigins: string[];
171172
retainedBundles: number[];
173+
protocolHandlers?: ProtocolHandler[];
172174

173175
private static log = new ConsoleLog('twa-manifest');
174176

@@ -219,6 +221,7 @@ export class TwaManifest {
219221
this.serviceAccountJsonFile = data.serviceAccountJsonFile;
220222
this.additionalTrustedOrigins = data.additionalTrustedOrigins || [];
221223
this.retainedBundles = data.retainedBundles || [];
224+
this.protocolHandlers = data.protocolHandlers;
222225
}
223226

224227
/**
@@ -312,6 +315,12 @@ export class TwaManifest {
312315
return icon ? new URL(icon.src, webManifestUrl).toString() : undefined;
313316
}
314317

318+
const processedProtocolHandlers = processProtocolHandlers(
319+
webManifest.protocol_handlers ?? [],
320+
fullStartUrl,
321+
fullScopeUrl,
322+
);
323+
315324
const twaManifest = new TwaManifest({
316325
packageId: generatePackageId(webManifestUrl.host) || '',
317326
host: webManifestUrl.host,
@@ -343,6 +352,7 @@ export class TwaManifest {
343352
shareTarget: TwaManifest.verifyShareTarget(webManifestUrl, webManifest.share_target),
344353
orientation: asOrientation(webManifest.orientation) || DEFAULT_ORIENTATION,
345354
fullScopeUrl: fullScopeUrl.toString(),
355+
protocolHandlers: processedProtocolHandlers,
346356
});
347357
return twaManifest;
348358
}
@@ -479,6 +489,19 @@ export class TwaManifest {
479489
oldTwaManifestJson.iconUrl!, webManifest.icons!, 'monochrome', MIN_NOTIFICATION_ICON_SIZE,
480490
webManifestUrl);
481491

492+
const protocolHandlersMap = new Map<string, string>();
493+
for (const handler of oldTwaManifest.protocolHandlers ?? []) {
494+
protocolHandlersMap.set(handler.protocol, handler.url);
495+
}
496+
if (!(fieldsToIgnore.includes('protocol_handlers'))) {
497+
for (const handler of webManifest.protocol_handlers ?? []) {
498+
protocolHandlersMap.set(handler.protocol, handler.url);
499+
};
500+
}
501+
const protocolHandlers = Array.from(protocolHandlersMap.entries()).map(([protocol, url]) => {
502+
return {protocol, url} as ProtocolHandler;
503+
});
504+
482505
const fullStartUrl: URL = new URL(webManifest['start_url'] || '/', webManifestUrl);
483506
const fullScopeUrl: URL = new URL(webManifest['scope'] || '.', webManifestUrl);
484507

@@ -503,6 +526,7 @@ export class TwaManifest {
503526
maskableIconUrl: maskableIconUrl || oldTwaManifestJson.maskableIconUrl,
504527
monochromeIconUrl: monochromeIconUrl || oldTwaManifestJson.monochromeIconUrl,
505528
shortcuts: shortcuts,
529+
protocolHandlers: protocolHandlers,
506530
});
507531
return twaManifest;
508532
}
@@ -558,6 +582,7 @@ export interface TwaManifestJson {
558582
serviceAccountJsonFile?: string;
559583
additionalTrustedOrigins?: string[];
560584
retainedBundles?: number[];
585+
protocolHandlers?: ProtocolHandler[];
561586
}
562587

563588
export interface SigningKeyInfo {

packages/core/src/lib/features/EmptyFeature.ts

+2
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ export class EmptyFeature implements Feature {
3030
permissions: string[];
3131
components: string[];
3232
applicationMetadata: Metadata[];
33+
launcherActivityEntries: string[];
3334
} = {
3435
permissions: new Array<string>(),
3536
components: new Array<string>(),
3637
applicationMetadata: new Array<Metadata>(),
38+
launcherActivityEntries: new Array<string>(),
3739
};
3840

3941
applicationClass: {

packages/core/src/lib/features/Feature.ts

+4
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export interface Feature {
6969
* Additional meta-data items to be added into the `application` tag.
7070
*/
7171
applicationMetadata: Metadata[];
72+
/**
73+
* Additional manifest entries to be added into the `activity` tag of LauncherActivity.
74+
*/
75+
launcherActivityEntries: string[];
7276
};
7377
/**
7478
* Customizations to be added to `app/src/main/java/<app-package>/Application.java`.

packages/core/src/lib/features/FeatureManager.ts

+6
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {TwaManifest} from '../TwaManifest';
2222
import {FirstRunFlagFeature} from './FirstRunFlagFeature';
2323
import {Log, ConsoleLog} from '../Log';
2424
import {ArCoreFeature} from './ArCoreFeature';
25+
import {ProtocolHandlersFeature} from './ProtocolHandlersFeature';
2526

2627
const ANDROID_BROWSER_HELPER_VERSIONS = {
2728
stable: 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0',
@@ -41,6 +42,7 @@ export class FeatureManager {
4142
permissions: new Set<string>(),
4243
components: new Array<string>(),
4344
applicationMetadata: new Array<Metadata>(),
45+
launcherActivityEntries: new Array<string>(),
4446
};
4547
applicationClass = {
4648
imports: new Set<string>(),
@@ -102,6 +104,10 @@ export class FeatureManager {
102104
if (twaManifest.enableNotifications) {
103105
this.androidManifest.permissions.add('android.permission.POST_NOTIFICATIONS');
104106
}
107+
108+
if (twaManifest.protocolHandlers) {
109+
this.addFeature(new ProtocolHandlersFeature(twaManifest.protocolHandlers));
110+
}
105111
}
106112

107113
private addFeature(feature: Feature): void {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2025 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {EmptyFeature} from './EmptyFeature';
18+
import {ProtocolHandler} from '../types/ProtocolHandler';
19+
20+
export class ProtocolHandlersFeature extends EmptyFeature {
21+
constructor(protocolHandlers: ProtocolHandler[]) {
22+
super('protocolHandlers');
23+
if (protocolHandlers.length === 0) return;
24+
for (const handler of protocolHandlers) {
25+
this.androidManifest.launcherActivityEntries.push(
26+
`<intent-filter>
27+
<action android:name="android.intent.action.VIEW"/>
28+
<category android:name="android.intent.category.DEFAULT" />
29+
<category android:name="android.intent.category.BROWSABLE"/>
30+
<data android:scheme="${handler.protocol}" />
31+
</intent-filter>`,
32+
);
33+
}
34+
this.launcherActivity.imports.push(
35+
'java.util.HashMap',
36+
'java.util.Map',
37+
);
38+
const mapEntries = new Array<string>();
39+
for (const handler of protocolHandlers) {
40+
mapEntries.push(
41+
`registry.put("${handler.protocol}", Uri.parse("${handler.url}"));`,
42+
);
43+
}
44+
this.launcherActivity.methods.push(
45+
`@Override
46+
protected Map<String, Uri> getProtocolHandlers() {
47+
Map<String, Uri> registry = new HashMap<>();
48+
${mapEntries.join('\n')}
49+
return registry;
50+
}`);
51+
}
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2025 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export interface ProtocolHandler {
18+
protocol: string;
19+
url: string;
20+
}
21+
22+
const ProtocolHandlerExtraScheme = /^web\+[a-z]+$/;
23+
const ProtcolHandlerAllowedSchemes = [
24+
'bitcoin', 'ftp', 'ftps', 'geo', 'im', 'irc', 'ircs',
25+
'magnet', 'mailto', 'matrix', 'news', 'nntp', 'openpgp4fpr',
26+
'sftp', 'sip', 'ssh', 'urn', 'webcal', 'wtai', 'xmpp',
27+
];
28+
// 'mms', 'sms', 'smsto', and 'tel' are not supported!
29+
30+
function normalizeProtocol(protocol: string): string | undefined {
31+
const normalized = protocol.toLowerCase();
32+
33+
if (ProtcolHandlerAllowedSchemes.includes(normalized)) {
34+
return normalized;
35+
}
36+
37+
if (ProtocolHandlerExtraScheme.test(normalized)) {
38+
return normalized;
39+
}
40+
41+
console.warn('Ignoring invalid protocol:', protocol);
42+
return undefined;
43+
}
44+
45+
function normalizeUrl(url: string, startUrl: URL, scopeUrl: URL): string | undefined {
46+
if (!url.includes('%s')) {
47+
console.warn('Ignoring url without %%s:', url);
48+
return undefined;
49+
}
50+
51+
try {
52+
const absoluteUrl = new URL(url);
53+
54+
if (absoluteUrl.protocol !== 'https:') {
55+
console.warn('Ignoring absolute url with illegal scheme:', absoluteUrl.toString());
56+
return undefined;
57+
}
58+
59+
if (absoluteUrl.origin != scopeUrl.origin) {
60+
console.warn('Ignoring absolute url with invalid origin:', absoluteUrl.toString());
61+
return undefined;
62+
}
63+
64+
if (!absoluteUrl.pathname.startsWith(scopeUrl.pathname)) {
65+
console.warn('Ignoring absolute url not within manifest scope: ', absoluteUrl.toString());
66+
return undefined;
67+
}
68+
69+
return absoluteUrl.toString();
70+
} catch (error) {
71+
// Expected, url might be relative!
72+
}
73+
74+
try {
75+
const relativeUrl = new URL(url, startUrl);
76+
return relativeUrl.toString();
77+
} catch (error) {
78+
console.warn('Ignoring invalid relative url:', url);
79+
}
80+
}
81+
82+
export function processProtocolHandlers(
83+
protocolHandlers: ProtocolHandler[],
84+
startUrl: URL,
85+
scopeUrl: URL,
86+
): ProtocolHandler[] {
87+
const processedProtocolHandlers: ProtocolHandler[] = [];
88+
89+
for (const handler of protocolHandlers) {
90+
if (!handler.protocol || !handler.url) continue;
91+
92+
const normalizedProtocol = normalizeProtocol(handler.protocol);
93+
const normalizedUrl = normalizeUrl(handler.url, startUrl, scopeUrl);
94+
95+
if (!normalizedProtocol || !normalizedUrl) {
96+
continue;
97+
}
98+
99+
processedProtocolHandlers.push({protocol: normalizedProtocol, url: normalizedUrl});
100+
}
101+
102+
return processedProtocolHandlers;
103+
}
104+
105+
export function normalizeProtocolForTesting(protocol: string): string | undefined {
106+
return normalizeProtocol(protocol);
107+
}
108+
109+
export function normalizeUrlForTesting(
110+
url: string,
111+
startUrl: URL,
112+
scopeUrl: URL,
113+
): string | undefined {
114+
return normalizeUrl(url, startUrl, scopeUrl);
115+
}

packages/core/src/lib/types/WebManifest.ts

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
* limitations under the License.
1616
*/
1717

18+
import {ProtocolHandler} from './ProtocolHandler';
19+
1820
export interface WebManifestIcon {
1921
src: string;
2022
sizes?: string;
@@ -67,4 +69,5 @@ export interface WebManifestJson {
6769
shortcuts?: Array<WebManifestShortcutJson>;
6870
share_target?: ShareTarget;
6971
orientation?: OrientationLock;
72+
protocol_handlers?: Array<ProtocolHandler>;
7073
}

0 commit comments

Comments
 (0)