Electron Incremental Update
This project is built on top of vite-plugin-electron, offers a lightweight update solution for Electron applications without using native executables.
Key Features
The solution includes a Vite plugin, a startup entry function, an Updater
class, and a set of utilities for Electron.
It use 2 asar file structure for updates:
app.asar
: The application entry, loads the ${electron.app.name}.asar
and initializes the updater on startup
${electron.app.name}.asar
: The package that contains main / preload / renderer process code
Update Steps
- Check update from remote server
- If update available, download the update asar, verify by presigned RSA + Signature and write to disk
- Quit and restart the app
- Replace the old
${electron.app.name}.asar
on startup and load the new one
Other Features
- Update size reduction: All native modules should be packaged into
app.asar
to reduce ${electron.app.name}.asar
file size, see usage
- Bytecode protection: Use V8 cache to protect source code, see details
Getting Started
Install
npm install -D electron-incremental-update
yarn add -D electron-incremental-update
pnpm add -D electron-incremental-update
Project Structure
Base on electron-vite-vue
electron
├── entry.ts // <- entry file
├── main
│ └── index.ts
├── preload
│ └── index.ts
└── native // <- possible native modules
└── index.ts
src
└── ...
Setup Entry
The entry is used to load the application and initialize the Updater
Updater
use the provider
to check and download the update. The built-in GithubProvider
is based on BaseProvider
, which implements the IProvider
interface (see types). And the provider
is optional, you can setup later
in electron/entry.ts
import { createElectronApp } from 'electron-incremental-update'
import { GitHubProvider } from 'electron-incremental-update/provider'
createElectronApp({
updater: {
provider: new GitHubProvider({
username: 'yourname',
repo: 'electron',
}),
},
beforeStart(mainFilePath, logger) {
logger?.debug(mainFilePath)
},
})
Setup vite.config.ts
The plugin config, main
and preload
parts are reference from electron-vite-vue
- certificate will read from
process.env.UPDATER_CERT
first, if absend, read config
- privatekey will read from
process.env.UPDATER_PK
first, if absend, read config
See all config in types
in vite.config.mts
import { debugStartup, electronWithUpdater } from 'electron-incremental-update/vite'
import { defineConfig } from 'vite'
export default defineConfig(async ({ command }) => {
const isBuild = command === 'build'
return {
plugins: [
electronWithUpdater({
isBuild,
logParsedOptions: true,
main: {
files: ['./electron/main/index.ts', './electron/main/worker.ts'],
onstart: debugStartup,
},
preload: {
files: './electron/preload/index.ts',
},
updater: {
}
}),
],
server: process.env.VSCODE_DEBUG && (() => {
const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL)
return {
host: url.hostname,
port: +url.port,
}
})(),
}
})
Modify package.json
{
"main": "dist-entry/entry.js"
}
Config electron-builder
const { name } = require('./package.json')
const targetFile = `${name}.asar`
module.exports = {
appId: 'YourAppID',
productName: name,
files: [
'dist-entry',
],
npmRebuild: false,
asarUnpack: [
'**/*.{node,dll,dylib,so}',
],
directories: {
output: 'release',
},
extraResources: [
{ from: `release/${targetFile}`, to: targetFile },
],
publish: null,
}
Usage
Use In Main Process
In most cases, you should also setup the UpdateProvider
before updating, unless you setup params when calling checkUpdate
or downloadUpdate
.
The update steps are similar to electron-updater and have same methods and events on Updater
NOTE: There should only one function and should be default export in the main index file
in electron/main/index.ts
import { app } from 'electron'
import { startupWithUpdater, UpdaterError } from 'electron-incremental-update'
import { getPathFromAppNameAsar, getVersions } from 'electron-incremental-update/utils'
export default startupWithUpdater((updater) => {
await app.whenReady()
console.table({
[`${app.name}.asar path:`]: getPathFromAppNameAsar(),
'app version:': getAppVersion(),
'entry (installer) version:': getEntryVersion(),
'electron version:': process.versions.electron,
})
updater.onDownloading = ({ percent }) => {
console.log(percent)
}
updater.on('update-available', async ({ version }) => {
const { response } = await dialog.showMessageBox({
type: 'info',
buttons: ['Download', 'Later'],
message: `v${version} update available!`,
})
if (response !== 0) {
return
}
await updater.downloadUpdate()
})
updater.on('update-not-available', (code, reason, info) => console.log(code, reason, info))
updater.on('download-progress', (data) => {
console.log(data)
main.send(BrowserWindow.getAllWindows()[0], 'msg', data)
})
updater.on('update-downloaded', () => {
updater.quitAndInstall()
})
updater.checkForUpdates()
})
Dynamicly setup UpdateProvider
updater.provider = new GitHubProvider({
user: 'yourname',
repo: 'electron',
urlHandler: (url) => {
url.hostname = 'mirror.ghproxy.com'
url.pathname = `https://github.com${url.pathname}`
return url
}
})
Custom logger
updater.logger = console
Setup Beta Channel
updater.receiveBeta = true
Use Native Modules
To reduce production size, it is recommended that all the native modules should be set as dependency
in package.json
and other packages should be set as devDependencies
. Also, electron-rebuild
only check dependencies inside dependency
field.
If you are using electron-builder
to build distributions, all the native modules with its large relavent node_modiles
will be packaged into app.asar
by default.
Luckily, vite
can bundle all the dependencies. Just follow the steps:
- setup
nativeModuleEntryMap
option
- Manually copy the native binaries in
postBuild
callback
- Exclude all the dependencies in
electron-builder
's config
- call the native functions with
requireNative
/ importNative
in your code
Example
in vite.config.ts
const plugin = electronWithUpdater({
updater: {
entry: {
nativeModuleEntryMap: {
db: './electron/native/db.ts',
img: './electron/native/img.ts',
},
postBuild: async ({ copyToEntryOutputDir, copyModules }) => {
copyToEntryOutputDir({
from: './node_modules/better-sqlite3/build/Release/better_sqlite3.node',
skipIfExist: false,
})
const startStr = '@napi-rs+image-'
const fileName = (await readdir('./node_modules/.pnpm')).filter(p => p.startsWith(startStr))[0]
const archName = fileName.substring(startStr.length).split('@')[0]
copyToEntryOutputDir({
from: `./node_modules/.pnpm/${fileName}/node_modules/@napi-rs/image-${archName}/image.${archName}.node`,
})
copyModules({ modules: ['better-sqlite3'] })
},
},
},
})
in electron/native/db.ts
import Database from 'better-sqlite3'
import { getPathFromEntryAsar } from 'electron-incremental-update/utils'
const db = new Database(':memory:', { nativeBinding: getPathFromEntryAsar('./better_sqlite3.node') })
export function test(): void {
db.exec(
'DROP TABLE IF EXISTS employees; '
+ 'CREATE TABLE IF NOT EXISTS employees (name TEXT, salary INTEGER)',
)
db.prepare('INSERT INTO employees VALUES (:n, :s)').run({
n: 'James',
s: 5000,
})
const r = db.prepare('SELECT * from employees').all()
console.log(r)
db.close()
}
in electron/main/service.ts
import { importNative, requireNative } from 'electron-incremental-update/utils'
requireNative<typeof import('../native/db')>('db').test()
importNative<typeof import('../native/db')>('db').test()
in electron-builder.config.js
module.exports = {
files: [
'dist-entry',
'!node_modules/**',
]
}
Bytecode Protection
Use V8 cache to protect the source code
electronWithUpdater({
bytecode: true,
})
Benifits
https://electron-vite.org/guide/source-code-protection
- Improve the string protection (see original issue)
- Protect all strings by default
- Minification is allowed
Limitation
- Only support commonjs
- Only for main process by default, if you want to use in preload script, please use
electronWithUpdater({ bytecode: { enablePreload: true } })
and set sandbox: false
when creating window
Utils
const isDev: boolean
const isWin: boolean
const isMac: boolean
const isLinux: boolean
function getPathFromAppNameAsar(...paths: string[]): string
function getAppVersion(): string
function getEntryVersion(): string
function requireNative<T = any>(moduleName: string): T
function importNative<T = any>(moduleName: string): Promise<T>
function restartApp(): void
function setAppUserModelId(id?: string): void
function disableHWAccForWin7(): void
function singleInstance(window?: BrowserWindow): void
function setPortableAppDataPath(dirName?: string): void
function loadPage(win: BrowserWindow, htmlFilePath?: string): void
interface BeautifyDevToolsOptions {
sans: string
mono: string
scrollbar?: boolean
}
function beautifyDevTools(win: BrowserWindow, options: BeautifyDevToolsOptions): void
function getPathFromMain(...paths: string[]): string
function getPathFromPreload(...paths: string[]): string
function getPathFromPublic(...paths: string[]): string
function getPathFromEntryAsar(...paths: string[]): string
function handleUnexpectedErrors(callback: (err: unknown) => void): void
function getHeader(headers: Record<string, Arrayable<string>>, key: any): any
function downloadUtil<T>(
url: string,
headers: Record<string, any>,
signal: AbortSignal,
onResponse: (
resp: IncomingMessage,
resolve: (data: T) => void,
reject: (e: any) => void
) => void
): Promise<T>
function defaultDownloadJSON<T>(
url: string,
headers: Record<string, any>,
signal: AbortSignal,
resolveData?: ResolveDataFn
): Promise<T>
function defaultDownloadUpdateJSON(
url: string,
headers: Record<string, any>,
signal: AbortSignal
): Promise<UpdateJSON>
function defaultDownloadAsar(
url: string,
headers: Record<string, any>,
signal: AbortSignal,
onDownloading?: (progress: DownloadingInfo) => void
): Promise<Buffer>
Types
Entry
export interface AppOption {
mainPath?: string
updater?: (() => Promisable<Updater>) | UpdaterOption
onInstall?: OnInstallFunction
beforeStart?: (mainFilePath: string, logger?: Logger) => Promisable<void>
onStartError?: (err: unknown, logger?: Logger) => void
}
type OnInstallFunction = (
install: VoidFunction,
tempAsarPath: string,
appNameAsarPath: string,
logger?: Logger
) => Promisable<void>
Updater
export interface UpdaterOption {
provider?: IProvider
SIGNATURE_CERT?: string
receiveBeta?: boolean
logger?: Logger
}
export type Logger = {
info: (msg: string) => void
debug: (msg: string) => void
warn: (msg: string) => void
error: (msg: string, e?: Error) => void
}
Provider
export type OnDownloading = (progress: DownloadingInfo) => void
export interface DownloadingInfo {
delta: number
percent: number
total: number
transferred: number
bps: number
}
export interface IProvider {
name: string
downloadJSON: (versionPath: string, signal: AbortSignal) => Promise<UpdateJSON>
downloadAsar: (
name: string,
updateInfo: UpdateInfo,
signal: AbortSignal,
onDownloading?: (info: DownloadingInfo) => void
) => Promise<Buffer>
isLowerVersion: (oldVer: string, newVer: string) => boolean
unzipFile: (buffer: Buffer) => Promise<Buffer>
verifySignaure: (buffer: Buffer, version: string, signature: string, cert: string) => Promisable<boolean>
}
Plugin
export interface ElectronWithUpdaterOptions {
isBuild: boolean
pkg?: PKG
sourcemap?: boolean
minify?: boolean
bytecode?: boolean | BytecodeOptions
useNotBundle?: boolean
buildVersionJson?: boolean
logParsedOptions?: boolean | { showKeys: boolean }
main: MakeRequiredAndReplaceKey<ElectronSimpleOptions['main'], 'entry', 'files'> & ExcludeOutputDirOptions
preload: MakeRequiredAndReplaceKey<Exclude<ElectronSimpleOptions['preload'], undefined>, 'input', 'files'> & ExcludeOutputDirOptions
updater?: ElectronUpdaterOptions
}
export interface ElectronUpdaterOptions {
minimumVersion?: string
entry?: BuildEntryOption
paths?: {
asarOutputPath?: string
versionPath?: string
gzipPath?: string
electronDistPath?: string
rendererDistPath?: string
}
keys?: {
privateKeyPath?: string
certPath?: string
keyLength?: number
certInfo?: {
subject?: DistinguishedName
days?: number
}
}
overrideGenerator?: GeneratorOverrideFunctions
}
export interface BytecodeOptions {
enable: boolean
preload?: boolean
electronPath?: string
beforeCompile?: (code: string, id: string) => Promisable<string | null | undefined | void>
}
export interface BuildEntryOption {
minify?: boolean
sourcemap?: boolean
entryOutputDirPath?: string
appEntryPath?: string
nativeModuleEntryMap?: Record<string, string>
ignoreDynamicRequires?: boolean
external?: string | string[] | ((source: string, importer: string | undefined, isResolved: boolean) => boolean | null | undefined | void)
overrideViteOptions?: InlineConfig
postBuild?: (args: {
/**
* Get path from `entryOutputDirPath`
*/
getPathFromEntryOutputDir: (...paths: string[]) => string
/**
* Check exist and copy file to `entryOutputDirPath`
*
* If `to` absent, set to `basename(from)`
*
* If `skipIfExist` absent, skip copy if `to` exist
*/
copyToEntryOutputDir: (options: {
from: string
to?: string
/**
* Skip copy if `to` exist
* @default true
*/
skipIfExist?: boolean
}) => void
/**
* Copy specified modules to entry output dir, just like `external` option in rollup
*/
copyModules: (options: {
/**
* External Modules
*/
modules: string[]
/**
* Skip copy if `to` exist
* @default true
*/
skipIfExist?: boolean
}) => void
}) => Promisable<void>
}
export interface GeneratorOverrideFunctions {
generateSignature?: (
buffer: Buffer,
privateKey: string,
cert: string,
version: string
) => Promisable<string>
generateUpdateJson?: (
existingJson: UpdateJSON,
signature: string,
version: string,
minVersion: string
) => Promisable<UpdateJSON>
generateGzipFile?: (buffer: Buffer) => Promisable<Buffer>
}
Credits
License
MIT