chromium/chrome/browser/resources/browser_switch/internals/app.ts

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

import '../strings.m.js';
import 'chrome://resources/cr_elements/cr_toolbar/cr_toolbar.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';

import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import {I18nMixinLit} from 'chrome://resources/cr_elements/i18n_mixin_lit.js';
import {addWebUiListener} from 'chrome://resources/js/cr.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';

import {getCss} from './app.css.js';
import {getHtml} from './app.html.js';
import type {BrowserSwitchInternalsProxy, Decision, RuleSet, RuleSetList, RulesetSources, TimestampPair} from './browser_switch_internals_proxy.js';
import {BrowserSwitchInternalsProxyImpl} from './browser_switch_internals_proxy.js';

interface XmlSiteListItem {
  policyName: string;
  url: string;
}

interface RuleItem {
  rule: string;
  rulesetName: string;
}

const AppElementBase = I18nMixinLit(CrLitElement);

export class AppElement extends AppElementBase {
  static get is() {
    return 'browser-switch-internals-app';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      isBrowserSwitcherEnabled_: {type: Boolean},
      showSearch_: {type: Boolean},
      lastFetch_: {type: String},
      nextFetch_: {type: String},
      urlCheckerInput_: {type: String},
      urlCheckerOutput_: {type: Array},
      greyListRules_: {type: Array},
      siteListRules_: {type: Array},
      xmlSiteLists_: {type: Array},
    };
  }

  protected isBrowserSwitcherEnabled_: boolean = true;
  protected showSearch_: boolean = false;
  protected greyListRules_: RuleItem[] = [];
  protected siteListRules_: RuleItem[] = [];
  protected xmlSiteLists_: XmlSiteListItem[] = [];
  protected urlCheckerInput_: string = '';
  protected urlCheckerOutput_: string[] = [];
  protected lastFetch_: string = '';
  protected nextFetch_: string = '';

  override firstUpdated() {
    this.updateEverything();

    document.addEventListener('DOMContentLoaded', () => {
      addWebUiListener('data-changed', () => this.updateEverything());
    });
  }

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);

    const changedPrivateProperties =
        changedProperties as Map<PropertyKey, unknown>;

    if (changedPrivateProperties.has('urlCheckerInput_')) {
      this.checkUrl_(this.urlCheckerInput_);
    }
  }

  getRuleBrowserName(rule: string) {
    return rule.startsWith('!') ? getBrowserName() : getAltBrowserName();
  }

  getPolicyFromRuleset(ruleSetName: string) {
    const rulesetToPolicy: Record<string, string> = {
      gpo: 'BrowserSwitcherUrlList',
      ieem: 'BrowserSwitcherUseIeSitelist',
      external_sitelist: 'BrowserSwitcherExternalSitelistUrl',
      external_greylist: 'BrowserSwitcherExternalGreylistUrl',
    };
    return rulesetToPolicy[ruleSetName];
  }

  /**
   * Updates the content of all tables after receiving data from the backend.
   */
  updateTables(rulesets: RuleSetList) {
    this.siteListRules_ = [];
    this.greyListRules_ = [];

    const listNameToProperty: Record<string, RuleItem[]> = {
      sitelist: this.siteListRules_,
      greylist: this.greyListRules_,
    };

    for (const [rulesetName, ruleset] of Object.entries(rulesets)) {
      for (const [listName, rules] of Object.entries(ruleset as RuleSet)) {
        listNameToProperty[listName]!.push(...rules.map((rule: string) => ({
                                                          rulesetName,
                                                          rule,
                                                        })));
      }
    }
  }

  /**
   * Takes the json from the url checker and makes it readable.
   */
  urlOutputText(decision: Decision): string[] {
    let opensIn = '';
    const altBrowserName = getAltBrowserName();
    const browserName = getBrowserName();

    switch (decision.action) {
      case 'stay':
        opensIn = this.i18n('openBrowser', browserName) + '\n';
        break;
      case 'go':
        opensIn = this.i18n('openBrowser', altBrowserName) + '\n';
        break;
    }

    let reason = '';
    if (decision.matching_rule) {
      if (decision.matching_rule.startsWith('!')) {
        reason += this.i18n(
                      'openBrowserInvertRuleReason',
                      JSON.stringify(decision.matching_rule)) +
            '\n';
      } else {
        const list = decision.reason === 'sitelist' ?
            this.i18n('forceOpenTitle') :
            this.i18n('ignoreTitle');
        reason += this.i18n(
                      'openBrowserRuleReason',
                      JSON.stringify(decision.matching_rule), list) +
            '\n';
      }
    }
    // if undefined - add nothing to the output

    switch (decision.reason) {
      case 'globally_disabled':
        throw new Error('BrowserSwitcherEnabled policy is set as false!');
      case 'protocol':
        reason += this.i18n('openBrowserProtocolReason') + '\n';
        break;
      case 'default':
        reason += this.i18n('openBrowserDefaultReason', browserName) + '\n';
        break;
    }

    return [opensIn, reason];
  }

  private checkUrl_(url: string) {
    if (!url) {
      this.urlCheckerOutput_ = [];
      return;
    }

    if (!url.includes('://')) {
      url = 'http://' + url;
    }

    getProxy()
        .getDecision(url)
        .then((decision) => {
          // URL is valid.
          this.urlCheckerOutput_ = this.urlOutputText(decision);
        })
        .catch((errorMessage) => {
          // URL is invalid.
          console.warn(errorMessage);
          this.urlCheckerOutput_ = [this.i18n('invalidURL')];
        });
  }

  refreshXml() {
    getProxy().refreshXml();
  }

  /**
   * Update the paragraphs under the "XML sitelists" section.
   */
  updateTimestamps(timestamps: TimestampPair|null) {
    if (!timestamps) {
      return;
    }
    const {last_fetch, next_fetch} = timestamps;

    this.lastFetch_ = last_fetch !== 0 ? formatTime(last_fetch) : '';
    this.nextFetch_ = next_fetch !== 0 ? formatTime(next_fetch) : '';
  }

  /**
   * Update the table under the "XML sitelists" section.
   */
  updateXmlTable(rulesetSources: RulesetSources) {
    this.xmlSiteLists_ =
        Object.entries(rulesetSources)
            .map(([prefName, url]) => ({
                   // Hacky name guessing
                   policyName: 'BrowserSwitcher' +
                       snakeCaseToUpperCamelCase(prefName.split('.')[1]!),
                   url: url || this.i18n('notConfigured'),
                 }));
  }

  /**
   * Called by C++ when we need to update everything on the page.
   */
  async updateEverything() {
    this.isBrowserSwitcherEnabled_ =
        await getProxy().isBrowserSwitcherEnabled();
    if (this.isBrowserSwitcherEnabled_) {
      getProxy().getAllRulesets().then(
          (rulesets) => this.updateTables(rulesets));
      getProxy().getTimestamps().then(
          (timestamps) => this.updateTimestamps(timestamps));
      getProxy().getRulesetSources().then(
          (sources) => this.updateXmlTable(sources));
    }
  }

  /**
   * Section: XML configuration source
   * Shows information about the last time XML sitelists were downloaded.
   */
  protected getXmlSitelistsLastDownloadLabel(): string {
    return this.i18n('xmlSitelistLastDownloadDate', this.lastFetch_);
  }

  /**
   * Section: XML configuration source
   * Shows information about the next download time of XML sitelists.
   */
  protected getXmlSitelistsNextDownloadLabel(): string {
    return this.i18n('xmlSitelistNextDownloadDate', this.nextFetch_);
  }

  /**
   * Section: Ignore
   * Paragraph that informs that the URLs that are affected by the lists
   * BrowserSwitcherExternalGreylistUrl and BrowserSwitcherUrlGreylist
   * will not trigger a browser switch.
   */
  protected getIgnoreUrlMatchingLabel(): string {
    return this.i18n('ignoreParagraph2', getBrowserName(), getAltBrowserName());
  }

  protected onUrlCheckerInputInput_(e: Event) {
    this.urlCheckerInput_ = (e.target as CrInputElement).value;
  }
}

