From 0aa835a2a05713016d927677feea209b0f469f27 Mon Sep 17 00:00:00 2001 From: Elton Li Date: Wed, 28 Feb 2024 18:32:09 -0800 Subject: [PATCH 1/3] Add GitHub Enterprise support --- scripts/makeManifest.js | 5 +- src/background.ts | 104 ++++++++++++++++++++++++++++++-------- src/gkApi.ts | 22 +++++++- src/permissions-helper.ts | 25 ++++++--- src/popup.ts | 10 +++- src/shared.ts | 60 +++++++++++++++++++++- src/types.ts | 17 +++++++ static/popup.css | 3 +- 8 files changed, 213 insertions(+), 33 deletions(-) diff --git a/scripts/makeManifest.js b/scripts/makeManifest.js index 570650c..9fbb08f 100644 --- a/scripts/makeManifest.js +++ b/scripts/makeManifest.js @@ -18,7 +18,7 @@ const manifestBase = { 48: 'icons/gk-grey-48.png', 128: 'icons/gk-grey-128.png', }, - permissions: ['cookies', 'scripting', 'webNavigation'], + permissions: ['cookies', 'scripting', 'storage', 'webNavigation'], host_permissions: [ '*://*.github.com/*', '*://*.gitlab.com/*', @@ -41,7 +41,7 @@ const getMakeManifest = browser_specific_settings: { gecko: { id: 'gitkraken-browser@gitkraken.com', - strict_min_version: '109.0', + strict_min_version: '115.0', }, }, // Delete this in favor of optional_host_permissions when https://bugzilla.mozilla.org/show_bug.cgi?id=1766026 @@ -52,6 +52,7 @@ const getMakeManifest = }; const chromiumKeys = { + minimum_chrome_version: "102", background: { service_worker: 'dist/service-worker.js', }, diff --git a/src/background.ts b/src/background.ts index bdef23f..6ba719d 100644 --- a/src/background.ts +++ b/src/background.ts @@ -6,16 +6,23 @@ import { injectionScope as inject_bitbucket } from './hosts/bitbucket'; import { injectionScope as inject_github } from './hosts/github'; import { injectionScope as inject_gitlab } from './hosts/gitlab'; import { refreshPermissions } from './permissions-helper'; -import { PopupInitMessage } from './shared'; - -webNavigation.onDOMContentLoaded.addListener(injectScript, { - url: [ - { hostContains: 'github.com' }, - { hostContains: 'gitlab.com' }, - { hostContains: 'bitbucket.org' }, - { hostContains: 'dev.azure.com' }, - ], -}); +import { getEnterpriseConnections, getKeyFromStorage, InjectionDomainsStorageKey, PopupInitMessage, setKeyToStorage } from './shared'; +import type { CacheContext } from './types'; +import { Provider } from './types'; + +interface InjectionDomains { + github: string[]; + gitlab: string[]; + bitbucket: string[]; + azureDevops: string[]; +} + +const DefaultInjectionDomains: InjectionDomains = { + github: ['github.com'], + gitlab: ['gitlab.com'], + bitbucket: ['bitbucket.org'], + azureDevops: ['dev.azure.com'] +}; webNavigation.onHistoryStateUpdated.addListener(details => { // used to detect when the user navigates to a different page in the same tab @@ -30,36 +37,85 @@ webNavigation.onHistoryStateUpdated.addListener(details => { runtime.onMessage.addListener(async (msg) => { if (msg === PopupInitMessage) { - return refreshPermissions(); + const context: CacheContext = {}; + const injectionDomains = await computeInjectionDomains(context); + await storeInjectionDomains(injectionDomains); + // NOTE: This may request hosts that we may not have permissions for, which will log errors for the extension + // This does not cause any issues, and eliminating the errors requires more logic + addInjectionListener(injectionDomains); + return refreshPermissions(context); } console.error('Recevied unknown runtime message', msg); return undefined; }); -function injectScript(details: WebNavigation.OnDOMContentLoadedDetailsType) { +async function retrieveInjectionDomains() { + return await getKeyFromStorage(InjectionDomainsStorageKey) as InjectionDomains | undefined; +} + +async function storeInjectionDomains(injectionDomains: InjectionDomains) { + await setKeyToStorage(InjectionDomainsStorageKey, injectionDomains); +} + +async function computeInjectionDomains(context: CacheContext) { + const injectionDomains = structuredClone(DefaultInjectionDomains); + const enterpriseConnections = await getEnterpriseConnections(context); + if (enterpriseConnections) { + for (const connection of enterpriseConnections) { + if (connection.provider === Provider.GITHUB_ENTERPRISE) { + injectionDomains.github.push(connection.domain); + } + } + } + return injectionDomains; +} + +function addInjectionListener(injectionDomains: InjectionDomains) { + // NOTE: The listener has to be a static reference so that we can re-add the listener at any point + if (webNavigation.onDOMContentLoaded.hasListener(injectScript)) { + webNavigation.onDOMContentLoaded.removeListener(injectScript); + } else { + console.debug('Adding onDOMContentLoaded injection listener for the first time'); + } + const allDomains = Object.values(injectionDomains as any).flat(); + webNavigation.onDOMContentLoaded.addListener(injectScript, { + url: allDomains.map((domain) => ({ hostContains: domain })), + }); +} + +async function injectScript(details: WebNavigation.OnDOMContentLoadedDetailsType) { + const injectionDomains = await retrieveInjectionDomains(); + if (!injectionDomains) { + console.error('Could not find injection domains in storage'); + return; + } void scripting.executeScript({ target: { tabId: details.tabId }, // injectImmediately: true, - func: getInjectionFn(details.url), + func: getInjectionFn(details.url, injectionDomains), args: [details.url], }); } -function getInjectionFn(url: string): (url: string) => void { - const uri = new URL(url); - if (uri.hostname.endsWith('github.com')) { +function urlHostHasDomain(url: URL, domains: string[]): boolean { + return domains.some((domain) => url.hostname.endsWith(domain)); +} + +function getInjectionFn(rawUrl: string, injectionDomains: InjectionDomains): (url: string) => void { + const url = new URL(rawUrl); + if (urlHostHasDomain(url, injectionDomains.github)) { return inject_github; } - if (uri.hostname.endsWith('gitlab.com')) { + if (urlHostHasDomain(url, injectionDomains.gitlab)) { return inject_gitlab; } - if (uri.hostname.endsWith('bitbucket.org')) { + if (urlHostHasDomain(url, injectionDomains.bitbucket)) { return inject_bitbucket; } - if (uri.hostname.endsWith('dev.azure.com')) { + if (urlHostHasDomain(url, injectionDomains.azureDevops)) { return inject_azureDevops; } @@ -67,12 +123,18 @@ function getInjectionFn(url: string): (url: string) => void { throw new Error('Unsupported host'); } -const main = async () => { +async function main() { // The fetchUser function also updates the extension icon if the user is logged in await fetchUser(); + const context: CacheContext = {}; // This removes unneded permissions - await refreshPermissions(); + await refreshPermissions(context); + const injectionDomains = await computeInjectionDomains(context); + await storeInjectionDomains(injectionDomains); + // NOTE: This may request hosts that we may not have permissions for, which will log errors for the extension + // This does not cause any issues, and eliminating the errors requires more logic + addInjectionListener(injectionDomains); }; void main(); diff --git a/src/gkApi.ts b/src/gkApi.ts index 496e96e..153c23d 100644 --- a/src/gkApi.ts +++ b/src/gkApi.ts @@ -1,7 +1,7 @@ import { cookies } from 'webextension-polyfill'; import { checkOrigins } from './permissions-helper'; import { updateExtensionIcon } from './shared'; -import type { User } from './types'; +import type { ProviderConnection, User } from './types'; declare const MODE: 'production' | 'development' | 'none'; @@ -72,3 +72,23 @@ export const logoutUser = async () => { await updateExtensionIcon(false); }; + +export const getProviderConnections = async (): Promise => { + const token = await getAccessToken(); + if (!token) { + return null; + } + + const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) { + return null; + } + + const payload = await res.json(); + return payload.data as ProviderConnection[]; +}; diff --git a/src/permissions-helper.ts b/src/permissions-helper.ts index 2bec98e..48d539f 100644 --- a/src/permissions-helper.ts +++ b/src/permissions-helper.ts @@ -1,6 +1,7 @@ import type { Permissions } from 'webextension-polyfill'; import { permissions } from 'webextension-polyfill'; -import { arrayDifference, CloudProviders } from './shared'; +import { arrayDifference, CloudProviders, getEnterpriseConnections } from './shared'; +import type { CacheContext } from './types'; function domainToMatchPattern(domain: string): string { return `*://*.${domain}/*`; @@ -12,21 +13,32 @@ const RequiredOriginPatterns = [ ].map(domainToMatchPattern); const CloudProviderOriginPatterns = CloudProviders.map(domainToMatchPattern); -export type OriginTypes = 'required' | 'cloud'; +async function computeEnterpriseOriginPatterns(context: CacheContext): Promise { + const enterpriseConnections = await getEnterpriseConnections(context); + if (!enterpriseConnections) { + return; + } + return enterpriseConnections.map((x) => domainToMatchPattern(x.domain)); +} + +export type OriginTypes = 'required' | 'cloud' | 'enterprise'; export interface PermissionsRequest { request: Permissions.Permissions; hasRequired: boolean; hasCloud: boolean; + hasEnterprise: boolean; } -export async function refreshPermissions(): Promise { +export async function refreshPermissions(context: CacheContext): Promise { const exitingPermissions = await permissions.getAll(); const newRequiredOrigins = arrayDifference(RequiredOriginPatterns, exitingPermissions.origins); + const enterpriseOrigins = await computeEnterpriseOriginPatterns(context); + const newEnterpriseOrigins = arrayDifference(enterpriseOrigins, exitingPermissions.origins); const newCloudOrigins = arrayDifference(CloudProviderOriginPatterns, exitingPermissions.origins); - const newOrigins = [...newRequiredOrigins, ...newCloudOrigins]; - const unusedOrigins = arrayDifference(exitingPermissions.origins, [...RequiredOriginPatterns, ...CloudProviderOriginPatterns]); + const newOrigins = [...newRequiredOrigins, ...newEnterpriseOrigins, ...newCloudOrigins]; + const unusedOrigins = arrayDifference(exitingPermissions.origins, [...RequiredOriginPatterns, ...CloudProviderOriginPatterns, ...(enterpriseOrigins ?? [])]); if (!unusedOrigins.length) { const unusedPermissions: Permissions.Permissions = { @@ -43,7 +55,8 @@ export async function refreshPermissions(): Promise { supportLink.classList.add('menu-row'); mainEl.append(supportLink); } else { - permissionRequestLink.append(createFAIcon('fa-triangle-exclamation'), `Allow permissions for cloud git providers`); + const typesRequested: string[] = []; + if (permissionsRequest.hasCloud) { + typesRequested.push('cloud'); + } + if (permissionsRequest.hasEnterprise) { + typesRequested.push('self-hosted'); + } + + permissionRequestLink.append(createFAIcon('fa-triangle-exclamation'), `Allow permissions for ${typesRequested.join(' & ')} git providers`); mainEl.append(permissionRequestLink); } }; diff --git a/src/shared.ts b/src/shared.ts index 9cc4eae..1d8457a 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,6 +1,10 @@ -import { action } from 'webextension-polyfill'; +import { action, storage } from 'webextension-polyfill'; +import { getProviderConnections } from './gkApi'; +import type { CacheContext, EnterpriseProviderConnection, ProviderConnection } from './types'; +import { Provider } from './types'; export const PopupInitMessage = 'popupInit'; +export const InjectionDomainsStorageKey = 'injectionDomains'; const IconPaths = { Grey: { @@ -37,3 +41,57 @@ export function arrayDifference(first: T[] | undefined, second: T[] | undefin } return first.filter(x => !second.includes(x)); } + +function ensureDomain(value: string): string { + // Check if value is a URL or actually a domain + try { + const url = new URL(value); + return url.hostname; + } catch (e) { + // Not a valid URL, so it's probably a domain + if (!(e instanceof TypeError)) { + console.error('Unexpected error constructing URL', e); + } + } + return value; +} + +async function cacheOnContext(cache: CacheContext, key: K, fn: () => Promise): ReturnType { + if (cache[key]) { + return cache[key]; + } + const result = await fn(); + if (result !== undefined) { + cache[key] = result; + } + return result; +} + +function isEnterpriseProviderConnection(connection: ProviderConnection): connection is EnterpriseProviderConnection { + return Boolean((connection.provider === Provider.GITHUB_ENTERPRISE) && connection.domain); +} + +export async function getEnterpriseConnections(context: CacheContext) { + return cacheOnContext(context, 'enterpriseConnectionsCache', async () => { + const providerConnections = await getProviderConnections(); + if (!providerConnections) { + return; + } + // note: GitLab support comes later + const enterpriseConnections = providerConnections + .filter(isEnterpriseProviderConnection) + .map( + // typing is weird here, but we need to ensure domain is actually a domain + (connection: EnterpriseProviderConnection): EnterpriseProviderConnection => ({ ...connection, domain: ensureDomain(connection.domain) }) + ); + return enterpriseConnections; + }); +} + +export async function getKeyFromStorage(key: string): Promise { + return (await storage.session.get(key))[key] as unknown; +} + +export async function setKeyToStorage(key: string, value: unknown) { + return storage.session.set({ [key]: value }); +}; diff --git a/src/types.ts b/src/types.ts index 99a993d..0f0e595 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,3 +8,20 @@ export interface User { }; username: string; } + +export enum Provider { + GITHUB_ENTERPRISE = 'githubEnterprise', +} + +export interface ProviderConnection { + provider: Provider; + type: string; + domain?: string; // NOTE: This could include the protocol scheme +} + +// NOTE: domain here is actually a domain name, not a URI +export type EnterpriseProviderConnection = ProviderConnection & Required>; + +export interface CacheContext { + enterpriseConnectionsCache?: EnterpriseProviderConnection[]; +} diff --git a/static/popup.css b/static/popup.css index 042df98..9a779b8 100644 --- a/static/popup.css +++ b/static/popup.css @@ -16,7 +16,7 @@ /* Popup container */ #main-content { - width: 287px; + min-width: 287px; padding: 8px; font-size: var(--text-md); } @@ -50,6 +50,7 @@ button.menu-row { display: flex; align-items: center; padding: 8px; + white-space: nowrap; } a.alert { text-decoration: none; From e3ba7925843ba08387fbd92c86b68d128c5f3386 Mon Sep 17 00:00:00 2001 From: Elton Li Date: Wed, 28 Feb 2024 11:39:33 -0800 Subject: [PATCH 2/3] Simplify injection by reloading the extension --- scripts/makeManifest.js | 5 ++-- src/background.ts | 57 ++++++++++++++--------------------------- src/popup.ts | 15 ++++++----- src/shared.ts | 12 ++------- 4 files changed, 31 insertions(+), 58 deletions(-) diff --git a/scripts/makeManifest.js b/scripts/makeManifest.js index 9fbb08f..570650c 100644 --- a/scripts/makeManifest.js +++ b/scripts/makeManifest.js @@ -18,7 +18,7 @@ const manifestBase = { 48: 'icons/gk-grey-48.png', 128: 'icons/gk-grey-128.png', }, - permissions: ['cookies', 'scripting', 'storage', 'webNavigation'], + permissions: ['cookies', 'scripting', 'webNavigation'], host_permissions: [ '*://*.github.com/*', '*://*.gitlab.com/*', @@ -41,7 +41,7 @@ const getMakeManifest = browser_specific_settings: { gecko: { id: 'gitkraken-browser@gitkraken.com', - strict_min_version: '115.0', + strict_min_version: '109.0', }, }, // Delete this in favor of optional_host_permissions when https://bugzilla.mozilla.org/show_bug.cgi?id=1766026 @@ -52,7 +52,6 @@ const getMakeManifest = }; const chromiumKeys = { - minimum_chrome_version: "102", background: { service_worker: 'dist/service-worker.js', }, diff --git a/src/background.ts b/src/background.ts index 6ba719d..4e36d43 100644 --- a/src/background.ts +++ b/src/background.ts @@ -6,7 +6,7 @@ import { injectionScope as inject_bitbucket } from './hosts/bitbucket'; import { injectionScope as inject_github } from './hosts/github'; import { injectionScope as inject_gitlab } from './hosts/gitlab'; import { refreshPermissions } from './permissions-helper'; -import { getEnterpriseConnections, getKeyFromStorage, InjectionDomainsStorageKey, PopupInitMessage, setKeyToStorage } from './shared'; +import { getEnterpriseConnections, PermissionsGrantedMessage, PopupInitMessage } from './shared'; import type { CacheContext } from './types'; import { Provider } from './types'; @@ -38,25 +38,16 @@ webNavigation.onHistoryStateUpdated.addListener(details => { runtime.onMessage.addListener(async (msg) => { if (msg === PopupInitMessage) { const context: CacheContext = {}; - const injectionDomains = await computeInjectionDomains(context); - await storeInjectionDomains(injectionDomains); - // NOTE: This may request hosts that we may not have permissions for, which will log errors for the extension - // This does not cause any issues, and eliminating the errors requires more logic - addInjectionListener(injectionDomains); return refreshPermissions(context); + } else if (msg === PermissionsGrantedMessage) { + // Reload extension to update injection listener + runtime.reload(); + return undefined; } console.error('Recevied unknown runtime message', msg); return undefined; }); -async function retrieveInjectionDomains() { - return await getKeyFromStorage(InjectionDomainsStorageKey) as InjectionDomains | undefined; -} - -async function storeInjectionDomains(injectionDomains: InjectionDomains) { - await setKeyToStorage(InjectionDomainsStorageKey, injectionDomains); -} - async function computeInjectionDomains(context: CacheContext) { const injectionDomains = structuredClone(DefaultInjectionDomains); const enterpriseConnections = await getEnterpriseConnections(context); @@ -70,33 +61,25 @@ async function computeInjectionDomains(context: CacheContext) { return injectionDomains; } -function addInjectionListener(injectionDomains: InjectionDomains) { - // NOTE: The listener has to be a static reference so that we can re-add the listener at any point - if (webNavigation.onDOMContentLoaded.hasListener(injectScript)) { - webNavigation.onDOMContentLoaded.removeListener(injectScript); - } else { - console.debug('Adding onDOMContentLoaded injection listener for the first time'); - } +async function addInjectionListener(context: CacheContext) { + const injectionDomains = await computeInjectionDomains(context); const allDomains = Object.values(injectionDomains as any).flat(); + + // note: This is a closure over injectionDomains + const injectScript = (details: WebNavigation.OnDOMContentLoadedDetailsType) => { + void scripting.executeScript({ + target: { tabId: details.tabId }, + // injectImmediately: true, + func: getInjectionFn(details.url, injectionDomains), + args: [details.url], + }); + }; + webNavigation.onDOMContentLoaded.addListener(injectScript, { url: allDomains.map((domain) => ({ hostContains: domain })), }); } -async function injectScript(details: WebNavigation.OnDOMContentLoadedDetailsType) { - const injectionDomains = await retrieveInjectionDomains(); - if (!injectionDomains) { - console.error('Could not find injection domains in storage'); - return; - } - void scripting.executeScript({ - target: { tabId: details.tabId }, - // injectImmediately: true, - func: getInjectionFn(details.url, injectionDomains), - args: [details.url], - }); -} - function urlHostHasDomain(url: URL, domains: string[]): boolean { return domains.some((domain) => url.hostname.endsWith(domain)); } @@ -130,11 +113,9 @@ async function main() { const context: CacheContext = {}; // This removes unneded permissions await refreshPermissions(context); - const injectionDomains = await computeInjectionDomains(context); - await storeInjectionDomains(injectionDomains); // NOTE: This may request hosts that we may not have permissions for, which will log errors for the extension // This does not cause any issues, and eliminating the errors requires more logic - addInjectionListener(injectionDomains); + await addInjectionListener(context); }; void main(); diff --git a/src/popup.ts b/src/popup.ts index 530e118..f337f02 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -4,7 +4,7 @@ import { permissions, runtime } from 'webextension-polyfill'; import { createAnchor, createFAIcon } from './domUtils'; import { fetchUser, logoutUser } from './gkApi'; import type { PermissionsRequest } from './permissions-helper'; -import { PopupInitMessage } from './shared'; +import { PermissionsGrantedMessage, PopupInitMessage } from './shared'; import type { User } from './types'; declare const MODE: 'production' | 'development' | 'none'; @@ -149,17 +149,18 @@ const syncWithBackground = async () => { return await runtime.sendMessage(PopupInitMessage) as PermissionsRequest | undefined; }; -function reloadPopup() { - // This seems to work on Firefox and Chromium but I couldn't find any docs confirming this is the correct way - window.location.reload(); -} +const sendPermissionsGranted = async () => { + await runtime.sendMessage(PermissionsGrantedMessage); +}; const renderPermissionRequest = (permissionsRequest: PermissionsRequest) => { const mainEl = document.getElementById('main-content')!; const permissionRequestLink = createAnchor('#', undefined, async () => { - await permissions.request(permissionsRequest.request); - reloadPopup(); + const granted = await permissions.request(permissionsRequest.request); + if (granted) { + await sendPermissionsGranted(); + } }); permissionRequestLink.classList.add('alert'); if (permissionsRequest.hasRequired) { diff --git a/src/shared.ts b/src/shared.ts index 1d8457a..80e7d30 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,10 +1,10 @@ -import { action, storage } from 'webextension-polyfill'; +import { action } from 'webextension-polyfill'; import { getProviderConnections } from './gkApi'; import type { CacheContext, EnterpriseProviderConnection, ProviderConnection } from './types'; import { Provider } from './types'; export const PopupInitMessage = 'popupInit'; -export const InjectionDomainsStorageKey = 'injectionDomains'; +export const PermissionsGrantedMessage = 'permissionsGranted'; const IconPaths = { Grey: { @@ -87,11 +87,3 @@ export async function getEnterpriseConnections(context: CacheContext) { return enterpriseConnections; }); } - -export async function getKeyFromStorage(key: string): Promise { - return (await storage.session.get(key))[key] as unknown; -} - -export async function setKeyToStorage(key: string, value: unknown) { - return storage.session.set({ [key]: value }); -}; From 219b28a84892aa312a422f7b5c2f5fe84bd6b088 Mon Sep 17 00:00:00 2001 From: Elton Li Date: Wed, 28 Feb 2024 17:41:11 -0800 Subject: [PATCH 3/3] Add new GHE-specific injection for repo code page --- src/hosts/github.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/hosts/github.ts b/src/hosts/github.ts index 59d0482..9cf14dc 100644 --- a/src/hosts/github.ts +++ b/src/hosts/github.ts @@ -134,6 +134,17 @@ export function injectionScope(url: string) { } case 'tree': case undefined: + // Enterpise v3.11.2 + insertions.set('[data-target="get-repo.modal"] ul li:last-child', { + html: /*html*/ `
  • + + ${this.getGitKrakenSvg(16, 'mr-2')} + ${label} + +
  • `, + position: 'afterend', + }); + insertions.set('[data-target="get-repo.modal"] #local-panel ul li:first-child', { html: /*html*/ `