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
This commit is contained in:
Felix Becker 2019-05-03 23:20:47 +02:00 committed by GitHub
parent 1b497472c9
commit d6a6bdccbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 2608 additions and 1359 deletions

View File

@ -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',

View File

@ -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'],

View File

@ -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
}

View File

@ -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<T extends keyof StorageItems> 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<T extends keyof StorageItems> extends Obser
this.value = defaultValue
}
public next(value: StorageItems[T]): void {
storage.setLocal({ [this.key]: value })
public async next(value: StorageItems[T]): Promise<void> {
await storage.local.set({ [this.key]: value })
}
public value: StorageItems[T]

View File

@ -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<void> =>
new Promise<void>(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)
}
}

View File

@ -1,21 +0,0 @@
const chrome = global.chrome
export const setDefaultSuggestion = (suggestion: Pick<chrome.omnibox.SuggestResult, 'description'>) => {
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)
}
}

View File

@ -1,55 +0,0 @@
const chrome = global.chrome
export function contains(url: string): Promise<boolean> {
return new Promise((resolve, reject) => {
chrome.permissions.contains({ origins: [url + '/*'] }, resolve)
})
}
export function request(urls: string[]): Promise<boolean> {
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<boolean> {
return new Promise((resolve, reject) => {
if (chrome && chrome.permissions) {
chrome.permissions.remove(
{
origins: [url + '/*'],
},
resolve
)
}
})
}
export function getAll(): Promise<chrome.permissions.Permissions> {
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)
}
}

View File

@ -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 = <T extends keyof BackgroundMessageHandlers>(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<string> => {
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'),
}

View File

@ -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<browser.storage.AreaName, browser.storage.StorageArea<StorageItems>> & {
onChanged: browser.CallbackEventEmitter<
(changes: browser.storage.ChangeDict<StorageItems>, 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<StorageItems>, callback?: (() => void) | undefined) => void
observeSync: <T extends keyof StorageItems>(key: T) => Observable<StorageItems[T]>
getLocal: (callback: (items: StorageItems) => void) => void
getLocalItem: (key: keyof StorageItems, callback: (items: StorageItems) => void) => void
setLocal: (items: Partial<StorageItems>, callback?: (() => void) | undefined) => void
observeLocal: <T extends keyof StorageItems>(key: T) => Observable<StorageItems[T]>
setSyncMigration: MigratableStorageArea['setMigration']
setLocalMigration: MigratableStorageArea['setMigration']
onChanged: (listener: (changes: Partial<StorageChange>, 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<StorageItems>, 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<StorageChange>, areaName: string) => void) => {
if (chrome && chrome.storage) {
chrome.storage.onChanged.addListener(listener)
}
}
const observe = (area: chrome.storage.StorageArea) => <T extends keyof StorageItems>(
key: T
): Observable<StorageItems[T]> =>
new Observable<StorageItems[T]>(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 = <K extends keyof StorageItems>(
areaName: browser.storage.AreaName,
key: K
): Observable<StorageItems[K] | undefined> =>
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)
)
)

View File

@ -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<MigrateFunc>()
const getCalls = new ReplaySubject<Parameters<chrome.storage.StorageArea['get']>>()
const setCalls = new ReplaySubject<Parameters<chrome.storage.StorageArea['set']>>()
const migrated = migrations.pipe(
switchMap(
migrate =>
new Observable<void>(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<chrome.storage.StorageArea['get']>))
})
// Cast is needed because Parameters<> doesn't include overloads
const get: chrome.storage.StorageArea['get'] = ((...args: Parameters<chrome.storage.StorageArea['get']>) => {
getCalls.next(args)
}) as chrome.storage.StorageArea['get']
const set: chrome.storage.StorageArea['set'] = (...args: Parameters<chrome.storage.StorageArea['set']>) => {
setCalls.next(args)
}
return {
...area,
get,
set,
setMigration: migrate => {
migrations.next(migrate)
},
}
}

View File

@ -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)
}
}

View File

@ -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<StorageItems> 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<FeatureFlags>
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<void>
createBlobURL(bundleUrl: string): Promise<string>
requestGraphQL<T extends IGraphQLResponseRoot>(params: GraphQLRequestArgs): Promise<T>
}

