blob: 8fb456577441b7acfcfcf28e3d491f514894ad3c [file] [log] [blame]
// Copyright 2020 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Common code for terminal and it settings page.
*/
import {hterm, lib} from './deps_local.concat.js';
import {migrateFilesystemFromDomToIndexeddb} from './nassh_fs.js';
// Fonts which are installed in ChromeOS.
/** @type {!Array<string>} */
const LOCAL_FONTS = [
'Noto Sans Mono',
'Cousine',
];
// Minimal set of fonts used without xterm.js. These fonts have additional
// Powerline glyphs provided.
/** @type {!Array<string>} */
export const SUPPORTED_FONT_FAMILIES_MINIMAL = [
'Noto Sans Mono',
'Cousine',
'Roboto Mono',
'Inconsolata',
'Source Code Pro',
];
// Fonts available as web fonts from fonts.google.com.
/** @type {!Array<string>} */
export const SUPPORTED_FONT_FAMILIES = [
'Anonymous Pro',
'Courier Prime',
'Cousine',
'Cutive Mono',
'Fira Code',
'Fira Mono',
'IBM Plex Mono',
'Inconsolata',
'JetBrains Mono',
'Nanum Gothic Coding',
'Noto Sans Mono',
'PT Mono',
'Roboto Mono',
'Share Tech Mono',
'Source Code Pro',
'Space Mono',
'Ubuntu Mono',
];
// 'Noto Sans Mono' is the default local font.
export const DEFAULT_FONT_FAMILY = 'Noto Sans Mono';
export const SUPPORTED_FONT_SIZES = [10, 11, 12, 13, 14, 16, 18, 20];
export const SUPPORTED_LINE_HEIGHT_PADDINGS = [-2, -1.5, -1, -0.5, 0, 0.5, 1,
1.5, 2, 3, 4, 5];
export const SUPPORTED_LINE_HEIGHT = [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8,
1.9, 2];
// Numeric chrome version (e.g. 78). `null` if fail to detect.
export const CHROME_VERSION = (function() {
const matches = navigator.userAgent.match(/Chrome\/(\d+)/);
if (matches) {
return parseInt(matches[1], 10);
}
return null;
})();
/** @type {!Array<string>} */
export const DEFAULT_ANSI_COLORS = [
'#3C4043',
'#F28B82',
'#137356',
'#E37400',
'#8AB4F8',
'#EE5FFA',
'#03BFC8',
'#FFFFFF',
'#9AA0A6',
'#F6AEA9',
'#87FFC5',
'#FDD663',
'#AECBFA',
'#F4B5FB',
'#80F9F9',
'#F8F9FA',
];
export const DEFAULT_BACKGROUND_COLOR = '#202124';
export const DEFAULT_BACKGROUND_SIZE = '100% 100%';
export const DEFAULT_FOREGROUND_COLOR = '#FFFFFF';
export const DEFAULT_CURSOR_COLOR = '#669DF680';
export const DEFAULT_FONT_SIZE = 13;
export const DEFAULT_SCREEN_PADDING_SIZE = 8;
export const DEFAULT_THEME = 'dark';
export const DEFAULT_VM_NAME = 'termina';
export const DEFAULT_CONTAINER_NAME = 'penguin';
export const PARAM_NAME_MOUNT = 'mount';
export const PARAM_NAME_SETTINGS_PROFILE = 'settings_profile';
export const PARAM_NAME_SFTP = 'sftp';
export const PARAM_NAME_TMUX = 'tmux';
// Cache the url at the first opportunity. The url normally should not change,
// so this is being defensive.
export const ORIGINAL_URL = new URL(document.location.href);
/**
* Returns whether we are using xterm.js for emulator.
*
* @param {!lib.PreferenceManager} prefs The preference manager.
* @return {boolean}
*/
export function isXtermJs(prefs) {
return !!getOSInfo().alternative_emulator;
}
/**
* Return supported fonts.
*
* @param {!lib.PreferenceManager} prefs The preference manager.
* @return {!Array<string>}
*/
export function getSupportedFontFamilies(prefs) {
if (isXtermJs(prefs)) {
return SUPPORTED_FONT_FAMILIES;
}
return SUPPORTED_FONT_FAMILIES_MINIMAL;
}
/**
* Convert a font family to a CSS string.
*
* @param {string} fontFamily one of the font in SUPPORTED_FONT_FAMILIES.
* @return {string}
*/
export function fontFamilyToCSS(fontFamily) {
// TODO(joelhockey): Remove powerline fonts once migrated to xterm.js.
const powerline = SUPPORTED_FONT_FAMILIES_MINIMAL.includes(fontFamily) ?
`, 'Powerline For ${fontFamily}'` : '';
const fallback =
fontFamily === DEFAULT_FONT_FAMILY ? '' : `, '${DEFAULT_FONT_FAMILY}'`;
return `'${fontFamily}'${powerline}${fallback}`;
}
/**
* Normalize a css font family string and return one of the font family in
* SUPPORTED_FONT_FAMILIES.
*
* @param {string} cssFontFamily The css font family string.
* @return {string} The normalized font.
*/
export function normalizeCSSFontFamily(cssFontFamily) {
for (let fontFamily of cssFontFamily.split(',')) {
// The regex can never fail, so it is safe to just use the result.
fontFamily = fontFamily.match(/^\s*['"]?(.*?)['"]?\s*$/)[1];
if (SUPPORTED_FONT_FAMILIES.includes(fontFamily)) {
return fontFamily;
}
}
return DEFAULT_FONT_FAMILY;
}
/**
* Local storage key for background image.
*
* @param {!lib.PreferenceManager} prefs The preference manager.
* @return {string}
*/
export function backgroundImageLocalStorageKey(prefs) {
return backgroundImageLocalStorageKeyForProfileId(
prefs.prefix.split('/')[3]);
}
/**
* Local storage key for background image.
*
* @param {string} profileId profile ID
* @return {string}
*/
export function backgroundImageLocalStorageKeyForProfileId(profileId) {
return 'background-image-' + profileId;
}
/**
* Change default values for some existing prefs and define new ones. Note that
* for the new prefs, you might get incorrect value if this is called after
* `prefs.readStorage()`.
*
* @param {!lib.PreferenceManager} prefs The preference manager.
*/
export function definePrefs(prefs) {
// Set terminal default overrides from hterm.
prefs.definePreference('audible-bell-sound', '');
prefs.definePreference('background-color', DEFAULT_BACKGROUND_COLOR);
prefs.definePreference('background-size', DEFAULT_BACKGROUND_SIZE);
prefs.definePreference('cursor-color', DEFAULT_CURSOR_COLOR);
prefs.definePreference('color-palette-overrides', DEFAULT_ANSI_COLORS);
prefs.definePreference('font-family', fontFamilyToCSS(DEFAULT_FONT_FAMILY));
prefs.definePreference('font-size', DEFAULT_FONT_SIZE);
prefs.definePreference('foreground-color', DEFAULT_FOREGROUND_COLOR);
prefs.definePreference('pass-alt-number', false);
prefs.definePreference('pass-ctrl-number', false);
prefs.definePreference('pass-ctrl-tab', true);
prefs.definePreference('screen-padding-size', DEFAULT_SCREEN_PADDING_SIZE);
// Add new prefs.
prefs.definePreference('theme', DEFAULT_THEME);
prefs.definePreference('theme-variations', {});
prefs.definePreference('line-height', 1);
// Background image multi-profile migration.
// TODO(joelhockey): Remove after M120.
const oldKey = 'background-image';
const newKey = 'background-image-default';
const img = window.localStorage.getItem(oldKey);
if (img) {
window.localStorage.setItem(newKey, img);
window.localStorage.removeItem(oldKey);
}
}
/**
* Make sure preference values are valid.
*
* @param {!lib.PreferenceManager} prefs The preference manager.
*/
export function normalizePrefsInPlace(prefs) {
// Remove alpha from background-color.
const backgroundColor = lib.colors.normalizeCSS(
/** @type {string} */(prefs.get('background-color')));
if (!backgroundColor) {
// The color value is invalid.
prefs.reset('background-color');
} else {
// Store uppercase hex to help detect when a value is set to default.
const rgb = lib.colors.setAlpha(backgroundColor, 1);
prefs.set('background-color', lib.colors.rgbToHex(rgb).toUpperCase());
}
}
/**
* Add a listener to 'foreground-color' and 'background-color' prefs to update
* outer body color and favicon.
*
* @param {!lib.PreferenceManager} prefs The preference manager.
*/
export function watchColors(prefs) {
const esc = encodeURIComponent;
const updateFavicon = (fg, bg) => {
const link = document.querySelector('head link[rel="icon"]');
link.href = `data:image/svg+xml,
<svg xmlns="http://www.w3.org/2000/svg" width="48px" height="48px"
viewBox="0 0 48 48">
<polyline points="7,17 20,24 7,31" stroke-width="5" fill="none"
stroke="${esc(fg)}"/>
<line x1="23" y1="36" x2="38" y2="36" stroke-width="5"
stroke="${esc(fg)}"/>
</svg>`;
};
prefs.addObserver('foreground-color', (color) => {
updateFavicon(color, prefs.get('background-color'));
});
prefs.addObserver('background-color', (color) => {
document.body.style.backgroundColor = /** @type {string} */ (color);
updateFavicon(prefs.get('foreground-color'), color);
});
}
/**
* A font manager to load (web) fonts. Normally, a `document` should only have
* one font manager.
*/
export class FontManager {
/**
* @param {!Document} doc
*/
constructor(doc) {
this.document_ = doc;
// Store ongoing or successful promises for loading fonts.
this.loadFontsPromises_ = new Map();
}
/**
* Load a font. Note that normally, only the latin font set is guaranteed to
* be loaded when this function is done.
*
* @param {string} cssFontFamily We will call normalizeCSSFontFamily() on
* this, and load the returning font family.
* @return {!Promise<void>}
*/
async loadFont(cssFontFamily) {
const fontFamily = normalizeCSSFontFamily(cssFontFamily);
if (LOCAL_FONTS.includes(fontFamily)) {
// Not a web font.
return;
}
let promise = this.loadFontsPromises_.get(fontFamily);
if (!promise) {
promise = this.loadFontImpl_(fontFamily).catch((error) => {
/* eslint-disable-next-line no-new */
new Notification(
hterm.messageManager.get('TERMINAL_FONT_UNAVAILABLE', [fontFamily]),
{
body: hterm.messageManager.get(
'TERMINAL_TRY_AGAIN_WITH_INTERNET'),
tag: 'TERMINAL_FONT_UNAVAILABLE',
},
);
// Delete it from the cache so that we will retry the next time.
this.loadFontsPromises_.delete(fontFamily);
throw error;
});
this.loadFontsPromises_.set(fontFamily, promise);
}
return promise;
}
/**
* Load the powerline css. This is only necessary for hterm, which uses it own
* document object inside an iframe.
*/
async loadPowerlineCSS() {
await this.insertStyleSheet_('../css/powerline_fonts.css');
}
/**
* @param {string} fontFamily Not cssFontFamily.
* @return {!Promise<void>}
*/
async loadFontImpl_(fontFamily) {
await this.insertStyleSheet_(`https://fonts.googleapis.com/css2?family=` +
`${encodeURIComponent(fontFamily)}&display=swap`);
// 'X' is the character from which hterm measures the size. For the font
// size, the default one is used because it probably does not matter.
const fonts = await this.document_.fonts.load(
`${DEFAULT_FONT_SIZE}px "${fontFamily}"`, 'X');
if (fonts.length === 0) {
throw new Error(`Unable to load fonts ${fontFamily}`);
}
}
/**
* @param {string} url Url to the style sheet.
* @return {!Promise<void>}
*/
async insertStyleSheet_(url) {
const link = this.document_.createElement('link');
link.href = url;
link.rel = 'stylesheet';
return new Promise((resolve, reject) => {
link.addEventListener('load', () => resolve());
link.addEventListener('error',
() => reject(new Error(`Unable to insert style sheet for ${url}`)));
this.document_.head.appendChild(link);
});
}
}
export const fontManager = new FontManager(document);
/**
* @typedef {{
* alternative_emulator: (boolean|undefined),
* multi_profile: (boolean|undefined),
* sftp: (boolean|undefined),
* tmux_integration: (boolean|undefined),
* }}
*/
export let OsInfo;
/**
* @type {?OsInfo}
*/
let OS_INFO;
/**
* Pre-fetch data for getOSInfo().
*
* @return {!Promise<void>}
*/
async function prefetchOSInfo() {
if (chrome.terminalPrivate) {
return new Promise((resolve) => {
chrome.terminalPrivate.getOSInfo((info) => {
OS_INFO = info;
resolve();
});
});
} else {
// Set it to something approriate for the testing environment.
OS_INFO = {
alternative_emulator: true,
multi_profile: true,
sftp: true,
};
}
}
/**
* Common initialization logic that should be executed once at the beginning.
*
* @return {!Promise<void>}
*/
export async function init() {
// See https://crbug.com/1364172#c6 for why we are overriding it. Note that
// this is safe because Terminal SWA sets a restricted
// CrossOriginOpenerPolicy.
lib.f.openWindow = window.open.bind(window);
// These initialization tasks should not affect each other, so we run them
// concurrently.
return Promise.all([
hterm.initPromise.then(() => {
hterm.messageManager.useCrlf = true;
return hterm.messageManager.findAndLoadMessages(
lib.f.getURL('/_locales/$1/messages.json'));
}),
prefetchOSInfo(),
// Load hterm.messageManager from /_locales/<lang>/messages.json.
// Migrate over the DOM filesystem to the new indexeddb-fs.
// TODO(vapier): Delete this with R110+.
migrateFilesystemFromDomToIndexeddb().catch((e) => {
console.error('Error migrating filesystem', e);
}),
]).then(() => {});
}
/**
* Return the pre-fetched os info from `chrome.terminalPrivate.getOSInfo()`.
*
* @return {!OsInfo}
*/
export function getOSInfo() {
if (!OS_INFO) {
throw new Error('OS_INFO is not initialized');
}
return OS_INFO;
}
/**
* @param {{
* windowChannelName: string,
* driverChannelName: string,
* settingsProfileId: (?string|undefined)
* }} obj
* @return {string}
*/
export function composeTmuxUrl(
{windowChannelName, driverChannelName, settingsProfileId}) {
const url = new URL(ORIGINAL_URL.origin);
url.pathname = '/html/terminal.html';
const paramValue = JSON.stringify({windowChannelName, driverChannelName});
const settingsProfileParam = settingsProfileId ?
`&${PARAM_NAME_SETTINGS_PROFILE}=${settingsProfileId}` : '';
url.search = `?${PARAM_NAME_TMUX}=${paramValue}${settingsProfileParam}`;
return url.toString();
}
/**
* @param {{
* settingsProfileId: (string|null|undefined),
* hash: (string|undefined),
* isSftp: (boolean|undefined),
* isMount: (boolean|undefined),
* }} params
* @return {string}
*/
export function composeSshUrl(params) {
const url = new URL(ORIGINAL_URL.origin);
url.pathname = '/html/terminal_ssh.html';
if (params.hash) {
url.hash = params.hash;
}
if (params.settingsProfileId &&
params.settingsProfileId !== hterm.Terminal.DEFAULT_PROFILE_ID) {
url.searchParams.append(
PARAM_NAME_SETTINGS_PROFILE, params.settingsProfileId);
}
if (params.isSftp) {
url.searchParams.append(PARAM_NAME_SFTP, 'true');
}
if (params.isMount) {
url.searchParams.append(PARAM_NAME_MOUNT, 'true');
}
return url.toString();
}
/**
* Re-dispatch an event on the element. Note that some events are "composed" and
* can cross shadow boundary [1], so you don't need this for them.
*
* [1] https://developers.google.com/web/fundamentals/web-components/shadowdom#events
*
* @param {!HTMLElement} element
* @param {!Event} event
*/
export function redispatchEvent(element, event) {
element.dispatchEvent(new event.constructor(event.type, event));
}
/**
* Await to sleep.
*
* @param {number} ms
* @return {!Promise<void>}
*/
export async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Return a new "scheduler" function. When the function is called, it will
* schedule the `callback` to be called after `delay` time. It also returns a
* promise, which is fulfilled after the callback is called. The function does
* nothing if it is called again and the last one hasn't timed out yet.
*
* TODO: This can probably replace some other existing scheduling code (search
* "schedule" in the source code).
*
* @param {function()} callback
* @param {number} delay
* @return {function(): !Promise<void>} The schedule function.
*/
export function delayedScheduler(callback, delay) {
let donePromise = null;
return () => {
if (!donePromise) {
donePromise = new Promise((resolve) => {
setTimeout(() => {
donePromise = null;
callback();
resolve();
}, delay);
});
}
return donePromise;
};
}
/**
* Position an element by setting the "top" and "left" css value. The position
* will be adjusted if the element's bottom right corner is outside the window.
*
* @param {!HTMLElement} element
* @param {{x: number, y: number}} position
*/
export function positionElementWithinWindow(element, position) {
function adjust(pos, size, boundary) {
if (pos + size <= boundary) {
return pos;
}
// The right/bottom of the element exceeds the boundary. We need to move
// left/up to make room for it.
return Math.max(0, boundary - size);
}
const {height, width} = element.getBoundingClientRect();
element.style.top = `${adjust(position.y, height, window.innerHeight)}px`;
element.style.left = `${adjust(position.x, width, window.innerWidth)}px`;
}