blob: dc4aa2d0f1d544d986b60ad7b26b5c6ca82207d2 [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 {getRequiredElement} from 'chrome://resources/js/util.js';
import {FrePageHandlerFactory, FrePageHandlerRemote, FreWebUiState} from './glic_fre.mojom-webui.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');
// Initial FRE width. Also used as the minimum and maximum width for FRE.
const INITIAL_WIDTH = loadTimeData.getInteger('freInitialWidth');
// Minimum height for FRE.
const MIN_HEIGHT = 200;
interface PageElementTypes {
webviewContainer: HTMLDivElement;
}
const $: PageElementTypes = new Proxy({}, {
get(_target: any, prop: string) {
return getRequiredElement(prop);
},
});
type PanelId = 'guestPanel'|'offlinePanel'|'errorPanel'|'loadingPanel';
interface StateDescriptor {
onEnter?: () => void;
onExit?: () => void;
}
const freHandler = new FrePageHandlerRemote();
FrePageHandlerFactory.getRemote().createPageHandler(
(freHandler).$.bindNewPipeAndPassReceiver());
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();
// 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;
constructor() {
this.onLoadCommit = this.onLoadCommit.bind(this);
this.onContentLoad = this.onContentLoad.bind(this);
this.onNewWindow = this.onNewWindow.bind(this);
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 = document.querySelectorAll('.close-button');
for (const button of buttons) {
button.addEventListener('click', () => {
freHandler.dismissFre();
});
}
document.getElementById('reload')?.addEventListener('click', () => {
this.reload();
});
});
document.addEventListener('keydown', ev => {
if (ev.code === 'Escape') {
ev.stopPropagation();
ev.preventDefault();
freHandler.dismissFre();
}
});
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;
// 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') {
freHandler.acceptFre();
} else if (urlHash === '#noThanks') {
freHandler.dismissFre();
}
}
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();
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.setState(FreWebUiState.kBeginLoading);
}
private showPanel(id: PanelId): void {
for (const panel of document.querySelectorAll<HTMLElement>('.panel')) {
panel.hidden = panel.id !== id;
}
}
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);
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('errorPanel');
},
},
],
[
FreWebUiState.kOffline,
{
onEnter: () => {
this.useReloadTimeout = false;
this.destroyWebview();
this.showPanel('offlinePanel');
},
},
],
[
FreWebUiState.kReady,
{
onEnter: () => {
this.useReloadTimeout = false;
this.showPanel('guestPanel');
},
},
],
]);
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 freHandler.prepareForClient();
if (!success) {
this.setState(FreWebUiState.kError);
return;
}
// Load the web client now that cookie sync is complete.
this.destroyWebview();
this.webview.src = loadTimeData.getString('glicFreURL');
this.loadingTimer = setTimeout(() => {
this.setState(FreWebUiState.kShowLoading);
}, Math.max(0, showLoadingTime - performance.now()));
}
showLoading(): void {
this.showPanel('loadingPanel');
// 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');
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', 'glicfrepart');
webview.setAttribute('autosize', 'true');
webview.setAttribute('minwidth', INITIAL_WIDTH.toString());
webview.setAttribute('maxwidth', INITIAL_WIDTH.toString());
webview.setAttribute('minheight', MIN_HEIGHT.toString());
webview.setAttribute('maxheight', window.screen.availHeight.toString());
$.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, 'newwindow', this.onNewWindow.bind(this));
this.webviewEventTracker.add(
webview, 'sizechanged', this.onSizeChanged.bind(this));
return webview;
}
// 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();
$.webviewContainer.removeChild(this.webview);
this.webview = this.createWebview();
}
}