customElements.define(AppElement.is, AppElement);

/**
 * Converts 'this_word' to 'ThisWord'
 */
function snakeCaseToUpperCamelCase(symbol: string): string {
  if (!symbol) {
    return symbol;
  }
  return symbol.replace(/(?:^|_)([a-z])/g, (_, letter) => {
    return letter.toUpperCase();
  });
}

/**
 * Formats |date| as "HH:MM:SS".
 */
function formatTime(dateNumber: number): string {
  const date = new Date(dateNumber);
  const hh = date.getHours().toString().padStart(2, '0');
  const mm = date.getMinutes().toString().padStart(2, '0');
  const ss = date.getSeconds().toString().padStart(2, '0');
  return `${hh}:${mm}:${ss}`;
}

/**
 * Gets the English name of the alternate browser.
 */
function getAltBrowserName(): string {
  // TODO (crbug.com/1258133): if you change the AlternativeBrowserPath
  // policy, then loadTimeData can contain stale data. It won't update
  // until you refresh (despite the rest of the page auto-updating).
  return loadTimeData.getString('altBrowserName') || 'alternative browser';
}

/**
 * Gets the English name of the browser.
 */
function getBrowserName(): string {
  // TODO (crbug.com/1258133): if you change the AlternativeBrowserPath
  // policy, then loadTimeData can contain stale data. It won't update
  // until you refresh (despite the rest of the page auto-updating).
  return loadTimeData.getString('browserName');
}

function getProxy(): BrowserSwitchInternalsProxy {
  return BrowserSwitchInternalsProxyImpl.getInstance();
}