feat: 新增包文件
This commit is contained in:
2
packages/electron-chrome-web-store/.gitignore
vendored
Normal file
2
packages/electron-chrome-web-store/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
*.preload.js
|
||||
3
packages/electron-chrome-web-store/.npmignore
Normal file
3
packages/electron-chrome-web-store/.npmignore
Normal file
@@ -0,0 +1,3 @@
|
||||
src
|
||||
tsconfig.json
|
||||
esbuild.config.js
|
||||
143
packages/electron-chrome-web-store/README.md
Normal file
143
packages/electron-chrome-web-store/README.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# electron-chrome-web-store
|
||||
|
||||
Install and update Chrome extensions from the Chrome Web Store for Electron.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
npm install electron-chrome-web-store
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> To enable full support for Chrome extensions in Electron, install [electron-chrome-extensions](https://www.npmjs.com/package/electron-chrome-extensions).
|
||||
|
||||
### Enable downloading extensions from the Chrome Web Store
|
||||
|
||||
```js
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { installChromeWebStore } = require('electron-chrome-web-store')
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const browserSession = session.defaultSession
|
||||
const browserWindow = new BrowserWindow({
|
||||
webPreferences: {
|
||||
session: browserSession,
|
||||
},
|
||||
})
|
||||
|
||||
// Install Chrome web store and wait for extensions to load
|
||||
await installChromeWebStore({ session: browserSession })
|
||||
|
||||
browserWindow.loadURL('https://chromewebstore.google.com/')
|
||||
})
|
||||
```
|
||||
|
||||
### Install and update extensions programmatically
|
||||
|
||||
```js
|
||||
const { app, session } = require('electron')
|
||||
const { installExtension, updateExtensions } = require('electron-chrome-web-store')
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Install Dark Reader
|
||||
await installExtension('eimadpbcbfnmbkopoojfekhnkhdbieeh')
|
||||
|
||||
// Install React Developer Tools with file:// access
|
||||
await installExtension('fmkadmapgofadopljbjfkapdkoienihi', {
|
||||
loadExtensionOptions: { allowFileAccess: true },
|
||||
})
|
||||
|
||||
// Install uBlock Origin Lite to custom session
|
||||
await installExtension('ddkjiahejlhfcafbddmgiahcphecmpfh', {
|
||||
session: session.fromPartition('persist:browser'),
|
||||
})
|
||||
|
||||
// Check and install updates for all loaded extensions
|
||||
await updateExtensions()
|
||||
})
|
||||
```
|
||||
|
||||
### Packaging the preload script
|
||||
|
||||
This module uses a [preload script](https://www.electronjs.org/docs/latest/tutorial/tutorial-preload#what-is-a-preload-script).
|
||||
When packaging your application, it's required that the preload script is included. This can be
|
||||
handled in two ways:
|
||||
|
||||
1. Include `node_modules` in your packaged app. This allows `electron-chrome-web-store/preload` to
|
||||
be resolved.
|
||||
2. In the case of using JavaScript bundlers, you may need to copy the preload script next to your
|
||||
app's entry point script. You can try using
|
||||
[copy-webpack-plugin](https://github.com/webpack-contrib/copy-webpack-plugin),
|
||||
[vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy),
|
||||
or [rollup-plugin-copy](https://github.com/vladshcherbin/rollup-plugin-copy) depending on your app's
|
||||
configuration.
|
||||
|
||||
Here's an example for webpack configurations:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
entry: './index.js',
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [require.resolve('electron-chrome-web-store/preload')],
|
||||
}),
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `installChromeWebStore`
|
||||
|
||||
Installs Chrome Web Store support in the specified session.
|
||||
|
||||
- `options`
|
||||
- `session`: The Electron session to enable the Chrome Web Store in. Defaults to `session.defaultSession`.
|
||||
- `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
|
||||
- `autoUpdate`: Whether to auto-update web store extensions at startup and once every 5 hours. Defaults to true.
|
||||
- `loadExtensions`: A boolean indicating whether to load extensions installed by Chrome Web Store. Defaults to true.
|
||||
- `allowUnpackedExtensions`: A boolean indicating whether to allow loading unpacked extensions. Only loads if `loadExtensions` is also enabled. Defaults to false.
|
||||
- `allowlist`: An array of allowed extension IDs to install.
|
||||
- `denylist`: An array of denied extension IDs to install.
|
||||
- `beforeInstall`: A function which receives install details and returns a promise. Allows for prompting prior to install.
|
||||
|
||||
### `installExtension`
|
||||
|
||||
Installs Chrome extension from the Chrome Web Store.
|
||||
|
||||
- `extensionId`: The Chrome Web Store extension ID to install.
|
||||
- `options`
|
||||
- `session`: The Electron session to load extensions in. Defaults to `session.defaultSession`.
|
||||
- `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
|
||||
- `loadExtensionOptions`: Extension options passed into `session.extensions.loadExtension`.
|
||||
|
||||
### `uninstallExtension`
|
||||
|
||||
Uninstalls Chrome Web Store extension.
|
||||
|
||||
- `extensionId`: The Chrome Web Store extension ID to uninstall.
|
||||
- `options`
|
||||
- `session`: The Electron session where extensions are loaded. Defaults to `session.defaultSession`.
|
||||
- `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
|
||||
|
||||
### `updateExtensions`
|
||||
|
||||
Checks loaded extensions for updates and installs any if available.
|
||||
|
||||
- `session`: The Electron session to load extensions in. Defaults to `session.defaultSession`.
|
||||
|
||||
### `loadAllExtensions`
|
||||
|
||||
Loads all extensions from the specified directory.
|
||||
|
||||
- `session`: The Electron session to load extensions in.
|
||||
- `extensionsPath`: The path to the directory containing the extensions.
|
||||
- `options`: An object with the following property:
|
||||
- `allowUnpacked`: A boolean indicating whether to allow loading unpacked extensions. Defaults to false.
|
||||
|
||||
> [!NOTE]
|
||||
> The `installChromeWebStore` API will automatically load web store extensions by default.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
36
packages/electron-chrome-web-store/esbuild.config.js
Normal file
36
packages/electron-chrome-web-store/esbuild.config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const packageJson = require('./package.json')
|
||||
const { createConfig, build, EXTERNAL_BASE } = require('../../build/esbuild/esbuild.config.base')
|
||||
|
||||
console.log(`building ${packageJson.name}`)
|
||||
|
||||
const external = [...EXTERNAL_BASE, 'adm-zip', 'pbf', 'electron-chrome-web-store/preload']
|
||||
|
||||
const esmOnlyModules = ['pbf']
|
||||
|
||||
const browserConfig = createConfig({
|
||||
entryPoints: ['src/browser/index.ts'],
|
||||
outfile: 'dist/cjs/browser/index.js',
|
||||
platform: 'node',
|
||||
external: external.filter((module) => !esmOnlyModules.includes(module)),
|
||||
})
|
||||
|
||||
const browserESMConfig = createConfig({
|
||||
entryPoints: ['src/browser/index.ts'],
|
||||
outfile: 'dist/esm/browser/index.mjs',
|
||||
platform: 'neutral',
|
||||
external,
|
||||
format: 'esm',
|
||||
})
|
||||
|
||||
build(browserConfig)
|
||||
build(browserESMConfig)
|
||||
|
||||
const preloadConfig = createConfig({
|
||||
entryPoints: ['src/renderer/chrome-web-store.preload.ts'],
|
||||
outfile: 'dist/chrome-web-store.preload.js',
|
||||
platform: 'browser',
|
||||
external,
|
||||
sourcemap: false,
|
||||
})
|
||||
|
||||
build(preloadConfig)
|
||||
43
packages/electron-chrome-web-store/package.json
Normal file
43
packages/electron-chrome-web-store/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "electron-chrome-web-store",
|
||||
"version": "1.0.0",
|
||||
"description": "Install and update Chrome extensions from the Chrome Web Store for Electron",
|
||||
"main": "./dist/cjs/browser/index.js",
|
||||
"module": "./dist/esm/browser/index.mjs",
|
||||
"types": "./dist/types/browser/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/browser/index.d.ts",
|
||||
"import": "./dist/esm/browser/index.mjs",
|
||||
"require": "./dist/cjs/browser/index.js"
|
||||
},
|
||||
"./preload": "./dist/chrome-web-store.preload.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn clean && tsc && node esbuild.config.js",
|
||||
"clean": "node ../../scripts/clean.js",
|
||||
"prepublish": "NODE_ENV=production yarn build"
|
||||
},
|
||||
"keywords": [
|
||||
"electron",
|
||||
"chrome",
|
||||
"web",
|
||||
"store",
|
||||
"webstore",
|
||||
"extensions"
|
||||
],
|
||||
"repository": "",
|
||||
"author": "Damon <damon.shuwen@gmail.com>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^4.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"adm-zip": "^0.5.16",
|
||||
"debug": "^4.3.7",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
}
|
||||
348
packages/electron-chrome-web-store/src/browser/api.ts
Normal file
348
packages/electron-chrome-web-store/src/browser/api.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import debug from 'debug'
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, NativeImage, Session } from 'electron'
|
||||
import { fetch } from './utils'
|
||||
|
||||
import {
|
||||
ExtensionInstallStatus,
|
||||
MV2DeprecationStatus,
|
||||
Result,
|
||||
WebGlStatus,
|
||||
} from '../common/constants'
|
||||
import { installExtension, uninstallExtension } from './installer'
|
||||
import { ExtensionId, WebStoreState } from './types'
|
||||
|
||||
const d = 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 (manifest && manifest.manifest_version < state.minimumManifestVersion) {
|
||||
return ExtensionInstallStatus.DEPRECATED_MANIFEST_VERSION
|
||||
}
|
||||
|
||||
if (state.denylist?.has(extensionId)) {
|
||||
return ExtensionInstallStatus.BLOCKED_BY_POLICY
|
||||
}
|
||||
|
||||
if (state.allowlist && !state.allowlist.has(extensionId)) {
|
||||
return ExtensionInstallStatus.BLOCKED_BY_POLICY
|
||||
}
|
||||
|
||||
const sessionExtensions = state.session.extensions || state.session
|
||||
const extensions = sessionExtensions.getAllExtensions()
|
||||
const extension = extensions.find((ext) => ext.id === extensionId)
|
||||
|
||||
if (!extension) {
|
||||
return ExtensionInstallStatus.INSTALLABLE
|
||||
}
|
||||
|
||||
if (extension.manifest.disabled) {
|
||||
return ExtensionInstallStatus.DISABLED
|
||||
}
|
||||
|
||||
return ExtensionInstallStatus.ENABLED
|
||||
}
|
||||
|
||||
interface InstallDetails {
|
||||
id: string
|
||||
manifest: string
|
||||
localizedName: string
|
||||
esbAllowlist: boolean
|
||||
iconUrl: string
|
||||
}
|
||||
|
||||
async function beginInstall(
|
||||
{ sender, senderFrame }: Electron.IpcMainInvokeEvent,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
let iconUrl: URL
|
||||
try {
|
||||
iconUrl = new URL(details.iconUrl)
|
||||
} catch {
|
||||
return { result: Result.INVALID_ICON_URL }
|
||||
}
|
||||
|
||||
let icon: NativeImage
|
||||
try {
|
||||
const response = await fetch(iconUrl.href)
|
||||
const imageBuffer = Buffer.from(await response.arrayBuffer())
|
||||
icon = nativeImage.createFromBuffer(imageBuffer)
|
||||
} catch {
|
||||
return { result: Result.ICON_ERROR }
|
||||
}
|
||||
|
||||
const browserWindow = BrowserWindow.fromWebContents(sender)
|
||||
if (!senderFrame || senderFrame.isDestroyed()) {
|
||||
return { result: Result.UNKNOWN_ERROR }
|
||||
}
|
||||
|
||||
if (state.beforeInstall) {
|
||||
const result: unknown = await state.beforeInstall({
|
||||
id: extensionId,
|
||||
localizedName: details.localizedName,
|
||||
manifest,
|
||||
icon,
|
||||
frame: senderFrame,
|
||||
browserWindow: browserWindow || undefined,
|
||||
})
|
||||
|
||||
if (typeof result !== 'object' || typeof (result as any).action !== 'string') {
|
||||
return { result: Result.UNKNOWN_ERROR }
|
||||
} else if ((result as any).action !== 'allow') {
|
||||
return { result: Result.USER_CANCELLED }
|
||||
}
|
||||
}
|
||||
|
||||
state.installing.add(extensionId)
|
||||
await installExtension(extensionId, state)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type IPCChannelHandler = (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any
|
||||
const handledIpcChannels = new Map<string, Map<Session, IPCChannelHandler>>()
|
||||
|
||||
export function registerWebStoreApi(webStoreState: WebStoreState) {
|
||||
/** Handle IPCs from the Chrome Web Store. */
|
||||
const handle = (
|
||||
channel: string,
|
||||
handle: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
|
||||
) => {
|
||||
let handlersMap = handledIpcChannels.get(channel)
|
||||
|
||||
// Handle each channel only once
|
||||
if (!handlersMap) {
|
||||
handlersMap = new Map<Session, IPCChannelHandler>()
|
||||
handledIpcChannels.set(channel, handlersMap)
|
||||
|
||||
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 session = event.sender.session
|
||||
|
||||
const handler = handlersMap?.get(session)
|
||||
if (!handler) {
|
||||
d('no handler for session %s', session.storagePath)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await handler(event, ...args)
|
||||
d('%s result', channel, result)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
// Add handler
|
||||
handlersMap.set(webStoreState.session, handle)
|
||||
}
|
||||
|
||||
handle('chromeWebstore.beginInstall', async (event, details: InstallDetails) => {
|
||||
const { senderFrame } = event
|
||||
|
||||
d('beginInstall', details)
|
||||
|
||||
const result = await beginInstall(event, webStoreState, details)
|
||||
|
||||
if (result.result === Result.SUCCESS) {
|
||||
queueMicrotask(() => {
|
||||
const sessionExtensions = webStoreState.session.extensions || webStoreState.session
|
||||
const ext = sessionExtensions.getExtension(details.id)
|
||||
if (ext && senderFrame && !senderFrame.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 webStoreState.minimumManifestVersion > 2
|
||||
? MV2DeprecationStatus.SOFT_DISABLE
|
||||
: 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 sessionExtensions = webStoreState.session.extensions || webStoreState.session
|
||||
const extensions = sessionExtensions.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(id, webStoreState)
|
||||
queueMicrotask(() => {
|
||||
event.sender.send('chrome.management.onUninstalled', id)
|
||||
})
|
||||
return Result.SUCCESS
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return Result.UNKNOWN_ERROR
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
62
packages/electron-chrome-web-store/src/browser/crx3.proto
Normal file
62
packages/electron-chrome-web-store/src/browser/crx3.proto
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright 2017 The Chromium Authors
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
syntax = "proto2";
|
||||
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
package crx_file;
|
||||
|
||||
// A CRX₃ file is a binary file of the following format:
|
||||
// [4 octets]: "Cr24", a magic number.
|
||||
// [4 octets]: The version of the *.crx file format used (currently 3).
|
||||
// [4 octets]: N, little-endian, the length of the header section.
|
||||
// [N octets]: The header (the binary encoding of a CrxFileHeader).
|
||||
// [M octets]: The ZIP archive.
|
||||
// Clients should reject CRX₃ files that contain an N that is too large for the
|
||||
// client to safely handle in memory.
|
||||
|
||||
message CrxFileHeader {
|
||||
// PSS signature with RSA public key. The public key is formatted as a
|
||||
// X.509 SubjectPublicKeyInfo block, as in CRX₂. In the common case of a
|
||||
// developer key proof, the first 128 bits of the SHA-256 hash of the
|
||||
// public key must equal the crx_id.
|
||||
repeated AsymmetricKeyProof sha256_with_rsa = 2;
|
||||
|
||||
// ECDSA signature, using the NIST P-256 curve. Public key appears in
|
||||
// named-curve format.
|
||||
// The pinned algorithm will be this, at least on 2017-01-01.
|
||||
repeated AsymmetricKeyProof sha256_with_ecdsa = 3;
|
||||
|
||||
// A verified contents file containing signatures over the archive contents.
|
||||
// The verified contents are encoded in UTF-8 and then GZIP-compressed.
|
||||
// Consult
|
||||
// https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/verified_contents.h
|
||||
// for information about the verified contents format.
|
||||
optional bytes verified_contents = 4;
|
||||
|
||||
// The binary form of a SignedData message. We do not use a nested
|
||||
// SignedData message, as handlers of this message must verify the proofs
|
||||
// on exactly these bytes, so it is convenient to parse in two steps.
|
||||
//
|
||||
// All proofs in this CrxFile message are on the value
|
||||
// "CRX3 SignedData\x00" + signed_header_size + signed_header_data +
|
||||
// archive, where "\x00" indicates an octet with value 0, "CRX3 SignedData"
|
||||
// is encoded using UTF-8, signed_header_size is the size in octets of the
|
||||
// contents of this field and is encoded using 4 octets in little-endian
|
||||
// order, signed_header_data is exactly the content of this field, and
|
||||
// archive is the remaining contents of the file following the header.
|
||||
optional bytes signed_header_data = 10000;
|
||||
}
|
||||
|
||||
message AsymmetricKeyProof {
|
||||
optional bytes public_key = 1;
|
||||
optional bytes signature = 2;
|
||||
}
|
||||
|
||||
message SignedData {
|
||||
// This is simple binary, not UTF-8 encoded mpdecimal; i.e. it is exactly
|
||||
// 16 bytes long.
|
||||
optional bytes crx_id = 1;
|
||||
}
|
||||
55
packages/electron-chrome-web-store/src/browser/crx3.ts
Normal file
55
packages/electron-chrome-web-store/src/browser/crx3.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// code generated by pbf v4.0.1
|
||||
// modified for electron-chrome-web-store
|
||||
|
||||
import Pbf from 'pbf'
|
||||
|
||||
interface AsymmetricKeyProof {
|
||||
public_key: Buffer
|
||||
signature: Buffer
|
||||
}
|
||||
|
||||
interface CrxFileHeader {
|
||||
sha256_with_rsa: AsymmetricKeyProof[]
|
||||
sha256_with_ecdsa: AsymmetricKeyProof[]
|
||||
verified_contents?: Buffer
|
||||
signed_header_data?: Buffer
|
||||
}
|
||||
|
||||
export function readCrxFileHeader(pbf: Pbf, end?: any): CrxFileHeader {
|
||||
return pbf.readFields(
|
||||
readCrxFileHeaderField,
|
||||
{
|
||||
sha256_with_rsa: [],
|
||||
sha256_with_ecdsa: [],
|
||||
verified_contents: undefined,
|
||||
signed_header_data: undefined,
|
||||
},
|
||||
end,
|
||||
)
|
||||
}
|
||||
function readCrxFileHeaderField(tag: any, obj: any, pbf: Pbf) {
|
||||
if (tag === 2) obj.sha256_with_rsa.push(readAsymmetricKeyProof(pbf, pbf.readVarint() + pbf.pos))
|
||||
else if (tag === 3)
|
||||
obj.sha256_with_ecdsa.push(readAsymmetricKeyProof(pbf, pbf.readVarint() + pbf.pos))
|
||||
else if (tag === 4) obj.verified_contents = pbf.readBytes()
|
||||
else if (tag === 10000) obj.signed_header_data = pbf.readBytes()
|
||||
}
|
||||
|
||||
export function readAsymmetricKeyProof(pbf: Pbf, end: any) {
|
||||
return pbf.readFields(
|
||||
readAsymmetricKeyProofField,
|
||||
{ public_key: undefined, signature: undefined },
|
||||
end,
|
||||
)
|
||||
}
|
||||
function readAsymmetricKeyProofField(tag: any, obj: any, pbf: Pbf) {
|
||||
if (tag === 1) obj.public_key = pbf.readBytes()
|
||||
else if (tag === 2) obj.signature = pbf.readBytes()
|
||||
}
|
||||
|
||||
export function readSignedData(pbf: Pbf, end?: any): { crx_id?: Buffer } {
|
||||
return pbf.readFields(readSignedDataField, { crx_id: undefined }, end)
|
||||
}
|
||||
function readSignedDataField(tag: any, obj: any, pbf: Pbf) {
|
||||
if (tag === 1) obj.crx_id = pbf.readBytes()
|
||||
}
|
||||
2
packages/electron-chrome-web-store/src/browser/deps.d.ts
vendored
Normal file
2
packages/electron-chrome-web-store/src/browser/deps.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module 'adm-zip'
|
||||
declare module 'debug'
|
||||
31
packages/electron-chrome-web-store/src/browser/id.ts
Normal file
31
packages/electron-chrome-web-store/src/browser/id.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Converts a normal hexadecimal string into the alphabet used by extensions.
|
||||
* We use the characters 'a'-'p' instead of '0'-'f' to avoid ever having a
|
||||
* completely numeric host, since some software interprets that as an IP address.
|
||||
*
|
||||
* @param id - The hexadecimal string to convert. This is modified in place.
|
||||
*/
|
||||
export function convertHexadecimalToIDAlphabet(id: string) {
|
||||
let result = ''
|
||||
for (const ch of id) {
|
||||
const val = parseInt(ch, 16)
|
||||
if (!isNaN(val)) {
|
||||
result += String.fromCharCode('a'.charCodeAt(0) + val)
|
||||
} else {
|
||||
result += 'a'
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function generateIdFromHash(hash: Buffer): string {
|
||||
const hashedId = hash.subarray(0, 16).toString('hex')
|
||||
return convertHexadecimalToIDAlphabet(hashedId)
|
||||
}
|
||||
|
||||
export function generateId(input: string): string {
|
||||
const hash = createHash('sha256').update(input, 'base64').digest()
|
||||
return generateIdFromHash(hash)
|
||||
}
|
||||
162
packages/electron-chrome-web-store/src/browser/index.ts
Normal file
162
packages/electron-chrome-web-store/src/browser/index.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { app, session as electronSession } from 'electron'
|
||||
import * as path from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { registerWebStoreApi } from './api'
|
||||
import { loadAllExtensions } from './loader'
|
||||
export { loadAllExtensions } from './loader'
|
||||
export { installExtension, uninstallExtension, downloadExtension } from './installer'
|
||||
import { initUpdater } from './updater'
|
||||
export { updateExtensions } from './updater'
|
||||
import { getDefaultExtensionsPath } from './utils'
|
||||
import { BeforeInstall, ExtensionId, WebStoreState } from './types'
|
||||
|
||||
function resolvePreloadPath(modulePath?: string) {
|
||||
// Attempt to resolve preload path from module exports
|
||||
try {
|
||||
return createRequire(__dirname).resolve('electron-chrome-web-store/preload')
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const preloadFilename = 'chrome-web-store.preload.js'
|
||||
|
||||
// Deprecated: use modulePath if provided
|
||||
if (modulePath) {
|
||||
process.emitWarning(
|
||||
'electron-chrome-web-store: "modulePath" is deprecated and will be removed in future versions.',
|
||||
{ type: 'DeprecationWarning' },
|
||||
)
|
||||
return path.join(modulePath, 'dist', preloadFilename)
|
||||
}
|
||||
|
||||
// Fallback to preload relative to entrypoint directory
|
||||
return path.join(__dirname, preloadFilename)
|
||||
}
|
||||
|
||||
interface ElectronChromeWebStoreOptions {
|
||||
/**
|
||||
* Session to enable the Chrome Web Store in.
|
||||
* Defaults to session.defaultSession
|
||||
*/
|
||||
session?: Electron.Session
|
||||
|
||||
/**
|
||||
* Path to the 'electron-chrome-web-store' module.
|
||||
*
|
||||
* @deprecated See "Packaging the preload script" in the readme.
|
||||
*/
|
||||
modulePath?: string
|
||||
|
||||
/**
|
||||
* Path to extensions directory.
|
||||
* Defaults to 'Extensions/' under app's userData path.
|
||||
*/
|
||||
extensionsPath?: string
|
||||
|
||||
/**
|
||||
* Load extensions installed by Chrome Web Store.
|
||||
* Defaults to true.
|
||||
*/
|
||||
loadExtensions?: boolean
|
||||
|
||||
/**
|
||||
* Whether to allow loading unpacked extensions. Only loads if
|
||||
* `loadExtensions` is also enabled.
|
||||
* Defaults to false.
|
||||
*/
|
||||
allowUnpackedExtensions?: boolean
|
||||
|
||||
/**
|
||||
* List of allowed extension IDs to install.
|
||||
*/
|
||||
allowlist?: ExtensionId[]
|
||||
|
||||
/**
|
||||
* List of denied extension IDs to install.
|
||||
*/
|
||||
denylist?: ExtensionId[]
|
||||
|
||||
/**
|
||||
* Whether extensions should auto-update.
|
||||
*/
|
||||
autoUpdate?: boolean
|
||||
|
||||
/**
|
||||
* Minimum supported version of Chrome extensions.
|
||||
* Defaults to 3.
|
||||
*/
|
||||
minimumManifestVersion?: number
|
||||
|
||||
/**
|
||||
* Called prior to installing an extension. If implemented, return a Promise
|
||||
* which resolves with `{ action: 'allow' | 'deny' }` depending on the action
|
||||
* to be taken.
|
||||
*/
|
||||
beforeInstall?: BeforeInstall
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Chrome Web Store support.
|
||||
*
|
||||
* @param options Chrome Web Store configuration options.
|
||||
*/
|
||||
export async function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {}) {
|
||||
const session = opts.session || electronSession.defaultSession
|
||||
const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
|
||||
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 minimumManifestVersion =
|
||||
typeof opts.minimumManifestVersion === 'number' ? opts.minimumManifestVersion : 3
|
||||
const beforeInstall = typeof opts.beforeInstall === 'function' ? opts.beforeInstall : undefined
|
||||
|
||||
const webStoreState: WebStoreState = {
|
||||
session,
|
||||
extensionsPath,
|
||||
installing: new Set(),
|
||||
allowlist: opts.allowlist ? new Set(opts.allowlist) : undefined,
|
||||
denylist: opts.denylist ? new Set(opts.denylist) : undefined,
|
||||
minimumManifestVersion,
|
||||
beforeInstall,
|
||||
}
|
||||
|
||||
// Add preload script to session
|
||||
const preloadPath = resolvePreloadPath(opts.modulePath)
|
||||
|
||||
if ('registerPreloadScript' in session) {
|
||||
session.registerPreloadScript({
|
||||
id: 'electron-chrome-web-store',
|
||||
type: 'frame',
|
||||
filePath: preloadPath,
|
||||
})
|
||||
} else {
|
||||
// @ts-expect-error Deprecated electron@<35
|
||||
session.setPreloads([...session.getPreloads(), preloadPath])
|
||||
}
|
||||
|
||||
if (!existsSync(preloadPath)) {
|
||||
console.error(
|
||||
new Error(
|
||||
`electron-chrome-web-store: Preload file not found at "${preloadPath}". ` +
|
||||
'See "Packaging the preload script" in the readme.',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
registerWebStoreApi(webStoreState)
|
||||
|
||||
await app.whenReady()
|
||||
|
||||
if (loadExtensions) {
|
||||
await loadAllExtensions(session, extensionsPath, { allowUnpacked: allowUnpackedExtensions })
|
||||
}
|
||||
|
||||
if (autoUpdate) {
|
||||
void initUpdater(webStoreState)
|
||||
}
|
||||
}
|
||||
276
packages/electron-chrome-web-store/src/browser/installer.ts
Normal file
276
packages/electron-chrome-web-store/src/browser/installer.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { session as electronSession } from 'electron'
|
||||
|
||||
import AdmZip from 'adm-zip'
|
||||
import debug from 'debug'
|
||||
import Pbf from 'pbf'
|
||||
|
||||
import { readCrxFileHeader, readSignedData } from './crx3'
|
||||
import { convertHexadecimalToIDAlphabet, generateId } from './id'
|
||||
import { fetch, getChromeVersion, getDefaultExtensionsPath } from './utils'
|
||||
import { findExtensionInstall } from './loader'
|
||||
import { ExtensionId } from './types'
|
||||
|
||||
const d = debug('electron-chrome-web-store:installer')
|
||||
|
||||
function getExtensionCrxURL(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 {
|
||||
extensionId: string
|
||||
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)
|
||||
|
||||
let extensionId: string
|
||||
let publicKey: Buffer
|
||||
|
||||
// For CRX2 format
|
||||
if (version === 2) {
|
||||
const pubKeyLength = buffer.readUInt32LE(8)
|
||||
const sigLength = buffer.readUInt32LE(12)
|
||||
publicKey = buffer.subarray(16, 16 + pubKeyLength)
|
||||
extensionId = generateId(publicKey.toString('base64'))
|
||||
} 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')
|
||||
}
|
||||
|
||||
extensionId = declaredCrxId
|
||||
publicKey = keyProof.public_key
|
||||
}
|
||||
|
||||
return {
|
||||
extensionId,
|
||||
version,
|
||||
header,
|
||||
contents,
|
||||
publicKey,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract CRX contents and update manifest
|
||||
async function unpackCrx(crx: CrxInfo, destPath: string): Promise<chrome.runtime.Manifest> {
|
||||
// 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 manifest = JSON.parse(manifestContent) as chrome.runtime.Manifest
|
||||
|
||||
// Add public key to manifest
|
||||
manifest.key = crx.publicKey.toString('base64')
|
||||
|
||||
// Write updated manifest back
|
||||
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function readCrx(crxPath: string) {
|
||||
const crxBuffer = await fs.promises.readFile(crxPath)
|
||||
return parseCrx(crxBuffer)
|
||||
}
|
||||
|
||||
async function downloadCrx(url: string, dest: string) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download extension')
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(dest)
|
||||
const downloadStream = Readable.fromWeb(response.body as any)
|
||||
await pipeline(downloadStream, fileStream)
|
||||
}
|
||||
|
||||
export async function downloadExtensionFromURL(
|
||||
url: string,
|
||||
extensionsDir: string,
|
||||
expectedExtensionId?: string,
|
||||
): Promise<string> {
|
||||
d('downloading %s', url)
|
||||
|
||||
const installUuid = crypto.randomUUID()
|
||||
const crxPath = path.join(os.tmpdir(), `electron-cws-download_${installUuid}.crx`)
|
||||
try {
|
||||
await downloadCrx(url, crxPath)
|
||||
|
||||
const crx = await readCrx(crxPath)
|
||||
|
||||
if (expectedExtensionId && expectedExtensionId !== crx.extensionId) {
|
||||
throw new Error(
|
||||
`CRX mismatches expected extension ID: ${expectedExtensionId} !== ${crx.extensionId}`,
|
||||
)
|
||||
}
|
||||
|
||||
const unpackedPath = path.join(extensionsDir, crx.extensionId, installUuid)
|
||||
await fs.promises.mkdir(unpackedPath, { recursive: true })
|
||||
const manifest = await unpackCrx(crx, unpackedPath)
|
||||
|
||||
if (!manifest.version) {
|
||||
throw new Error('Installed extension is missing manifest version')
|
||||
}
|
||||
|
||||
const versionedPath = path.join(extensionsDir, crx.extensionId, `${manifest.version}_0`)
|
||||
await fs.promises.rename(unpackedPath, versionedPath)
|
||||
|
||||
return versionedPath
|
||||
} finally {
|
||||
await fs.promises.rm(crxPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and unpack extension to the given extensions directory.
|
||||
*/
|
||||
export async function downloadExtension(
|
||||
extensionId: string,
|
||||
extensionsDir: string,
|
||||
): Promise<string> {
|
||||
const url = getExtensionCrxURL(extensionId)
|
||||
return await downloadExtensionFromURL(url, extensionsDir, extensionId)
|
||||
}
|
||||
|
||||
interface CommonExtensionOptions {
|
||||
/** Session to load extensions into. */
|
||||
session?: Electron.Session
|
||||
|
||||
/**
|
||||
* Directory where web store extensions will be installed.
|
||||
* Defaults to `Extensions` under the app's `userData` directory.
|
||||
*/
|
||||
extensionsPath?: string
|
||||
}
|
||||
|
||||
interface InstallExtensionOptions extends CommonExtensionOptions {
|
||||
/** Options for loading the extension. */
|
||||
loadExtensionOptions?: Electron.LoadExtensionOptions
|
||||
}
|
||||
|
||||
interface UninstallExtensionOptions extends CommonExtensionOptions {}
|
||||
|
||||
/**
|
||||
* Install extension from the web store.
|
||||
*/
|
||||
export async function installExtension(
|
||||
extensionId: string,
|
||||
opts: InstallExtensionOptions = {},
|
||||
): Promise<Electron.Extension> {
|
||||
d('installing %s', extensionId)
|
||||
|
||||
const session = opts.session || electronSession.defaultSession
|
||||
const sessionExtensions = session.extensions || session
|
||||
const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
|
||||
|
||||
// Check if already loaded
|
||||
const existingExtension = sessionExtensions.getExtension(extensionId)
|
||||
if (existingExtension) {
|
||||
d('%s already loaded', extensionId)
|
||||
return existingExtension
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
const existingExtensionInfo = await findExtensionInstall(extensionId, extensionsPath)
|
||||
if (existingExtensionInfo && existingExtensionInfo.type === 'store') {
|
||||
d('%s already installed', extensionId)
|
||||
return await sessionExtensions.loadExtension(
|
||||
existingExtensionInfo.path,
|
||||
opts.loadExtensionOptions,
|
||||
)
|
||||
}
|
||||
|
||||
// Download and load new extension
|
||||
const extensionPath = await downloadExtension(extensionId, extensionsPath)
|
||||
const extension = await sessionExtensions.loadExtension(extensionPath, opts.loadExtensionOptions)
|
||||
d('installed %s', extensionId)
|
||||
|
||||
return extension
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall extension from the web store.
|
||||
*/
|
||||
export async function uninstallExtension(
|
||||
extensionId: string,
|
||||
opts: UninstallExtensionOptions = {},
|
||||
) {
|
||||
d('uninstalling %s', extensionId)
|
||||
|
||||
const session = opts.session || electronSession.defaultSession
|
||||
const sessionExtensions = session.extensions || session
|
||||
const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
|
||||
|
||||
const extensions = sessionExtensions.getAllExtensions()
|
||||
const existingExt = extensions.find((ext) => ext.id === extensionId)
|
||||
if (existingExt) {
|
||||
sessionExtensions.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') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
172
packages/electron-chrome-web-store/src/browser/loader.ts
Normal file
172
packages/electron-chrome-web-store/src/browser/loader.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import debug from 'debug'
|
||||
|
||||
import { generateId } from './id'
|
||||
import { compareVersions } from './utils'
|
||||
import { ExtensionId } from './types'
|
||||
|
||||
const d = debug('electron-chrome-web-store:loader')
|
||||
|
||||
type ExtensionPathBaseInfo = { manifest: chrome.runtime.Manifest; path: string }
|
||||
type ExtensionPathInfo =
|
||||
| ({ type: 'store'; id: string } & ExtensionPathBaseInfo)
|
||||
| ({ type: 'unpacked' } & ExtensionPathBaseInfo)
|
||||
|
||||
const manifestExists = async (dirPath: string) => {
|
||||
if (!dirPath) return false
|
||||
const manifestPath = path.join(dirPath, 'manifest.json')
|
||||
try {
|
||||
return (await fs.promises.stat(manifestPath)).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DFS directories for extension manifests.
|
||||
*/
|
||||
async function extensionSearch(dirPath: string, depth: number = 0): Promise<string[]> {
|
||||
if (depth >= 2) return []
|
||||
const results = []
|
||||
const dirEntries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (await manifestExists(path.join(dirPath, entry.name))) {
|
||||
results.push(path.join(dirPath, entry.name))
|
||||
} else {
|
||||
results.push(...(await extensionSearch(path.join(dirPath, entry.name), depth + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover list of extensions in the given path.
|
||||
*/
|
||||
async function discoverExtensions(extensionsPath: string): Promise<ExtensionPathInfo[]> {
|
||||
try {
|
||||
const stat = await fs.promises.stat(extensionsPath)
|
||||
if (!stat.isDirectory()) {
|
||||
d('%s is not a directory', extensionsPath)
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
d('%s does not exist', extensionsPath)
|
||||
return []
|
||||
}
|
||||
|
||||
const extensionDirectories = await extensionSearch(extensionsPath)
|
||||
const results: ExtensionPathInfo[] = []
|
||||
|
||||
for (const extPath of extensionDirectories.filter(Boolean)) {
|
||||
try {
|
||||
const manifestPath = path.join(extPath!, 'manifest.json')
|
||||
const manifestJson = (await fs.promises.readFile(manifestPath)).toString()
|
||||
const manifest: chrome.runtime.Manifest = JSON.parse(manifestJson)
|
||||
const result = manifest.key
|
||||
? {
|
||||
type: 'store' as const,
|
||||
path: extPath!,
|
||||
manifest,
|
||||
id: generateId(manifest.key),
|
||||
}
|
||||
: {
|
||||
type: 'unpacked' as const,
|
||||
path: extPath!,
|
||||
manifest,
|
||||
}
|
||||
results.push(result)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter any outdated extensions in the case of duplicate installations.
|
||||
*/
|
||||
function filterOutdatedExtensions(extensions: ExtensionPathInfo[]): ExtensionPathInfo[] {
|
||||
const uniqueExtensions: ExtensionPathInfo[] = []
|
||||
const storeExtMap = new Map<ExtensionId, ExtensionPathInfo>()
|
||||
|
||||
for (const ext of extensions) {
|
||||
if (ext.type === 'unpacked') {
|
||||
// Unpacked extensions are always unique to their path
|
||||
uniqueExtensions.push(ext)
|
||||
} else if (!storeExtMap.has(ext.id)) {
|
||||
// New store extension
|
||||
storeExtMap.set(ext.id, ext)
|
||||
} else {
|
||||
// Existing store extension, compare with existing version
|
||||
const latestExt = storeExtMap.get(ext.id)!
|
||||
if (compareVersions(latestExt.manifest.version, ext.manifest.version) < 0) {
|
||||
storeExtMap.set(ext.id, ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append up to date store extensions
|
||||
storeExtMap.forEach((ext) => uniqueExtensions.push(ext))
|
||||
|
||||
return uniqueExtensions
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all extensions from the given directory.
|
||||
*/
|
||||
export async function loadAllExtensions(
|
||||
session: Electron.Session,
|
||||
extensionsPath: string,
|
||||
options: {
|
||||
allowUnpacked?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const sessionExtensions = session.extensions || session
|
||||
|
||||
let extensions = await discoverExtensions(extensionsPath)
|
||||
extensions = filterOutdatedExtensions(extensions)
|
||||
d('discovered %d extension(s) in %s', extensions.length, extensionsPath)
|
||||
|
||||
for (const ext of extensions) {
|
||||
try {
|
||||
let extension: Electron.Extension | undefined
|
||||
if (ext.type === 'store') {
|
||||
const existingExt = sessionExtensions.getExtension(ext.id)
|
||||
if (existingExt) {
|
||||
d('skipping loading existing extension %s', ext.id)
|
||||
continue
|
||||
}
|
||||
d('loading extension %s', `${ext.id}@${ext.manifest.version}`)
|
||||
extension = await sessionExtensions.loadExtension(ext.path)
|
||||
} else if (options.allowUnpacked) {
|
||||
d('loading unpacked extension %s', ext.path)
|
||||
extension = await sessionExtensions.loadExtension(ext.path)
|
||||
}
|
||||
|
||||
if (
|
||||
extension &&
|
||||
extension.manifest.manifest_version === 3 &&
|
||||
extension.manifest.background?.service_worker
|
||||
) {
|
||||
const scope = `chrome-extension://${extension.id}`
|
||||
await session.serviceWorkers.startWorkerForScope(scope).catch(() => {
|
||||
console.error(`Failed to start worker for extension ${extension.id}`)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load extension from ${ext.path}`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function findExtensionInstall(extensionId: string, extensionsPath: string) {
|
||||
const extensionPath = path.join(extensionsPath, extensionId)
|
||||
let extensions = await discoverExtensions(extensionPath)
|
||||
extensions = filterOutdatedExtensions(extensions)
|
||||
return extensions.length > 0 ? extensions[0] : null
|
||||
}
|
||||
24
packages/electron-chrome-web-store/src/browser/types.ts
Normal file
24
packages/electron-chrome-web-store/src/browser/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type ExtensionId = Electron.Extension['id']
|
||||
|
||||
export interface ExtensionInstallDetails {
|
||||
id: string
|
||||
localizedName: string
|
||||
manifest: chrome.runtime.Manifest
|
||||
icon: Electron.NativeImage
|
||||
browserWindow?: Electron.BrowserWindow
|
||||
frame: Electron.WebFrameMain
|
||||
}
|
||||
|
||||
export type BeforeInstall = (
|
||||
details: ExtensionInstallDetails,
|
||||
) => Promise<{ action: 'allow' | 'deny' }>
|
||||
|
||||
export interface WebStoreState {
|
||||
session: Electron.Session
|
||||
extensionsPath: string
|
||||
installing: Set<ExtensionId>
|
||||
allowlist?: Set<ExtensionId>
|
||||
denylist?: Set<ExtensionId>
|
||||
minimumManifestVersion: number
|
||||
beforeInstall?: BeforeInstall
|
||||
}
|
||||
327
packages/electron-chrome-web-store/src/browser/updater.ts
Normal file
327
packages/electron-chrome-web-store/src/browser/updater.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import debug from 'debug'
|
||||
import { app, powerMonitor, session as electronSession } from 'electron'
|
||||
|
||||
import { compareVersions, fetch, getChromeVersion } from './utils'
|
||||
import { downloadExtensionFromURL } from './installer'
|
||||
import { WebStoreState } from './types'
|
||||
|
||||
const d = 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 = (): string => {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return 'win'
|
||||
case 'darwin':
|
||||
return 'mac'
|
||||
default:
|
||||
return process.platform
|
||||
}
|
||||
}
|
||||
|
||||
const getOmahaArch = (): string => {
|
||||
switch (process.arch) {
|
||||
case 'ia32':
|
||||
return 'x86'
|
||||
case 'x64':
|
||||
return 'x64'
|
||||
default:
|
||||
return process.arch
|
||||
}
|
||||
}
|
||||
|
||||
function filterWebStoreExtension(extension: Electron.Extension) {
|
||||
const manifest = extension.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)
|
||||
}
|
||||
|
||||
async function fetchAvailableUpdates(extensions: Electron.Extension[]): Promise<ExtensionUpdate[]> {
|
||||
if (extensions.length === 0) return []
|
||||
|
||||
const extensionIds = extensions.map((extension) => extension.id)
|
||||
const extensionMap: Record<string, Electron.Extension> = extensions.reduce(
|
||||
(map, ext) => ({
|
||||
...map,
|
||||
[ext.id]: ext,
|
||||
}),
|
||||
{},
|
||||
)
|
||||
|
||||
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 {
|
||||
const apps = result?.response?.app || []
|
||||
updates = apps
|
||||
// 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(session: Electron.Session, update: ExtensionUpdate) {
|
||||
const sessionExtensions = session.extensions || session
|
||||
const extensionId = update.id
|
||||
const oldExtension = update.extension
|
||||
d('updating %s %s -> %s', extensionId, oldExtension.version, update.version)
|
||||
|
||||
// Updates must be installed in adjacent directories. Ensure the old install
|
||||
// was contained in a versioned directory structure.
|
||||
const oldVersionDirectoryName = path.basename(oldExtension.path)
|
||||
if (!oldVersionDirectoryName.startsWith(oldExtension.version)) {
|
||||
console.error(
|
||||
`updateExtension: extension ${extensionId} must conform to versioned directory names`,
|
||||
{
|
||||
oldPath: oldExtension.path,
|
||||
},
|
||||
)
|
||||
d('skipping %s update due to invalid install path %s', extensionId, oldExtension.path)
|
||||
return
|
||||
}
|
||||
|
||||
// Download update
|
||||
const extensionsPath = path.join(oldExtension.path, '..', '..')
|
||||
const updatePath = await downloadExtensionFromURL(update.url, extensionsPath, extensionId)
|
||||
d('downloaded update %s@%s', extensionId, update.version)
|
||||
|
||||
// Reload extension if already loaded
|
||||
if (sessionExtensions.getExtension(extensionId)) {
|
||||
sessionExtensions.removeExtension(extensionId)
|
||||
await sessionExtensions.loadExtension(updatePath)
|
||||
d('loaded update %s@%s', extensionId, update.version)
|
||||
}
|
||||
|
||||
// Remove old version
|
||||
await fs.promises.rm(oldExtension.path, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function checkForUpdates(session: Electron.Session) {
|
||||
// Only check for extensions from the store
|
||||
const sessionExtensions = session.extensions || session
|
||||
const extensions = sessionExtensions.getAllExtensions().filter(filterWebStoreExtension)
|
||||
d('checking for updates: %s', extensions.map((ext) => `${ext.id}@${ext.version}`).join(','))
|
||||
|
||||
const updates = await fetchAvailableUpdates(extensions)
|
||||
if (!updates || updates.length === 0) {
|
||||
d('no updates found')
|
||||
return []
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
async function installUpdates(session: Electron.Session, updates: ExtensionUpdate[]) {
|
||||
d('updating %d extension(s)', updates.length)
|
||||
for (const update of updates) {
|
||||
try {
|
||||
await updateExtension(session, update)
|
||||
} catch (error) {
|
||||
console.error(`checkForUpdates: Error updating extension ${update.id}`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check session's loaded extensions for updates and install any if available.
|
||||
*/
|
||||
export async function updateExtensions(
|
||||
session: Electron.Session = electronSession.defaultSession,
|
||||
): Promise<void> {
|
||||
const updates = await checkForUpdates(session)
|
||||
if (updates.length > 0) {
|
||||
await installUpdates(session, updates)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
void updateExtensions(session)
|
||||
}
|
||||
|
||||
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':
|
||||
case 'linux':
|
||||
app.on('browser-window-focus', check)
|
||||
break
|
||||
}
|
||||
|
||||
const updateIntervalId = setInterval(check, UPDATE_CHECK_INTERVAL)
|
||||
check()
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
queueMicrotask(() => {
|
||||
if (!event.defaultPrevented) {
|
||||
d('stopping update checks')
|
||||
clearInterval(updateIntervalId)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
28
packages/electron-chrome-web-store/src/browser/utils.ts
Normal file
28
packages/electron-chrome-web-store/src/browser/utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as path from 'node:path'
|
||||
import { app, net } from 'electron'
|
||||
|
||||
// Include fallbacks for node environments that aren't Electron
|
||||
export const fetch =
|
||||
// Prefer Node's fetch until net.fetch crash is fixed
|
||||
// https://github.com/electron/electron/pull/45050
|
||||
globalThis.fetch ||
|
||||
net?.fetch ||
|
||||
(() => {
|
||||
throw new Error(
|
||||
'electron-chrome-web-store: Missing fetch API. Please upgrade Electron or Node.',
|
||||
)
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
export const getDefaultExtensionsPath = () => path.join(app.getPath('userData'), 'Extensions')
|
||||
47
packages/electron-chrome-web-store/src/common/constants.ts
Normal file
47
packages/electron-chrome-web-store/src/common/constants.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export 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',
|
||||
}
|
||||
|
||||
export const MV2DeprecationStatus = {
|
||||
INACTIVE: 'inactive',
|
||||
SOFT_DISABLE: 'soft_disable',
|
||||
WARNING: 'warning',
|
||||
}
|
||||
|
||||
export 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',
|
||||
}
|
||||
|
||||
export const WebGlStatus = {
|
||||
WEBGL_ALLOWED: 'webgl_allowed',
|
||||
WEBGL_BLOCKED: 'webgl_blocked',
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { contextBridge, ipcRenderer, webFrame } from 'electron'
|
||||
import {
|
||||
ExtensionInstallStatus,
|
||||
MV2DeprecationStatus,
|
||||
Result,
|
||||
WebGlStatus,
|
||||
} from '../common/constants'
|
||||
|
||||
interface WebstorePrivate {
|
||||
ExtensionInstallStatus: typeof ExtensionInstallStatus
|
||||
MV2DeprecationStatus: typeof MV2DeprecationStatus
|
||||
Result: typeof Result
|
||||
WebGlStatus: typeof WebGlStatus
|
||||
|
||||
beginInstallWithManifest3: (
|
||||
details: unknown,
|
||||
callback?: (result: string) => void,
|
||||
) => Promise<string>
|
||||
completeInstall: (id: string, callback?: (result: string) => void) => Promise<string>
|
||||
enableAppLauncher: (enable: boolean, callback?: (result: boolean) => void) => Promise<boolean>
|
||||
getBrowserLogin: (callback?: (result: string) => void) => Promise<string>
|
||||
getExtensionStatus: (
|
||||
id: string,
|
||||
manifestJson: string,
|
||||
callback?: (status: string) => void,
|
||||
) => Promise<string>
|
||||
getFullChromeVersion: (callback?: (result: string) => void) => Promise<{
|
||||
version_number: string
|
||||
app_name: string
|
||||
}>
|
||||
getIsLauncherEnabled: (callback?: (result: boolean) => void) => Promise<boolean>
|
||||
getMV2DeprecationStatus: (callback?: (result: string) => void) => Promise<string>
|
||||
getReferrerChain: (callback?: (result: unknown[]) => void) => Promise<unknown[]>
|
||||
getStoreLogin: (callback?: (result: string) => void) => Promise<string>
|
||||
getWebGLStatus: (callback?: (result: string) => void) => Promise<string>
|
||||
install: (
|
||||
id: string,
|
||||
silentInstall: boolean,
|
||||
callback?: (result: string) => void,
|
||||
) => Promise<string>
|
||||
isInIncognitoMode: (callback?: (result: boolean) => void) => Promise<boolean>
|
||||
isPendingCustodianApproval: (id: string, callback?: (result: boolean) => void) => Promise<boolean>
|
||||
setStoreLogin: (login: string, callback?: (result: boolean) => void) => Promise<boolean>
|
||||
}
|
||||
|
||||
function updateBranding(appName: string) {
|
||||
const update = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const chromeButtons = Array.from(document.querySelectorAll('span')).filter((node) =>
|
||||
node.innerText.includes('Chrome'),
|
||||
)
|
||||
|
||||
for (const button of chromeButtons) {
|
||||
button.innerText = button.innerText.replace('Chrome', appName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Try twice to ensure branding changes
|
||||
update()
|
||||
setTimeout(update, 1000 / 60)
|
||||
}
|
||||
|
||||
function getUAProductVersion(userAgent: string, product: string) {
|
||||
const regex = new RegExp(`${product}/([\\d.]+)`)
|
||||
return userAgent.match(regex)?.[1]
|
||||
}
|
||||
|
||||
function overrideUserAgent() {
|
||||
const chromeVersion = getUAProductVersion(navigator.userAgent, 'Chrome') || '133.0.6920.0'
|
||||
const userAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
|
||||
webFrame.executeJavaScript(
|
||||
`(${function (userAgent: string) {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: userAgent })
|
||||
}})(${JSON.stringify(userAgent)});`,
|
||||
)
|
||||
}
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development'
|
||||
|
||||
function log(...args: any[]) {
|
||||
if (!DEBUG) return
|
||||
console.debug(...args)
|
||||
}
|
||||
|
||||
function setupChromeWebStoreApi() {
|
||||
let appName: string | undefined
|
||||
|
||||
const setAppName = (name: string) => {
|
||||
appName = name
|
||||
updateBranding(appName)
|
||||
}
|
||||
|
||||
const maybeUpdateBranding = () => {
|
||||
if (appName) updateBranding(appName)
|
||||
}
|
||||
|
||||
const setExtensionError = (message?: string) => {
|
||||
webFrame.executeJavaScript(`
|
||||
if (typeof chrome !== 'undefined') {
|
||||
if (!chrome.extension) chrome.extension = {};
|
||||
chrome.extension.lastError = ${JSON.stringify(message ? { message } : null)};
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of Chrome's webstorePrivate for Electron.
|
||||
*/
|
||||
const electronWebstore: WebstorePrivate = {
|
||||
ExtensionInstallStatus,
|
||||
MV2DeprecationStatus,
|
||||
Result,
|
||||
WebGlStatus,
|
||||
|
||||
beginInstallWithManifest3: async (details, callback) => {
|
||||
log('webstorePrivate.beginInstallWithManifest3', details)
|
||||
const { result, message } = await ipcRenderer.invoke('chromeWebstore.beginInstall', details)
|
||||
log('webstorePrivate.beginInstallWithManifest3 result:', result)
|
||||
setExtensionError(result === Result.SUCCESS ? null : message)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
completeInstall: async (id, callback) => {
|
||||
log('webstorePrivate.completeInstall', id)
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.completeInstall', id)
|
||||
log('webstorePrivate.completeInstall result:', result)
|
||||
if (callback) callback(result)
|
||||
maybeUpdateBranding()
|
||||
return result
|
||||
},
|
||||
|
||||
enableAppLauncher: async (enable, callback) => {
|
||||
log('webstorePrivate.enableAppLauncher', enable)
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.enableAppLauncher', enable)
|
||||
log('webstorePrivate.enableAppLauncher result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
getBrowserLogin: async (callback) => {
|
||||
log('webstorePrivate.getBrowserLogin called')
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.getBrowserLogin')
|
||||
log('webstorePrivate.getBrowserLogin result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
getExtensionStatus: async (id, manifestJson, callback) => {
|
||||
log('webstorePrivate.getExtensionStatus', id, { id, manifestJson, callback })
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.getExtensionStatus', id, manifestJson)
|
||||
log('webstorePrivate.getExtensionStatus result:', id, result)
|
||||
if (callback) callback(result)
|
||||
maybeUpdateBranding()
|
||||
return result
|
||||
},
|
||||
|
||||
getFullChromeVersion: async (callback) => {
|
||||
log('webstorePrivate.getFullChromeVersion called')
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.getFullChromeVersion')
|
||||
log('webstorePrivate.getFullChromeVersion result:', result)
|
||||
|
||||
if (result.app_name) {
|
||||
setAppName(result.app_name)
|
||||
delete result.app_name
|
||||
}
|
||||
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
getIsLauncherEnabled: async (callback) => {
|
||||
log('webstorePrivate.getIsLauncherEnabled called')
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.getIsLauncherEnabled')
|
||||
log('webstorePrivate.getIsLauncherEnabled result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
getMV2DeprecationStatus: async (callback) => {
|
||||
log('webstorePrivate.getMV2DeprecationStatus called')
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.getMV2DeprecationStatus')
|
||||
log('webstorePrivate.getMV2DeprecationStatus result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
getReferrerChain: async (callback) => {
|
||||
log('webstorePrivate.getReferrerChain called')
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.getReferrerChain')
|
||||
log('webstorePrivate.getReferrerChain result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
getStoreLogin: async (callback) => {
|
||||
log('webstorePrivate.getStoreLogin called')
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.getStoreLogin')
|
||||
log('webstorePrivate.getStoreLogin result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
getWebGLStatus: async (callback) => {
|
||||
log('webstorePrivate.getWebGLStatus called')
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.getWebGLStatus')
|
||||
log('webstorePrivate.getWebGLStatus result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
install: async (id, silentInstall, callback) => {
|
||||
log('webstorePrivate.install', { id, silentInstall })
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.install', id, silentInstall)
|
||||
log('webstorePrivate.install result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
isInIncognitoMode: async (callback) => {
|
||||
log('webstorePrivate.isInIncognitoMode called')
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.isInIncognitoMode')
|
||||
log('webstorePrivate.isInIncognitoMode result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
isPendingCustodianApproval: async (id, callback) => {
|
||||
log('webstorePrivate.isPendingCustodianApproval', id)
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.isPendingCustodianApproval', id)
|
||||
log('webstorePrivate.isPendingCustodianApproval result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
|
||||
setStoreLogin: async (login, callback) => {
|
||||
log('webstorePrivate.setStoreLogin', login)
|
||||
const result = await ipcRenderer.invoke('chromeWebstore.setStoreLogin', login)
|
||||
log('webstorePrivate.setStoreLogin result:', result)
|
||||
if (callback) callback(result)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
// Expose webstorePrivate API
|
||||
contextBridge.exposeInMainWorld('electronWebstore', electronWebstore)
|
||||
|
||||
// Expose chrome.runtime and chrome.management APIs
|
||||
const runtime = {
|
||||
lastError: null,
|
||||
getManifest: async () => {
|
||||
log('chrome.runtime.getManifest called')
|
||||
return {}
|
||||
},
|
||||
}
|
||||
contextBridge.exposeInMainWorld('electronRuntime', runtime)
|
||||
|
||||
const management = {
|
||||
onInstalled: {
|
||||
addListener: (callback: () => void) => {
|
||||
log('chrome.management.onInstalled.addListener called')
|
||||
ipcRenderer.on('chrome.management.onInstalled', callback)
|
||||
},
|
||||
removeListener: (callback: () => void) => {
|
||||
log('chrome.management.onInstalled.removeListener called')
|
||||
ipcRenderer.removeListener('chrome.management.onInstalled', callback)
|
||||
},
|
||||
},
|
||||
onUninstalled: {
|
||||
addListener: (callback: () => void) => {
|
||||
log('chrome.management.onUninstalled.addListener called')
|
||||
ipcRenderer.on('chrome.management.onUninstalled', callback)
|
||||
},
|
||||
removeListener: (callback: () => void) => {
|
||||
log('chrome.management.onUninstalled.removeListener called')
|
||||
ipcRenderer.removeListener('chrome.management.onUninstalled', callback)
|
||||
},
|
||||
},
|
||||
getAll: (callback: (extensions: any[]) => void) => {
|
||||
log('chrome.management.getAll called')
|
||||
ipcRenderer.invoke('chrome.management.getAll').then((result) => {
|
||||
log('chrome.management.getAll result:', result)
|
||||
callback(result)
|
||||
})
|
||||
},
|
||||
setEnabled: async (id: string, enabled: boolean) => {
|
||||
log('chrome.management.setEnabled', { id, enabled })
|
||||
const result = await ipcRenderer.invoke('chrome.management.setEnabled', id, enabled)
|
||||
log('chrome.management.setEnabled result:', result)
|
||||
return result
|
||||
},
|
||||
uninstall: (id: string, options: { showConfirmDialog: boolean }, callback?: () => void) => {
|
||||
log('chrome.management.uninstall', { id, options })
|
||||
ipcRenderer.invoke('chrome.management.uninstall', id, options).then((result) => {
|
||||
log('chrome.management.uninstall result:', result)
|
||||
if (callback) callback()
|
||||
})
|
||||
},
|
||||
}
|
||||
contextBridge.exposeInMainWorld('electronManagement', management)
|
||||
|
||||
webFrame.executeJavaScript(`
|
||||
(function () {
|
||||
chrome.webstorePrivate = globalThis.electronWebstore;
|
||||
Object.assign(chrome.runtime, electronRuntime);
|
||||
Object.assign(chrome.management, electronManagement);
|
||||
void 0;
|
||||
}());
|
||||
`)
|
||||
|
||||
// Fetch app name
|
||||
electronWebstore.getFullChromeVersion()
|
||||
|
||||
// Replace branding
|
||||
overrideUserAgent()
|
||||
process.once('document-start', maybeUpdateBranding)
|
||||
if ('navigation' in window) {
|
||||
;(window.navigation as any).addEventListener('navigate', maybeUpdateBranding)
|
||||
}
|
||||
}
|
||||
|
||||
if (location.href.startsWith('https://chromewebstore.google.com')) {
|
||||
log('Injecting Chrome Web Store API')
|
||||
setupChromeWebStoreApi()
|
||||
}
|
||||
13
packages/electron-chrome-web-store/tsconfig.json
Normal file
13
packages/electron-chrome-web-store/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist/types",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user