📝 Added entire config directory for backup purposes

This commit is contained in:
z3rOR0ne 2022-11-03 00:09:22 -07:00
parent 4b38e59bb6
commit 9b946e2d14
11091 changed files with 1440953 additions and 0 deletions

View file

@ -0,0 +1,166 @@
const TIME_DELTA = 500; // Milliseconds.
// TabRecency associates a logical timestamp with each tab id. These are used to provide an initial
// recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs).
class TabRecency {
constructor() {
this.timestamp = 1;
this.current = -1;
this.cache = {};
this.lastVisited = null;
this.lastVisitedTime = null;
chrome.tabs.onActivated.addListener(activeInfo => this.register(activeInfo.tabId));
chrome.tabs.onRemoved.addListener(tabId => this.deregister(tabId));
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
this.deregister(removedTabId);
this.register(addedTabId);
});
if (chrome.windows != null) {
chrome.windows.onFocusChanged.addListener(wnd => {
if (wnd !== chrome.windows.WINDOW_ID_NONE) {
chrome.tabs.query({windowId: wnd, active: true}, tabs => {
if (tabs[0])
this.register(tabs[0].id);
});
}
});
}
}
register(tabId) {
const currentTime = new Date();
// Register tabId if it has been visited for at least @timeDelta ms. Tabs which are visited only for a
// very-short time (e.g. those passed through with `5J`) aren't registered as visited at all.
if ((this.lastVisitedTime != null) && (TIME_DELTA <= (currentTime - this.lastVisitedTime))) {
this.cache[this.lastVisited] = ++this.timestamp;
}
this.current = (this.lastVisited = tabId);
this.lastVisitedTime = currentTime;
}
deregister(tabId) {
if (tabId === this.lastVisited) {
// Ensure we don't register this tab, since it's going away.
this.lastVisited = (this.lastVisitedTime = null);
}
delete this.cache[tabId];
}
// Recently-visited tabs get a higher score (except the current tab, which gets a low score).
recencyScore(tabId) {
if (!this.cache[tabId])
this.cache[tabId] = 1;
if (tabId === this.current)
return 0.0;
else
return this.cache[tabId] / this.timestamp;
}
// Returns a list of tab Ids sorted by recency, most recent tab first.
getTabsByRecency() {
const tabIds = Object.keys(this.cache || {});
tabIds.sort((a,b) => this.cache[b] - this.cache[a]);
return tabIds.map(tId => parseInt(tId));
}
}
var BgUtils = {
tabRecency: new TabRecency(),
// Log messages to the extension's logging page, but only if that page is open.
log: (function() {
const loggingPageUrl = chrome.runtime.getURL("pages/logging.html");
if (loggingPageUrl != null) { console.log(`Vimium logging URL:\n ${loggingPageUrl}`); } // Do not output URL for tests.
// For development, it's sometimes useful to automatically launch the logging page on reload.
if (localStorage.autoLaunchLoggingPage) { chrome.windows.create({url: loggingPageUrl, focused: false}); }
return function(message, sender = null) {
for (let viewWindow of chrome.extension.getViews({type: "tab"})) {
if (viewWindow.location.pathname === "/pages/logging.html") {
// Don't log messages from the logging page itself. We do this check late because most of the time
// it's not needed.
if ((sender != null ? sender.url : undefined) !== loggingPageUrl) {
const date = new Date;
let [hours, minutes, seconds, milliseconds] =
[date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()];
if (minutes < 10) { minutes = "0" + minutes; }
if (seconds < 10) { seconds = "0" + seconds; }
if (milliseconds < 10) { milliseconds = "00" + milliseconds; }
if (milliseconds < 100) { milliseconds = "0" + milliseconds; }
const dateString = `${hours}:${minutes}:${seconds}.${milliseconds}`;
const logElement = viewWindow.document.getElementById("log-text");
logElement.value += `${dateString}: ${message}\n`;
logElement.scrollTop = 2000000000;
}
}
}
};
})(),
// Remove comments and leading/trailing whitespace from a list of lines, and merge lines where the last
// character on the preceding line is "\".
parseLines(text) {
return text.replace(/\\\n/g, "")
.split("\n")
.map(line => line.trim())
.filter(line => (line.length > 0) && !(Array.from('#"').includes(line[0])));
},
escapedEntities: {
'"': "&quots;",
'&': "&amp;",
"'": "&apos;",
"<": "&lt;",
">": "&gt;"
},
escapeAttribute(string) {
return string.replace(/["&'<>]/g, char => BgUtils.escapedEntities[char]);
}
};
// Utility for parsing and using the custom search-engine configuration. We re-use the previous parse if the
// search-engine configuration is unchanged.
const SearchEngines = {
previousSearchEngines: null,
searchEngines: null,
refresh(searchEngines) {
if ((this.previousSearchEngines == null) || (searchEngines !== this.previousSearchEngines)) {
this.previousSearchEngines = searchEngines;
this.searchEngines = new AsyncDataFetcher(function(callback) {
const engines = {};
for (let line of BgUtils.parseLines(searchEngines)) {
const tokens = line.split(/\s+/);
if (2 <= tokens.length) {
const keyword = tokens[0].split(":")[0];
const searchUrl = tokens[1];
const description = tokens.slice(2).join(" ") || `search (${keyword})`;
if (Utils.hasFullUrlPrefix(searchUrl) || Utils.hasJavascriptPrefix(searchUrl))
engines[keyword] = {keyword, searchUrl, description};
}
}
callback(engines);
});
}
},
// Use the parsed search-engine configuration, possibly asynchronously.
use(callback) { this.searchEngines.use(callback); },
// Both set (refresh) the search-engine configuration and use it at the same time.
refreshAndUse(searchEngines, callback) {
this.refresh(searchEngines);
this.use(callback);
}
};
BgUtils.TIME_DELTA = TIME_DELTA; // Referenced by our tests.
global.SearchEngines = SearchEngines;
global.BgUtils = BgUtils;

View file

@ -0,0 +1,463 @@
const Commands = {
availableCommands: {},
keyToCommandRegistry: null,
mapKeyRegistry: null,
init() {
for (let command of Object.keys(commandDescriptions)) {
const [description, options] = commandDescriptions[command];
this.availableCommands[command] = Object.assign((options || {}), {description});
}
Settings.postUpdateHooks["keyMappings"] = this.loadKeyMappings.bind(this);
this.loadKeyMappings(Settings.get("keyMappings"));
},
loadKeyMappings(customKeyMappings) {
let key, command;
this.keyToCommandRegistry = {};
this.mapKeyRegistry = {};
const configLines = Object.keys(defaultKeyMappings).map((key) => `map ${key} ${defaultKeyMappings[key]}`);
configLines.push(...BgUtils.parseLines(customKeyMappings));
const seen = {};
let unmapAll = false;
for (let line of configLines.reverse()) {
const tokens = line.split(/\s+/);
switch (tokens[0].toLowerCase()) {
case "map":
if ((3 <= tokens.length) && !unmapAll) {
var _, optionList, registryEntry;
[_, key, command, ...optionList] = tokens;
if (!seen[key] && (registryEntry = this.availableCommands[command])) {
seen[key] = true;
const keySequence = this.parseKeySequence(key);
const options = this.parseCommandOptions(command, optionList);
this.keyToCommandRegistry[key] =
Object.assign({keySequence, command, options, optionList}, this.availableCommands[command]);
}
}
break;
case "unmap":
if (tokens.length == 2)
seen[tokens[1]] = true;
break;
case "unmapall":
unmapAll = true;
break;
case "mapkey":
if (tokens.length === 3) {
const fromChar = this.parseKeySequence(tokens[1]);
const toChar = this.parseKeySequence(tokens[2]);
if ((fromChar.length === toChar.length && toChar.length === 1)
&& this.mapKeyRegistry[fromChar[0]] == null) {
this.mapKeyRegistry[fromChar[0]] = toChar[0];
}
}
break;
}
}
chrome.storage.local.set({mapKeyRegistry: this.mapKeyRegistry});
this.installKeyStateMapping();
this.prepareHelpPageData();
// Push the key mapping for passNextKey into Settings so that it's available in the front end for insert
// mode. We exclude single-key mappings (that is, printable keys) because when users press printable keys
// in insert mode they expect the character to be input, not to be droppped into some special Vimium
// mode.
const passNextKeys = Object.keys(this.keyToCommandRegistry)
.filter(key => (this.keyToCommandRegistry[key].command === "passNextKey") && (key.length > 1));
Settings.set("passNextKeyKeys", passNextKeys);
},
// Lower-case the appropriate portions of named keys.
//
// A key name is one of three forms exemplified by <c-a> <left> or <c-f12>
// (prefixed normal key, named key, or prefixed named key). Internally, for
// simplicity, we would like prefixes and key names to be lowercase, though
// humans may prefer other forms <Left> or <C-a>.
// On the other hand, <c-a> and <c-A> are different named keys - for one of
// them you have to press "shift" as well.
// We sort modifiers here to match the order used in keyboard_utils.js.
// The return value is a sequence of keys: e.g. "<Space><c-A>b" -> ["<space>", "<c-A>", "b"].
parseKeySequence: (function() {
const modifier = "(?:[acms]-)"; // E.g. "a-", "c-", "m-", "s-".
const namedKey = "(?:[a-z][a-z0-9]+)"; // E.g. "left" or "f12" (always two characters or more).
const modifiedKey = `(?:${modifier}+(?:.|${namedKey}))`; // E.g. "c-*" or "c-left".
const specialKeyRegexp = new RegExp(`^<(${namedKey}|${modifiedKey})>(.*)`, "i");
return function(key) {
if (key.length === 0) {
return [];
// Parse "<c-a>bcd" as "<c-a>" and "bcd".
} else if (0 === key.search(specialKeyRegexp)) {
const array = RegExp.$1.split("-");
const adjustedLength = Math.max(array.length, 1)
let modifiers = array.slice(0, adjustedLength - 1);
let keyChar = array[adjustedLength - 1];
if (keyChar.length !== 1)
keyChar = keyChar.toLowerCase();
modifiers = modifiers.map(m => m.toLowerCase());
modifiers.sort();
return ["<" + modifiers.concat([keyChar]).join("-") + ">", ...this.parseKeySequence(RegExp.$2)];
} else {
return [key[0], ...this.parseKeySequence(key.slice(1))];
}
};
})(),
// Command options follow command mappings, and are of one of two forms:
// key=value - a value
// key - a flag
parseCommandOptions(command, optionList) {
const options = {};
for (let option of Array.from(optionList)) {
const parse = option.split("=", 2);
options[parse[0]] = parse.length === 1 ? true : parse[1];
}
// We parse any `count` option immediately (to avoid having to parse it repeatedly later).
if ("count" in options) {
options.count = parseInt(options.count);
if (isNaN(options.count) || this.availableCommands[command].noRepeat)
delete options.count;
}
return options;
},
// This generates and installs a nested key-to-command mapping structure. There is an example in
// mode_key_handler.js.
installKeyStateMapping() {
const keyStateMapping = {};
for (let keys of Object.keys(this.keyToCommandRegistry || {})) {
const registryEntry = this.keyToCommandRegistry[keys];
let currentMapping = keyStateMapping;
for (let index = 0; index < registryEntry.keySequence.length; index++) {
const key = registryEntry.keySequence[index];
if (currentMapping[key] != null ? currentMapping[key].command : undefined) {
// Do not overwrite existing command bindings, they take priority. NOTE(smblott) This is the legacy
// behaviour.
break;
} else if (index < (registryEntry.keySequence.length - 1)) {
currentMapping = currentMapping[key] != null ? currentMapping[key] : (currentMapping[key] = {});
} else {
currentMapping[key] = Object.assign({}, registryEntry);
// We don't need these properties in the content scripts.
for (let prop of ["keySequence", "description"])
delete currentMapping[key][prop];
}
}
}
chrome.storage.local.set({normalModeKeyStateMapping: keyStateMapping});
// Inform `KeyboardUtils.isEscape()` whether `<c-[>` should be interpreted as `Escape` (which it is by
// default).
chrome.storage.local.set({useVimLikeEscape: !("<c-[>" in keyStateMapping)});
},
// Build the "helpPageData" data structure which the help page needs and place it in Chrome storage.
prepareHelpPageData() {
const commandToKey = {};
for (let key of Object.keys(this.keyToCommandRegistry || {})) {
const registryEntry = this.keyToCommandRegistry[key];
(commandToKey[registryEntry.command] != null ? commandToKey[registryEntry.command] : (commandToKey[registryEntry.command] = [])).push(key);
}
const commandGroups = {};
for (let group of Object.keys(this.commandGroups || {})) {
const commands = this.commandGroups[group];
commandGroups[group] = [];
for (let command of commands) {
commandGroups[group].push({
command,
description: this.availableCommands[command].description,
keys: commandToKey[command] != null ? commandToKey[command] : [],
advanced: this.advancedCommands.includes(command)
});
}
}
chrome.storage.local.set({helpPageData: commandGroups});
},
// An ordered listing of all available commands, grouped by type. This is the order they will
// be shown in the help page.
commandGroups: {
pageNavigation:
["scrollDown",
"scrollUp",
"scrollToTop",
"scrollToBottom",
"scrollPageDown",
"scrollPageUp",
"scrollFullPageDown",
"scrollFullPageUp",
"scrollLeft",
"scrollRight",
"scrollToLeft",
"scrollToRight",
"reload",
"copyCurrentUrl",
"openCopiedUrlInCurrentTab",
"openCopiedUrlInNewTab",
"goUp",
"goToRoot",
"enterInsertMode",
"enterVisualMode",
"enterVisualLineMode",
"passNextKey",
"focusInput",
"LinkHints.activateMode",
"LinkHints.activateModeToOpenInNewTab",
"LinkHints.activateModeToOpenInNewForegroundTab",
"LinkHints.activateModeWithQueue",
"LinkHints.activateModeToDownloadLink",
"LinkHints.activateModeToOpenIncognito",
"LinkHints.activateModeToCopyLinkUrl",
"goPrevious",
"goNext",
"nextFrame",
"mainFrame",
"Marks.activateCreateMode",
"Marks.activateGotoMode"],
vomnibarCommands:
["Vomnibar.activate",
"Vomnibar.activateInNewTab",
"Vomnibar.activateBookmarks",
"Vomnibar.activateBookmarksInNewTab",
"Vomnibar.activateTabSelection",
"Vomnibar.activateEditUrl",
"Vomnibar.activateEditUrlInNewTab"],
findCommands: ["enterFindMode", "performFind", "performBackwardsFind"],
historyNavigation:
["goBack", "goForward"],
tabManipulation:
["createTab",
"previousTab",
"nextTab",
"visitPreviousTab",
"firstTab",
"lastTab",
"duplicateTab",
"togglePinTab",
"toggleMuteTab",
"removeTab",
"restoreTab",
"moveTabToNewWindow",
"closeTabsOnLeft","closeTabsOnRight",
"closeOtherTabs",
"moveTabLeft",
"moveTabRight"],
misc:
["showHelp",
"toggleViewSource"]
},
// Rarely used commands are not shown by default in the help dialog or in the README. The goal is to present
// a focused, high-signal set of commands to the new and casual user. Only those truly hungry for more power
// from Vimium will uncover these gems.
advancedCommands: [
"scrollToLeft",
"scrollToRight",
"moveTabToNewWindow",
"goUp",
"goToRoot",
"LinkHints.activateModeWithQueue",
"LinkHints.activateModeToDownloadLink",
"Vomnibar.activateEditUrl",
"Vomnibar.activateEditUrlInNewTab",
"LinkHints.activateModeToOpenIncognito",
"LinkHints.activateModeToCopyLinkUrl",
"goNext",
"goPrevious",
"Marks.activateCreateMode",
"Marks.activateGotoMode",
"moveTabLeft",
"moveTabRight",
"closeTabsOnLeft",
"closeTabsOnRight",
"closeOtherTabs",
"enterVisualLineMode",
"toggleViewSource",
"passNextKey"]
};
const defaultKeyMappings = {
// Navigating the current page
"j": "scrollDown",
"k": "scrollUp",
"h": "scrollLeft",
"l": "scrollRight",
"gg": "scrollToTop",
"G": "scrollToBottom",
"zH": "scrollToLeft",
"zL": "scrollToRight",
"<c-e>": "scrollDown",
"<c-y>": "scrollUp",
"d": "scrollPageDown",
"u": "scrollPageUp",
"r": "reload",
"yy": "copyCurrentUrl",
"p": "openCopiedUrlInCurrentTab",
"P": "openCopiedUrlInNewTab",
"gi": "focusInput",
"[[": "goPrevious",
"]]": "goNext",
"gf": "nextFrame",
"gF": "mainFrame",
"gu": "goUp",
"gU": "goToRoot",
"i": "enterInsertMode",
"v": "enterVisualMode",
"V": "enterVisualLineMode",
// Link hints
"f": "LinkHints.activateMode",
"F": "LinkHints.activateModeToOpenInNewTab",
"<a-f>": "LinkHints.activateModeWithQueue",
"yf": "LinkHints.activateModeToCopyLinkUrl",
// Using find
"/": "enterFindMode",
"n": "performFind",
"N": "performBackwardsFind",
// Vomnibar
"o": "Vomnibar.activate",
"O": "Vomnibar.activateInNewTab",
"T": "Vomnibar.activateTabSelection",
"b": "Vomnibar.activateBookmarks",
"B": "Vomnibar.activateBookmarksInNewTab",
"ge": "Vomnibar.activateEditUrl",
"gE": "Vomnibar.activateEditUrlInNewTab",
// Navigating history
"H": "goBack",
"L": "goForward",
// Manipulating tabs
"K": "nextTab",
"J": "previousTab",
"gt": "nextTab",
"gT": "previousTab",
"^": "visitPreviousTab",
"<<": "moveTabLeft",
">>": "moveTabRight",
"g0": "firstTab",
"g$": "lastTab",
"W": "moveTabToNewWindow",
"t": "createTab",
"yt": "duplicateTab",
"x": "removeTab",
"X": "restoreTab",
"<a-p>": "togglePinTab",
"<a-m>": "toggleMuteTab",
// Marks
"m": "Marks.activateCreateMode",
"`": "Marks.activateGotoMode",
// Misc
"?": "showHelp",
"gs": "toggleViewSource"
};
// This is a mapping of: commandIdentifier => [description, options].
// If the noRepeat and repeatLimit options are both specified, then noRepeat takes precedence.
const commandDescriptions = {
// Navigating the current page
showHelp: ["Show help", { topFrame: true, noRepeat: true }],
scrollDown: ["Scroll down"],
scrollUp: ["Scroll up"],
scrollLeft: ["Scroll left"],
scrollRight: ["Scroll right"],
scrollToTop: ["Scroll to the top of the page"],
scrollToBottom: ["Scroll to the bottom of the page", { noRepeat: true }],
scrollToLeft: ["Scroll all the way to the left", { noRepeat: true }],
scrollToRight: ["Scroll all the way to the right", { noRepeat: true }],
scrollPageDown: ["Scroll a half page down"],
scrollPageUp: ["Scroll a half page up"],
scrollFullPageDown: ["Scroll a full page down"],
scrollFullPageUp: ["Scroll a full page up"],
reload: ["Reload the page", { background: true }],
toggleViewSource: ["View page source", { noRepeat: true }],
copyCurrentUrl: ["Copy the current URL to the clipboard", { noRepeat: true }],
openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { noRepeat: true }],
openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { repeatLimit: 20 }],
enterInsertMode: ["Enter insert mode", { noRepeat: true }],
passNextKey: ["Pass the next key to the page"],
enterVisualMode: ["Enter visual mode", { noRepeat: true }],
enterVisualLineMode: ["Enter visual line mode", { noRepeat: true }],
focusInput: ["Focus the first text input on the page"],
"LinkHints.activateMode": ["Open a link in the current tab"],
"LinkHints.activateModeToOpenInNewTab": ["Open a link in a new tab"],
"LinkHints.activateModeToOpenInNewForegroundTab": ["Open a link in a new tab & switch to it"],
"LinkHints.activateModeWithQueue": ["Open multiple links in a new tab", { noRepeat: true }],
"LinkHints.activateModeToOpenIncognito": ["Open a link in incognito window"],
"LinkHints.activateModeToDownloadLink": ["Download link url"],
"LinkHints.activateModeToCopyLinkUrl": ["Copy a link URL to the clipboard"],
enterFindMode: ["Enter find mode", { noRepeat: true }],
performFind: ["Cycle forward to the next find match"],
performBackwardsFind: ["Cycle backward to the previous find match"],
goPrevious: ["Follow the link labeled previous or <", { noRepeat: true }],
goNext: ["Follow the link labeled next or >", { noRepeat: true }],
// Navigating your history
goBack: ["Go back in history"],
goForward: ["Go forward in history"],
// Navigating the URL hierarchy
goUp: ["Go up the URL hierarchy"],
goToRoot: ["Go to root of current URL hierarchy"],
// Manipulating tabs
nextTab: ["Go one tab right", { background: true }],
previousTab: ["Go one tab left", { background: true }],
visitPreviousTab: ["Go to previously-visited tab", { background: true }],
firstTab: ["Go to the first tab", { background: true }],
lastTab: ["Go to the last tab", { background: true }],
createTab: ["Create new tab", { background: true, repeatLimit: 20 }],
duplicateTab: ["Duplicate current tab", { background: true, repeatLimit: 20 }],
removeTab: ["Close current tab", { background: true,
repeatLimit: (chrome.session ? chrome.session.MAX_SESSION_RESULTS : null) || 25 }],
restoreTab: ["Restore closed tab", { background: true, repeatLimit: 20 }],
moveTabToNewWindow: ["Move tab to new window", { background: true }],
togglePinTab: ["Pin or unpin current tab", { background: true }],
toggleMuteTab: ["Mute or unmute current tab", { background: true, noRepeat: true }],
closeTabsOnLeft: ["Close tabs on the left", {background: true, noRepeat: true}],
closeTabsOnRight: ["Close tabs on the right", {background: true, noRepeat: true}],
closeOtherTabs: ["Close all other tabs", {background: true, noRepeat: true}],
moveTabLeft: ["Move tab to the left", { background: true }],
moveTabRight: ["Move tab to the right", { background: true }],
"Vomnibar.activate": ["Open URL, bookmark or history entry", { topFrame: true }],
"Vomnibar.activateInNewTab": ["Open URL, bookmark or history entry in a new tab", { topFrame: true }],
"Vomnibar.activateTabSelection": ["Search through your open tabs", { topFrame: true }],
"Vomnibar.activateBookmarks": ["Open a bookmark", { topFrame: true }],
"Vomnibar.activateBookmarksInNewTab": ["Open a bookmark in a new tab", { topFrame: true }],
"Vomnibar.activateEditUrl": ["Edit the current URL", { topFrame: true }],
"Vomnibar.activateEditUrlInNewTab": ["Edit the current URL and open in a new tab", { topFrame: true }],
nextFrame: ["Select the next frame on the page", { background: true }],
mainFrame: ["Select the page's main/top frame", { topFrame: true, noRepeat: true }],
"Marks.activateCreateMode": ["Create a new mark", { noRepeat: true }],
"Marks.activateGotoMode": ["Go to a mark", { noRepeat: true }]
};
Commands.init();
global.Commands = Commands;

