chromium/chrome/browser/resources/settings/router.ts

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import type {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {dedupingMixin} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {loadTimeData} from './i18n_setup.js';

/**
 * Specifies all possible routes in settings.
 */
export interface SettingsRoutes {
  ABOUT: Route;
  ACCESSIBILITY: Route;
  ADDRESSES: Route;
  ADVANCED: Route;
  AI: Route;
  APPEARANCE: Route;
  AUTOFILL: Route;
  BASIC: Route;
  CAPTIONS: Route;
  CERTIFICATES: Route;
  CHROME_CLEANUP: Route;
  CLEAR_BROWSER_DATA: Route;
  COOKIES: Route;
  DEFAULT_BROWSER: Route;
  DOWNLOADS: Route;
  EDIT_DICTIONARY: Route;
  FONTS: Route;
  // <if expr="_google_chrome">
  GET_MOST_CHROME: Route;
  // </if>
  HISTORY_SEARCH: Route;
  INCOMPATIBLE_APPLICATIONS: Route;
  LANGUAGES: Route;
  MANAGE_PROFILE: Route;
  ON_STARTUP: Route;
  PAGE_CONTENT: Route;
  PASSKEYS: Route;
  PAYMENTS: Route;
  PEOPLE: Route;
  PERFORMANCE: Route;
  PRELOADING: Route;
  PRIVACY: Route;
  PRIVACY_GUIDE: Route;
  PRIVACY_SANDBOX: Route;
  PRIVACY_SANDBOX_AD_MEASUREMENT: Route;
  PRIVACY_SANDBOX_FLEDGE: Route;
  PRIVACY_SANDBOX_TOPICS: Route;
  PRIVACY_SANDBOX_MANAGE_TOPICS: Route;
  RESET: Route;
  RESET_DIALOG: Route;
  SAFETY_CHECK: Route;
  SAFETY_HUB: Route;
  SEARCH: Route;
  SEARCH_ENGINES: Route;
  SECURITY: Route;
  SECURITY_KEYS: Route;
  SECURITY_KEYS_PHONES: Route;
  SITE_SETTINGS: Route;
  SITE_SETTINGS_ADS: Route;
  SITE_SETTINGS_ALL: Route;
  SITE_SETTINGS_AR: Route;
  SITE_SETTINGS_AUTOMATIC_DOWNLOADS: Route;
  SITE_SETTINGS_AUTOMATIC_FULLSCREEN: Route;
  SITE_SETTINGS_AUTO_PICTURE_IN_PICTURE: Route;
  SITE_SETTINGS_AUTO_VERIFY: Route;
  SITE_SETTINGS_BACKGROUND_SYNC: Route;
  SITE_SETTINGS_BLUETOOTH_DEVICES: Route;
  SITE_SETTINGS_BLUETOOTH_SCANNING: Route;
  SITE_SETTINGS_CAMERA: Route;
  SITE_SETTINGS_CAPTURED_SURFACE_CONTROL: Route;
  SITE_SETTINGS_CLIPBOARD: Route;
  SITE_SETTINGS_COOKIES: Route;
  SITE_SETTINGS_FEDERATED_IDENTITY_API: Route;
  SITE_SETTINGS_HANDLERS: Route;
  SITE_SETTINGS_HAND_TRACKING: Route;
  SITE_SETTINGS_HID_DEVICES: Route;
  SITE_SETTINGS_IDLE_DETECTION: Route;
  SITE_SETTINGS_IMAGES: Route;
  SITE_SETTINGS_KEYBOARD_LOCK: Route;
  SITE_SETTINGS_LOCAL_FONTS: Route;
  SITE_SETTINGS_MIXEDSCRIPT: Route;
  SITE_SETTINGS_JAVASCRIPT: Route;
  SITE_SETTINGS_JAVASCRIPT_JIT: Route;
  SITE_SETTINGS_SENSORS: Route;
  SITE_SETTINGS_SOUND: Route;
  SITE_SETTINGS_LOCATION: Route;
  SITE_SETTINGS_MICROPHONE: Route;
  SITE_SETTINGS_MIDI_DEVICES: Route;
  SITE_SETTINGS_FILE_SYSTEM_WRITE: Route;
  SITE_SETTINGS_FILE_SYSTEM_WRITE_DETAILS: Route;
  SITE_SETTINGS_NOTIFICATIONS: Route;
  SITE_SETTINGS_OFFER_WRITING_HELP: Route;
  SITE_SETTINGS_PAYMENT_HANDLER: Route;
  SITE_SETTINGS_PDF_DOCUMENTS: Route;
  SITE_SETTINGS_POINTER_LOCK: Route;
  SITE_SETTINGS_POPUPS: Route;
  SITE_SETTINGS_PROTECTED_CONTENT: Route;
  SITE_SETTINGS_SERIAL_PORTS: Route;
  SITE_SETTINGS_SITE_DATA: Route;
  SITE_SETTINGS_SITE_DETAILS: Route;
  SITE_SETTINGS_STORAGE_ACCESS: Route;
  SITE_SETTINGS_USB_DEVICES: Route;
  SITE_SETTINGS_VR: Route;
  SITE_SETTINGS_WINDOW_MANAGEMENT: Route;
  SITE_SETTINGS_ZOOM_LEVELS: Route;
  SITE_SETTINGS_WEB_PRINTING: Route;
  SPELL_CHECK: Route;
  SYNC: Route;
  SYNC_ADVANCED: Route;
  SYSTEM: Route;
  TRACKING_PROTECTION: Route;
  TRIGGERED_RESET_DIALOG: Route;

  // <if expr="not chromeos_ash">
  IMPORT_DATA: Route;
  SIGN_OUT: Route;
  // </if>
}

/** Class for navigable routes. */
export class Route {
  path: string;
  parent: Route|null = null;
  depth: number = 0;
  title: string|undefined;

  /**
   * Whether this route corresponds to a navigable dialog. Those routes must
   * belong to a "section".
   */
  isNavigableDialog: boolean = false;

  // Legacy property to provide compatibility with the old routing system.
  section: string = '';

  constructor(path: string, title?: string) {
    this.path = path;
    this.title = title;
  }

  /**
   * Returns a new Route instance that's a child of this route.
   * @param path Extends this route's path if it doesn't contain a
   *     leading slash.
   */
  createChild(path: string, title?: string): Route {
    assert(path);

    // |path| extends this route's path if it doesn't have a leading slash.
    // If it does have a leading slash, it's just set as the new route's URL.
    const newUrl = path[0] === '/' ? path : `${this.path}/${path}`;

    const route = new Route(newUrl, title);
    route.parent = this;
    route.section = this.section;
    route.depth = this.depth + 1;

    return route;
  }

  /**
   * Returns a new Route instance that's a child section of this route.
   * TODO(tommycli): Remove once we've obsoleted the concept of sections.
   */
  createSection(path: string, section: string, title?: string): Route {
    const route = this.createChild(path, title);
    route.section = section;
    return route;
  }

  /**
   * Returns the absolute path string for this Route, assuming this function
   * has been called from within chrome://settings.
   */
  getAbsolutePath(): string {
    return window.location.origin + this.path;
  }

  /**
   * Returns true if this route matches or is an ancestor of the parameter.
   */
  contains(route: Route): boolean {
    for (let r: Route|null = route; r != null; r = r.parent) {
      if (this === r) {
        return true;
      }
    }
    return false;
  }

  /**
   * Returns true if this route is a subpage of a section.
   */
  isSubpage(): boolean {
    return !this.isNavigableDialog && !!this.parent && !!this.section &&
        this.parent.section === this.section;
  }
}

/**
 * Regular expression that captures the leading slash, the content and the
 * trailing slash in three different groups.
 */
const CANONICAL_PATH_REGEX: RegExp = /(^\/)([\/-\w]+)(\/$)/;

let routerInstance: Router|null = null;

export class Router {
  /**
   * List of available routes. This is populated taking into account current
   * state (like guest mode).
   */
  private routes_: SettingsRoutes;

  /**
   * The current active route. This updated is only by settings.navigateTo
   * or settings.initializeRouteFromUrl.
   */
  currentRoute: Route;

  /**
   * The current query parameters. This is updated only by
   * settings.navigateTo or settings.initializeRouteFromUrl.
   */
  private currentQueryParameters_: URLSearchParams = new URLSearchParams();

  private wasLastRouteChangePopstate_: boolean = false;

  private initializeRouteFromUrlCalled_: boolean = false;

  private routeObservers_: Set<RouteObserverMixinInterface> = new Set();

  /** @return The singleton instance. */
  static getInstance(): Router {
    assert(routerInstance);
    return routerInstance;
  }

  static setInstance(instance: Router) {
    assert(!routerInstance);
    routerInstance = instance;
  }

  static resetInstanceForTesting(instance: Router) {
    if (routerInstance) {
      instance.routeObservers_ = routerInstance.routeObservers_;
    }
    routerInstance = instance;
  }

  constructor(availableRoutes: SettingsRoutes) {
    this.routes_ = availableRoutes;
    this.currentRoute = this.routes_.BASIC;
  }

  addObserver(observer: RouteObserverMixinInterface) {
    assert(!this.routeObservers_.has(observer));
    this.routeObservers_.add(observer);
  }

  removeObserver(observer: RouteObserverMixinInterface) {
    assert(this.routeObservers_.delete(observer));
  }

  getRoute(routeName: string): Route {
    return this.routeDictionary_()[routeName];
  }

  getRoutes(): SettingsRoutes {
    return this.routes_;
  }

  /**
   * Helper function to set the current route and notify all observers.
   */
  setCurrentRoute(
      route: Route, queryParameters: URLSearchParams, isPopstate: boolean) {
    this.recordMetrics(route.path);

    const oldRoute = this.currentRoute;
    this.currentRoute = route;
    this.currentQueryParameters_ = queryParameters;
    this.wasLastRouteChangePopstate_ = isPopstate;
    new Set(this.routeObservers_).forEach((observer) => {
      observer.currentRouteChanged(this.currentRoute, oldRoute);
    });

    this.updateTitle_();
  }

  /**
   * Updates the page title to reflect the current route.
   */
  private updateTitle_() {
    if (this.currentRoute.title) {
      document.title = loadTimeData.getStringF(
          'settingsAltPageTitle', this.currentRoute.title);
    } else if (
        this.currentRoute.isNavigableDialog && this.currentRoute.parent &&
        this.currentRoute.parent.title) {
      document.title = loadTimeData.getStringF(
          'settingsAltPageTitle', this.currentRoute.parent.title);
    } else if (
        !this.currentRoute.isSubpage() &&
        !this.routes_.ABOUT.contains(this.currentRoute)) {
      document.title = loadTimeData.getString('settings');
    }
  }

  getCurrentRoute(): Route {
    return this.currentRoute;
  }

  getQueryParameters(): URLSearchParams {
    return new URLSearchParams(
        this.currentQueryParameters_);  // Defensive copy.
  }

  lastRouteChangeWasPopstate(): boolean {
    return this.wasLastRouteChangePopstate_;
  }

  private routeDictionary_(): {[key: string]: Route} {
    return this.routes_ as unknown as {[key: string]: Route};
  }

  /**
   * @return The matching canonical route, or null if none matches.
   */
  getRouteForPath(path: string): Route|null {
    // Allow trailing slash in paths.
    const canonicalPath = path.replace(CANONICAL_PATH_REGEX, '$1$2');

    // TODO(tommycli): Use Object.values once Closure compilation supports it.
    const matchingKey =
        Object.keys(this.routes_)
            .find((key) => this.routeDictionary_()[key].path === canonicalPath);

    return matchingKey ? this.routeDictionary_()[matchingKey] : null;
  }

  /**
   * Updates the URL parameters of the current route via exchanging the
   * window history state. This changes the Settings route path, but doesn't
   * change the route itself, hence does not push a new route history entry.
   * Notifies routeChangedObservers.
   */
  updateRouteParams(params: URLSearchParams) {
    let url = this.currentRoute.path;
    const queryString = params.toString();
    if (queryString) {
      url += '?' + queryString;
    }
    window.history.replaceState(window.history.state, '', url);

    // We can't call |setCurrentRoute()| for the following, as it would also
    // update |oldRoute| and |currentRoute|, which should not happen when
    // only the URL parameters are updated.
    this.currentQueryParameters_ = params;
    new Set(this.routeObservers_).forEach((observer) => {
      observer.currentRouteChanged(this.currentRoute, this.currentRoute);
    });
  }

  /**
   * Navigates to a canonical route and pushes a new history entry.
   * @param dynamicParameters Navigations to the same
   *     URL parameters in a different order will still push to history.
   * @param removeSearch Whether to strip the 'search' URL
   *     parameter during navigation. Defaults to false.
   */
  navigateTo(
      route: Route, dynamicParameters?: URLSearchParams,
      removeSearch: boolean = false) {
    // The ADVANCED route only serves as a parent of subpages, and should not
    // be possible to navigate to it directly.
    if (route === this.routes_.ADVANCED) {
      route = this.routes_.BASIC;
    }

    const params = dynamicParameters || new URLSearchParams();

    const oldSearchParam = this.getQueryParameters().get('search') || '';
    const newSearchParam = params.get('search') || '';

    if (!removeSearch && oldSearchParam && !newSearchParam) {
      params.append('search', oldSearchParam);
    }

    let url = route.path;
    const queryString = params.toString();
    if (queryString) {
      url += '?' + queryString;
    }

    // History serializes the state, so we don't push the actual route object.
    window.history.pushState(this.currentRoute.path, '', url);
    this.setCurrentRoute(route, params, false);
  }

  /**
   * Navigates to the previous route if it has an equal or lesser depth.
   * If there is no previous route in history meeting those requirements,
   * this navigates to the immediate parent. This will never exit Settings.
   */
  navigateToPreviousRoute() {
    let previousRoute = null;
    if (window.history.state) {
      previousRoute = this.getRouteForPath(window.history.state);
      assert(previousRoute);
    }

    if (previousRoute && previousRoute.depth <= this.currentRoute.depth) {
      window.history.back();
    } else {
      this.navigateTo(this.currentRoute.parent || this.routes_.BASIC);
    }
  }

  /**
   * Initialize the route and query params from the URL.
   */
  initializeRouteFromUrl() {
    assert(!this.initializeRouteFromUrlCalled_);
    this.initializeRouteFromUrlCalled_ = true;

    const route = this.getRouteForPath(window.location.pathname);

    // Record all correct paths entered on the settings page, and
    // as all incorrect paths are routed to the main settings page,
    // record all incorrect paths as hitting the main settings page.
    this.recordMetrics(route ? route.path : this.routes_.BASIC.path);

    // Never allow direct navigation to ADVANCED.
    if (route && route !== this.routes_.ADVANCED) {
      this.currentRoute = route;
      this.currentQueryParameters_ =
          new URLSearchParams(window.location.search);
    } else {
      window.history.replaceState(undefined, '', this.routes_.BASIC.path);
    }

    this.updateTitle_();
  }

  /**
   * Make a UMA note about visiting this URL path.
   * @param urlPath The url path (only).
   */
  recordMetrics(urlPath: string) {
    assert(!urlPath.startsWith('chrome://'));
    assert(!urlPath.startsWith('settings'));
    assert(urlPath.startsWith('/'));
    assert(!urlPath.match(/\?/g));

    const metricName = 'WebUI.Settings.PathVisited';
    chrome.metricsPrivate.recordSparseValueWithPersistentHash(
        metricName, urlPath);
  }

  resetRouteForTesting() {
    this.initializeRouteFromUrlCalled_ = false;
    this.wasLastRouteChangePopstate_ = false;
    this.currentRoute = this.routes_.BASIC;
    this.currentQueryParameters_ = new URLSearchParams();
  }
}

type Constructor<T> = new (...args: any[]) => T;

export const RouteObserverMixin = dedupingMixin(
    <T extends Constructor<PolymerElement>>(superClass: T): T&
    Constructor<RouteObserverMixinInterface> => {
      class RouteObserverMixin extends superClass implements
          RouteObserverMixinInterface {
        override connectedCallback() {
          super.connectedCallback();

          assert(routerInstance);
          routerInstance.addObserver(this);

          // Emulating Polymer data bindings, the observer is called when the
          // element starts observing the route.
          this.currentRouteChanged(routerInstance.currentRoute, undefined);
        }

        override disconnectedCallback() {
          super.disconnectedCallback();

          assert(routerInstance);
          routerInstance.removeObserver(this);
        }

        currentRouteChanged(_newRoute: Route, _oldRoute?: Route) {
          assertNotReached();
        }
      }
      return RouteObserverMixin;
    });

export interface RouteObserverMixinInterface {
  currentRouteChanged(newRoute: Route, oldRoute?: Route): void;
}