From 610dfad68cf9f8b8051457e96cfde535354a7c4f Mon Sep 17 00:00:00 2001 From: Samuel Maddock Date: Wed, 4 Dec 2024 10:12:18 -0500 Subject: [PATCH] feat: extension updater --- .../src/browser/api.ts | 315 +++++++++++ .../src/browser/index.ts | 489 +----------------- .../src/browser/installer.ts | 152 ++++++ .../src/browser/loader.ts | 5 +- .../src/browser/types.ts | 9 + .../src/browser/updater.ts | 266 ++++++++++ .../src/browser/utils.ts | 16 + 7 files changed, 776 insertions(+), 476 deletions(-) create mode 100644 packages/electron-chrome-web-store/src/browser/api.ts create mode 100644 packages/electron-chrome-web-store/src/browser/installer.ts create mode 100644 packages/electron-chrome-web-store/src/browser/types.ts create mode 100644 packages/electron-chrome-web-store/src/browser/updater.ts create mode 100644 packages/electron-chrome-web-store/src/browser/utils.ts diff --git a/packages/electron-chrome-web-store/src/browser/api.ts b/packages/electron-chrome-web-store/src/browser/api.ts new file mode 100644 index 00000000..9b657d97 --- /dev/null +++ b/packages/electron-chrome-web-store/src/browser/api.ts @@ -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 + } + }, + ) +} diff --git a/packages/electron-chrome-web-store/src/browser/index.ts b/packages/electron-chrome-web-store/src/browser/index.ts index 2e3a9a8a..80dce669 100644 --- a/packages/electron-chrome-web-store/src/browser/index.ts +++ b/packages/electron-chrome-web-store/src/browser/index.ts @@ -1,479 +1,10 @@ -import { app, ipcMain, net, session as electronSession } from 'electron' -import * as os from 'os' +import { app, session as electronSession } from 'electron' import * as path from 'path' -import * as fs from 'fs' -import { Readable } from 'stream' -import { pipeline } from 'stream/promises' -import { readCrxFileHeader, readSignedData } from './crx3' -import Pbf from 'pbf' -import { - ExtensionInstallStatus, - MV2DeprecationStatus, - Result, - WebGlStatus, -} from '../common/constants' + +import { registerWebStoreApi } from './api' import { loadAllExtensions } from './loader' -import { convertHexadecimalToIDAlphabet, generateId } from './id' export { loadAllExtensions } from './loader' - -const d = require('debug')('electron-chrome-web-store') -const AdmZip = require('adm-zip') - -const WEBSTORE_URL = 'https://chromewebstore.google.com' - -type ExtensionId = Electron.Extension['id'] - -interface WebStoreState { - session: Electron.Session - extensionsPath: string - installing: Set - allowlist?: Set - denylist?: Set -} - -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 fetchCrx(extensionId: ExtensionId) { - // Include fallbacks for node environments that aren't Electron - const chromeVersion = process.versions.chrome || '131.0.6778.109' - const fetch = net?.fetch || globalThis.fetch - - if (!fetch) { - throw new Error('No available fetch implementation') - } - - const url = new URL('https://clients2.google.com/service/update2/crx') - url.searchParams.append('response', 'redirect') - url.searchParams.append('acceptformat', ['crx2', 'crx3'].join(',')) - - const x = new URLSearchParams() - x.append('id', extensionId) - x.append('uc', '') - - url.searchParams.append('x', x.toString()) - url.searchParams.append('prodversion', chromeVersion) - - const downloadUrl = url.toString() - const response = await fetch(downloadUrl) - - if (!response.ok) { - throw new Error('Failed to download extension') - } - - return response -} - -interface CrxInfo { - version: number - header: Buffer - contents: Buffer - publicKey: Buffer -} - -// Parse CRX header and extract contents -function parseCrx(buffer: Buffer): CrxInfo { - // CRX3 magic number: 'Cr24' - const magicNumber = buffer.toString('utf8', 0, 4) - if (magicNumber !== 'Cr24') { - throw new Error('Invalid CRX format') - } - - // CRX3 format has version = 3 and header size at bytes 8-12 - const version = buffer.readUInt32LE(4) - const headerSize = buffer.readUInt32LE(8) - - // Extract header and contents - const header = buffer.subarray(12, 12 + headerSize) - const contents = buffer.subarray(12 + headerSize) - - // For CRX2 format - let publicKey: Buffer - if (version === 2) { - const pubKeyLength = buffer.readUInt32LE(8) - const sigLength = buffer.readUInt32LE(12) - publicKey = buffer.subarray(16, 16 + pubKeyLength) - } else { - // For CRX3, extract public key from header - // CRX3 header contains a protocol buffer message - const crxFileHeader = readCrxFileHeader(new Pbf(header)) - const crxSignedData = readSignedData(new Pbf(crxFileHeader.signed_header_data)) - const declaredCrxId = crxSignedData.crx_id - ? convertHexadecimalToIDAlphabet(crxSignedData.crx_id.toString('hex')) - : null - - if (!declaredCrxId) { - throw new Error('Invalid CRX signed data') - } - - // Need to find store key proof which matches the declared ID - const keyProof = crxFileHeader.sha256_with_rsa.find((proof) => { - const crxId = proof.public_key ? generateId(proof.public_key.toString('base64')) : null - return crxId === declaredCrxId - }) - - if (!keyProof) { - throw new Error('Invalid CRX key') - } - - publicKey = keyProof.public_key - } - - return { - version, - header, - contents, - publicKey, - } -} - -// Extract CRX contents and update manifest -async function extractCrx(crx: CrxInfo, destPath: string) { - // Create zip file from contents - const zip = new AdmZip(crx.contents) - - // Extract zip to destination - zip.extractAllTo(destPath, true) - - // Read manifest.json - const manifestPath = path.join(destPath, 'manifest.json') - const manifestContent = await fs.promises.readFile(manifestPath, 'utf8') - const manifestJson = JSON.parse(manifestContent) - - // Add public key to manifest - manifestJson.key = crx.publicKey.toString('base64') - - // Write updated manifest back - await fs.promises.writeFile(manifestPath, JSON.stringify(manifestJson, null, 2)) -} - -async function unpackCrx(crxPath: string, destDir: string) { - // Read and parse CRX file - const crxBuffer = await fs.promises.readFile(crxPath) - const crx = await parseCrx(crxBuffer) - await extractCrx(crx, destDir) -} - -/** - * Download extension ID from the Chrome Web Store to the given destination. - * - * @param extensionId Extension ID. - * @param destDir Destination directory. Directory is expected to exist. - */ -export async function downloadExtension(extensionId: ExtensionId, destDir: string) { - const response = await fetchCrx(extensionId) - const tmpCrxPath = path.join(os.tmpdir(), `electron-cws-download_${extensionId}.crx`) - - try { - // Save extension file - const fileStream = fs.createWriteStream(tmpCrxPath) - - // Convert ReadableStream to Node stream and pipe to file - const downloadStream = Readable.fromWeb(response.body as any) - await pipeline(downloadStream, fileStream) - - await unpackCrx(tmpCrxPath, destDir) - } finally { - await fs.promises.rm(tmpCrxPath, { force: true }) - } -} - -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) - } -} - -function addIpcListeners(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 - } - }, - ) -} +import { initUpdater } from './updater' interface ElectronChromeWebStoreOptions { /** @@ -515,6 +46,11 @@ interface ElectronChromeWebStoreOptions { * List of denied extension IDs to install. */ denylist?: ExtensionId[] + + /** + * Whether extensions should auto-update. + */ + autoUpdate?: boolean } /** @@ -529,6 +65,7 @@ export async function installChromeWebStore(opts: ElectronChromeWebStoreOptions const loadExtensions = typeof opts.loadExtensions === 'boolean' ? opts.loadExtensions : true const allowUnpackedExtensions = typeof opts.allowUnpackedExtensions === 'boolean' ? opts.allowUnpackedExtensions : false + const autoUpdate = typeof opts.autoUpdate === 'boolean' ? opts.autoUpdate : true const webStoreState: WebStoreState = { session, @@ -552,11 +89,15 @@ export async function installChromeWebStore(opts: ElectronChromeWebStoreOptions session.setPreloads([...session.getPreloads(), preloadPath]) } - addIpcListeners(webStoreState) + registerWebStoreApi(webStoreState) await app.whenReady() if (loadExtensions) { await loadAllExtensions(session, extensionsPath, { allowUnpacked: allowUnpackedExtensions }) } + + if (autoUpdate) { + await initUpdater(webStoreState) + } } diff --git a/packages/electron-chrome-web-store/src/browser/installer.ts b/packages/electron-chrome-web-store/src/browser/installer.ts new file mode 100644 index 00000000..5425d5e5 --- /dev/null +++ b/packages/electron-chrome-web-store/src/browser/installer.ts @@ -0,0 +1,152 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { Readable } from 'stream' +import { pipeline } from 'stream/promises' + +import Pbf from 'pbf' + +import { readCrxFileHeader, readSignedData } from './crx3' +import { convertHexadecimalToIDAlphabet, generateId } from './id' +import { fetch, getChromeVersion } from './utils' + +const AdmZip = require('adm-zip') + +function getCrxDownloadURL(extensionId: ExtensionId) { + const url = new URL('https://clients2.google.com/service/update2/crx') + url.searchParams.append('response', 'redirect') + url.searchParams.append('acceptformat', ['crx2', 'crx3'].join(',')) + + const x = new URLSearchParams() + x.append('id', extensionId) + x.append('uc', '') + + url.searchParams.append('x', x.toString()) + url.searchParams.append('prodversion', getChromeVersion()) + + return url.toString() +} + +interface CrxInfo { + version: number + header: Buffer + contents: Buffer + publicKey: Buffer +} + +// Parse CRX header and extract contents +function parseCrx(buffer: Buffer): CrxInfo { + // CRX3 magic number: 'Cr24' + const magicNumber = buffer.toString('utf8', 0, 4) + if (magicNumber !== 'Cr24') { + throw new Error('Invalid CRX format') + } + + // CRX3 format has version = 3 and header size at bytes 8-12 + const version = buffer.readUInt32LE(4) + const headerSize = buffer.readUInt32LE(8) + + // Extract header and contents + const header = buffer.subarray(12, 12 + headerSize) + const contents = buffer.subarray(12 + headerSize) + + // For CRX2 format + let publicKey: Buffer + if (version === 2) { + const pubKeyLength = buffer.readUInt32LE(8) + const sigLength = buffer.readUInt32LE(12) + publicKey = buffer.subarray(16, 16 + pubKeyLength) + } else { + // For CRX3, extract public key from header + // CRX3 header contains a protocol buffer message + const crxFileHeader = readCrxFileHeader(new Pbf(header)) + const crxSignedData = readSignedData(new Pbf(crxFileHeader.signed_header_data)) + const declaredCrxId = crxSignedData.crx_id + ? convertHexadecimalToIDAlphabet(crxSignedData.crx_id.toString('hex')) + : null + + if (!declaredCrxId) { + throw new Error('Invalid CRX signed data') + } + + // Need to find store key proof which matches the declared ID + const keyProof = crxFileHeader.sha256_with_rsa.find((proof) => { + const crxId = proof.public_key ? generateId(proof.public_key.toString('base64')) : null + return crxId === declaredCrxId + }) + + if (!keyProof) { + throw new Error('Invalid CRX key') + } + + publicKey = keyProof.public_key + } + + return { + version, + header, + contents, + publicKey, + } +} + +// Extract CRX contents and update manifest +async function extractCrx(crx: CrxInfo, destPath: string) { + // Create zip file from contents + const zip = new AdmZip(crx.contents) + + // Extract zip to destination + zip.extractAllTo(destPath, true) + + // Read manifest.json + const manifestPath = path.join(destPath, 'manifest.json') + const manifestContent = await fs.promises.readFile(manifestPath, 'utf8') + const manifestJson = JSON.parse(manifestContent) + + // Add public key to manifest + manifestJson.key = crx.publicKey.toString('base64') + + // Write updated manifest back + await fs.promises.writeFile(manifestPath, JSON.stringify(manifestJson, null, 2)) +} + +async function unpackCrx(crxPath: string, destDir: string) { + // Read and parse CRX file + const crxBuffer = await fs.promises.readFile(crxPath) + const crx = await parseCrx(crxBuffer) + await extractCrx(crx, destDir) +} + +export async function downloadCrx(url: string, destDir: string) { + const response = await fetch(url) + if (!response.ok) { + throw new Error('Failed to download extension') + } + + const downloadUuid = crypto.randomUUID() + const tmpCrxPath = path.join(os.tmpdir(), `electron-cws-download_${downloadUuid}.crx`) + + try { + // Save extension file + const fileStream = fs.createWriteStream(tmpCrxPath) + + // Convert ReadableStream to Node stream and pipe to file + const downloadStream = Readable.fromWeb(response.body as any) + await pipeline(downloadStream, fileStream) + + await unpackCrx(tmpCrxPath, destDir) + } finally { + await fs.promises.rm(tmpCrxPath, { force: true }) + } +} + +/** + * Download extension ID from the Chrome Web Store to the given destination. + * + * @param extensionId Extension ID. + * @param destDir Destination directory. Directory is expected to exist. + */ +export async function downloadExtension(extensionId: string, destDir: string) { + const url = getCrxDownloadURL(extensionId) + await downloadCrx(url, destDir) +} diff --git a/packages/electron-chrome-web-store/src/browser/loader.ts b/packages/electron-chrome-web-store/src/browser/loader.ts index 1ed7e651..5f271df4 100644 --- a/packages/electron-chrome-web-store/src/browser/loader.ts +++ b/packages/electron-chrome-web-store/src/browser/loader.ts @@ -5,9 +5,10 @@ import { generateId } from './id' const d = require('debug')('electron-chrome-web-store:loader') +type ExtensionPathBaseInfo = { manifest: chrome.runtime.Manifest; path: string } type ExtensionPathInfo = - | { type: 'store'; manifest: chrome.runtime.Manifest; path: string; id: string } - | { type: 'unpacked'; manifest: chrome.runtime.Manifest; path: string } + | ({ type: 'store'; id: string } & ExtensionPathBaseInfo) + | ({ type: 'unpacked' } & ExtensionPathBaseInfo) const manifestExists = async (dirPath: string) => { if (!dirPath) return false diff --git a/packages/electron-chrome-web-store/src/browser/types.ts b/packages/electron-chrome-web-store/src/browser/types.ts new file mode 100644 index 00000000..781eff48 --- /dev/null +++ b/packages/electron-chrome-web-store/src/browser/types.ts @@ -0,0 +1,9 @@ +type ExtensionId = Electron.Extension['id'] + +interface WebStoreState { + session: Electron.Session + extensionsPath: string + installing: Set + allowlist?: Set + denylist?: Set +} diff --git a/packages/electron-chrome-web-store/src/browser/updater.ts b/packages/electron-chrome-web-store/src/browser/updater.ts new file mode 100644 index 00000000..80a538c2 --- /dev/null +++ b/packages/electron-chrome-web-store/src/browser/updater.ts @@ -0,0 +1,266 @@ +import * as path from 'node:path' +import { app, powerMonitor } from 'electron' + +import { compareVersions, fetch, getChromeVersion } from './utils' +import { downloadCrx } from './installer' + +const d = require('debug')('electron-chrome-web-store:updater') + +interface OmahaResponseBody { + response: { + server: string + protocol: string + daystart: { + elapsed_seconds: number + elapsed_days: number + } + app: Array<{ + appid: string + cohort: string + status: string + cohortname: string + updatecheck: { + _esbAllowlist: string + status: + | 'ok' + | 'noupdate' + | 'error-internal' + | 'error-hash' + | 'error-osnotsupported' + | 'error-hwnotsupported' + | 'error-unsupportedprotocol' + urls?: { + url: Array<{ + codebase: string + }> + } + manifest?: { + version: string + packages: { + package: Array<{ + hash_sha256: string + size: number + name: string + fp: string + required: boolean + }> + } + } + } + }> + } +} + +type ExtensionUpdate = { + extension: Electron.Extension + id: string + name: string + version: string + url: string +} + +const SYSTEM_IDLE_DURATION = 1 * 60 * 60 * 1000 // 1 hour +const UPDATE_CHECK_INTERVAL = 5 * 60 * 60 * 1000 // 5 hours +const MIN_UPDATE_INTERVAL = 3 * 60 * 60 * 1000 // 3 hours + +/** Time of last update check */ +let lastUpdateCheck: number | undefined + +/** + * Updates are limited to certain URLs for the initial implementation. + */ +const ALLOWED_UPDATE_URLS = new Set(['https://clients2.google.com/service/update2/crx']) + +const getSessionId = (() => { + let sessionId: string + return () => sessionId || (sessionId = crypto.randomUUID()) +})() + +const getOmahaPlatform = () => { + switch (process.platform) { + case 'win32': + return 'win' + case 'darwin': + return 'mac' + default: + return process.platform + } +} + +const getOmahaArch = () => { + switch (process.arch) { + case 'ia32': + return 'x86' + case 'x64': + return 'x64' + default: + process.arch + } +} + +async function requestExtensionUpdates(extensions: Electron.Extension[]) { + const extensionIds = extensions.map((extension) => extension.id) + const extensionMap: Record = extensions.reduce( + (map, ext) => ({ + ...map, + [ext.id]: ext, + }), + {}, + ) + d('checking extensions for updates', extensionIds) + + const chromeVersion = getChromeVersion() + const url = 'https://update.googleapis.com/service/update2/json' + + // Chrome's extension updater uses its Omaha Protocol. + // https://chromium.googlesource.com/chromium/src/+/main/docs/updater/protocol_3_1.md + const body = { + request: { + '@updater': 'electron-chrome-web-store', + acceptformat: 'crx3', + app: [ + ...extensions.map((extension) => ({ + appid: extension.id, + updatecheck: {}, + // API always reports 'noupdate' when version is set :thinking: + // version: extension.version, + })), + ], + os: { + platform: getOmahaPlatform(), + arch: getOmahaArch(), + }, + prodversion: chromeVersion, + protocol: '3.1', + requestid: crypto.randomUUID(), + sessionid: getSessionId(), + testsource: process.env.NODE_ENV === 'production' ? '' : 'electron_dev', + }, + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-Goog-Update-Interactivity': 'bg', + 'X-Goog-Update-AppId': extensionIds.join(','), + 'X-Goog-Update-Updater': `chromiumcrx-${chromeVersion}`, + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + d('update response not ok') + return + } + + // Skip safe JSON prefix + const text = await response.text() + const prefix = `)]}'\n` + if (!text.startsWith(prefix)) { + d('unexpected update response: %s', text) + return + } + + const json = text.substring(prefix.length) + const result: OmahaResponseBody = JSON.parse(json) + + let updates: ExtensionUpdate[] + try { + updates = result.response.app + // Find extensions with update + .filter((app) => app.updatecheck.status === 'ok') + // Collect info + .map((app) => { + const extensionId = app.appid + const extension = extensionMap[extensionId] + const manifest = app.updatecheck.manifest! + const pkg = manifest!.packages.package[0] + return { + extension, + id: extensionId, + version: manifest.version, + name: pkg.name, + url: app.updatecheck.urls!.url[0].codebase, + } + }) + // Remove extensions without newer version + .filter((update) => { + const extension = extensionMap[update.id] + return compareVersions(extension.version, update.version) < 0 + }) + } catch (error) { + console.error('Unable to read extension updates response', error) + return + } + + return updates +} + +async function updateExtension(update: ExtensionUpdate) { + d('updating %s', update.id) + const updateDir = path.join(update.extension.path, '..', `${update.version}_0`) + await downloadCrx(update.url, updateDir) + d('updated %s', update.id) + // TODO: load new extension version +} + +async function checkForUpdates(extensions: Electron.Extension[]) { + d('checking for updates', extensions) + + const updates = await requestExtensionUpdates(extensions) + if (!updates) { + d('no updates found') + return + } + + d('updating %d extensions', updates.length) + for (const update of updates) { + await updateExtension(update) + } +} + +async function maybeCheckForUpdates(session: Electron.Session) { + const idleState = powerMonitor.getSystemIdleState(SYSTEM_IDLE_DURATION) + if (idleState !== 'active') { + d('skipping update check while system is in "%s" idle state', idleState) + return + } + + // Determine if enough time has passed to check updates + if (lastUpdateCheck && Date.now() - lastUpdateCheck < MIN_UPDATE_INTERVAL) { + return + } + lastUpdateCheck = Date.now() + + // Only check for extensions from the store + const extensions = session.getAllExtensions().filter((ext) => { + const manifest = ext.manifest as chrome.runtime.Manifest + if (!manifest) return false + // TODO: implement extension.isFromStore() to check creation flags + return manifest.key && manifest.update_url && ALLOWED_UPDATE_URLS.has(manifest.update_url) + }) + + if (extensions.length === 0) { + d('no extensions installed') + return + } + + await checkForUpdates(extensions) +} + +export async function initUpdater(state: WebStoreState) { + const check = () => maybeCheckForUpdates(state.session) + + switch (process.platform) { + case 'darwin': + app.on('did-become-active', check) + break + case 'win32': + app.on('browser-window-focus', check) + break + } + + setInterval(check, UPDATE_CHECK_INTERVAL) + check() +} diff --git a/packages/electron-chrome-web-store/src/browser/utils.ts b/packages/electron-chrome-web-store/src/browser/utils.ts new file mode 100644 index 00000000..a2619622 --- /dev/null +++ b/packages/electron-chrome-web-store/src/browser/utils.ts @@ -0,0 +1,16 @@ +import { net } from 'electron' + +// Include fallbacks for node environments that aren't Electron +export const fetch = net?.fetch || globalThis.fetch +export const getChromeVersion = () => process.versions.chrome || '131.0.6778.109' + +export function compareVersions(version1: string, version2: string) { + const v1 = version1.split('.').map(Number) + const v2 = version2.split('.').map(Number) + + for (let i = 0; i < 3; i++) { + if (v1[i] > v2[i]) return 1 + if (v1[i] < v2[i]) return -1 + } + return 0 +}