View File

@ -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
}
}

View File

@ -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<void> {
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<void> {
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 !== '<all_urls>' && (!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<MessagePort, 'start'>
): 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 === '<all_urls>' || (!!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<void> {
await browser.runtime.openOptionsPage()
},
async createBlobURL(bundleUrl: string): Promise<string> {
return await createBlobURLForBundle(bundleUrl)
},
async requestGraphQL<T extends IGraphQLResponseRoot>(params: GraphQLRequestArgs): Promise<T> {
return await requestGraphQL<T>(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<void> {
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<Record<'proxy' | 'expose', browser.runtime.Port>> = 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<MessagePort, 'start'>
): 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()

View File

@ -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<void> {
subtree: true,
}).pipe(startWith([{ addedNodes: [document.body], removedNodes: [] }]))
const items = await new Promise<StorageItems>(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)

View File

@ -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 },

View File

@ -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)
})
}

View File

@ -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<chrome.omnibox.SuggestResult[]> =>
public getSuggestions = (query: string): Promise<browser.omnibox.SuggestResult[]> =>
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<void> => {
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
}
}
}

View File

@ -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(),

View File

@ -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<ViewOnSourcegraphButtonPro
// user to configure Sourcegraph.
if (
!this.state.repoExists &&
this.props.sourcegraphUrl === 'https://sourcegraph.com' &&
this.props.sourcegraphUrl === DEFAULT_SOURCEGRAPH_URL &&
this.props.onConfigureSourcegraphClick
) {
return (

View File

@ -1,3 +1,5 @@
// tslint:disable:jsx-no-lambda Okay in tests
import * as React from 'react'
import { render, RenderResult } from 'react-testing-library'
import { noop, Observable, of } from 'rxjs'
@ -44,7 +46,7 @@ describe('OptionsContainer', () => {
{...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) {

View File

@ -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<OptionsMenuProps['currentTabStatus']>
hasPermissions: (url: string) => Promise<boolean>
requestPermissions: (url: string) => void
setSourcegraphURL: (url: string) => void
setSourcegraphURL: (url: string) => Promise<void>
toggleFeatureFlag: (key: string) => void
featureFlags: { key: string; value: boolean }[]
}
@ -24,7 +24,7 @@ interface OptionsContainerState
> {}
export class OptionsContainer extends React.Component<OptionsContainerProps, OptionsContainerState> {
private version = getExtensionVersionSync()
private version = getExtensionVersion()
private urlUpdates = new Subject<string>()
@ -84,7 +84,7 @@ export class OptionsContainer extends React.Component<OptionsContainerProps, Opt
const urlHasPermissions = await props.hasPermissions(url)
this.setState({ urlHasPermissions })
props.setSourcegraphURL(url)
await props.setSourcegraphURL(url)
})
)
@ -126,8 +126,8 @@ export class OptionsContainer extends React.Component<OptionsContainerProps, Opt
this.setState({ sourcegraphURL: value })
}
private handleURLSubmit = () => {
this.props.setSourcegraphURL(this.state.sourcegraphURL)
private handleURLSubmit = async () => {
await this.props.setSourcegraphURL(this.state.sourcegraphURL)
}
private handleSettingsClick = () => {

View File

@ -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<PhabricatorRepoDetails | null> {
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<PhabricatorRepoDetails | null> {
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 {

View File

@ -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')
})
})
}

View File

@ -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<CodeHost, 'urlToFile'>
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<CodeHost, 'urlToFile'>
})
}
return storage.observeSync('sourcegraphURL').pipe(
return observeStorageKey('sync', 'sourcegraphURL').pipe(
take(1),
mergeMap(
(url: string): Observable<GraphQLResult<any>> =>
(url: string = defaultStorageItems.sourcegraphURL): Observable<GraphQLResult<any>> =>
requestGraphQL({
ctx: getContext({ repoKey: '', isRepoSpecific: false }),
request,
@ -114,16 +116,7 @@ export function createPlatformContext({ urlToFile }: Pick<CodeHost, 'urlToFile'>
// 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<string>(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<CodeHost, 'urlToFile'>
},
sourcegraphURL: sourcegraphUrl,
clientApplication: 'other',
sideloadedExtensionURL: new ExtensionStorageSubject('sideloadedExtensionURL', null),
sideloadedExtensionURL: isInPage
? new LocalStorageSubject<string | null>('sideloadedExtensionURL', null)
: new ExtensionStorageSubject('sideloadedExtensionURL', null),
}
return context
}

View File

@ -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<EndpointPair> {
}
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<EndpointPair> {
}
/**
* 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)
},
})
}

View File

@ -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<SettingsCascade> = () => {
const storageSubject = isInPage
? new LocalStorageSubject<string>(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<Pick<GQL.ISettingsCascade, 'su
/**
* Applies an edit and persists the result to client settings.
*/
export function editClientSettings(edit: SettingsEdit | string): Promise<void> {
export async function editClientSettings(edit: SettingsEdit | string): Promise<void> {
const getNext = (prev: string) =>
typeof edit === 'string'
? edit
@ -180,16 +179,8 @@ export function editClientSettings(edit: SettingsEdit | string): Promise<void> {
return Promise.resolve()
}
return new Promise<StorageItems>(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<undefined>(resolve =>
storage.setSync({ clientSettings: next }, () => {
resolve(undefined)
})
)
}
)
await storage.sync.set({ clientSettings: next })
}

View File

@ -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 = <T extends GQL.IGraphQLResponseRoot>(args: GraphQLRequestArgs): Observable<T> => {
// 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<T>(args))
}
function performRequest<T extends GQL.IGraphQLResponseRoot>({
@ -76,7 +77,7 @@ function performRequest<T extends GQL.IGraphQLResponseRoot>({
authError,
requestMightContainPrivateInfo = true,
ajaxRequest = ajax,
}: GraphQLRequestArgs & { ajaxRequest?: typeof ajax }): Observable<T> {
}: GraphQLRequestArgs): Observable<T> {
const nameMatch = request.match(/^\s*(?:query|mutation)\s+(\w+)/)
const queryName = nameMatch ? '?' + nameMatch[1] : ''

View File

@ -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()}`,
}
}

View File

@ -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

View File

@ -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<Props, {}> {
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 (
<div className="sg-alert sg-alert-success site-alert">
<a
onClick={this.onClick}
className="site-alert__link"
href={`${sourcegraphUrl}/site-admin/external-services`}
target="_blank"
>
<span className="icon-inline site-alert__link-icon">
<WarningIcon size={17} />
</span>{' '}
<span className="underline">Configure code hosts</span>
</a>
&nbsp; (and other external services) to add repositories to Sourcegraph.
<div
style={{
display: 'inline-flex',
flex: '1 1 auto',
textAlign: 'right',
flexDirection: 'row-reverse',
}}
>
<span
onClick={this.onClose}
style={{
fill: 'white',
cursor: 'pointer',
width: 17,
height: 17,
color: 'white',
paddingTop: 3,
}}
>
<CloseIcon size={17} />
</span>
</div>
</div>
)
}
}

View File

@ -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<Props, {}> {
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 (
<div className="sg-alert sg-alert-warning site-alert" style={{ justifyContent: 'space-between' }}>
<a
onClick={this.onClicked}
className="site-alert__link"
href="https://docs.sourcegraph.com"
target="_blank"
>
<span className="icon-inline site-alert__link-icon">
<WarningIcon size={17} />
</span>{' '}
<span className="underline">Configure Sourcegraph</span>
</a>
&nbsp;for code intelligence on private repositories.
<div
style={{
display: 'inline-flex',
flex: '1 1 auto',
textAlign: 'right',
flexDirection: 'row-reverse',
}}
>
<span
onClick={this.onClose}
style={{
fill: 'white',
cursor: 'pointer',
width: 17,
height: 17,
color: 'white',
paddingTop: 3,
}}
>
<CloseIcon size={17} />
</span>
</div>
</div>
)
}
}

View File

@ -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<string> =>
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)
}
}
}

View File

@ -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 = <F extends (...args: any[]) => void>(
emitter: browser.CallbackEventEmitter<F>
): Observable<Parameters<F>> =>
// 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)
})

View File

@ -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 {

View File

@ -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<K extends keyof FeatureFlags>(key: K): Promise<boolean>
enable<K extends keyof FeatureFlags>(key: K): Promise<void>
/**
* Disable a feature flag.
*/
disable<K extends keyof FeatureFlags>(key: K): Promise<boolean>
disable<K extends keyof FeatureFlags>(key: K): Promise<void>
/**
* Set a feature flag.
*/
set<K extends keyof FeatureFlags>(key: K, enabled: boolean): Promise<boolean>
set<K extends keyof FeatureFlags>(key: K, enabled: boolean): Promise<void>
/** Toggle a feature flag. */
toggle<K extends keyof FeatureFlags>(key: K): Promise<boolean>
}
interface FeatureFlagUtilities {
get<K extends keyof FeatureFlags>(key: K): Promise<boolean>
set<K extends keyof FeatureFlags>(key: K, enabled: boolean): Promise<boolean>
get(key: keyof FeatureFlags): Promise<boolean | undefined>
set(key: keyof FeatureFlags, enabled: boolean): Promise<void>
}
const createFeatureFlagStorage = ({ get, set }: FeatureFlagUtilities): FeatureFlagsStorage => ({
set,
enable: key => set(key, true),
disable: key => set(key, false),
isEnabled<K extends keyof FeatureFlags>(key: K): Promise<boolean> {
return get(key).then(val => !!val)
async isEnabled<K extends keyof FeatureFlags>(key: K): Promise<boolean> {
const value = await get(key)
return typeof value === 'boolean' ? value : featureFlagDefaults[key]
},
toggle<K extends keyof FeatureFlags>(key: K): Promise<FeatureFlags[K]> {
return get(key).then(val => set(key, !val))
async toggle<K extends keyof FeatureFlags>(key: K): Promise<boolean> {
const val = await get(key)
await set(key, !val)
return !val
},
})
function bextGet<K extends keyof FeatureFlags>(key: K): Promise<FeatureFlags[K]> {
return new Promise(resolve =>
storage.getSync(({ featureFlags }) => {
resolve(featureFlags[key])
})
)
async function bextGet<K extends keyof FeatureFlags>(key: K): Promise<boolean | undefined> {
const { featureFlags = {} } = await storage.sync.get()
return featureFlags[key]
}
function bextSet<K extends keyof FeatureFlags>(key: K, val: FeatureFlags[K]): Promise<FeatureFlags[K]> {
return new Promise(resolve =>
storage.getSync(({ featureFlags }) =>
storage.setSync({ featureFlags: { ...featureFlags, [key]: val } }, () => bextGet(key).then(resolve))
)
)
async function bextSet<K extends keyof FeatureFlags>(key: K, val: FeatureFlags[K]): Promise<void> {
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

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"typeRoots": [
"src/types",
"../../shared/src/types",

View File

@ -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

View File

@ -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",

3
shared/dev/globalThis.js Normal file
View File

@ -0,0 +1,3 @@
// Remove this file after upgrading to Node 12 (blocked on node-sass support)
global.globalThis = global

View File

@ -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}' },
}

View File

@ -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<GQL.ISettingsSubject, 'id' | 'settingsURL' | 'viewerCanAdminister'> &
export type SettingsSubject = Pick<GQL.ISettingsSubject, 'id' | 'viewerCanAdminister'> &
(
| Pick<IClient, '__typename' | 'displayName'>
| Pick<GQL.IUser, '__typename' | 'username' | 'displayName'>

View File

@ -10,7 +10,7 @@ export const isDefined = <T>(val: T): val is NonNullable<T> => val !== undefined
*/
export const propertyIsDefined = <T extends object, K extends keyof T>(key: K) => (
val: T
): val is K extends unknown ? T & { [k in K]-?: NonNullable<T[k]> } : never => isDefined(val[key])
): val is T & { [k in K]-?: NonNullable<T[k]> } => isDefined(val[key])
/**
* Returns a function that returns `true` if the given value is an instance of the given class.

View File

@ -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',
},
],

View File

@ -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"