|
| 1 | +import { app, ipcMain, net, BrowserWindow, Session } from 'electron' |
| 2 | +import * as path from 'path' |
| 3 | +import * as fs from 'fs' |
| 4 | +import { Readable } from 'stream' |
| 5 | + |
| 6 | +const AdmZip = require('adm-zip') |
| 7 | + |
| 8 | +const ExtensionInstallStatus = { |
| 9 | + BLACKLISTED: 'blacklisted', |
| 10 | + BLOCKED_BY_POLICY: 'blocked_by_policy', |
| 11 | + CAN_REQUEST: 'can_request', |
| 12 | + CORRUPTED: 'corrupted', |
| 13 | + CUSTODIAN_APPROVAL_REQUIRED: 'custodian_approval_required', |
| 14 | + CUSTODIAN_APPROVAL_REQUIRED_FOR_INSTALLATION: 'custodian_approval_required_for_installation', |
| 15 | + DEPRECATED_MANIFEST_VERSION: 'deprecated_manifest_version', |
| 16 | + DISABLED: 'disabled', |
| 17 | + ENABLED: 'enabled', |
| 18 | + FORCE_INSTALLED: 'force_installed', |
| 19 | + INSTALLABLE: 'installable', |
| 20 | + REQUEST_PENDING: 'request_pending', |
| 21 | + TERMINATED: 'terminated', |
| 22 | +} |
| 23 | + |
| 24 | +const MV2DeprecationStatus = { |
| 25 | + INACTIVE: 'inactive', |
| 26 | + SOFT_DISABLE: 'soft_disable', |
| 27 | + WARNING: 'warning', |
| 28 | +} |
| 29 | + |
| 30 | +const Result = { |
| 31 | + ALREADY_INSTALLED: 'already_installed', |
| 32 | + BLACKLISTED: 'blacklisted', |
| 33 | + BLOCKED_BY_POLICY: 'blocked_by_policy', |
| 34 | + BLOCKED_FOR_CHILD_ACCOUNT: 'blocked_for_child_account', |
| 35 | + FEATURE_DISABLED: 'feature_disabled', |
| 36 | + ICON_ERROR: 'icon_error', |
| 37 | + INSTALL_ERROR: 'install_error', |
| 38 | + INSTALL_IN_PROGRESS: 'install_in_progress', |
| 39 | + INVALID_ICON_URL: 'invalid_icon_url', |
| 40 | + INVALID_ID: 'invalid_id', |
| 41 | + LAUNCH_IN_PROGRESS: 'launch_in_progress', |
| 42 | + MANIFEST_ERROR: 'manifest_error', |
| 43 | + MISSING_DEPENDENCIES: 'missing_dependencies', |
| 44 | + SUCCESS: 'success', |
| 45 | + UNKNOWN_ERROR: 'unknown_error', |
| 46 | + UNSUPPORTED_EXTENSION_TYPE: 'unsupported_extension_type', |
| 47 | + USER_CANCELLED: 'user_cancelled', |
| 48 | + USER_GESTURE_REQUIRED: 'user_gesture_required', |
| 49 | +} |
| 50 | + |
| 51 | +const WebGlStatus = { |
| 52 | + WEBGL_ALLOWED: 'webgl_allowed', |
| 53 | + WEBGL_BLOCKED: 'webgl_blocked', |
| 54 | +} |
| 55 | + |
| 56 | +export function setupChromeWebStore(session: Session, modulePath: string = __dirname) { |
| 57 | + const preloadPath = path.join(modulePath, 'dist/renderer/web-store-api.js') |
| 58 | + |
| 59 | + // Add preload script to session |
| 60 | + session.setPreloads([...session.getPreloads(), preloadPath]) |
| 61 | + interface InstallDetails { |
| 62 | + id: string |
| 63 | + manifest: string |
| 64 | + localizedName: string |
| 65 | + esbAllowlist: boolean |
| 66 | + iconUrl: string |
| 67 | + } |
| 68 | + |
| 69 | + ipcMain.handle('chromeWebstore.beginInstall', async (event, details: InstallDetails) => { |
| 70 | + try { |
| 71 | + const manifest: chrome.runtime.Manifest = JSON.parse(details.manifest) |
| 72 | + const installVersion = manifest.version; |
| 73 | + |
| 74 | + // Check if extension is already loaded in session and remove it |
| 75 | + const extensions = session.getAllExtensions() |
| 76 | + const existingExt = extensions.find(ext => ext.id === details.id) |
| 77 | + if (existingExt) { |
| 78 | + await session.removeExtension(details.id) |
| 79 | + } |
| 80 | + |
| 81 | + // Get user data directory and ensure extensions folder exists |
| 82 | + const userDataPath = app.getPath('userData') |
| 83 | + const extensionsPath = path.join(userDataPath, 'Extensions') |
| 84 | + await fs.promises.mkdir(extensionsPath, { recursive: true }) |
| 85 | + |
| 86 | + // Create extension directory |
| 87 | + const extensionDir = path.join(extensionsPath, details.id) |
| 88 | + |
| 89 | + // Remove existing directory if it exists |
| 90 | + await fs.promises.rm(extensionDir, { recursive: true, force: true }) |
| 91 | + await fs.promises.mkdir(extensionDir, { recursive: true }) |
| 92 | + |
| 93 | + // Download extension from Chrome Web Store |
| 94 | + const chromeVersion = process.versions.chrome; |
| 95 | + const response = await net.fetch( |
| 96 | + `https://clients2.google.com/service/update2/crx?response=redirect&acceptformat=crx2,crx3&x=id%3D${details.id}%26uc&prodversion=${chromeVersion}` |
| 97 | + ) |
| 98 | + |
| 99 | + if (!response.ok) { |
| 100 | + throw new Error('Failed to download extension') |
| 101 | + } |
| 102 | + |
| 103 | + // Save extension file |
| 104 | + const extensionFile = path.join(extensionDir, 'extension.crx') |
| 105 | + const fileStream = fs.createWriteStream(extensionFile) |
| 106 | + |
| 107 | + // Convert ReadableStream to Node stream and pipe to file |
| 108 | + const readableStream = Readable.fromWeb(response.body as any) |
| 109 | + await new Promise((resolve, reject) => { |
| 110 | + readableStream.pipe(fileStream) |
| 111 | + readableStream.on('error', reject) |
| 112 | + fileStream.on('finish', resolve) |
| 113 | + }) |
| 114 | + |
| 115 | + // Unpack extension |
| 116 | + const unpackedDir = path.join(extensionDir, installVersion) |
| 117 | + await fs.promises.mkdir(unpackedDir, { recursive: true }) |
| 118 | + // Use crx-parser to extract contents |
| 119 | + const crxBuffer = await fs.promises.readFile(extensionFile) |
| 120 | + |
| 121 | + interface CrxInfo { |
| 122 | + version: number; |
| 123 | + header: Buffer; |
| 124 | + contents: Buffer; |
| 125 | + } |
| 126 | + |
| 127 | + // Parse CRX header and extract contents |
| 128 | + function parseCrx(buffer: Buffer): CrxInfo { |
| 129 | + // CRX3 magic number: 'Cr24' |
| 130 | + const magicNumber = buffer.toString('utf8', 0, 4) |
| 131 | + if (magicNumber !== 'Cr24') { |
| 132 | + throw new Error('Invalid CRX format') |
| 133 | + } |
| 134 | + |
| 135 | + // CRX3 format has version = 3 and header size at bytes 8-12 |
| 136 | + const version = buffer.readUInt32LE(4) |
| 137 | + const headerSize = buffer.readUInt32LE(8) |
| 138 | + |
| 139 | + // Extract header and contents |
| 140 | + const header = buffer.subarray(16, 16 + headerSize) |
| 141 | + const contents = buffer.subarray(16 + headerSize) |
| 142 | + |
| 143 | + return { |
| 144 | + version, |
| 145 | + header, |
| 146 | + contents |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + // Extract CRX contents to directory |
| 151 | + async function extractCrx(crx: CrxInfo, destPath: string) { |
| 152 | + // Create zip file from contents |
| 153 | + const zip = new AdmZip(crx.contents) |
| 154 | + |
| 155 | + // Extract zip to destination |
| 156 | + zip.extractAllTo(destPath, true) |
| 157 | + } |
| 158 | + |
| 159 | + const crx = await parseCrx(crxBuffer) |
| 160 | + console.log('crx', crx) |
| 161 | + await extractCrx(crx, unpackedDir) |
| 162 | + |
| 163 | + // Load extension into session |
| 164 | + await session.loadExtension(unpackedDir) |
| 165 | + |
| 166 | + return Result.SUCCESS |
| 167 | + } catch (error) { |
| 168 | + console.error('Extension installation failed:', error) |
| 169 | + return Result.INSTALL_ERROR |
| 170 | + } |
| 171 | + }) |
| 172 | + |
| 173 | + ipcMain.handle('chromeWebstore.completeInstall', async (event, id) => { |
| 174 | + // TODO: Implement completion of extension installation |
| 175 | + return Result.SUCCESS |
| 176 | + }) |
| 177 | + |
| 178 | + ipcMain.handle('chromeWebstore.enableAppLauncher', async (event, enable) => { |
| 179 | + // TODO: Implement app launcher enable/disable |
| 180 | + return true |
| 181 | + }) |
| 182 | + |
| 183 | + ipcMain.handle('chromeWebstore.getBrowserLogin', async () => { |
| 184 | + // TODO: Implement getting browser login |
| 185 | + return '' |
| 186 | + }) |
| 187 | + ipcMain.handle('chromeWebstore.getExtensionStatus', async (event, id, manifestJson) => { |
| 188 | + console.log('webstorePrivate.getExtensionStatus', JSON.stringify({ id })) |
| 189 | + const extensions = session.getAllExtensions() |
| 190 | + const extension = extensions.find((ext) => ext.id === id) |
| 191 | + |
| 192 | + if (!extension) { |
| 193 | + console.log(extensions) |
| 194 | + console.log('webstorePrivate.getExtensionStatus result:', id, ExtensionInstallStatus.INSTALLABLE) |
| 195 | + return ExtensionInstallStatus.INSTALLABLE |
| 196 | + } |
| 197 | + |
| 198 | + if (extension.manifest.disabled) { |
| 199 | + console.log('webstorePrivate.getExtensionStatus result:', id, ExtensionInstallStatus.DISABLED) |
| 200 | + return ExtensionInstallStatus.DISABLED |
| 201 | + } |
| 202 | + |
| 203 | + console.log('webstorePrivate.getExtensionStatus result:', id, ExtensionInstallStatus.ENABLED) |
| 204 | + return ExtensionInstallStatus.ENABLED |
| 205 | + }) |
| 206 | + |
| 207 | + ipcMain.handle('chromeWebstore.getFullChromeVersion', async () => { |
| 208 | + return { version_number: process.versions.chrome } |
| 209 | + }) |
| 210 | + |
| 211 | + ipcMain.handle('chromeWebstore.getIsLauncherEnabled', async () => { |
| 212 | + // TODO: Implement checking if launcher is enabled |
| 213 | + return true |
| 214 | + }) |
| 215 | + |
| 216 | + ipcMain.handle('chromeWebstore.getMV2DeprecationStatus', async () => { |
| 217 | + // TODO: Implement MV2 deprecation status check |
| 218 | + return MV2DeprecationStatus.INACTIVE |
| 219 | + }) |
| 220 | + |
| 221 | + ipcMain.handle('chromeWebstore.getReferrerChain', async () => { |
| 222 | + // TODO: Implement getting referrer chain |
| 223 | + return 'EgIIAA==' |
| 224 | + }) |
| 225 | + |
| 226 | + ipcMain.handle('chromeWebstore.getStoreLogin', async () => { |
| 227 | + // TODO: Implement getting store login |
| 228 | + return '' |
| 229 | + }) |
| 230 | + |
| 231 | + ipcMain.handle('chromeWebstore.getWebGLStatus', async () => { |
| 232 | + // TODO: Implement WebGL status check |
| 233 | + return WebGlStatus.WEBGL_ALLOWED |
| 234 | + }) |
| 235 | + |
| 236 | + ipcMain.handle('chromeWebstore.install', async (event, id, silentInstall) => { |
| 237 | + // TODO: Implement extension installation |
| 238 | + return Result.SUCCESS |
| 239 | + }) |
| 240 | + |
| 241 | + ipcMain.handle('chromeWebstore.isInIncognitoMode', async () => { |
| 242 | + // TODO: Implement incognito mode check |
| 243 | + return false |
| 244 | + }) |
| 245 | + |
| 246 | + ipcMain.handle('chromeWebstore.isPendingCustodianApproval', async (event, id) => { |
| 247 | + // TODO: Implement custodian approval check |
| 248 | + return false |
| 249 | + }) |
| 250 | + |
| 251 | + ipcMain.handle('chromeWebstore.setStoreLogin', async (event, login) => { |
| 252 | + // TODO: Implement setting store login |
| 253 | + return true |
| 254 | + }) |
| 255 | + |
| 256 | + ipcMain.handle('chrome.runtime.getManifest', async () => { |
| 257 | + // TODO: Implement getting extension manifest |
| 258 | + return {} |
| 259 | + }) |
| 260 | + |
| 261 | + ipcMain.handle('chrome.management.getAll', async (event) => { |
| 262 | + const extensions = session.getAllExtensions() |
| 263 | + |
| 264 | + return extensions.map((ext) => { |
| 265 | + const manifest: chrome.runtime.Manifest = ext.manifest |
| 266 | + return { |
| 267 | + description: manifest.description || '', |
| 268 | + enabled: !manifest.disabled, |
| 269 | + homepageUrl: manifest.homepage_url || '', |
| 270 | + hostPermissions: manifest.host_permissions || [], |
| 271 | + icons: Object.entries(manifest?.icons || {}).map(([size, url]) => ({ |
| 272 | + size: parseInt(size), |
| 273 | + url: `chrome://extension-icon/${ext.id}/${size}/0`, |
| 274 | + })), |
| 275 | + id: ext.id, |
| 276 | + installType: 'normal', |
| 277 | + isApp: !!manifest.app, |
| 278 | + mayDisable: true, |
| 279 | + name: manifest.name, |
| 280 | + offlineEnabled: !!manifest.offline_enabled, |
| 281 | + optionsUrl: manifest.options_page |
| 282 | + ? `chrome-extension://${ext.id}/${manifest.options_page}` |
| 283 | + : '', |
| 284 | + permissions: manifest.permissions || [], |
| 285 | + shortName: manifest.short_name || manifest.name, |
| 286 | + type: manifest.app ? 'app' : 'extension', |
| 287 | + updateUrl: manifest.update_url || '', |
| 288 | + version: manifest.version, |
| 289 | + } |
| 290 | + }) |
| 291 | + }) |
| 292 | + |
| 293 | + ipcMain.handle('chrome.management.setEnabled', async (event, id, enabled) => { |
| 294 | + // TODO: Implement enabling/disabling extension |
| 295 | + return true |
| 296 | + }) |
| 297 | + |
| 298 | + ipcMain.handle('chrome.management.uninstall', async (event, id, options) => { |
| 299 | + // TODO: Implement uninstalling extension |
| 300 | + return true |
| 301 | + }) |
| 302 | + |
| 303 | + // Handle extension install/uninstall events |
| 304 | + function emitExtensionEvent(eventName: string) { |
| 305 | + BrowserWindow.getAllWindows().forEach((window) => { |
| 306 | + window.webContents.send(`chrome.management.${eventName}`) |
| 307 | + }) |
| 308 | + } |
| 309 | +} |
0 commit comments