From d6a6bdccbf218ccf8925e107e6e3f151dfd11437 Mon Sep 17 00:00:00 2001 From: Felix Becker Date: Fri, 3 May 2019 23:20:47 +0200 Subject: [PATCH] Use modern browser API (#3606) * Use browser API * Renames * Polyfill globalThis in Jest * Don't use dynamic import * More type safe storage access * Prettier webpack log * Don't use NonNullable * Remove unused background page handlers * Replace sourcegraph.com string literals with constant * Use BackgroundMessageHandlers interface in client too --- client/browser/babel.config.js | 2 +- client/browser/config/webpack/base.config.ts | 3 +- client/browser/scripts/dev.ts | 4 +- .../src/browser/ExtensionStorageSubject.ts | 8 +- client/browser/src/browser/browserAction.ts | 22 - client/browser/src/browser/omnibox.ts | 21 - client/browser/src/browser/permissions.ts | 55 - client/browser/src/browser/runtime.ts | 112 +- client/browser/src/browser/storage.ts | 144 +- .../browser/src/browser/storage_migrations.ts | 67 - client/browser/src/browser/tabs.ts | 70 - client/browser/src/browser/types.ts | 45 +- client/browser/src/context.ts | 4 +- .../src/extension/scripts/background.tsx | 660 +++--- .../browser/src/extension/scripts/inject.tsx | 9 +- .../browser/src/extension/scripts/options.tsx | 29 +- client/browser/src/libs/cli/index.ts | 19 +- client/browser/src/libs/cli/search.ts | 43 +- .../code_intelligence/code_intelligence.tsx | 5 +- .../libs/code_intelligence/external_links.tsx | 3 +- .../libs/options/OptionsContainer.test.tsx | 8 +- .../src/libs/options/OptionsContainer.tsx | 12 +- .../browser/src/libs/phabricator/backend.tsx | 65 +- client/browser/src/libs/sentry/index.ts | 39 +- client/browser/src/platform/context.ts | 27 +- client/browser/src/platform/extensionHost.ts | 32 +- client/browser/src/platform/settings.ts | 25 +- .../backend/{graphql.tsx => graphql.ts} | 31 +- client/browser/src/shared/backend/headers.tsx | 4 +- .../browser/src/shared/backend/userEvents.tsx | 1 + .../NeedsRepositoryConfigurationAlert.tsx | 74 - .../src/shared/components/ServerAlert.tsx | 71 - .../src/shared/tracking/EventLogger.tsx | 92 - client/browser/src/shared/util/browser.ts | 20 + client/browser/src/shared/util/context.tsx | 24 +- .../browser/src/shared/util/featureFlags.ts | 56 +- .../src/types/web-extensions/index.d.ts | 2028 +++++++++++++++++ client/browser/tsconfig.json | 1 - jest.config.base.js | 2 +- package.json | 13 +- shared/dev/globalThis.js | 3 + shared/src/settings/settings.test.ts | 2 - shared/src/settings/settings.ts | 2 +- shared/src/util/types.ts | 2 +- web/babel.config.js | 3 +- yarn.lock | 5 - 46 files changed, 2608 insertions(+), 1359 deletions(-) delete mode 100644 client/browser/src/browser/browserAction.ts delete mode 100644 client/browser/src/browser/omnibox.ts delete mode 100644 client/browser/src/browser/permissions.ts delete mode 100644 client/browser/src/browser/storage_migrations.ts delete mode 100644 client/browser/src/browser/tabs.ts rename client/browser/src/shared/backend/{graphql.tsx => graphql.ts} (90%) delete mode 100644 client/browser/src/shared/components/NeedsRepositoryConfigurationAlert.tsx delete mode 100644 client/browser/src/shared/components/ServerAlert.tsx delete mode 100644 client/browser/src/shared/tracking/EventLogger.tsx create mode 100644 client/browser/src/shared/util/browser.ts create mode 100644 client/browser/src/types/web-extensions/index.d.ts create mode 100644 shared/dev/globalThis.js diff --git a/client/browser/babel.config.js b/client/browser/babel.config.js index cd9b3b44b7e..6fc32271ac1 100644 --- a/client/browser/babel.config.js +++ b/client/browser/babel.config.js @@ -2,7 +2,7 @@ /** @type {import('@babel/core').TransformOptions} */ const config = { - plugins: ['babel-plugin-lodash'], + plugins: ['@babel/plugin-syntax-dynamic-import', 'babel-plugin-lodash'], presets: [ [ '@babel/preset-env', diff --git a/client/browser/config/webpack/base.config.ts b/client/browser/config/webpack/base.config.ts index 0f4341f02fb..90db0f6dc55 100644 --- a/client/browser/config/webpack/base.config.ts +++ b/client/browser/config/webpack/base.config.ts @@ -20,7 +20,6 @@ const config: webpack.Configuration = { inject: buildEntry(extEntry, contentEntry, '../../src/extension/scripts/inject.tsx'), phabricator: buildEntry(pageEntry, '../../src/libs/phabricator/extension.tsx'), - bootstrap: path.join(__dirname, '../../../../node_modules/bootstrap/dist/css/bootstrap.css'), style: path.join(__dirname, '../../src/app.scss'), }, output: { @@ -32,6 +31,8 @@ const config: webpack.Configuration = { plugins: [ new MiniCssExtractPlugin({ filename: '../css/[name].bundle.css' }) as any, // @types package is incorrect new OptimizeCssAssetsPlugin(), + // Code splitting doesn't make sense/work in the browser extension, but we still want to use dynamic import() + new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }), ], resolve: { extensions: ['.ts', '.tsx', '.js'], diff --git a/client/browser/scripts/dev.ts b/client/browser/scripts/dev.ts index 93069f6078d..9222432dfec 100644 --- a/client/browser/scripts/dev.ts +++ b/client/browser/scripts/dev.ts @@ -25,9 +25,9 @@ compiler.watch( aggregateTimeout: 300, }, (err, stats) => { - console.log(stats.toString(tasks.WEBPACK_STATS_OPTIONS)) + signale.complete(stats.toString(tasks.WEBPACK_STATS_OPTIONS)) - if (stats.hasErrors()) { + if (err || stats.hasErrors()) { signale.error('Webpack compilation error') return } diff --git a/client/browser/src/browser/ExtensionStorageSubject.ts b/client/browser/src/browser/ExtensionStorageSubject.ts index 60843d40e4b..c35825ebeb8 100644 --- a/client/browser/src/browser/ExtensionStorageSubject.ts +++ b/client/browser/src/browser/ExtensionStorageSubject.ts @@ -1,5 +1,5 @@ import { BehaviorSubject, NextObserver, Observable } from 'rxjs' -import storage from './storage' +import { observeStorageKey, storage } from './storage' import { StorageItems } from './types' /** @@ -10,7 +10,7 @@ export class ExtensionStorageSubject extends Obser constructor(private key: T, defaultValue: StorageItems[T]) { super(subscriber => { subscriber.next(this.value) - return storage.observeLocal(this.key).subscribe(item => { + return observeStorageKey('local', this.key).subscribe((item = defaultValue) => { this.value = item subscriber.next(item) }) @@ -18,8 +18,8 @@ export class ExtensionStorageSubject extends Obser this.value = defaultValue } - public next(value: StorageItems[T]): void { - storage.setLocal({ [this.key]: value }) + public async next(value: StorageItems[T]): Promise { + await storage.local.set({ [this.key]: value }) } public value: StorageItems[T] diff --git a/client/browser/src/browser/browserAction.ts b/client/browser/src/browser/browserAction.ts deleted file mode 100644 index 9daeceb3f78..00000000000 --- a/client/browser/src/browser/browserAction.ts +++ /dev/null @@ -1,22 +0,0 @@ -const chrome = global.chrome - -export const setBadgeText = (details: chrome.browserAction.BadgeTextDetails) => { - if (chrome && chrome.browserAction) { - chrome.browserAction.setBadgeText(details) - } -} - -export const setPopup = (details: chrome.browserAction.PopupDetails): Promise => - new Promise(resolve => { - if (chrome && chrome.browserAction) { - chrome.browserAction.setPopup(details, resolve) - return - } - resolve() - }) - -export function onClicked(listener: (tab: chrome.tabs.Tab) => void): void { - if (chrome && chrome.browserAction && chrome.browserAction.onClicked) { - chrome.browserAction.onClicked.addListener(listener) - } -} diff --git a/client/browser/src/browser/omnibox.ts b/client/browser/src/browser/omnibox.ts deleted file mode 100644 index 321e0afe368..00000000000 --- a/client/browser/src/browser/omnibox.ts +++ /dev/null @@ -1,21 +0,0 @@ -const chrome = global.chrome - -export const setDefaultSuggestion = (suggestion: Pick) => { - if (chrome && chrome.omnibox) { - chrome.omnibox.setDefaultSuggestion(suggestion) - } -} - -export const onInputChanged = ( - handler: (text: string, suggest: (suggestResults: chrome.omnibox.SuggestResult[]) => void) => void -) => { - if (chrome && chrome.omnibox) { - chrome.omnibox.onInputChanged.addListener(handler) - } -} - -export const onInputEntered = (handler: (url: string, disposition?: string) => void) => { - if (chrome && chrome.omnibox) { - chrome.omnibox.onInputEntered.addListener(handler) - } -} diff --git a/client/browser/src/browser/permissions.ts b/client/browser/src/browser/permissions.ts deleted file mode 100644 index 5ce27671a72..00000000000 --- a/client/browser/src/browser/permissions.ts +++ /dev/null @@ -1,55 +0,0 @@ -const chrome = global.chrome - -export function contains(url: string): Promise { - return new Promise((resolve, reject) => { - chrome.permissions.contains({ origins: [url + '/*'] }, resolve) - }) -} - -export function request(urls: string[]): Promise { - return new Promise((resolve, reject) => { - if (chrome && chrome.permissions) { - urls = urls.map(url => url + '/*') - chrome.permissions.request( - { - origins: [...urls], - }, - resolve - ) - } - }) -} - -export function remove(url: string): Promise { - return new Promise((resolve, reject) => { - if (chrome && chrome.permissions) { - chrome.permissions.remove( - { - origins: [url + '/*'], - }, - resolve - ) - } - }) -} - -export function getAll(): Promise { - return new Promise(resolve => { - if (chrome && chrome.permissions) { - chrome.permissions.getAll(resolve) - return - } - }) -} - -export function onAdded(listener: (p: chrome.permissions.Permissions) => void): void { - if (chrome && chrome.permissions && chrome.permissions.onAdded) { - chrome.permissions.onAdded.addListener(listener) - } -} - -export function onRemoved(listener: (p: chrome.permissions.Permissions) => void): void { - if (chrome && chrome.permissions && chrome.permissions.onRemoved) { - chrome.permissions.onRemoved.addListener(listener) - } -} diff --git a/client/browser/src/browser/runtime.ts b/client/browser/src/browser/runtime.ts index 51db449c1fd..a9bdd8861ff 100644 --- a/client/browser/src/browser/runtime.ts +++ b/client/browser/src/browser/runtime.ts @@ -1,107 +1,23 @@ -import { isBackground } from '../context' +import { isBackground, isInPage } from '../context' +import { BackgroundMessageHandlers } from './types' -const chrome = global.chrome - -export interface Message { - type: - | 'setIdentity' - | 'getIdentity' - | 'setEnterpriseUrl' - | 'setSourcegraphUrl' - | 'removeEnterpriseUrl' - | 'insertCSS' - | 'setBadgeText' - | 'openOptionsPage' - | 'fetched-files' - | 'repo-closed' - | 'createBlobURL' - | 'requestGraphQL' - payload?: any -} - -export const sendMessage = (message: Message, responseCallback?: (response: any) => void) => { - if (chrome && chrome.runtime) { - chrome.runtime.sendMessage(message, responseCallback) - } -} - -export const onMessage = ( - callback: (message: Message, sender: chrome.runtime.MessageSender, sendResponse?: (response: any) => void) => void +const messageSender = (type: T): BackgroundMessageHandlers[T] => ( + payload: any ) => { - if (chrome && chrome.runtime && chrome.runtime.onMessage) { - chrome.runtime.onMessage.addListener(callback) - return + if (isBackground) { + throw new Error('Tried to call background page function from background page itself') } - - throw new Error('do not call runtime.onMessage from a content script') -} - -export const setUninstallURL = (url: string) => { - if (chrome && chrome.runtime && chrome.runtime.setUninstallURL) { - chrome.runtime.setUninstallURL(url) + if (isInPage) { + throw new Error('Tried to call background page function from in-page integration') } -} - -export const getManifest = () => { - if (chrome && chrome.runtime && chrome.runtime.getManifest) { - return chrome.runtime.getManifest() - } - - return null -} - -export const getContentScripts = () => { - if (chrome && chrome.runtime) { - return chrome.runtime.getManifest().content_scripts - } - return [] + return browser.runtime.sendMessage({ type, payload }) } /** - * openOptionsPage opens the options.html page. This must be called from the background script. - * @param callback Called when the options page is opened. + * Functions that can be invoked from content scripts that will be executed in the background page. */ -export const openOptionsPage = (callback?: () => void): void => { - if (chrome && chrome.runtime) { - if (!isBackground) { - throw new Error('openOptionsPage must be called from the extension background script.') - } - if (chrome.runtime.openOptionsPage) { - chrome.runtime.openOptionsPage(callback) - } - } -} - -export const getExtensionVersionSync = (): string => { - // Content scripts don't have access to the manifest, but do have chrome.app.getDetails - const c = chrome as any - if (c && c.app && c.app.getDetails) { - const details = c.app.getDetails() - if (details && details.version) { - return details.version - } - } - - if (chrome && chrome.runtime && chrome.runtime.getManifest) { - const manifest = chrome.runtime.getManifest() - if (manifest) { - return manifest.version - } - } - - return 'NO_VERSION' -} - -export const getExtensionVersion = (): Promise => { - if (chrome && chrome.runtime && chrome.runtime.getManifest) { - return Promise.resolve(getExtensionVersionSync()) - } - - return Promise.resolve('NO_VERSION') -} - -export const onInstalled = (handler: (info?: chrome.runtime.InstalledDetails) => void) => { - if (chrome && chrome.runtime && chrome.runtime.onInstalled) { - chrome.runtime.onInstalled.addListener(handler) - } +export const background: BackgroundMessageHandlers = { + createBlobURL: messageSender('createBlobURL'), + openOptionsPage: messageSender('openOptionsPage'), + requestGraphQL: messageSender('requestGraphQL'), } diff --git a/client/browser/src/browser/storage.ts b/client/browser/src/browser/storage.ts index c628fc74adf..330421932fe 100644 --- a/client/browser/src/browser/storage.ts +++ b/client/browser/src/browser/storage.ts @@ -1,115 +1,33 @@ -import { EMPTY, Observable } from 'rxjs' -import { shareReplay } from 'rxjs/operators' -import { MigratableStorageArea, noopMigration, provideMigrations } from './storage_migrations' -import { StorageChange, StorageItems } from './types' +import { concat, from, Observable } from 'rxjs' +import { filter, map } from 'rxjs/operators' +import { fromBrowserEvent } from '../shared/util/browser' +import { StorageItems } from './types' -export { StorageItems, defaultStorageItems } from './types' +/** + * Type-safe access to browser extension storage. + * + * `undefined` in non-browser context (in-page integration). Make sure to check `isInPage`/`isExtension` first. + */ +export const storage: Record> & { + onChanged: browser.CallbackEventEmitter< + (changes: browser.storage.ChangeDict, areaName: browser.storage.AreaName) => void + > +} = globalThis.browser && browser.storage -interface Storage { - getManaged: (callback: (items: StorageItems) => void) => void - getManagedItem: (key: keyof StorageItems, callback: (items: StorageItems) => void) => void - getSync: (callback: (items: StorageItems) => void) => void - getSyncItem: (key: keyof StorageItems, callback: (items: StorageItems) => void) => void - setSync: (items: Partial, callback?: (() => void) | undefined) => void - observeSync: (key: T) => Observable - getLocal: (callback: (items: StorageItems) => void) => void - getLocalItem: (key: keyof StorageItems, callback: (items: StorageItems) => void) => void - setLocal: (items: Partial, callback?: (() => void) | undefined) => void - observeLocal: (key: T) => Observable - setSyncMigration: MigratableStorageArea['setMigration'] - setLocalMigration: MigratableStorageArea['setMigration'] - onChanged: (listener: (changes: Partial, areaName: string) => void) => void -} - -const get = (area: chrome.storage.StorageArea) => (callback: (items: StorageItems) => void) => - area.get(items => callback(items as StorageItems)) -const set = (area: chrome.storage.StorageArea) => (items: Partial, callback?: () => void) => { - area.set(items, callback) -} -const getItem = (area: chrome.storage.StorageArea) => ( - key: keyof StorageItems, - callback: (items: StorageItems) => void -) => area.get(key, items => callback(items as StorageItems)) - -const onChanged = (listener: (changes: Partial, areaName: string) => void) => { - if (chrome && chrome.storage) { - chrome.storage.onChanged.addListener(listener) - } -} - -const observe = (area: chrome.storage.StorageArea) => ( - key: T -): Observable => - new Observable(observer => { - get(area)(items => { - const item = items[key] - observer.next(item) - }) - onChanged(changes => { - const change = changes[key] - if (change) { - observer.next(change.newValue) - } - }) - }).pipe(shareReplay(1)) - -const noopObserve = () => EMPTY - -const throwNoopErr = () => { - throw new Error('do not call browser extension apis from an in page script') -} - -export default ((): Storage => { - if (window.SG_ENV === 'EXTENSION') { - const chrome = global.chrome - - const syncStorageArea = provideMigrations(chrome.storage.sync) - const localStorageArea = provideMigrations(chrome.storage.local) - const managedStorageArea: chrome.storage.StorageArea = chrome.storage.managed - - const storage: Storage = { - getManaged: get(managedStorageArea), - getManagedItem: getItem(managedStorageArea), - - getSync: get(syncStorageArea), - getSyncItem: getItem(syncStorageArea), - setSync: set(syncStorageArea), - observeSync: observe(syncStorageArea), - - getLocal: get(localStorageArea), - getLocalItem: getItem(localStorageArea), - setLocal: set(localStorageArea), - observeLocal: observe(localStorageArea), - - setSyncMigration: syncStorageArea.setMigration, - setLocalMigration: localStorageArea.setMigration, - - onChanged, - } - - // Only background script should set migrations. - if (window.EXTENSION_ENV !== 'BACKGROUND') { - storage.setSyncMigration(noopMigration) - storage.setLocalMigration(noopMigration) - } - - return storage - } - - // Running natively in the webpage(in Phabricator patch) so we don't need any storage. - return { - getManaged: throwNoopErr, - getManagedItem: throwNoopErr, - getSync: throwNoopErr, - getSyncItem: throwNoopErr, - setSync: throwNoopErr, - observeSync: noopObserve, - onChanged: throwNoopErr, - getLocal: throwNoopErr, - getLocalItem: throwNoopErr, - setLocal: throwNoopErr, - observeLocal: noopObserve, - setSyncMigration: throwNoopErr, - setLocalMigration: throwNoopErr, - } -})() +export const observeStorageKey = ( + areaName: browser.storage.AreaName, + key: K +): Observable => + concat( + // Start with current value of the item + from(storage[areaName].get(key)).pipe(map(items => items[key])), + // Emit every new value from change events that affect that item + fromBrowserEvent(storage.onChanged).pipe( + filter(([, name]) => areaName === name), + map(([changes]) => changes), + filter( + (changes): changes is typeof changes & { [k in K]-?: StorageItems[k] } => changes.hasOwnProperty(key) + ), + map(changes => changes[key].newValue) + ) + ) diff --git a/client/browser/src/browser/storage_migrations.ts b/client/browser/src/browser/storage_migrations.ts deleted file mode 100644 index 5e71dee72ee..00000000000 --- a/client/browser/src/browser/storage_migrations.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Observable, ReplaySubject, Subject } from 'rxjs' -import { share, switchMap, take } from 'rxjs/operators' -import { StorageItems } from './types' - -type MigrateFunc = (items: StorageItems) => { newItems?: StorageItems; keysToRemove?: string[] } - -export interface MigratableStorageArea extends chrome.storage.StorageArea { - setMigration: (migrate: MigrateFunc) => void -} - -export const noopMigration: MigrateFunc = () => ({}) - -export function provideMigrations(area: chrome.storage.StorageArea): MigratableStorageArea { - const migrations = new Subject() - const getCalls = new ReplaySubject>() - const setCalls = new ReplaySubject>() - - const migrated = migrations.pipe( - switchMap( - migrate => - new Observable(observer => { - area.get(items => { - const { newItems, keysToRemove } = migrate(items as StorageItems) - area.remove(keysToRemove || [], () => { - area.set(newItems || {}, () => { - observer.next() - observer.complete() - }) - }) - }) - }) - ), - take(1), - share() - ) - - const initializedGets = migrated.pipe(switchMap(() => getCalls)) - const initializedSets = migrated.pipe(switchMap(() => setCalls)) - - initializedSets.subscribe(args => { - area.set(...args) - }) - - initializedGets.subscribe(args => { - // Cast is needed because Parameters<> doesn't include overloads - area.get(...(args as Parameters)) - }) - - // Cast is needed because Parameters<> doesn't include overloads - const get: chrome.storage.StorageArea['get'] = ((...args: Parameters) => { - getCalls.next(args) - }) as chrome.storage.StorageArea['get'] - - const set: chrome.storage.StorageArea['set'] = (...args: Parameters) => { - setCalls.next(args) - } - - return { - ...area, - get, - set, - - setMigration: migrate => { - migrations.next(migrate) - }, - } -} diff --git a/client/browser/src/browser/tabs.ts b/client/browser/src/browser/tabs.ts deleted file mode 100644 index 6861325eb11..00000000000 --- a/client/browser/src/browser/tabs.ts +++ /dev/null @@ -1,70 +0,0 @@ -const chrome = global.chrome - -export const onUpdated = ( - callback: (tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => void -) => { - if (chrome && chrome.tabs && chrome.tabs.onUpdated) { - chrome.tabs.onUpdated.addListener(callback) - } -} - -export const getActive = (callback: (tab: chrome.tabs.Tab) => void) => { - if (chrome && chrome.tabs) { - chrome.tabs.query({ active: true }, (tabs: chrome.tabs.Tab[]) => { - for (const tab of tabs) { - callback(tab) - } - }) - } -} - -export const query = (queryInfo: chrome.tabs.QueryInfo, handler: (tabs: chrome.tabs.Tab[]) => void) => { - if (chrome && chrome.tabs && chrome.tabs.onUpdated) { - chrome.tabs.query(queryInfo, handler) - } -} - -export const reload = (tabId: number) => { - if (chrome && chrome.tabs && chrome.tabs.reload) { - chrome.tabs.reload(tabId) - } -} - -interface InjectDetails extends chrome.tabs.InjectDetails { - origin?: string - whitelist?: string[] - blacklist?: string[] - file?: string - runAt?: string -} - -export const insertCSS = (tabId: number, details: InjectDetails, callback?: () => void) => { - if (chrome && chrome.tabs && chrome.tabs.insertCSS) { - chrome.tabs.insertCSS(tabId, details, callback) - } -} - -export const executeScript = (tabId: number, details: InjectDetails, callback?: (result: any[]) => void) => { - if (chrome && chrome.tabs) { - const { origin, whitelist, blacklist, ...rest } = details - chrome.tabs.executeScript(tabId, rest, callback) - } -} - -export const sendMessage = (tabId: number, message: any, responseCallback?: (response: any) => void) => { - if (chrome && chrome.tabs) { - chrome.tabs.sendMessage(tabId, message, responseCallback) - } -} - -export const create = (props: chrome.tabs.CreateProperties, callback?: (tab: chrome.tabs.Tab) => void) => { - if (chrome && chrome.tabs) { - chrome.tabs.create(props, callback) - } -} - -export const update = (props: chrome.tabs.UpdateProperties, callback?: (tab?: chrome.tabs.Tab) => void) => { - if (chrome && chrome.tabs) { - chrome.tabs.update(props, callback) - } -} diff --git a/client/browser/src/browser/types.ts b/client/browser/src/browser/types.ts index 652c187c962..f18166ba9de 100644 --- a/client/browser/src/browser/types.ts +++ b/client/browser/src/browser/types.ts @@ -1,3 +1,7 @@ +import { IGraphQLResponseRoot } from '../../../../shared/src/graphql/schema' +import { GraphQLRequestArgs } from '../shared/backend/graphql' +import { DEFAULT_SOURCEGRAPH_URL } from '../shared/util/context' + interface RepoLocations { [key: string]: string } @@ -11,23 +15,11 @@ interface PhabricatorMapping { * The feature flags available. */ export interface FeatureFlags { - /** - * Whether or not to use the new inject method for code intelligence. - * - * @duration temporary - to be removed November first. - */ - newInject: boolean - /** - * Enable inline symbol search by typing `!symbolQueryText` inside of GitHub PR comments (requires reload after toggling). - * - * @duration temporary - needs feedback from users. - */ - inlineSymbolSearchEnabled: boolean - /** * Allow error reporting. * * @duration permanent + * @todo Since this is not really a feature flag, just unnest it into settings (and potentially get rid of the feature flags abstraction completely) */ allowErrorReporting: boolean @@ -43,21 +35,16 @@ export interface FeatureFlags { } export const featureFlagDefaults: FeatureFlags = { - newInject: false, - inlineSymbolSearchEnabled: true, allowErrorReporting: false, experimentalLinkPreviews: false, experimentalTextFieldCompletion: false, } -// TODO(chris) Switch to Partial to eliminate bugs caused by -// missing items. export interface StorageItems { sourcegraphURL: string identity: string enterpriseUrls: string[] - hasSeenServerModal: boolean repoLocations: RepoLocations phabricatorMappings: PhabricatorMapping[] sourcegraphAnonymousUid: string @@ -65,17 +52,13 @@ export interface StorageItems { /** * Storage for feature flags. */ - featureFlags: FeatureFlags + featureFlags: Partial clientConfiguration: ClientConfigurationDetails /** * Overrides settings from Sourcegraph. */ clientSettings: string sideloadedExtensionURL: string | null - NeedsServerConfigurationAlertDismissed?: boolean - NeedsRepoConfigurationAlertDismissed?: { - [repoName: string]: boolean - } } interface ClientConfigurationDetails { @@ -86,11 +69,10 @@ interface ClientConfigurationDetails { } export const defaultStorageItems: StorageItems = { - sourcegraphURL: 'https://sourcegraph.com', + sourcegraphURL: DEFAULT_SOURCEGRAPH_URL, identity: '', enterpriseUrls: [], - hasSeenServerModal: false, repoLocations: {}, phabricatorMappings: [], sourcegraphAnonymousUid: '', @@ -99,11 +81,18 @@ export const defaultStorageItems: StorageItems = { clientConfiguration: { contentScriptUrls: [], parentSourcegraph: { - url: 'https://sourcegraph.com', + url: DEFAULT_SOURCEGRAPH_URL, }, }, clientSettings: '', - sideloadedExtensionURL: '', + sideloadedExtensionURL: null, } -export type StorageChange = { [key in keyof StorageItems]: chrome.storage.StorageChange } +/** + * Functions in the background page that can be invoked from content scripts. + */ +export interface BackgroundMessageHandlers { + openOptionsPage(): Promise + createBlobURL(bundleUrl: string): Promise + requestGraphQL(params: GraphQLRequestArgs): Promise +} diff --git a/client/browser/src/context.ts b/client/browser/src/context.ts index 1b34d83e395..281d4f6faec 100644 --- a/client/browser/src/context.ts +++ b/client/browser/src/context.ts @@ -21,11 +21,9 @@ function getContext(): AppContext { let scriptEnv: ScriptEnv = ScriptEnv.Content if (appEnv === AppEnv.Extension) { - const chrome = global.chrome - if (options.test(window.location.pathname)) { scriptEnv = ScriptEnv.Options - } else if (chrome && chrome.runtime.getBackgroundPage) { + } else if (globalThis.browser && browser.runtime.getBackgroundPage) { scriptEnv = ScriptEnv.Background } } diff --git a/client/browser/src/extension/scripts/background.tsx b/client/browser/src/extension/scripts/background.tsx index 7b1653313eb..dc5f107d515 100644 --- a/client/browser/src/extension/scripts/background.tsx +++ b/client/browser/src/extension/scripts/background.tsx @@ -1,26 +1,22 @@ // We want to polyfill first. -// prettier-ignore import '../../config/polyfill' import { Endpoint } from '@sourcegraph/comlink' import { without } from 'lodash' -import { fromEventPattern, noop, Observable } from 'rxjs' +import { noop, Observable } from 'rxjs' import { bufferCount, filter, groupBy, map, mergeMap } from 'rxjs/operators' -import DPT from 'webext-domain-permission-toggle' +import * as domainPermissionToggle from 'webext-domain-permission-toggle' import { createExtensionHostWorker } from '../../../../../shared/src/api/extension/worker' -import * as browserAction from '../../browser/browserAction' -import * as omnibox from '../../browser/omnibox' -import * as permissions from '../../browser/permissions' -import * as runtime from '../../browser/runtime' -import storage, { defaultStorageItems } from '../../browser/storage' -import * as tabs from '../../browser/tabs' -import { featureFlagDefaults, FeatureFlags } from '../../browser/types' -import initializeCli from '../../libs/cli' +import { IGraphQLResponseRoot } from '../../../../../shared/src/graphql/schema' +import { storage } from '../../browser/storage' +import { BackgroundMessageHandlers, defaultStorageItems } from '../../browser/types' +import { initializeOmniboxInterface } from '../../libs/cli' import { initSentry } from '../../libs/sentry' import { createBlobURLForBundle } from '../../platform/worker' -import { requestGraphQL } from '../../shared/backend/graphql' +import { GraphQLRequestArgs, requestGraphQL } from '../../shared/backend/graphql' import { resolveClientConfiguration } from '../../shared/backend/server' -import { DEFAULT_SOURCEGRAPH_URL, setSourcegraphUrl } from '../../shared/util/context' +import { fromBrowserEvent } from '../../shared/util/browser' +import { DEFAULT_SOURCEGRAPH_URL, getPlatformName, setSourcegraphUrl } from '../../shared/util/context' import { assertEnv } from '../envAssertion' assertEnv('BACKGROUND') @@ -29,7 +25,7 @@ initSentry('background') let customServerOrigins: string[] = [] -const contentScripts = runtime.getContentScripts() +const contentScripts = browser.runtime.getManifest().content_scripts // jsContentScriptOrigins are the required URLs inside of the manifest. When checking for permissions to inject // the content script on optional pages (inside browser.tabs.onUpdated) we need to skip manual injection of the @@ -44,415 +40,285 @@ if (contentScripts) { } } -const configureOmnibox = (serverUrl: string) => { - omnibox.setDefaultSuggestion({ +const configureOmnibox = (serverUrl: string): void => { + browser.omnibox.setDefaultSuggestion({ description: `Search code on ${serverUrl}`, }) } -initializeCli(omnibox) +initializeOmniboxInterface() -storage.getSync(({ sourcegraphURL }) => { +async function main(): Promise { + let { sourcegraphURL } = await storage.sync.get() // If no sourcegraphURL is set ensure we default back to https://sourcegraph.com. if (!sourcegraphURL) { - storage.setSync({ sourcegraphURL: DEFAULT_SOURCEGRAPH_URL }) + await storage.sync.set({ sourcegraphURL: DEFAULT_SOURCEGRAPH_URL }) setSourcegraphUrl(DEFAULT_SOURCEGRAPH_URL) + sourcegraphURL = DEFAULT_SOURCEGRAPH_URL } - resolveClientConfiguration().subscribe( - config => { - // ClientConfiguration is the new storage option. - // Request permissions for the urls. - storage.setSync({ - clientConfiguration: { - parentSourcegraph: { - url: config.parentSourcegraph.url, - }, - contentScriptUrls: config.contentScriptUrls, + async function syncClientConfiguration(): Promise { + const config = await resolveClientConfiguration().toPromise() + // ClientConfiguration is the new storage option. + // Request permissions for the urls. + await storage.sync.set({ + clientConfiguration: { + parentSourcegraph: { + url: config.parentSourcegraph.url, }, - }) - }, - () => { - /* noop */ - } - ) + contentScriptUrls: config.contentScriptUrls, + }, + }) + } + configureOmnibox(sourcegraphURL) -}) -storage.getManaged(items => { - if (!items.enterpriseUrls || !items.enterpriseUrls.length) { - setDefaultBrowserAction() - return - } - const urls = items.enterpriseUrls.map(item => { - if (item.endsWith('/')) { - return item.substr(item.length - 1) + // Sync managed enterprise URLs + // TODO why sync vs merging values? + // Managed storage is currently only supported for Google Chrome (GSuite Admin) + // We don't have a managed storage manifest for Firefox, so storage.managed.get() throws on Firefox. + if (getPlatformName() === 'chrome-extension') { + const items = await storage.managed.get() + if (items.enterpriseUrls && items.enterpriseUrls.length > 1) { + setDefaultBrowserAction() + const urls = items.enterpriseUrls.map(item => item.replace(/\/$/, '')) + await handleManagedPermissionRequest(urls) } - return item - }) - handleManagedPermissionRequest(urls) -}) + } -storage.onChanged((changes, areaName) => { - if (areaName === 'managed') { - storage.getSync(() => { + storage.onChanged.addListener(async (changes, areaName) => { + if (areaName === 'managed') { if (changes.enterpriseUrls && changes.enterpriseUrls.newValue) { - handleManagedPermissionRequest(changes.enterpriseUrls.newValue) + await handleManagedPermissionRequest(changes.enterpriseUrls.newValue) } - }) - return - } - - if (changes.sourcegraphURL && changes.sourcegraphURL.newValue) { - setSourcegraphUrl(changes.sourcegraphURL.newValue) - resolveClientConfiguration().subscribe( - config => { - // ClientConfiguration is the new storage option. - // Request permissions for the urls. - storage.setSync({ - clientConfiguration: { - parentSourcegraph: { - url: config.parentSourcegraph.url, - }, - contentScriptUrls: config.contentScriptUrls, - }, - }) - }, - () => { - /* noop */ - } - ) - configureOmnibox(changes.sourcegraphURL.newValue) - } -}) - -permissions - .getAll() - .then(permissions => { - if (!permissions.origins) { - customServerOrigins = [] return } - customServerOrigins = without(permissions.origins, ...jsContentScriptOrigins) - }) - .catch(err => console.error('could not get permissions:', err)) -permissions.onAdded(permissions => { + if (changes.sourcegraphURL && changes.sourcegraphURL.newValue) { + setSourcegraphUrl(changes.sourcegraphURL.newValue) + await syncClientConfiguration() + configureOmnibox(changes.sourcegraphURL.newValue) + } + }) + + const permissions = await browser.permissions.getAll() if (!permissions.origins) { + customServerOrigins = [] return } - storage.getSync(items => { - const enterpriseUrls = items.enterpriseUrls || [] - for (const url of permissions.origins as string[]) { - enterpriseUrls.push(url.replace('/*', '')) - } - storage.setSync({ enterpriseUrls }) - }) - const origins = without(permissions.origins, ...jsContentScriptOrigins) - customServerOrigins.push(...origins) -}) + customServerOrigins = without(permissions.origins, ...jsContentScriptOrigins) -permissions.onRemoved(permissions => { - if (!permissions.origins) { - return - } - customServerOrigins = without(customServerOrigins, ...permissions.origins) - storage.getSync(items => { - const enterpriseUrls = items.enterpriseUrls || [] - const urlsToRemove: string[] = [] - for (const url of permissions.origins as string[]) { - urlsToRemove.push(url.replace('/*', '')) - } - storage.setSync({ enterpriseUrls: without(enterpriseUrls, ...urlsToRemove) }) - }) -}) - -storage.setSyncMigration(items => { - const newItems = { ...defaultStorageItems, ...items } - - let featureFlags: FeatureFlags = { - ...featureFlagDefaults, - ...(newItems.featureFlags || {}), - } - - const keysToRemove: string[] = [] - - // Ensure all feature flags are in storage. - for (const key of Object.keys(featureFlagDefaults) as (keyof FeatureFlags)[]) { - if (typeof featureFlags[key] === 'undefined') { - keysToRemove.push(key) - featureFlags = { - ...featureFlagDefaults, - ...items.featureFlags, - [key]: featureFlagDefaults[key], - } - } - } - - newItems.featureFlags = featureFlags - - // TODO: Remove this block after a few releases - const clientSettings = JSON.parse(items.clientSettings || '{}') - if (clientSettings['codecov.endpoints'] || typeof clientSettings['codecov.showCoverage'] !== 'undefined') { - if (typeof clientSettings.extensions === 'undefined') { - clientSettings.extensions = clientSettings.extensions || {} - } - clientSettings.extensions['souercegraph/codecov'] = true - newItems.clientSettings = JSON.stringify(clientSettings, null, 4) - } - - return { newItems, keysToRemove } -}) - -tabs.onUpdated((tabId, changeInfo, tab) => { - if (changeInfo.status === 'complete') { - for (const origin of customServerOrigins) { - if (origin !== '' && (!tab.url || !tab.url.startsWith(origin.replace('/*', '')))) { - continue - } - tabs.executeScript(tabId, { file: 'js/inject.bundle.js', runAt: 'document_end', origin }) - } - } -}) - -runtime.onMessage((message, _, cb) => { - switch (message.type) { - case 'setIdentity': - storage.setLocal({ identity: message.payload.identity }) - return - - case 'getIdentity': - storage.getLocalItem('identity', obj => { - const { identity } = obj - - // TODO: remove "!"" added after typescript upgrade - cb!(identity) - }) - return true - - case 'setEnterpriseUrl': - // TODO: remove "!"" added after typescript upgrade - requestPermissionsForEnterpriseUrls([message.payload], cb!) - return true - - case 'setSourcegraphUrl': - requestPermissionsForSourcegraphUrl(message.payload) - return true - - case 'removeEnterpriseUrl': - // TODO: remove "!"" added after typescript upgrade - removeEnterpriseUrl(message.payload, cb!) - return true - - case 'insertCSS': - const details = message.payload as { file: string; origin: string } - storage.getSyncItem('sourcegraphURL', ({ sourcegraphURL }) => - tabs.insertCSS(0, { - ...details, - whitelist: details.origin ? [details.origin] : [], - blacklist: [sourcegraphURL], - }) - ) - return - - case 'setBadgeText': - browserAction.setBadgeText({ text: message.payload }) - return - case 'openOptionsPage': - runtime.openOptionsPage() - return true - case 'createBlobURL': - createBlobURLForBundle(message.payload) - .then(url => { - if (cb) { - cb(url) - } - }) - .catch(err => { - throw new Error(`Unable to create blob url for bundle ${message.payload} error: ${err}`) - }) - return true - case 'requestGraphQL': - requestGraphQL(message.payload) - .toPromise() - .then(result => cb && cb({ result })) - .catch(err => cb && cb({ err })) - return true - } - - return -}) - -function requestPermissionsForEnterpriseUrls(urls: string[], cb: (res?: any) => void): void { - storage.getSync(items => { - const enterpriseUrls = items.enterpriseUrls || [] - storage.setSync( - { - enterpriseUrls: [...new Set([...enterpriseUrls, ...urls])], - }, - cb - ) - }) -} - -function requestPermissionsForSourcegraphUrl(url: string): void { - permissions - .request([url]) - .then(granted => { - if (granted) { - storage.setSync({ sourcegraphURL: url }) - } - }) - .catch(err => console.error('Permissions request denied', err)) -} - -function removeEnterpriseUrl(url: string, cb: (res?: any) => void): void { - permissions.remove(url).catch(err => console.error('could not remove permission', err)) - - storage.getSyncItem('enterpriseUrls', ({ enterpriseUrls }) => { - storage.setSync({ enterpriseUrls: without(enterpriseUrls, url) }, cb) - }) -} - -runtime.setUninstallURL('https://about.sourcegraph.com/uninstall/') - -runtime.onInstalled(() => { - setDefaultBrowserAction() - storage.getSync(items => { - // Enterprise deployments of Sourcegraph are passed a configuration file. - storage.getManaged(managedItems => { - storage.setSync( - { - ...defaultStorageItems, - ...items, - ...managedItems, - }, - () => { - if (managedItems && managedItems.enterpriseUrls && managedItems.enterpriseUrls.length) { - handleManagedPermissionRequest(managedItems.enterpriseUrls) - } else { - setDefaultBrowserAction() - } - } - ) - }) - }) -}) - -function handleManagedPermissionRequest(managedUrls: string[]): void { - setDefaultBrowserAction() - if (managedUrls.length === 0) { - return - } - permissions - .getAll() - .then(perms => { - const origins = perms.origins || [] - if (managedUrls.every(val => origins.indexOf(`${val}/*`) >= 0)) { - setDefaultBrowserAction() + // Not supported in Firefox + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded#Browser_compatibility + if (browser.permissions.onAdded) { + browser.permissions.onAdded.addListener(async permissions => { + if (!permissions.origins) { return } - browserAction.onClicked(() => { - runtime.openOptionsPage() - }) - }) - .catch(err => console.error('could not get all permissions', err)) -} + const items = await storage.sync.get() + const enterpriseUrls = items.enterpriseUrls || [] + for (const url of permissions.origins) { + enterpriseUrls.push(url.replace('/*', '')) + } + await storage.sync.set({ enterpriseUrls }) -function setDefaultBrowserAction(): void { - browserAction.setBadgeText({ text: '' }) - browserAction.setPopup({ popup: 'options.html?popup=true' }).catch(err => console.error(err)) -} - -browserAction.onClicked(noop) -setDefaultBrowserAction() - -// Add "Enable Sourcegraph on this domain" context menu item -DPT.addContextMenu() - -const ENDPOINT_KIND_REGEX = /^(proxy|expose)-/ - -const portKind = (port: chrome.runtime.Port) => { - const match = port.name.match(ENDPOINT_KIND_REGEX) - return match && match[1] -} - -/** - * A stream of EndpointPair created from Port objects emitted by chrome.runtime.onConnect. - * - * On initialization, the content script creates a pair of chrome.runtime.Port objects - * using chrome.runtime.connect(). The two ports are named 'proxy-{uuid}' and 'expose-{uuid}', - * and wrapped using {@link endpointFromPort} to behave like comlink endpoints on the content script side. - * - * This listens to events on chrome.runtime.onConnect, pairs emitted ports using their naming pattern, - * and emits pairs. Each pair of ports represents a connection with an instance of the content script. - */ -const endpointPairs: Observable<{ proxy: chrome.runtime.Port; expose: chrome.runtime.Port }> = fromEventPattern< - chrome.runtime.Port ->( - handler => chrome.runtime.onConnect.addListener(handler), - handler => chrome.runtime.onConnect.removeListener(handler) -).pipe( - groupBy( - port => (port.name || 'other').replace(ENDPOINT_KIND_REGEX, ''), - port => port, - group => group.pipe(bufferCount(2)) - ), - filter(group => group.key !== 'other'), - mergeMap(group => - group.pipe( - bufferCount(2), - map(ports => { - const proxyPort = ports.find(port => portKind(port) === 'proxy') - if (!proxyPort) { - throw new Error('No proxy port') - } - const exposePort = ports.find(port => portKind(port) === 'expose') - if (!exposePort) { - throw new Error('No expose port') - } - return { - proxy: proxyPort, - expose: exposePort, - } - }) - ) - ) -) - -/** - * Extension Host Connection - * - * When an Port pair is emitted, create an extension host worker. - * - * Messages from the ports are forwarded to the endpoints returned by {@link createExtensionHostWorker}, and vice-versa. - * - * The lifetime of the extension host worker is tied to that of the content script instance: - * when a port disconnects, the worker is terminated. This means there should always be exactly one - * extension host worker per active instance of the content script. - * - */ -endpointPairs.subscribe(({ proxy, expose }) => { - // It's necessary to wrap endpoints because chrome.runtime.Port objects do not support transfering MessagePorts. - // See https://github.com/GoogleChromeLabs/comlink/blob/master/messagechanneladapter.md - const { worker, clientEndpoints } = createExtensionHostWorker({ wrapEndpoints: true }) - const connectPortAndEndpoint = ( - port: chrome.runtime.Port, - endpoint: Endpoint & Pick - ): void => { - endpoint.start() - port.onMessage.addListener(message => { - endpoint.postMessage(message) - }) - endpoint.addEventListener('message', ({ data }) => { - port.postMessage(data) + const origins = without(permissions.origins, ...jsContentScriptOrigins) + customServerOrigins.push(...origins) }) } - // Connect proxy client endpoint - connectPortAndEndpoint(proxy, clientEndpoints.proxy) - // Connect expose client endpoint - connectPortAndEndpoint(expose, clientEndpoints.expose) - // Kill worker when either port disconnects - proxy.onDisconnect.addListener(() => worker.terminate()) - expose.onDisconnect.addListener(() => worker.terminate()) -}) + if (browser.permissions.onRemoved) { + browser.permissions.onRemoved.addListener(async permissions => { + if (!permissions.origins) { + return + } + customServerOrigins = without(customServerOrigins, ...permissions.origins) + const items = await storage.sync.get() + const enterpriseUrls = items.enterpriseUrls || [] + const urlsToRemove: string[] = [] + for (const url of permissions.origins) { + urlsToRemove.push(url.replace('/*', '')) + } + await storage.sync.set({ + enterpriseUrls: without(enterpriseUrls, ...urlsToRemove), + }) + }) + } + + // Inject content script whenever a new tab was opened with a URL that we have permissions for + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if ( + changeInfo.status === 'complete' && + customServerOrigins.some( + origin => origin === '' || (!!tab.url && tab.url.startsWith(origin.replace('/*', ''))) + ) + ) { + await browser.tabs.executeScript(tabId, { file: 'js/inject.bundle.js', runAt: 'document_end' }) + } + }) + + const handlers: BackgroundMessageHandlers = { + async openOptionsPage(): Promise { + await browser.runtime.openOptionsPage() + }, + + async createBlobURL(bundleUrl: string): Promise { + return await createBlobURLForBundle(bundleUrl) + }, + + async requestGraphQL(params: GraphQLRequestArgs): Promise { + return await requestGraphQL(params).toPromise() + }, + } + + // Handle calls from other scripts + browser.runtime.onMessage.addListener(async message => { + const method = message.type as keyof typeof handlers + if (!handlers[method]) { + throw new Error(`Invalid RPC call for "${method}"`) + } + return await handlers[method](message.payload) + }) + + await browser.runtime.setUninstallURL('https://about.sourcegraph.com/uninstall/') + + browser.runtime.onInstalled.addListener(async () => { + setDefaultBrowserAction() + const items = await storage.sync.get() + // Enterprise deployments of Sourcegraph are passed a configuration file. + const managedItems = await storage.managed.get() + await storage.sync.set({ + ...defaultStorageItems, + ...items, + ...managedItems, + }) + if (managedItems && managedItems.enterpriseUrls && managedItems.enterpriseUrls.length) { + await handleManagedPermissionRequest(managedItems.enterpriseUrls) + } else { + setDefaultBrowserAction() + } + }) + + async function handleManagedPermissionRequest(managedUrls: string[]): Promise { + setDefaultBrowserAction() + if (managedUrls.length === 0) { + return + } + const perms = await browser.permissions.getAll() + const origins = perms.origins || [] + if (managedUrls.every(val => origins.indexOf(`${val}/*`) >= 0)) { + setDefaultBrowserAction() + return + } + browser.browserAction.onClicked.addListener(async () => { + await browser.runtime.openOptionsPage() + }) + } + + function setDefaultBrowserAction(): void { + browser.browserAction.setBadgeText({ text: '' }) + browser.browserAction.setPopup({ popup: 'options.html?popup=true' }) + } + + browser.browserAction.onClicked.addListener(noop) + setDefaultBrowserAction() + + // Add "Enable Sourcegraph on this domain" context menu item + domainPermissionToggle.addContextMenu() + + const ENDPOINT_KIND_REGEX = /^(proxy|expose)-/ + + const portKind = (port: browser.runtime.Port): string | null => { + const match = port.name.match(ENDPOINT_KIND_REGEX) + return match && match[1] + } + + /** + * A stream of EndpointPair created from Port objects emitted by browser.runtime.onConnect. + * + * On initialization, the content script creates a pair of browser.runtime.Port objects + * using browser.runtime.connect(). The two ports are named 'proxy-{uuid}' and 'expose-{uuid}', + * and wrapped using {@link endpointFromPort} to behave like comlink endpoints on the content script side. + * + * This listens to events on browser.runtime.onConnect, pairs emitted ports using their naming pattern, + * and emits pairs. Each pair of ports represents a connection with an instance of the content script. + */ + const endpointPairs: Observable> = fromBrowserEvent( + browser.runtime.onConnect + ).pipe( + map(([port]) => port), + groupBy( + port => (port.name || 'other').replace(ENDPOINT_KIND_REGEX, ''), + port => port, + group => group.pipe(bufferCount(2)) + ), + filter(group => group.key !== 'other'), + mergeMap(group => + group.pipe( + bufferCount(2), + map(ports => { + const proxyPort = ports.find(port => portKind(port) === 'proxy') + if (!proxyPort) { + throw new Error('No proxy port') + } + const exposePort = ports.find(port => portKind(port) === 'expose') + if (!exposePort) { + throw new Error('No expose port') + } + return { + proxy: proxyPort, + expose: exposePort, + } + }) + ) + ) + ) + + /** + * Extension Host Connection + * + * When an Port pair is emitted, create an extension host worker. + * + * Messages from the ports are forwarded to the endpoints returned by {@link createExtensionHostWorker}, and vice-versa. + * + * The lifetime of the extension host worker is tied to that of the content script instance: + * when a port disconnects, the worker is terminated. This means there should always be exactly one + * extension host worker per active instance of the content script. + * + */ + endpointPairs.subscribe( + ({ proxy, expose }) => { + console.log('Extension host client connected') + // It's necessary to wrap endpoints because browser.runtime.Port objects do not support transfering MessagePorts. + // See https://github.com/GoogleChromeLabs/comlink/blob/master/messagechanneladapter.md + const { worker, clientEndpoints } = createExtensionHostWorker({ wrapEndpoints: true }) + const connectPortAndEndpoint = ( + port: browser.runtime.Port, + endpoint: Endpoint & Pick + ): void => { + endpoint.start() + port.onMessage.addListener(message => { + endpoint.postMessage(message) + }) + endpoint.addEventListener('message', ({ data }) => { + port.postMessage(data) + }) + } + // Connect proxy client endpoint + connectPortAndEndpoint(proxy, clientEndpoints.proxy) + // Connect expose client endpoint + connectPortAndEndpoint(expose, clientEndpoints.expose) + // Kill worker when either port disconnects + proxy.onDisconnect.addListener(() => worker.terminate()) + expose.onDisconnect.addListener(() => worker.terminate()) + }, + err => { + console.error('Error handling extension host client connection', err) + } + ) + + console.log('Sourcegraph background page initialized') +} + +// Browsers log this unhandled Promise automatically (and with a better stack trace through console.error) +// tslint:disable-next-line: no-floating-promises +main() diff --git a/client/browser/src/extension/scripts/inject.tsx b/client/browser/src/extension/scripts/inject.tsx index 4b35d9ec9d2..2dbe1175de0 100644 --- a/client/browser/src/extension/scripts/inject.tsx +++ b/client/browser/src/extension/scripts/inject.tsx @@ -5,12 +5,11 @@ import React from 'react' import { Observable, Subscription } from 'rxjs' import { startWith } from 'rxjs/operators' import { setLinkComponent } from '../../../../../shared/src/components/Link' -import storage from '../../browser/storage' -import { StorageItems } from '../../browser/types' +import { storage } from '../../browser/storage' import { determineCodeHost as detectCodeHost, injectCodeIntelligenceToCodeHost } from '../../libs/code_intelligence' import { initSentry } from '../../libs/sentry' import { checkIsSourcegraph, injectSourcegraphApp } from '../../libs/sourcegraph/inject' -import { setSourcegraphUrl } from '../../shared/util/context' +import { DEFAULT_SOURCEGRAPH_URL, setSourcegraphUrl } from '../../shared/util/context' import { MutationRecordLike, observeMutations } from '../../shared/util/dom' import { featureFlags } from '../../shared/util/featureFlags' import { assertEnv } from '../envAssertion' @@ -55,12 +54,12 @@ async function main(): Promise { subtree: true, }).pipe(startWith([{ addedNodes: [document.body], removedNodes: [] }])) - const items = await new Promise(resolve => storage.getSync(resolve)) + const items = await storage.sync.get() if (items.disableExtension) { return } - const sourcegraphServerUrl = items.sourcegraphURL || 'https://sourcegraph.com' + const sourcegraphServerUrl = items.sourcegraphURL || DEFAULT_SOURCEGRAPH_URL setSourcegraphUrl(sourcegraphServerUrl) const isSourcegraphServer = checkIsSourcegraph(sourcegraphServerUrl) diff --git a/client/browser/src/extension/scripts/options.tsx b/client/browser/src/extension/scripts/options.tsx index 75edcdc567e..aa5c5451f5a 100644 --- a/client/browser/src/extension/scripts/options.tsx +++ b/client/browser/src/extension/scripts/options.tsx @@ -5,8 +5,8 @@ import '../../config/polyfill' import * as React from 'react' import { render } from 'react-dom' import { noop, Subscription } from 'rxjs' -import storage from '../../browser/storage' -import { featureFlagDefaults, FeatureFlags } from '../../browser/types' +import { observeStorageKey, storage } from '../../browser/storage' +import { defaultStorageItems, featureFlagDefaults, FeatureFlags } from '../../browser/types' import { OptionsMenuProps } from '../../libs/options/Menu' import { OptionsContainer, OptionsContainerProps } from '../../libs/options/OptionsContainer' import { initSentry } from '../../libs/sentry' @@ -62,17 +62,21 @@ class Options extends React.Component<{}, State> { public componentDidMount(): void { this.subscriptions.add( - storage - .observeSync('featureFlags') - .subscribe(({ allowErrorReporting, experimentalLinkPreviews, experimentalTextFieldCompletion }) => { - this.setState({ allowErrorReporting, experimentalLinkPreviews, experimentalTextFieldCompletion }) - }) + observeStorageKey('sync', 'featureFlags').subscribe(featureFlags => { + const { allowErrorReporting, experimentalLinkPreviews, experimentalTextFieldCompletion } = { + ...featureFlagDefaults, + ...featureFlags, + } + this.setState({ allowErrorReporting, experimentalLinkPreviews, experimentalTextFieldCompletion }) + }) ) this.subscriptions.add( - storage.observeSync('sourcegraphURL').subscribe(sourcegraphURL => { - this.setState({ sourcegraphURL }) - }) + observeStorageKey('sync', 'sourcegraphURL').subscribe( + (sourcegraphURL = defaultStorageItems.sourcegraphURL) => { + this.setState({ sourcegraphURL }) + } + ) ) } @@ -99,10 +103,7 @@ class Options extends React.Component<{}, State> { origins: [`${url}/*`], }), - setSourcegraphURL: (url: string) => { - storage.setSync({ sourcegraphURL: url }) - }, - + setSourcegraphURL: (sourcegraphURL: string) => storage.sync.set({ sourcegraphURL }), toggleFeatureFlag, featureFlags: [ { key: 'allowErrorReporting', value: this.state.allowErrorReporting }, diff --git a/client/browser/src/libs/cli/index.ts b/client/browser/src/libs/cli/index.ts index 06d0ff329ec..c0c6eba114d 100644 --- a/client/browser/src/libs/cli/index.ts +++ b/client/browser/src/libs/cli/index.ts @@ -1,15 +1,16 @@ -import * as omnibox from '../../browser/omnibox' import searchCommand from './search' -export default function initialize({ onInputEntered, onInputChanged }: typeof omnibox): void { - onInputChanged((query, suggest) => { - searchCommand - .getSuggestions(query) - .then(suggest) - .catch(err => console.error('error getting suggestions', err)) +export function initializeOmniboxInterface(): void { + browser.omnibox.onInputChanged.addListener(async (query, suggest) => { + try { + const suggestions = await searchCommand.getSuggestions(query) + suggest(suggestions) + } catch (err) { + console.error('error getting suggestions', err) + } }) - onInputEntered((query, disposition) => { - searchCommand.action(query, disposition) + browser.omnibox.onInputEntered.addListener(async (query, disposition) => { + await searchCommand.action(query, disposition) }) } diff --git a/client/browser/src/libs/cli/search.ts b/client/browser/src/libs/cli/search.ts index f629e5532e1..36325124b94 100644 --- a/client/browser/src/libs/cli/search.ts +++ b/client/browser/src/libs/cli/search.ts @@ -1,7 +1,5 @@ -import storage from '../../browser/storage' -import * as tabs from '../../browser/tabs' - import { buildSearchURLQuery } from '../../../../../shared/src/util/url' +import { storage } from '../../browser/storage' import { createSuggestionFetcher } from '../../shared/backend/search' import { sourcegraphUrl } from '../../shared/util/context' @@ -12,9 +10,9 @@ class SearchCommand { private suggestionFetcher = createSuggestionFetcher(20) - private prev: { query: string; suggestions: chrome.omnibox.SuggestResult[] } = { query: '', suggestions: [] } + private prev: { query: string; suggestions: browser.omnibox.SuggestResult[] } = { query: '', suggestions: [] } - public getSuggestions = (query: string): Promise => + public getSuggestions = (query: string): Promise => new Promise(resolve => { if (this.prev.query === query) { resolve(this.prev.suggestions) @@ -39,25 +37,24 @@ class SearchCommand { }) }) - public action = (query: string, disposition?: string): void => { - storage.getSync(({ sourcegraphURL: url }) => { - const props = { - url: isURL.test(query) ? query : `${url}/search?${buildSearchURLQuery(query)}&utm_source=omnibox`, - } + public action = async (query: string, disposition?: string): Promise => { + const { sourcegraphURL: url } = await storage.sync.get() + const props = { + url: isURL.test(query) ? query : `${url}/search?${buildSearchURLQuery(query)}&utm_source=omnibox`, + } - switch (disposition) { - case 'newForegroundTab': - tabs.create(props) - break - case 'newBackgroundTab': - tabs.create({ ...props, active: false }) - break - case 'currentTab': - default: - tabs.update(props) - break - } - }) + switch (disposition) { + case 'newForegroundTab': + await browser.tabs.create(props) + break + case 'newBackgroundTab': + await browser.tabs.create({ ...props, active: false }) + break + case 'currentTab': + default: + await browser.tabs.update(props) + break + } } } diff --git a/client/browser/src/libs/code_intelligence/code_intelligence.tsx b/client/browser/src/libs/code_intelligence/code_intelligence.tsx index 955a03bd1c4..df4f53db7e3 100644 --- a/client/browser/src/libs/code_intelligence/code_intelligence.tsx +++ b/client/browser/src/libs/code_intelligence/code_intelligence.tsx @@ -47,7 +47,6 @@ import { toURIWithPath, ViewStateSpec, } from '../../../../../shared/src/util/url' -import { sendMessage } from '../../browser/runtime' import { isInPage } from '../../context' import { ERPRIVATEREPOPUBLICSOURCEGRAPHCOM } from '../../shared/backend/errors' import { createLSPFromExtensions, toTextDocumentIdentifier } from '../../shared/backend/lsp' @@ -362,9 +361,7 @@ export function handleCodeHost({ catchError(() => [false]) ) - const openOptionsMenu = () => { - sendMessage({ type: 'openOptionsPage' }) - } + const openOptionsMenu = () => browser.runtime.sendMessage({ type: 'openOptionsPage' }) const addedElements = mutations.pipe( concatAll(), diff --git a/client/browser/src/libs/code_intelligence/external_links.tsx b/client/browser/src/libs/code_intelligence/external_links.tsx index 561a901f8d6..f590761a060 100644 --- a/client/browser/src/libs/code_intelligence/external_links.tsx +++ b/client/browser/src/libs/code_intelligence/external_links.tsx @@ -5,6 +5,7 @@ import { render } from 'react-dom' import { Observable, Subject, Subscription } from 'rxjs' import { distinctUntilChanged, map, switchMap } from 'rxjs/operators' import { SourcegraphIconButton } from '../../shared/components/Button' +import { DEFAULT_SOURCEGRAPH_URL } from '../../shared/util/context' import { CodeHost, CodeHostContext } from './code_intelligence' export interface ViewOnSourcegraphButtonClassProps { @@ -73,7 +74,7 @@ class ViewOnSourcegraphButton extends React.Component { {...stubs} sourcegraphURL={url} ensureValidSite={ensureValidSite} - setSourcegraphURL={noop} + setSourcegraphURL={() => Promise.resolve()} /> ) }) @@ -92,7 +94,7 @@ describe('OptionsContainer', () => { {...stubs} sourcegraphURL={url} ensureValidSite={ensureValidSite} - setSourcegraphURL={noop} + setSourcegraphURL={() => Promise.resolve()} /> ) }) @@ -114,7 +116,7 @@ describe('OptionsContainer', () => { {...stubs} sourcegraphURL={'https://test.com'} ensureValidSite={ensureValidSite} - setSourcegraphURL={noop} + setSourcegraphURL={() => Promise.resolve()} /> ) } catch (err) { diff --git a/client/browser/src/libs/options/OptionsContainer.tsx b/client/browser/src/libs/options/OptionsContainer.tsx index 1d10ded7df1..b8e196d1414 100644 --- a/client/browser/src/libs/options/OptionsContainer.tsx +++ b/client/browser/src/libs/options/OptionsContainer.tsx @@ -1,8 +1,8 @@ import * as React from 'react' import { Observable, of, Subject, Subscription } from 'rxjs' import { catchError, distinctUntilChanged, filter, map, share, switchMap } from 'rxjs/operators' -import { getExtensionVersionSync } from '../../browser/runtime' import { ERAUTHREQUIRED, ErrorLike, isErrorLike } from '../../shared/backend/errors' +import { getExtensionVersion } from '../../shared/util/context' import { OptionsMenu, OptionsMenuProps } from './Menu' import { ConnectionErrors } from './ServerURLForm' @@ -12,7 +12,7 @@ export interface OptionsContainerProps { fetchCurrentTabStatus: () => Promise hasPermissions: (url: string) => Promise requestPermissions: (url: string) => void - setSourcegraphURL: (url: string) => void + setSourcegraphURL: (url: string) => Promise toggleFeatureFlag: (key: string) => void featureFlags: { key: string; value: boolean }[] } @@ -24,7 +24,7 @@ interface OptionsContainerState > {} export class OptionsContainer extends React.Component { - private version = getExtensionVersionSync() + private version = getExtensionVersion() private urlUpdates = new Subject() @@ -84,7 +84,7 @@ export class OptionsContainer extends React.Component { - this.props.setSourcegraphURL(this.state.sourcegraphURL) + private handleURLSubmit = async () => { + await this.props.setSourcegraphURL(this.state.sourcegraphURL) } private handleSettingsClick = () => { diff --git a/client/browser/src/libs/phabricator/backend.tsx b/client/browser/src/libs/phabricator/backend.tsx index 8dde4b37e73..f8bd95c692d 100644 --- a/client/browser/src/libs/phabricator/backend.tsx +++ b/client/browser/src/libs/phabricator/backend.tsx @@ -1,7 +1,7 @@ import { from, Observable } from 'rxjs' import { map } from 'rxjs/operators' import { memoizeObservable } from '../../../../../shared/src/util/memoizeObservable' -import storage from '../../browser/storage' +import { storage } from '../../browser/storage' import { isExtension } from '../../context' import { getContext } from '../../shared/backend/context' import { mutateGraphQL } from '../../shared/backend/graphql' @@ -404,45 +404,42 @@ export async function getRepoDetailsFromDifferentialID(differentialID: number): return await getRepoDetailsFromRepoPHID(repositoryPHID) } -function convertConduitRepoToRepoDetails(repo: ConduitRepo): Promise { - return new Promise((resolve, reject) => { - if (isExtension) { - return storage.getSync(items => { - if (items.phabricatorMappings) { - for (const mapping of items.phabricatorMappings) { - if (mapping.callsign === repo.fields.callsign) { - return resolve({ - callsign: repo.fields.callsign, - repoName: mapping.path, - }) - } - } - } - return resolve(convertToDetails(repo)) - }) - } - // The path to a phabricator repository on a Sourcegraph instance may differ than it's URI / name from the - // phabricator conduit API. Since we do not currently send the PHID with the Phabricator repository this a - // backwards work around configuration setting to ensure mappings are correct. This logic currently exists - // in the browser extension options menu. - type Mappings = { callsign: string; path: string }[] - const mappingsString = window.localStorage.getItem('PHABRICATOR_CALLSIGN_MAPPINGS') - const callsignMappings = mappingsString - ? (JSON.parse(mappingsString) as Mappings) - : window.PHABRICATOR_CALLSIGN_MAPPINGS || [] - const details = convertToDetails(repo) - if (callsignMappings) { - for (const mapping of callsignMappings) { +async function convertConduitRepoToRepoDetails(repo: ConduitRepo): Promise { + if (isExtension) { + const items = await storage.sync.get() + if (items.phabricatorMappings) { + for (const mapping of items.phabricatorMappings) { if (mapping.callsign === repo.fields.callsign) { - return resolve({ + return { callsign: repo.fields.callsign, repoName: mapping.path, - }) + } } } } - return resolve(details) - }) + return convertToDetails(repo) + } + // The path to a phabricator repository on a Sourcegraph instance may differ than it's URI / name from the + // phabricator conduit API. Since we do not currently send the PHID with the Phabricator repository this a + // backwards work around configuration setting to ensure mappings are correct. This logic currently exists + // in the browser extension options menu. + type Mappings = { callsign: string; path: string }[] + const mappingsString = window.localStorage.getItem('PHABRICATOR_CALLSIGN_MAPPINGS') + const callsignMappings = mappingsString + ? (JSON.parse(mappingsString) as Mappings) + : window.PHABRICATOR_CALLSIGN_MAPPINGS || [] + const details = convertToDetails(repo) + if (callsignMappings) { + for (const mapping of callsignMappings) { + if (mapping.callsign === repo.fields.callsign) { + return { + callsign: repo.fields.callsign, + repoName: mapping.path, + } + } + } + } + return details } function convertToDetails(repo: ConduitRepo): PhabricatorRepoDetails | null { diff --git a/client/browser/src/libs/sentry/index.ts b/client/browser/src/libs/sentry/index.ts index 30ea436c284..8059e4319a6 100644 --- a/client/browser/src/libs/sentry/index.ts +++ b/client/browser/src/libs/sentry/index.ts @@ -1,14 +1,10 @@ import * as Sentry from '@sentry/browser' import { once } from 'lodash' - -import { getExtensionVersionSync } from '../../browser/runtime' -import storage from '../../browser/storage' +import { observeStorageKey } from '../../browser/storage' +import { featureFlagDefaults } from '../../browser/types' import { isInPage } from '../../context' -import { bitbucketServerCodeHost } from '../bitbucket/code_intelligence' -import { CodeHost } from '../code_intelligence' -import { githubCodeHost } from '../github/code_intelligence' -import { gitlabCodeHost } from '../gitlab/code_intelligence' -import { phabricatorCodeHost } from '../phabricator/code_intelligence' +import { DEFAULT_SOURCEGRAPH_URL, getExtensionVersion } from '../../shared/util/context' +import { determineCodeHost } from '../code_intelligence' const isExtensionStackTrace = (stacktrace: Sentry.Stacktrace, extensionID: string): boolean => !!(stacktrace.frames && stacktrace.frames.some(({ filename }) => !!(filename && filename.includes(extensionID)))) @@ -38,11 +34,15 @@ export function initSentry(script: 'content' | 'options' | 'background'): void { return } - storage.observeSync('featureFlags').subscribe(flags => { + observeStorageKey('sync', 'featureFlags').subscribe((flags = featureFlagDefaults) => { const allowed = flags.allowErrorReporting // Don't initialize if user hasn't allowed us to report errors or in Phabricator. if (!allowed || isInPage) { + const client = Sentry.getCurrentHub().getClient() as Sentry.BrowserClient | undefined + if (client) { + client.getOptions().enabled = false + } return } @@ -50,22 +50,17 @@ export function initSentry(script: 'content' | 'options' | 'background'): void { Sentry.configureScope(async scope => { scope.setTag('script', script) - scope.setTag('extension_version', getExtensionVersionSync()) - - const codeHosts: CodeHost[] = [bitbucketServerCodeHost, githubCodeHost, gitlabCodeHost, phabricatorCodeHost] - for (const { check, name } of codeHosts) { - const is = await Promise.resolve(check()) - if (is) { - scope.setTag('code_host', name) - } - return + scope.setTag('extension_version', getExtensionVersion()) + const codeHost = determineCodeHost() + if (codeHost) { + scope.setTag('code_host', codeHost.name) } }) + }) - storage.observeSync('sourcegraphURL').subscribe(url => { - Sentry.configureScope(scope => { - scope.setTag('using_dot_com', url === 'https://sourcegraph.com' ? 'true' : 'false') - }) + observeStorageKey('sync', 'sourcegraphURL').subscribe(url => { + Sentry.configureScope(scope => { + scope.setTag('using_dot_com', url === DEFAULT_SOURCEGRAPH_URL ? 'true' : 'false') }) }) } diff --git a/client/browser/src/platform/context.ts b/client/browser/src/platform/context.ts index 9c75c41df71..f2a26105785 100644 --- a/client/browser/src/platform/context.ts +++ b/client/browser/src/platform/context.ts @@ -5,10 +5,12 @@ import * as GQL from '../../../../shared/src/graphql/schema' import { PlatformContext } from '../../../../shared/src/platform/context' import { mutateSettings, updateSettings } from '../../../../shared/src/settings/edit' import { EMPTY_SETTINGS_CASCADE, gqlToCascade } from '../../../../shared/src/settings/settings' +import { LocalStorageSubject } from '../../../../shared/src/util/LocalStorageSubject' import { toPrettyBlobURL } from '../../../../shared/src/util/url' import { ExtensionStorageSubject } from '../browser/ExtensionStorageSubject' -import * as runtime from '../browser/runtime' -import storage from '../browser/storage' +import { background } from '../browser/runtime' +import { observeStorageKey } from '../browser/storage' +import { defaultStorageItems } from '../browser/types' import { isInPage } from '../context' import { CodeHost } from '../libs/code_intelligence' import { getContext } from '../shared/backend/context' @@ -37,7 +39,7 @@ export function createPlatformContext({ urlToFile }: Pick merge( isInPage ? fetchViewerSettings() - : storage.observeSync('sourcegraphURL').pipe(switchMap(() => fetchViewerSettings())), + : observeStorageKey('sync', 'sourcegraphURL').pipe(switchMap(() => fetchViewerSettings())), updatedViewerSettings ).pipe( publishReplay(1), @@ -86,10 +88,10 @@ export function createPlatformContext({ urlToFile }: Pick }) } - return storage.observeSync('sourcegraphURL').pipe( + return observeStorageKey('sync', 'sourcegraphURL').pipe( take(1), mergeMap( - (url: string): Observable> => + (url: string = defaultStorageItems.sourcegraphURL): Observable> => requestGraphQL({ ctx: getContext({ repoKey: '', isRepoSpecific: false }), request, @@ -114,16 +116,7 @@ export function createPlatformContext({ urlToFile }: Pick // with a CSP that allowlists https://* in script-src (see // https://developer.chrome.com/extensions/contentSecurityPolicy#relaxing-remote-script). (Firefox // add-ons have an even stricter restriction.) - const blobURL = await new Promise(resolve => - runtime.sendMessage( - { - type: 'createBlobURL', - payload: bundleURL, - }, - resolve - ) - ) - + const blobURL = await background.createBlobURL(bundleURL) return blobURL }, urlToFile: location => { @@ -136,7 +129,9 @@ export function createPlatformContext({ urlToFile }: Pick }, sourcegraphURL: sourcegraphUrl, clientApplication: 'other', - sideloadedExtensionURL: new ExtensionStorageSubject('sideloadedExtensionURL', null), + sideloadedExtensionURL: isInPage + ? new LocalStorageSubject('sideloadedExtensionURL', null) + : new ExtensionStorageSubject('sideloadedExtensionURL', null), } return context } diff --git a/client/browser/src/platform/extensionHost.ts b/client/browser/src/platform/extensionHost.ts index 7687d77a8e5..d9bbb188ada 100644 --- a/client/browser/src/platform/extensionHost.ts +++ b/client/browser/src/platform/extensionHost.ts @@ -11,7 +11,7 @@ import { isInPage } from '../context' * When executing in-page (for example as a Phabricator plugin), this simply * creates an extension host worker and emits the returned EndpointPair. * - * When executing in the browser extension, we create pair of chrome.runtime.Port objects, + * When executing in the browser extension, we create pair of browser.runtime.Port objects, * named 'expose-{uuid}' and 'proxy-{uuid}', and return the ports wrapped using ${@link endpointFromPort}. * * The background script will listen to newly created ports, create an extension host @@ -24,8 +24,8 @@ export function createExtensionHost(): Observable { } const id = uuid.v4() return new Observable(subscriber => { - const proxyPort = chrome.runtime.connect({ name: `proxy-${id}` }) - const exposePort = chrome.runtime.connect({ name: `expose-${id}` }) + const proxyPort = browser.runtime.connect({ name: `proxy-${id}` }) + const exposePort = browser.runtime.connect({ name: `expose-${id}` }) subscriber.next({ proxy: endpointFromPort(proxyPort), expose: endpointFromPort(exposePort), @@ -38,25 +38,25 @@ export function createExtensionHost(): Observable { } /** - * Partially wraps a chrome.runtime.Port and returns a MessagePort created using + * Partially wraps a browser.runtime.Port and returns a MessagePort created using * comlink's {@link MessageChannelAdapter}, so that the Port can be used * as a comlink Endpoint to transport messages between the content script and the extension host. * - * It is necessary to wrap the port using MessageChannelAdapter because chrome.runtime.Port objects do not support + * It is necessary to wrap the port using MessageChannelAdapter because browser.runtime.Port objects do not support * transfering MessagePort objects (see https://github.com/GoogleChromeLabs/comlink/blob/master/messagechanneladapter.md). * */ -function endpointFromPort(port: chrome.runtime.Port): MessagePort { - const listeners = new Map<(event: MessageEvent) => any, (message: object, port: chrome.runtime.Port) => void>() +function endpointFromPort(port: browser.runtime.Port): MessagePort { + const messageListeners = new Map<(event: MessageEvent) => any, (message: unknown) => void>() return MessageChannelAdapter.wrap({ - send(data): void { + send(data: string): void { port.postMessage(data) }, - addEventListener(event, messageListener): void { + addEventListener(event: 'message', messageListener: (event: MessageEvent) => any): void { if (event !== 'message') { return } - const chromePortListener = (data: object) => { + const portListener = (data: unknown) => { // This callback is called *very* often (e.g., ~900 times per keystroke in a // monitored textarea). Avoid creating unneeded objects here because GC // significantly hurts perf. See @@ -69,18 +69,18 @@ function endpointFromPort(port: chrome.runtime.Port): MessagePort { // so losing the properties is not a big problem. messageListener.call(this, { data } as any) } - listeners.set(messageListener, chromePortListener) - port.onMessage.addListener(chromePortListener) + messageListeners.set(messageListener, portListener) + port.onMessage.addListener(portListener) }, - removeEventListener(event, messageListener): void { + removeEventListener(event: 'message', messageListener: (event: MessageEvent) => any): void { if (event !== 'message') { return } - const chromePortListener = listeners.get(messageListener) - if (!chromePortListener) { + const portListener = messageListeners.get(messageListener) + if (!portListener) { return } - port.onMessage.removeListener(chromePortListener) + port.onMessage.removeListener(portListener) }, }) } diff --git a/client/browser/src/platform/settings.ts b/client/browser/src/platform/settings.ts index aa32c9a0ebf..c46b58e724d 100644 --- a/client/browser/src/platform/settings.ts +++ b/client/browser/src/platform/settings.ts @@ -13,7 +13,7 @@ import { } from '../../../../shared/src/settings/settings' import { createAggregateError, isErrorLike } from '../../../../shared/src/util/errors' import { LocalStorageSubject } from '../../../../shared/src/util/LocalStorageSubject' -import storage, { StorageItems } from '../browser/storage' +import { observeStorageKey, storage } from '../browser/storage' import { isInPage } from '../context' import { getContext } from '../shared/backend/context' import { queryGraphQL } from '../shared/backend/graphql' @@ -24,14 +24,13 @@ const inPageClientSettingsKey = 'sourcegraphClientSettings' const createStorageSettingsCascade: () => Observable = () => { const storageSubject = isInPage ? new LocalStorageSubject(inPageClientSettingsKey, '{}') - : storage.observeSync('clientSettings') + : observeStorageKey('sync', 'clientSettings') const subject: SettingsSubject = { - id: 'Client', - settingsURL: 'N/A', - viewerCanAdminister: true, __typename: 'Client', + id: 'Client', displayName: 'Client', + viewerCanAdminister: true, } return storageSubject.pipe( @@ -157,7 +156,7 @@ export function fetchViewerSettings(): Observable { +export async function editClientSettings(edit: SettingsEdit | string): Promise { const getNext = (prev: string) => typeof edit === 'string' ? edit @@ -180,16 +179,8 @@ export function editClientSettings(edit: SettingsEdit | string): Promise { return Promise.resolve() } - return new Promise(resolve => storage.getSync(storageItems => resolve(storageItems))).then( - storageItems => { - const prev = storageItems.clientSettings - const next = getNext(prev) + const { clientSettings: prev = '{}' } = await storage.sync.get() + const next = getNext(prev) - return new Promise(resolve => - storage.setSync({ clientSettings: next }, () => { - resolve(undefined) - }) - ) - } - ) + await storage.sync.set({ clientSettings: next }) } diff --git a/client/browser/src/shared/backend/graphql.tsx b/client/browser/src/shared/backend/graphql.ts similarity index 90% rename from client/browser/src/shared/backend/graphql.tsx rename to client/browser/src/shared/backend/graphql.ts index cc94c1ac894..f2e38dcb6e8 100644 --- a/client/browser/src/shared/backend/graphql.tsx +++ b/client/browser/src/shared/backend/graphql.ts @@ -3,18 +3,27 @@ import { ajax } from 'rxjs/ajax' import { catchError, map } from 'rxjs/operators' import { GraphQLResult } from '../../../../../shared/src/graphql/graphql' import * as GQL from '../../../../../shared/src/graphql/schema' +import { background } from '../../browser/runtime' import { isBackground, isInPage } from '../../context' import { DEFAULT_SOURCEGRAPH_URL, repoUrlCache, sourcegraphUrl } from '../util/context' import { RequestContext } from './context' import { AuthRequiredError, createAuthRequiredError, PrivateRepoPublicSourcegraphComError } from './errors' import { getHeaders } from './headers' -interface GraphQLRequestArgs { +export interface GraphQLRequestArgs { ctx: RequestContext + + /** The GraphQL request (query or mutation) */ request: string + + /** A key/value object with variable values */ variables?: any + + /** the url the request is going to */ url?: string + retry?: boolean + /** * Whether or not to use an access token for the request. All requests * except requests used while creating an access token should use an access @@ -22,10 +31,13 @@ interface GraphQLRequestArgs { * user ID for `createAccessToken`. */ useAccessToken?: boolean + authError?: AuthRequiredError requestMightContainPrivateInfo?: boolean + /** * An alternative `AjaxCreationMethod`, useful to stub rxjs.ajax in tests. + * Cannot be provided from content scripts. */ ajaxRequest?: typeof ajax } @@ -42,13 +54,9 @@ function privateRepoPublicSourcegraph({ /** * Does a GraphQL request to the Sourcegraph GraphQL API running under `/.api/graphql` * - * @param request The GraphQL request (query or mutation) - * @param variables A key/value object with variable values - * @param url the url the request is going to - * @param options configuration options for the request * @return Observable That emits the result or errors if the HTTP request failed */ -export const requestGraphQL: typeof performRequest = (args: GraphQLRequestArgs) => { +export const requestGraphQL = (args: GraphQLRequestArgs): Observable => { // Make sure all GraphQL API requests are sent from the background page, so as to bypass CORS // restrictions when running on private code hosts with the public Sourcegraph instance. This // allows us to run extensions on private code hosts without needing a private Sourcegraph @@ -57,14 +65,7 @@ export const requestGraphQL: typeof performRequest = (args: GraphQLRequestArgs) return performRequest(args) } - return from(browser.runtime.sendMessage({ type: 'requestGraphQL', payload: args })).pipe( - map(({ result, err }) => { - if (err) { - throw err - } - return result - }) - ) + return from(background.requestGraphQL(args)) } function performRequest({ @@ -76,7 +77,7 @@ function performRequest({ authError, requestMightContainPrivateInfo = true, ajaxRequest = ajax, -}: GraphQLRequestArgs & { ajaxRequest?: typeof ajax }): Observable { +}: GraphQLRequestArgs): Observable { const nameMatch = request.match(/^\s*(?:query|mutation)\s+(\w+)/) const queryName = nameMatch ? '?' + nameMatch[1] : '' diff --git a/client/browser/src/shared/backend/headers.tsx b/client/browser/src/shared/backend/headers.tsx index 5a4126bb073..ee0459f8fb3 100644 --- a/client/browser/src/shared/backend/headers.tsx +++ b/client/browser/src/shared/backend/headers.tsx @@ -1,4 +1,4 @@ -import { getExtensionVersionSync, getPlatformName } from '../util/context' +import { getExtensionVersion, getPlatformName } from '../util/context' /** * getHeaders emits the required headers for making requests to Sourcegraph server instances. @@ -7,6 +7,6 @@ import { getExtensionVersionSync, getPlatformName } from '../util/context' export function getHeaders(): { [name: string]: string } | undefined { // This is required for requests to be allowed by Sourcegraph's CORS rules. return { - 'X-Requested-With': `Sourcegraph - ${getPlatformName()} v${getExtensionVersionSync()}`, + 'X-Requested-With': `Sourcegraph - ${getPlatformName()} v${getExtensionVersion()}`, } } diff --git a/client/browser/src/shared/backend/userEvents.tsx b/client/browser/src/shared/backend/userEvents.tsx index 92067a77d42..00e5cb1c2c4 100644 --- a/client/browser/src/shared/backend/userEvents.tsx +++ b/client/browser/src/shared/backend/userEvents.tsx @@ -24,6 +24,7 @@ export const logUserEvent = (event: string, uid: string): void => { }`, variables: { event, userCookieID: uid }, retry: false, + // tslint:disable-next-line: rxjs-no-ignored-subscription }).subscribe({ error: error => { // Swallow errors. If a Sourcegraph instance isn't upgraded, this request may fail diff --git a/client/browser/src/shared/components/NeedsRepositoryConfigurationAlert.tsx b/client/browser/src/shared/components/NeedsRepositoryConfigurationAlert.tsx deleted file mode 100644 index c46e7d7a610..00000000000 --- a/client/browser/src/shared/components/NeedsRepositoryConfigurationAlert.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import CloseIcon from 'mdi-react/CloseIcon' -import WarningIcon from 'mdi-react/WarningIcon' -import * as React from 'react' -import storage from '../../browser/storage' -import { sourcegraphUrl } from '../util/context' - -interface Props { - onClose: () => void - repoName: string -} - -export const REPO_CONFIGURATION_KEY = 'NeedsRepoConfigurationAlertDismissed' - -/** - * A global alert telling the site admin that they need to configure - * external services on this site. - */ -export class NeedsRepositoryConfigurationAlert extends React.Component { - private sync = () => { - const obj = { [REPO_CONFIGURATION_KEY]: { [this.props.repoName]: true } } - storage.setSync(obj, () => { - this.props.onClose() - }) - } - - private onClick = () => { - this.sync() - } - - private onClose = () => { - this.sync() - } - - public render(): JSX.Element | null { - return ( -
- - - - {' '} - Configure code hosts - -   (and other external services) to add repositories to Sourcegraph. -
- - - -
-
- ) - } -} diff --git a/client/browser/src/shared/components/ServerAlert.tsx b/client/browser/src/shared/components/ServerAlert.tsx deleted file mode 100644 index 9767800bc20..00000000000 --- a/client/browser/src/shared/components/ServerAlert.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import CloseIcon from 'mdi-react/CloseIcon' -import WarningIcon from 'mdi-react/WarningIcon' -import * as React from 'react' -import storage from '../../browser/storage' - -interface Props { - onClose: () => void -} - -export const SERVER_CONFIGURATION_KEY = 'NeedsServerConfigurationAlertDismissed' - -/** - * A global alert telling the user that they need to configure Sourcegraph - * to get code intelligence and search on private code. - */ -export class NeedsServerConfigurationAlert extends React.Component { - private sync(): void { - storage.setSync({ [SERVER_CONFIGURATION_KEY]: true }, () => { - this.props.onClose() - }) - } - - private onClicked = () => { - this.sync() - } - - private onClose = () => { - this.sync() - } - - public render(): JSX.Element | null { - return ( -
- - - - {' '} - Configure Sourcegraph - -  for code intelligence on private repositories. -
- - - -
-
- ) - } -} diff --git a/client/browser/src/shared/tracking/EventLogger.tsx b/client/browser/src/shared/tracking/EventLogger.tsx deleted file mode 100644 index cf50c222bcd..00000000000 --- a/client/browser/src/shared/tracking/EventLogger.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import uuid from 'uuid' -import * as GQL from '../../../../../shared/src/graphql/schema' -import { TelemetryService } from '../../../../../shared/src/telemetry/telemetryService' -import storage from '../../browser/storage' -import { isInPage } from '../../context' -import { logUserEvent } from '../backend/userEvents' - -const uidKey = 'sourcegraphAnonymousUid' - -export class EventLogger implements TelemetryService { - private uid: string | null = null - - constructor() { - // Fetch user ID on initial load. - this.getAnonUserID().then( - () => { - /* noop */ - }, - () => { - /* noop */ - } - ) - } - - /** - * Generate a new anonymous user ID if one has not yet been set and stored. - */ - private generateAnonUserID = (): string => uuid.v4() - - /** - * Get the anonymous identifier for this user (allows site admins on a private Sourcegraph - * instance to see a count of unique users on a daily, weekly, and monthly basis). - * - * Not used at all for public/Sourcegraph.com usage. - */ - private getAnonUserID = (): Promise => - new Promise(resolve => { - if (this.uid) { - resolve(this.uid) - return - } - - if (isInPage) { - let id = localStorage.getItem(uidKey) - if (id === null) { - id = this.generateAnonUserID() - localStorage.setItem(uidKey, id) - } - this.uid = id - resolve(this.uid) - } else { - storage.getSyncItem(uidKey, ({ sourcegraphAnonymousUid }) => { - if (sourcegraphAnonymousUid === '') { - sourcegraphAnonymousUid = this.generateAnonUserID() - storage.setSync({ sourcegraphAnonymousUid }) - } - this.uid = sourcegraphAnonymousUid - resolve(sourcegraphAnonymousUid) - }) - } - }) - - /** - * Log a user action on the associated self-hosted Sourcegraph instance (allows site admins on a private - * Sourcegraph instance to see a count of unique users on a daily, weekly, and monthly basis). - * - * This is never sent to Sourcegraph.com (i.e., when using the integration with open source code). - */ - public logCodeIntelligenceEvent(event: GQL.UserEvent): void { - this.getAnonUserID().then( - anonUserId => logUserEvent(event, anonUserId), - () => { - /* noop */ - } - ) - } - - /** - * Implements {@link TelemetryService}. - * - * @todo Handle arbitrary action IDs. - * - * @param _eventName The ID of the action executed. - */ - public log(_eventName: string): void { - if (_eventName === 'goToDefinition' || _eventName === 'goToDefinition.preloaded' || _eventName === 'hover') { - this.logCodeIntelligenceEvent(GQL.UserEvent.CODEINTELINTEGRATION) - } else if (_eventName === 'findReferences') { - this.logCodeIntelligenceEvent(GQL.UserEvent.CODEINTELINTEGRATIONREFS) - } - } -} diff --git a/client/browser/src/shared/util/browser.ts b/client/browser/src/shared/util/browser.ts new file mode 100644 index 00000000000..d74347127d3 --- /dev/null +++ b/client/browser/src/shared/util/browser.ts @@ -0,0 +1,20 @@ +import { Observable } from 'rxjs' + +/** + * Returns an Observable for a WebExtension API event listener. + * The handler will always return `void`. + */ +export const fromBrowserEvent = void>( + emitter: browser.CallbackEventEmitter +): Observable> => + // Do not use fromEventPattern() because of https://github.com/ReactiveX/rxjs/issues/4736 + new Observable(subscriber => { + const handler: any = (...args: any) => subscriber.next(args) + try { + emitter.addListener(handler) + } catch (err) { + subscriber.error(err) + return undefined + } + return () => emitter.removeListener(handler) + }) diff --git a/client/browser/src/shared/util/context.tsx b/client/browser/src/shared/util/context.tsx index e06872a7548..91c7121fbae 100644 --- a/client/browser/src/shared/util/context.tsx +++ b/client/browser/src/shared/util/context.tsx @@ -1,12 +1,8 @@ -import * as runtime from '../../browser/runtime' -import storage from '../../browser/storage' +import { storage } from '../../browser/storage' import { isPhabricator, isPublicCodeHost } from '../../context' -import { EventLogger } from '../tracking/EventLogger' export const DEFAULT_SOURCEGRAPH_URL = 'https://sourcegraph.com' -export let eventLogger = new EventLogger() - export let sourcegraphUrl = window.localStorage.getItem('SOURCEGRAPH_URL') || window.SOURCEGRAPH_URL || DEFAULT_SOURCEGRAPH_URL @@ -16,9 +12,12 @@ interface UrlCache { export const repoUrlCache: UrlCache = {} -if (window.SG_ENV === 'EXTENSION') { - storage.getSync(items => { - sourcegraphUrl = items.sourcegraphURL +if (window.SG_ENV === 'EXTENSION' && globalThis.browser) { + // tslint:disable-next-line: no-floating-promises TODO just get rid of the global sourcegraphUrl + storage.sync.get().then(items => { + if (items.sourcegraphURL) { + sourcegraphUrl = items.sourcegraphURL + } }) } @@ -38,8 +37,13 @@ export function getPlatformName(): 'phabricator-integration' | 'firefox-extensio return isFirefoxExtension() ? 'firefox-extension' : 'chrome-extension' } -export function getExtensionVersionSync(): string { - return runtime.getExtensionVersionSync() +export function getExtensionVersion(): string { + if (globalThis.browser) { + const manifest = browser.runtime.getManifest() + return manifest.version + } + + return 'NO_VERSION' } function isFirefoxExtension(): boolean { diff --git a/client/browser/src/shared/util/featureFlags.ts b/client/browser/src/shared/util/featureFlags.ts index 9db6e153552..81103f8f759 100644 --- a/client/browser/src/shared/util/featureFlags.ts +++ b/client/browser/src/shared/util/featureFlags.ts @@ -1,5 +1,5 @@ -import storage from '../../browser/storage' -import { FeatureFlags } from '../../browser/types' +import { storage } from '../../browser/storage' +import { featureFlagDefaults, FeatureFlags } from '../../browser/types' import { isInPage } from '../../context' interface FeatureFlagsStorage { @@ -10,50 +10,47 @@ interface FeatureFlagsStorage { /** * Enable a feature flag. */ - enable(key: K): Promise + enable(key: K): Promise /** * Disable a feature flag. */ - disable(key: K): Promise + disable(key: K): Promise /** * Set a feature flag. */ - set(key: K, enabled: boolean): Promise + set(key: K, enabled: boolean): Promise /** Toggle a feature flag. */ toggle(key: K): Promise } interface FeatureFlagUtilities { - get(key: K): Promise - set(key: K, enabled: boolean): Promise + get(key: keyof FeatureFlags): Promise + set(key: keyof FeatureFlags, enabled: boolean): Promise } const createFeatureFlagStorage = ({ get, set }: FeatureFlagUtilities): FeatureFlagsStorage => ({ set, enable: key => set(key, true), disable: key => set(key, false), - isEnabled(key: K): Promise { - return get(key).then(val => !!val) + async isEnabled(key: K): Promise { + const value = await get(key) + return typeof value === 'boolean' ? value : featureFlagDefaults[key] }, - toggle(key: K): Promise { - return get(key).then(val => set(key, !val)) + async toggle(key: K): Promise { + const val = await get(key) + await set(key, !val) + return !val }, }) -function bextGet(key: K): Promise { - return new Promise(resolve => - storage.getSync(({ featureFlags }) => { - resolve(featureFlags[key]) - }) - ) +async function bextGet(key: K): Promise { + const { featureFlags = {} } = await storage.sync.get() + return featureFlags[key] } -function bextSet(key: K, val: FeatureFlags[K]): Promise { - return new Promise(resolve => - storage.getSync(({ featureFlags }) => - storage.setSync({ featureFlags: { ...featureFlags, [key]: val } }, () => bextGet(key).then(resolve)) - ) - ) +async function bextSet(key: K, val: FeatureFlags[K]): Promise { + const { featureFlags } = await storage.sync.get('featureFlags') + await storage.sync.set({ featureFlags: { ...featureFlags, [key]: val } }) } const browserExtensionFeatureFlags = createFeatureFlagStorage({ @@ -62,12 +59,13 @@ const browserExtensionFeatureFlags = createFeatureFlagStorage({ }) const inPageFeatureFlags = createFeatureFlagStorage({ - get: key => new Promise(resolve => resolve(!!localStorage.getItem(key))), - set: (key, val) => - new Promise(resolve => { - localStorage.setItem(key, val.toString()) - resolve(val) - }), + get: async key => { + const value = localStorage.getItem(key) + return value === null ? undefined : value === 'true' + }, + set: async (key, val) => { + localStorage.setItem(key, val.toString()) + }, }) export const featureFlags: FeatureFlagsStorage = isInPage ? inPageFeatureFlags : browserExtensionFeatureFlags diff --git a/client/browser/src/types/web-extensions/index.d.ts b/client/browser/src/types/web-extensions/index.d.ts new file mode 100644 index 00000000000..da58f4d5452 --- /dev/null +++ b/client/browser/src/types/web-extensions/index.d.ts @@ -0,0 +1,2028 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// license, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +declare namespace browser { + /** + * An object that allows adding, removing and inspecting listeners. + * Event listeners may return a value. + */ + interface CallbackEventEmitter any> { + addListener(callback: F): void + removeListener(callback: F): void + hasListener(callback: F): boolean + } + + /** + * Simpler version for events with a single parameter that always return void. + */ + type EventEmitter = CallbackEventEmitter<(event: T) => void> +} + +declare namespace browser.alarms { + interface Alarm { + name: string + scheduledTime: number + periodInMinutes?: number + } + + interface When { + when?: number + periodInMinutes?: number + } + interface DelayInMinutes { + delayInMinutes?: number + periodInMinutes?: number + } + function create(name?: string, alarmInfo?: When | DelayInMinutes): void + function get(name?: string): Promise + function getAll(): Promise + function clear(name?: string): Promise + function clearAll(): Promise + + const onAlarm: EventEmitter +} + +declare namespace browser.bookmarks { + type BookmarkTreeNodeUnmodifiable = 'managed' + type BookmarkTreeNodeType = 'bookmark' | 'folder' | 'separator' + interface BookmarkTreeNode { + id: string + parentId?: string + index?: number + url?: string + title: string + dateAdded?: number + dateGroupModified?: number + unmodifiable?: BookmarkTreeNodeUnmodifiable + children?: BookmarkTreeNode[] + type?: BookmarkTreeNodeType + } + + interface CreateDetails { + parentId?: string + index?: number + title?: string + type?: BookmarkTreeNodeType + url?: string + } + + function create(bookmark: CreateDetails): Promise + function get(idOrIdList: string | string[]): Promise + function getChildren(id: string): Promise + function getRecent(numberOfItems: number): Promise + function getSubTree(id: string): Promise<[BookmarkTreeNode]> + function getTree(): Promise<[BookmarkTreeNode]> + + type Destination = + | { + parentId: string + index?: number + } + | { + index: number + parentId?: string + } + function move(id: string, destination: Destination): Promise + function remove(id: string): Promise + function removeTree(id: string): Promise + function search( + query: + | string + | { + query?: string + url?: string + title?: string + } + ): Promise + function update(id: string, changes: { title: string; url: string }): Promise + + const onCreated: CallbackEventEmitter<(id: string, bookmark: BookmarkTreeNode) => void> + const onRemoved: CallbackEventEmitter< + ( + id: string, + removeInfo: { + parentId: string + index: number + node: BookmarkTreeNode + } + ) => void + > + const onChanged: CallbackEventEmitter< + ( + id: string, + changeInfo: { + title: string + url?: string + } + ) => void + > + const onMoved: CallbackEventEmitter< + ( + id: string, + moveInfo: { + parentId: string + index: number + oldParentId: string + oldIndex: number + } + ) => void + > +} + +declare namespace browser.browserAction { + type ColorArray = [number, number, number, number] + type ImageDataType = ImageData + + function setTitle(details: { title: string | null; tabId?: number }): void + function getTitle(details: { tabId?: number }): Promise + + interface IconViaPath { + path: string | { [size: number]: string } + tabId?: number + } + + interface IconViaImageData { + imageData: ImageDataType | { [size: number]: ImageDataType } + tabId?: number + } + + interface IconReset { + imageData?: {} | null + path?: {} | null + tabId?: number + } + + function setIcon(details: IconViaPath | IconViaImageData | IconReset): Promise + function setPopup(details: { popup: string | null; tabId?: number }): void + function getPopup(details: { tabId?: number }): Promise + function openPopup(): Promise + function setBadgeText(details: { text: string | null; tabId?: number }): void + function getBadgeText(details: { tabId?: number }): Promise + function setBadgeBackgroundColor(details: { color: string | ColorArray | null; tabId?: number }): void + function getBadgeBackgroundColor(details: { tabId?: number }): Promise + + interface SetBadgeTextColorDetails { + /** + * The color, specified as one of: + * - a string: any CSS color value, for example "red", "#FF0000", or "rgb(255,0,0)". If the string is not a valid color, the returned promise will be rejected and the text color won't be altered. + * - a `browserAction.ColorArray` object. + * - `null`. If a tabId is specified, it removes the tab-specific badge text color so that the tab inherits the global badge text color. Otherwise it reverts the global badge text color to the default value. + */ + color: string | ColorArray | null + } + function setBadgeTextColor(details: SetBadgeTextColorDetails & { tabId?: number }): void + // tslint:disable-next-line:unified-signatures a union type would allow specifying both, which is not allowed. + function setBadgeTextColor(details: SetBadgeTextColorDetails & { windowId?: number }): void + + function getBadgeTextColor(details: { tabId?: string }): Promise + // tslint:disable-next-line:unified-signatures a union type would allow specifying both, which is not allowed. + function getBadgeTextColor(details: { windowId?: string }): Promise + + function enable(tabId?: number): void + function disable(tabId?: number): void + + const onClicked: EventEmitter +} + +declare namespace browser.browsingData { + interface DataTypeSet { + cache?: boolean + cookies?: boolean + downloads?: boolean + fileSystems?: boolean + formData?: boolean + history?: boolean + indexedDB?: boolean + localStorage?: boolean + passwords?: boolean + pluginData?: boolean + serverBoundCertificates?: boolean + serviceWorkers?: boolean + } + + interface DataRemovalOptions { + since?: number + originTypes?: { unprotectedWeb: boolean } + } + + function remove(removalOptions: DataRemovalOptions, dataTypes: DataTypeSet): Promise + function removeCache(removalOptions?: DataRemovalOptions): Promise + function removeCookies(removalOptions: DataRemovalOptions): Promise + function removeDownloads(removalOptions: DataRemovalOptions): Promise + function removeFormData(removalOptions: DataRemovalOptions): Promise + function removeHistory(removalOptions: DataRemovalOptions): Promise + function removePasswords(removalOptions: DataRemovalOptions): Promise + function removePluginData(removalOptions: DataRemovalOptions): Promise + function settings(): Promise<{ + options: DataRemovalOptions + dataToRemove: DataTypeSet + dataRemovalPermitted: DataTypeSet + }> +} + +declare namespace browser.commands { + interface Command { + name?: string + description?: string + shortcut?: string + } + + function getAll(): Promise + + const onCommand: EventEmitter +} + +declare namespace browser.menus { + type ContextType = + | 'all' + | 'audio' + | 'bookmarks' + | 'browser_action' + | 'editable' + | 'frame' + | 'image' + // | "launcher" unsupported + | 'link' + | 'page' + | 'page_action' + | 'password' + | 'selection' + | 'tab' + | 'tools_menu' + | 'video' + + type ItemType = 'normal' | 'checkbox' | 'radio' | 'separator' + + interface OnClickData { + bookmarkId?: string + checked?: boolean + editable: boolean + frameId?: number + frameUrl?: string + linkText?: string + linkUrl?: string + mediaType?: string + menuItemId: number | string + modifiers: string[] + pageUrl?: string + parentMenuItemId?: number | string + selectionText?: string + srcUrl?: string + targetElementId?: number + wasChecked?: boolean + } + + const ACTION_MENU_TOP_LEVEL_LIMIT: number + + function create( + createProperties: { + checked?: boolean + command?: '_execute_browser_action' | '_execute_page_action' | '_execute_sidebar_action' + contexts?: ContextType[] + documentUrlPatterns?: string[] + enabled?: boolean + icons?: object + id?: string + onclick?: (info: OnClickData, tab: tabs.Tab) => void + parentId?: number | string + targetUrlPatterns?: string[] + title?: string + type?: ItemType + visible?: boolean + }, + callback?: () => void + ): number | string + + function getTargetElement(targetElementId: number): object | null + + function refresh(): Promise + + function remove(menuItemId: number | string): Promise + + function removeAll(): Promise + + function update( + id: number | string, + updateProperties: { + checked?: boolean + command?: '_execute_browser_action' | '_execute_page_action' | '_execute_sidebar_action' + contexts?: ContextType[] + documentUrlPatterns?: string[] + enabled?: boolean + onclick?: (info: OnClickData, tab: tabs.Tab) => void + parentId?: number | string + targetUrlPatterns?: string[] + title?: string + type?: ItemType + visible?: boolean + } + ): Promise + + const onClicked: CallbackEventEmitter<(info: OnClickData, tab: tabs.Tab) => void> + + const onHidden: CallbackEventEmitter<() => void> + + const onShown: CallbackEventEmitter<(info: OnClickData, tab: tabs.Tab) => void> +} + +declare namespace browser.contextualIdentities { + type IdentityColor = 'blue' | 'turquoise' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'purple' + type IdentityIcon = 'fingerprint' | 'briefcase' | 'dollar' | 'cart' | 'circle' + + interface ContextualIdentity { + cookieStoreId: string + color: IdentityColor + icon: IdentityIcon + name: string + } + + function create(details: { name: string; color: IdentityColor; icon: IdentityIcon }): Promise + function get(cookieStoreId: string): Promise + function query(details: { name?: string }): Promise + function update( + cookieStoreId: string, + details: { + name: string + color: IdentityColor + icon: IdentityIcon + } + ): Promise + function remove(cookieStoreId: string): Promise +} + +declare namespace browser.cookies { + interface Cookie { + name: string + value: string + domain: string + hostOnly: boolean + path: string + secure: boolean + httpOnly: boolean + session: boolean + expirationDate?: number + storeId: string + } + + interface CookieStore { + id: string + tabIds: number[] + } + + type OnChangedCause = 'evicted' | 'expired' | 'explicit' | 'expired_overwrite' | 'overwrite' + + function get(details: { url: string; name: string; storeId?: string }): Promise + function getAll(details: { + url?: string + name?: string + domain?: string + path?: string + secure?: boolean + session?: boolean + storeId?: string + }): Promise + function set(details: { + url: string + name?: string + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + expirationDate?: number + storeId?: string + }): Promise + function remove(details: { url: string; name: string; storeId?: string }): Promise + function getAllCookieStores(): Promise + + const onChanged: EventEmitter<{ + removed: boolean + cookie: Cookie + cause: OnChangedCause + }> +} + +declare namespace browser.contentScripts { + interface RegisteredContentScriptOptions { + allFrames?: boolean + css?: ({ file: string } | { code: string })[] + excludeGlobs?: string[] + excludeMatches?: string[] + includeGlobs?: string[] + js?: ({ file: string } | { code: string })[] + matchAboutBlank?: boolean + matches: string[] + runAt?: 'document_start' | 'document_end' | 'document_idle' + } + + interface RegisteredContentScript { + unregister: () => void + } + + function register(contentScriptOptions: RegisteredContentScriptOptions): Promise +} + +declare namespace browser.devtools.inspectedWindow { + const tabId: number + + function eval( + expression: string + ): Promise<[any, { isException: boolean; value: string } | { isError: boolean; code: string }]> + + function reload(reloadOptions?: { ignoreCache?: boolean; userAgent?: string; injectedScript?: string }): void +} + +declare namespace browser.devtools.network { + const onNavigated: EventEmitter +} + +declare namespace browser.devtools.panels { + interface ExtensionPanel { + onShown: EventEmitter + onHidden: EventEmitter + } + + function create(title: string, iconPath: string, pagePath: string): Promise +} + +declare namespace browser.downloads { + type FilenameConflictAction = 'uniquify' | 'overwrite' | 'prompt' + + type InterruptReason = + | 'FILE_FAILED' + | 'FILE_ACCESS_DENIED' + | 'FILE_NO_SPACE' + | 'FILE_NAME_TOO_LONG' + | 'FILE_TOO_LARGE' + | 'FILE_VIRUS_INFECTED' + | 'FILE_TRANSIENT_ERROR' + | 'FILE_BLOCKED' + | 'FILE_SECURITY_CHECK_FAILED' + | 'FILE_TOO_SHORT' + | 'NETWORK_FAILED' + | 'NETWORK_TIMEOUT' + | 'NETWORK_DISCONNECTED' + | 'NETWORK_SERVER_DOWN' + | 'NETWORK_INVALID_REQUEST' + | 'SERVER_FAILED' + | 'SERVER_NO_RANGE' + | 'SERVER_BAD_CONTENT' + | 'SERVER_UNAUTHORIZED' + | 'SERVER_CERT_PROBLEM' + | 'SERVER_FORBIDDEN' + | 'USER_CANCELED' + | 'USER_SHUTDOWN' + | 'CRASH' + + type DangerType = 'file' | 'url' | 'content' | 'uncommon' | 'host' | 'unwanted' | 'safe' | 'accepted' + + type State = 'in_progress' | 'interrupted' | 'complete' + + interface DownloadItem { + id: number + url: string + referrer: string + filename: string + incognito: boolean + danger: string + mime: string + startTime: string + endTime?: string + estimatedEndTime?: string + state: string + paused: boolean + canResume: boolean + error?: string + bytesReceived: number + totalBytes: number + fileSize: number + exists: boolean + byExtensionId?: string + byExtensionName?: string + } + + interface Delta { + current?: T + previous?: T + } + + type StringDelta = Delta + type DoubleDelta = Delta + type BooleanDelta = Delta + type DownloadTime = Date | string | number + + interface DownloadQuery { + query?: string[] + startedBefore?: DownloadTime + startedAfter?: DownloadTime + endedBefore?: DownloadTime + endedAfter?: DownloadTime + totalBytesGreater?: number + totalBytesLess?: number + filenameRegex?: string + urlRegex?: string + limit?: number + orderBy?: string + id?: number + url?: string + filename?: string + danger?: DangerType + mime?: string + startTime?: string + endTime?: string + state?: State + paused?: boolean + error?: InterruptReason + bytesReceived?: number + totalBytes?: number + fileSize?: number + exists?: boolean + } + + function download(options: { + url: string + filename?: string + conflictAction?: string + saveAs?: boolean + method?: string + headers?: { [key: string]: string } + body?: string + }): Promise + function search(query: DownloadQuery): Promise + function pause(downloadId: number): Promise + function resume(downloadId: number): Promise + function cancel(downloadId: number): Promise + // unsupported: function getFileIcon(downloadId: number, options?: { size?: number }): + // Promise; + function open(downloadId: number): Promise + function show(downloadId: number): Promise + function showDefaultFolder(): void + function erase(query: DownloadQuery): Promise + function removeFile(downloadId: number): Promise + // unsupported: function acceptDanger(downloadId: number): Promise; + // unsupported: function drag(downloadId: number): Promise; + // unsupported: function setShelfEnabled(enabled: boolean): void; + + const onCreated: EventEmitter + const onErased: EventEmitter + const onChanged: EventEmitter<{ + id: number + url?: StringDelta + filename?: StringDelta + danger?: StringDelta + mime?: StringDelta + startTime?: StringDelta + endTime?: StringDelta + state?: StringDelta + canResume?: BooleanDelta + paused?: BooleanDelta + error?: StringDelta + totalBytes?: DoubleDelta + fileSize?: DoubleDelta + exists?: BooleanDelta + }> +} + +declare namespace browser.events { + interface UrlFilter { + hostContains?: string + hostEquals?: string + hostPrefix?: string + hostSuffix?: string + pathContains?: string + pathEquals?: string + pathPrefix?: string + pathSuffix?: string + queryContains?: string + queryEquals?: string + queryPrefix?: string + querySuffix?: string + urlContains?: string + urlEquals?: string + urlMatches?: string + originAndPathMatches?: string + urlPrefix?: string + urlSuffix?: string + schemes?: string[] + ports?: (number | number[])[] + } +} + +declare namespace browser.extension { + type ViewType = 'tab' | 'notification' | 'popup' + + const lastError: string | null + const inIncognitoContext: boolean + + function getURL(path: string): string + function getViews(fetchProperties?: { type?: ViewType; windowId?: number }): Window[] + function getBackgroundPage(): Window + function isAllowedIncognitoAccess(): Promise + function isAllowedFileSchemeAccess(): Promise + // unsupported: events as they are deprecated +} + +declare namespace browser.extensionTypes { + type ImageFormat = 'jpeg' | 'png' + interface ImageDetails { + format: ImageFormat + quality: number + } + type RunAt = 'document_start' | 'document_end' | 'document_idle' + interface InjectDetails { + allFrames?: boolean + code?: string + file?: string + frameId?: number + matchAboutBlank?: boolean + runAt?: RunAt + } + type InjectDetailsCSS = InjectDetails & { cssOrigin?: 'user' | 'author' } +} + +declare namespace browser.history { + type TransitionType = + | 'link' + | 'typed' + | 'auto_bookmark' + | 'auto_subframe' + | 'manual_subframe' + | 'generated' + | 'auto_toplevel' + | 'form_submit' + | 'reload' + | 'keyword' + | 'keyword_generated' + + interface HistoryItem { + id: string + url?: string + title?: string + lastVisitTime?: number + visitCount?: number + typedCount?: number + } + + interface VisitItem { + id: string + visitId: string + VisitTime?: number + refferingVisitId: string + transition: TransitionType + } + + function search(query: { + text: string + startTime?: number | string | Date + endTime?: number | string | Date + maxResults?: number + }): Promise + + function getVisits(details: { url: string }): Promise + + function addUrl(details: { + url: string + title?: string + transition?: TransitionType + visitTime?: number | string | Date + }): Promise + + function deleteUrl(details: { url: string }): Promise + + function deleteRange(range: { startTime: number | string | Date; endTime: number | string | Date }): Promise + + function deleteAll(): Promise + + const onVisited: EventEmitter + + // TODO: Ensure that urls is not `urls: [string]` instead + const onVisitRemoved: EventEmitter<{ allHistory: boolean; urls: string[] }> +} + +declare namespace browser.i18n { + type LanguageCode = string + + function getAcceptLanguages(): Promise + + function getMessage(messageName: string, substitutions?: string | string[]): string + + function getUILanguage(): LanguageCode + + function detectLanguage( + text: string + ): Promise<{ + isReliable: boolean + languages: { language: LanguageCode; percentage: number }[] + }> +} + +declare namespace browser.identity { + function getRedirectURL(): string + function launchWebAuthFlow(details: { url: string; interactive: boolean }): Promise +} + +declare namespace browser.idle { + type IdleState = 'active' | 'idle' /* unsupported: | "locked" */ + + function queryState(detectionIntervalInSeconds: number): Promise + function setDetectionInterval(intervalInSeconds: number): void + + const onStateChanged: EventEmitter +} + +declare namespace browser.management { + interface ExtensionInfo { + description: string + // unsupported: disabledReason: string, + enabled: boolean + homepageUrl: string + hostPermissions: string[] + icons: { size: number; url: string }[] + id: string + installType: 'admin' | 'development' | 'normal' | 'sideload' | 'other' + mayDisable: boolean + name: string + // unsupported: offlineEnabled: boolean, + optionsUrl: string + permissions: string[] + shortName: string + // unsupported: type: string, + updateUrl: string + version: string + // unsupported: versionName: string, + } + + function getSelf(): Promise + function uninstallSelf(options: { showConfirmDialog: boolean; dialogMessage: string }): Promise +} + +declare namespace browser.notifications { + type TemplateType = 'basic' /* | "image" | "list" | "progress" */ + + interface NotificationOptions { + type: TemplateType + message: string + title: string + iconUrl?: string + } + + function create(id: string | null, options: NotificationOptions): Promise + function create(options: NotificationOptions): Promise + + function clear(id: string): Promise + + function getAll(): Promise<{ [key: string]: NotificationOptions }> + + const onClosed: EventEmitter + + const onClicked: EventEmitter +} + +declare namespace browser.omnibox { + type OnInputEnteredDisposition = 'currentTab' | 'newForegroundTab' | 'newBackgroundTab' + interface SuggestResult { + content: string + description: string + } + + function setDefaultSuggestion(suggestion: { description: string }): void + + const onInputStarted: EventEmitter + const onInputChanged: CallbackEventEmitter<(text: string, suggest: (arg: SuggestResult[]) => void) => void> + const onInputEntered: CallbackEventEmitter<(text: string, disposition: OnInputEnteredDisposition) => void> + const onInputCancelled: EventEmitter +} + +declare namespace browser.pageAction { + type ImageDataType = ImageData + + function show(tabId: number): void + + function hide(tabId: number): void + + function setTitle(details: { tabId: number; title: string }): void + + function getTitle(details: { tabId: number }): Promise + + function setIcon(details: { tabId: number; path?: string | object; imageData?: ImageDataType }): Promise + + function setPopup(details: { tabId: number; popup: string }): void + + function getPopup(details: { tabId: number }): Promise + + const onClicked: EventEmitter +} + +declare namespace browser.permissions { + type Permission = + | 'activeTab' + | 'alarms' + | 'background' + | 'bookmarks' + | 'browsingData' + | 'browserSettings' + | 'clipboardRead' + | 'clipboardWrite' + | 'contextMenus' + | 'contextualIdentities' + | 'cookies' + | 'downloads' + | 'downloads.open' + | 'find' + | 'geolocation' + | 'history' + | 'identity' + | 'idle' + | 'management' + | 'menus' + | 'nativeMessaging' + | 'notifications' + | 'pkcs11' + | 'privacy' + | 'proxy' + | 'sessions' + | 'storage' + | 'tabs' + | 'theme' + | 'topSites' + | 'unlimitedStorage' + | 'webNavigation' + | 'webRequest' + | 'webRequestBlocking' + + interface Permissions { + origins?: string[] + permissions?: Permission[] + } + + function contains(permissions: Permissions): Promise + + function getAll(): Promise + + function remove(permissions: Permissions): Promise + + function request(permissions: Permissions): Promise + + /** + * Not supported yet in Firefox: + * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded#Browser_compatibility + */ + const onAdded: EventEmitter | undefined + + /** + * Not supported yet in Firefox: + * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded#Browser_compatibility + */ + const onRemoved: EventEmitter | undefined +} + +declare namespace browser.runtime { + const lastError: string | null + const id: string + + interface Port { + /** + * The port's name, defined in the runtime.connect() or tabs.connect() call that created it. + * If this port is connected to a native application, its name is the name of the native application. + */ + name: string + disconnect(): void + error: Error | null + onDisconnect: EventEmitter + onMessage: EventEmitter + postMessage(message: any): void + } + interface PortWithSender extends Port { + /** + * Contains information about the sender of the message. + * This property will only be present on ports passed to onConnect/onConnectExternal listeners. + */ + sender: MessageSender + } + + interface MessageSender { + tab?: tabs.Tab + frameId?: number + id?: string + url?: string + tlsChannelId?: string + } + + type PlatformOs = 'mac' | 'win' | 'android' | 'cros' | 'linux' | 'openbsd' + type PlatformArch = 'arm' | 'x86-32' | 'x86-64' + type PlatformNaclArch = 'arm' | 'x86-32' | 'x86-64' + + interface PlatformInfo { + os: PlatformOs + arch: PlatformArch + } + + // type RequestUpdateCheckStatus = "throttled" | "no_update" | "update_available"; + type OnInstalledReason = 'install' | 'update' | 'chrome_update' | 'shared_module_update' + type OnRestartRequiredReason = 'app_update' | 'os_update' | 'periodic' + + interface FirefoxSpecificProperties { + id?: string + strict_min_version?: string + strict_max_version?: string + update_url?: string + } + + type IconPath = { [urlName: string]: string } | string + + interface Manifest { + // Required + manifest_version: 2 + name: string + version: string + /** Required in Microsoft Edge */ + author?: string + + // Optional + + // ManifestBase + description?: string + homepage_url?: string + short_name?: string + + // WebExtensionManifest + background?: { + page: string + script: string[] + persistent?: boolean + } + content_scripts?: { + matches: string[] + exclude_matches?: string[] + include_globs?: string[] + exclude_globs?: string[] + css?: string[] + js?: string[] + all_frames?: boolean + match_about_blank?: boolean + run_at?: 'document_start' | 'document_end' | 'document_idle' + }[] + content_security_policy?: string + developer?: { + name?: string + url?: string + } + icons?: { + [imgSize: string]: string + } + incognito?: 'spanning' | 'split' | 'not_allowed' + optional_permissions?: permissions.Permission[] + options_ui?: { + page: string + browser_style?: boolean + chrome_style?: boolean + open_in_tab?: boolean + } + permissions?: permissions.Permission[] + web_accessible_resources?: string[] + + // WebExtensionLangpackManifest + languages: { + [langCode: string]: { + chrome_resources: { + [resName: string]: string | { [urlName: string]: string } + } + version: string + } + } + langpack_id?: string + sources?: { + [srcName: string]: { + base_path: string + paths?: string[] + } + } + + // Extracted from components + browser_action?: { + default_title?: string + default_icon?: IconPath + theme_icons?: { + light: string + dark: string + size: number + }[] + default_popup?: string + browser_style?: boolean + default_area?: 'navbar' | 'menupanel' | 'tabstrip' | 'personaltoolbar' + } + commands?: { + [keyName: string]: { + suggested_key?: { + default?: string + mac?: string + linux?: string + windows?: string + chromeos?: string + android?: string + ios?: string + } + description?: string + } + } + default_locale?: i18n.LanguageCode + devtools_page?: string + omnibox?: { + keyword: string + } + page_action?: { + default_title?: string + default_icon?: IconPath + default_popup?: string + browser_style?: boolean + show_matches?: string[] + hide_matches?: string[] + } + sidebar_action?: { + default_panel: string + default_title?: string + default_icon?: IconPath + browser_style?: boolean + } + + // Firefox specific + applications?: { + gecko?: FirefoxSpecificProperties + } + browser_specific_settings?: { + gecko?: FirefoxSpecificProperties + } + experiment_apis?: any + protocol_handlers?: { + name: string + protocol: string + uriTemplate: string + } + + // Opera specific + minimum_opera_version?: string + + // Chrome specific + action?: any + automation?: any + background_page?: any + chrome_settings_overrides?: { + homepage?: string + search_provider?: { + name: string + search_url: string + keyword?: string + favicon_url?: string + suggest_url?: string + instant_url?: string + is_default?: string + image_url?: string + search_url_post_params?: string + instant_url_post_params?: string + image_url_post_params?: string + alternate_urls?: string[] + prepopulated_id?: number + } + } + chrome_ui_overrides?: { + bookmarks_ui?: { + remove_bookmark_shortcut?: true + remove_button?: true + } + } + chrome_url_overrides?: { + newtab?: string + bookmarks?: string + history?: string + } + content_capabilities?: any + converted_from_user_script?: any + current_locale?: any + declarative_net_request?: any + event_rules?: any[] + export?: { + whitelist?: string[] + } + externally_connectable?: { + ids?: string[] + matches?: string[] + accepts_tls_channel_id?: boolean + } + file_browser_handlers?: { + id: string + default_title: string + file_filters: string[] + }[] + file_system_provider_capabilities?: { + source: 'file' | 'device' | 'network' + configurable?: boolean + multiple_mounts?: boolean + watchable?: boolean + } + import?: { + id: string + minimum_version?: string + }[] + input_components?: any + key?: string + minimum_chrome_version?: string + nacl_modules?: { + path: string + mime_type: string + }[] + oauth2?: any + offline_enabled?: boolean + options_page?: string + platforms?: any + requirements?: any + sandbox?: { + pages: string[] + content_security_policy?: string + }[] + signature?: any + spellcheck?: any + storage?: { + managed_schema: string + } + system_indicator?: any + tts_engine?: { + voice: { + voice_name: string + lang?: string + gender?: 'male' | 'female' + event_types: ('start' | 'word' | 'sentence' | 'marker' | 'end' | 'error')[] + }[] + } + update_url?: string + version_name?: string + } + + /** + * Only defined in the background page + */ + const getBackgroundPage: (() => Promise) | undefined + + function openOptionsPage(): Promise + function getManifest(): Manifest + + function getURL(path: string): string + function setUninstallURL(url: string): Promise + function reload(): void + // Will not exist: https://bugzilla.mozilla.org/show_bug.cgi?id=1314922 + // function RequestUpdateCheck(): Promise; + + interface ConnectInfo { + name?: string + includeTlsChannelId?: boolean + } + /** + * @param connectInfo Details of the connection + */ + function connect(connectInfo?: ConnectInfo): Port + /** + * @param extensionId The ID of the extension to connect to. If the target has set an ID explicitly using the applications key in manifest.json, then extensionId should have that value. Otherwise it should have the ID that was generated for the target. + * @param connectInfo Details of the connection + */ + function connect(extensionId?: string, connectInfo?: ConnectInfo): Port + + function connectNative(application: string): Port + + function sendMessage( + message: any, + options?: { includeTlsChannelId?: boolean; toProxyScript?: boolean } + ): Promise + function sendMessage( + extensionId: string, + message: any, + options?: { includeTlsChannelId?: boolean; toProxyScript?: boolean } + ): Promise + + function sendNativeMessage(application: string, message: object): Promise + function getPlatformInfo(): Promise + function getBrowserInfo(): Promise<{ + name: string + vendor: string + version: string + buildID: string + }> + // Unsupported: https://bugzilla.mozilla.org/show_bug.cgi?id=1339407 + // function getPackageDirectoryEntry(): Promise; + + const onStartup: EventEmitter + const onInstalled: EventEmitter<{ + reason: OnInstalledReason + previousVersion?: string + id?: string + }> + // Unsupported + // const onSuspend: Listener; + // const onSuspendCanceled: Listener; + // const onBrowserUpdateAvailable: Listener; + // const onRestartRequired: Listener; + const onUpdateAvailable: EventEmitter<{ version: string }> + const onConnect: EventEmitter + + const onConnectExternal: EventEmitter + + type OnMessageHandler = (message: any, sender: MessageSender) => void | Promise + + const onMessage: CallbackEventEmitter + + const onMessageExternal: CallbackEventEmitter +} + +declare namespace browser.sessions { + interface Filter { + maxResults?: number + } + + interface Session { + lastModified: number + tab: tabs.Tab + window: windows.Window + } + + const MAX_SESSION_RESULTS: number + + function getRecentlyClosed(filter?: Filter): Promise + + function restore(sessionId: string): Promise + + function setTabValue(tabId: number, key: string, value: string | object): Promise + + function getTabValue(tabId: number, key: string): Promise + + function removeTabValue(tabId: number, key: string): Promise + + function setWindowValue(windowId: number, key: string, value: string | object): Promise + + function getWindowValue(windowId: number, key: string): Promise + + function removeWindowValue(windowId: number, key: string): Promise + + const onChanged: CallbackEventEmitter<() => void> +} + +declare namespace browser.sidebarAction { + type ImageDataType = ImageData + + function setPanel(details: { panel: string; tabId?: number }): void + + function getPanel(details: { tabId?: number }): Promise + + function setTitle(details: { title: string; tabId?: number }): void + + function getTitle(details: { tabId?: number }): Promise + + interface IconViaPath { + path: string | { [index: number]: string } + tabId?: number + } + + interface IconViaImageData { + imageData: ImageDataType | { [index: number]: ImageDataType } + tabId?: number + } + + function setIcon(details: IconViaPath | IconViaImageData): Promise + + function open(): Promise + + function close(): Promise +} + +declare namespace browser.storage { + /** + * Example for type-safe usage: + * + * ```ts + * interface MyStorageItems { + * foo: number + * } + * + * (browser.storage.sync as browser.storage.StorageArea).get('foo') + * ``` + */ + interface StorageArea { + get(): Promise> + get(keys: K[] | K): Promise<{ [k in K]?: T[k] }> + + /** + * Stores one or more items in the storage area, or update existing items. + * + * When you store or update a value using this API, the storage.onChanged event will fire. + * + * @param items An object containing one or more key/value pairs to be stored in storage. If an item already exists, its value will be updated. + * Values may be primitive types (such as numbers, booleans, and strings) or Array types. + * It's generally not possible to store other types, such as Function, Date, RegExp, Set, Map, ArrayBuffer and so on. Some of these unsupported types will restore as an empty object, and some cause set() to throw an error. The exact behavior here is browser-specific. + * If a value is `undefined`, it will not be changed. + * If a value is `null`, it will be set to `null`. + */ + set(items: Partial): Promise + remove(keys: keyof T | (keyof T)[]): Promise + clear(): Promise + + // unsupported: getBytesInUse: (keys: string|string[]|null) => Promise, + } + + interface StorageChange { + oldValue?: T + newValue?: T + } + + const sync: StorageArea + const local: StorageArea + const managed: StorageArea + + type ChangeDict = { [K in keyof T]?: StorageChange } + type AreaName = 'sync' | 'local' | 'managed' + + const onChanged: CallbackEventEmitter<(changes: ChangeDict, areaName: AreaName) => void> +} + +declare namespace browser.tabs { + type MutedInfoReason = 'capture' | 'extension' | 'user' + interface MutedInfo { + muted: boolean + extensionId?: string + reason: MutedInfoReason + } + // TODO: Specify PageSettings properly. + type PageSettings = object + interface Tab { + active: boolean + audible?: boolean + autoDiscardable?: boolean + cookieStoreId?: string + discarded?: boolean + favIconUrl?: string + height?: number + hidden: boolean + highlighted: boolean + id?: number + incognito: boolean + index: number + isArticle: boolean + isInReaderMode: boolean + lastAccessed: number + mutedInfo?: MutedInfo + openerTabId?: number + pinned: boolean + selected: boolean + sessionId?: string + status?: string + title?: string + url?: string + width?: number + windowId: number + } + + type TabStatus = 'loading' | 'complete' + type WindowType = 'normal' | 'popup' | 'panel' | 'devtools' + type ZoomSettingsMode = 'automatic' | 'disabled' | 'manual' + type ZoomSettingsScope = 'per-origin' | 'per-tab' + interface ZoomSettings { + defaultZoomFactor?: number + mode?: ZoomSettingsMode + scope?: ZoomSettingsScope + } + + const TAB_ID_NONE: number + + function connect(tabId: number, connectInfo?: { name?: string; frameId?: number }): runtime.Port + function create(createProperties: { + active?: boolean + cookieStoreId?: string + index?: number + openerTabId?: number + pinned?: boolean + // deprecated: selected: boolean, + url?: string + windowId?: number + }): Promise + function captureTab(tabId?: number, options?: extensionTypes.ImageDetails): Promise + function captureVisibleTab(windowId?: number, options?: extensionTypes.ImageDetails): Promise + function detectLanguage(tabId?: number): Promise + function duplicate(tabId: number): Promise + function executeScript(tabId: number | undefined, details: extensionTypes.InjectDetails): Promise + function get(tabId: number): Promise + // deprecated: function getAllInWindow(): x; + function getCurrent(): Promise + // deprecated: function getSelected(windowId?: number): Promise; + function getZoom(tabId?: number): Promise + function getZoomSettings(tabId?: number): Promise + function hide(tabIds: number | number[]): Promise + // unsupported: function highlight(highlightInfo: { + // windowId?: number, + // tabs: number[]|number, + // }): Promise; + function insertCSS(tabId: number | undefined, details: extensionTypes.InjectDetailsCSS): Promise + function removeCSS(tabId: number | undefined, details: extensionTypes.InjectDetails): Promise + function move( + tabIds: number | number[], + moveProperties: { + windowId?: number + index: number + } + ): Promise + function print(): Promise + function printPreview(): Promise + function query(queryInfo: { + active?: boolean + audible?: boolean + // unsupported: autoDiscardable?: boolean, + cookieStoreId?: string + currentWindow?: boolean + discarded?: boolean + hidden?: boolean + highlighted?: boolean + index?: number + muted?: boolean + lastFocusedWindow?: boolean + pinned?: boolean + status?: TabStatus + title?: string + url?: string | string[] + windowId?: number + windowType?: WindowType + }): Promise + function reload(tabId?: number, reloadProperties?: { bypassCache?: boolean }): Promise + function remove(tabIds: number | number[]): Promise + function saveAsPDF( + pageSettings: PageSettings + ): Promise<'saved' | 'replaced' | 'canceled' | 'not_saved' | 'not_replaced'> + function sendMessage( + tabId: number, + message: T, + options?: { frameId?: number } + ): Promise + // deprecated: function sendRequest(): x; + function setZoom(tabId: number | undefined, zoomFactor: number): Promise + function setZoomSettings(tabId: number | undefined, zoomSettings: ZoomSettings): Promise + function show(tabIds: number | number[]): Promise + function toggleReaderMode(tabId?: number): Promise + + interface UpdateProperties { + active?: boolean + // unsupported: autoDiscardable?: boolean, + // unsupported: highlighted?: boolean, + // unsupported: hidden?: boolean; + loadReplace?: boolean + muted?: boolean + openerTabId?: number + pinned?: boolean + // deprecated: selected?: boolean, + url?: string + } + function update(tabId: number | undefined, updateProperties: UpdateProperties): Promise + function update(updateProperties: UpdateProperties): Promise + + const onActivated: EventEmitter<{ tabId: number; windowId: number }> + const onAttached: CallbackEventEmitter< + ( + tabId: number, + attachInfo: { + newWindowId: number + newPosition: number + } + ) => void + > + const onCreated: EventEmitter + const onDetached: CallbackEventEmitter< + ( + tabId: number, + detachInfo: { + oldWindowId: number + oldPosition: number + } + ) => void + > + const onHighlighted: EventEmitter<{ windowId: number; tabIds: number[] }> + const onMoved: CallbackEventEmitter< + ( + tabId: number, + moveInfo: { + windowId: number + fromIndex: number + toIndex: number + } + ) => void + > + const onRemoved: CallbackEventEmitter< + ( + tabId: number, + removeInfo: { + windowId: number + isWindowClosing: boolean + } + ) => void + > + const onReplaced: CallbackEventEmitter<(addedTabId: number, removedTabId: number) => void> + const onUpdated: CallbackEventEmitter< + ( + tabId: number, + changeInfo: { + audible?: boolean + discarded?: boolean + favIconUrl?: string + mutedInfo?: MutedInfo + pinned?: boolean + status?: string + title?: string + url?: string + }, + tab: Tab + ) => void + > + const onZoomChanged: EventEmitter<{ + tabId: number + oldZoomFactor: number + newZoomFactor: number + zoomSettings: ZoomSettings + }> +} + +declare namespace browser.topSites { + interface MostVisitedURL { + title: string + url: string + } + function get(): Promise +} + +declare namespace browser.webNavigation { + type TransitionType = 'link' | 'auto_subframe' | 'form_submit' | 'reload' + // unsupported: | "typed" | "auto_bookmark" | "manual_subframe" + // | "generated" | "start_page" | "keyword" + // | "keyword_generated"; + + type TransitionQualifier = 'client_redirect' | 'server_redirect' | 'forward_back' + // unsupported: "from_address_bar"; + + function getFrame(details: { + tabId: number + processId: number + frameId: number + }): Promise<{ errorOccured: boolean; url: string; parentFrameId: number }> + + function getAllFrames(details: { + tabId: number + }): Promise< + { + errorOccured: boolean + processId: number + frameId: number + parentFrameId: number + url: string + }[] + > + + interface NavListener { + addListener: ( + callback: (arg: T) => void, + filter?: { + url: events.UrlFilter[] + } + ) => void + removeListener: (callback: (arg: T) => void) => void + hasListener: (callback: (arg: T) => void) => boolean + } + + type DefaultNavListener = NavListener<{ + tabId: number + url: string + processId: number + frameId: number + timeStamp: number + }> + + type TransitionNavListener = NavListener<{ + tabId: number + url: string + processId: number + frameId: number + timeStamp: number + transitionType: TransitionType + transitionQualifiers: TransitionQualifier[] + }> + + const onBeforeNavigate: NavListener<{ + tabId: number + url: string + processId: number + frameId: number + parentFrameId: number + timeStamp: number + }> + + const onCommitted: TransitionNavListener + + const onCreatedNavigationTarget: NavListener<{ + sourceFrameId: number + // Unsupported: sourceProcessId: number, + sourceTabId: number + tabId: number + timeStamp: number + url: string + windowId: number + }> + + const onDOMContentLoaded: DefaultNavListener + + const onCompleted: DefaultNavListener + + const onErrorOccurred: DefaultNavListener // error field unsupported + + const onReferenceFragmentUpdated: TransitionNavListener + + const onHistoryStateUpdated: TransitionNavListener +} + +declare namespace browser.webRequest { + type ResourceType = + | 'main_frame' + | 'sub_frame' + | 'stylesheet' + | 'script' + | 'image' + | 'object' + | 'xmlhttprequest' + | 'xbl' + | 'xslt' + | 'ping' + | 'beacon' + | 'xml_dtd' + | 'font' + | 'media' + | 'websocket' + | 'csp_report' + | 'imageset' + | 'web_manifest' + | 'other' + + interface RequestFilter { + urls: string[] + types?: ResourceType[] + tabId?: number + windowId?: number + } + + interface StreamFilter { + onstart: (event: any) => void + ondata: (event: { data: ArrayBuffer }) => void + onstop: (event: any) => void + onerror: (event: any) => void + + close(): void + disconnect(): void + resume(): void + suspend(): void + write(data: Uint8Array | ArrayBuffer): void + + error: string + status: + | 'uninitialized' + | 'transferringdata' + | 'finishedtransferringdata' + | 'suspended' + | 'closed' + | 'disconnected' + | 'failed' + } + + type HttpHeaders = ( + | { name: string; binaryValue: number[]; value?: string } + | { name: string; value: string; binaryValue?: number[] })[] + + interface BlockingResponse { + cancel?: boolean + redirectUrl?: string + requestHeaders?: HttpHeaders + responseHeaders?: HttpHeaders + authCredentials?: { username: string; password: string } + } + + interface UploadData { + bytes?: ArrayBuffer + file?: string + } + + const MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES: number + + function handlerBehaviorChanged(): Promise + + // TODO: Enforce the return result of the addListener call in the contract + // Use an intersection type for all the default properties + interface ReqListener { + addListener: ( + callback: (arg: T) => void, + filter: RequestFilter, + extraInfoSpec?: U[] + ) => BlockingResponse | Promise + removeListener: (callback: (arg: T) => void) => void + hasListener: (callback: (arg: T) => void) => boolean + } + + const onBeforeRequest: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + requestBody?: { + error?: string + formData?: { [key: string]: string[] } + raw?: UploadData[] + } + tabId: number + type: ResourceType + timeStamp: number + originUrl: string + }, + 'blocking' | 'requestBody' + > + + const onBeforeSendHeaders: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + tabId: number + type: ResourceType + timeStamp: number + originUrl: string + requestHeaders?: HttpHeaders + }, + 'blocking' | 'requestHeaders' + > + + const onSendHeaders: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + tabId: number + type: ResourceType + timeStamp: number + originUrl: string + requestHeaders?: HttpHeaders + }, + 'requestHeaders' + > + + const onHeadersReceived: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + tabId: number + type: ResourceType + timeStamp: number + originUrl: string + statusLine: string + responseHeaders?: HttpHeaders + statusCode: number + }, + 'blocking' | 'responseHeaders' + > + + const onAuthRequired: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + tabId: number + type: ResourceType + timeStamp: number + scheme: string + realm?: string + challenger: { host: string; port: number } + isProxy: boolean + responseHeaders?: HttpHeaders + statusLine: string + statusCode: number + }, + 'blocking' | 'responseHeaders' + > + + const onResponseStarted: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + tabId: number + type: ResourceType + timeStamp: number + originUrl: string + ip?: string + fromCache: boolean + statusLine: string + responseHeaders?: HttpHeaders + statusCode: number + }, + 'responseHeaders' + > + + const onBeforeRedirect: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + tabId: number + type: ResourceType + timeStamp: number + originUrl: string + ip?: string + fromCache: boolean + statusCode: number + redirectUrl: string + statusLine: string + responseHeaders?: HttpHeaders + }, + 'responseHeaders' + > + + const onCompleted: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + tabId: number + type: ResourceType + timeStamp: number + originUrl: string + ip?: string + fromCache: boolean + statusCode: number + statusLine: string + responseHeaders?: HttpHeaders + }, + 'responseHeaders' + > + + const onErrorOccurred: ReqListener< + { + requestId: string + url: string + method: string + frameId: number + parentFrameId: number + tabId: number + type: ResourceType + timeStamp: number + originUrl: string + ip?: string + fromCache: boolean + error: string + }, + void + > + + function filterResponseData(requestId: string): StreamFilter +} + +declare namespace browser.windows { + type WindowType = 'normal' | 'popup' | 'panel' | 'devtools' + + type WindowState = 'normal' | 'minimized' | 'maximized' | 'fullscreen' | 'docked' + + interface Window { + id?: number + focused: boolean + top?: number + left?: number + width?: number + height?: number + tabs?: tabs.Tab[] + incognito: boolean + type?: WindowType + state?: WindowState + alwaysOnTop: boolean + sessionId?: string + } + + type CreateType = 'normal' | 'popup' | 'panel' | 'detached_panel' + + const WINDOW_ID_NONE: number + + const WINDOW_ID_CURRENT: number + + function get( + windowId: number, + getInfo?: { + populate?: boolean + windowTypes?: WindowType[] + } + ): Promise + + function getCurrent(getInfo?: { populate?: boolean; windowTypes?: WindowType[] }): Promise + + function getLastFocused(getInfo?: { populate?: boolean; windowTypes?: WindowType[] }): Promise + + function getAll(getInfo?: { populate?: boolean; windowTypes?: WindowType[] }): Promise + + // TODO: url and tabId should be exclusive + function create(createData?: { + allowScriptsToClose?: boolean + url?: string | string[] + tabId?: number + left?: number + top?: number + width?: number + height?: number + // unsupported: focused?: boolean, + incognito?: boolean + titlePreface?: string + type?: CreateType + state?: WindowState + }): Promise + + function update( + windowId: number, + updateInfo: { + left?: number + top?: number + width?: number + height?: number + focused?: boolean + drawAttention?: boolean + state?: WindowState + } + ): Promise + + function remove(windowId: number): Promise + + const onCreated: EventEmitter + + const onRemoved: EventEmitter + + const onFocusChanged: EventEmitter +} + +declare namespace browser.theme { + interface Theme { + images: ThemeImages + colors: ThemeColors + properties?: ThemeProperties + } + + interface ThemeImages { + headerURL: string + theme_frame?: string + additional_backgrounds?: string[] + } + + interface ThemeColors { + accentcolor: string + textcolor: string + frame?: [number, number, number] + tab_text?: [number, number, number] + toolbar?: string + toolbar_text?: string + toolbar_field?: string + toolbar_field_text?: string + } + + interface ThemeProperties { + additional_backgrounds_alignment: Alignment[] + additional_backgrounds_tiling: Tiling[] + } + + type Alignment = + | 'bottom' + | 'center' + | 'left' + | 'right' + | 'top' + | 'center bottom' + | 'center center' + | 'center top' + | 'left bottom' + | 'left center' + | 'left top' + | 'right bottom' + | 'right center' + | 'right top' + + type Tiling = 'no-repeat' | 'repeat' | 'repeat-x' | 'repeat-y' + + function getCurrent(windowId?: number): Promise + function update(theme: Theme): Promise + function update(windowId: number, theme: Theme): Promise + function reset(windowId?: number): Promise +} diff --git a/client/browser/tsconfig.json b/client/browser/tsconfig.json index 381e8240237..c9136d97619 100644 --- a/client/browser/tsconfig.json +++ b/client/browser/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "baseUrl": ".", "typeRoots": [ "src/types", "../../shared/src/types", diff --git a/jest.config.base.js b/jest.config.base.js index 18bbe2a6a6f..bd0da8e71f5 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -39,7 +39,7 @@ const config = { }, }, - setupFiles: [path.join(__dirname, 'shared/dev/mockDate.js')], + setupFiles: [path.join(__dirname, 'shared/dev/mockDate.js'), path.join(__dirname, 'shared/dev/globalThis.js')], } module.exports = config diff --git a/package.json b/package.json index 32e9e881ca0..0dfa88fa6e0 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,18 @@ "last 1 Chrome versions", "not IE > 0" ], + "jscpd": { + "gitignore": true, + "ignore": [ + "**/__snapshots__", + "**/__fixtures__", + "**/*.svg", + "migrations", + "client/browser/build", + "ui", + "**/assets" + ] + }, "devDependencies": { "@babel/core": "^7.2.2", "@babel/plugin-syntax-dynamic-import": "^7.2.0", @@ -174,7 +186,6 @@ "typedoc": "^0.14.2", "typescript": "3.5.0-dev.20190406", "utc-version": "^1.1.0", - "web-ext-types": "^3.1.0", "webpack": "^4.30.0", "webpack-dev-server": "^3.1.13", "worker-loader": "^2.0.0", diff --git a/shared/dev/globalThis.js b/shared/dev/globalThis.js new file mode 100644 index 00000000000..9822b7762db --- /dev/null +++ b/shared/dev/globalThis.js @@ -0,0 +1,3 @@ +// Remove this file after upgrading to Node 12 (blocked on node-sass support) + +global.globalThis = global diff --git a/shared/src/settings/settings.test.ts b/shared/src/settings/settings.test.ts index 73b05c33bd0..dab549c0411 100644 --- a/shared/src/settings/settings.test.ts +++ b/shared/src/settings/settings.test.ts @@ -15,7 +15,6 @@ const FIXTURE_ORG: SettingsSubject & SubjectSettingsContents = { name: 'n', displayName: 'n', id: 'a', - settingsURL: 'u', viewerCanAdminister: true, latestSettings: { id: 1, contents: '{"a":1}' }, } @@ -25,7 +24,6 @@ const FIXTURE_USER: SettingsSubject & SubjectSettingsContents = { username: 'n', displayName: 'n', id: 'b', - settingsURL: 'u', viewerCanAdminister: true, latestSettings: { id: 2, contents: '{"b":2}' }, } diff --git a/shared/src/settings/settings.ts b/shared/src/settings/settings.ts index db31341d404..4221ef1b8c7 100644 --- a/shared/src/settings/settings.ts +++ b/shared/src/settings/settings.ts @@ -32,7 +32,7 @@ export interface Settings { * A settings subject is something that can have settings associated with it, such as a site ("global * settings"), an organization ("organization settings"), a user ("user settings"), etc. */ -export type SettingsSubject = Pick & +export type SettingsSubject = Pick & ( | Pick | Pick diff --git a/shared/src/util/types.ts b/shared/src/util/types.ts index 76d03e5f3c7..962a0a32f46 100644 --- a/shared/src/util/types.ts +++ b/shared/src/util/types.ts @@ -10,7 +10,7 @@ export const isDefined = (val: T): val is NonNullable => val !== undefined */ export const propertyIsDefined = (key: K) => ( val: T -): val is K extends unknown ? T & { [k in K]-?: NonNullable } : never => isDefined(val[key]) +): val is T & { [k in K]-?: NonNullable } => isDefined(val[key]) /** * Returns a function that returns `true` if the given value is an instance of the given class. diff --git a/web/babel.config.js b/web/babel.config.js index 45434a19b38..0d1e9e57b0f 100644 --- a/web/babel.config.js +++ b/web/babel.config.js @@ -8,8 +8,7 @@ const config = { '@babel/preset-env', { modules: false, - // Must match "browserslist" from web/package.json - targets: ['last 1 version', '>1%', 'not dead', 'not <0.25%', 'last 1 Chrome versions', 'not IE > 0'], + targets: require('../package.json').browserslist, useBuiltIns: 'entry', }, ], diff --git a/yarn.lock b/yarn.lock index fc112c86893..2e008a25add 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15045,11 +15045,6 @@ wcwidth@^1.0.0: dependencies: defaults "^1.0.3" -web-ext-types@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/web-ext-types/-/web-ext-types-3.1.0.tgz#32aab56a3d0f4a3d499d43b4838b3356102d11eb" - integrity sha512-HKVibk040vuhpbOljcIYYYC8GP9w9REbHpquI3im/aoZqoDIRq9DnsHl4Zsg+4Fg3SBnWsnvlIr1rnspV4TdXQ== - web-namespaces@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.2.tgz#c8dc267ab639505276bae19e129dbd6ae72b22b4"