blob: f39b07700ed9b87fc27563afcbacfb594dbbdeae [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 {loadTimeData} from '//resources/js/load_time_data.js';
import {getRequiredElement} from 'chrome://resources/js/util.js';
import {BrowserProxyImpl} from './browser_proxy.js';
import {PrepareForClientResult, ProfileReadyState, WebUiState} from './glic.mojom-webui.js';
import type {PageInterface} from './glic.mojom-webui.js';
import type {ApiHostEmbedder} from './glic_api_impl/glic_api_host.js';
import {WebClientState} from './glic_api_impl/glic_api_host.js';
import type {PageType, WebviewDelegate} from './webview.js';
import {WebviewController, WebviewPersistentState} from './webview.js';
const transitionDuration = {
microseconds: BigInt(100000),
};
// Time to wait before showing loading panel.
const kPreHoldLoadingTimeMs = loadTimeData.getInteger('preLoadingTimeMs');
// Minimum time to hold "loading" panel visible.
const kMinHoldLoadingTimeMs = loadTimeData.getInteger('minLoadingTimeMs');
// Maximum time to wait for load before showing error panel.
const kMaxWaitTimeMs = loadTimeData.getInteger('maxLoadingTimeMs');
// Whether to enable the debug button on the error panel. Can be enabled with
// the --enable-features=GlicDebugWebview command-line flag.
const kEnableDebug = loadTimeData.getBoolean('enableDebug');
// Whether additional web client unresponsiveness tracking metrics should be
// recorded.
const kEnableUnresponsiveMetrics =
loadTimeData.getBoolean('enableWebClientUnresponsiveMetrics');
interface PageElementTypes {
panelContainer: HTMLElement;
loadingPanel: HTMLElement;
offlinePanel: HTMLElement;
errorPanel: HTMLElement;
unavailablePanel: HTMLElement;
disabledByAdminPanel: HTMLElement;
signInPanel: HTMLElement;
guestPanel: HTMLElement;
webviewHeader: HTMLDivElement;
webviewContainer: HTMLDivElement;
profilePickerButton: HTMLButtonElement;
disabledByAdminCloseButton: HTMLButtonElement;
signInButton: HTMLButtonElement;
unresponsiveOverlay: HTMLElement;
}
const $: PageElementTypes = new Proxy({}, {
get(_target: any, prop: string) {
return getRequiredElement(prop);
},
});
type PanelId = 'loadingPanel'|'guestPanel'|'offlinePanel'|'errorPanel'|
'unavailablePanel'|'disabledByAdminPanel'|'signInPanel';
interface StateDescriptor {
onEnter?: () => void;
onExit?: () => void;
// Whether to try to reload the webview on open while in this state.
reloadOnOpen?: boolean;
}
// Web client unresponsiveness state tracking values for metrics reporting.
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
//
// LINT.IfChange(WebClientUnresponsiveState)
export enum WebClientUnresponsiveState {
ENTERED_FROM_WEBVIEW_EVENT = 0,
ENTERED_FROM_CUSTOM_HEARTBEAT = 1,
ALREADY_ON_FROM_WEBVIEW_EVENT = 2,
ALREADY_ON_FROM_CUSTOM_HEARTBEAT = 3,
EXITED = 4,
MAX_VALUE = EXITED,
}
// LINT.ThenChange(//tools/metrics/histograms/metadata/glic/enums.xml:WebClientUnresponsiveState)
// Enum for specific stages of loading the web client, reported if loading times
// out.
// LINT.IfChange(LoadingStage)
export enum LoadingStage {
NOT_LOADING = 0,
AWAITING_PROFILE_READY = 1,
AWAITING_COOKIE_SYNC = 2,
LOADING_WEB_CLIENT = 3,
AWAITING_NOTIFY_PANEL_WILL_OPEN = 4,
MAX_VALUE = AWAITING_NOTIFY_PANEL_WILL_OPEN,
}
// LINT.ThenChange(//tools/metrics/histograms/metadata/glic/enums.xml:LoadingStage,//tools/metrics/histograms/metadata/glic/histograms.xml:LoadingStage)
export class GlicAppController implements PageInterface, WebviewDelegate,
ApiHostEmbedder {
loadingTimer: number|undefined;
// This is used to simulate no connection for tests.
private simulateNoConnection: boolean =
loadTimeData.getBoolean('simulateNoConnection');
private guestResizeEnabled: boolean = false;
// Width for non-resizable panel.
private defaultWidth: number = 352;
// Last seen width and height of guest panel.
private lastWidth: number = 400;
private lastHeight: number = 80;
// Present only when loading or after loading is finished. Removed on error.
private webview?: WebviewController;
private webviewPersistentState = new WebviewPersistentState();
private profileReadyState: ProfileReadyState|undefined = undefined;
private profileReadyInitialState = Promise.withResolvers<void>();
private enteredUnresponsiveTimestampMs?: number;
// Loading stage, affects metrics only.
private loadingStage: LoadingStage = LoadingStage.NOT_LOADING;
private loadingStageStartTimestampMs?: DOMHighResTimeStamp;
state: WebUiState|undefined;
// 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;
browserProxy: BrowserProxyImpl;
constructor() {
this.browserProxy = new BrowserProxyImpl(this);
window.addEventListener('online', () => {
this.online();
});
window.addEventListener('offline', () => {
this.offline();
});
if (navigator.onLine && !this.simulateNoConnection) {
this.setState(WebUiState.kBeginLoad);
} else {
this.setState(WebUiState.kOffline);
}
$.profilePickerButton.addEventListener('click', () => {
this.openProfilePicker();
});
$.disabledByAdminCloseButton.addEventListener('click', () => {
this.browserProxy.handler.closePanel();
});
$.signInButton.addEventListener('click', () => {
this.signIn();
});
document.addEventListener('keydown', ev => {
if (this.state !== WebUiState.kReady) {
if (ev.code === 'Escape') {
ev.stopPropagation();
ev.preventDefault();
this.browserProxy.handler.closePanel();
}
}
});
if (kEnableDebug) {
window.addEventListener('load', () => {
this.installDebugButton();
});
}
}
// WebviewDelegate implementation.
webviewUnresponsive(): void {
console.warn('webview unresponsive');
this.trackUnresponsiveState(
this.state === WebUiState.kUnresponsive ?
WebClientUnresponsiveState.ALREADY_ON_FROM_WEBVIEW_EVENT :
WebClientUnresponsiveState.ENTERED_FROM_WEBVIEW_EVENT);
this.setState(WebUiState.kUnresponsive);
}
trackUnresponsiveState(newState: WebClientUnresponsiveState): void {
if (!kEnableUnresponsiveMetrics) {
return;
}
// Track and record unresponsive state duration.
if (newState === WebClientUnresponsiveState.ENTERED_FROM_WEBVIEW_EVENT ||
newState === WebClientUnresponsiveState.ENTERED_FROM_CUSTOM_HEARTBEAT) {
// Entering an unresponsive state.
this.enteredUnresponsiveTimestampMs = Date.now();
} else if (newState === WebClientUnresponsiveState.EXITED) {
// Existing an unresponsive state.
if (this.enteredUnresponsiveTimestampMs !== undefined) {
const unresponsiveDuration =
Date.now() - this.enteredUnresponsiveTimestampMs;
chrome.metricsPrivate.recordMediumTime(
'Glic.Host.WebClientUnresponsiveState.Duration',
unresponsiveDuration);
this.enteredUnresponsiveTimestampMs = undefined;
} else {
console.error('Unresponsive state exited without an entering timestamp');
}
}
// Record unresponsive state detections and transitions.
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Host.WebClientUnresponsiveState', newState,
WebClientUnresponsiveState.MAX_VALUE + 1);
}
webviewError(reason: string): void {
console.warn(`webview exit. reason: ${reason}`);
this.setState(WebUiState.kError);
}
webviewPageCommit(type: PageType) {
switch (type) {
case 'login':
this.lastWidth = 400;
this.lastHeight = 800;
this.cancelTimeout();
$.guestPanel.classList.toggle('show-header', true);
this.showPanel('guestPanel');
break;
case 'guestError':
this.setState(WebUiState.kGuestError);
break;
case 'regular':
$.guestPanel.classList.toggle('show-header', false);
if (this.state === WebUiState.kReady ||
this.state === WebUiState.kGuestError) {
this.setState(WebUiState.kBeginLoad);
}
break;
}
}
private setState(newState: WebUiState): void {
if (this.state === newState) {
return;
}
if (this.state) {
this.states.get(this.state)!.onExit?.call(this);
}
this.state = newState;
this.states.get(this.state)!.onEnter?.call(this);
this.browserProxy.handler.webUiStateChanged(this.state);
this.browserProxy.handler.enableDragResize(
this.state === WebUiState.kReady && this.guestResizeEnabled);
}
private stateDescriptor(): StateDescriptor|undefined {
return this.state !== undefined ? this.states.get(this.state) : undefined;
}
readonly states: Map<WebUiState, StateDescriptor> = new Map([
[
WebUiState.kBeginLoad,
{onEnter: this.beginLoad, onExit: this.cancelTimeout},
],
[
WebUiState.kShowLoading,
{onEnter: this.showLoading, onExit: this.cancelTimeout},
],
[
WebUiState.kHoldLoading,
{onEnter: this.holdLoading, onExit: this.cancelTimeout},
],
[
WebUiState.kFinishLoading,
{onEnter: this.finishLoading, onExit: this.cancelTimeout},
],
[
WebUiState.kError,
{
reloadOnOpen: true,
onEnter:
() => {
this.destroyWebview();
this.showPanel('errorPanel');
},
},
],
[
WebUiState.kOffline,
{
onEnter: () => {
this.destroyWebview();
this.showPanel('offlinePanel');
},
},
],
[
WebUiState.kUnavailable,
{
reloadOnOpen: true,
onEnter:
() => {
this.destroyWebview();
this.showPanel('unavailablePanel');
},
},
],
[
WebUiState.kDisabledByAdmin,
{
reloadOnOpen: true,
onEnter:
() => {
this.destroyWebview();
this.showPanel('disabledByAdminPanel');
},
},
],
[
WebUiState.kReady,
{
onEnter: () => {
this.trackLoadingStageEnd();
$.guestPanel.classList.toggle('show-header', false);
this.showPanel('guestPanel');
},
},
],
[
WebUiState.kUnresponsive,
{
reloadOnOpen: true,
onEnter:
() => {
$.unresponsiveOverlay.classList.toggle('hidden', false);
},
onExit:
() => {
this.trackUnresponsiveState(WebClientUnresponsiveState.EXITED);
$.unresponsiveOverlay.classList.toggle('hidden', true);
},
},
],
[
WebUiState.kSignIn,
{
reloadOnOpen: true,
onEnter:
() => {
this.destroyWebview();
this.showPanel('signInPanel');
},
},
],
[
WebUiState.kGuestError,
{
reloadOnOpen: true,
onEnter:
() => {
this.lastWidth = 400;
this.lastHeight = 800;
$.guestPanel.classList.toggle('show-header', true);
this.showPanel('guestPanel');
},
},
],
]);
private cancelTimeout(): void {
if (this.loadingTimer) {
clearTimeout(this.loadingTimer);
this.loadingTimer = undefined;
}
}
private beginLoad(): void {
// Wait a moment before showing the loading panel.
this.loadingTimer = setTimeout(() => {
this.setState(WebUiState.kShowLoading);
}, kPreHoldLoadingTimeMs);
this.load();
}
private trackLoadingStageStart(newStage: LoadingStage) {
this.loadingStage = newStage;
this.loadingStageStartTimestampMs = performance.now();
}
private trackLoadingStageEnd() {
if (this.loadingStage === LoadingStage.NOT_LOADING) {
return;
}
chrome.metricsPrivate.recordMediumTime(
'Glic.Host.LoadingStageDuration.' +
LoadingStage[this.getLoadingStage()],
Math.floor(performance.now() - this.loadingStageStartTimestampMs!));
this.loadingStage = LoadingStage.NOT_LOADING;
}
private getLoadingStage(): LoadingStage {
if (this.loadingStage === LoadingStage.LOADING_WEB_CLIENT &&
this.webview?.waitingOnPanelWillOpen()) {
return LoadingStage.AWAITING_NOTIFY_PANEL_WILL_OPEN;
}
return this.loadingStage;
}
private async load(): Promise<void> {
// profileReadyState isn't available right away. Wait until it's ready.
this.trackLoadingStageStart(LoadingStage.AWAITING_PROFILE_READY);
await this.profileReadyInitialState.promise;
this.trackLoadingStageEnd();
const readyState = this.profileReadyState;
switch (readyState) {
case ProfileReadyState.kIneligible:
case ProfileReadyState.kUnknownError:
this.setState(WebUiState.kUnavailable);
return;
case ProfileReadyState.kDisabledByAdmin:
this.setState(WebUiState.kDisabledByAdmin);
return;
case ProfileReadyState.kSignInRequired:
this.setState(WebUiState.kSignIn);
return;
case ProfileReadyState.kReady:
break;
}
// Blocking on cookie syncing here introduces latency, we should consider
// ways to avoid it.
this.trackLoadingStageStart(LoadingStage.AWAITING_COOKIE_SYNC);
const {result} = await this.browserProxy.handler.prepareForClient();
this.trackLoadingStageEnd();
switch (result) {
case PrepareForClientResult.kSuccess:
break;
case PrepareForClientResult.kErrorResyncingCookies:
console.warn('prepareForClient in beginLoad() failed.');
this.setState(WebUiState.kError);
return;
case PrepareForClientResult.kRequiresSignIn:
this.setState(WebUiState.kSignIn);
return;
}
// Load the web client only after cookie sync is complete.
this.trackLoadingStageStart(LoadingStage.LOADING_WEB_CLIENT);
this.destroyWebview();
this.webview = new WebviewController(
$.webviewContainer, this.browserProxy, this, this,
this.webviewPersistentState);
this.webview.getWebClientState().subscribe(
this.webClientStateChanged.bind(this));
// Browser is expected to call client's notifyPanelWillOpen(), and then we
// expect a call to webClientReady() when that finishes.
}
private showLoading(): void {
this.showPanel('loadingPanel');
// After kMinHoldLoadingTimeMs, transition to finish-loading or ready. Note
// that we do not transition from show-loading to ready before the timeout.
this.earliestLoadingDismissTime = performance.now() + kMinHoldLoadingTimeMs;
this.loadingTimer = setTimeout(() => {
this.setState(WebUiState.kFinishLoading);
}, kMinHoldLoadingTimeMs);
}
private holdLoading(): void {
// The web client is ready, but we still wait for the remainder of
// `kMinHoldLoadingTimeMs` before showing it, to allow the loading animation
// to complete once.
this.loadingTimer = setTimeout(() => {
this.setState(WebUiState.kReady);
}, Math.max(0, this.earliestLoadingDismissTime! - performance.now()));
}
private finishLoading(): void {
// The web client is not yet ready, so wait for the remainder of
// `kMaxWaitTimeMs`. Switch to error state at that time unless interrupted
// by `webClientReady`.
this.loadingTimer = setTimeout(() => {
if (this.webview?.waitingOnPanelWillOpen()) {
console.warn('Exceeded timeout waiting for notifyPanelWillOpen');
this.setState(WebUiState.kError);
} else if (
this.webview?.getWebClientState().getCurrentValue() ===
WebClientState.RESPONSIVE) {
this.setState(WebUiState.kReady);
} else {
console.warn('Exceeded timeout waiting for client to load');
this.setState(WebUiState.kError);
}
if (this.state !== WebUiState.kReady) {
chrome.metricsPrivate.recordEnumerationValue(
'Glic.Host.LoadingTimedOut', this.getLoadingStage(),
LoadingStage.MAX_VALUE + 1);
this.webview?.onLoadTimeOut();
}
this.trackLoadingStageEnd();
}, kMaxWaitTimeMs - kMinHoldLoadingTimeMs);
}
// Show one webUI panel and hide the others. The widget is resized to fit the
// newly-visible content. If the guest panel is now visible, then its size
// will be determined by the most recent resize request.
private showPanel(id: PanelId): void {
for (const panel of document.querySelectorAll<HTMLElement>('.panel')) {
panel.hidden = panel.id !== id;
}
// Resize widget to size of new panel.
if (id === 'guestPanel') {
// For the guest webview, use the most recently requested size.
this.browserProxy.handler.resizeWidget(
{width: this.lastWidth, height: this.lastHeight}, transitionDuration);
} else {
this.browserProxy.handler.resizeWidget(
{
width: this.defaultWidth,
height: $[id].getBoundingClientRect().height,
},
transitionDuration);
}
}
// Destroy the current webview if it exists. This is necessary because
// webview does not support unloading content by setting src=""
private destroyWebview(): void {
if (!this.webview) {
return;
}
this.webview.destroy();
this.webview = undefined;
}
private online(): void {
if (this.simulateNoConnection) {
return;
}
if (this.state !== WebUiState.kOffline) {
return;
}
this.setState(WebUiState.kBeginLoad);
}
private offline(): void {
const allowedStates = [
WebUiState.kBeginLoad,
WebUiState.kShowLoading,
WebUiState.kFinishLoading,
];
if (allowedStates.includes(this.state!)) {
this.setState(WebUiState.kOffline);
}
}
private installDebugButton(): void {
const button = document.createElement('cr-icon-button');
button.id = 'debug';
button.classList.add('tonal-button');
button.setAttribute('iron-icon', 'cr:search');
document.querySelector('#errorPanel .notice')!.appendChild(button);
button.addEventListener('click', () => {
this.showDebug();
});
}
// ApiHostEmbedder implementation.
// Called when the web client requests that the window size be changed.
onGuestResizeRequest(request: {width: number, height: number}) {
// Save most recently requested guest window size.
this.lastWidth = request.width;
this.lastHeight = request.height;
}
// Called when the web client requests to enable manual drag resize.
enableDragResize(enabled: boolean) {
this.guestResizeEnabled = enabled;
if (this.state === WebUiState.kReady) {
this.browserProxy.handler.enableDragResize(this.guestResizeEnabled);
}
}
// Called when the notifyPanelWillOpen promise resolves to open the panel
// when triggered from the browser.
webClientReady(): void {
if (this.state === WebUiState.kBeginLoad ||
this.state === WebUiState.kFinishLoading) {
this.trackLoadingStageEnd();
this.setState(WebUiState.kReady);
} else if (this.state === WebUiState.kShowLoading) {
this.setState(WebUiState.kHoldLoading);
}
}
webClientStateChanged(state: WebClientState): void {
switch (state) {
case WebClientState.RESPONSIVE:
// If we're still in a loading state, let it transition naturally
// through the loading process.
switch (this.state) {
case WebUiState.kBeginLoad:
case WebUiState.kShowLoading:
case WebUiState.kHoldLoading:
return;
}
this.setState(WebUiState.kReady);
break;
case WebClientState.UNRESPONSIVE:
this.trackUnresponsiveState(
this.state === WebUiState.kUnresponsive ?
WebClientUnresponsiveState.ALREADY_ON_FROM_CUSTOM_HEARTBEAT :
WebClientUnresponsiveState.ENTERED_FROM_CUSTOM_HEARTBEAT);
this.setState(WebUiState.kUnresponsive);
break;
case WebClientState.ERROR:
this.guestResizeEnabled = false;
this.setState(WebUiState.kError);
break;
}
}
// External entry points.
// TODO: Make this a proper state.
showDebug(): void {
this.lastWidth = 400;
this.lastHeight = 800;
this.setState(WebUiState.kReady);
$.guestPanel.classList.toggle('show-header', true);
$.guestPanel.classList.toggle('debug', true);
}
close(): void {
// If we're in the debug view, switch back to error. Otherwise close the
// window.
if (this.state === WebUiState.kReady &&
$.guestPanel.classList.contains('debug')) {
$.guestPanel.classList.toggle('debug', false);
this.setState(WebUiState.kError);
} else if (this.state === WebUiState.kReady) {
this.browserProxy.handler.closePanel();
} else {
// Reload in the background if user closes window while web client is not
// ready. This is an escape hatch for situation where we're stuck in a
// loading state caused by an error.
this.browserProxy.handler.closePanel().then(() => {
this.reload();
});
}
}
// Called when the reload button is clicked.
reload(): void {
this.destroyWebview();
// TODO: Allow the timeout on this load to be longer than the initial load.
this.setState(WebUiState.kBeginLoad);
}
private openProfilePicker(): void {
this.browserProxy.handler.openProfilePickerAndClosePanel();
}
private signIn(): void {
this.browserProxy.handler.signInAndClosePanel();
}
// PageInterface implementation.
// Called before the WebUI is shown. If we're in an error state, automatically
// try to reload.
intentToShow() {
if (this.stateDescriptor()?.reloadOnOpen) {
this.reload();
}
}
setProfileReadyState(state: ProfileReadyState) {
if (this.profileReadyState === state) {
return;
}
const initialCall = this.profileReadyState === undefined;
this.profileReadyState = state;
if (initialCall) {
// The initial state is handled in `beginLoad()`.
this.profileReadyInitialState.resolve();
} else {
switch (this.profileReadyState) {
case ProfileReadyState.kUnknownError:
case ProfileReadyState.kIneligible:
this.setState(WebUiState.kUnavailable);
break;
case ProfileReadyState.kDisabledByAdmin:
this.setState(WebUiState.kDisabledByAdmin);
break;
case ProfileReadyState.kSignInRequired:
this.setState(WebUiState.kSignIn);
break;
case ProfileReadyState.kReady:
if (this.stateDescriptor()?.reloadOnOpen) {
this.setState(WebUiState.kBeginLoad);
}
break;
}
}
}
}