blob: a75260e5766c7e3bcf0cb9d0fd7f70a9113799b8 [file] [log] [blame]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '/strings.m.js';
import {assertNotReached} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {getRequiredElement} from 'chrome://resources/js/util.js';
import type {FilePath} from 'chrome://resources/mojo/mojo/public/mojom/base/file_path.mojom-webui.js';
import type {Origin} from 'chrome://resources/mojo/url/mojom/origin.mojom-webui.js';
import type {Url} from 'chrome://resources/mojo/url/mojom/url.mojom-webui.js';
import type {InstallIsolatedWebAppResult, IwaDevModeAppInfo, IwaDevModeLocation, ParseUpdateManifestFromUrlResult, UpdateInfo, UpdateManifest, VersionEntry} from './web_app_internals.mojom-webui.js';
import {WebAppInternalsHandler} from './web_app_internals.mojom-webui.js';
const webAppInternalsHandler = WebAppInternalsHandler.getRemote();
const debugInfoAsJsonString: Promise<string> =
webAppInternalsHandler.getDebugInfoAsJsonString().then(
response => response.result);
const iwaDevProxyInstallButton =
getRequiredElement<HTMLButtonElement>('iwa-dev-install-proxy-button');
const iwaDevProxyInstallUrl =
getRequiredElement<HTMLInputElement>('iwa-dev-install-proxy-url');
const iwaDevUpdateManifestUrl =
getRequiredElement<HTMLInputElement>('iwa-dev-update-manifest-url');
const iwaDevUpdateManifestDialog =
getRequiredElement<HTMLDialogElement>('iwa-update-manifest-dialog');
const iwaSwitchChannelDialog =
getRequiredElement<HTMLDialogElement>('iwa-switch-channel-input-dialog');
const switchChannelButton =
getRequiredElement<HTMLButtonElement>('iwa-switch-channel-dialog-switch');
const closeSwitchChannelDialogButton =
getRequiredElement<HTMLButtonElement>('iwa-switch-channel-dialog-close');
const iwaPinnedVersionDialog =
getRequiredElement<HTMLDialogElement>('iwa-pinned-version-input-dialog');
/**
* Converts a mojo origin into a user-readable string, omitting default ports.
* @param origin Origin to convert
*
* TODO(b/304717391): Extract origin serialization logic from here and use it
* everywhere `url.mojom.Origin` is serialized in JS/TS.
*/
function originToText(origin: Origin): string {
if (origin.host.length === 0) {
return 'null';
}
let result = origin.scheme + '://' + origin.host;
if (!(origin.scheme === 'https' && origin.port === 443) &&
!(origin.scheme === 'http' && origin.port === 80)) {
result += ':' + origin.port;
}
return result;
}
/**
* Converts a mojo representation of `base::FilePath` into a user-readable
* string.
* @param filePath File path to convert
*/
function filePathToText(filePath: FilePath): string {
if (typeof filePath.path === 'string') {
return filePath.path;
}
const decoder = new TextDecoder('utf-16');
const buffer = new Uint16Array(filePath.path);
return decoder.decode(buffer);
}
getRequiredElement('copy-button').addEventListener('click', async () => {
navigator.clipboard.writeText(await debugInfoAsJsonString);
});
getRequiredElement('download-button').addEventListener('click', async () => {
const url = URL.createObjectURL(new Blob([await debugInfoAsJsonString], {
type: 'application/json',
}));
const a = document.createElement('a');
a.href = url;
a.download = 'web_app_internals.json';
a.click();
// Downloading succeeds even if the URL was revoked during downloading. See
// the spec for details (https://w3c.github.io/FileAPI/#dfn-revokeObjectURL):
//
// "Note: ... Requests that were started before the url was revoked should
// still succeed."
URL.revokeObjectURL(url);
});
function updateDevProxyInstallButtonState() {
iwaDevProxyInstallButton.disabled = iwaDevProxyInstallUrl.value.length === 0;
}
function setDevInstallMessageText(
message: string,
) {
setTimeout(() => {
getRequiredElement('iwa-dev-install-message').innerText = message;
}, 0);
}
async function iwaDevProxyInstall() {
iwaDevProxyInstallButton.disabled = true;
// Validate the provided URL.
let valid = false;
try {
// We don't need the result of this, only to verify it doesn't throw an
// exception.
new URL(iwaDevProxyInstallUrl.value);
valid =
(iwaDevProxyInstallUrl.value.startsWith('http:') ||
iwaDevProxyInstallUrl.value.startsWith('https:'));
} catch (_) {
// Fall-through.
}
if (!valid) {
setDevInstallMessageText(
`Installing IWA: ${iwaDevProxyInstallUrl.value} is not a valid URL`);
updateDevProxyInstallButtonState();
return;
}
setDevInstallMessageText(`Installing IWA: ${iwaDevProxyInstallUrl.value}...`);
const location: Url = {url: iwaDevProxyInstallUrl.value};
const result: InstallIsolatedWebAppResult =
(await webAppInternalsHandler.installIsolatedWebAppFromDevProxy(location))
.result;
if (result.success) {
setDevInstallMessageText(`Installing IWA: ${
iwaDevProxyInstallUrl.value} successfully installed.`);
iwaDevProxyInstallUrl.value = '';
updateDevProxyInstallButtonState();
refreshDevModeAppList();
return;
}
setDevInstallMessageText(`Installing IWA: ${
iwaDevProxyInstallUrl.value} failed to install: ${result.error}`);
updateDevProxyInstallButtonState();
}
iwaDevProxyInstallUrl.addEventListener('enter', iwaDevProxyInstall);
iwaDevProxyInstallButton.addEventListener('click', iwaDevProxyInstall);
iwaDevProxyInstallUrl.addEventListener('keyup', (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
iwaDevProxyInstall();
return;
}
updateDevProxyInstallButtonState();
});
updateDevProxyInstallButtonState();
getRequiredElement('iwa-dev-install-bundle-selector')
.addEventListener('click', async () => {
setDevInstallMessageText(`Installing IWA from bundle...`);
const result: InstallIsolatedWebAppResult =
(await webAppInternalsHandler
.selectFileAndInstallIsolatedWebAppFromDevBundle())
.result;
if (result.success) {
setDevInstallMessageText(
`Installing IWA: successfully installed (Web Bundle ID: ${
result.success.webBundleId}).`);
refreshDevModeAppList();
return;
}
setDevInstallMessageText(
`Installing IWA: failed to install: ${result.error}`);
});
async function iwaDevFetchUpdateManifest() {
// Validate the provided URL.
try {
// We don't need the result of this, only to verify it doesn't throw an
// exception.
new URL(iwaDevUpdateManifestUrl.value);
} catch (_) {
setDevInstallMessageText(`Fetching the update manifest: ${
iwaDevUpdateManifestUrl.value} is not a valid URL`);
return;
}
setDevInstallMessageText(
`Fetching the update manifest at ${iwaDevUpdateManifestUrl.value}...`);
const updateManifestUrl: Url = {url: iwaDevUpdateManifestUrl.value};
const result: ParseUpdateManifestFromUrlResult =
(await webAppInternalsHandler.parseUpdateManifestFromUrl(
updateManifestUrl))
.result;
if (result.error) {
setDevInstallMessageText(`Installing IWA from update manifest: ${
iwaDevUpdateManifestUrl.value} failed to install: ${result.error}`);
return;
}
// `result` is a mojo union where there's always one of `error` or
// `updateManifest` defined.
const manifest: UpdateManifest = result.updateManifest!;
const versions: VersionEntry[] = manifest.versions;
const select = getRequiredElement<HTMLSelectElement>(
'iwa-update-manifest-version-select');
select.replaceChildren();
for (const versionEntry of versions) {
const option = document.createElement('option');
option.value = versionEntry.version;
option.textContent = versionEntry.version;
select.appendChild(option);
}
const installButton = getRequiredElement<HTMLButtonElement>(
'iwa-update-manifest-dialog-install');
const closeButton =
getRequiredElement<HTMLButtonElement>('iwa-update-manifest-dialog-close');
closeButton.addEventListener('click', () => {
iwaDevUpdateManifestDialog.close();
}, {once: true});
const installEventListener = async () => {
installButton.removeEventListener('click', installEventListener);
const selectedVersion = select.value;
iwaDevUpdateManifestDialog.close();
setDevInstallMessageText(`Installing version ${selectedVersion} from ${
updateManifestUrl.url}...`);
const selectedVersionEntry: VersionEntry|null =
versions.find(
versionEntry => versionEntry.version === selectedVersion) ||
null;
if (!selectedVersionEntry) {
setDevInstallMessageText(`Installing version ${selectedVersion} from ${
updateManifestUrl.url} failed: no such version`);
return;
}
const installResult: InstallIsolatedWebAppResult =
(await webAppInternalsHandler.installIsolatedWebAppFromBundleUrl({
webBundleUrl: selectedVersionEntry.webBundleUrl,
updateInfo: {
updateManifestUrl,
// TODO(crbug.com/373396075): Allow selecting the channel.
updateChannel: 'default',
pinnedVersion: null,
allowDowngrades: false,
},
})).result;
if (installResult.success) {
setDevInstallMessageText(`Installing version ${selectedVersion} from ${
updateManifestUrl.url}: success!`);
} else {
setDevInstallMessageText(`Installing version ${selectedVersion} from ${
updateManifestUrl.url} failed: ${installResult.error}`);
}
refreshDevModeAppList();
};
installButton.addEventListener('click', installEventListener);
iwaDevUpdateManifestDialog.showModal();
}
// Logic for handling the channel switching dialog for IWAs.
function showSwitchChannelDialog(appId: string, name: string) {
switchChannelButton.addEventListener('click', async () => {
const updateChannel =
getRequiredElement<HTMLInputElement>('iwa-update-channel');
iwaSwitchChannelDialog.close();
try {
setDevInstallMessageText(
`Switching channel to ${updateChannel.value} for ${name}...`);
const {success} =
await webAppInternalsHandler.setUpdateChannelForIsolatedWebApp(
appId,
updateChannel.value,
);
setDevInstallMessageText(
success ? `Successful channel switch to ${updateChannel.value} for ${
name}.` :
`Failed to switch channel to ${updateChannel.value} for ${
name}.`);
if (success) {
refreshDevModeAppList();
}
} catch (error) {
setDevInstallMessageText(
`An error occurred while switching the update channel of ${name}.`);
console.error(error);
}
updateChannel.value = '';
}, {once: true});
iwaSwitchChannelDialog.showModal();
}
closeSwitchChannelDialogButton.addEventListener('click', () => {
iwaSwitchChannelDialog.close();
});
// Logic for handling the version pinning for IWAs.
function showPinnedVersionDialog(appId: string, name: string) {
const pinButton =
getRequiredElement<HTMLButtonElement>('iwa-pinned-version-dialog-pin');
const unpinButton =
getRequiredElement<HTMLButtonElement>('iwa-pinned-version-dialog-unpin');
const pinnedVersion =
getRequiredElement<HTMLInputElement>('iwa-pinned-version');
pinButton.addEventListener('click', () => {
const version = pinnedVersion.value;
setDevInstallMessageText(`Pinning ${name} to version ${version}...`);
iwaPinnedVersionDialog.close();
setTimeout(async () => {
const {success} =
await webAppInternalsHandler.setPinnedVersionForIsolatedWebApp(
appId, version);
setDevInstallMessageText(
success ?
`Successfully pinned ${name} to version ${
version}; Version will be applied when an
update is triggered.` :
`Something went wrong while setting pinned version of ${name}
to version ${version}.`);
if (success) {
refreshDevModeAppList();
}
}, 0);
}, {once: true});
unpinButton.addEventListener('click', () => {
iwaPinnedVersionDialog.close();
webAppInternalsHandler.resetPinnedVersionForIsolatedWebApp(appId);
});
iwaPinnedVersionDialog.showModal();
}
getRequiredElement('iwa-pinned-version-dialog-close')
.addEventListener('click', () => {
iwaPinnedVersionDialog.close();
setDevInstallMessageText('');
});
// Logic for downgrades
async function toggleAllowDowngrades(appId: string, isChecked: boolean) {
try {
await webAppInternalsHandler.setAllowDowngradesForIsolatedWebApp(
isChecked, appId);
setTimeout(refreshDevModeAppList, 0);
} catch (error) {
setDevInstallMessageText('Error toggling allowDowngrades');
}
}
iwaDevUpdateManifestUrl.addEventListener('enter', iwaDevFetchUpdateManifest);
getRequiredElement('iwa-dev-update-manifest-fetch-button')
.addEventListener('click', iwaDevFetchUpdateManifest);
getRequiredElement('iwa-updates-search-button')
.addEventListener('click', async () => {
const messageDiv = getRequiredElement('iwa-updates-message');
messageDiv.innerText = `Queueing update discovery tasks...`;
const result: string =
(await webAppInternalsHandler.searchForIsolatedWebAppUpdates())
.result;
messageDiv.innerText = result;
});
const iwaRotateKeyButton =
getRequiredElement<HTMLButtonElement>('iwa-rotate-key-button');
iwaRotateKeyButton.addEventListener('click', () => {
const webBundleId =
getRequiredElement<HTMLInputElement>('iwa-kr-web-bundle-id');
const publicKeyBase64 =
getRequiredElement<HTMLInputElement>('iwa-kr-public-key-b64');
const keyRotationMessageDiv = getRequiredElement('iwa-kr-message');
keyRotationMessageDiv.innerText = '';
if (webBundleId.value.length === 0) {
keyRotationMessageDiv.innerText = `web-bundle-id must not be empty.`;
return;
}
let publicKeyBytes: number[]|null = null;
if (publicKeyBase64.value.length > 0) {
try {
const pk = atob(publicKeyBase64.value);
publicKeyBytes = [];
for (let i = 0; i < pk.length; i++) {
publicKeyBytes.push(pk.charCodeAt(i));
}
} catch (err) {
// This block handles `atob()` errors.
keyRotationMessageDiv.innerText =
`${publicKeyBase64.value} is not a base64 encoded key.`;
return;
}
}
iwaRotateKeyButton.disabled = true;
// If `publicKeyBytes` are `null`, the app with this `webBundleId` will be
// disabled.
webAppInternalsHandler.rotateKey(webBundleId.value, publicKeyBytes);
// Improve end user experience by providing a delay of 1000 ms to enable the
// key rotation button.
setTimeout(() => {
keyRotationMessageDiv.innerText = `Successfully rotated public key for ${
webBundleId.value} to ${publicKeyBase64.value}!`;
publicKeyBase64.value = '';
webBundleId.value = '';
iwaRotateKeyButton.disabled = false;
}, 1000);
});
function formatDevModeLocation(location: IwaDevModeLocation): string|void {
if (location.proxyOrigin) {
return originToText(location.proxyOrigin);
}
if (location.bundlePath) {
return filePathToText(location.bundlePath);
}
assertNotReached();
}
function describeIsolatedWebApp(
name: string, installedVersion: string, location: IwaDevModeLocation,
updateInfo: UpdateInfo|null): string {
let updateMsg = `${name} (${installedVersion}) →`;
if (updateInfo) {
const pinnedVersionValue =
updateInfo.pinnedVersion ? updateInfo.pinnedVersion : '-';
updateMsg += ` ${updateInfo.updateManifestUrl.url} ( update_channel: ${
updateInfo.updateChannel} | pinned_version: ${
pinnedVersionValue} | allow_downgrades: ${updateInfo.allowDowngrades})`;
} else {
updateMsg += ` (${formatDevModeLocation(location)})`;
}
return updateMsg;
}
function showIwaSection(containerId: string) {
getRequiredElement(containerId).style.display = '';
getRequiredElement('iwa-container').style.display = '';
}
async function refreshDevModeAppList() {
const devModeUpdatesMessage = getRequiredElement('iwa-dev-updates-message');
devModeUpdatesMessage.innerText = 'Loading IWAs list...';
const devModeApps: IwaDevModeAppInfo[] =
(await webAppInternalsHandler.getIsolatedWebAppDevModeAppInfo()).apps;
const devModeAppList = getRequiredElement('iwa-dev-updates-app-list');
devModeAppList.replaceChildren();
if (devModeApps.length === 0) {
devModeUpdatesMessage.innerText = 'None';
} else {
devModeUpdatesMessage.innerText = '';
for (const {appId, name, location, installedVersion, updateInfo} of
devModeApps) {
const li = document.createElement('li');
li.innerText =
describeIsolatedWebApp(name, installedVersion, location, updateInfo);
li.className = 'iwa-dev-mode-list-item';
const {updateMsg, buttonsSection} =
prepareAppButtons(appId, name, location, updateInfo);
li.appendChild(buttonsSection);
li.appendChild(updateMsg);
devModeAppList.appendChild(li);
}
}
}
function prepareAppButtons(
appId: string,
name: string,
location: IwaDevModeLocation,
updateInfo: UpdateInfo|null,
): {updateMsg: HTMLParagraphElement, buttonsSection: HTMLElement} {
const updateMsg = document.createElement('p');
const buttonsSection = document.createElement('div');
buttonsSection.className = 'dev-iwa-buttons';
const updateBtn = document.createElement('button');
updateBtn.innerText = 'Perform update now';
updateBtn.onclick = async () => {
const oldText = updateBtn.innerText;
try {
updateBtn.disabled = true;
updateBtn.innerText =
'Performing update... (close the IWA if it is currently open!)';
if (updateInfo) {
const {result}: {result: string} =
await webAppInternalsHandler.updateManifestInstalledIsolatedWebApp(
appId);
updateMsg.innerText = result;
} else if (location.bundlePath) {
const {result}: {result: string} =
await webAppInternalsHandler
.selectFileAndUpdateIsolatedWebAppFromDevBundle(appId);
updateMsg.innerText = result;
} else if (location.proxyOrigin) {
const {result}: {result: string} =
await webAppInternalsHandler.updateDevProxyIsolatedWebApp(appId);
updateMsg.innerText = result;
} else {
assertNotReached();
}
} finally {
updateBtn.innerText = oldText;
updateBtn.disabled = false;
}
};
buttonsSection.appendChild(updateBtn);
if (updateInfo) {
const switchChannelBtn = document.createElement('button');
switchChannelBtn.innerText = 'Switch channel';
switchChannelBtn.onclick = () => {
showSwitchChannelDialog(appId, name);
};
buttonsSection.appendChild(switchChannelBtn);
const pinnedVersionBtn = document.createElement('button');
pinnedVersionBtn.innerText = 'Pin To Version';
pinnedVersionBtn.onclick = () => {
showPinnedVersionDialog(appId, name);
};
buttonsSection.appendChild(pinnedVersionBtn);
const allowDowngradesToggle = document.createElement('input');
allowDowngradesToggle.type = 'checkbox';
allowDowngradesToggle.checked = updateInfo.allowDowngrades || false;
allowDowngradesToggle.id = 'allow-downgrades-toggle';
const allowDowngradesLabel = document.createElement('label');
allowDowngradesLabel.htmlFor = 'allow-downgrades-toggle';
allowDowngradesLabel.textContent = 'Allow downgrades';
allowDowngradesToggle.addEventListener('change', () => {
toggleAllowDowngrades(appId, allowDowngradesToggle.checked);
});
buttonsSection.appendChild(allowDowngradesToggle);
buttonsSection.appendChild(allowDowngradesLabel);
}
return {updateMsg, buttonsSection};
}
document.addEventListener('DOMContentLoaded', () => {
if (loadTimeData.getBoolean('isIwaPolicyInstallEnabled')) {
showIwaSection('iwa-updates-container');
}
if (loadTimeData.getBoolean('isIwaDevModeEnabled')) {
if (loadTimeData.getBoolean('isIwaKeyDistributionDevModeEnabled')) {
showIwaSection('iwa-kr-container');
}
showIwaSection('iwa-dev-container');
const devModeUpdatesMessage = getRequiredElement('iwa-dev-updates-message');
devModeUpdatesMessage.innerText = 'Loading...';
refreshDevModeAppList();
}
setTimeout(async () => {
getRequiredElement('json').innerText = await debugInfoAsJsonString;
}, 0);
});