Skip to content

Commit

Permalink
feat: extension updater
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelmaddock committed Dec 20, 2024
1 parent 420d08f commit 610dfad
Show file tree
Hide file tree
Showing 7 changed files with 776 additions and 476 deletions.
315 changes: 315 additions & 0 deletions packages/electron-chrome-web-store/src/browser/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
import * as fs from 'fs'
import * as path from 'path'
import { app, ipcMain } from 'electron'

import {
ExtensionInstallStatus,
MV2DeprecationStatus,
Result,
WebGlStatus,
} from '../common/constants'
import { downloadExtension } from './installer'

const d = require('debug')('electron-chrome-web-store:api')

const WEBSTORE_URL = 'https://chromewebstore.google.com'

function getExtensionInfo(ext: Electron.Extension) {
const manifest: chrome.runtime.Manifest = ext.manifest
return {
description: manifest.description || '',
enabled: !manifest.disabled,
homepageUrl: manifest.homepage_url || '',
hostPermissions: manifest.host_permissions || [],
icons: Object.entries(manifest?.icons || {}).map(([size, url]) => ({
size: parseInt(size),
url: `chrome://extension-icon/${ext.id}/${size}/0`,
})),
id: ext.id,
installType: 'normal',
isApp: !!manifest.app,
mayDisable: true,
name: manifest.name,
offlineEnabled: !!manifest.offline_enabled,
optionsUrl: manifest.options_page
? `chrome-extension://${ext.id}/${manifest.options_page}`
: '',
permissions: manifest.permissions || [],
shortName: manifest.short_name || manifest.name,
type: manifest.app ? 'app' : 'extension',
updateUrl: manifest.update_url || '',
version: manifest.version,
}
}

function getExtensionInstallStatus(
state: WebStoreState,
extensionId: ExtensionId,
manifest?: chrome.runtime.Manifest,
) {
if (state.denylist?.has(extensionId)) {
return ExtensionInstallStatus.BLOCKED_BY_POLICY
}

if (state.allowlist && !state.allowlist.has(extensionId)) {
return ExtensionInstallStatus.BLOCKED_BY_POLICY
}

if (manifest) {
if (manifest.manifest_version < 2) {
return ExtensionInstallStatus.DEPRECATED_MANIFEST_VERSION
}
}

const extensions = state.session.getAllExtensions()
const extension = extensions.find((ext) => ext.id === extensionId)

if (!extension) {
return ExtensionInstallStatus.INSTALLABLE
}

if (extension.manifest.disabled) {
return ExtensionInstallStatus.DISABLED
}

return ExtensionInstallStatus.ENABLED
}

async function uninstallExtension(
{ session, extensionsPath }: WebStoreState,
extensionId: ExtensionId,
) {
const extensions = session.getAllExtensions()
const existingExt = extensions.find((ext) => ext.id === extensionId)
if (existingExt) {
await session.removeExtension(extensionId)
}

const extensionDir = path.join(extensionsPath, extensionId)
try {
const stat = await fs.promises.stat(extensionDir)
if (stat.isDirectory()) {
await fs.promises.rm(extensionDir, { recursive: true, force: true })
}
} catch (error: any) {
if (error?.code !== 'ENOENT') {
console.error(error)
}
}
}

interface InstallDetails {
id: string
manifest: string
localizedName: string
esbAllowlist: boolean
iconUrl: string
}

async function beginInstall(state: WebStoreState, details: InstallDetails) {
const extensionId = details.id

try {
if (state.installing.has(extensionId)) {
return { result: Result.INSTALL_IN_PROGRESS }
}

let manifest: chrome.runtime.Manifest
try {
manifest = JSON.parse(details.manifest)
} catch {
return { result: Result.MANIFEST_ERROR }
}

const installStatus = getExtensionInstallStatus(state, extensionId, manifest)
switch (installStatus) {
case ExtensionInstallStatus.INSTALLABLE:
break // good to go
case ExtensionInstallStatus.BLOCKED_BY_POLICY:
return { result: Result.BLOCKED_BY_POLICY }
default: {
d('unable to install extension %s with status "%s"', extensionId, installStatus)
return { result: Result.UNKNOWN_ERROR }
}
}

state.installing.add(extensionId)

// Check if extension is already loaded in session and remove it
await uninstallExtension(state, extensionId)

// Create extension directory
const installVersion = manifest.version
const unpackedDir = path.join(state.extensionsPath, extensionId, `${installVersion}_0`)
await fs.promises.mkdir(unpackedDir, { recursive: true })

await downloadExtension(extensionId, unpackedDir)

// Load extension into session
await state.session.loadExtension(unpackedDir)

return { result: Result.SUCCESS }
} catch (error) {
console.error('Extension installation failed:', error)
return {
result: Result.INSTALL_ERROR,
message: error instanceof Error ? error.message : String(error),
}
} finally {
state.installing.delete(extensionId)
}
}