View file

@ -0,0 +1,258 @@
// A completion engine provides search suggestions for a custom search engine. A custom search engine is
// identified by a "searchUrl". An "engineUrl" is used for fetching suggestions, whereas a "searchUrl" is used
// for the actual search itself.
//
// Each completion engine defines:
//
// 1. An "engineUrl". This is the URL to use for search completions and is passed as the option "engineUrl"
// to the "BaseEngine" constructor.
//
// 2. One or more regular expressions which define the custom search engine URLs for which the completion
// engine will be used. This is passed as the "regexps" option to the "BaseEngine" constructor.
//
// 3. A "parse" function. This takes a successful XMLHttpRequest object (the request has completed
// successfully), and returns a list of suggestions (a list of strings). This method is always executed
// within the context of a try/catch block, so errors do not propagate.
//
// 4. Each completion engine *must* include an example custom search engine. The example must include an
// example "keyword" and an example "searchUrl", and may include an example "description" and an
// "explanation".
//
// Each new completion engine must be added to the list "CompletionEngines" at the bottom of this file.
//
// The lookup logic which uses these completion engines is in "./completion_search.js".
//
// A base class for common regexp-based matching engines. "options" must define:
// options.engineUrl: the URL to use for the completion engine. This must be a string.
// options.regexps: one or regular expressions. This may either a single string or a list of strings.
// options.example: an example object containing at least "keyword" and "searchUrl", and optional "description".
class BaseEngine {
constructor(options) {
Object.assign(this, options);
this.regexps = this.regexps.map(regexp => new RegExp(regexp));
}
match(searchUrl) { return Utils.matchesAnyRegexp(this.regexps, searchUrl); }
getUrl(queryTerms) { return Utils.createSearchUrl(queryTerms, this.engineUrl); }
}
// Several Google completion engines package responses as XML. This parses such XML.
class GoogleXMLBaseEngine extends BaseEngine {
parse(xhr) {
return Array.from(xhr.responseXML.getElementsByTagName("suggestion"))
.map(suggestion => suggestion.getAttribute("data"))
.filter(suggestion => suggestion);
}
}
class Google extends GoogleXMLBaseEngine {
constructor() {
super({
engineUrl: "https://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s",
regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/"],
example: {
searchUrl: "https://www.google.com/search?q=%s",
keyword: "g"
}
});
}
}
class GoogleMaps extends GoogleXMLBaseEngine {
constructor() {
const q = GoogleMaps.prefix.split(" ").join("+");
super({
engineUrl: `https://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=${q}%s`,
regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/maps"],
example: {
searchUrl: "https://www.google.com/maps?q=%s",
keyword: "m",
explanation:
`\
This uses regular Google completion, but prepends the text "<tt>map of</tt>" to the query. It works
well for places, countries, states, geographical regions and the like, but will not perform address
search.\
`
}
});
}
parse(xhr) {
return Array.from(super.parse(xhr))
.filter(suggestion => suggestion.startsWith(GoogleMaps.prefix))
.map(suggestion => suggestion.slice(GoogleMaps.prefix.length));
}
}
GoogleMaps.prefix = "map of";
class Youtube extends GoogleXMLBaseEngine {
constructor() {
super({
engineUrl: "https://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=%s",
regexps: ["^https?://[a-z]+\\.youtube\\.com/results"],
example: {
searchUrl: "https://www.youtube.com/results?search_query=%s",
keyword: "y"
}
});
}
}
class Wikipedia extends BaseEngine {
constructor() {
super({
engineUrl: "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s",
regexps: ["^https?://[a-z]+\\.wikipedia\\.org/"],
example: {
searchUrl: "https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s",
keyword: "w"
}
});
}
parse(xhr) { return JSON.parse(xhr.responseText)[1]; }
}
class Bing extends BaseEngine {
constructor() {
super({
engineUrl: "https://api.bing.com/osjson.aspx?query=%s",
regexps: ["^https?://www\\.bing\\.com/search"],
example: {
searchUrl: "https://www.bing.com/search?q=%s",
keyword: "b"
}
});
}
parse(xhr) { return JSON.parse(xhr.responseText)[1]; }
}
class Amazon extends BaseEngine {
constructor() {
super({
engineUrl: "https://completion.amazon.com/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=1&q=%s",
regexps: ["^https?://www\\.amazon\\.(com|co\\.uk|ca|de|com\\.au)/s/"],
example: {
searchUrl: "https://www.amazon.com/s/?field-keywords=%s",
keyword: "a"
}
});
}
parse(xhr) { return JSON.parse(xhr.responseText)[1]; }
}
class AmazonJapan extends BaseEngine {
constructor() {
super({
engineUrl: "https://completion.amazon.co.jp/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=6&q=%s",
regexps: ["^https?://www\\.amazon\\.co\\.jp/(s/|gp/search)"],
example: {
searchUrl: "https://www.amazon.co.jp/s/?field-keywords=%s",
keyword: "aj"
}
});
}
parse(xhr) { return JSON.parse(xhr.responseText)[1]; }
}
class DuckDuckGo extends BaseEngine {
constructor() {
super({
engineUrl: "https://duckduckgo.com/ac/?q=%s",
regexps: ["^https?://([a-z]+\\.)?duckduckgo\\.com/"],
example: {
searchUrl: "https://duckduckgo.com/?q=%s",
keyword: "d"
}
});
}
parse(xhr) {
return Array.from(JSON.parse(xhr.responseText)).map((suggestion) => suggestion.phrase);
}
}
class Webster extends BaseEngine {
constructor() {
super({
engineUrl: "https://www.merriam-webster.com/lapi/v1/mwol-search/autocomplete?search=%s",
regexps: ["^https?://www.merriam-webster.com/dictionary/"],
example: {
searchUrl: "https://www.merriam-webster.com/dictionary/%s",
keyword: "dw",
description: "Dictionary"
}
});
}
parse(xhr) {
return Array.from(JSON.parse(xhr.responseText).docs).map((suggestion) => suggestion.word);
}
}
class Qwant extends BaseEngine {
constructor() {
super({
engineUrl: "https://api.qwant.com/api/suggest?q=%s",
regexps: ["^https?://www\\.qwant\\.com/"],
example: {
searchUrl: "https://www.qwant.com/?q=%s",
keyword: "qw"
}
});
}
parse(xhr) {
return Array.from(JSON.parse(xhr.responseText).data.items).map((suggestion) => suggestion.value);
}
}
class UpToDate extends BaseEngine {
constructor() {
super({
engineUrl: "https://www.uptodate.com/services/app/contents/search/autocomplete/json?term=%s&limit=10",
regexps: ["^https?://www\\.uptodate\\.com/"],
example: {
searchUrl: "https://www.uptodate.com/contents/search?search=%s&searchType=PLAIN_TEXT&source=USER_INPUT&searchControl=TOP_PULLDOWN&autoComplete=false",
keyword: "upto"
}
});
}
parse(xhr) { return JSON.parse(xhr.responseText).data.searchTerms; }
}
// A dummy search engine which is guaranteed to match any search URL, but never produces completions. This
// allows the rest of the logic to be written knowing that there will always be a completion engine match.
class DummyCompletionEngine extends BaseEngine {
constructor() {
super({
regexps: ["."],
dummy: true
});
}
}
// Note: Order matters here.
const CompletionEngines = [
Youtube,
GoogleMaps,
Google,
DuckDuckGo,
Wikipedia,
Bing,
Amazon,
AmazonJapan,
Webster,
Qwant,
UpToDate,
DummyCompletionEngine
];
global.CompletionEngines = CompletionEngines;

