diff --git a/src/background.ts b/src/background.ts index bdef23f..4e36d43 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, PermissionsGrantedMessage, PopupInitMessage } 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,68 @@ webNavigation.onHistoryStateUpdated.addListener(details => { runtime.onMessage.addListener(async (msg) => { if (msg === PopupInitMessage) { - return refreshPermissions(); + const context: CacheContext = {}; + 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; }); -function injectScript(details: WebNavigation.OnDOMContentLoadedDetailsType) { - void scripting.executeScript({ - target: { tabId: details.tabId }, - // injectImmediately: true, - func: getInjectionFn(details.url), - args: [details.url], +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; +} + +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 })), }); } -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 +106,16 @@ 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); + // 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 + await addInjectionListener(context); }; 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/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*/ `
  • 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 { 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) { @@ -174,7 +175,15 @@ const renderPermissionRequest = (permissionsRequest: PermissionsRequest) => { 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..80e7d30 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,6 +1,10 @@ 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 PermissionsGrantedMessage = 'permissionsGranted'; const IconPaths = { Grey: { @@ -37,3 +41,49 @@ 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; + }); +} 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;