├── pnpm-workspace.yaml ├── assets ├── logo.ico ├── icons │ ├── 16x16.png │ ├── 24x24.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 64x64.png │ ├── 96x96.png │ ├── 128x128.png │ ├── 256x256.png │ ├── 512x512.png │ └── 1024x1024.png ├── entitlements.mac.plist └── icon.svg ├── test └── dev-app-update.yml ├── src ├── main │ ├── index.ts │ ├── package.json │ ├── logger │ │ └── index.ts │ ├── windows │ │ ├── about.ts │ │ ├── main.ts │ │ ├── base.ts │ │ ├── index.ts │ │ └── update.ts │ ├── store │ │ └── index.ts │ ├── rspack.config.ts │ ├── utils │ │ └── index.ts │ └── core │ │ └── index.ts ├── renderer │ ├── styles │ │ ├── reset.less │ │ ├── fonts │ │ │ ├── AlimamaFangYuanTiVF-Thin.woff │ │ │ └── AlimamaFangYuanTiVF-Thin.woff2 │ │ ├── scroll-bar.less │ │ └── common.less │ ├── entry │ │ ├── about.tsx │ │ ├── home.tsx │ │ └── update.tsx │ ├── template.html │ ├── components │ │ ├── MenuBar │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── MenuIcon.less │ │ │ └── MenuIcon.tsx │ │ ├── About │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── Home │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── TextLine │ │ │ └── index.tsx │ │ └── AppUpdate │ │ │ ├── index.less │ │ │ └── index.tsx │ ├── images │ │ ├── maximize.svg │ │ └── unmaximize.svg │ ├── package.json │ ├── hooks │ │ ├── usePackageJson.ts │ │ ├── useAppUpdate.ts │ │ └── useDarkMode.ts │ ├── utils │ │ └── index.ts │ └── rspack.config.ts ├── preload │ ├── package.json │ ├── index.ts │ └── rspack.config.ts ├── common │ ├── env.ts │ └── constant.ts └── @types │ ├── global.d.ts │ └── asset.d.ts ├── .gitignore ├── .npmrc ├── .prettierignore ├── prettier.config.js ├── scripts └── clean.ts ├── tsconfig.json ├── .vscode └── launch.json ├── README.md ├── eslint.config.mjs ├── .github └── workflows │ └── build.yml └── package.json /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'src/*' 3 | -------------------------------------------------------------------------------- /assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/logo.ico -------------------------------------------------------------------------------- /test/dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: github 2 | owner: RyanProMax 3 | repo: electron-react-rspack 4 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import Core from './core'; 2 | 3 | const core = new Core(); 4 | core.startApp(); 5 | -------------------------------------------------------------------------------- /assets/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/16x16.png -------------------------------------------------------------------------------- /assets/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/24x24.png -------------------------------------------------------------------------------- /assets/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/32x32.png -------------------------------------------------------------------------------- /assets/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/48x48.png -------------------------------------------------------------------------------- /assets/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/64x64.png -------------------------------------------------------------------------------- /assets/icons/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/96x96.png -------------------------------------------------------------------------------- /assets/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/128x128.png -------------------------------------------------------------------------------- /assets/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/256x256.png -------------------------------------------------------------------------------- /assets/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/512x512.png -------------------------------------------------------------------------------- /src/renderer/styles/reset.less: -------------------------------------------------------------------------------- 1 | p { 2 | margin: 0; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | } 9 | -------------------------------------------------------------------------------- /assets/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/assets/icons/1024x1024.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # OSX 5 | .DS_Store 6 | 7 | build 8 | release 9 | .env 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | electron_mirror=https://npmmirror.com/mirrors/electron/ 2 | registry=https://registry.npmmirror.com 3 | auto-install-peers=true 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | output 5 | release 6 | *.log 7 | *.lock 8 | *.json 9 | *.md 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /src/renderer/styles/fonts/AlimamaFangYuanTiVF-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/src/renderer/styles/fonts/AlimamaFangYuanTiVF-Thin.woff -------------------------------------------------------------------------------- /src/renderer/styles/fonts/AlimamaFangYuanTiVF-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanProMax/electron-react-rspack/HEAD/src/renderer/styles/fonts/AlimamaFangYuanTiVF-Thin.woff2 -------------------------------------------------------------------------------- /src/preload/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preload", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "build": "rspack build", 6 | "analyze": "rspack build --analyze" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/entry/about.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import About from '../components/About'; 3 | 4 | const root = ReactDOM.createRoot(document.getElementById('root')!); 5 | root.render(); 6 | -------------------------------------------------------------------------------- /src/renderer/entry/home.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import Home from '../components/Home'; 3 | 4 | const root = ReactDOM.createRoot(document.getElementById('root')!); 5 | root.render(); 6 | -------------------------------------------------------------------------------- /src/renderer/entry/update.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import AppUpdate from '../components/AppUpdate'; 3 | 4 | const root = ReactDOM.createRoot(document.getElementById('root')!); 5 | root.render(); 6 | -------------------------------------------------------------------------------- /src/renderer/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | electron-react-rspack 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/common/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import path from 'path'; 3 | 4 | dotenv.config({ path: path.resolve(__dirname, '../../.env') }); 5 | 6 | export const isDev = process.env.NODE_ENV === 'development'; 7 | 8 | export const port = Number(process.env.PORT) || 9527; 9 | -------------------------------------------------------------------------------- /src/main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "main", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "start": "cross-env NODE_ENV=development NODE_OPTIONS=\"--import tsx\" electron ./index.ts", 6 | "build": "rspack build", 7 | "analyze": "rspack build --analyze" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: true, 4 | jsxSingleQuote: true, 5 | printWidth: 100, 6 | tabWidth: 2, 7 | useTabs: false, 8 | trailingComma: 'es5', 9 | bracketSpacing: true, 10 | plugins: ['prettier-plugin-tailwindcss'], 11 | endOfLine: 'crlf', 12 | }; 13 | -------------------------------------------------------------------------------- /scripts/clean.ts: -------------------------------------------------------------------------------- 1 | import { rimrafSync } from 'rimraf'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | const foldersToRemove = [path.join(process.cwd(), 'build'), path.join(process.cwd(), 'release')]; 6 | 7 | foldersToRemove.forEach((folder) => { 8 | if (fs.existsSync(folder)) rimrafSync(folder); 9 | }); 10 | -------------------------------------------------------------------------------- /src/main/logger/index.ts: -------------------------------------------------------------------------------- 1 | import log from 'electron-log/main'; 2 | import path from 'path'; 3 | import dayjs from 'dayjs'; 4 | 5 | log.transports.file.resolvePathFn = (variables) => 6 | path.join(variables.libraryDefaultDir, `${dayjs().format('YYYY-MM-DD')}.log`); 7 | 8 | log.initialize({ preload: true }); 9 | 10 | export const logger = log; 11 | -------------------------------------------------------------------------------- /src/main/windows/about.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron'; 2 | 3 | import BaseWindow from './base'; 4 | import { Channels, Pages } from '../../common/constant'; 5 | 6 | export default class About extends BaseWindow { 7 | page = Pages.About; 8 | 9 | register() { 10 | ipcMain.on(Channels.AboutMe, () => { 11 | this.createWindow(); 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron'; 2 | 3 | const __ELECTRON_API__ = { 4 | ipcRenderer: { 5 | invoke: ipcRenderer.invoke.bind(ipcRenderer), 6 | send: ipcRenderer.send.bind(ipcRenderer), 7 | on: ipcRenderer.on.bind(ipcRenderer), 8 | removeListener: ipcRenderer.removeListener.bind(ipcRenderer), 9 | }, 10 | }; 11 | 12 | contextBridge.exposeInMainWorld('__ELECTRON__', __ELECTRON_API__); 13 | -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | __ELECTRON__: { 4 | ipcRenderer: Electron.IpcRenderer; 5 | }; 6 | } 7 | 8 | interface PackageJson { 9 | name: string; 10 | author: string; 11 | version: string; 12 | description: string; 13 | homepage: string; 14 | repository: { 15 | type: string; 16 | url: string; 17 | }; 18 | license: string; 19 | } 20 | } 21 | 22 | export {}; 23 | -------------------------------------------------------------------------------- /src/preload/rspack.config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from '@rspack/cli'; 2 | import path from 'path'; 3 | 4 | const configuration: Configuration = { 5 | mode: 'production', 6 | target: 'electron-preload', 7 | resolve: { 8 | tsConfig: path.resolve(process.cwd(), '../../tsconfig.json'), 9 | }, 10 | entry: { 11 | preload: path.join(__dirname, 'index.ts'), 12 | }, 13 | output: { 14 | path: path.join(process.cwd(), '../../build'), 15 | filename: '[name].js', 16 | }, 17 | }; 18 | 19 | export default configuration; 20 | -------------------------------------------------------------------------------- /src/renderer/components/MenuBar/index.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/common.less'; 2 | 3 | .menu-bar { 4 | width: 100%; 5 | height: 40px; 6 | padding: 0 20px 0 12px; 7 | background-color: rgb(var(--color-rgb-255)); 8 | -webkit-app-region: drag; 9 | box-sizing: border-box; 10 | 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | 15 | &__logo { 16 | width: 24px; 17 | } 18 | 19 | &-left { 20 | display: flex; 21 | align-items: center; 22 | 23 | span { 24 | margin-left: 6px; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/styles/scroll-bar.less: -------------------------------------------------------------------------------- 1 | @scrollBarWidth: 8px; 2 | 3 | .scroll-bar() { 4 | &::-webkit-scrollbar { 5 | background: transparent; 6 | border-radius: (@scrollBarWidth / 2); 7 | width: @scrollBarWidth; 8 | } 9 | 10 | // 滚动条轨道 11 | // 内阴影+圆角 12 | &::-webkit-scrollbar-track { 13 | border-radius: @scrollBarWidth; 14 | background-color: transparent; 15 | } 16 | 17 | // 滑块 18 | // 内阴影+圆角 19 | &::-webkit-scrollbar-thumb { 20 | border-radius: (@scrollBarWidth / 2); 21 | background-color: rgba(var(--color-rgb-0), 0.32); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/components/About/index.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/common.less'; 2 | 3 | .about { 4 | width: 100vw; 5 | height: 100vh; 6 | background-color: rgb(var(--color-rgb-255)); 7 | overflow: hidden; 8 | border-radius: @borderRadius; 9 | 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | 14 | &__content { 15 | flex: 1; 16 | width: 100%; 17 | padding: 18px 32px; 18 | box-sizing: border-box; 19 | 20 | &-item { 21 | font-size: medium; 22 | 23 | &:not(:first-child) { 24 | line-height: 1.5; 25 | margin-top: 8px; 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/renderer/images/maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/renderer/components/Home/index.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/common.less'; 2 | 3 | .home { 4 | width: 100vw; 5 | height: 100vh; 6 | overflow: hidden; 7 | background-color: @background-color; 8 | border-radius: @borderRadius; 9 | 10 | 11 | &__content { 12 | height: 100%; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | flex-direction: column; 17 | } 18 | 19 | &__logo { 20 | width: 180px; 21 | 22 | &-container { 23 | margin-top: -90px; 24 | margin-bottom: 48px; 25 | } 26 | } 27 | 28 | &__title { 29 | font-size: 32px; 30 | font-weight: bold; 31 | margin-bottom: 24px; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/components/MenuBar/index.tsx: -------------------------------------------------------------------------------- 1 | import logo from 'assets/icons/32x32.png'; 2 | import MenuIcon from './MenuIcon'; 3 | 4 | import './index.less'; 5 | 6 | export default ({ 7 | title, 8 | minimize = false, 9 | maximize = false, 10 | closable = true, 11 | }: { 12 | title?: string; 13 | minimize?: boolean; 14 | maximize?: boolean; 15 | closable?: boolean; 16 | }) => { 17 | return ( 18 |
19 |
20 | 21 | {title ? {title} : null} 22 |
23 | 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/main/windows/main.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | 3 | import BaseWindow from './base'; 4 | import { Pages } from '../../common/constant'; 5 | 6 | export default class Main extends BaseWindow { 7 | page = Pages.Home; 8 | browserWindow = this.createWindow(); 9 | 10 | constructor() { 11 | super(); 12 | app.on('activate', () => { 13 | if (!this.browserWindow) { 14 | this.browserWindow = this.createWindow(); 15 | } 16 | }); 17 | } 18 | 19 | createWindow() { 20 | return super.createWindow({ 21 | width: 1024, 22 | height: 728, 23 | minWidth: 720, 24 | minHeight: 480, 25 | webPreferences: { 26 | webSecurity: false, 27 | }, 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/components/MenuBar/MenuIcon.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/common.less'; 2 | 3 | .menu-icon { 4 | -webkit-app-region: no-drag; 5 | display: flex; 6 | align-items: center; 7 | 8 | svg:not(:first-child) { 9 | margin-left: 12px; 10 | } 11 | 12 | &__item { 13 | font-size: 18px; 14 | color: rgb(var(--color-rgb-0)); 15 | cursor: pointer; 16 | transition: color .25s; 17 | 18 | &:hover { 19 | color: @color-primary; 20 | } 21 | 22 | &--svg { 23 | fill: rgb(var(--color-rgb-0)); 24 | width: 18px; 25 | height: 18px; 26 | cursor: pointer; 27 | transition: fill .25s; 28 | 29 | &:hover { 30 | fill: @color-primary; 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/common/constant.ts: -------------------------------------------------------------------------------- 1 | export enum Pages { 2 | Home = 'home', 3 | About = 'about', 4 | Update = 'update', 5 | } 6 | 7 | export enum Channels { 8 | // main events 9 | Close = 'Close', 10 | Quit = 'Quit', 11 | Minimize = 'Minimize', 12 | Maximize = 'Maximize', 13 | GetPackageJson = 'GetPackageJson', 14 | OpenExternal = 'OpenExternal', 15 | Broadcast = 'Broadcast', 16 | ToggleTheme = 'ToggleTheme', 17 | Render = 'Render', 18 | 19 | // app updater 20 | AppUpdaterConfirm = 'AppUpdaterConfirm', 21 | AppUpdaterProgress = 'AppUpdaterProgress', 22 | AppUpdaterAbort = 'AppUpdaterAbort', 23 | 24 | // store 25 | GetUserStore = 'GetUserStore', 26 | SetUserStore = 'SetUserStore', 27 | 28 | // sub window 29 | AboutMe = 'AboutMe', 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "renderer", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "start": "rspack serve", 6 | "build": "rspack build", 7 | "analyze": "cross-env rspack build --analyze" 8 | }, 9 | "dependencies": { 10 | "@arco-design/web-react": "^2.66.1", 11 | "classnames": "^2.5.1", 12 | "lodash-es": "^4.17.21", 13 | "react": "^19.1.0", 14 | "react-dom": "^19.1.0" 15 | }, 16 | "devDependencies": { 17 | "@arco-plugins/unplugin-react": "2.0.0-beta.5", 18 | "@svgr/webpack": "^8.1.0", 19 | "@types/lodash-es": "^4.17.12", 20 | "@types/react": "^19.1.8", 21 | "@types/react-dom": "^19.1.6", 22 | "css-loader": "^7.1.2", 23 | "less": "^4.2.0", 24 | "less-loader": "^12.2.0", 25 | "style-loader": "^4.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2021", 5 | "module": "NodeNext", 6 | "lib": [ 7 | "dom", 8 | "esnext" 9 | ], 10 | "jsx": "react-jsx", 11 | "strict": true, 12 | "sourceMap": true, 13 | "baseUrl": "./", 14 | "moduleResolution": "nodenext", 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "resolveJsonModule": true, 18 | "allowJs": true, 19 | "outDir": "build", 20 | "typeRoots": ["./node_modules/@types", "./src/@types"] 21 | }, 22 | "include": [ 23 | "**/*.js", 24 | "**/*.mjs", 25 | "**/*.ts", 26 | "**/*.tsx", 27 | "**/*.json" 28 | ], 29 | "exclude": [ 30 | "node_modules", 31 | "release/**/*", 32 | "build/**/*" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Electron: Main", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "pnpm", 9 | "runtimeArgs": [ 10 | "pnpm --filter main run start" 11 | ], 12 | "env": { 13 | "MAIN_ARGS": "--remote-debugging-port=9527" 14 | } 15 | }, 16 | { 17 | "name": "Electron: Renderer", 18 | "type": "chrome", 19 | "request": "attach", 20 | "port": 9527, 21 | "webRoot": "${workspaceFolder}", 22 | "timeout": 15000 23 | } 24 | ], 25 | "compounds": [ 26 | { 27 | "name": "Electron: All", 28 | "configurations": [ 29 | "Electron: Main", 30 | "Electron: Renderer" 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/images/unmaximize.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/store/index.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain } from 'electron'; 2 | import ElectronStore from 'electron-store'; 3 | import path from 'path'; 4 | 5 | import { Channels } from '../../common/constant'; 6 | 7 | export default class Store { 8 | rootPath = path.join(app.getPath('userData'), 'ElectronStorage'); 9 | defaultOptions = { 10 | cwd: this.rootPath, 11 | }; 12 | 13 | userStore = new ElectronStore({ 14 | ...this.defaultOptions, 15 | name: 'userStore', 16 | }); 17 | 18 | constructor() { 19 | this.register(); 20 | } 21 | 22 | private register() { 23 | ipcMain.handle(Channels.GetUserStore, (_, key: string) => { 24 | return this.userStore.get(key); 25 | }); 26 | ipcMain.handle(Channels.SetUserStore, (_, key: string, val: unknown) => { 27 | return this.userStore.set(key, val); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/hooks/usePackageJson.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { pick } from 'lodash-es'; 3 | 4 | import { Channels } from 'src/common/constant'; 5 | import { ipcRenderer } from 'src/renderer/utils'; 6 | 7 | export default ( 8 | pickProps: string[] = [ 9 | 'name', 10 | 'author', 11 | 'version', 12 | 'description', 13 | 'homepage', 14 | 'repository', 15 | 'license', 16 | ] 17 | ) => { 18 | const [packageJson, setPackageJson] = useState(); 19 | 20 | useEffect(() => { 21 | (async () => { 22 | const packageJsonStr = await ipcRenderer.invoke(Channels.GetPackageJson); 23 | const _packageJson = JSON.parse(packageJsonStr || '{}'); 24 | setPackageJson(pickProps.length ? pick(_packageJson, pickProps) : _packageJson); 25 | })(); 26 | }, []); 27 | 28 | return packageJson; 29 | }; 30 | -------------------------------------------------------------------------------- /src/main/rspack.config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from '@rspack/cli'; 2 | import path from 'path'; 3 | 4 | const configuration: Configuration = { 5 | mode: 'production', 6 | target: 'electron-main', 7 | resolve: { 8 | tsConfig: path.resolve(process.cwd(), '../../tsconfig.json'), 9 | extensions: ['.ts', '.js'], 10 | }, 11 | entry: { 12 | loader: path.join(__dirname, 'index.ts'), 13 | }, 14 | output: { 15 | path: path.join(process.cwd(), '../../build'), 16 | filename: '[name].js', 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts$/, 22 | exclude: [/node_modules/], 23 | loader: 'builtin:swc-loader', 24 | options: { 25 | jsc: { 26 | parser: { 27 | syntax: 'typescript', 28 | }, 29 | }, 30 | }, 31 | type: 'javascript/auto', 32 | }, 33 | ], 34 | }, 35 | }; 36 | 37 | export default configuration; 38 | -------------------------------------------------------------------------------- /src/renderer/styles/common.less: -------------------------------------------------------------------------------- 1 | @import './reset.less'; 2 | @import './scroll-bar.less'; 3 | 4 | @font-face { 5 | font-family: @font-family; 6 | src: url('./fonts/AlimamaFangYuanTiVF-Thin.woff2')format('woff2'), 7 | url('./fonts/AlimamaFangYuanTiVF-Thin.woff'); 8 | } 9 | 10 | @font-family: 'FangYuanTiVF-Thin'; 11 | @color-primary: rgb(var(--primary-6)); 12 | @color-error: rgb(var(--red-5)); 13 | @color-hover: rgba(22, 22, 22, 0.72); 14 | @background-color: rgba(var(--color-rgb-255), 0.95); 15 | @borderRadius: 8px; 16 | 17 | body { 18 | font-family: @font-family; 19 | color: var(--color-text-1); 20 | line-height: 1.2; 21 | transition: background-color .25s; 22 | 23 | .scroll-bar(); 24 | } 25 | 26 | body { 27 | --color-rgb-0: 0, 0, 0; 28 | --color-rgb-255: 255, 255, 255; 29 | --color-rgb-22: 233, 233, 233; 30 | 31 | &[arco-theme='dark'] { 32 | --color-rgb-0: 255, 255, 255; 33 | --color-rgb-255: 0, 0, 0; 34 | --color-rgb-22: 22, 22, 22; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/components/TextLine/index.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@arco-design/web-react'; 2 | import classnames from 'classnames'; 3 | 4 | const Row = Grid.Row; 5 | const Col = Grid.Col; 6 | 7 | export default ({ 8 | label, 9 | content, 10 | className, 11 | colSpan = [10, 12], 12 | labelAlign = 'left', 13 | }: { 14 | label: string; 15 | content?: string | React.ReactElement; 16 | className?: string; 17 | colSpan?: [number, number]; 18 | labelAlign?: 'left' | 'right'; 19 | }) => { 20 | const gap = 24 - colSpan[1] - colSpan[0]; 21 | 22 | return ( 23 | 24 | 31 | {label}: 32 | 33 | 34 | {content} 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/renderer/utils/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios'; 2 | 3 | import { Channels } from 'src/common/constant'; 4 | 5 | export const ipcRenderer = window.__ELECTRON__.ipcRenderer; 6 | 7 | export const callApi = async ({ 8 | raw = false, 9 | headers, 10 | ...config 11 | }: AxiosRequestConfig & { raw?: boolean }) => { 12 | const result = await axios({ 13 | headers: { 14 | Accept: 'application/json', 15 | ['Content-Type']: 16 | config.method?.toLowerCase() === 'post' 17 | ? 'application/x-www-form-urlencoded' 18 | : 'application/json', 19 | ...headers, 20 | }, 21 | method: 'get', 22 | ...config, 23 | }); 24 | return raw ? result : result.data; 25 | }; 26 | 27 | export const getUserStore = (key: T) => { 28 | return ipcRenderer.invoke(Channels.GetUserStore, key); 29 | }; 30 | 31 | export const setUserStore = (key: T, value: U) => { 32 | return ipcRenderer.invoke(Channels.SetUserStore, key, value); 33 | }; 34 | -------------------------------------------------------------------------------- /src/@types/asset.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg?react' { 2 | const ReactComponent: React.FunctionComponent & { title?: string }>; 3 | 4 | export default ReactComponent; 5 | } 6 | 7 | declare module '*.png' { 8 | const content: string; 9 | export default content; 10 | } 11 | 12 | declare module '*.jpg' { 13 | const content: string; 14 | export default content; 15 | } 16 | 17 | declare module '*.jpeg' { 18 | const content: string; 19 | export default content; 20 | } 21 | 22 | declare module '*.gif' { 23 | const content: string; 24 | export default content; 25 | } 26 | 27 | declare module '*.webp' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | declare module '*.svg' { 33 | const content: string; 34 | export default content; 35 | } 36 | 37 | declare module '*.ico' { 38 | const content: string; 39 | export default content; 40 | } 41 | 42 | declare module '*.bmp' { 43 | const content: string; 44 | export default content; 45 | } 46 | -------------------------------------------------------------------------------- /src/main/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import path from 'path'; 3 | import { URL } from 'url'; 4 | import fse from 'fs-extra'; 5 | 6 | import { port } from '../../common/env'; 7 | 8 | export function getAssetPath(...paths: string[]): string { 9 | const resourcePath = app.isPackaged 10 | ? path.join(process.resourcesPath, 'assets') 11 | : path.join(__dirname, '../../assets'); 12 | 13 | return path.join(resourcePath, ...paths); 14 | } 15 | 16 | export function getHtmlPath(htmlFileName: string) { 17 | if (process.env.NODE_ENV === 'development') { 18 | const url = new URL(`http://localhost:${port}`); 19 | url.pathname = htmlFileName; 20 | return url.href; 21 | } 22 | return `file://${path.resolve(__dirname, './renderer/', htmlFileName)}`; 23 | } 24 | 25 | export function getPreloadPath(): string { 26 | return app.isPackaged 27 | ? path.join(__dirname, 'preload.js') 28 | : path.join(process.cwd(), '../../build/preload.js'); 29 | } 30 | 31 | export const removeFileExtname = (fileName: string) => { 32 | return path.basename(fileName, path.extname(fileName)); 33 | }; 34 | 35 | export const getPackageJson = () => { 36 | const filePath = app.isPackaged 37 | ? path.resolve(__dirname, '../package.json') 38 | : path.join(process.cwd(), '../../package.json'); 39 | 40 | return fse.readFile(filePath, 'utf-8'); 41 | }; 42 | -------------------------------------------------------------------------------- /src/renderer/components/AppUpdate/index.less: -------------------------------------------------------------------------------- 1 | @import '../../styles/common.less'; 2 | 3 | .app-updater { 4 | width: 100vw; 5 | height: 100vh; 6 | padding: 0 24px; 7 | background-color: rgba(var(--color-rgb-255), 0.9); 8 | overflow: hidden; 9 | box-sizing: border-box; 10 | border-radius: @borderRadius; 11 | font-size: medium; 12 | 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | 17 | &__header { 18 | -webkit-app-region: drag; 19 | position: relative; 20 | width: 100%; 21 | padding: 16px 0; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | border-bottom: 1px solid rgb(var(--color-rgb-0)); 26 | box-sizing: border-box; 27 | } 28 | 29 | &__menu { 30 | position: absolute; 31 | right: 0; 32 | } 33 | 34 | &__content { 35 | flex: 1; 36 | width: 100%; 37 | padding: 18px 0; 38 | box-sizing: border-box; 39 | 40 | &-item { 41 | &:not(:first-child) { 42 | line-height: 1.5; 43 | margin-top: 8px; 44 | } 45 | } 46 | } 47 | 48 | &__progress { 49 | margin-top: 18px; 50 | } 51 | 52 | &__footer { 53 | width: 100%; 54 | padding: 16px 0; 55 | border-top: 1px solid rgb(var(--color-rgb-0)); 56 | 57 | display: flex; 58 | justify-content: flex-end; 59 | align-items: center; 60 | 61 | &-button { 62 | &:not(:last-child) { 63 | margin-right: 12px; 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Electron + React + Rspack 2 | 3 | An Electron boilerplate including TypeScript, React, Rspack and ESLint. 4 | 5 | > Reference [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) 6 | 7 | ![ElectronReactRspack](https://github.com/RyanProMax/image-hub/blob/main/electron-react-rspack/03.png) 8 | 9 | ![AutoUpdate](https://github.com/RyanProMax/image-hub/blob/main/electron-react-rspack/04.png) 10 | 11 | ## Installation 12 | 13 | Use pnpm in order to install all dependencies. 14 | 15 | ```bash 16 | pnpm install 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```bash 22 | # use `pnpm start:renderer` to start renderer process. 23 | pnpm start:renderer 24 | 25 | # and use `pnpm start:main` to start main process. 26 | pnpm start:main 27 | ``` 28 | 29 | ## Packaging 30 | 31 | To generate the project package based on the OS you're running on, just run: 32 | 33 | ```bash 34 | pnpm package 35 | ``` 36 | 37 | ## Features 38 | 39 | - [x] **Electron**: update to v37.2.0 40 | - [x] **Typescript** 41 | - [x] **RSPack**: for electron product (preload, main, renderer) 42 | - [x] **Electron-Store**: local persistent storage 43 | - [x] **Electron-Log**: local logger 44 | - [x] **Electron-Builder**: [have to keep using v24.9.1](https://github.com/electron-userland/electron-builder/issues/8175) 45 | - [x] **Electron-Updater**: auto update app version 46 | - [x] **ESLint & Prettier** 47 | - [x] **Less** 48 | - [x] **[Arco-Design](https://github.com/arco-design/arco-design)**: a comprehensive React UI components library 49 | - [x] **Theme**: light/dark mode 50 | - [x] **CI/CD**: auto build and release when push tag 51 | 52 | ## License 53 | 54 | [MIT](https://choosealicense.com/licenses/mit/) © [Ryan](https://github.com/RyanProMax) 55 | -------------------------------------------------------------------------------- /src/renderer/components/MenuBar/MenuIcon.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import classnames from 'classnames'; 3 | import { Channels } from 'src/common/constant'; 4 | import { ipcRenderer } from 'src/renderer/utils'; 5 | 6 | import { IconMinus, IconClose } from '@arco-design/web-react/icon'; 7 | import IconMaximize from 'src/renderer/images/maximize.svg?react'; 8 | import IconUnmaximize from 'src/renderer/images/unmaximize.svg?react'; 9 | 10 | import './MenuIcon.less'; 11 | 12 | export default ({ 13 | minimize = false, 14 | maximize = false, 15 | closable = true, 16 | isDefaultMaximize = false, 17 | className, 18 | }: { 19 | minimize?: boolean; 20 | maximize?: boolean; 21 | closable?: boolean; 22 | isDefaultMaximize?: boolean; 23 | className?: string; 24 | }) => { 25 | const [isMaximize, setIsMaximize] = useState(isDefaultMaximize); 26 | 27 | const onMinimize = () => { 28 | return ipcRenderer.send(Channels.Minimize); 29 | }; 30 | 31 | const onMaximize = () => { 32 | setIsMaximize((v) => !v); 33 | return ipcRenderer.send(Channels.Maximize); 34 | }; 35 | 36 | const onClose = () => { 37 | return ipcRenderer.send(Channels.Close); 38 | }; 39 | 40 | return ( 41 |
42 | {minimize ? : null} 43 | {maximize ? ( 44 | isMaximize ? ( 45 | 46 | ) : ( 47 | 48 | ) 49 | ) : null} 50 | {closable ? : null} 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/renderer/hooks/useAppUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { UpdateInfo, ProgressInfo } from 'electron-updater'; 3 | 4 | import { Channels } from 'src/common/constant'; 5 | import { ipcRenderer } from 'src/renderer/utils'; 6 | 7 | export enum Status { 8 | Confirm = 'Confirm', 9 | Waiting = 'Waiting', 10 | Downloading = 'Downloading', 11 | Done = 'Done', 12 | } 13 | 14 | export default () => { 15 | const [status, setStatus] = useState(Status.Confirm); 16 | const [updateInfo, setUpdateInfo] = useState(); 17 | const [progress, setProgress] = useState(); 18 | 19 | const confirmCallback = (option: boolean) => { 20 | if (option) { 21 | setStatus(Status.Waiting); 22 | } 23 | ipcRenderer.send(Channels.AppUpdaterConfirm, option); 24 | }; 25 | 26 | const abort = () => { 27 | ipcRenderer.send(Channels.AppUpdaterAbort); 28 | }; 29 | 30 | useEffect(() => { 31 | const handleUpdate = (_: Electron.IpcRendererEvent, data: UpdateInfo) => { 32 | setUpdateInfo(data); 33 | }; 34 | const handleProgress = (_: Electron.IpcRendererEvent, data: ProgressInfo) => { 35 | if (status === Status.Waiting) { 36 | setStatus(Status.Downloading); 37 | } 38 | if (data.percent === 100) { 39 | setStatus(Status.Done); 40 | } 41 | setProgress(data); 42 | }; 43 | 44 | ipcRenderer.on(Channels.Render, handleUpdate); 45 | ipcRenderer.on(Channels.AppUpdaterProgress, handleProgress); 46 | 47 | return () => { 48 | ipcRenderer.removeListener(Channels.Render, handleUpdate); 49 | ipcRenderer.removeListener(Channels.AppUpdaterProgress, handleProgress); 50 | }; 51 | }, []); 52 | 53 | return { status, updateInfo, progress, confirmCallback, abort }; 54 | }; 55 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import globals from 'globals'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import path from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | import js from '@eslint/js'; 7 | import { FlatCompat } from '@eslint/eslintrc'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | }); 14 | 15 | export default [ 16 | { 17 | ignores: ['build/**', 'release/**', 'dist/**', 'node_modules/**'], 18 | }, 19 | js.configs.recommended, 20 | ...compat.extends( 21 | 'plugin:@typescript-eslint/eslint-recommended', 22 | 'plugin:@typescript-eslint/recommended', 23 | 'plugin:prettier/recommended' 24 | ), 25 | { 26 | plugins: { 27 | '@typescript-eslint': typescriptEslint, 28 | }, 29 | 30 | languageOptions: { 31 | globals: { 32 | ...globals.browser, 33 | ...globals.amd, 34 | ...globals.node, 35 | }, 36 | 37 | parser: tsParser, 38 | ecmaVersion: 'latest', 39 | sourceType: 'module', 40 | 41 | parserOptions: { 42 | project: true, 43 | tsconfigRootDir: __dirname, 44 | }, 45 | }, 46 | 47 | rules: { 48 | 'prettier/prettier': 'error', 49 | 'react/react-in-jsx-scope': 'off', 50 | 'react/prop-types': 'off', 51 | '@typescript-eslint/no-unused-vars': 'off', 52 | 'react/no-unescaped-entities': 'off', 53 | '@typescript-eslint/explicit-module-boundary-types': 'off', 54 | '@typescript-eslint/no-var-requires': 'off', 55 | '@typescript-eslint/ban-ts-comment': 'off', 56 | semi: ['error', 'always'], 57 | }, 58 | }, 59 | ]; 60 | -------------------------------------------------------------------------------- /src/main/windows/base.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, shell } from 'electron'; 2 | import { merge } from 'lodash'; 3 | 4 | import { getHtmlPath, getPreloadPath } from '../utils/index'; 5 | import { Pages } from '../../common/constant'; 6 | 7 | export default abstract class BaseWindow { 8 | abstract page: Pages; 9 | browserWindow: BrowserWindow | null = null; 10 | 11 | private DefaultConfig: Electron.BrowserWindowConstructorOptions = { 12 | show: false, 13 | width: 480, 14 | height: 240, 15 | minWidth: 480, 16 | minHeight: 240, 17 | autoHideMenuBar: true, 18 | frame: false, 19 | transparent: true, 20 | resizable: true, 21 | webPreferences: { 22 | preload: getPreloadPath(), 23 | }, 24 | }; 25 | 26 | constructor() { 27 | this.register(); 28 | } 29 | 30 | createWindow(options?: Electron.BrowserWindowConstructorOptions) { 31 | if (this.browserWindow && !this.browserWindow.isDestroyed()) { 32 | this.browserWindow.show(); 33 | this.browserWindow.focus(); 34 | } else { 35 | this.browserWindow = new BrowserWindow(merge({}, this.DefaultConfig, options)); 36 | 37 | this.browserWindow.loadURL(getHtmlPath(`${this.page}.html`)); 38 | 39 | this.browserWindow.on('ready-to-show', () => { 40 | this.browserWindow?.show(); 41 | }); 42 | 43 | this.browserWindow.webContents.on('will-navigate', (e, url) => { 44 | e.preventDefault(); 45 | shell.openExternal(url); 46 | }); 47 | 48 | this.browserWindow.on('close', () => { 49 | this.browserWindow = null; 50 | }); 51 | } 52 | 53 | return this.browserWindow; 54 | } 55 | 56 | protected register() { 57 | // ... 58 | } 59 | 60 | close() { 61 | if (this.browserWindow?.closable) { 62 | this.browserWindow.close(); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/renderer/components/About/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@arco-design/web-react'; 2 | import MenuBar from '../MenuBar'; 3 | import TextLine from '../TextLine'; 4 | 5 | import usePackageJson from 'src/renderer/hooks/usePackageJson'; 6 | import useDarkMode from 'src/renderer/hooks/useDarkMode'; 7 | 8 | import './index.less'; 9 | 10 | const colSpan: [number, number] = [6, 18]; 11 | 12 | export default () => { 13 | const packageJson = usePackageJson(); 14 | useDarkMode(); 15 | 16 | return ( 17 |
18 | 19 |
20 | 24 | {packageJson?.name} 25 | 26 | } 27 | colSpan={colSpan} 28 | className='about__content-item' 29 | /> 30 | 36 | 42 | 46 | {packageJson?.license} 47 | © 48 | {packageJson?.author} 49 |
50 | } 51 | colSpan={colSpan} 52 | className='about__content-item' 53 | /> 54 |
55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/main/core/index.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain } from 'electron'; 2 | 3 | import { Channels } from '../../common/constant'; 4 | import { getPackageJson } from '../utils'; 5 | import { logger } from '../logger'; 6 | 7 | import Store from '../store'; 8 | import Windows from '../windows'; 9 | 10 | export default class Core { 11 | logger = logger.scope('Core'); 12 | 13 | store: Store | null = null; 14 | windows: Windows | null = null; 15 | 16 | async startApp() { 17 | try { 18 | this.logger.info('app start'); 19 | 20 | await this.beforeAppReady(); 21 | await app.whenReady(); 22 | await this.afterAppReady(); 23 | 24 | this.logger.info('app start success'); 25 | } catch (e) { 26 | this.logger.error(e); 27 | } 28 | } 29 | 30 | // run before app-ready 31 | private async beforeAppReady() { 32 | app.on('window-all-closed', () => { 33 | if (process.platform !== 'darwin') { 34 | app.quit(); 35 | } 36 | }); 37 | } 38 | 39 | // run after app-ready 40 | private async afterAppReady() { 41 | this.store = new Store(); 42 | 43 | // init windows 44 | this.windows = new Windows(this); 45 | 46 | this.register(); 47 | } 48 | 49 | private register() { 50 | // on 51 | ipcMain.on(Channels.Quit, this.quitApp); 52 | 53 | ipcMain.on(Channels.Broadcast, (event, channel: Channels, ...data: unknown[]) => { 54 | const { sender } = event; 55 | const allWindows = this.windows?.getAllWindows() || []; 56 | allWindows 57 | .filter((w) => w && w.webContents.id !== sender.id) 58 | .forEach((w) => { 59 | w?.webContents?.send(channel, ...data); 60 | }); 61 | }); 62 | 63 | // handle 64 | ipcMain.handle(Channels.GetPackageJson, getPackageJson); 65 | } 66 | 67 | quitApp() { 68 | this.logger.info('app quit'); 69 | app.quit(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | release: 10 | name: build and release electron app 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | fail-fast: true 15 | matrix: 16 | # os: [windows-latest, macos-latest, ubuntu-latest] 17 | os: [windows-latest, macos-latest] 18 | 19 | steps: 20 | - name: Check out git repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Install Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: "20" 27 | 28 | - name: Install pnpm 29 | uses: pnpm/action-setup@v3 30 | with: 31 | version: latest 32 | 33 | - name: Get pnpm store directory 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 37 | 38 | - name: Setup pnpm cache 39 | uses: actions/cache@v4 40 | with: 41 | path: ${{ env.STORE_PATH }} 42 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ runner.os }}-pnpm-store- 45 | 46 | - name: Install Dependencies 47 | run: pnpm install 48 | 49 | - name: Build Electron App 50 | run: pnpm package 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 53 | 54 | # - name: Create Release 55 | # uses: softprops/action-gh-release@v0.1.14 56 | # if: startsWith(github.ref, 'refs/tags/') 57 | # with: 58 | # files: | 59 | # release/*.exe 60 | # release/*.dmg 61 | # release/*.zip 62 | # release/*.blockmap 63 | # release/latest*.yml 64 | # draft: false 65 | # prerelease: false 66 | # generate_release_notes: true 67 | # env: 68 | # GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 69 | -------------------------------------------------------------------------------- /src/renderer/hooks/useDarkMode.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { IconMoon, IconSun } from '@arco-design/web-react/icon'; 3 | import { Channels } from 'src/common/constant'; 4 | import { ipcRenderer } from 'src/renderer/utils'; 5 | 6 | export enum THEME { 7 | Light = 'light', 8 | Dark = 'dark', 9 | } 10 | 11 | const ThemeStorageKey = '__THEME__'; 12 | 13 | const mediaQueryListDark = window.matchMedia('(prefers-color-scheme: dark)'); 14 | 15 | const getTheme = () => 16 | (window.localStorage.getItem(ThemeStorageKey) as THEME) || 17 | (mediaQueryListDark.matches ? THEME.Dark : THEME.Light); 18 | 19 | const refreshTheme = (theme: THEME) => { 20 | if (theme === THEME.Light) { 21 | document.body.removeAttribute('arco-theme'); 22 | } else { 23 | document.body.setAttribute('arco-theme', THEME.Dark); 24 | } 25 | }; 26 | 27 | export default () => { 28 | const [theme, setTheme] = useState(getTheme()); 29 | const ThemeIcon = theme === THEME.Light ? IconSun : IconMoon; 30 | 31 | const toggleTheme = () => { 32 | const _t = theme === THEME.Dark ? THEME.Light : THEME.Dark; 33 | setTheme(_t); 34 | ipcRenderer.send(Channels.Broadcast, Channels.ToggleTheme, _t); 35 | }; 36 | 37 | useEffect(() => { 38 | window.localStorage.setItem(ThemeStorageKey, theme); 39 | refreshTheme(theme); 40 | }, [theme]); 41 | 42 | useEffect(() => { 43 | const handleChangeTheme = (event: MediaQueryListEvent) => { 44 | setTheme(event.matches ? THEME.Dark : THEME.Light); 45 | }; 46 | 47 | mediaQueryListDark.addEventListener('change', handleChangeTheme); 48 | return () => mediaQueryListDark.removeEventListener('change', handleChangeTheme); 49 | }, []); 50 | 51 | useEffect(() => { 52 | const updateTheme = (_: Electron.IpcRendererEvent, _theme: THEME) => { 53 | setTheme(_theme); 54 | }; 55 | 56 | ipcRenderer.on(Channels.ToggleTheme, updateTheme); 57 | return () => { 58 | ipcRenderer.removeListener(Channels.ToggleTheme, updateTheme); 59 | }; 60 | }, []); 61 | 62 | return { ThemeIcon, theme, toggleTheme }; 63 | }; 64 | -------------------------------------------------------------------------------- /src/renderer/components/AppUpdate/index.tsx: -------------------------------------------------------------------------------- 1 | import { round } from 'lodash-es'; 2 | import { Button, Progress } from '@arco-design/web-react'; 3 | import MenuIcon from '../MenuBar/MenuIcon'; 4 | import TextLine from '../TextLine'; 5 | 6 | import usePackageJson from 'src/renderer/hooks/usePackageJson'; 7 | import useDarkMode from 'src/renderer/hooks/useDarkMode'; 8 | import useAppUpdate, { Status } from 'src/renderer/hooks/useAppUpdate'; 9 | 10 | import './index.less'; 11 | 12 | export default () => { 13 | useDarkMode(); 14 | const packageJson = usePackageJson(); 15 | const { status, updateInfo, progress, confirmCallback, abort } = useAppUpdate(); 16 | 17 | return ( 18 |
19 |
20 | New Version: {updateInfo?.version} 21 | 22 |
23 |
24 | 30 | 36 | {status !== Status.Confirm ? ( 37 | 38 | ) : null} 39 |
40 |
41 | {status === Status.Confirm ? ( 42 | <> 43 | 46 | 53 | 54 | ) : ( 55 | 58 | )} 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/renderer/components/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@arco-design/web-react'; 2 | import { upperFirst } from 'lodash-es'; 3 | import { IconBulb, IconGithub } from '@arco-design/web-react/icon'; 4 | import log from 'electron-log/renderer'; 5 | 6 | import { Channels } from 'src/common/constant'; 7 | import { ipcRenderer } from 'src/renderer/utils'; 8 | import usePackageJson from 'src/renderer/hooks/usePackageJson'; 9 | import useDarkMode from 'src/renderer/hooks/useDarkMode'; 10 | 11 | import MenuBar from '../MenuBar'; 12 | import logo from 'assets/icons/256x256.png'; 13 | 14 | import './index.less'; 15 | 16 | const homeLogger = log.scope('home'); 17 | 18 | export default () => { 19 | const packageJson = usePackageJson(); 20 | const { ThemeIcon, toggleTheme } = useDarkMode(); 21 | const title = packageJson 22 | ? `${packageJson.name.split('-').map(upperFirst).join('')} Ver: ${packageJson.version}` 23 | : ''; 24 | 25 | const openGithub = () => { 26 | if (packageJson) { 27 | ipcRenderer.send(Channels.OpenExternal, packageJson.homepage); 28 | } 29 | }; 30 | 31 | const openAboutMe = () => { 32 | homeLogger.info('open about me'); 33 | ipcRenderer.send(Channels.AboutMe); 34 | }; 35 | 36 | return ( 37 |
38 | 39 |
40 |
41 | 42 |
43 |

44 | {packageJson?.name ? packageJson.name.split('-').map(upperFirst).join(' ') : null} 45 |

46 |
47 |
69 |
70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/main/windows/index.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain, shell } from 'electron'; 2 | 3 | import BaseWindow from './base'; 4 | import Main from './main'; 5 | import About from './about'; 6 | import Update from './update'; 7 | 8 | import Core from '../core'; 9 | import { logger } from '../logger'; 10 | import { Channels, Pages } from '../../common/constant'; 11 | 12 | export default class Windows { 13 | logger = logger.scope('Windows'); 14 | 15 | private windowInstances = new Map(); 16 | private RegisterWindows = [Main, About, Update]; 17 | private core: Core; 18 | 19 | constructor(core: Core) { 20 | this.core = core; 21 | this.register(); 22 | } 23 | 24 | private register() { 25 | this.RegisterWindows.forEach((WindowClass) => { 26 | const instance = new WindowClass(); 27 | this.windowInstances.set(instance.page, instance); 28 | }); 29 | 30 | // events 31 | ipcMain.on(Channels.Close, (event) => { 32 | this.logger.info(Channels.Close); 33 | const { sender } = event; 34 | 35 | const mainWindow = this.getBrowserWindow(Pages.Home); 36 | if (sender.id === mainWindow?.id) { 37 | this.core.quitApp(); 38 | } else { 39 | const browserWindow = BrowserWindow.fromWebContents(sender); 40 | if (browserWindow?.closable) { 41 | browserWindow.close(); 42 | } 43 | } 44 | }); 45 | 46 | ipcMain.on(Channels.Maximize, (event) => { 47 | this.logger.info(Channels.Maximize); 48 | const { sender } = event; 49 | const browserWindow = BrowserWindow.fromWebContents(sender); 50 | if (browserWindow?.maximizable) { 51 | if (browserWindow.isMaximized()) { 52 | browserWindow.unmaximize(); 53 | } else { 54 | browserWindow.maximize(); 55 | } 56 | } 57 | }); 58 | 59 | ipcMain.on(Channels.Minimize, (event) => { 60 | this.logger.info(Channels.Minimize); 61 | const { sender } = event; 62 | const browserWindow = BrowserWindow.fromWebContents(sender); 63 | if (browserWindow?.minimizable) { 64 | browserWindow.minimize(); 65 | } 66 | }); 67 | 68 | ipcMain.on(Channels.OpenExternal, (_, url: string, options?: Electron.OpenExternalOptions) => { 69 | shell.openExternal(url, options); 70 | }); 71 | } 72 | 73 | getWindowInstance(page: Pages) { 74 | return this.windowInstances.get(page) || null; 75 | } 76 | 77 | getBrowserWindow(page: Pages) { 78 | const windowInstance = this.getWindowInstance(page); 79 | return windowInstance?.browserWindow || null; 80 | } 81 | 82 | getAllWindows() { 83 | return Array.from(this.windowInstances.values()) 84 | .map((instance) => instance.browserWindow) 85 | .filter((v) => Boolean(v)) as Electron.BrowserWindow[]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-react-rspack", 3 | "version": "0.0.9", 4 | "author": "Ryan", 5 | "description": "An Electron boilerplate including TypeScript, React, Rspack and ESLint.", 6 | "keywords": [ 7 | "electron", 8 | "boilerplate", 9 | "react", 10 | "typescript", 11 | "ts", 12 | "rspack" 13 | ], 14 | "main": "build/loader.js", 15 | "scripts": { 16 | "start:renderer": "pnpm clean && pnpm --filter preload run build && pnpm --filter renderer run start", 17 | "start:main": "pnpm --filter main run start", 18 | "clean": "ts-node ./scripts/clean.ts", 19 | "build": "pnpm clean && concurrently \"pnpm --filter main run build\" \"pnpm --filter preload run build\" \"pnpm --filter renderer run build\"", 20 | "package:local": "pnpm build && electron-builder build -c ./build.config.json --publish never", 21 | "package": "pnpm build && electron-builder build -c ./build.config.json --publish always", 22 | "lint": "eslint . --ext .ts,.tsx,.js,.jsx", 23 | "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix" 24 | }, 25 | "devDependencies": { 26 | "@eslint/eslintrc": "^3.3.1", 27 | "@eslint/js": "^9.30.1", 28 | "@rspack/cli": "^1.4.3", 29 | "@rspack/core": "^1.4.3", 30 | "@types/fs-extra": "^11.0.4", 31 | "@types/lodash": "^4.17.20", 32 | "@types/node": "^24.0.10", 33 | "@typescript-eslint/eslint-plugin": "^8.35.1", 34 | "@typescript-eslint/parser": "^8.35.1", 35 | "axios": "^1.10.0", 36 | "concurrently": "^9.2.0", 37 | "cross-env": "^7.0.3", 38 | "dayjs": "^1.11.13", 39 | "dotenv": "^17.0.1", 40 | "electron": "^37.2.0", 41 | "electron-builder": "^26.0.12", 42 | "electron-log": "^5.4.1", 43 | "electron-store": "^10.1.0", 44 | "electron-updater": "^6.6.2", 45 | "eslint": "^9.30.1", 46 | "eslint-config-prettier": "^10.1.5", 47 | "eslint-plugin-prettier": "^5.5.1", 48 | "fs-extra": "^11.3.0", 49 | "globals": "^16.3.0", 50 | "husky": "^9.1.7", 51 | "lint-staged": "^16.1.2", 52 | "lodash": "^4.17.21", 53 | "prettier": "^3.6.2", 54 | "prettier-plugin-tailwindcss": "^0.6.13", 55 | "rimraf": "^6.0.1", 56 | "ts-node": "^10.9.2", 57 | "tsx": "^4.20.3", 58 | "typescript": "^5.8.3" 59 | }, 60 | "homepage": "https://github.com/RyanProMax/electron-react-rspack#readme", 61 | "repository": { 62 | "type": "git", 63 | "url": "git+https://github.com/RyanProMax/electron-react-rspack.git" 64 | }, 65 | "engines": { 66 | "node": ">=20.x", 67 | "npm": ">=10.x" 68 | }, 69 | "pnpm": { 70 | "onlyBuiltDependencies": [ 71 | "electron" 72 | ] 73 | }, 74 | "lint-staged": { 75 | "*.+(js|jsx|ts|tsx)": [ 76 | "eslint --fix" 77 | ], 78 | "*.+(js|jsx|ts|tsx|json|css|md|mdx)": [ 79 | "prettier --write" 80 | ] 81 | }, 82 | "license": "MIT" 83 | } 84 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/main/windows/update.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron'; 2 | import { CancellationToken, UpdateInfo, autoUpdater } from 'electron-updater'; 3 | import path from 'path'; 4 | 5 | import BaseWindow from './base'; 6 | import { Channels, Pages } from '../../common/constant'; 7 | import { isDev } from '../../common/env'; 8 | import { logger } from '../logger'; 9 | 10 | type CheckAvailableVersionResult = { 11 | result: boolean; 12 | data?: UpdateInfo; 13 | }; 14 | 15 | export default class Update extends BaseWindow { 16 | private logger = logger.scope('AppUpdater'); 17 | page = Pages.Update; 18 | 19 | private TIME_OUT = 10 * 1000; 20 | 21 | constructor() { 22 | super(); 23 | if (isDev) { 24 | autoUpdater.updateConfigPath = path.join(__dirname, '../../../test/dev-app-update.yml'); 25 | autoUpdater.forceDevUpdateConfig = true; 26 | } 27 | autoUpdater.autoDownload = false; 28 | autoUpdater.logger = this.logger; 29 | 30 | this.checkUpdate(); 31 | } 32 | 33 | createWindow() { 34 | return super.createWindow({ 35 | width: 360, 36 | height: 240, 37 | resizable: false, 38 | }); 39 | } 40 | 41 | async checkUpdate() { 42 | const { result, data } = await this.checkAvailableVersion(); 43 | if (result && data) { 44 | const option = await this.updateConfirm(data); 45 | this.logger.info('confirm option', option); 46 | 47 | if (option) { 48 | // start update 49 | autoUpdater.on('download-progress', (progress) => { 50 | console.log('download-progress', progress); 51 | this.browserWindow?.webContents.send(Channels.AppUpdaterProgress, progress); 52 | }); 53 | 54 | autoUpdater.on('update-downloaded', (updateDownloadedEvent) => { 55 | this.logger.info('update-downloaded', updateDownloadedEvent); 56 | autoUpdater.quitAndInstall(false); 57 | }); 58 | 59 | const cancellationToken = new CancellationToken(); 60 | 61 | ipcMain.once(Channels.AppUpdaterAbort, () => { 62 | cancellationToken.cancel(); 63 | this.browserWindow?.close(); 64 | }); 65 | 66 | autoUpdater.downloadUpdate(cancellationToken); 67 | } else { 68 | // cancel update 69 | this.browserWindow?.close(); 70 | } 71 | } 72 | } 73 | 74 | checkAvailableVersion(): Promise { 75 | return new Promise((r) => { 76 | const timer = setTimeout(() => { 77 | r({ result: false }); 78 | }, this.TIME_OUT); 79 | 80 | const finish = (result: CheckAvailableVersionResult) => { 81 | clearTimeout(timer); 82 | r(result); 83 | }; 84 | 85 | autoUpdater.on('error', (...args) => { 86 | this.logger.error(...args); 87 | finish({ result: false }); 88 | }); 89 | 90 | autoUpdater.on('checking-for-update', (...args) => { 91 | console.log('checking-for-update', args); 92 | }); 93 | 94 | autoUpdater.on('update-available', (versionAvailable) => { 95 | this.logger.info('update-available', versionAvailable); 96 | finish({ result: true, data: versionAvailable }); 97 | }); 98 | 99 | autoUpdater.on('update-not-available', (updateVersionNotAvailable) => { 100 | this.logger.info('update-not-available', updateVersionNotAvailable); 101 | finish({ result: false, data: updateVersionNotAvailable }); 102 | }); 103 | 104 | autoUpdater.checkForUpdates(); 105 | }); 106 | } 107 | 108 | updateConfirm(data: UpdateInfo): Promise { 109 | const browserWindow = this.createWindow(); 110 | 111 | browserWindow.webContents.on('did-finish-load', () => { 112 | if (this.browserWindow) { 113 | this.browserWindow.webContents.send(Channels.Render, data); 114 | this.browserWindow.show(); 115 | } 116 | }); 117 | 118 | return new Promise((resolve) => { 119 | ipcMain.once(Channels.AppUpdaterConfirm, (_, option: boolean) => { 120 | resolve(option); 121 | }); 122 | }); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/renderer/rspack.config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, HtmlRspackPlugin } from '@rspack/core'; 2 | import { ArcoDesignPlugin } from '@arco-plugins/unplugin-react'; 3 | import path from 'path'; 4 | import fse from 'fs-extra'; 5 | import keyBy from 'lodash/keyBy'; 6 | import upperFirst from 'lodash/upperFirst'; 7 | 8 | import { removeFileExtname } from '../main/utils'; 9 | import { port } from '../common/env'; 10 | 11 | const isDevelopment = process.env.NODE_ENV === 'development'; 12 | const isProduction = !isDevelopment; 13 | 14 | const htmlTemplate = path.join(__dirname, 'template.html'); 15 | const entryDir = path.join(__dirname, 'entry'); 16 | const files = fse.readdirSync(entryDir); 17 | const entry = keyBy( 18 | files.map((f) => path.join(entryDir, f)), 19 | (filePath) => removeFileExtname(filePath) 20 | ); 21 | 22 | const configuration: Configuration = { 23 | target: 'web', 24 | mode: isProduction ? 'production' : 'development', 25 | devtool: isProduction ? 'source-map' : 'cheap-module-source-map', 26 | entry, 27 | output: { 28 | path: path.join(process.cwd(), '../../build/renderer'), 29 | publicPath: 'auto', 30 | filename: isProduction ? '[name].[contenthash:8].js' : '[name].js', 31 | chunkFilename: isProduction ? '[name].[contenthash:8].chunk.js' : '[name].chunk.js', 32 | clean: true, 33 | }, 34 | resolve: { 35 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 36 | alias: { 37 | '@': path.resolve(__dirname, './'), 38 | src: path.resolve(__dirname, '../'), 39 | assets: path.resolve(__dirname, '../../assets'), 40 | }, 41 | }, 42 | module: { 43 | rules: [ 44 | { 45 | test: /\.(ts|tsx)$/, 46 | use: [ 47 | { 48 | loader: 'builtin:swc-loader', 49 | options: { 50 | jsc: { 51 | parser: { 52 | syntax: 'typescript', 53 | tsx: true, 54 | }, 55 | transform: { 56 | react: { 57 | runtime: 'automatic', 58 | development: isDevelopment, 59 | }, 60 | }, 61 | }, 62 | }, 63 | }, 64 | ], 65 | exclude: /node_modules/, // 排除 node_modules 提高性能 66 | }, 67 | { 68 | test: /\.less$/, 69 | use: [ 70 | 'style-loader', 71 | 'css-loader', 72 | { 73 | loader: 'less-loader', 74 | options: { 75 | lessOptions: { 76 | modifyVars: { 77 | // 'arcoblue-6': '#37D4CF', 78 | }, 79 | javascriptEnabled: true, 80 | }, 81 | }, 82 | }, 83 | ], 84 | }, 85 | { 86 | test: /\.css$/, 87 | use: ['style-loader', 'css-loader'], 88 | }, 89 | { 90 | test: /\.svg$/, 91 | oneOf: [ 92 | { 93 | // 当使用 ?react 查询参数时,转换为 React 组件 94 | resourceQuery: /react/, 95 | use: [ 96 | { 97 | loader: '@svgr/webpack', 98 | options: { 99 | icon: true, // 优化为图标使用 100 | svgoConfig: { 101 | plugins: [ 102 | { 103 | name: 'removeViewBox', 104 | active: false, // 保留 viewBox 以支持响应式 105 | }, 106 | ], 107 | }, 108 | }, 109 | }, 110 | ], 111 | }, 112 | { 113 | type: 'asset/resource', 114 | }, 115 | ], 116 | }, 117 | { 118 | test: /\.(png|jpg|jpeg|gif|webp)$/, 119 | type: 'asset', 120 | parser: { 121 | dataUrlCondition: { 122 | maxSize: 8 * 1024, // 小于 8KB 的图片内联为 base64 123 | }, 124 | }, 125 | }, 126 | { 127 | test: /\.(woff|woff2|eot|ttf|otf)$/, 128 | type: 'asset/resource', 129 | }, 130 | ], 131 | }, 132 | plugins: [ 133 | ...Object.keys(entry).map((entryName) => { 134 | return new HtmlRspackPlugin({ 135 | template: htmlTemplate, 136 | filename: `${entryName}.html`, 137 | chunks: [entryName], // 只包含对应的入口点 138 | title: `${upperFirst(entryName)} - Electron React App`, 139 | minify: isProduction, 140 | }); 141 | }), 142 | new ArcoDesignPlugin({ 143 | // theme: '@arco-themes/react-asuka', 144 | // iconBox: '@arco-iconbox/react-partial-bits', 145 | removeFontFace: true, 146 | }), 147 | ], 148 | devServer: { 149 | port, 150 | hot: true, 151 | open: false, 152 | }, 153 | optimization: { 154 | splitChunks: { 155 | chunks: 'all', 156 | cacheGroups: { 157 | vendor: { 158 | name: 'vendors', 159 | test: /[\\/]node_modules[\\/]/, 160 | priority: 10, 161 | chunks: 'all', 162 | }, 163 | arco: { 164 | name: 'arco-design', 165 | test: /[\\/]node_modules[\\/]@arco-design[\\/]/, 166 | priority: 20, // 优先级高于 vendor 167 | chunks: 'all', 168 | }, 169 | }, 170 | }, 171 | ...(isProduction && { 172 | minimize: true, 173 | sideEffects: false, 174 | }), 175 | }, 176 | performance: { 177 | // 资源大小警告阈值 178 | maxAssetSize: 500 * 1000, // 500KB 179 | maxEntrypointSize: 2 * 1024 * 1000, // 2MB 180 | // 过滤掉字体文件的警告 181 | assetFilter: (assetFilename) => { 182 | return !assetFilename.endsWith('.woff') && !assetFilename.endsWith('.woff2'); 183 | }, 184 | }, 185 | }; 186 | 187 | export default configuration; 188 | --------------------------------------------------------------------------------