tridactyl/src/commandline_frame.ts

391 lines
14 KiB
TypeScript
Raw Normal View History

2018-11-02 06:26:24 +01:00
/** # Command line functions
*
* This file contains functions to interact with the command line.
*
* If you want to bind them to keyboard shortcuts, be sure to prefix them with "ex.". For example, if you want to bind control-p to `prev_completion`, use:
*
* ```
* bind --mode=ex <C-p> ex.prev_completion
* ```
*
* Note that you can also bind Tridactyl's [editor functions](/static/docs/modules/_src_lib_editor_.html) in the command line.
2018-11-02 06:26:24 +01:00
*
* Contrary to the main tridactyl help page, this one doesn't tell you whether a specific function is bound to something. For now, you'll have to make do with `:bind` and `:viewconfig`.
*
*/
/** ignore this line */
/** Script used in the commandline iframe. Communicates with background. */
2018-10-04 13:59:19 +01:00
import * as perf from "@src/perf"
import "@src/lib/number.clamp"
2018-09-29 17:38:58 -07:00
import "@src/lib/html-tagged-template"
import { TabAllCompletionSource } from "@src/completions/TabAll"
2020-02-27 21:31:41 +08:00
import { BindingsCompletionSource } from "@src/completions/Bindings"
2018-11-20 20:16:07 +00:00
import { BufferCompletionSource } from "@src/completions/Tab"
2018-09-29 17:38:58 -07:00
import { BmarkCompletionSource } from "@src/completions/Bmark"
import { ExcmdCompletionSource } from "@src/completions/Excmd"
2020-02-27 13:53:06 +00:00
import { CompositeCompletionSource } from "@src/completions/Composite"
2018-10-11 16:55:01 +02:00
import { FileSystemCompletionSource } from "@src/completions/FileSystem"
2019-02-23 16:17:05 +01:00
import { GuisetCompletionSource } from "@src/completions/Guiset"
import { HelpCompletionSource } from "@src/completions/Help"
2019-11-06 11:26:52 +00:00
import { AproposCompletionSource } from "@src/completions/Apropos"
2018-09-29 17:38:58 -07:00
import { HistoryCompletionSource } from "@src/completions/History"
2018-10-12 12:44:14 +02:00
import { PreferenceCompletionSource } from "@src/completions/Preferences"
2018-12-27 11:05:47 +01:00
import { RssCompletionSource } from "@src/completions/Rss"
import { SessionsCompletionSource } from "@src/completions/Sessions"
2018-09-29 17:38:58 -07:00
import { SettingsCompletionSource } from "@src/completions/Settings"
import { WindowCompletionSource } from "@src/completions/Window"
2019-09-14 02:54:50 +05:30
import { ExtensionsCompletionSource } from "@src/completions/Extensions"
2018-09-29 16:17:52 -07:00
import * as Messaging from "@src/lib/messaging"
2018-09-29 16:23:06 -07:00
import "@src/lib/number.clamp"
2018-09-29 17:38:58 -07:00
import state from "@src/state"
2020-03-03 09:41:04 +00:00
import * as State from "@src/state"
2018-09-29 15:30:48 -07:00
import Logger from "@src/lib/logging"
2018-09-29 16:43:18 -07:00
import { theme } from "@src/content/styling"
2018-10-04 13:59:19 +01:00
import * as genericParser from "@src/parsers/genericmode"
import * as tri_editor from "@src/lib/editor"
2018-11-02 06:26:24 +01:00
/** @hidden **/
const logger = new Logger("cmdline")
2018-11-02 06:26:24 +01:00
/** @hidden **/
const commandline_state = {
activeCompletions: undefined,
clInput: (window.document.getElementById("tridactyl-input") as HTMLInputElement),
clear,
cmdline_history_position: 0,
completionsDiv: window.document.getElementById("completions"),
fns: undefined,
getCompletion,
history,
/** @hidden
* This is to handle Escape key which, while the cmdline is focused,
* ends up firing both keydown and input listeners. In the worst case
* hides the cmdline, shows and refocuses it and replaces its text
* which could be the prefix to generate a completion.
* tl;dr TODO: delete this and better resolve race condition
*/
isVisible: false,
keyEvents: new Array<KeyEventLike>(),
refresh_completions,
state,
}
2018-05-20 13:17:28 +01:00
// first theming of commandline iframe
theme(document.querySelector(":root"))
2018-11-02 06:26:24 +01:00
/** @hidden **/
2017-11-22 18:05:54 +00:00
function resizeArea() {
if (commandline_state.isVisible) {
Messaging.messageOwnTab("commandline_content", "show")
Messaging.messageOwnTab("commandline_content", "focus")
focus()
2017-11-22 18:05:54 +00:00
}
}
2018-11-02 06:26:24 +01:00
/** @hidden
* This is a bit loosely defined at the moment.
* Should work so long as there's only one completion source per prefix.
*/
function getCompletion() {
if (!commandline_state.activeCompletions) return undefined
for (const comp of commandline_state.activeCompletions) {
if (comp.state === "normal" && comp.completion !== undefined) {
return comp.completion
}
}
}
commandline_state.getCompletion = getCompletion
2018-11-02 06:26:24 +01:00
/** @hidden **/
export function enableCompletions() {
if (!commandline_state.activeCompletions) {
commandline_state.activeCompletions = [
// FindCompletionSource,
2020-02-27 21:31:41 +08:00
BindingsCompletionSource,
BmarkCompletionSource,
TabAllCompletionSource,
BufferCompletionSource,
ExcmdCompletionSource,
2020-02-27 13:53:06 +00:00
CompositeCompletionSource,
FileSystemCompletionSource,
2019-02-23 16:17:05 +01:00
GuisetCompletionSource,
HelpCompletionSource,
2019-11-06 11:26:52 +00:00
AproposCompletionSource,
HistoryCompletionSource,
PreferenceCompletionSource,
2018-12-27 11:05:47 +01:00
RssCompletionSource,
SessionsCompletionSource,
SettingsCompletionSource,
WindowCompletionSource,
2019-09-14 02:54:50 +05:30
ExtensionsCompletionSource,
2017-11-22 18:05:54 +00:00
]
.map(constructorr => {
try {
return new constructorr(commandline_state.completionsDiv)
} catch (e) {}
})
.filter(c => c)
const fragment = document.createDocumentFragment()
commandline_state.activeCompletions.forEach(comp => fragment.appendChild(comp.node))
commandline_state.completionsDiv.appendChild(fragment)
logger.debug(commandline_state.activeCompletions)
2017-11-22 18:05:54 +00:00
}
}
/* document.addEventListener("DOMContentLoaded", enableCompletions) */
2018-11-02 06:26:24 +01:00
/** @hidden **/
const noblur = e => setTimeout(() => commandline_state.clInput.focus(), 0)
2018-11-02 06:26:24 +01:00
/** @hidden **/
export function focus() {
commandline_state.clInput.focus()
commandline_state.clInput.removeEventListener("blur", noblur)
commandline_state.clInput.addEventListener("blur", noblur)
}
2018-11-02 06:26:24 +01:00
/** @hidden **/
let HISTORY_SEARCH_STRING: string
2018-11-02 06:26:24 +01:00
/** @hidden
* Command line keybindings
**/
2019-04-13 09:46:23 +02:00
const keyParser = keys => genericParser.parser("exmaps", keys)
2018-11-02 06:26:24 +01:00
/** @hidden **/
let history_called = false
/** @hidden **/
let prev_cmd_called_history = false
/** @hidden **/
commandline_state.clInput.addEventListener(
2018-11-04 12:28:31 +00:00
"keydown",
function(keyevent: KeyboardEvent) {
This allowed malicious web pages to send artificial key events to the parsers for all modes except the command line (which has always been protected inside an iframe). If the native messenger was not installed, the bug could not be exploited for any more than nuisance attacks (closing tabs, quitting Firefox, etc.). If the native messenger was installed, an attack using the mpv hint mode (bound to `;v` by default) and a specially crafted link would allow an attacker to execute some commands in the user's shell. Due to the way hyperlinks are encoded, it would require more cunning than the Tridactyl developers possess to usefully exploit as it is difficult to pass arguments to commands. This did mean that the standard output of mpv (including the attacker's URL) was also available to an attacker via pipes. We are not aware of any way to abuse that with commonly installed utilities. We are unaware of any pages exploiting this in the wild. Nevertheless, this security regression should not have happened. A short incident report follows: These checks were accidentally removed when key handling was rewritten in September 2018. The PR was reviewed, but it was a large PR and the regression was missed by the reviewers. We became aware of the regression after a question in our support chat prompted @glacambre to check on exactly how we were using `isTrusted` and they realised that we weren't using it any more. We will shortly introduce automated testing to check these security properties that we rely on. We will consider adding a check to continuous integration that flags any change to files containing security relevant code for more detailed review. Affected versions: - Tridactyl 1.14.0 - 1.14.10, 1.15.0. Mitigation: - Update to Tridactyl 1.16.0+ or 1.14.13+ - If updating is unfeasible, we recommend removing the native messenger by running `:! pwd` in Tridactyl and then deleting that directory from your filesystem. - If you've thought of a clever exploit, please contact bovine3dom or cmcaine privately on Matrix or by email.
2019-06-14 10:13:19 +01:00
if (!keyevent.isTrusted) return
commandline_state.keyEvents.push(keyevent)
const response = keyParser(commandline_state.keyEvents)
2018-11-04 12:28:31 +00:00
if (response.isMatch) {
2017-11-22 23:01:34 +00:00
keyevent.preventDefault()
2018-11-04 12:28:31 +00:00
keyevent.stopImmediatePropagation()
} else {
// Ideally, all keys that aren't explicitly bound to an ex command
// should be bound to a "self-insert" command that would input the
// key itself. Because it's not possible to generate events as if
// they originated from the user, we can't do this, but we still
// need to simulate it, in order to have history() work.
prev_cmd_called_history = false
2018-11-04 12:28:31 +00:00
}
if (response.value) {
commandline_state.keyEvents = []
history_called = false
2019-04-21 01:38:57 -07:00
// If excmds start with 'ex.' they're coming back to us anyway, so skip that.
// This is definitely a hack. Should expand aliases with exmode, etc.
// but this whole thing should be scrapped soon, so whatever.
if (response.value.startsWith("ex.")) {
const funcname = response.value.slice(3)
commandline_state.fns[funcname]()
prev_cmd_called_history = history_called
} else {
// Send excmds directly to our own tab, which fixes the
// old bug where a command would be issued in one tab but
// land in another because the active tab had
// changed. Background-mode excmds will be received by the
// own tab's content script and then bounced through a
// shim to the background, but the latency increase should
// be acceptable becuase the background-mode excmds tend
// to be a touch less latency-sensitive.
Messaging.messageOwnTab("controller_content", "acceptExCmd", [
response.value,
]).then(_ => (prev_cmd_called_history = history_called))
}
2018-11-04 12:28:31 +00:00
} else {
commandline_state.keyEvents = response.keys
2018-11-04 12:28:31 +00:00
}
},
true,
)
export function refresh_completions(exstr) {
if (!commandline_state.activeCompletions) enableCompletions()
return Promise.all(
commandline_state.activeCompletions.map(comp =>
comp.filter(exstr).then(() => {
if (comp.shouldRefresh()) {
return resizeArea()
}
}),
),
).catch(err => {
console.error(err)
return []
}) // We can't use the regular logging mechanism because the user is using the command line.
}
2018-11-02 06:26:24 +01:00
/** @hidden **/
2018-10-11 16:55:01 +02:00
let onInputPromise: Promise<any> = Promise.resolve()
2018-11-02 06:26:24 +01:00
/** @hidden **/
commandline_state.clInput.addEventListener("input", () => {
const exstr = commandline_state.clInput.value
// Schedule completion computation. We do not start computing immediately because this would incur a slow down on quickly repeated input events (e.g. maintaining <Backspace> pressed)
setTimeout(async () => {
// Make sure the previous computation has ended
await onInputPromise
// If we're not the current completion computation anymore, stop
if (exstr !== commandline_state.clInput.value) return
onInputPromise = refresh_completions(exstr)
}, 100)
})
2018-11-02 06:26:24 +01:00
/** @hidden **/
let cmdline_history_current = ""
2018-11-02 06:26:24 +01:00
/** @hidden
* Clears the command line.
* If you intend to close the command line after this, set evlistener to true in order to enable losing focus.
* Otherwise, no need to pass an argument.
*/
export function clear(evlistener = false) {
if (evlistener) commandline_state.clInput.removeEventListener("blur", noblur)
commandline_state.clInput.value = ""
commandline_state.cmdline_history_position = 0
cmdline_history_current = ""
}
commandline_state.clear = clear
2018-11-02 06:26:24 +01:00
/** @hidden **/
2020-03-03 09:41:04 +00:00
async function history(n) {
history_called = true
if (!prev_cmd_called_history) {
HISTORY_SEARCH_STRING = commandline_state.clInput.value
}
2020-03-03 09:41:04 +00:00
const matches = (await State.getAsync("cmdHistory")).filter(key =>
key.startsWith(HISTORY_SEARCH_STRING),
)
if (commandline_state.cmdline_history_position === 0) {
cmdline_history_current = commandline_state.clInput.value
}
let clamped_ind = matches.length + n - commandline_state.cmdline_history_position
clamped_ind = clamped_ind.clamp(0, matches.length)
const pot_history = matches[clamped_ind]
commandline_state.clInput.value =
pot_history === undefined ? cmdline_history_current : pot_history
// if there was no clampage, update history position
// there's a more sensible way of doing this but that would require more programmer time
if (clamped_ind === matches.length + n - commandline_state.cmdline_history_position)
commandline_state.cmdline_history_position = commandline_state.cmdline_history_position - n
}
commandline_state.history = history
2018-11-02 06:26:24 +01:00
/** @hidden **/
export function fillcmdline(
newcommand?: string,
trailspace = true,
ffocus = true,
) {
if (trailspace) commandline_state.clInput.value = newcommand + " "
else commandline_state.clInput.value = newcommand
commandline_state.isVisible = true
let result = Promise.resolve([])
// Focus is lost for some reason.
if (ffocus) {
focus()
result = refresh_completions(commandline_state.clInput.value)
}
return result
}
2018-11-02 06:26:24 +01:00
/** @hidden
* Create a temporary textarea and give it to fn. Remove the textarea afterwards
*
* Useful for document.execCommand
**/
function applyWithTmpTextArea(fn) {
let textarea
try {
textarea = document.createElement("textarea")
// Scratchpad must be `display`ed, but can be tiny and invisible.
// Being tiny and invisible means it won't make the parent page move.
textarea.style.cssText =
"visible: invisible; width: 0; height: 0; position: fixed"
textarea.contentEditable = "true"
document.documentElement.appendChild(textarea)
return fn(textarea)
} finally {
document.documentElement.removeChild(textarea)
}
2017-10-28 19:20:31 +08:00
}
2018-11-02 06:26:24 +01:00
/** @hidden **/
export async function setClipboard(content: string) {
await Messaging.messageOwnTab("commandline_content", "focus")
2018-02-19 00:15:44 +00:00
applyWithTmpTextArea(scratchpad => {
2017-10-28 19:20:31 +08:00
scratchpad.value = content
scratchpad.select()
if (document.execCommand("Copy")) {
// // todo: Maybe we can consider to using some logger and show it with status bar in the future
logger.info("set clipboard:", scratchpad.value)
} else throw "Failed to copy!"
2017-10-28 19:20:31 +08:00
})
2018-02-19 00:15:44 +00:00
// Return focus to the document
await Messaging.messageOwnTab("commandline_content", "hide")
return Messaging.messageOwnTab("commandline_content", "blur")
2017-10-28 19:20:31 +08:00
}
2018-11-02 06:26:24 +01:00
/** @hidden **/
export async function getClipboard() {
await Messaging.messageOwnTab("commandline_content", "focus")
2018-02-19 00:15:44 +00:00
const result = applyWithTmpTextArea(scratchpad => {
2017-10-28 19:20:31 +08:00
scratchpad.focus()
document.execCommand("Paste")
return scratchpad.textContent
2017-10-28 19:20:31 +08:00
})
2018-02-19 00:15:44 +00:00
// Return focus to the document
await Messaging.messageOwnTab("commandline_content", "hide")
await Messaging.messageOwnTab("commandline_content", "blur")
2018-02-19 00:15:44 +00:00
return result
2017-10-28 19:20:31 +08:00
}
2018-11-02 06:26:24 +01:00
/** @hidden **/
export function getContent() {
return commandline_state.clInput.value
}
2018-11-02 06:26:24 +01:00
/** @hidden **/
export function editor_function(fn_name, ...args) {
let result = Promise.resolve([])
if (tri_editor[fn_name]) {
tri_editor[fn_name](commandline_state.clInput, ...args)
result = refresh_completions(commandline_state.clInput.value)
} else {
// The user is using the command line so we can't log message there
// logger.error(`No editor function named ${fn_name}!`)
console.error(`No editor function named ${fn_name}!`)
}
return result
}
import * as SELF from "@src/commandline_frame"
Messaging.addListener("commandline_frame", Messaging.attributeCaller(SELF))
import { getCommandlineFns } from "@src/lib/commandline_cmds"
import { KeyEventLike } from "./lib/keyseq"
commandline_state.fns = getCommandlineFns(commandline_state)
Messaging.addListener("commandline_cmd", Messaging.attributeCaller(commandline_state.fns))
// Listen for statistics from the commandline iframe and send them to
// the background for collection. Attach the observer to the window
// object since there's apparently a bug that causes performance
// observers to be GC'd even if they're still the target of a
// callback.
2019-04-12 05:54:31 +02:00
; (window as any).tri = Object.assign(window.tri || {}, {
perfObserver: perf.listenForCounters(),
})