View file

@ -0,0 +1,216 @@
// This is a wrapper class for completion engines. It handles the case where a custom search engine includes a
// prefix query term (or terms). For example:
//
// https://www.google.com/search?q=javascript+%s
//
// In this case, we get better suggestions if we include the term "javascript" in queries sent to the
// completion engine. This wrapper handles adding such prefixes to completion-engine queries and removing them
// from the resulting suggestions.
class EnginePrefixWrapper {
constructor(searchUrl, engine) {
this.searchUrl = searchUrl;
this.engine = engine;
}
getUrl(queryTerms) {
// This tests whether @searchUrl contains something of the form "...=abc+def+%s...", from which we extract
// a prefix of the form "abc def ".
if (/\=.+\+%s/.test(this.searchUrl)) {
let terms = this.searchUrl.replace(/\+%s.*/, "");
terms = terms.replace(/.*=/, "");
terms = terms.replace(/\+/g, " ");
queryTerms = [ ...terms.split(" "), ...queryTerms ];
const prefix = `${terms} `;
this.postprocessSuggestions = (suggestions) => {
return suggestions
.filter(s => s.startsWith(prefix))
.map(s => s.slice(prefix.length));
};
}
return this.engine.getUrl(queryTerms);
}
parse(xhr) {
return this.postprocessSuggestions(this.engine.parse(xhr));
}
postprocessSuggestions(suggestions) { return suggestions; }
}
const CompletionSearch = {
debug: false,
inTransit: {},
completionCache: new SimpleCache(2 * 60 * 60 * 1000, 5000), // Two hours, 5000 entries.
engineCache:new SimpleCache(1000 * 60 * 60 * 1000), // 1000 hours.
// The amount of time to wait for new requests before launching the current request (for example, if the user
// is still typing).
delay: 100,
get(searchUrl, url, callback) {
const xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.timeout = 2500;
// According to https://xhr.spec.whatwg.org/#request-error-steps,
// readystatechange always gets called whether a request succeeds or not,
// and the `readyState == 4` means an associated `state` is "done", which is true even if any error happens
xhr.onreadystatechange = function() {
if (xhr.readyState === 4)
return callback(xhr.status === 200 ? xhr : null);
};
return xhr.send();
},
// Look up the completion engine for this searchUrl. Because of DummyCompletionEngine, we know there will
// always be a match.
lookupEngine(searchUrl) {
if (this.engineCache.has(searchUrl)) {
return this.engineCache.get(searchUrl);
} else {
for (let engine of Array.from(CompletionEngines)) {
engine = new engine();
if (engine.match(searchUrl))
return this.engineCache.set(searchUrl, engine);
}
}
},
// True if we have a completion engine for this search URL, false otherwise.
haveCompletionEngine(searchUrl) {
return !this.lookupEngine(searchUrl).dummy;
},
// This is the main entry point.
// - searchUrl is the search engine's URL, e.g. Settings.get("searchUrl"), or a custom search engine's URL.
// This is only used as a key for determining the relevant completion engine.
// - queryTerms are the query terms.
// - callback will be applied to a list of suggestion strings (which may be an empty list, if anything goes
// wrong).
//
// If no callback is provided, then we're to provide suggestions only if we can do so synchronously (ie.
// from a cache). In this case we just return the results. Returns null if we cannot service the request
// synchronously.
//
complete(searchUrl, queryTerms, callback = null) {
let handler;
const query = queryTerms.join(" ").toLowerCase();
const returnResultsOnlyFromCache = (callback == null);
if (callback == null) { callback = suggestions => suggestions; }
// We don't complete queries which are too short: the results are usually useless.
if (query.length < 4)
return callback([]);
// We don't complete regular URLs or Javascript URLs.
if (queryTerms.length == 1 && Utils.isUrl(query))
return callback([]);
if (Utils.hasJavascriptPrefix(query))
return callback([]);
const completionCacheKey = JSON.stringify([ searchUrl, queryTerms ]);
if (this.completionCache.has(completionCacheKey)) {
if (this.debug)
console.log("hit", completionCacheKey);
return callback(this.completionCache.get(completionCacheKey));
}
// If the user appears to be typing a continuation of the characters of the most recent query, then we can
// sometimes re-use the previous suggestions.
if ((this.mostRecentQuery != null) && (this.mostRecentSuggestions != null) && (this.mostRecentSearchUrl != null)) {
if (searchUrl === this.mostRecentSearchUrl) {
const reusePreviousSuggestions = (() => {
// Verify that the previous query is a prefix of the current query.
if (!query.startsWith(this.mostRecentQuery.toLowerCase()))
return false;
// Verify that every previous suggestion contains the text of the new query.
// Note: @mostRecentSuggestions may also be empty, in which case we drop though. The effect is that
// previous queries with no suggestions suppress subsequent no-hope HTTP requests as the user
// continues to type.
for (let suggestion of this.mostRecentSuggestions)
if (!suggestion.includes(query))
return false;
// Ok. Re-use the suggestion.
return true;
})();
if (reusePreviousSuggestions) {
if (this.debug)
console.log("reuse previous query:", this.mostRecentQuery, this.mostRecentSuggestions.length);
return callback(this.completionCache.set(completionCacheKey, this.mostRecentSuggestions));
}
}
}
// That's all of the caches we can try. Bail if the caller is only requesting synchronous results. We
// signal that we haven't found a match by returning null.
if (returnResultsOnlyFromCache)
return callback(null);
// We pause in case the user is still typing.
Utils.setTimeout(this.delay, (handler = (this.mostRecentHandler = () => {
if (handler !== this.mostRecentHandler)
return;
this.mostRecentHandler = null;
// Elide duplicate requests. First fetch the suggestions...
if (this.inTransit[completionCacheKey] == null) {
this.inTransit[completionCacheKey] = new AsyncDataFetcher(callback => {
const engine = new EnginePrefixWrapper(searchUrl, this.lookupEngine(searchUrl));
const url = engine.getUrl(queryTerms);
// TODO(philc): Do we need to return the result of this.get here, or can we remove this return statement?
return this.get(searchUrl, url, (xhr = null) => {
// Parsing the response may fail if we receive an unexpected or an unexpectedly-formatted response.
// In all cases, we fall back to the catch clause, below. Therefore, we "fail safe" in the case of
// incorrect or out-of-date completion engines.
let suggestions;
try {
suggestions = engine.parse(xhr)
// Make all suggestions lower case. It looks odd when suggestions from one completion engine are
// upper case, and those from another are lower case.
.map(s => s.toLowerCase())
// Filter out the query itself. It's not adding anything.
.filter(s => s !== query);
if (this.debug)
console.log("GET", url);
} catch (error) {
suggestions = [];
// We allow failures to be cached too, but remove them after just thirty seconds.
Utils.setTimeout(30 * 1000, () => this.completionCache.set(completionCacheKey, null));
if (this.debug)
console.log("fail", url);
}
callback(suggestions);
delete this.inTransit[completionCacheKey];
});
});
}
// ... then use the suggestions.
this.inTransit[completionCacheKey].use(suggestions => {
this.mostRecentSearchUrl = searchUrl;
this.mostRecentQuery = query;
this.mostRecentSuggestions = suggestions;
// TODO(philc): Is this return necessary?
return callback(this.completionCache.set(completionCacheKey, suggestions));
});
})));
},
// Cancel any pending (ie. blocked on @delay) queries. Does not cancel in-flight queries. This is called
// whenever the user is typing.
cancel() {
if (this.mostRecentHandler != null) {
this.mostRecentHandler = null;
if (this.debug)
console.log("cancel (user is typing)");
}
}
};
global.CompletionSearch = CompletionSearch;

