blob: 9fea9f1c076a2d4aa04cf600e01249b91cd458c6 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {EventTracker} from '//resources/js/event_tracker.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {GlicRequestHeaderInjector} from '/shared/glic_request_headers.js';
import type {ChromeEvent} from '/tools/typescript/definitions/chrome_event.js';
import type {BrowserProxyImpl} from './browser_proxy.js';
import type {Subscriber} from './glic_api/glic_api.js';
import {DetailedWebClientState, GlicApiHost, WebClientState} from './glic_api_impl/glic_api_host.js';
import type {ApiHostEmbedder} from './glic_api_impl/glic_api_host.js';
import {ObservableValue} from './observable.js';
import type {ObservableValueReadOnly} from './observable.js';
import {OneShotTimer} from './timer.js';
// LINT.IfChange(WebviewExitReason)
enum WebviewExitReason {
NORMAL = 0,
ABNORMAL = 1,
CRASHED = 2,
KILLED = 3,
OOM_KILLED = 4,
OOM = 5,
FAILED_TO_LAUNCH = 6,
INTEGRITY_FAILURE = 7,
UNKNOWN = 8,
}
// LINT.ThenChange(//tools/metrics/histograms/metadata/glic/enums.xml:GlicWebviewExitReason)
const WEBVIEW_EXIT_REASON_MAP = {
'normal': WebviewExitReason.NORMAL,
'abnormal': WebviewExitReason.ABNORMAL,
'crashed': WebviewExitReason.CRASHED,
'killed': WebviewExitReason.KILLED,
'oom killed': WebviewExitReason.OOM_KILLED,
'oom': WebviewExitReason.OOM,
'failed to launch': WebviewExitReason.FAILED_TO_LAUNCH,
'integrity failure': WebviewExitReason.INTEGRITY_FAILURE,
};
function webviewExitReasonStringToEnum(reason: chrome.webviewTag.ExitReason):
WebviewExitReason {
return WEBVIEW_EXIT_REASON_MAP[reason] ?? WebviewExitReason.UNKNOWN;
}
export type PageType =
// A login page.
'login'
// A page that should be displayed.
|'regular'
// A error page that should be displayed.
|'guestError'
// An error page that indicates access loss.
|'guestCaaError'
// The page could not be loaded.
|'loadError';
// Calls from the webview to its owner.
export interface WebviewDelegate {
// Called when there is an error during page load.
webviewError(reason: string): void;
// Called when the embedded web page is unresponsive.
webviewUnresponsive(): void;
// Called when a page commits inside the webview.
webviewPageCommit(pageType: PageType): void;
// Called when the webview redirects to an access error page.
webviewDeniedByAdmin(): void;
}
// To match needed pieces of tools/typescript/definitions/web_request.d.ts,
// because this enum isn't actually available in this context.
enum ResourceType {
MAIN_FRAME = 'main_frame',
}
// State for the WebviewController which lives as long as the WebUI content.
// This is necessary because we may destroy and rebuild the WebviewController
// multiple times.
export class WebviewPersistentState {
// Normally, we load only the glicGuestURL. However, if that guest decides to
// navigate to a different URL after the client connects, we will remember
// that URL for loading later. To avoid getting stuck on a bad URL, we will
// allow using `loadUrl` only once unless a client successfully connects.
// Note that this supports internal development.
private loadUrl: string|undefined;
private loadUrlUsed = false;
useLoadUrl(): string {
if (this.loadUrl && !this.loadUrlUsed) {
this.loadUrlUsed = true;
return this.loadUrl;
} else {
return loadTimeData.getString('glicGuestURL');
}
}
onCommitAfterConnect(newUrl: string) {
this.loadUrl = newUrl;
this.loadUrlUsed = false;
}
onClientReady() {
// Web client became ready, allow loadUrl to be used again.
this.loadUrlUsed = false;
}
}
type ChromeEventFunctionType<T> =
T extends ChromeEvent<infer ListenerType>? ListenerType : never;
// Creates and manages the <webview> element, and the GlicApiHost which
// communicates with it.
export class WebviewController {
webview: chrome.webviewTag.WebView;
private host?: GlicApiHost;
private hostSubscriber?: Subscriber;
private onDestroy: Array<() => void> = [];
private eventTracker = new EventTracker();
private webClientState =
ObservableValue.withValue(WebClientState.UNINITIALIZED);
private oneMinuteTimer = new OneShotTimer(1000 * 60);
private glicRequestHeaderInjector: GlicRequestHeaderInjector;
constructor(
private readonly container: HTMLElement,
private browserProxy: BrowserProxyImpl,
private delegate: WebviewDelegate,
private hostEmbedder: ApiHostEmbedder,
private persistentState: WebviewPersistentState,
) {
this.webview =
document.createElement('webview') as chrome.webviewTag.WebView;
this.glicRequestHeaderInjector = new GlicRequestHeaderInjector(
this.webview, loadTimeData.getString('chromeVersion'),
loadTimeData.getString('chromeChannel'),
loadTimeData.getString('glicHeaderRequestTypes'));
// Intercept all main frame requests, and block them if they are not allowed
// origins.
const onBeforeRequest = this.onBeforeRequest.bind(this);
this.webview.request.onBeforeRequest.addListener(
onBeforeRequest, {
types: [ResourceType.MAIN_FRAME],
urls: ['<all_urls>'],
},
['blocking']);
this.onDestroy.push(() => {
this.webview.request.onBeforeRequest.removeListener(onBeforeRequest);
});
this.webview.id = 'guestFrame';
this.webview.setAttribute('partition', 'persist:glicpart');
this.container.appendChild(this.webview);
this.eventTracker.add(
this.webview, 'loadcommit', this.onLoadCommit.bind(this));
this.eventTracker.add(
this.webview, 'contentload', this.contentLoaded.bind(this));
this.eventTracker.add(this.webview, 'loadstop', this.onLoadStop.bind(this));
this.eventTracker.add(
this.webview, 'newwindow', this.onNewWindow.bind(this));
this.eventTracker.add(
this.webview, 'permissionrequest', this.onPermissionRequest.bind(this));
this.eventTracker.add(
this.webview, 'unresponsive', this.onUnresponsive.bind(this));
this.eventTracker.add(this.webview, 'exit', this.onExit.bind(this) as any);
this.webview.src = this.persistentState.useLoadUrl();
this.oneMinuteTimer.start(() => {
if (this.host) {
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Host.WebClientState.AtOneMinute',
this.host.getDetailedWebClientState(),
DetailedWebClientState.MAX_VALUE + 1);
}
});
}
getWebClientState(): ObservableValueReadOnly<WebClientState> {
return this.webClientState;
}
destroy() {
this.glicRequestHeaderInjector.destroy();
this.oneMinuteTimer.reset();
if (this.host) {
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Host.WebClientState.OnDestroy',
this.host.getDetailedWebClientState(),
DetailedWebClientState.MAX_VALUE + 1);
}
this.destroyHost(
this.webClientState.getCurrentValue() === WebClientState.ERROR ?
WebClientState.ERROR :
WebClientState.UNINITIALIZED);
this.eventTracker.removeAll();
this.onDestroy.forEach(f => f());
this.onDestroy = [];
this.webview.remove();
}
private destroyHost(webClientState: WebClientState) {
if (this.hostSubscriber) {
this.hostSubscriber.unsubscribe();
this.hostSubscriber = undefined;
}
if (this.host) {
this.host.destroy();
this.host = undefined;
}
this.webClientState.assignAndSignal(webClientState);
}
waitingOnPanelWillOpen(): boolean {
return this.host?.waitingOnPanelWillOpen() ?? false;
}
onLoadTimeOut(): void {
if (this.host) {
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Host.WebClientState.OnLoadTimeOut',
this.host.getDetailedWebClientState(),
DetailedWebClientState.MAX_VALUE + 1);
}
}
private onLoadCommit(e: any): void {
this.loadCommit(e.url, e.isTopLevel);
}
private onLoadStop(): void {
this.webview.focus();
}
private onNewWindow(e: Event): void {
this.onNewWindowEvent(e as chrome.webviewTag.NewWindowEvent);
}
private async onPermissionRequest(e: any): Promise<void> {
e.preventDefault();
if (!this.host) {
e.request.deny();
return;
}
switch (e.permission) {
case 'media': {
// TODO(b/416092165): Block mic requests if the mic permission was not
// granted.
e.request.allow();
return;
}
case 'geolocation': {
const isGeolocationAllowed =
await this.host.shouldAllowGeolocationPermissionRequest();
if (isGeolocationAllowed) {
e.request.allow();
} else {
e.request.deny();
}
return;
}
}
console.warn(`Webview permission request was denied: ${e.permission}`);
e.request.deny();
}
private onUnresponsive(): void {
this.delegate.webviewUnresponsive();
}
private onExit: ChromeEventFunctionType<typeof chrome.webviewTag.exit> =
(event) => {
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Session.WebClientCrash.ExitReason',
webviewExitReasonStringToEnum(event.reason),
Object.keys(WEBVIEW_EXIT_REASON_MAP).length);
if (event.reason !== 'normal') {
this.destroyHost(WebClientState.ERROR);
chrome.metricsPrivate.recordUserAction('GlicSessionWebClientCrash');
console.warn(`webview exit. processID: ${event.processID}, reason: ${
event.reason}`);
}
};
private loadCommit(url: string, isTopLevel: boolean) {
if (!isTopLevel) {
return;
}
if (this.host) {
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Host.WebClientState.OnCommit',
this.host.getDetailedWebClientState(),
DetailedWebClientState.MAX_VALUE + 1);
}
const wasResponsive = this.getWebClientState().getCurrentValue() ===
WebClientState.RESPONSIVE;
this.destroyHost(WebClientState.UNINITIALIZED);
const origin = new URL(url).origin;
if (this.webview.contentWindow && origin !== 'null') {
this.host = new GlicApiHost(
this.browserProxy, this.webview.contentWindow, origin,
this.hostEmbedder);
this.hostSubscriber = this.host.getWebClientState().subscribe(state => {
if (state === WebClientState.RESPONSIVE) {
this.persistentState.onClientReady();
}
this.webClientState.assignAndSignal(state);
});
}
this.browserProxy.handler.webviewCommitted({url});
if (!this.host) {
this.delegate.webviewPageCommit('loadError');
return;
}
// TODO(https://crbug.com/388328847): Remove when login issues are
// resolved.
if (url.startsWith('https://login.corp.google.com/') ||
url.startsWith('https://accounts.google.com/') ||
url.startsWith('https://accounts.googlers.com/') ||
url.startsWith('https://gaiastaging.corp.google.com/')) {
this.delegate.webviewPageCommit('login');
} else if (new URL(url).pathname.startsWith('/sorry/')) {
this.delegate.webviewPageCommit('guestError');
} else {
if (wasResponsive) {
this.persistentState.onCommitAfterConnect(url);
}
// This forces the page to reload after navigation.
// TODO(b/439718538): revisit overall logic, this may be buggy.
if (loadTimeData.getBoolean('reloadAfterNavigation')) {
this.delegate.webviewPageCommit('regular');
}
}
}
private contentLoaded() {
if (this.host) {
this.host.contentLoaded();
}
}
private onNewWindowEvent(event: chrome.webviewTag.NewWindowEvent) {
if (!this.host) {
return;
}
event.preventDefault();
this.host.openLinkInNewTab(event.targetUrl);
event.stopPropagation();
}
private urlMatchesAdminBlockedUrl(url: string) {
const adminBlockedRedirectPatterns =
loadTimeData.getString('adminBlockedRedirectPatterns');
if (!adminBlockedRedirectPatterns) {
return false;
}
if (adminBlockedRedirectPatterns.split(' ').some(
pattern => new URLPattern(pattern.trim()).test(url))) {
console.warn(`Admin blocked error page detected.`);
return true;
}
return false;
}
private onBeforeRequest:
ChromeEventFunctionType<typeof chrome.webRequest.onBeforeRequest> =
(details) => {
// Allow subframe requests.
if (details.frameId !== 0) {
return {};
}
if (this.urlMatchesAdminBlockedUrl(details.url)) {
this.delegate.webviewDeniedByAdmin();
return {cancel: true};
}
return {cancel: !urlMatchesAllowedOrigin(details.url)};
};
}
/**
* Returns a URLPattern given an origin pattern string that has the syntax:
* <protocol>://<hostname>[:<port>]
* where <protocol>, <hostname> and <port> are inserted into URLPattern.
*/
export function matcherForOrigin(originPattern: string): URLPattern|null {
// This regex is overly permissive in what characters can exist in protocol
// or hostname. This isn't a problem because we're just passing data to
// URLPattern.
const match = originPattern.match(/([^:]+):\/\/([^:]*)(?::(\d+))?[/]?/);
if (!match) {
return null;
}
const [protocol, hostname, port] = [match[1], match[2], match[3] ?? '*'];
try {
return new URLPattern({protocol, hostname, port});
} catch (_) {
return null;
}
}
export function urlMatchesAllowedOrigin(url: string) {
// For development.
if (loadTimeData.getBoolean('devMode')) {
return true;
}
// A URL is allowed if it either matches glicGuestURL's origin, or it matches
// any of the approved origins.
const defaultUrl = new URL(loadTimeData.getString('glicGuestURL'));
if (matcherForOrigin(defaultUrl.origin)?.test(url)) {
return true;
}
return loadTimeData.getString('glicAllowedOrigins')
.split(' ')
.some(origin => matcherForOrigin(origin.trim())?.test(url));
}