blob: ba735d85b84117df1c17a422332692e39af0afc7 [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 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';
export type PageType =
// A login page.
'login'
// A page that should be displayed.
|'regular'
// A error page that should be displayed.
|'guestError';
// 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;
}
// 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);
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;
// 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);
});
const onBeforeSendHeaders = this.onBeforeSendHeaders.bind(this);
this.webview.request.onBeforeSendHeaders.addListener(
onBeforeSendHeaders, {
types: [ResourceType.MAIN_FRAME],
urls: ['<all_urls>'],
},
['blocking', 'requestHeaders']);
this.onDestroy.push(() => {
this.webview.request.onBeforeSendHeaders.removeListener(
onBeforeSendHeaders);
});
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));
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.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;
}
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(crbug.com/409118577): Block mic requests if the mic permission
// is not enabled.
e.request.allow();
return;
}
case 'geolocation': {
const isGeolocationAllowed =
await this.host.shouldAllowGeolocationPermissionRequest();
if (isGeolocationAllowed) {
e.request.allow();
} else {
e.request.deny();
}
return;
}
}
e.request.deny();
}
private onUnresponsive(): void {
this.delegate.webviewUnresponsive();
}
private onExit(e: any): void {
if (e.reason !== 'normal') {
this.destroyHost(WebClientState.ERROR);
chrome.metricsPrivate.recordUserAction('GlicSessionWebClientCrash');
console.warn(`webview exit. reason: ${e.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);
if (this.webview.contentWindow) {
this.host = new GlicApiHost(
this.browserProxy, this.webview.contentWindow, new URL(url).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});
// 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.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 onBeforeRequest:
ChromeEventFunctionType<typeof chrome.webRequest.onBeforeRequest> =
(details) => {
// Allow subframe requests.
if (details.frameId !== 0) {
return {};
}
return {cancel: !urlMatchesAllowedOrigin(details.url)};
};
// Attaches the X-Glic headers to all main-frame requests.
// X-Glic: 1
// X-Glic-Chrome-Channel: stable
// X-Glic-Chrome-Version: 137.0.1234.0
private onBeforeSendHeaders:
ChromeEventFunctionType<typeof chrome.webRequest.onBeforeSendHeaders> =
(details) => {
// Ignore subframe requests.
if (details.frameId !== 0) {
return {};
}
const requestHeaders = details.requestHeaders || [];
requestHeaders.push({
name: 'X-Glic',
value: '1',
});
requestHeaders.push({
name: 'X-Glic-Chrome-Version',
value: loadTimeData.getString('chromeVersion'),
});
requestHeaders.push({
name: 'X-Glic-Chrome-Channel',
value: loadTimeData.getString('chromeChannel'),
});
return {requestHeaders};
};
}
/**
* 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));
}