Skip to content

Commit

Permalink
initial electron-chrome-web-store
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelmaddock committed Nov 12, 2024
1 parent 25de153 commit 3b27e1c
Show file tree
Hide file tree
Showing 13 changed files with 796 additions and 2 deletions.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
"workspaces": [
"packages/shell",
"packages/electron-chrome-extensions",
"packages/electron-chrome-context-menu"
"packages/electron-chrome-context-menu",
"packages/electron-chrome-web-store"
],
"scripts": {
"build": "yarn run build:context-menu && yarn run build:extensions && yarn run build:shell",
"build": "yarn run build:context-menu && yarn run build:chrome-web-store && yarn run build:extensions && yarn run build:shell",
"build:chrome-web-store": "yarn --cwd ./packages/electron-chrome-web-store build",
"build:context-menu": "yarn --cwd ./packages/electron-chrome-context-menu build",
"build:extensions": "yarn --cwd ./packages/electron-chrome-extensions build",
"build:shell": "yarn --cwd ./packages/shell build",
Expand Down
1 change: 1 addition & 0 deletions packages/electron-chrome-web-store/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
25 changes: 25 additions & 0 deletions packages/electron-chrome-web-store/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "electron-chrome-web-store",
"version": "0.0.1",
"description": "Download extensions from the Chrome Web Store in Electron",
"main": "dist/browser/index.js",
"scripts": {
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"chrome",
"web",
"store",
"webstore",
"extensions"
],
"author": "Samuel Maddock",
"license": "ISC",
"devDependencies": {
"typescript": "^5.6.3"
},
"dependencies": {
"adm-zip": "^0.5.16"
}
}
309 changes: 309 additions & 0 deletions packages/electron-chrome-web-store/src/browser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
import { app, ipcMain, net, BrowserWindow, Session } from 'electron'
import * as path from 'path'
import * as fs from 'fs'
import { Readable } from 'stream'

const AdmZip = require('adm-zip')

const ExtensionInstallStatus = {
BLACKLISTED: 'blacklisted',
BLOCKED_BY_POLICY: 'blocked_by_policy',
CAN_REQUEST: 'can_request',
CORRUPTED: 'corrupted',
CUSTODIAN_APPROVAL_REQUIRED: 'custodian_approval_required',
CUSTODIAN_APPROVAL_REQUIRED_FOR_INSTALLATION: 'custodian_approval_required_for_installation',
DEPRECATED_MANIFEST_VERSION: 'deprecated_manifest_version',
DISABLED: 'disabled',
ENABLED: 'enabled',
FORCE_INSTALLED: 'force_installed',
INSTALLABLE: 'installable',
REQUEST_PENDING: 'request_pending',
TERMINATED: 'terminated',
}

const MV2DeprecationStatus = {
INACTIVE: 'inactive',
SOFT_DISABLE: 'soft_disable',
WARNING: 'warning',
}

const Result = {
ALREADY_INSTALLED: 'already_installed',
BLACKLISTED: 'blacklisted',
BLOCKED_BY_POLICY: 'blocked_by_policy',
BLOCKED_FOR_CHILD_ACCOUNT: 'blocked_for_child_account',
FEATURE_DISABLED: 'feature_disabled',
ICON_ERROR: 'icon_error',
INSTALL_ERROR: 'install_error',
INSTALL_IN_PROGRESS: 'install_in_progress',
INVALID_ICON_URL: 'invalid_icon_url',
INVALID_ID: 'invalid_id',
LAUNCH_IN_PROGRESS: 'launch_in_progress',
MANIFEST_ERROR: 'manifest_error',
MISSING_DEPENDENCIES: 'missing_dependencies',
SUCCESS: 'success',
UNKNOWN_ERROR: 'unknown_error',
UNSUPPORTED_EXTENSION_TYPE: 'unsupported_extension_type',
USER_CANCELLED: 'user_cancelled',
USER_GESTURE_REQUIRED: 'user_gesture_required',
}

const WebGlStatus = {
WEBGL_ALLOWED: 'webgl_allowed',
WEBGL_BLOCKED: 'webgl_blocked',
}

