|
| 1 | +import * as fs from 'fs' |
| 2 | +import * as path from 'path' |
| 3 | +import { app, ipcMain } from 'electron' |
| 4 | + |
| 5 | +import { |
| 6 | + ExtensionInstallStatus, |
| 7 | + MV2DeprecationStatus, |
| 8 | + Result, |
| 9 | + WebGlStatus, |
| 10 | +} from '../common/constants' |
| 11 | +import { downloadExtension } from './installer' |
| 12 | + |
| 13 | +const d = require('debug')('electron-chrome-web-store:api') |
| 14 | + |
| 15 | +const WEBSTORE_URL = 'https://chromewebstore.google.com' |
| 16 | + |
| 17 | +function getExtensionInfo(ext: Electron.Extension) { |
| 18 | + const manifest: chrome.runtime.Manifest = ext.manifest |
| 19 | + return { |
| 20 | + description: manifest.description || '', |
| 21 | + enabled: !manifest.disabled, |
| 22 | + homepageUrl: manifest.homepage_url || '', |
| 23 | + hostPermissions: manifest.host_permissions || [], |
| 24 | + icons: Object.entries(manifest?.icons || {}).map(([size, url]) => ({ |
| 25 | + size: parseInt(size), |
| 26 | + url: `chrome://extension-icon/${ext.id}/${size}/0`, |
| 27 | + })), |
| 28 | + id: ext.id, |
| 29 | + installType: 'normal', |
| 30 | + isApp: !!manifest.app, |
| 31 | + mayDisable: true, |
| 32 | + name: manifest.name, |
| 33 | + offlineEnabled: !!manifest.offline_enabled, |
| 34 | + optionsUrl: manifest.options_page |
| 35 | + ? `chrome-extension://${ext.id}/${manifest.options_page}` |
| 36 | + : '', |
| 37 | + permissions: manifest.permissions || [], |
| 38 | + shortName: manifest.short_name || manifest.name, |
| 39 | + type: manifest.app ? 'app' : 'extension', |
| 40 | + updateUrl: manifest.update_url || '', |
| 41 | + version: manifest.version, |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +function getExtensionInstallStatus( |
| 46 | + state: WebStoreState, |
| 47 | + extensionId: ExtensionId, |
| 48 | + manifest?: chrome.runtime.Manifest, |
| 49 | +) { |
| 50 | + if (state.denylist?.has(extensionId)) { |
| 51 | + return ExtensionInstallStatus.BLOCKED_BY_POLICY |
| 52 | + } |
| 53 | + |
| 54 | + if (state.allowlist && !state.allowlist.has(extensionId)) { |
| 55 | + return ExtensionInstallStatus.BLOCKED_BY_POLICY |
| 56 | + } |
| 57 | + |
| 58 | + if (manifest) { |
| 59 | + if (manifest.manifest_version < 2) { |
| 60 | + return ExtensionInstallStatus.DEPRECATED_MANIFEST_VERSION |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + const extensions = state.session.getAllExtensions() |
| 65 | + const extension = extensions.find((ext) => ext.id === extensionId) |
| 66 | + |
| 67 | + if (!extension) { |
| 68 | + return ExtensionInstallStatus.INSTALLABLE |
| 69 | + } |
| 70 | + |
| 71 | + if (extension.manifest.disabled) { |
| 72 | + return ExtensionInstallStatus.DISABLED |
| 73 | + } |
| 74 | + |
| 75 | + return ExtensionInstallStatus.ENABLED |
| 76 | +} |
| 77 | + |
| 78 | +async function uninstallExtension( |
| 79 | + { session, extensionsPath }: WebStoreState, |
| 80 | + extensionId: ExtensionId, |
| 81 | +) { |
| 82 | + const extensions = session.getAllExtensions() |
| 83 | + const existingExt = extensions.find((ext) => ext.id === extensionId) |
| 84 | + if (existingExt) { |
| 85 | + await session.removeExtension(extensionId) |
| 86 | + } |
| 87 | + |
| 88 | + const extensionDir = path.join(extensionsPath, extensionId) |
| 89 | + try { |
| 90 | + const stat = await fs.promises.stat(extensionDir) |
| 91 | + if (stat.isDirectory()) { |
| 92 | + await fs.promises.rm(extensionDir, { recursive: true, force: true }) |
| 93 | + } |
| 94 | + } catch (error: any) { |
| 95 | + if (error?.code !== 'ENOENT') { |
| 96 | + console.error(error) |
| 97 | + } |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +interface InstallDetails { |
| 102 | + id: string |
| 103 | + manifest: string |
| 104 | + localizedName: string |
| 105 | + esbAllowlist: boolean |
| 106 | + iconUrl: string |
| 107 | +} |
| 108 | + |
| 109 | +async function beginInstall(state: WebStoreState, details: InstallDetails) { |
| 110 | + const extensionId = details.id |
| 111 | + |
| 112 | + try { |
| 113 | + if (state.installing.has(extensionId)) { |
| 114 | + return { result: Result.INSTALL_IN_PROGRESS } |
| 115 | + } |
| 116 | + |
| 117 | + let manifest: chrome.runtime.Manifest |
| 118 | + try { |
| 119 | + manifest = JSON.parse(details.manifest) |
| 120 | + } catch { |
| 121 | + return { result: Result.MANIFEST_ERROR } |
| 122 | + } |
| 123 | + |
| 124 | + const installStatus = getExtensionInstallStatus(state, extensionId, manifest) |
| 125 | + switch (installStatus) { |
| 126 | + case ExtensionInstallStatus.INSTALLABLE: |
| 127 | + break // good to go |
| 128 | + case ExtensionInstallStatus.BLOCKED_BY_POLICY: |
| 129 | + return { result: Result.BLOCKED_BY_POLICY } |
| 130 | + default: { |
| 131 | + d('unable to install extension %s with status "%s"', extensionId, installStatus) |
| 132 | + return { result: Result.UNKNOWN_ERROR } |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + state.installing.add(extensionId) |
| 137 | + |
| 138 | + // Check if extension is already loaded in session and remove it |
| 139 | + await uninstallExtension(state, extensionId) |
| 140 | + |
| 141 | + // Create extension directory |
| 142 | + const installVersion = manifest.version |
| 143 | + const unpackedDir = path.join(state.extensionsPath, extensionId, `${installVersion}_0`) |
| 144 | + await fs.promises.mkdir(unpackedDir, { recursive: true }) |
| 145 | + |
| 146 | + await downloadExtension(extensionId, unpackedDir) |
| 147 | + |
| 148 | + // Load extension into session |
| 149 | + await state.session.loadExtension(unpackedDir) |
| 150 | + |
| 151 | + return { result: Result.SUCCESS } |
| 152 | + } catch (error) { |
| 153 | + console.error('Extension installation failed:', error) |
| 154 | + return { |
| 155 | + result: Result.INSTALL_ERROR, |
| 156 | + message: error instanceof Error ? error.message : String(error), |
| 157 | + } |
| 158 | + } finally { |
| 159 | + state.installing.delete(extensionId) |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +export function registerWebStoreApi(webStoreState: WebStoreState) { |
| 164 | + /** Handle IPCs from the Chrome Web Store. */ |
| 165 | + const handle = ( |
| 166 | + channel: string, |
| 167 | + handle: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any, |
| 168 | + ) => { |
| 169 | + ipcMain.handle(channel, async function handleWebStoreIpc(event, ...args) { |
| 170 | + d('received %s', channel) |
| 171 | + |
| 172 | + const senderOrigin = event.senderFrame?.origin |
| 173 | + if (!senderOrigin || !senderOrigin.startsWith(WEBSTORE_URL)) { |
| 174 | + d('ignoring webstore request from %s', senderOrigin) |
| 175 | + return |
| 176 | + } |
| 177 | + |
| 178 | + const result = await handle(event, ...args) |
| 179 | + d('%s result', channel, result) |
| 180 | + return result |
| 181 | + }) |
| 182 | + } |
| 183 | + |
| 184 | + handle('chromeWebstore.beginInstall', async (event, details: InstallDetails) => { |
| 185 | + const { senderFrame } = event |
| 186 | + |
| 187 | + d('beginInstall', details) |
| 188 | + |
| 189 | + const result = await beginInstall(webStoreState, details) |
| 190 | + |
| 191 | + if (result.result === Result.SUCCESS) { |
| 192 | + queueMicrotask(() => { |
| 193 | + const ext = webStoreState.session.getExtension(details.id) |
| 194 | + if (ext) { |
| 195 | + // TODO: use WebFrameMain.isDestroyed |
| 196 | + try { |
| 197 | + senderFrame.send('chrome.management.onInstalled', getExtensionInfo(ext)) |
| 198 | + } catch (error) { |
| 199 | + console.error(error) |
| 200 | + } |
| 201 | + } |
| 202 | + }) |
| 203 | + } |
| 204 | + |
| 205 | + return result |
| 206 | + }) |
| 207 | + |
| 208 | + handle('chromeWebstore.completeInstall', async (event, id) => { |
| 209 | + // TODO: Implement completion of extension installation |
| 210 | + return Result.SUCCESS |
| 211 | + }) |
| 212 | + |
| 213 | + handle('chromeWebstore.enableAppLauncher', async (event, enable) => { |
| 214 | + // TODO: Implement app launcher enable/disable |
| 215 | + return true |
| 216 | + }) |
| 217 | + |
| 218 | + handle('chromeWebstore.getBrowserLogin', async () => { |
| 219 | + // TODO: Implement getting browser login |
| 220 | + return '' |
| 221 | + }) |
| 222 | + handle('chromeWebstore.getExtensionStatus', async (_event, id, manifestJson) => { |
| 223 | + const manifest = JSON.parse(manifestJson) |
| 224 | + return getExtensionInstallStatus(webStoreState, id, manifest) |
| 225 | + }) |
| 226 | + |
| 227 | + handle('chromeWebstore.getFullChromeVersion', async () => { |
| 228 | + return { |
| 229 | + version_number: process.versions.chrome, |
| 230 | + app_name: app.getName(), |
| 231 | + } |
| 232 | + }) |
| 233 | + |
| 234 | + handle('chromeWebstore.getIsLauncherEnabled', async () => { |
| 235 | + // TODO: Implement checking if launcher is enabled |
| 236 | + return true |
| 237 | + }) |
| 238 | + |
| 239 | + handle('chromeWebstore.getMV2DeprecationStatus', async () => { |
| 240 | + return MV2DeprecationStatus.INACTIVE |
| 241 | + }) |
| 242 | + |
| 243 | + handle('chromeWebstore.getReferrerChain', async () => { |
| 244 | + // TODO: Implement getting referrer chain |
| 245 | + return 'EgIIAA==' |
| 246 | + }) |
| 247 | + |
| 248 | + handle('chromeWebstore.getStoreLogin', async () => { |
| 249 | + // TODO: Implement getting store login |
| 250 | + return '' |
| 251 | + }) |
| 252 | + |
| 253 | + handle('chromeWebstore.getWebGLStatus', async () => { |
| 254 | + await app.getGPUInfo('basic') |
| 255 | + const features = app.getGPUFeatureStatus() |
| 256 | + return features.webgl.startsWith('enabled') |
| 257 | + ? WebGlStatus.WEBGL_ALLOWED |
| 258 | + : WebGlStatus.WEBGL_BLOCKED |
| 259 | + }) |
| 260 | + |
| 261 | + handle('chromeWebstore.install', async (event, id, silentInstall) => { |
| 262 | + // TODO: Implement extension installation |
| 263 | + return Result.SUCCESS |
| 264 | + }) |
| 265 | + |
| 266 | + handle('chromeWebstore.isInIncognitoMode', async () => { |
| 267 | + // TODO: Implement incognito mode check |
| 268 | + return false |
| 269 | + }) |
| 270 | + |
| 271 | + handle('chromeWebstore.isPendingCustodianApproval', async (event, id) => { |
| 272 | + // TODO: Implement custodian approval check |
| 273 | + return false |
| 274 | + }) |
| 275 | + |
| 276 | + handle('chromeWebstore.setStoreLogin', async (event, login) => { |
| 277 | + // TODO: Implement setting store login |
| 278 | + return true |
| 279 | + }) |
| 280 | + |
| 281 | + handle('chrome.runtime.getManifest', async () => { |
| 282 | + // TODO: Implement getting extension manifest |
| 283 | + return {} |
| 284 | + }) |
| 285 | + |
| 286 | + handle('chrome.management.getAll', async (event) => { |
| 287 | + const extensions = webStoreState.session.getAllExtensions() |
| 288 | + return extensions.map(getExtensionInfo) |
| 289 | + }) |
| 290 | + |
| 291 | + handle('chrome.management.setEnabled', async (event, id, enabled) => { |
| 292 | + // TODO: Implement enabling/disabling extension |
| 293 | + return true |
| 294 | + }) |
| 295 | + |
| 296 | + handle( |
| 297 | + 'chrome.management.uninstall', |
| 298 | + async (event, id, options: { showConfirmDialog: boolean }) => { |
| 299 | + if (options?.showConfirmDialog) { |
| 300 | + // TODO: confirmation dialog |
| 301 | + } |
| 302 | + |
| 303 | + try { |
| 304 | + await uninstallExtension(webStoreState, id) |
| 305 | + queueMicrotask(() => { |
| 306 | + event.sender.send('chrome.management.onUninstalled', id) |
| 307 | + }) |
| 308 | + return Result.SUCCESS |
| 309 | + } catch (error) { |
| 310 | + console.error(error) |
| 311 | + return Result.UNKNOWN_ERROR |
| 312 | + } |
| 313 | + }, |
| 314 | + ) |
| 315 | +} |
0 commit comments