View file

@ -0,0 +1,77 @@
const ExclusionRegexpCache = {
cache: {},
clear(cache) {
this.cache = cache || {};
},
get(pattern) {
if (pattern in this.cache) {
return this.cache[pattern];
} else {
let result;
// We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium.
try {
result = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
} catch (error) {
BgUtils.log(`bad regexp in exclusion rule: ${pattern}`);
result = /^$/; // Match the empty string.
}
this.cache[pattern] = result;
return result;
}
}
};
// The Exclusions class manages the exclusion rule setting. An exclusion is an object with two attributes:
// pattern and passKeys. The exclusion rules are an array of such objects.
var Exclusions = {
// Make RegexpCache, which is required on the page popup, accessible via the Exclusions object.
RegexpCache: ExclusionRegexpCache,
rules: Settings.get("exclusionRules"),
// Merge the matching rules for URL, or null. In the normal case, we use the configured @rules; hence, this
// is the default. However, when called from the page popup, we are testing what effect candidate new rules
// would have on the current tab. In this case, the candidate rules are provided by the caller.
getRule(url, rules) {
if (rules == null)
rules = this.rules;
const matchingRules = rules.filter(r => r.pattern && (url.search(ExclusionRegexpCache.get(r.pattern)) >= 0));
// An absolute exclusion rule (one with no passKeys) takes priority.
for (let rule of matchingRules)
if (!rule.passKeys)
return rule;
// Strip whitespace from all matching passKeys strings, and join them together.
const passKeys = matchingRules.map(r => r.passKeys.split(/\s+/).join("")).join("");
// passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matchingRules).join ""
if (matchingRules.length > 0)
return {passKeys: Utils.distinctCharacters(passKeys)};
else
return null;
},
isEnabledForUrl(url) {
const rule = Exclusions.getRule(url);
return {
isEnabledForUrl: !rule || (rule.passKeys.length > 0),
passKeys: rule ? rule.passKeys : ""
};
},
setRules(rules) {
// Callers map a rule to null to have it deleted, and rules without a pattern are useless.
this.rules = rules.filter(rule => rule && rule.pattern);
Settings.set("exclusionRules", this.rules);
},
postUpdateHook(rules) {
// NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions popup is
// closed. Do NOT store it/use it asynchronously.
this.rules = Settings.get("exclusionRules");
ExclusionRegexpCache.clear();
}
};
// Register postUpdateHook for exclusionRules setting.
Settings.postUpdateHooks["exclusionRules"] = Exclusions.postUpdateHook.bind(Exclusions);
global.Exclusions = Exclusions;