export function registerWebStoreApi(webStoreState: WebStoreState) {
/** Handle IPCs from the Chrome Web Store. */
const handle = (
channel: string,
handle: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
) => {
ipcMain.handle(channel, async function handleWebStoreIpc(event, ...args) {
d('received %s', channel)

const senderOrigin = event.senderFrame?.origin
if (!senderOrigin || !senderOrigin.startsWith(WEBSTORE_URL)) {
d('ignoring webstore request from %s', senderOrigin)
return
}

const result = await handle(event, ...args)
d('%s result', channel, result)
return result
})
}

handle('chromeWebstore.beginInstall', async (event, details: InstallDetails) => {
const { senderFrame } = event

d('beginInstall', details)

const result = await beginInstall(webStoreState, details)

if (result.result === Result.SUCCESS) {
queueMicrotask(() => {
const ext = webStoreState.session.getExtension(details.id)
if (ext) {
// TODO: use WebFrameMain.isDestroyed
try {
senderFrame.send('chrome.management.onInstalled', getExtensionInfo(ext))
} catch (error) {
console.error(error)
}
}
})
}

return result
})

handle('chromeWebstore.completeInstall', async (event, id) => {
// TODO: Implement completion of extension installation
return Result.SUCCESS
})

handle('chromeWebstore.enableAppLauncher', async (event, enable) => {
// TODO: Implement app launcher enable/disable
return true
})

handle('chromeWebstore.getBrowserLogin', async () => {
// TODO: Implement getting browser login
return ''
})
handle('chromeWebstore.getExtensionStatus', async (_event, id, manifestJson) => {
const manifest = JSON.parse(manifestJson)
return getExtensionInstallStatus(webStoreState, id, manifest)
})

handle('chromeWebstore.getFullChromeVersion', async () => {
return {
version_number: process.versions.chrome,
app_name: app.getName(),
}
})

handle('chromeWebstore.getIsLauncherEnabled', async () => {
// TODO: Implement checking if launcher is enabled
return true
})

handle('chromeWebstore.getMV2DeprecationStatus', async () => {
return MV2DeprecationStatus.INACTIVE
})

handle('chromeWebstore.getReferrerChain', async () => {
// TODO: Implement getting referrer chain
return 'EgIIAA=='
})

handle('chromeWebstore.getStoreLogin', async () => {
// TODO: Implement getting store login
return ''
})

handle('chromeWebstore.getWebGLStatus', async () => {
await app.getGPUInfo('basic')
const features = app.getGPUFeatureStatus()
return features.webgl.startsWith('enabled')
? WebGlStatus.WEBGL_ALLOWED
: WebGlStatus.WEBGL_BLOCKED
})

handle('chromeWebstore.install', async (event, id, silentInstall) => {
// TODO: Implement extension installation
return Result.SUCCESS
})

handle('chromeWebstore.isInIncognitoMode', async () => {
// TODO: Implement incognito mode check
return false
})

handle('chromeWebstore.isPendingCustodianApproval', async (event, id) => {
// TODO: Implement custodian approval check
return false
})

handle('chromeWebstore.setStoreLogin', async (event, login) => {
// TODO: Implement setting store login
return true
})

handle('chrome.runtime.getManifest', async () => {
// TODO: Implement getting extension manifest
return {}
})

handle('chrome.management.getAll', async (event) => {
const extensions = webStoreState.session.getAllExtensions()
return extensions.map(getExtensionInfo)
})

handle('chrome.management.setEnabled', async (event, id, enabled) => {
// TODO: Implement enabling/disabling extension
return true
})

handle(
'chrome.management.uninstall',
async (event, id, options: { showConfirmDialog: boolean }) => {
if (options?.showConfirmDialog) {
// TODO: confirmation dialog
}

try {
await uninstallExtension(webStoreState, id)
queueMicrotask(() => {
event.sender.send('chrome.management.onUninstalled', id)
})
return Result.SUCCESS
} catch (error) {
console.error(error)
return Result.UNKNOWN_ERROR
}
},
)
}
Loading

0 comments on commit 610dfad

Please sign in to comment.