mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 18:51:59 +00:00
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:
parent
1b497472c9
commit
d6a6bdccbf
@ -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',
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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] : ''
|
||||
|
||||
@ -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()}`,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
(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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
client/browser/src/shared/util/browser.ts
Normal file
20
client/browser/src/shared/util/browser.ts
Normal 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)
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
2028
client/browser/src/types/web-extensions/index.d.ts
vendored
Normal file
2028
client/browser/src/types/web-extensions/index.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"typeRoots": [
|
||||
"src/types",
|
||||
"../../shared/src/types",
|
||||
|
||||
@ -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
|
||||
|
||||
13
package.json
13
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",
|
||||
|
||||
3
shared/dev/globalThis.js
Normal file
3
shared/dev/globalThis.js
Normal file
@ -0,0 +1,3 @@
|
||||
// Remove this file after upgrading to Node 12 (blocked on node-sass support)
|
||||
|
||||
global.globalThis = global
|
||||
@ -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}' },
|
||||
}
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user