View file

@ -0,0 +1,719 @@
// NOTE(philc): This file has many superfluous return statements in its functions, as a result of
// converting from coffeescript to es6. Many can be removed, but I didn't take the time to diligently
// track down precisely which return statements could be removed when I was doing the conversion.
let showUpgradeMessage;
// The browser may have tabs already open. We inject the content scripts immediately so that they work straight
// away.
chrome.runtime.onInstalled.addListener(function({ reason }) {
// See https://developer.chrome.com/extensions/runtime#event-onInstalled
if ([ "chrome_update", "shared_module_update" ].includes(reason)) { return; }
if (Utils.isFirefox()) { return; }
const manifest = chrome.runtime.getManifest();
// Content scripts loaded on every page should be in the same group. We assume it is the first.
const contentScripts = manifest.content_scripts[0];
const jobs = [ [ chrome.tabs.executeScript, contentScripts.js ], [ chrome.tabs.insertCSS, contentScripts.css ] ];
// Chrome complains if we don't evaluate chrome.runtime.lastError on errors (and we get errors for tabs on
// which Vimium cannot run).
const checkLastRuntimeError = () => chrome.runtime.lastError;
return chrome.tabs.query({ status: "complete" }, function(tabs) {
for (let tab of tabs)
for (let [ func, files ] of jobs)
for (let file of files)
func(tab.id, { file, allFrames: contentScripts.all_frames }, checkLastRuntimeError);
});
});
const frameIdsForTab = {};
global.portsForTab = {};
global.urlForTab = {};
// This is exported for use by "marks.js".
global.tabLoadedHandlers = {}; // tabId -> function()
// A secret, available only within the current instantiation of Vimium, for the duration of the browser
// session. The secret is a generated strong random string.
const randomArray = window.crypto.getRandomValues(new Uint8Array(32)); // 32-byte random token.
const secretToken = randomArray.reduce((a,b) => a.toString(16) + b.toString(16));
chrome.storage.local.set({vimiumSecret: secretToken});
const completionSources = {
bookmarks: new BookmarkCompleter,
history: new HistoryCompleter,
domains: new DomainCompleter,
tabs: new TabCompleter,
searchEngines: new SearchEngineCompleter
};
const completers = {
omni: new MultiCompleter([
completionSources.bookmarks,
completionSources.history,
completionSources.domains,
completionSources.tabs,
completionSources.searchEngines
]),
bookmarks: new MultiCompleter([completionSources.bookmarks]),
tabs: new MultiCompleter([completionSources.tabs])
};
const completionHandlers = {
filter(completer, request, port) {
// TODO(philc): Do we need any of these return statements?
return completer.filter(request, function(response) {
// NOTE(smblott): response contains `relevancyFunction` (function) properties which cause postMessage,
// below, to fail in Firefox. See #2576. We cannot simply delete these methods, as they're needed
// elsewhere. Converting the response to JSON and back is a quick and easy way to sanitize the object.
response = JSON.parse(JSON.stringify(response));
// We use try here because this may fail if the sender has already navigated away from the original page.
// This can happen, for example, when posting completion suggestions from the SearchEngineCompleter
// (which is done asynchronously).
try {
return port.postMessage(Object.assign(request, response, {handler: "completions"}));
} catch (error) {}
});
},
refresh(completer, _, port) { completer.refresh(port); },
cancel(completer, _, port) { completer.cancel(port); }
};
const handleCompletions = sender => (request, port) =>
completionHandlers[request.handler](completers[request.name], request, port);
chrome.runtime.onConnect.addListener(function(port) {
if (portHandlers[port.name])
return port.onMessage.addListener(portHandlers[port.name](port.sender, port));
});
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
request = Object.assign({count: 1, frameId: sender.frameId},
request,
{tab: sender.tab, tabId: sender.tab.id});
if (sendRequestHandlers[request.handler]) {
sendResponse(sendRequestHandlers[request.handler](request, sender));
}
// Ensure that the sendResponse callback is freed.
return false;
});
const onURLChange = details => chrome.tabs.sendMessage(details.tabId, {name: "checkEnabledAfterURLChange"});
// Re-check whether Vimium is enabled for a frame when the url changes without a reload.
chrome.webNavigation.onHistoryStateUpdated.addListener(onURLChange); // history.pushState.
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onURLChange); // Hash changed.
// Cache "content_scripts/vimium.css" in chrome.storage.local for UI components.
(function() {
const req = new XMLHttpRequest();
req.open("GET", chrome.runtime.getURL("content_scripts/vimium.css"), true); // true -> asynchronous.
req.onload = function() {
const {status, responseText} = req;
if (status === 200)
return chrome.storage.local.set({vimiumCSSInChromeStorage: responseText});
};
return req.send();
})();
const TabOperations = {
// Opens the url in the current tab.
openUrlInCurrentTab(request) {
if (Utils.hasJavascriptPrefix(request.url)) {
const tabId = request.tabId;
const frameId = request.frameId;
chrome.tabs.sendMessage(tabId, {frameId, name: "executeScript", script: request.url});
} else {
chrome.tabs.update(request.tabId, {url: Utils.convertToUrl(request.url)});
}
},
// Opens request.url in new tab and switches to it.
openUrlInNewTab(request, callback) {
if (callback == null)
callback = function() {};
const tabConfig = {
url: Utils.convertToUrl(request.url),
active: true,
windowId: request.tab.windowId
};
const position = request.position;
let tabIndex = null;
// TODO(philc): Convert to a switch statement ES6.
switch (position) {
case "start": tabIndex = 0; break;
case "before": tabIndex = request.tab.index; break;
// if on Chrome or on Firefox but without openerTabId, `tabs.create` opens a tab at the end.
// but on Firefox and with openerTabId, it opens a new tab next to the opener tab
case "end": tabIndex = (Utils.isFirefox() ? 9999 : null); break;
// "after" is the default case when there are no options.
default: tabIndex = request.tab.index + 1;
}
tabConfig.index = tabIndex;
if (request.active != null)
tabConfig.active = request.active;
// Firefox does not support "about:newtab" in chrome.tabs.create.
if (tabConfig["url"] === Settings.defaults.newTabUrl)
delete tabConfig["url"];
// Firefox <57 throws an error when openerTabId is used (issue 1238314).
const canUseOpenerTabId = !(Utils.isFirefox() && (Utils.compareVersions(Utils.firefoxVersion(), "57") < 0));
if (canUseOpenerTabId)
tabConfig.openerTabId = request.tab.id;
// clean position and active, so following `openUrlInNewTab(request)` will create a tab just next to this new tab
return chrome.tabs.create(tabConfig, tab =>
callback(Object.assign(request, {tab, tabId: tab.id, position: "", active: false})));
},
// Opens request.url in new window and switches to it.
openUrlInNewWindow(request, callback) {
if (callback == null)
callback = function() {};
const winConfig = {
url: Utils.convertToUrl(request.url),
active: true
};
if (request.active != null)
winConfig.active = request.active;
// Firefox does not support "about:newtab" in chrome.tabs.create.
if (winConfig["url"] === Settings.defaults.newTabUrl)
delete winConfig["url"];
return chrome.windows.create(winConfig, callback);
}
};
const muteTab = tab => chrome.tabs.update(tab.id, {muted: !tab.mutedInfo.muted});
const toggleMuteTab = function({tab: currentTab, registryEntry, tabId, frameId}) {
if ((registryEntry.options.all != null) || (registryEntry.options.other != null)) {
// If there are any audible, unmuted tabs, then we mute them; otherwise we unmute any muted tabs.
chrome.tabs.query({audible: true}, function(tabs) {
let tab;
if (registryEntry.options.other != null)
tabs = tabs.filter(t => t.id !== currentTab.id);
const audibleUnmutedTabs = tabs.filter(t => t.audible && !t.mutedInfo.muted);
if (audibleUnmutedTabs.length >= 0) {
chrome.tabs.sendMessage(tabId, {frameId, name: "showMessage", message: `Muting ${audibleUnmutedTabs.length} tab(s).`});
for (tab of audibleUnmutedTabs)
muteTab(tab);
} else {
chrome.tabs.sendMessage(tabId, {frameId, name: "showMessage", message: "Unmuting all muted tabs."});
for (tab of tabs)
if (tab.mutedInfo.muted)
muteTab(tab);
}
});
} else {
if (currentTab.mutedInfo.muted)
chrome.tabs.sendMessage(tabId, {frameId, name: "showMessage", message: "Unmuted tab."});
else
chrome.tabs.sendMessage(tabId, {frameId, name: "showMessage", message: "Muted tab."});
muteTab(currentTab);
}
};
//
// Selects the tab with the ID specified in request.id
//
const selectSpecificTab = request => chrome.tabs.get(request.id, function(tab) {
if (chrome.windows != null)
chrome.windows.update(tab.windowId, { focused: true });
return chrome.tabs.update(request.id, { active: true });
});
const moveTab = function({count, tab, registryEntry}) {
if (registryEntry.command === "moveTabLeft")
count = -count;
return chrome.tabs.query({ currentWindow: true }, function(tabs) {
const pinnedCount = (tabs.filter(tab => tab.pinned)).length;
const minIndex = tab.pinned ? 0 : pinnedCount;
const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1;
return chrome.tabs.move(tab.id,
{index: Math.max(minIndex, Math.min(maxIndex, tab.index + count))});
});
};
var mkRepeatCommand = command => (function(request) {
request.count--;
if (request.count >= 0)
return command(request, request => (mkRepeatCommand(command))(request));
});
// These are commands which are bound to keystrokes which must be handled by the background page. They are
// mapped in commands.coffee.
const BackgroundCommands = {
// Create a new tab. Also, with:
// map X createTab http://www.bbc.com/news
// create a new tab with the given URL.
createTab: mkRepeatCommand(function(request, callback) {
if (request.urls == null) {
if (request.url) {
// If the request contains a URL, then use it.
request.urls = [request.url];
} else {
// Otherwise, if we have a registryEntry containing URLs, then use them.
const urlList = request.registryEntry.optionList.filter(opt => Utils.isUrl(opt));
if (urlList.length > 0) {
request.urls = urlList;
} else {
// Otherwise, just create a new tab.
const newTabUrl = Settings.get("newTabUrl");
if (newTabUrl === "pages/blank.html") {
// "pages/blank.html" does not work in incognito mode, so fall back to "chrome://newtab" instead.
request.urls = [request.tab.incognito ? "chrome://newtab" : chrome.runtime.getURL(newTabUrl)];
} else {
request.urls = [newTabUrl];
}
}
}
}
if (request.registryEntry.options.incognito || request.registryEntry.options.window) {
const windowConfig = {
url: request.urls,
incognito: request.registryEntry.options.incognito || false
};
return chrome.windows.create(windowConfig, () => callback(request));
} else {
let openNextUrl;
const urls = request.urls.slice().reverse();
if (request.position == null)
request.position = request.registryEntry.options.position;
return (openNextUrl = function(request) {
if (urls.length > 0)
return TabOperations.openUrlInNewTab((Object.assign(request, {url: urls.pop()})), openNextUrl);
else
return callback(request);
})(request);
}
}),
duplicateTab: mkRepeatCommand((request, callback) => {
return chrome.tabs.duplicate(request.tabId,
tab => callback(Object.assign(request, {tab, tabId: tab.id})))
}),
moveTabToNewWindow({count, tab}) {
chrome.tabs.query({currentWindow: true}, function(tabs) {
const activeTabIndex = tab.index;
const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count));
[ tab, ...tabs ] = tabs.slice(startTabIndex, startTabIndex + count);
chrome.windows.create({tabId: tab.id, incognito: tab.incognito}, function(window) {
chrome.tabs.move(tabs.map(t => t.id), {windowId: window.id, index: -1});
});
});
},
nextTab(request) { return selectTab("next", request); },
previousTab(request) { return selectTab("previous", request); },
firstTab(request) { return selectTab("first", request); },
lastTab(request) { return selectTab("last", request); },
removeTab({count, tab}) { return forCountTabs(count, tab, tab => chrome.tabs.remove(tab.id)); },
restoreTab: mkRepeatCommand((request, callback) => chrome.sessions.restore(null, callback(request))),
togglePinTab({count, tab}) { return forCountTabs(count, tab, tab => chrome.tabs.update(tab.id, {pinned: !tab.pinned})); },
toggleMuteTab,
moveTabLeft: moveTab,
moveTabRight: moveTab,
nextFrame({count, frameId, tabId}) {
frameIdsForTab[tabId] = cycleToFrame(frameIdsForTab[tabId], frameId, count);
return chrome.tabs.sendMessage(tabId, {name: "focusFrame", frameId: frameIdsForTab[tabId][0], highlight: true});
},
closeTabsOnLeft(request) { return removeTabsRelative("before", request); },
closeTabsOnRight(request) { return removeTabsRelative("after", request); },
closeOtherTabs(request) { return removeTabsRelative("both", request); },
visitPreviousTab({count, tab}) {
const tabIds = BgUtils.tabRecency.getTabsByRecency().filter(tabId => tabId !== tab.id);
if (tabIds.length > 0)
return selectSpecificTab({id: tabIds[(count-1) % tabIds.length]});
},
reload({count, tabId, registryEntry, tab: {windowId}}){
const bypassCache = registryEntry.options.hard != null ? registryEntry.options.hard : false;
return chrome.tabs.query({windowId}, function(tabs) {
const position = (function() {
for (let index = 0; index < tabs.length; index++) {
const tab = tabs[index];
if (tab.id === tabId) { return index; }
}
})();
tabs = [...tabs.slice(position), ...tabs.slice(0, position)];
count = Math.min(count, tabs.length);
for (let tab of tabs.slice(0, count)) {
chrome.tabs.reload(tab.id, {bypassCache});
}
});
}
};
var forCountTabs = (count, currentTab, callback) => chrome.tabs.query({currentWindow: true}, function(tabs) {
const activeTabIndex = currentTab.index;
const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count));
for (let tab of tabs.slice(startTabIndex, startTabIndex + count))
callback(tab);
});
// Remove tabs before, after, or either side of the currently active tab
var removeTabsRelative = (direction, {tab: activeTab}) => chrome.tabs.query({currentWindow: true}, function(tabs) {
const shouldDelete =
(() => { switch (direction) {
case "before":
return index => index < activeTab.index;
case "after":
return index => index > activeTab.index;
case "both":
return index => index !== activeTab.index;
} })();
chrome.tabs.remove(tabs.filter(t => !t.pinned && shouldDelete(t.index))
.map((t) => t.id));
});
// Selects a tab before or after the currently selected tab.
// - direction: "next", "previous", "first" or "last".
var selectTab = (direction, {count, tab}) => chrome.tabs.query({ currentWindow: true }, function(tabs) {
if (tabs.length > 1) {
const toSelect =
(() => { switch (direction) {
case "next":
return (tab.index + count) % tabs.length;
case "previous":
return ((tab.index - count) + (count * tabs.length)) % tabs.length;
case "first":
return Math.min(tabs.length - 1, count - 1);
case "last":
return Math.max(0, tabs.length - count);
} })();
chrome.tabs.update(tabs[toSelect].id, {active: true});
}
});
chrome.webNavigation.onCommitted.addListener(function({tabId, frameId}) {
const cssConf = {
frameId,
code: Settings.get("userDefinedLinkHintCss"),
runAt: "document_start"
};
return chrome.tabs.insertCSS(tabId, cssConf, () => chrome.runtime.lastError);
});
// Symbolic names for the three browser-action icons.
const ENABLED_ICON = "icons/browser_action_enabled.png";
const DISABLED_ICON = "icons/browser_action_disabled.png";
const PARTIAL_ICON = "icons/browser_action_partial.png";
// Convert the three icon PNGs to image data.
const iconImageData = {};
for (let icon of [ENABLED_ICON, DISABLED_ICON, PARTIAL_ICON]) {
iconImageData[icon] = {};
for (let scale of [19, 38]) {
(function(icon, scale) {
const canvas = document.createElement("canvas");
canvas.width = (canvas.height = scale);
// We cannot do the rest of this in the tests.
if ((chrome.areRunningVimiumTests == null) || !chrome.areRunningVimiumTests) {
const context = canvas.getContext("2d");
const image = new Image;
image.src = icon;
image.onload = function() {
context.drawImage(image, 0, 0, scale, scale);
iconImageData[icon][scale] = context.getImageData(0, 0, scale, scale);
return document.body.removeChild(canvas);
};
return document.body.appendChild(canvas);
}
})(icon, scale);
}
}
var Frames = {
onConnect(sender, port) {
const [tabId, frameId] = [sender.tab.id, sender.frameId];
port.onDisconnect.addListener(() => Frames.unregisterFrame({tabId, frameId, port}));
port.postMessage({handler: "registerFrameId", chromeFrameId: frameId});
(portsForTab[tabId] != null ? portsForTab[tabId] : (portsForTab[tabId] = {}))[frameId] = port;
// Return our onMessage handler for this port.
return (request, port) => {
return this[request.handler]({request, tabId, frameId, port, sender});
};
},
registerFrame({tabId, frameId, port}) {
frameIdsForTab[tabId] = frameIdsForTab[tabId] || [];
if (!frameIdsForTab[tabId].includes(frameId)) {
frameIdsForTab[tabId].push(frameId);
}
portsForTab[tabId] = portsForTab[tabId] || {};
return portsForTab[tabId][frameId] = port;
},
unregisterFrame({tabId, frameId, port}) {
// Check that the port trying to unregister the frame hasn't already been replaced by a new frame
// registering. See #2125.
const registeredPort = portsForTab[tabId] != null ? portsForTab[tabId][frameId] : undefined;
if ((registeredPort === port) || !registeredPort) {
if (tabId in frameIdsForTab)
frameIdsForTab[tabId] = frameIdsForTab[tabId].filter((fId) => fId !== frameId);
if (tabId in portsForTab)
delete portsForTab[tabId][frameId];
}
HintCoordinator.unregisterFrame(tabId, frameId);
},
isEnabledForUrl({request, tabId, port}) {
if (request.frameIsFocused)
urlForTab[tabId] = request.url;
request.isFirefox = Utils.isFirefox(); // Update the value for Utils.isFirefox in the frontend.
const enabledState = Exclusions.isEnabledForUrl(request.url);
if (request.frameIsFocused) {
if (chrome.browserAction.setIcon) {
chrome.browserAction.setIcon({tabId, imageData: (function() {
const enabledStateIcon =
!enabledState.isEnabledForUrl ?
DISABLED_ICON
: enabledState.passKeys.length >= 0 ?
PARTIAL_ICON
:
ENABLED_ICON;
return iconImageData[enabledStateIcon];
})()});
}
}
return port.postMessage(Object.assign(request, enabledState));
},
domReady({tabId, frameId}) {
if (frameId == 0) {
if (tabLoadedHandlers[tabId])
tabLoadedHandlers[tabId]();
delete tabLoadedHandlers[tabId];
}
},
linkHintsMessage({request, tabId, frameId}) {
HintCoordinator.onMessage(tabId, frameId, request);
},
// For debugging only. This allows content scripts to log messages to the extension's logging page.
log({frameId, sender, request: {message}}) {
BgUtils.log(`${frameId} ${message}`, sender);
}
};
const handleFrameFocused = function({tabId, frameId}) {
if (frameIdsForTab[tabId] == null)
frameIdsForTab[tabId] = [];
frameIdsForTab[tabId] = cycleToFrame(frameIdsForTab[tabId], frameId);
// Inform all frames that a frame has received the focus.
return chrome.tabs.sendMessage(tabId, {name: "frameFocused", focusFrameId: frameId});
};
// Rotate through frames to the frame count places after frameId.
var cycleToFrame = function(frames, frameId, count) {
// We can't always track which frame chrome has focused, but here we learn that it's frameId; so add an
// additional offset such that we do indeed start from frameId.
if (count == null)
count = 0;
count = (count + Math.max(0, frames.indexOf(frameId))) % frames.length;
return [...frames.slice(count), ...frames.slice(0, count)];
};
var HintCoordinator = {
tabState: {},
onMessage(tabId, frameId, request) {
if (request.messageType in this) {
return this[request.messageType](tabId, frameId, request);
} else {
// If there's no handler here, then the message is forwarded to all frames in the sender's tab.
return this.sendMessage(request.messageType, tabId, request);
}
},
// Post a link-hints message to a particular frame's port. We catch errors in case the frame has gone away.
postMessage(tabId, frameId, messageType, port, request) {
if (request == null)
request = {};
try {
return port.postMessage(Object.assign(request, {handler: "linkHintsMessage", messageType}));
} catch (error) {
return this.unregisterFrame(tabId, frameId);
}
},
// Post a link-hints message to all participating frames.
sendMessage(messageType, tabId, request) {
if (request == null)
request = {};
for (let frameId of Object.keys(this.tabState[tabId].ports || {})) {
const port = this.tabState[tabId].ports[frameId];
this.postMessage(tabId, parseInt(frameId), messageType, port, request)
}
},
prepareToActivateMode(tabId, originatingFrameId, {modeIndex, isVimiumHelpDialog}) {
this.tabState[tabId] = {frameIds: frameIdsForTab[tabId].slice(), hintDescriptors: {}, originatingFrameId, modeIndex};
this.tabState[tabId].ports = {};
frameIdsForTab[tabId].map(frameId => {
return this.tabState[tabId].ports[frameId] = portsForTab[tabId][frameId];
});
this.sendMessage("getHintDescriptors", tabId, {modeIndex, isVimiumHelpDialog});
},
// Receive hint descriptors from all frames and activate link-hints mode when we have them all.
postHintDescriptors(tabId, frameId, {hintDescriptors}) {
if (!this.tabState[tabId].frameIds.includes(frameId)) {
return;
}
this.tabState[tabId].hintDescriptors[frameId] = hintDescriptors;
this.tabState[tabId].frameIds = this.tabState[tabId].frameIds.filter(fId => fId !== frameId);
if (this.tabState[tabId].frameIds.length === 0) {
for (frameId of Object.keys(this.tabState[tabId].ports || {})) {
const port = this.tabState[tabId].ports[frameId];
if (frameId in this.tabState[tabId].hintDescriptors) {
hintDescriptors = Object.assign({}, this.tabState[tabId].hintDescriptors);
// We do not send back the frame's own hint descriptors. This is faster (approx. speedup 3/2) for
// link-busy sites like reddit.
delete hintDescriptors[frameId];
this.postMessage(tabId, parseInt(frameId), "activateMode", port, {
originatingFrameId: this.tabState[tabId].originatingFrameId,
hintDescriptors,
modeIndex: this.tabState[tabId].modeIndex
});
}
}
}
},
// If an unregistering frame is participating in link-hints mode, then we need to tidy up after it.
unregisterFrame(tabId, frameId) {
if (!this.tabState[tabId])
return;
if ((this.tabState[tabId].ports != null ? this.tabState[tabId].ports[frameId] : undefined) != null)
delete this.tabState[tabId].ports[frameId];
if ((this.tabState[tabId].frameIds != null) && this.tabState[tabId].frameIds.includes(frameId)) {
// We fake an empty "postHintDescriptors" because the frame has gone away.
return this.postHintDescriptors(tabId, frameId, {hintDescriptors: []});
}
}
};
// Port handler mapping
var portHandlers = {
completions: handleCompletions,
frames: Frames.onConnect.bind(Frames)
};
var sendRequestHandlers = {
runBackgroundCommand(request) { return BackgroundCommands[request.registryEntry.command](request); },
// getCurrentTabUrl is used by the content scripts to get their full URL, because window.location cannot help
// with Chrome-specific URLs like "view-source:http:..".
getCurrentTabUrl({tab}) { return tab.url; },
openUrlInNewTab: mkRepeatCommand((request, callback) => TabOperations.openUrlInNewTab(request, callback)),
openUrlInNewWindow(request) { return TabOperations.openUrlInNewWindow(request); },
openUrlInIncognito(request) { return chrome.windows.create({incognito: true, url: Utils.convertToUrl(request.url)}); },
openUrlInCurrentTab: TabOperations.openUrlInCurrentTab,
openOptionsPageInNewTab(request) {
return chrome.tabs.create({url: chrome.runtime.getURL("pages/options.html"), index: request.tab.index + 1});
},
frameFocused: handleFrameFocused,
nextFrame: BackgroundCommands.nextFrame,
selectSpecificTab,
createMark: Marks.create.bind(Marks),
gotoMark: Marks.goto.bind(Marks),
// Send a message to all frames in the current tab.
sendMessageToFrames(request, sender) { return chrome.tabs.sendMessage(sender.tab.id, request.message); }
};
// We always remove chrome.storage.local/findModeRawQueryListIncognito on startup.
chrome.storage.local.remove("findModeRawQueryListIncognito");
// Tidy up tab caches when tabs are removed. Also remove chrome.storage.local/findModeRawQueryListIncognito if
// there are no remaining incognito-mode windows. Since the common case is that there are none to begin with,
// we first check whether the key is set at all.
chrome.tabs.onRemoved.addListener(function(tabId) {
for (let cache of [frameIdsForTab, urlForTab, portsForTab, HintCoordinator.tabState]) { delete cache[tabId]; }
return chrome.storage.local.get("findModeRawQueryListIncognito", function(items) {
if (items.findModeRawQueryListIncognito) {
return chrome.windows != null ? chrome.windows.getAll(null, function(windows) {
for (let window of windows) {
if (window.incognito)
return;
}
// There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set.
return chrome.storage.local.remove("findModeRawQueryListIncognito");
}) : undefined;
}
});
});
// Convenience function for development use.
window.runTests = () => open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html'));
//
// Begin initialization.
//
// Show notification on upgrade.
(showUpgradeMessage = function() {
const currentVersion = Utils.getCurrentVersion();
// Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new
// installs.
if (!Settings.has("previousVersion"))
Settings.set("previousVersion", currentVersion);
const previousVersion = Settings.get("previousVersion");
if (Utils.compareVersions(currentVersion, previousVersion ) === 1) {
const currentVersionNumbers = currentVersion.split(".");
const previousVersionNumbers = previousVersion.split(".");
if (currentVersionNumbers.slice(0, 2).join(".") === previousVersionNumbers.slice(0, 2).join(".")) {
// We do not show an upgrade message for patch/silent releases. Such releases have the same major and
// minor version numbers. We do, however, update the recorded previous version.
Settings.set("previousVersion", currentVersion);
} else {
const notificationId = "VimiumUpgradeNotification";
const notification = {
type: "basic",
iconUrl: chrome.runtime.getURL("icons/vimium.png"),
title: "Vimium Upgrade",
message: `Vimium has been upgraded to version ${currentVersion}. Click here for more information.`,
isClickable: true
};
if (chrome.notifications && chrome.notifications.create) {
chrome.notifications.create(notificationId, notification, function() {
if (!chrome.runtime.lastError) {
Settings.set("previousVersion", currentVersion);
chrome.notifications.onClicked.addListener(function(id) {
if (id === notificationId) {
chrome.tabs.query({ active: true, currentWindow: true }, function(...args) {
const [tab] = args[0];
return TabOperations.openUrlInNewTab({tab, tabId: tab.id, url: "https://github.com/philc/vimium/blob/master/CHANGELOG.md"});
});
}
});
}
});
} else {
// We need to wait for the user to accept the "notifications" permission.
chrome.permissions.onAdded.addListener(showUpgradeMessage);
}
}
}
})();
// The install date is shown on the logging page.
chrome.runtime.onInstalled.addListener(function({reason}) {
if (!["chrome_update", "shared_module_update"].includes(reason))
chrome.storage.local.set({installDate: new Date().toString()});
});
Object.assign(global, {TabOperations, Frames});

