Merge pull request #953 from saulrh/autocontain-coexistence

Autocontain coexistence
This commit is contained in:
Oliver Blanthorn 2019-04-17 13:31:45 +01:00 committed by GitHub
commit 8e344b7c12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 423 additions and 66 deletions

View file

@ -56,5 +56,7 @@ Since Tridactyl aims to provide all the features Vimperator and Pentadactyl had,
- This is needed for Tridactyl to be able to go back to normal mode every time you open a new page. In the future we may use it for autocommands.
- Read the text of all open tabs
- This allows us to use Firefox's built-in find-in-page API, for, for example, allowing you to bind find-next and find-previous to `n` and `N`.
- Monitor extension usage and manage themes:
- Tridactyl needs this to integrate with and avoid conflicts with other extensions. For example, Tridactyl's contextual identity features use this to cooperate with the Multi-Account Containers extension.
[betas]: https://tridactyl.cmcaine.co.uk/betas/?sort=time&order=desc

View file

@ -19,6 +19,7 @@ import * as native from "@src/lib/native"
import state from "@src/state"
import * as webext from "@src/lib/webext"
import { AutoContain } from "@src/lib/autocontainers"
import * as extension_info from "@src/lib/extension_info"
/* tslint:disable:import-spacing */
; (window as any).tri = Object.assign(Object.create(null), {
messaging,
@ -133,22 +134,20 @@ browser.tabs.onActivated.addListener(ev => {
// {{{ AUTOCONTAINERS
extension_info.init()
const aucon = new AutoContain()
function cancelReq(details) {
if (aucon.getCancelledRequest(details.tabId)) {
aucon.clearCancelledRequests(details.tabId)
}
}
// Handle cancelled requests as a result of autocontain.
browser.webRequest.onCompleted.addListener(cancelReq,
{ urls: ["<all_urls"], types: ["main_frame"] },
)
browser.webRequest.onCompleted.addListener(aucon.completedRequestListener, {
urls: ["<all_urls>"],
types: ["main_frame"],
})
browser.webRequest.onErrorOccurred.addListener(cancelReq,
{ urls: ["<all_urls>"], types: ["main_frame"] },
)
browser.webRequest.onErrorOccurred.addListener(aucon.completedRequestListener, {
urls: ["<all_urls>"],
types: ["main_frame"],
})
// Contain autocmd.
browser.webRequest.onBeforeRequest.addListener(
@ -156,6 +155,11 @@ browser.webRequest.onBeforeRequest.addListener(
{ urls: ["<all_urls>"], types: ["main_frame"] },
["blocking"],
)
browser.tabs.onCreated.addListener(
aucon.tabCreatedListener,
)
// }}}
// {{{ PERFORMANCE LOGGING

View file

@ -78,6 +78,7 @@ import * as UrlUtil from "@src/lib/url_util"
import * as config from "@src/lib/config"
import * as aliases from "@src/lib/aliases"
import * as Logging from "@src/lib/logging"
import { AutoContain } from "@src/lib/autocontainers"
/** @hidden */
const logger = new Logging.Logger("excmds")
import * as CSS from "css"
@ -1865,7 +1866,7 @@ export async function tabprev(increment = 1) {
/** Like [[open]], but in a new tab. If no address is given, it will open the newtab page, which can be set with `set newtab [url]`
Use the `-c` flag followed by a container name to open a tab in said container. Tridactyl will try to fuzzy match a name if an exact match is not found.
Use the `-c` flag followed by a container name to open a tab in said container. Tridactyl will try to fuzzy match a name if an exact match is not found. If any autocontainer directives are configured and -c is not set, Tridactyl will try to use the right container automatically using your configurations.
Use the `-b` flag to open the tab in the background.
These two can be combined in any order, but need to be placed as the first arguments.
@ -1888,6 +1889,8 @@ export async function tabopen(...addressarr: string[]) {
let active
let container
const win = await browser.windows.getCurrent()
// Lets us pass both -b and -c in no particular order as long as they are up front.
async function argParse(args): Promise<string[]> {
if (args[0] === "-b") {
@ -1896,7 +1899,6 @@ export async function tabopen(...addressarr: string[]) {
argParse(args)
} else if (args[0] === "-c") {
// Ignore the -c flag if incognito as containers are disabled.
const win = await browser.windows.getCurrent()
if (!win.incognito) container = await Container.fuzzyMatch(args[1])
else logger.error("[tabopen] can't open a container in a private browsing window.")
@ -1914,6 +1916,15 @@ export async function tabopen(...addressarr: string[]) {
return nativeopen(address)
}
const aucon = new AutoContain()
if (!container && aucon.autocontainConfigured()) {
const autoContainer = await aucon.getAuconForUrl(address)
if (autoContainer && autoContainer !== "firefox-default") {
container = autoContainer
logger.debug("tabopen setting container automatically using autocontain directive")
}
}
return activeTabContainerId().then(containerId => {
const args = { active } as any
// Ensure -c has priority.
@ -3158,6 +3169,8 @@ export function autocmd(event: string, url: string, ...excmd: string[]) {
* * Unescaped periods will match *anything*. `autocontain google.co.uk work` will match `google!co$uk`. Escape your periods or accept that you might get some false positives.
* * You can use regex in your domain pattern. `autocontain google\,(co\.uk|com) work` will match either `google.co.uk` or `google.com`.
*
* This *should* now peacefully coexist with the Temporary Containers and Multi-Account Containers addons. Do not trust this claim. If a fight starts the participants will try to open infinite tabs. It is *strongly* recommended that you use a tridactylrc so that you can abort a sorceror's-apprentice scenario by killing firefox, commenting out all of autocontainer directives in your rc file, and restarting firefox to clean up the mess. There are a number of strange behaviors resulting from limited coordination between extensions. Redirects can be particularly surprising; for example, with `:autocontain will-redirect.example.org example` set and `will-redirect.example.org` redirecting to `redirected.example.org`, navigating to `will-redirect.example.org` will result in the new tab being in the `example` container under some conditions and in the `firefox-default` container under others.
*
* @param domain The domain which will trigger the autoContain directive. Includes all subdomains.
* @param container The container to open the url in.
*/

View file

@ -12,8 +12,6 @@
* Unescaped periods will match *anything*. `autocontain google.co.uk work` will match `google!co$uk`. Escape your periods or accept that you might get some false positives.
* You can use regex in your domain pattern. `autocontain google\,(co\.uk|com) work` will match either `google.co.uk` or `google.com`.
TODO: Should try and detect Multi Account Containers and/or Contain Facebook extensions from Mozilla.
A lot of the inspiration for this code was drawn from the Mozilla `contain facebook` Extension.
https://github.com/mozilla/contain-facebook/
@ -27,89 +25,118 @@
import * as Config from "@src/lib/config"
import * as Container from "@src/lib/containers"
import * as Logging from "@src/lib/logging"
import * as ExtensionInfo from "@src/lib/extension_info"
const logger = new Logging.Logger("containers")
/** An interface for the additional object that's supplied in the BlockingResponse callback.
Details here:
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest/onBeforeRequest#details
*/
interface IDetails {
frameAncestors: any[]
frameId: number
method: string
originUrl: string
parentFrameId: number
proxyInfo?: any
requestBody?: any
requestId: string
tabId: number
timeStamp: number
type: browser.webRequest.ResourceType
url: string
}
interface ICancelledRequest {
requestIds: any
urls: any
}
interface IAutoContain {
autoContain(details: IDetails): any
cancelEarly(tab: browser.tabs.Tab, details: IDetails): boolean
cancelRequest(tab: browser.tabs.Tab, details: IDetails): void
autoContain(details: browser.webRequest.IDetails): any
cancelEarly(
tab: browser.tabs.Tab,
details: browser.webRequest.IDetails,
): boolean
cancelRequest(
tab: browser.tabs.Tab,
details: browser.webRequest.IDetails,
): void
clearCancelledRequests(tabId: number): void
getCancelledRequest(tabId: number): ICancelledRequest
parseAucons(details: IDetails): Promise<string>
completedRequestListener(details: browser.webRequest.IDetails): void
autocontainConfigured(): boolean
getAuconForUrl(url: string): Promise<string>
getAuconForDetails(details: browser.webRequest.IDetails): Promise<string>
}
export class AutoContain implements IAutoContain {
private cancelledRequests: ICancelledRequest[] = []
private lastCreatedTab = null
autoContain = async (
details: IDetails,
): Promise<browser.webRequest.BlockingResponse> => {
tabCreatedListener = (tab) => {
this.lastCreatedTab = tab
}
completedRequestListener = (details: browser.webRequest.IDetails) => {
if (this.getCancelledRequest(details.tabId)) {
this.clearCancelledRequests(details.tabId)
}
}
autocontainConfigured = (): boolean => {
// No autocontain directives, no nothing.
const aucons = Config.get("autocontain")
if (Object.keys(aucons).length === 0) return { cancel: false }
return Object.keys(aucons).length !== 0
}
// Do not handle private tabs or invalid tabIds.
if (details.tabId === -1) return { cancel: false }
const tab = await browser.tabs.get(details.tabId)
if (tab.incognito) return { cancel: false }
autoContain = async (
details: browser.webRequest.IDetails,
): Promise<browser.webRequest.BlockingResponse> => {
if (!this.autocontainConfigured()) return { cancel: false }
// Only handle http requests.
if (details.url.search("^https?://") < 0) return { cancel: false }
const cookieStoreId = await this.parseAucons(details)
// Do not handle invalid tabIds.
if (details.tabId === -1) return { cancel: false }
// Do all of our async lookups in parallel.
const [tab, otherExtensionHasPriority, cookieStoreId] = await Promise.all(
[
browser.tabs.get(details.tabId),
this.checkOtherExtensionsHavePriority(details),
this.getAuconForDetails(details),
],
)
// If any other extensions claim this request, we'll ignore it and let them handle it.
if (otherExtensionHasPriority) return { cancel: false }
// Do not handle private tabs.
if (tab.incognito) return { cancel: false }
// Silently return if we're already in the correct container.
if (tab.cookieStoreId === cookieStoreId) return { cancel: false }
if (this.cancelEarly(tab, details)) return { cancel: true }
browser.tabs
.create({
// If this navigation created a tab, we cancel and then kill
// the newly-created tab after opening.
const removeTab = this.lastCreatedTab && (this.lastCreatedTab.id === tab.id)
// Figure out which tab should be the parent of the tab we'll
// be creating in the selected container.
const openerTabId = removeTab ? tab.openerTabId : tab.id
logger.debug("in tab %o and with details %o, reopening from container %o to container %o",
tab, details, tab.cookieStoreId, cookieStoreId)
browser.tabs.create({
url: details.url,
cookieStoreId,
active: tab.active,
windowId: tab.windowId,
index: tab.index + 1,
openerTabId,
}).then(result => {
logger.debug("Autocontainer created tab %o", result)
})
.then(_ => {
if (details.originUrl) {
window.history.back()
} else {
browser.tabs.remove(details.tabId)
}
})
if (removeTab) {
logger.debug("Closing newly-opened tab %o", tab)
browser.tabs.remove(tab.id)
}
return { cancel: true }
}
// Handles the requests after the initial checks made in this.autoContain.
cancelEarly = (tab: browser.tabs.Tab, details: IDetails): boolean => {
cancelEarly = (
tab: browser.tabs.Tab,
details: browser.webRequest.IDetails,
): boolean => {
if (!this.cancelledRequests[tab.id]) {
this.cancelRequest(tab, details)
} else {
@ -130,7 +157,10 @@ export class AutoContain implements IAutoContain {
return false
}
cancelRequest = (tab: browser.tabs.Tab, details: IDetails): void => {
cancelRequest = (
tab: browser.tabs.Tab,
details: browser.webRequest.IDetails,
): void => {
this.cancelledRequests[tab.id] = {
requestIds: {
[details.requestId]: true,
@ -158,12 +188,100 @@ export class AutoContain implements IAutoContain {
}
}
// Parses autocontain directives and returns valid cookieStoreIds or errors.
parseAucons = async (details): Promise<string> => {
// Checks to see if there are any other container-related extensions and avoids getting into
// fights with them.
checkOtherExtensionsHavePriority = async (
details: browser.webRequest.IDetails,
): Promise<boolean> => {
// The checks for each extension can be done in parallel.
const priorities = await Promise.all([
this.checkMACPriority(details),
this.checkTempContainersPriority(details),
])
return priorities.some(t => t)
}
checkMACPriority = async (
details: browser.webRequest.IDetails,
): Promise<boolean> => {
if (
!ExtensionInfo.getExtensionEnabled(
ExtensionInfo.KNOWN_EXTENSIONS.multi_account_containers,
)
) {
// It can't take priority if it's not enabled.
logger.debug(
"multi-account containers extension does not exist",
)
return false
}
// Do not handle urls that are claimed by the multi-account
// containers extension. Code from
// https://github.com/mozilla/multi-account-containers/wiki/API
const macAssignment = await browser.runtime
.sendMessage(
ExtensionInfo.KNOWN_EXTENSIONS.multi_account_containers,
{
method: "getAssignment",
url: details.url,
},
)
.catch(error => {
logger.warning("failed to communicate with multi-account containers extension: %o",
error)
return false
})
if (macAssignment) {
logger.debug(
"multi-account containers extension has priority over autocontainer directives",
)
return true
} else {
logger.debug(
"multi-account containers extension exists but does not claim priority",
)
return false
}
}
checkTempContainersPriority = async (
details: browser.webRequest.IDetails,
): Promise<boolean> => {
if (
!ExtensionInfo.getExtensionEnabled(
ExtensionInfo.KNOWN_EXTENSIONS.temp_containers,
)
) {
// It can't take priority if it's not enabled.
logger.debug(
"temporary containers extension does not exist",
)
return false
}
// Anything that we'd contain in the default container will be
// handed to the temp containers extension
const willContainInDefault =
(await this.getAuconForDetails(details)) === "firefox-default"
if (willContainInDefault) {
logger.info(
"temporary containers extension has priority over autocontainer directives",
)
} else {
logger.debug(
"temporary containers extension exists but does not claim priority",
)
}
return willContainInDefault
}
getAuconForUrl = async (url: string): Promise<string> => {
const aucons = Config.get("autocontain")
const ausites = Object.keys(aucons)
const aukeyarr = ausites.filter(
e => details.url.search("^https?://[^/]*" + e + "/") >= 0,
e => url.search("^https?://[^/]*" + e + "/") >= 0,
)
if (aukeyarr.length > 1) {
logger.error(
@ -186,4 +304,11 @@ export class AutoContain implements IAutoContain {
return Container.getId(aucons[aukeyarr[0]])
}
}
// Parses autocontain directives and returns valid cookieStoreIds or errors.
getAuconForDetails = async (
details: browser.webRequest.IDetails,
): Promise<string> => {
return this.getAuconForUrl(details.url)
}
}

77
src/lib/extension_info.ts Normal file
View file

@ -0,0 +1,77 @@
/** Extensions and compatibility
Looks us and communicates with other installed extensions so we can
be compatible with them.
*/
/** Friendly-names of extensions that are used in different places so
that we can refer to them with more readable and less magic ids.
*/
export const KNOWN_EXTENSIONS: { [name: string]: string } = {
temp_containers: "{c607c8df-14a7-4f28-894f-29e8722976af}",
multi_account_containers: "@testpilot-containers",
}
/** List of currently installed extensions.
*/
const installedExtensions: {
[id: string]: browser.management.IExtensionInfo
} = {}
function updateExtensionInfo(
extension: browser.management.IExtensionInfo,
): void {
installedExtensions[extension.id] = extension
}
export function getExtensionEnabled(id: string): boolean {
if (getExtensionInstalled(id)) {
return installedExtensions[id].enabled
} else {
return false
}
}
export function getExtensionInstalled(id: string): boolean {
return id in installedExtensions
}
async function hasManagementPermission() {
return browser.permissions.contains({
"permissions": ["management"],
})
}
/** Read installed extensions to populate the list at startup time.
*/
export async function init() {
// If we don't have the permission, bail out. Our list of
// installed extensions will be left uninitialized, so all of our
// external interfaces will pretend that no other extensions
// exist. This SHOULD result in tridactyl acting the same as it
// did before the extension interoperability feature was added in
// the first place, which isn't a great loss.
const hasPermission = await hasManagementPermission()
if (!hasPermission) {
return
}
// Code borrowed from
// https://github.com/stoically/temporary-containers/blob/master/src/background/management.js
let extensions = []
try {
extensions = await browser.management.getAll()
} catch (e) {
return
}
for (const extension of extensions) {
installedExtensions[extension.id] = extension
}
browser.management.onInstalled.addListener(updateExtensionInfo)
browser.management.onEnabled.addListener(updateExtensionInfo)
browser.management.onDisabled.addListener(updateExtensionInfo)
browser.management.onUninstalled.addListener(updateExtensionInfo)
}

View file

@ -4,7 +4,7 @@ import Logger from "@src/lib/logging"
const logger = new Logger("requests")
class DefaultMap extends Map {
class DefaultMap<K, V> extends Map<K, V> {
constructor(private defaultFactory, ...args) {
// super(...args)
super()
@ -36,7 +36,7 @@ export function clobberCSP(response) {
)
if (cspHeader !== undefined) {
const policy = new DefaultMap(
const policy = new DefaultMap<string, Set<string>>(
() => new Set(),
csp.parse(cspHeader.value),
)

136
src/tridactyl.d.ts vendored
View file

@ -87,6 +87,142 @@ declare namespace browser.tabs {
function toggleReaderMode(tabId?: number): Promise<void>
}
// web-ext-browser barely declares a third of the management
// interface, and we can't switch to @types/firefox-webext-browser yet
// because their enums are all messed up (see
// https://github.com/DefinitelyTyped/DefinitelyTyped/pull/28369)
// Instead, we'll copy-paste as much as we need from the fixed branch:
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/d1180e5218a7bf69e6f0da5ac2e2584bd57a1cdf/types/firefox-webext-browser/index.d.ts
interface WebExtEventBase<
TAddListener extends (...args: any[]) => any,
TCallback
> {
addListener: TAddListener
removeListener(cb: TCallback): void
hasListener(cb: TCallback): boolean
}
type WebExtEvent<TCallback extends (...args: any[]) => any> = WebExtEventBase<
(callback: TCallback) => void,
TCallback
>
declare namespace browser.management {
/* management types */
/** Information about an icon belonging to an extension. */
interface IconInfo {
/**
* A number representing the width and height of the icon. Likely values include (but are not limited to) 128,
* 48, 24, and 16.
*/
size: number
/**
* The URL for this icon image. To display a grayscale version of the icon (to indicate that an extension is
* disabled, for example), append `?grayscale=true` to the URL.
*/
url: string
}
/** A reason the item is disabled. */
type ExtensionDisabledReason = "unknown" | "permissions_increase"
/** The type of this extension. Will always be 'extension'. */
type ExtensionType = "extension" | "theme"
/**
* How the extension was installed. One of
* `development`: The extension was loaded unpacked in developer mode,
* `normal`: The extension was installed normally via an .xpi file,
* `sideload`: The extension was installed by other software on the machine,
* `other`: The extension was installed by other means.
*/
type ExtensionInstallType = "development" | "normal" | "sideload" | "other"
/** Information about an installed extension. */
interface IExtensionInfo {
/** The extension's unique identifier. */
id: string
/** The name of this extension. */
name: string
/** A short version of the name of this extension. */
shortName?: string
/** The description of this extension. */
description: string
/** The version of this extension. */
version: string
/** The version name of this extension if the manifest specified one. */
versionName?: string
/** Whether this extension can be disabled or uninstalled by the user. */
mayDisable: boolean
/** Whether it is currently enabled or disabled. */
enabled: boolean
/** A reason the item is disabled. */
disabledReason?: ExtensionDisabledReason
/** The type of this extension. Will always return 'extension'. */
type: ExtensionType
/** The URL of the homepage of this extension. */
homepageUrl?: string
/** The update URL of this extension. */
updateUrl?: string
/** The url for the item's options page, if it has one. */
optionsUrl: string
/**
* A list of icon information. Note that this just reflects what was declared in the manifest, and the actual
* image at that url may be larger or smaller than what was declared, so you might consider using explicit
* width and height attributes on img tags referencing these images. See the manifest documentation on icons
* for more details.
*/
icons?: IconInfo[]
/** Returns a list of API based permissions. */
permissions?: string[]
/** Returns a list of host based permissions. */
hostPermissions?: string[]
/** How the extension was installed. */
installType: ExtensionInstallType
}
/* management functions */
/** Returns a list of information about installed extensions. */
function getAll(): Promise<IExtensionInfo[] | undefined>
/* management events */
/** Fired when an addon has been disabled. */
const onDisabled: WebExtEvent<(info: IExtensionInfo) => void>
/** Fired when an addon has been enabled. */
const onEnabled: WebExtEvent<(info: IExtensionInfo) => void>
/** Fired when an addon has been installed. */
const onInstalled: WebExtEvent<(info: IExtensionInfo) => void>
/** Fired when an addon has been uninstalled. */
const onUninstalled: WebExtEvent<(info: IExtensionInfo) => void>
}
/** An interface for the additional object that's supplied in the BlockingResponse callback.
Details here:
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest/onBeforeRequest#details
*/
declare namespace browser.webRequest {
interface IDetails {
// frameAncestors: any[]
frameId: number
method: string
originUrl: string
parentFrameId: number
proxyInfo?: any
requestBody?: any
requestId: string
tabId: number
timeStamp: number
type: ResourceType
url: string
}
}
// html-tagged-template.js
declare function html(
strings: TemplateStringsArray,