export function setupChromeWebStore(session: Session, modulePath: string = __dirname) {
const preloadPath = path.join(modulePath, 'dist/renderer/web-store-api.js')

// Add preload script to session
session.setPreloads([...session.getPreloads(), preloadPath])
interface InstallDetails {
id: string
manifest: string
localizedName: string
esbAllowlist: boolean
iconUrl: string
}

ipcMain.handle('chromeWebstore.beginInstall', async (event, details: InstallDetails) => {
try {
const manifest: chrome.runtime.Manifest = JSON.parse(details.manifest)
const installVersion = manifest.version;

// Check if extension is already loaded in session and remove it
const extensions = session.getAllExtensions()
const existingExt = extensions.find(ext => ext.id === details.id)
if (existingExt) {
await session.removeExtension(details.id)
}

// Get user data directory and ensure extensions folder exists
const userDataPath = app.getPath('userData')
const extensionsPath = path.join(userDataPath, 'Extensions')
await fs.promises.mkdir(extensionsPath, { recursive: true })

// Create extension directory
const extensionDir = path.join(extensionsPath, details.id)

// Remove existing directory if it exists
await fs.promises.rm(extensionDir, { recursive: true, force: true })
await fs.promises.mkdir(extensionDir, { recursive: true })

// Download extension from Chrome Web Store
const chromeVersion = process.versions.chrome;
const response = await net.fetch(
`https://clients2.google.com/service/update2/crx?response=redirect&acceptformat=crx2,crx3&x=id%3D${details.id}%26uc&prodversion=${chromeVersion}`
)

if (!response.ok) {
throw new Error('Failed to download extension')
}

// Save extension file
const extensionFile = path.join(extensionDir, 'extension.crx')
const fileStream = fs.createWriteStream(extensionFile)

// Convert ReadableStream to Node stream and pipe to file
const readableStream = Readable.fromWeb(response.body as any)
await new Promise((resolve, reject) => {
readableStream.pipe(fileStream)
readableStream.on('error', reject)
fileStream.on('finish', resolve)
})

// Unpack extension
const unpackedDir = path.join(extensionDir, installVersion)
await fs.promises.mkdir(unpackedDir, { recursive: true })
// Use crx-parser to extract contents
const crxBuffer = await fs.promises.readFile(extensionFile)

interface CrxInfo {
version: number;
header: Buffer;
contents: 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(16, 16 + headerSize)
const contents = buffer.subarray(16 + headerSize)

return {
version,
header,
contents
}
}

// Extract CRX contents to directory
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)
}

const crx = await parseCrx(crxBuffer)
console.log('crx', crx)
await extractCrx(crx, unpackedDir)

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

return Result.SUCCESS
} catch (error) {
console.error('Extension installation failed:', error)
return Result.INSTALL_ERROR
}
})

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

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

ipcMain.handle('chromeWebstore.getBrowserLogin', async () => {
// TODO: Implement getting browser login
return ''
})
ipcMain.handle('chromeWebstore.getExtensionStatus', async (event, id, manifestJson) => {
console.log('webstorePrivate.getExtensionStatus', JSON.stringify({ id }))
const extensions = session.getAllExtensions()
const extension = extensions.find((ext) => ext.id === id)

if (!extension) {
console.log(extensions)
console.log('webstorePrivate.getExtensionStatus result:', id, ExtensionInstallStatus.INSTALLABLE)
return ExtensionInstallStatus.INSTALLABLE
}

if (extension.manifest.disabled) {
console.log('webstorePrivate.getExtensionStatus result:', id, ExtensionInstallStatus.DISABLED)
return ExtensionInstallStatus.DISABLED
}

console.log('webstorePrivate.getExtensionStatus result:', id, ExtensionInstallStatus.ENABLED)
return ExtensionInstallStatus.ENABLED
})

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

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

ipcMain.handle('chromeWebstore.getMV2DeprecationStatus', async () => {
// TODO: Implement MV2 deprecation status check
return MV2DeprecationStatus.INACTIVE
})

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

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

ipcMain.handle('chromeWebstore.getWebGLStatus', async () => {
// TODO: Implement WebGL status check
return WebGlStatus.WEBGL_ALLOWED
})

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

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

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

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

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

ipcMain.handle('chrome.management.getAll', async (event) => {
const extensions = session.getAllExtensions()

return extensions.map((ext) => {
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,
}
})
})

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

ipcMain.handle('chrome.management.uninstall', async (event, id, options) => {
// TODO: Implement uninstalling extension
return true
})

// Handle extension install/uninstall events
function emitExtensionEvent(eventName: string) {
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send(`chrome.management.${eventName}`)
})
}
}
Loading

0 comments on commit 3b27e1c

Please sign in to comment.