View file

@ -0,0 +1,130 @@
const Marks = {
// This returns the key which is used for storing mark locations in chrome.storage.sync.
getLocationKey(markName) { return `vimiumGlobalMark|${markName}`; },
// Get the part of a URL we use for matching here (that is, everything up to the first anchor).
getBaseUrl(url) { return url.split("#")[0]; },
// Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the mark is
// used, whether this is the original Vimium session or a subsequent session. This affects whether or not
// tabId can be considered valid.
create(req, sender) {
chrome.storage.local.get("vimiumSecret", items => {
const markInfo = {
vimiumSecret: items.vimiumSecret,
markName: req.markName,
url: this.getBaseUrl(sender.tab.url),
tabId: sender.tab.id,
scrollX: req.scrollX,
scrollY: req.scrollY
};
if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) {
return this.saveMark(markInfo);
} else {
// The front-end frame hasn't provided the scroll position (because it's not the top frame within its
// tab). We need to ask the top frame what its scroll position is.
return chrome.tabs.sendMessage(sender.tab.id, {name: "getScrollPosition"}, response => {
return this.saveMark(Object.assign(markInfo,
{scrollX: response.scrollX, scrollY: response.scrollY}));
});
}
});
},
saveMark(markInfo) {
const item = {};
item[this.getLocationKey(markInfo.markName)] = markInfo;
return Settings.storage.set(item);
},
// Goto a global mark. We try to find the original tab. If we can't find that, then we try to find another
// tab with the original URL, and use that. And if we can't find such an existing tab, then we create a new
// one. Whichever of those we do, we then set the scroll position to the original scroll position.
goto(req, sender) {
chrome.storage.local.get("vimiumSecret", items => {
const {
vimiumSecret
} = items;
const key = this.getLocationKey(req.markName);
return Settings.storage.get(key, items => {
const markInfo = items[key];
if (markInfo.vimiumSecret !== vimiumSecret) {
// This is a different Vimium instantiation, so markInfo.tabId is definitely out of date.
return this.focusOrLaunch(markInfo, req);
} else {
// Check whether markInfo.tabId still exists. According to
// https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome session. So, if
// we find a match, we can use it.
return chrome.tabs.get(markInfo.tabId, tab => {
if (!chrome.runtime.lastError && tab && tab.url && (markInfo.url === this.getBaseUrl(tab.url))) {
// The original tab still exists.
return this.gotoPositionInTab(markInfo);
} else {
// The original tab no longer exists.
return this.focusOrLaunch(markInfo, req);
}
});
}
});
});
},
// Focus an existing tab and scroll to the given position within it.
gotoPositionInTab({ tabId, scrollX, scrollY }) {
chrome.tabs.update(tabId, { active: true }, (tab) => {
chrome.windows.update(tab.windowId, { focused: true });
chrome.tabs.sendMessage(tabId, {name: "setScrollPosition", scrollX, scrollY});
});
},
// The tab we're trying to find no longer exists. We either find another tab with a matching URL and use it,
// or we create a new tab.
focusOrLaunch(markInfo, req) {
// If we're not going to be scrolling to a particular position in the tab, then we choose all tabs with a
// matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense to scroll
// unless there's an exact URL match).
const query = markInfo.scrollX === markInfo.scrollY && markInfo.scrollY === 0 ? `${markInfo.url}*` : markInfo.url;
return chrome.tabs.query({ url: query }, tabs => {
if (tabs.length > 0) {
// We have at least one matching tab. Pick one and go to it.
return this.pickTab(tabs, tab => {
return this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id}));
});
} else {
// There is no existing matching tab, we'll have to create one.
return TabOperations.openUrlInNewTab(Object.assign(req, {url: this.getBaseUrl(markInfo.url)}), tab => {
// Note. tabLoadedHandlers is defined in "main.js". The handler below will be called when the tab
// is loaded, its DOM is ready and it registers with the background page.
return tabLoadedHandlers[tab.id] =
() => this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id}));
});
}
});
},
// Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with shorter
// (matching) URLs.
pickTab(tabs, callback) {
const tabPicker = function({ id }) {
// Prefer tabs in the current window, if there are any.
let tab;
const tabsInWindow = tabs.filter(tab => tab.windowId === id);
if (tabsInWindow.length > 0) { tabs = tabsInWindow; }
// If more than one tab remains and the current tab is still a candidate, then don't pick the current
// tab (because jumping to it does nothing).
if (tabs.length > 1)
tabs = tabs.filter(t => !t.active)
// Prefer shorter URLs.
tabs.sort((a, b) => a.url.length - b.url.length);
return callback(tabs[0]);
};
if (chrome.windows != null)
return chrome.windows.getCurrent(tabPicker);
else
return tabPicker({id: undefined});
}
};
global.Marks = Marks;