blob: f2b4486d3f4f04bb00fb640572686b2455c0de9e [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 {assert} from 'chrome://resources/js/assert.js';
import {getRequiredElement} from 'chrome://resources/js/util.js';
import {FrePageHandlerFactory, FrePageHandlerRemote, FreWebUiState} from './glic_fre.mojom-webui.js';
import {GlicFreWebviewLoadAbortReason} from './metrics_enums.js';
// Time to wait before showing loading panel.
const PRE_HOLD_LOADING_TIME_MS = loadTimeData.getInteger('preLoadingTimeMs');
// Minimum time to hold "loading" panel visible.
const MIN_HOLD_LOADING_TIME_MS = loadTimeData.getInteger('minLoadingTimeMs');
// Maximum time to wait for load before showing error panel.
const MAX_WAIT_TIME_MS = loadTimeData.getInteger('maxLoadingTimeMs');
// Maximum time to wait for load before showing error panel following a
// user-initiated reload.
const RELOAD_MAX_WAIT_TIME_RELOAD_MS =
loadTimeData.getInteger('reloadMaxLoadingTimeMs');
// Minimum height for FRE.
const MIN_HEIGHT = 200;
export enum FreResultType {
ACCEPT,
DISMISS,
REJECT,
}
export interface FreResult {
type: FreResultType;
}
interface FreControllerOptions {
partitionString?: string;
shouldSizeForDialog?: boolean;
onClose?: () => void;
}
type PanelId = 'freGuestPanel'|'freOfflinePanel'|'freErrorPanel'|
'freLoadingPanel'|'freDisabledByAdminPanel';
interface StateDescriptor {
onEnter?: () => void;
onExit?: () => void;
}
export class FreAppController {
state = FreWebUiState.kUninitialized;
loadingTimer: number|undefined;
// Created from constructor and never null since the destructor replaces it
// with an empty <webview>.
private webview: chrome.webviewTag.WebView;
private webviewEventTracker = new EventTracker();
private glicRequestHeaderInjector: GlicRequestHeaderInjector|undefined;
private freHandler: FrePageHandlerRemote;
// When entering loading state, this represents the earliest timestamp at
// which the UI can transition to the ready state. This ensures that the
// loading UI isn't just a brief flash on screen.
private earliestLoadingDismissTime: number|undefined;
// This is set when the "Try again" button in the error state is pressed,
// indicating that a different timeout value should be used for the
// subsequent content load. This value is reset when we the associated
// content load ends.
private useReloadTimeout = false;
// Unified FRE variables.
private freContainer: HTMLElement;
private webviewContainer: HTMLElement;
private partitionString: string;
private shouldSizeForDialog: boolean;
private onCloseCallback?: () => void;
constructor(options: FreControllerOptions = {}) {
this.onLoadCommit = this.onLoadCommit.bind(this);
this.onContentLoad = this.onContentLoad.bind(this);
this.onNewWindow = this.onNewWindow.bind(this);
const container = getRequiredElement('fre-app-container');
assert(container, '#fre-app-container not found in constructor');
this.freContainer = container!;
this.freHandler = new FrePageHandlerRemote();
FrePageHandlerFactory.getRemote().createPageHandler(
this.freHandler.$.bindNewPipeAndPassReceiver());
this.webviewContainer = getRequiredElement('freWebviewContainer')!;
assert(
this.webviewContainer, '#freWebviewContainer not found in constructor');
this.partitionString = options.partitionString ?? 'glicfrepart';
this.shouldSizeForDialog = options.shouldSizeForDialog ?? true;
this.onCloseCallback = options.onClose;
this.webview = this.createWebview();
window.addEventListener('online', () => {
this.online();
});
window.addEventListener('offline', () => {
this.offline();
});
window.addEventListener('load', () => {
// Allow WebUI close buttons to close the window. Close buttons are
// present on all UI states except for `FreWebUiState.kReady`.
const buttons = this.freContainer.querySelectorAll('.close-button');
for (const button of buttons) {
const parentPanel = button.closest('.panel');
if (parentPanel) {
button.addEventListener('click', () => {
chrome.metricsPrivate.recordUserAction('Glic.Fre.CloseWithX');
this.dismissFre(this.panelIdToEnum(parentPanel.id));
});
}
}
const disabledByAdminButton =
getRequiredElement('freDisabledByAdminCloseButton');
assert(disabledByAdminButton);
const parentPanel = disabledByAdminButton.closest('.panel');
assert(parentPanel);
disabledByAdminButton.addEventListener('click', () => {
chrome.metricsPrivate.recordUserAction(
'Glic.Fre.DisabledByAdminPanelCloseButton');
this.dismissFre(this.panelIdToEnum(parentPanel.id));
});
const disabledByAdminLink =
this.freContainer.querySelector<HTMLAnchorElement>(
'#freDisabledByAdminPanel a');
assert(disabledByAdminLink);
disabledByAdminLink.addEventListener(
'click', (e) => {
e.preventDefault();
chrome.metricsPrivate.recordUserAction(
'Glic.Fre.DisabledByAdminPanelLinkClicked');
this.freHandler.validateAndOpenLinkInNewTab({
url: (e.target as HTMLAnchorElement).href,
});
e.stopPropagation();
});
getRequiredElement('fre-reload')?.addEventListener('click', () => {
this.reload();
});
});
this.freContainer.addEventListener('keydown', (ev: KeyboardEvent) => {
if (ev.code === 'Escape') {
ev.stopPropagation();
ev.preventDefault();
const visiblePanel = this.freContainer.querySelector<HTMLElement>(
'.panel:not([hidden])');
if (visiblePanel) {
chrome.metricsPrivate.recordUserAction('Glic.Fre.CloseWithEsc');
this.dismissFre(this.panelIdToEnum(visiblePanel.id));
}
}
});
if (navigator.onLine) {
this.setState(FreWebUiState.kBeginLoading);
} else {
this.setState(FreWebUiState.kOffline);
}
}
onLoadCommit(e: any) {
if (!e.isTopLevel) {
return;
}
const url = new URL(e.url);
const urlHash = url.hash;
if (loadTimeData.getBoolean('caaGuestError') &&
(url.hostname === 'access.workspace.google.com' ||
url.hostname === 'admin.google.com')) {
this.setState(FreWebUiState.kDisabledByAdmin);
return;
}
// Fragment navigations are used to represent actions taken in the web
// client following this mapping: “Continue” button navigates to
// glic/intro...#continue, “No thanks” button navigates to
// glic/intro...#noThanks
if (urlHash === '#continue') {
this.acceptFre();
} else if (urlHash.startsWith('#noThanks')) {
const source = url.searchParams.get('source');
if (source === 'x_button') {
chrome.metricsPrivate.recordUserAction(`Glic.Fre.CloseWithX`);
this.dismissFre(FreWebUiState.kReady);
} else {
this.rejectFre();
}
}
}
onContentLoad() {
if (this.state === FreWebUiState.kBeginLoading ||
this.state === FreWebUiState.kFinishLoading) {
this.setState(FreWebUiState.kReady);
} else if (this.state === FreWebUiState.kShowLoading) {
this.setState(FreWebUiState.kHoldLoading);
}
}
onNewWindow(e: any) {
e.preventDefault();
this.freHandler.validateAndOpenLinkInNewTab({
url: e.targetUrl,
});
e.stopPropagation();
}
online(): void {
if (this.state !== FreWebUiState.kOffline) {
return;
}
this.setState(FreWebUiState.kBeginLoading);
}
offline(): void {
const allowedStates = [
FreWebUiState.kBeginLoading,
FreWebUiState.kShowLoading,
FreWebUiState.kFinishLoading,
];
if (allowedStates.includes(this.state)) {
this.setState(FreWebUiState.kOffline);
}
}
// Called when the "Try again" button is clicked.
reload(): void {
this.destroyWebview();
this.useReloadTimeout = true;
this.freHandler.freReloaded();
this.setState(FreWebUiState.kBeginLoading);
}
private showPanel(id: PanelId): void {
for (const panel of this.freContainer.querySelectorAll<HTMLElement>(
'.panel')) {
panel.hidden = panel.id !== id;
}
// After making the guest panel visible, programmatically move focus
// to the content inside the webview. This ensures that screen readers
// announce the new content.
if (id === 'freGuestPanel') {
this.webview.focus();
}
}
setState(newState: FreWebUiState): void {
if (this.state === newState) {
return;
}
if (this.state) {
this.states.get(this.state)!.onExit?.call(this);
this.cancelTimeout();
}
this.state = newState;
this.states.get(this.state)!.onEnter?.call(this);
this.freHandler.webUiStateChanged(newState);
}
readonly states: Map<FreWebUiState, StateDescriptor> = new Map([
[
FreWebUiState.kBeginLoading,
{onEnter: this.beginLoading},
],
[
FreWebUiState.kShowLoading,
{onEnter: this.showLoading},
],
[
FreWebUiState.kHoldLoading,
{onEnter: this.holdLoading},
],
[
FreWebUiState.kFinishLoading,
{onEnter: this.finishLoading},
],
[
FreWebUiState.kError,
{
onEnter: () => {
this.useReloadTimeout = false;
this.destroyWebview();
this.showPanel('freErrorPanel');
},
},
],
[
FreWebUiState.kDisabledByAdmin,
{
onEnter: () => {
this.destroyWebview();
this.showPanel('freDisabledByAdminPanel');
},
},
],
[
FreWebUiState.kOffline,
{
onEnter: () => {
this.useReloadTimeout = false;
this.destroyWebview();
this.showPanel('freOfflinePanel');
},
},
],
[
FreWebUiState.kReady,
{
onEnter: () => {
this.useReloadTimeout = false;
this.showPanel('freGuestPanel');
},
},
],
]);
cancelTimeout(): void {
if (this.loadingTimer) {
clearTimeout(this.loadingTimer);
this.loadingTimer = undefined;
}
}
async beginLoading(): Promise<void> {
// Time at which to show the loading panel if the web client is not ready.
const showLoadingTime = performance.now() + PRE_HOLD_LOADING_TIME_MS;
// Attempt to re-sync cookies before continuing.
const {success} = await this.freHandler.prepareForClient();
if (!success) {
this.setState(FreWebUiState.kError);
return;
}
// Load the web client now that cookie sync is complete.
this.destroyWebview();
// Signal to the fre controller that the web ui framework has completed
// loading and the remote web content is about to start loading in the
// webview. This is used to record timing metrics.
this.freHandler.logWebUiLoadComplete();
const glicFreURL = new URL(loadTimeData.getString('glicFreURL'));
// If `shouldSizeForDialog` is false, this indicates the side panel context.
// Append a query parameter to notify the webview of this context.
if (!this.shouldSizeForDialog) {
glicFreURL.searchParams.append('sidepanelFre', 'true');
}
this.webview.src = glicFreURL.toString();
this.loadingTimer = setTimeout(() => {
this.setState(FreWebUiState.kShowLoading);
}, Math.max(0, showLoadingTime - performance.now()));
}
showLoading(): void {
this.showPanel('freLoadingPanel');
// After `kMinHoldLoadingTimeMs`, transition to `kFinishLoading` or `kReady`
// states. Note that we never transition from `kShowLoading` to `kReady`
// before the timeout.
this.earliestLoadingDismissTime =
performance.now() + MIN_HOLD_LOADING_TIME_MS;
this.loadingTimer = setTimeout(() => {
this.setState(FreWebUiState.kFinishLoading);
}, MIN_HOLD_LOADING_TIME_MS);
}
holdLoading(): void {
// The web client is ready but we wait for the remainder of
// `kMinHoldLoadingTimeMs` before showing it. This is to allow enough time
// to view the loading animation.
this.loadingTimer = setTimeout(() => {
this.setState(FreWebUiState.kReady);
}, Math.max(0, this.earliestLoadingDismissTime! - performance.now()));
}
finishLoading(): void {
// The web client is not yet ready, so wait for the remainder of
// `kMaxWaitTimeMs`. If a reload initiated by the user is being processed,
// this max time is increased. Switch to the error state at that time
// unless interrupted by `onContentLoad`, triggering the ready state.
const timeoutValue = this.useReloadTimeout ?
RELOAD_MAX_WAIT_TIME_RELOAD_MS :
MAX_WAIT_TIME_MS;
this.loadingTimer = setTimeout(() => {
console.warn('Exceeded timeout in finishLoading');
chrome.metricsPrivate.recordUserAction('Glic.Fre.WebviewLoadTimedOut');
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Fre.WebviewLoadAbortReason',
GlicFreWebviewLoadAbortReason.ERR_TIMED_OUT,
GlicFreWebviewLoadAbortReason.MAX_VALUE + 1);
this.freHandler.exceededTimeoutError();
this.setState(FreWebUiState.kError);
}, timeoutValue - MIN_HOLD_LOADING_TIME_MS);
}
onSizeChanged(e: any): void {
window.resizeTo(e.newWidth, e.newHeight);
}
private createWebview(): chrome.webviewTag.WebView {
const webview =
document.createElement('webview') as chrome.webviewTag.WebView;
webview.id = 'freGuestFrame';
// TODO(crbug.com/408475473): Update the webviewTag definition to be able to
// define properties rather than using setAttribute.
webview.setAttribute('partition', this.partitionString);
webview.setAttribute('autosize', 'true');
if (this.shouldSizeForDialog) {
webview.setAttribute(
'minwidth', loadTimeData.getInteger('freInitialWidth').toString());
webview.setAttribute(
'maxwidth', loadTimeData.getInteger('freInitialWidth').toString());
webview.setAttribute('minheight', MIN_HEIGHT.toString());
webview.setAttribute('maxheight', window.screen.availHeight.toString());
}
this.glicRequestHeaderInjector = new GlicRequestHeaderInjector(
webview, loadTimeData.getString('chromeVersion'),
loadTimeData.getString('chromeChannel'),
loadTimeData.getString('glicHeaderRequestTypes'));
this.webviewContainer.appendChild(webview);
this.webviewEventTracker.add(
webview, 'loadcommit', this.onLoadCommit.bind(this));
this.webviewEventTracker.add(
webview, 'contentload', this.onContentLoad.bind(this));
this.webviewEventTracker.add(
webview, 'loadabort', this.onLoadAbort.bind(this));
this.webviewEventTracker.add(
webview, 'newwindow', this.onNewWindow.bind(this));
this.webviewEventTracker.add(
webview, 'sizechanged', this.onSizeChanged.bind(this));
return webview;
}
private reasonStringToEnum(reason: string|undefined):
GlicFreWebviewLoadAbortReason {
switch (reason) {
case 'ERR_ABORTED':
return GlicFreWebviewLoadAbortReason.ERR_ABORTED;
case 'ERR_INVALID_URL':
return GlicFreWebviewLoadAbortReason.ERR_INVALID_URL;
case 'ERR_DISALLOWED_URL_SCHEME':
return GlicFreWebviewLoadAbortReason.ERR_DISALLOWED_URL_SCHEME;
case 'ERR_BLOCKED_BY_CLIENT':
return GlicFreWebviewLoadAbortReason.ERR_BLOCKED_BY_CLIENT;
case 'ERR_ADDRESS_UNREACHABLE':
return GlicFreWebviewLoadAbortReason.ERR_ADDRESS_UNREACHABLE;
case 'ERR_EMPTY_RESPONSE':
return GlicFreWebviewLoadAbortReason.ERR_EMPTY_RESPONSE;
case 'ERR_FILE_NOT_FOUND':
return GlicFreWebviewLoadAbortReason.ERR_FILE_NOT_FOUND;
case 'ERR_UNKNOWN_URL_SCHEME':
return GlicFreWebviewLoadAbortReason.ERR_UNKNOWN_URL_SCHEME;
case 'ERR_TIMED_OUT':
return GlicFreWebviewLoadAbortReason.ERR_TIMED_OUT;
case 'ERR_HTTP_RESPONSE_CODE_FAILURE':
return GlicFreWebviewLoadAbortReason.ERR_HTTP_RESPONSE_CODE_FAILURE;
default:
return GlicFreWebviewLoadAbortReason.UNKNOWN;
}
}
private onLoadAbort(e: any) {
const reasonEnum = this.reasonStringToEnum(e.reason);
chrome.metricsPrivate.recordUserAction('Glic.Fre.WebviewLoadAborted');
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Fre.WebviewLoadAbortReason', reasonEnum,
GlicFreWebviewLoadAbortReason.MAX_VALUE + 1);
this.setState(FreWebUiState.kError);
}
private panelIdToEnum(panelId: string): FreWebUiState {
switch (panelId) {
case 'freGuestPanel':
return FreWebUiState.kReady;
case 'freOfflinePanel':
return FreWebUiState.kOffline;
case 'freErrorPanel':
return FreWebUiState.kError;
case 'freLoadingPanel':
return FreWebUiState.kShowLoading;
case 'freDisabledByAdminPanel':
return FreWebUiState.kDisabledByAdmin;
default:
return FreWebUiState.kUninitialized;
}
}
// Destroy the current webview and create a new one. This is necessary because
// webview does not support unloading content by setting src=""
destroyWebview(): void {
this.webviewEventTracker.removeAll();
if (this.glicRequestHeaderInjector) {
this.glicRequestHeaderInjector.destroy();
this.glicRequestHeaderInjector = undefined;
}
this.webviewContainer.removeChild(this.webview);
this.webview = this.createWebview();
}
private dismissFre(state: FreWebUiState): void {
this.freHandler.dismissFre(state);
this.onCloseCallback?.();
}
private acceptFre(): void {
this.freHandler.acceptFre();
}
private rejectFre(): void {
this.freHandler.rejectFre();
this.onCloseCallback?.();
}
}