Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DO NOT MERGE YET Protocol Handler support #911

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Protocol Handler support
gstepniewski-google committed Mar 10, 2025
commit 13cccb7de267459f384703febf7d06470f097bd3
11 changes: 11 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -392,6 +392,7 @@ Fields:
|webManifestUrl|string|false|Full URL to the PWA Web Manifest. Required for the application to be compatible with Chrome OS and Meta Quest devices.|
|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.|
|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.|
|protocolHandlers|[ProtocolHandler](#protocolhandlers)[]|false|List of [Protocol Handlers](#protocolhandlers) supported by the app.|

### Features

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


### ProtocolHandlers

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).

|Name|Type|Required|Description|
|:--:|:--:|:------:|:---------:|
|protocol|string|true|Data scheme to register (e.g. `bitcoin`, `irc`, `web+coffee`).|
|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`|

## Manually setting up the Environment

### Get the Java Development Kit (JDK) 17.
25 changes: 25 additions & 0 deletions packages/core/src/lib/TwaManifest.ts
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ import {findSuitableIcon, generatePackageId, validateNotEmpty} from './util';
import Color = require('color');
import {ConsoleLog} from './Log';
import {ShareTarget, WebManifestIcon, WebManifestJson} from './types/WebManifest';
import {processProtocolHandlers, ProtocolHandler} from './types/ProtocolHandler';
import {ShortcutInfo} from './ShortcutInfo';
import {AppsFlyerConfig} from './features/AppsFlyerFeature';
import {LocationDelegationConfig} from './features/LocationDelegationFeature';
@@ -169,6 +170,7 @@ export class TwaManifest {
serviceAccountJsonFile: string | undefined;
additionalTrustedOrigins: string[];
retainedBundles: number[];
protocolHandlers?: ProtocolHandler[];

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

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

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

const processedProtocolHandlers = processProtocolHandlers(
webManifest.protocol_handlers ?? [],
fullStartUrl,
fullScopeUrl,
);

const twaManifest = new TwaManifest({
packageId: generatePackageId(webManifestUrl.host) || '',
host: webManifestUrl.host,
@@ -343,6 +352,7 @@ export class TwaManifest {
shareTarget: TwaManifest.verifyShareTarget(webManifestUrl, webManifest.share_target),
orientation: asOrientation(webManifest.orientation) || DEFAULT_ORIENTATION,
fullScopeUrl: fullScopeUrl.toString(),
protocolHandlers: processedProtocolHandlers,
});
return twaManifest;
}
@@ -479,6 +489,19 @@ export class TwaManifest {
oldTwaManifestJson.iconUrl!, webManifest.icons!, 'monochrome', MIN_NOTIFICATION_ICON_SIZE,
webManifestUrl);

const protocolHandlersMap = new Map<string, string>();
for (const handler of oldTwaManifest.protocolHandlers ?? []) {
protocolHandlersMap.set(handler.protocol, handler.url);
}
if (!(fieldsToIgnore.includes('protocol_handlers'))) {
for (const handler of webManifest.protocol_handlers ?? []) {
protocolHandlersMap.set(handler.protocol, handler.url);
};
}
const protocolHandlers = Array.from(protocolHandlersMap.entries()).map(([protocol, url]) => {
return {protocol, url} as ProtocolHandler;
});

const fullStartUrl: URL = new URL(webManifest['start_url'] || '/', webManifestUrl);
const fullScopeUrl: URL = new URL(webManifest['scope'] || '.', webManifestUrl);

@@ -503,6 +526,7 @@ export class TwaManifest {
maskableIconUrl: maskableIconUrl || oldTwaManifestJson.maskableIconUrl,
monochromeIconUrl: monochromeIconUrl || oldTwaManifestJson.monochromeIconUrl,
shortcuts: shortcuts,
protocolHandlers: protocolHandlers,
});
return twaManifest;
}
@@ -558,6 +582,7 @@ export interface TwaManifestJson {
serviceAccountJsonFile?: string;
additionalTrustedOrigins?: string[];
retainedBundles?: number[];
protocolHandlers?: ProtocolHandler[];
}

export interface SigningKeyInfo {
2 changes: 2 additions & 0 deletions packages/core/src/lib/features/EmptyFeature.ts
Original file line number Diff line number Diff line change
@@ -30,10 +30,12 @@ export class EmptyFeature implements Feature {
permissions: string[];
components: string[];
applicationMetadata: Metadata[];
launcherActivityEntries: string[];
} = {
permissions: new Array<string>(),
components: new Array<string>(),
applicationMetadata: new Array<Metadata>(),
launcherActivityEntries: new Array<string>(),
};

applicationClass: {
4 changes: 4 additions & 0 deletions packages/core/src/lib/features/Feature.ts
Original file line number Diff line number Diff line change
@@ -69,6 +69,10 @@ export interface Feature {
* Additional meta-data items to be added into the `application` tag.
*/
applicationMetadata: Metadata[];
/**
* Additional manifest entries to be added into the `activity` tag of LauncherActivity.
*/
launcherActivityEntries: string[];
};
/**
* Customizations to be added to `app/src/main/java/<app-package>/Application.java`.
6 changes: 6 additions & 0 deletions packages/core/src/lib/features/FeatureManager.ts
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ import {TwaManifest} from '../TwaManifest';
import {FirstRunFlagFeature} from './FirstRunFlagFeature';
import {Log, ConsoleLog} from '../Log';
import {ArCoreFeature} from './ArCoreFeature';
import {ProtocolHandlersFeature} from './ProtocolHandlersFeature';

const ANDROID_BROWSER_HELPER_VERSIONS = {
stable: 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0',
@@ -41,6 +42,7 @@ export class FeatureManager {
permissions: new Set<string>(),
components: new Array<string>(),
applicationMetadata: new Array<Metadata>(),
launcherActivityEntries: new Array<string>(),
};
applicationClass = {
imports: new Set<string>(),
@@ -102,6 +104,10 @@ export class FeatureManager {
if (twaManifest.enableNotifications) {
this.androidManifest.permissions.add('android.permission.POST_NOTIFICATIONS');
}

if (twaManifest.protocolHandlers) {
this.addFeature(new ProtocolHandlersFeature(twaManifest.protocolHandlers));
}
}

private addFeature(feature: Feature): void {
52 changes: 52 additions & 0 deletions packages/core/src/lib/features/ProtocolHandlersFeature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {EmptyFeature} from './EmptyFeature';
import {ProtocolHandler} from '../types/ProtocolHandler';

export class ProtocolHandlersFeature extends EmptyFeature {
constructor(protocolHandlers: ProtocolHandler[]) {
super('protocolHandlers');
if (protocolHandlers.length === 0) return;
for (const handler of protocolHandlers) {
this.androidManifest.launcherActivityEntries.push(
`<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="${handler.protocol}" />
</intent-filter>`,
);
}
this.launcherActivity.imports.push(
'java.util.HashMap',
'java.util.Map',
);
const mapEntries = new Array<string>();
for (const handler of protocolHandlers) {
mapEntries.push(
`registry.put("${handler.protocol}", Uri.parse("${handler.url}"));`,
);
}
this.launcherActivity.methods.push(
`@Override
protected Map<String, Uri> getProtocolHandlers() {
Map<String, Uri> registry = new HashMap<>();
${mapEntries.join('\n')}
return registry;
}`);
}
}
115 changes: 115 additions & 0 deletions packages/core/src/lib/types/ProtocolHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export interface ProtocolHandler {
protocol: string;
url: string;
}

const ProtocolHandlerExtraScheme = /^web\+[a-z]+$/;
const ProtcolHandlerAllowedSchemes = [
'bitcoin', 'ftp', 'ftps', 'geo', 'im', 'irc', 'ircs',
'magnet', 'mailto', 'matrix', 'news', 'nntp', 'openpgp4fpr',
'sftp', 'sip', 'ssh', 'urn', 'webcal', 'wtai', 'xmpp',
];
// 'mms', 'sms', 'smsto', and 'tel' are not supported!

function normalizeProtocol(protocol: string): string | undefined {
const normalized = protocol.toLowerCase();

if (ProtcolHandlerAllowedSchemes.includes(normalized)) {
return normalized;
}

if (ProtocolHandlerExtraScheme.test(normalized)) {
return normalized;
}

console.warn('Ignoring invalid protocol:', protocol);
return undefined;
}

function normalizeUrl(url: string, startUrl: URL, scopeUrl: URL): string | undefined {
if (!url.includes('%s')) {
console.warn('Ignoring url without %%s:', url);
return undefined;
}

try {
const absoluteUrl = new URL(url);

if (absoluteUrl.protocol !== 'https:') {
console.warn('Ignoring absolute url with illegal scheme:', absoluteUrl.toString());
return undefined;
}

if (absoluteUrl.origin != scopeUrl.origin) {
console.warn('Ignoring absolute url with invalid origin:', absoluteUrl.toString());
return undefined;
}

if (!absoluteUrl.pathname.startsWith(scopeUrl.pathname)) {
console.warn('Ignoring absolute url not within manifest scope: ', absoluteUrl.toString());
return undefined;
}

return absoluteUrl.toString();
} catch (error) {
// Expected, url might be relative!
}

try {
const relativeUrl = new URL(url, startUrl);
return relativeUrl.toString();
} catch (error) {
console.warn('Ignoring invalid relative url:', url);
}
}

export function processProtocolHandlers(
protocolHandlers: ProtocolHandler[],
startUrl: URL,
scopeUrl: URL,
): ProtocolHandler[] {
const processedProtocolHandlers: ProtocolHandler[] = [];

for (const handler of protocolHandlers) {
if (!handler.protocol || !handler.url) continue;

const normalizedProtocol = normalizeProtocol(handler.protocol);
const normalizedUrl = normalizeUrl(handler.url, startUrl, scopeUrl);

if (!normalizedProtocol || !normalizedUrl) {
continue;
}

processedProtocolHandlers.push({protocol: normalizedProtocol, url: normalizedUrl});
}

return processedProtocolHandlers;
}

export function normalizeProtocolForTesting(protocol: string): string | undefined {
return normalizeProtocol(protocol);
}

export function normalizeUrlForTesting(
url: string,
startUrl: URL,
scopeUrl: URL,
): string | undefined {
return normalizeUrl(url, startUrl, scopeUrl);
}
3 changes: 3 additions & 0 deletions packages/core/src/lib/types/WebManifest.ts
Original file line number Diff line number Diff line change
@@ -15,6 +15,8 @@
* limitations under the License.
*/

import {ProtocolHandler} from './ProtocolHandler';

export interface WebManifestIcon {
src: string;
sizes?: string;
@@ -67,4 +69,5 @@ export interface WebManifestJson {
shortcuts?: Array<WebManifestShortcutJson>;
share_target?: ShareTarget;
orientation?: OrientationLock;
protocol_handlers?: Array<ProtocolHandler>;
}
143 changes: 143 additions & 0 deletions packages/core/src/spec/lib/ProtocolHandlerSpec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
normalizeProtocolForTesting,
normalizeUrlForTesting,
processProtocolHandlers,
ProtocolHandler,
} from '../../lib/types/ProtocolHandler';

describe('ProtocolHandler', () => {
describe('#normalizeProtocol', () => {
it('Accepts allowed schemes', () => {
let normalized = normalizeProtocolForTesting('bitcoin');
expect(normalized).toBe('bitcoin');
normalized = normalizeProtocolForTesting('XMPP');
expect(normalized).toBe('xmpp');
});
it('Rejects not allowed schemes', () => {
const normalized = normalizeProtocolForTesting('something-else');
expect(normalized).toBeUndefined();
});
it('Allows web+ schemes', () => {
let normalized = normalizeProtocolForTesting('web+tea');
expect(normalized).toBe('web+tea');
normalized = normalizeProtocolForTesting('web+Coffee');
expect(normalized).toBe('web+coffee');
});
it('Rejects invalid web+ schemes', () => {
let normalized = normalizeProtocolForTesting('web+');
expect(normalized).toBeUndefined();
normalized = normalizeProtocolForTesting('web+a-b');
expect(normalized).toBeUndefined();
normalized = normalizeProtocolForTesting('web+mailto:');
expect(normalized).toBeUndefined();
});
});

describe('#normalizeUrl', () => {
it('Accepts a valid relative url format', () => {
const normalized = normalizeUrlForTesting(
'?coffee=%s',
new URL('https://test.com/app/start'),
new URL('https://test.com/app/'),
);
expect(normalized).toBe('https://test.com/app/start?coffee=%s');
});
it('Accepts a valid absolute url format', () => {
const normalized = normalizeUrlForTesting(
'https://test.com/app/start?tea=%s',
new URL('https://test.com/app/start'),
new URL('https://test.com/app/'),
);
expect(normalized).toBe('https://test.com/app/start?tea=%s');
});
it('Rejects url format without %s', () => {
const normalized = normalizeUrlForTesting(
'coffee',
new URL('https://test.com/app/start'),
new URL('https://test.com/app/'),
);
expect(normalized).toBeUndefined();
});
it('Rejects absolute url with different origin', () => {
const normalized = normalizeUrlForTesting(
'https://fail.com/?tea=%s',
new URL('https://test.com/app/start'),
new URL('https://test.com/app/'),
);
expect(normalized).toBeUndefined();
});
it('Rejects absolute url outside of scope', () => {
const normalized = normalizeUrlForTesting(
'https://test.com/notapp?tea=%s',
new URL('https://test.com/app/start'),
new URL('https://test.com/app/'),
);
expect(normalized).toBeUndefined();
});
it('Rejects absolute url with illegal scheme', () => {
const normalized = normalizeUrlForTesting(
'vnc://test.com/?tea=%s',
new URL('https://test.com/app/start'),
new URL('https://test.com/app/'),
);
expect(normalized).toBeUndefined();
});
it('Rejects absolute url with http scheme', () => {
const normalized = normalizeUrlForTesting(
'http://test.com/?tea=%s',
new URL('https://test.com/app/start'),
new URL('https://test.com/app/'),
);
expect(normalized).toBeUndefined();
});
});

describe('#processProtocolHandlers', () => {
it('Accepts valid protocol handlers', () => {
const startUrl = new URL('https://test.com/app/start');
const scopeUrl = new URL('https://test.com/app/');
const testHandlers: ProtocolHandler[] = [
{protocol: 'bitcoin', url: '?wallet=%s'},
{protocol: 'XMPP', url: `${startUrl}?contact=%s`},
{protocol: 'web+tea', url: '?tea=%s'},
{protocol: 'web+HEY', url: '?there=%s'},
];
const expectedHandlers: ProtocolHandler[] = [
{protocol: 'bitcoin', url: `${startUrl}?wallet=%s`},
{protocol: 'xmpp', url: `${startUrl}?contact=%s`},
{protocol: 'web+tea', url: `${startUrl}?tea=%s`},
{protocol: 'web+hey', url: `${startUrl}?there=%s`},
];
const processedHandlers = processProtocolHandlers(testHandlers, startUrl, scopeUrl);
expect(processedHandlers).toEqual(expectedHandlers);
});
it('Rejects invalid protocol handlers', () => {
const startUrl = new URL('https://test.com/app/start');
const scopeUrl = new URL('https://test.com/app/');
const testHandlers: ProtocolHandler[] = [
{protocol: 'coffee', url: '?wallet=%s'},
{protocol: 'web+tea', url: `${startUrl}?contact`},
{protocol: 'web+tea', url: '?tea'},
{protocol: 'web', url: '?there=%s'},
];
const processedHandlers = processProtocolHandlers(testHandlers, startUrl, scopeUrl);
expect(processedHandlers).toEqual([]);
});
});
});
46 changes: 44 additions & 2 deletions packages/core/src/spec/lib/TwaManifestSpec.ts
Original file line number Diff line number Diff line change
@@ -354,6 +354,10 @@ describe('TwaManifest', () => {
'purpose': 'any',
},
],
'protocol_handlers': [{
'protocol': 'web+test-replace',
'url': 'test-format-web/%s',
}],
};
const twaManifest = new TwaManifest({
'packageId': 'id',
@@ -390,12 +394,32 @@ describe('TwaManifest', () => {
'fullScopeUrl': 'https://name.github.io/',
'appVersion': '1',
'serviceAccountJsonFile': '/home/service-account.json',
'protocolHandlers': [
{
'protocol': 'web+test-replace',
'url': 'test-format-twa/%s',
},
{
'protocol': 'web+test-keep',
'url': 'test-format-twa/%s',
},
],
});
// The versions shouldn't change because the update happens in `cli`.
const expectedTwaManifest = new TwaManifest({
...twaManifest.toJson(),
'launcherName': 'different_name',
'display': 'fullscreen',
'protocolHandlers': [
{
'protocol': 'web+test-replace',
'url': 'test-format-web/%s',
},
{
'protocol': 'web+test-keep',
'url': 'test-format-twa/%s',
},
],
});
// A URL to insert as the webManifestUrl.
const url = new URL('https://name.github.io/manifest.json');
@@ -415,6 +439,10 @@ describe('TwaManifest', () => {
'purpose': 'any',
},
],
'protocol_handlers': [{
'protocol': 'web+test-replace',
'url': 'test-format-web/%s',
}],
};
const twaManifest = new TwaManifest({
'packageId': 'id',
@@ -451,13 +479,27 @@ describe('TwaManifest', () => {
'fullScopeUrl': 'https://name.github.io/',
'appVersion': '1',
'serviceAccountJsonFile': '/home/service-account.json',
'protocolHandlers': [
{
'protocol': 'web+test-replace',
'url': 'test-format-twa/%s',
},
{
'protocol': 'web+test-keep',
'url': 'test-format-twa/%s',
},
],
});
// The versions shouldn't change because the update happens in `cli`.
const expectedTwaManifest = twaManifest;
// A URL to insert as the webManifestUrl.
const url = new URL('https://name.github.io/manifest.json');
expect(await TwaManifest.merge(['short_name', 'display'], url, webManifest, twaManifest))
.toEqual(expectedTwaManifest);
expect(await TwaManifest.merge(
['short_name', 'display', 'protocol_handlers'],
url,
webManifest,
twaManifest,
)).toEqual(expectedTwaManifest);
});
});
});
Original file line number Diff line number Diff line change
@@ -207,6 +207,10 @@
android:host="<%= additionalOrigin %>"/>
</intent-filter>
<% } %>

<% for(const entry of androidManifest.launcherActivityEntries) { %>
<%= entry %>
<% } %>
</activity>

<activity android:name="com.google.androidbrowserhelper.trusted.FocusActivity" />