feat: add electron-builder (#123)
# feat : add electron auto update # feat : add some components url : https://github.com/RSS1101/electron-vite-react # * feat : Add electron auto update function # * chore : use css module conduct style isolation # * refactor: cleanup # * chore : electron-auto-update refinement # * chore: cleanup # * chore : update electron-auto-update # * chore: typo # * feat: add `mac.target.dmg` for electron-updater --------- Co-authored-by: 任帅 <1064425721@qq.com>
This commit is contained in:
parent
29d5fa95a8
commit
2f2880a9f1
|
@ -14,7 +14,8 @@
|
|||
"mac": {
|
||||
"artifactName": "${productName}_${version}.${ext}",
|
||||
"target": [
|
||||
"dmg"
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
|
@ -33,5 +34,10 @@
|
|||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false
|
||||
},
|
||||
publish:{
|
||||
provider: 'generic',
|
||||
channel: 'latest',
|
||||
url: 'https://github.com/electron-vite/electron-vite-react/releases/download/v0.9.9/',
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
||||
import { release } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { update } from './update'
|
||||
|
||||
// The built directory structure
|
||||
//
|
||||
|
@ -72,6 +73,9 @@ async function createWindow() {
|
|||
if (url.startsWith('https:')) shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// Apply electron-updater
|
||||
update(win)
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow)
|
||||
|
@ -114,3 +118,4 @@ ipcMain.handle('open-win', (_, arg) => {
|
|||
childWindow.loadFile(indexHtml, { hash: arg })
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { app, ipcMain } from 'electron'
|
||||
import {
|
||||
type ProgressInfo,
|
||||
type UpdateDownloadedEvent,
|
||||
autoUpdater
|
||||
} from 'electron-updater'
|
||||
|
||||
export function update(win: Electron.BrowserWindow) {
|
||||
|
||||
// When set to false, the update download will be triggered through the API
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.disableWebInstaller = false
|
||||
autoUpdater.allowDowngrade = false
|
||||
|
||||
// start check
|
||||
autoUpdater.on('checking-for-update', function () { })
|
||||
// update available
|
||||
autoUpdater.on('update-available', (arg) => {
|
||||
win.webContents.send('update-can-available', { update: true, version: app.getVersion(), newVersion: arg?.version })
|
||||
})
|
||||
// update not available
|
||||
autoUpdater.on('update-not-available', (arg) => {
|
||||
win.webContents.send('update-can-available', { update: false, version: app.getVersion(), newVersion: arg?.version })
|
||||
})
|
||||
|
||||
// Checking for updates
|
||||
ipcMain.handle('check-update', async () => {
|
||||
if (!app.isPackaged) {
|
||||
const error = new Error('The update feature is only available after the package.')
|
||||
return { message: error.message, error }
|
||||
}
|
||||
|
||||
try {
|
||||
return await autoUpdater.checkForUpdatesAndNotify()
|
||||
} catch (error) {
|
||||
return { message: 'Network error', error }
|
||||
}
|
||||
})
|
||||
|
||||
// Start downloading and feedback on progress
|
||||
ipcMain.handle('start-download', (event) => {
|
||||
startDownload(
|
||||
(error, progressInfo) => {
|
||||
if (error) {
|
||||
// feedback download error message
|
||||
event.sender.send('update-error', { message: error.message, error })
|
||||
} else {
|
||||
// feedback update progress message
|
||||
event.sender.send('download-progress', progressInfo)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// feedback update downloaded message
|
||||
event.sender.send('update-downloaded')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Install now
|
||||
ipcMain.handle('quit-and-install', () => {
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
})
|
||||
}
|
||||
|
||||
function startDownload(
|
||||
callback: (error: Error | null, info: ProgressInfo) => void,
|
||||
complete: (evnet: UpdateDownloadedEvent) => void,
|
||||
) {
|
||||
autoUpdater.on('download-progress', info => callback(null, info))
|
||||
autoUpdater.on('error', error => callback(error, null))
|
||||
autoUpdater.on('update-downloaded', complete)
|
||||
autoUpdater.downloadUpdate()
|
||||
}
|
|
@ -18,6 +18,9 @@
|
|||
"pree2e": "vite build --mode=test",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.31.0",
|
||||
"@types/react": "^18.0.28",
|
||||
|
|
20
src/App.tsx
20
src/App.tsx
|
@ -1,21 +1,21 @@
|
|||
import nodeLogo from "./assets/node.svg"
|
||||
import nodeLogo from './assets/node.svg'
|
||||
import { useState } from 'react'
|
||||
import Update from '@/components/update'
|
||||
import './App.scss'
|
||||
|
||||
console.log('[App.tsx]', `Hello world from Electron ${process.versions.electron}!`)
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<div className='App'>
|
||||
<div>
|
||||
<a href="https://github.com/electron-vite/electron-vite-react" target="_blank">
|
||||
<img src="./electron-vite.svg" className="logo" alt="Electron + Vite logo" />
|
||||
<a href='https://github.com/electron-vite/electron-vite-react' target='_blank'>
|
||||
<img src='./electron-vite.svg' className='logo' alt='Electron + Vite logo' />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Electron + Vite + React</h1>
|
||||
<div className="card">
|
||||
<div className='card'>
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
|
@ -23,12 +23,14 @@ function App() {
|
|||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
<p className='read-the-docs'>
|
||||
Click on the Electron + Vite logo to learn more
|
||||
</p>
|
||||
<div className="flex-center">
|
||||
Place static files into the<code>/public</code> folder <img style={{ width: "5em" }} src={nodeLogo} alt="Node logo" />
|
||||
<div className='flex-center'>
|
||||
Place static files into the<code>/public</code> folder <img style={{ width: '5em' }} src={nodeLogo} alt='Node logo' />
|
||||
</div>
|
||||
|
||||
<Update />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './modal.module.scss'
|
||||
|
||||
const ModalTemplate: React.FC<React.PropsWithChildren<{
|
||||
title?: ReactNode
|
||||
footer?: ReactNode
|
||||
cancelText?: string
|
||||
okText?: string
|
||||
onCancel?: () => void
|
||||
onOk?: () => void
|
||||
width?: number
|
||||
}>> = props => {
|
||||
const {
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
cancelText = 'Cancel',
|
||||
okText = 'OK',
|
||||
onCancel,
|
||||
onOk,
|
||||
width = 530,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div className={styles.modal}>
|
||||
<div className='modal-mask' />
|
||||
<div className='modal-warp'>
|
||||
<div className='modal-content' style={{ width }}>
|
||||
<div className='modal-header'>
|
||||
<div className='modal-header-text'>{title}</div>
|
||||
<span
|
||||
className='modal-close'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M557.312 513.248l265.28-263.904c12.544-12.48 12.608-32.704 0.128-45.248-12.512-12.576-32.704-12.608-45.248-0.128l-265.344 263.936-263.04-263.84C236.64 191.584 216.384 191.52 203.84 204 191.328 216.48 191.296 236.736 203.776 249.28l262.976 263.776L201.6 776.8c-12.544 12.48-12.608 32.704-0.128 45.248 6.24 6.272 14.464 9.44 22.688 9.44 8.16 0 16.32-3.104 22.56-9.312l265.216-263.808 265.44 266.24c6.24 6.272 14.432 9.408 22.656 9.408 8.192 0 16.352-3.136 22.592-9.344 12.512-12.48 12.544-32.704 0.064-45.248L557.312 513.248z" p-id="2764" fill="currentColor">
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className='modal-body'>{children}</div>
|
||||
{typeof footer !== 'undefined' ? (
|
||||
<div className='modal-footer'>
|
||||
<button onClick={onCancel}>{cancelText}</button>
|
||||
<button onClick={onOk}>{okText}</button>
|
||||
</div>
|
||||
) : footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Modal = (props: Parameters<typeof ModalTemplate>[0] & { open: boolean }) => {
|
||||
const { open, ...omit } = props
|
||||
|
||||
return createPortal(
|
||||
open ? ModalTemplate(omit) : null,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default Modal
|
|
@ -0,0 +1,89 @@
|
|||
.modal {
|
||||
--primary-color: rgb(224, 30, 90);
|
||||
|
||||
:global {
|
||||
.modal-mask {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.modal-warp {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 19;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
box-shadow: 0 0 10px -4px rgb(130, 86, 208);
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
line-height: 38px;
|
||||
background-color: var(--primary-color);
|
||||
|
||||
.modal-header-text {
|
||||
font-weight: bold;
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 4px;
|
||||
line-height: 34px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
|
||||
button {
|
||||
padding: 7px 11px;
|
||||
background-color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
margin-left: 10px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 0 15px;
|
||||
width: 20px;
|
||||
fill: currentColor;
|
||||
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react'
|
||||
import styles from './progress.module.scss'
|
||||
|
||||
const Progress: React.FC<React.PropsWithChildren<{
|
||||
percent?: number
|
||||
}>> = props => {
|
||||
const { percent = 0 } = props
|
||||
|
||||
return (
|
||||
<div className={styles.progress}>
|
||||
<div className='progress-pr'>
|
||||
<div
|
||||
className='progress-rate'
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='progress-num'>{(percent ?? 0).toString().substring(0,4)}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Progress
|
|
@ -0,0 +1,23 @@
|
|||
.progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:global {
|
||||
.progress-pr {
|
||||
border: 1px solid #000;
|
||||
border-radius: 3px;
|
||||
height: 6px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.progress-rate {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-image: linear-gradient(to right, rgb(130, 86, 208) 0%, var(--primary-color) 100%)
|
||||
}
|
||||
|
||||
.progress-num {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
interface VersionInfo {
|
||||
update: boolean
|
||||
version: string
|
||||
newVersion?: string
|
||||
}
|
||||
|
||||
interface ErrorType {
|
||||
message: string
|
||||
error: Error
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
import { ipcRenderer } from 'electron'
|
||||
import type { ProgressInfo } from 'electron-updater'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Modal from '@/components/update/Modal'
|
||||
import Progress from '@/components/update/Progress'
|
||||
import styles from './update.module.scss'
|
||||
|
||||
const Update = () => {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo>()
|
||||
const [updateError, setUpdateError] = useState<ErrorType>()
|
||||
const [progressInfo, setProgressInfo] = useState<Partial<ProgressInfo>>()
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false)
|
||||
const [modalBtn, setModalBtn] = useState<{
|
||||
cancelText?: string
|
||||
okText?: string
|
||||
onCancel?: () => void
|
||||
onOk?: () => void
|
||||
}>({
|
||||
onCancel: () => setModalOpen(false),
|
||||
onOk: () => ipcRenderer.invoke('start-download'),
|
||||
})
|
||||
|
||||
const checkUpdate = async () => {
|
||||
setChecking(true)
|
||||
/**
|
||||
* @type {import('electron-updater').UpdateCheckResult | null | { message: string, error: Error }}
|
||||
*/
|
||||
const result = await ipcRenderer.invoke('check-update')
|
||||
setProgressInfo({ percent: 0 })
|
||||
setChecking(false)
|
||||
setModalOpen(true)
|
||||
if (result?.error) {
|
||||
setUpdateAvailable(false)
|
||||
setUpdateError(result?.error)
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateCanAvailable = useCallback((_event: Electron.IpcRendererEvent, arg1: VersionInfo) => {
|
||||
setVersionInfo(arg1)
|
||||
setUpdateError(undefined)
|
||||
// Can be update
|
||||
if (arg1.update) {
|
||||
setModalBtn(state => ({
|
||||
...state,
|
||||
cancelText: 'Cancel',
|
||||
okText: 'Update',
|
||||
onOk: () => ipcRenderer.invoke('start-download'),
|
||||
}))
|
||||
setUpdateAvailable(true)
|
||||
} else {
|
||||
setUpdateAvailable(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onUpdateError = useCallback((_event: Electron.IpcRendererEvent, arg1: ErrorType) => {
|
||||
setUpdateAvailable(false)
|
||||
setUpdateError(arg1)
|
||||
}, [])
|
||||
|
||||
const onDownloadProgress = useCallback((_event: Electron.IpcRendererEvent, arg1: ProgressInfo) => {
|
||||
setProgressInfo(arg1)
|
||||
}, [])
|
||||
|
||||
const onUpdateDownloaded = useCallback((_event: Electron.IpcRendererEvent, ...args: any[]) => {
|
||||
setProgressInfo({ percent: 100 })
|
||||
setModalBtn(state => ({
|
||||
...state,
|
||||
cancelText: 'Later',
|
||||
okText: 'Install now',
|
||||
onOk: () => ipcRenderer.invoke('quit-and-install'),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Get version information and whether to update
|
||||
ipcRenderer.on('update-can-available', onUpdateCanAvailable)
|
||||
ipcRenderer.on('update-error', onUpdateError)
|
||||
ipcRenderer.on('download-progress', onDownloadProgress)
|
||||
ipcRenderer.on('update-downloaded', onUpdateDownloaded)
|
||||
|
||||
return () => {
|
||||
ipcRenderer.off('update-can-available', onUpdateCanAvailable)
|
||||
ipcRenderer.off('update-error', onUpdateError)
|
||||
ipcRenderer.off('download-progress', onDownloadProgress)
|
||||
ipcRenderer.off('update-downloaded', onUpdateDownloaded)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
cancelText={modalBtn?.cancelText}
|
||||
okText={modalBtn?.okText}
|
||||
onCancel={modalBtn?.onCancel}
|
||||
onOk={modalBtn?.onOk}
|
||||
footer={updateAvailable ? /* hide footer */null : undefined}
|
||||
>
|
||||
<div className={styles.modalslot}>
|
||||
{updateError
|
||||
? (
|
||||
<div className='update-error'>
|
||||
<p>Error downloading the latest version.</p>
|
||||
<p>{updateError.message}</p>
|
||||
</div>
|
||||
) : updateAvailable
|
||||
? (
|
||||
<div className='can-available'>
|
||||
<div>The last version is: v{versionInfo?.newVersion}</div>
|
||||
<div className='new-version-target'>v{versionInfo?.version} -> v{versionInfo?.newVersion}</div>
|
||||
<div className='update-progress'>
|
||||
<div className='progress-title'>Update progress:</div>
|
||||
<div className='progress-bar'>
|
||||
<Progress percent={progressInfo?.percent} ></Progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='can-not-available'>{JSON.stringify(versionInfo ?? {}, null, 2)}</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
<button disabled={checking} onClick={checkUpdate}>
|
||||
{checking ? 'Checking...' : 'Check update'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Update
|
|
@ -0,0 +1,31 @@
|
|||
.modalslot {
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// justify-content: center;
|
||||
|
||||
:global {
|
||||
.update-progress {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.can-available {
|
||||
.new-version-target,.update-progress {
|
||||
margin-left: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.can-not-available {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue