Skip to content

Commit 654867a

Browse files
Protocol Handler support
1 parent 9ba50ce commit 654867a

File tree

11 files changed

+378
-2
lines changed

11 files changed

+378
-2
lines changed

Diff for: 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.

Diff for: packages/core/src/lib/TwaManifest.ts

+24
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,11 @@ 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.toString(),
321+
);
322+
315323
const twaManifest = new TwaManifest({
316324
packageId: generatePackageId(webManifestUrl.host) || '',
317325
host: webManifestUrl.host,
@@ -343,6 +351,7 @@ export class TwaManifest {
343351
shareTarget: TwaManifest.verifyShareTarget(webManifestUrl, webManifest.share_target),
344352
orientation: asOrientation(webManifest.orientation) || DEFAULT_ORIENTATION,
345353
fullScopeUrl: fullScopeUrl.toString(),
354+
protocolHandlers: processedProtocolHandlers,
346355
});
347356
return twaManifest;
348357
}
@@ -479,6 +488,19 @@ export class TwaManifest {
479488
oldTwaManifestJson.iconUrl!, webManifest.icons!, 'monochrome', MIN_NOTIFICATION_ICON_SIZE,
480489
webManifestUrl);
481490

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

@@ -503,6 +525,7 @@ export class TwaManifest {
503525
maskableIconUrl: maskableIconUrl || oldTwaManifestJson.maskableIconUrl,
504526
monochromeIconUrl: monochromeIconUrl || oldTwaManifestJson.monochromeIconUrl,
505527
shortcuts: shortcuts,
528+
protocolHandlers: protocolHandlers,
506529
});
507530
return twaManifest;
508531
}
@@ -558,6 +581,7 @@ export interface TwaManifestJson {
558581
serviceAccountJsonFile?: string;
559582
additionalTrustedOrigins?: string[];
560583
retainedBundles?: number[];
584+
protocolHandlers?: ProtocolHandler[];
561585
}
562586

563587
export interface SigningKeyInfo {

Diff for: 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: {

Diff for: 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`.

Diff for: 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 {
+52
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+
}

Diff for: packages/core/src/lib/types/ProtocolHandler.ts

+115
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, baseUrl: string): string | undefined {
46+
if (!url.includes('%s')) {
47+
console.warn('Ignoring url without %%s:', url);
48+
return undefined;
49+
}
50+
51+
let baseURL = undefined;
52+
try {
53+
baseURL = new URL(baseUrl);
54+
} catch (error) {
55+
console.warn('Cannot parse baseUrl:', baseUrl);
56+
return undefined;
57+
}
58+
59+
let absoluteUrl = undefined;
60+
try {
61+
absoluteUrl = new URL(url);
62+
63+
if (absoluteUrl.protocol !== 'https:') {
64+
console.warn('Ignoring absolute url with illegal scheme:', absoluteUrl.toString());
65+
return undefined;
66+
}
67+
68+
if (absoluteUrl.origin != baseURL.origin) {
69+
console.warn('Ignoring absolute url with invalid origin:', absoluteUrl.toString());
70+
return undefined;
71+
}
72+
73+
return absoluteUrl.toString();
74+
} catch (error) {
75+
// Expected, url might be relative!
76+
}
77+
78+
let relativeUrl = undefined;
79+
try {
80+
relativeUrl = new URL(url, baseURL);
81+
return relativeUrl.toString();
82+
} catch (error) {
83+
console.warn('Ignoring invalid relative url:', url);
84+
}
85+
}
86+
87+
export function processProtocolHandlers(
88+
protocolHandlers: ProtocolHandler[],
89+
baseUrl: string,
90+
): ProtocolHandler[] {
91+
const processedProtocolHandlers: ProtocolHandler[] = [];
92+
93+
for (const handler of protocolHandlers) {
94+
if (!handler.protocol || !handler.url) continue;
95+
96+
const normalizedProtocol = normalizeProtocol(handler.protocol);
97+
const normalizedUrl = normalizeUrl(handler.url, baseUrl);
98+
99+
if (!normalizedProtocol || !normalizedUrl) {
100+
continue;
101+
}
102+
103+
processedProtocolHandlers.push({protocol: normalizedProtocol, url: normalizedUrl});
104+
}
105+
106+
return processedProtocolHandlers;
107+
}
108+
109+
export function normalizeProtocolForTesting(protocol: string): string | undefined {
110+
return normalizeProtocol(protocol);
111+
}
112+
113+
export function normalizeUrlForTesting(url: string, baseUrl: string): string | undefined {
114+
return normalizeUrl(url, baseUrl);
115+
}

Diff for: 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)