├── .commitlintrc
├── .gitattributes
├── .github
├── renovate.json
└── workflows
│ └── releases.yaml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .lintstagedrc
├── .npmrc
├── .nvmrc
├── .vscode
├── extensions.json
└── settings.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── assets
├── icons
│ ├── icon.icns
│ ├── icon.ico
│ └── icon.png
├── react.svg
└── vite.svg
├── components.json
├── config
├── vite.base.config.ts
├── vite.main.config.ts
├── vite.preload.config.ts
└── vite.renderer.config.ts
├── eslint.config.js
├── forge.config.ts
├── forge.env.d.ts
├── index.html
├── package.json
├── pnpm-lock.yaml
├── prettier.config.js
├── screenshots
├── window-mac.png
└── window-win.png
├── src
├── @types
│ ├── electron-forge.d.ts
│ ├── globals.d.ts
│ └── index.d.ts
├── app
│ ├── App.tsx
│ ├── components
│ │ ├── control-button.tsx
│ │ ├── menu-item.tsx
│ │ ├── menu.tsx
│ │ ├── mode-toggle.tsx
│ │ ├── theme-provider.tsx
│ │ ├── titlebar.tsx
│ │ ├── ui
│ │ │ ├── badge.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ └── dropdown-menu.tsx
│ │ └── window-controls.tsx
│ ├── hooks
│ │ ├── index.ts
│ │ ├── useEventListener.ts
│ │ └── useRendererListener.ts
│ ├── index.tsx
│ ├── lib
│ │ └── utils.ts
│ ├── screens
│ │ └── landing.tsx
│ └── styles
│ │ └── globals.css
├── appWindow.ts
├── channels
│ └── menuChannels.ts
├── ipc
│ └── menuIPC.ts
├── main.ts
├── menu
│ ├── accelerators.ts
│ ├── appMenu.ts
│ └── contextMenu.ts
├── preload.ts
├── webContents.ts
└── windowState.ts
└── tsconfig.json
/.commitlintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "@commitlint/config-conventional"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text eol=lf
2 | *.exe binary
3 | *.png binary
4 | *.jpg binary
5 | *.jpeg binary
6 | *.ico binary
7 | *.icns binary
8 | *.eot binary
9 | *.otf binary
10 | *.ttf binary
11 | *.woff binary
12 | *.woff2 binary
13 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:recommended"
5 | ],
6 | "rangeStrategy": "update-lockfile",
7 | "prHourlyLimit": 1,
8 | "packageRules": [
9 | {
10 | "matchUpdateTypes": [
11 | "minor",
12 | "patch",
13 | "pin",
14 | "digest"
15 | ],
16 | "automerge": true
17 | }
18 | ],
19 | "lockFileMaintenance": {
20 | "enabled": true,
21 | "automerge": true
22 | },
23 | "timezone": "Europe/Rome",
24 | "schedule": [
25 | "* 0-6 * * 6,0"
26 | ],
27 | "rebaseWhen": "conflicted"
28 | }
29 |
--------------------------------------------------------------------------------
/.github/workflows/releases.yaml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | permissions:
4 | contents: write
5 |
6 | on:
7 | push:
8 | tags:
9 | - v[0-9]+.*
10 |
11 | jobs:
12 | create-release:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v5
16 | - uses: taiki-e/create-gh-release-action@v1
17 | with:
18 | # (optional) Path to changelog.
19 | # changelog: CHANGELOG.md
20 | # (required) GitHub token for creating GitHub Releases.
21 | token: ${{ secrets.GITHUB_TOKEN }}
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 | .DS_Store
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # TypeScript cache
43 | *.tsbuildinfo
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env
62 | .env.test
63 |
64 | # parcel-bundler cache (https://parceljs.org/)
65 | .cache
66 |
67 | # next.js build output
68 | .next
69 |
70 | # nuxt.js build output
71 | .nuxt
72 |
73 | # vuepress build output
74 | .vuepress/dist
75 |
76 | # Serverless directories
77 | .serverless/
78 |
79 | # FuseBox cache
80 | .fusebox/
81 |
82 | # DynamoDB Local files
83 | .dynamodb/
84 |
85 | # Webpack
86 | .webpack/
87 |
88 | # Vite
89 | .vite/
90 |
91 | # Electron-Forge
92 | out/
93 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | npx --no-install commitlint --edit "$1"
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | lint-staged
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "{src,config}/**/*.ts": "pnpm lint"
3 | }
4 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 | node-linker=hoisted
3 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 23
2 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode"
5 | ]
6 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[javascript]": {
3 | "editor.defaultFormatter": "esbenp.prettier-vscode"
4 | },
5 | "[javascriptreact]": {
6 | "editor.defaultFormatter": "esbenp.prettier-vscode"
7 | },
8 | "[typescript]": {
9 | "editor.defaultFormatter": "esbenp.prettier-vscode"
10 | },
11 | "[typescriptreact]": {
12 | "editor.defaultFormatter": "esbenp.prettier-vscode"
13 | },
14 | "biome.enabled": false,
15 | "editor.codeActionsOnSave": {
16 | "source.fixAll.eslint": "explicit",
17 | "source.organizeImports": "never"
18 | },
19 | "editor.formatOnPaste": true,
20 | "editor.formatOnSave": true,
21 | "eslint.workingDirectories": [
22 | {
23 | "mode": "auto"
24 | }
25 | ]
26 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ### [2.3.2](https://github.com/flaviodelgrosso/reactronite/compare/v2.3.1...v2.3.2) (2025-10-18)
6 |
7 |
8 | ### Bug Fixes
9 |
10 | * **deps:** update dependency lucide-react to ^0.544.0 ([#27](https://github.com/flaviodelgrosso/reactronite/issues/27)) ([c899568](https://github.com/flaviodelgrosso/reactronite/commit/c8995689a04afab19fba0c06d14ee9a01e159666))
11 |
12 | ### [2.3.1](https://github.com/flaviodelgrosso/reactronite/compare/v2.3.0...v2.3.1) (2025-08-02)
13 |
14 | ## [2.3.0](https://github.com/flaviodelgrosso/reactronite/compare/v2.2.2...v2.3.0) (2025-06-24)
15 |
16 |
17 | ### Features
18 |
19 | * **deps:** upgrade to vite 7 and electron 37 ([9216714](https://github.com/flaviodelgrosso/reactronite/commit/9216714301d102c804b205c6d372248651deb4aa))
20 |
21 |
22 | ### Bug Fixes
23 |
24 | * enable useFlatConfig in vite-plugin-checker config to support ESLint 9 flat config ([30dbf7b](https://github.com/flaviodelgrosso/reactronite/commit/30dbf7bf2872a15de1103e2b172550dad7cf4e43))
25 |
26 | ### [2.2.2](https://github.com/flaviodelgrosso/reactronite/compare/v2.2.1...v2.2.2) (2025-06-17)
27 |
28 |
29 | ### Features
30 |
31 | * add ESLint 9 and neostandard ([2943b3d](https://github.com/flaviodelgrosso/reactronite/commit/2943b3daf4f758e779ef4faf3eca9ab640e5a5c7))
32 |
33 | ### [2.2.1](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/compare/v2.2.0...v2.2.1) (2025-06-07)
34 |
35 | ## [2.2.0](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/compare/v2.1.1...v2.2.0) (2025-06-07)
36 |
37 |
38 | ### Features
39 |
40 | * add macOS-specific titlebar styling and app name display ([8ffb4f5](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/8ffb4f5c06e976b0d4695ad8747568307f5ef418))
41 |
42 |
43 | ### Bug Fixes
44 |
45 | * change import to type for globals in globals.d.ts ([788952e](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/788952e93f84c1ebceeb69d3780c7466c7c3c88f))
46 | * update default formatter for JSON and JSONC files ([ae9d336](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/ae9d3369f06e0dc77002631b8e8e2678f166a104))
47 | * update path resolution method in createAppWindow function ([6ed5b94](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/6ed5b9478e9c8e0837b286393a336803840a29da))
48 |
49 | ### [2.1.1](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/compare/v2.1.0...v2.1.1) (2025-06-07)
50 |
51 |
52 | ### Features
53 |
54 | * add shadcn and landing page ([94223fd](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/94223fd4e797b153db08241727b2d3cd3d54962d))
55 |
56 |
57 | ### Bug Fixes
58 |
59 | * update color variables in globals.css for consistency ([0fb386e](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/0fb386e5f3fc6b374778b709a2c003d03d86147d))
60 | * update conditional rendering for Menu and WindowControls on Windows ([105c639](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/105c6399c3b9782bdd380a9f42ef4c6027676236))
61 |
62 | ## [2.1.0](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/compare/v2.0.0...v2.1.0) (2025-06-07)
63 |
64 | ### Features
65 |
66 | * add tailwindcss v4 ([#4](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/issues/4)) ([58a422d](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/58a422d53fdd9d1a463cff6499ddb15d46edd075))
67 |
68 | ### Bug Fixes
69 |
70 | * correct variable name in installExtension promise ([5df058a](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/5df058a43511f87d6b54b5c4441cf69e9880a88c))
71 | * update preload path to use import.meta.dirname ([131227d](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/commit/131227d3d56c07406121514b1c1409140ab0158e))
72 |
73 | ## [2.0.0](https://github.com/flaviodelgrosso/electron-forge-react-typescript-boilerplate/compare/v1.1.0...v2.0.0) (2025-06-06)
74 |
75 | ## 🚀 BREAKING CHANGE
76 |
77 | * ⬆️ Upgraded `@electron-forge` and related sub-packages to `7.8.1`.
78 | * ⚡ Upgraded `vite` to version `6` for better performance and features.
79 | * 🧹 Updated all dependencies and devDependencies to modernize the codebase.
80 | * ✨ Added support for ECMAScript Modules (ESM).
81 | * 📦 Move from yarn to pnpm package manager.
82 |
83 | ## 🛠️ Tooling
84 |
85 | * 🔄 Switched from ESLint to [Biome](https://biomejs.dev) for unified linting and formatting.
86 | * 🧰 Refactored build configurations for improved compatibility and maintainability.
87 | * 📝 Updated project metadata and documentation to reflect the latest tooling and structure.
88 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Reactronite
2 |
3 | Thank you for your interest in contributing to this project! Here are some guidelines to help you get started.
4 |
5 | ## Code of Conduct
6 |
7 | Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
8 |
9 | ## How to Contribute
10 |
11 | 1. Fork this repository.
12 | 2. Create a new branch for your changes.
13 | 3. Make your changes and commit them with a descriptive commit message.
14 | 4. Push your changes to your fork.
15 | 5. Submit a pull request to this repository.
16 |
17 | ## Development Setup
18 |
19 | To set up your development environment, follow these steps:
20 |
21 | 1. Clone this repository.
22 | 2. Install dependencies with `pnpm install`.
23 | 3. Run the application with `pnpm dev`.
24 |
25 | ## License
26 |
27 | By contributing to this project, you agree that your contributions will be licensed under the terms of the [MIT license](./LICENSE).
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Flavio Del Grosso
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ⚛ Reactronite ⚛
2 |
3 |
4 |
5 | [](https://opensource.org/licenses/MIT)
6 | [](https://www.typescriptlang.org/)
7 | [](https://reactjs.org/)
8 | [](https://electronjs.org/)
9 | [](https://vitejs.dev/)
10 |
11 | **A modern, feature-rich Electron kit for building cross-platform desktop applications with React and Vite**
12 |
13 | [Features](#-features) • [Quick Start](#-quick-start) • [Configuration](#-configuration) • [Contributing](#-contributing)
14 |
15 |
16 |
17 | 
18 | 
19 |
20 | ---
21 |
22 | ## 🎯 Overview
23 |
24 | The **Reactronite** is your ultimate starting point for creating modern, performant desktop applications. This carefully crafted template combines the power of Electron with the speed of Vite, the flexibility of React, and the safety of TypeScript to deliver an exceptional development experience.
25 |
26 | ### Why This Kit?
27 |
28 | - 🏗️ **Production-Ready Architecture** - Clean, scalable project structure with separation of concerns
29 | - ⚡ **Lightning Fast Development** - Hot Module Replacement (HMR) with Vite for instant feedback
30 | - 🎨 **Native Desktop Experience** - Custom titlebar and native-feeling UI components
31 | - 🛡️ **Type Safety First** - Full TypeScript support with strict type checking
32 | - 🔧 **Developer Experience** - Pre-configured linting, formatting, and git hooks
33 | - 📦 **Zero Configuration** - Ready to code out of the box with sensible defaults
34 |
35 | ## ✨ Features
36 |
37 | ### 🎨 **User Interface & Experience**
38 |
39 | - **Custom Titlebar** - Native-looking titlebar with integrated window controls
40 | - **Responsive Design** - Adaptive layouts that work across different screen sizes
41 | - **Modern Styling** - TailwindCSS v4 styling system with theme support
42 | - **Cross-Platform Consistency** - Unified experience across Windows, macOS, and Linux
43 |
44 | ### 🔧 **Development Tools**
45 |
46 | - **Hot Module Replacement** - Instant updates during development
47 | - **TypeScript Integration** - Full type safety with excellent IntelliSense support
48 | - **Code Quality Tools** - Integrated ESLint and [neostandard](https://github.com/neostandard/neostandard) for linting
49 | - **Git Hooks** - Automated code quality checks with Husky
50 | - **Path Mapping** - Clean imports with TypeScript path resolution
51 |
52 | ### 🏗️ **Architecture & Security**
53 |
54 | - **Secure IPC Communication** - Safe main-renderer process communication
55 | - **Context Isolation** - Properly isolated preload scripts
56 | - **Window State Management** - Remembers window size, position, and state
57 | - **Auto-updater Ready** - Built with Electron Forge for easy distribution
58 |
59 | ### 📦 **Build & Distribution**
60 |
61 | - **Multi-Platform Building** - Build for Windows, macOS, and Linux from any platform
62 | - **Optimized Bundles** - Tree-shaking and code splitting for smaller app sizes
63 | - **Auto-Packaging** - One-command building and packaging
64 | - **Distribution Ready** - Pre-configured makers for various package formats
65 |
66 | ## 🚀 Quick Start
67 |
68 | ### Prerequisites
69 |
70 | Make sure you have the following installed:
71 |
72 | - **Node.js** (LTS or higher)
73 | - **pnpm** (v10 or higher) - This project uses pnpm as the package manager
74 |
75 | ### Installation
76 |
77 | 1. **Clone the repository**
78 |
79 | ```bash
80 | git clone https://github.com/flaviodelgrosso/reactronite.git
81 | cd reactronite
82 | ```
83 |
84 | 2. **Install dependencies**
85 |
86 | ```bash
87 | pnpm install
88 | ```
89 |
90 | 3. **Start development**
91 |
92 | ```bash
93 | pnpm dev
94 | ```
95 |
96 | That's it! Your application will launch in development mode with hot reloading enabled.
97 |
98 | ### Available Scripts
99 |
100 | | Command | Description |
101 | |---------|-------------|
102 | | `pnpm dev` | Start the app in development mode with hot reloading |
103 | | `pnpm package` | Package the app for the current platform |
104 | | `pnpm make` | Create distributable packages for the current platform |
105 | | `pnpm publish` | Publish the app (configure publishers in forge.config.ts) |
106 |
107 | ## 📁 Project Structure
108 |
109 | ```txt
110 | ├── src/
111 | │ ├── main.ts # Main Electron process
112 | │ ├── preload.ts # Preload script for secure IPC
113 | │ ├── app/ # React application
114 | │ │ ├── App.tsx # Main app component
115 | │ │ ├── components/ # Reusable UI components
116 | │ │ ├── screens/ # Application screens/pages
117 | │ ├── menu/ # Application menu configuration
118 | │ ├── ipc/ # IPC handlers and channels
119 | │ └── @types/ # TypeScript declarations
120 | ├── config/ # Vite configuration files
121 | ├── assets/ # Static assets (icons, fonts, images)
122 | ```
123 |
124 | ## 🔧 Configuration
125 |
126 | ### Customizing the Build
127 |
128 | The project uses Electron Forge for building and packaging. You can customize the build process by modifying:
129 |
130 | - **`forge.config.ts`** - Main Forge configuration
131 | - **`config/vite.*.config.ts`** - Vite configurations for different processes
132 | - **`package.json`** - Scripts and metadata
133 |
134 | ### Adding New Features
135 |
136 | The boilerplate is designed to be easily extensible:
137 |
138 | 1. **New UI Components** - Add to `src/app/components/`
139 | 2. **New Screens** - Add to `src/app/screens/`
140 | 3. **IPC Channels** - Define in `src/channels/` and handle in `src/ipc/`
141 | 4. **Styling** - Use TailwindCSS classes in your components or create custom styles in `src/app/styles/`
142 |
143 | ## 🤝 Contributing
144 |
145 | We love contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
146 |
147 | - 📋 Code of Conduct
148 | - 🐛 Bug Reports
149 | - 💡 Feature Requests
150 | - 🔧 Development Setup
151 | - 📝 Pull Request Process
152 |
153 | ## 📄 License
154 |
155 | This project is licensed under the [MIT License](./LICENSE) - feel free to use it for your own projects!
156 |
157 | ---
158 |
159 |
160 |
161 | **[⭐ Star this repo](https://github.com/flaviodelgrosso/reactronite)** if you found it helpful!
162 |
163 | Made with ❤️ by [Flavio Del Grosso](https://github.com/flaviodelgrosso)
164 |
165 |
166 |
--------------------------------------------------------------------------------
/assets/icons/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flaviodelgrosso/reactronite/0d760ef14fc3f21baa9906609394f56b9527f837/assets/icons/icon.icns
--------------------------------------------------------------------------------
/assets/icons/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flaviodelgrosso/reactronite/0d760ef14fc3f21baa9906609394f56b9527f837/assets/icons/icon.ico
--------------------------------------------------------------------------------
/assets/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flaviodelgrosso/reactronite/0d760ef14fc3f21baa9906609394f56b9527f837/assets/icons/icon.png
--------------------------------------------------------------------------------
/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/app/styles/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/app/components",
15 | "utils": "@/app/lib/utils",
16 | "ui": "@/app/components/ui",
17 | "lib": "@/app/lib",
18 | "hooks": "@/app/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/config/vite.base.config.ts:
--------------------------------------------------------------------------------
1 | import { builtinModules } from 'node:module';
2 |
3 | import pkg from '../package.json';
4 |
5 | import type { AddressInfo } from 'node:net';
6 | import type { ConfigEnv, Plugin, UserConfig } from 'vite';
7 |
8 | export const builtins = ['electron', ...builtinModules.flatMap((m) => [m, `node:${m}`])];
9 |
10 | export const external = [
11 | ...builtins,
12 | ...Object.keys('dependencies' in pkg ? (pkg.dependencies as Record) : {})
13 | ];
14 |
15 | export function getBuildConfig (env: ConfigEnv<'build'>): UserConfig {
16 | const { root, mode, command } = env;
17 |
18 | return {
19 | root,
20 | mode,
21 | build: {
22 | // Prevent multiple builds from interfering with each other.
23 | emptyOutDir: false,
24 | // 🚧 Multiple builds may conflict.
25 | outDir: '.vite/build',
26 | watch: command === 'serve' ? {} : null,
27 | minify: command === 'build'
28 | },
29 | clearScreen: false
30 | };
31 | }
32 |
33 | export function getDefineKeys (names: string[]) {
34 | const define: { [name: string]: VitePluginRuntimeKeys } = {};
35 |
36 | return names.reduce((acc, name) => {
37 | const NAME = name.toUpperCase();
38 | const keys: VitePluginRuntimeKeys = {
39 | VITE_DEV_SERVER_URL: `${NAME}_VITE_DEV_SERVER_URL`,
40 | VITE_NAME: `${NAME}_VITE_NAME`
41 | };
42 |
43 | acc[name] = keys;
44 | return acc;
45 | }, define);
46 | }
47 |
48 | export function getBuildDefine (env: ConfigEnv<'build'>) {
49 | const { command, forgeConfig } = env;
50 | const names = forgeConfig.renderer
51 | .filter(({ name }) => name != null)
52 | .map(({ name }) => name as string);
53 | const defineKeys = getDefineKeys(names);
54 | const define = Object.entries(defineKeys).reduce(
55 | (acc, [name, keys]) => {
56 | const { VITE_DEV_SERVER_URL, VITE_NAME } = keys;
57 | const def = {
58 | [VITE_DEV_SERVER_URL]:
59 | command === 'serve' ? JSON.stringify(process.env[VITE_DEV_SERVER_URL]) : undefined,
60 | [VITE_NAME]: JSON.stringify(name)
61 | };
62 | return Object.assign(acc, def);
63 | },
64 | {} as Record
65 | );
66 |
67 | return define;
68 | }
69 |
70 | export function pluginExposeRenderer (name: string): Plugin {
71 | const { VITE_DEV_SERVER_URL } = getDefineKeys([name])[name];
72 |
73 | return {
74 | name: '@electron-forge/plugin-vite:expose-renderer',
75 | configureServer (server) {
76 | process.viteDevServers ??= {};
77 | // Expose server for preload scripts hot reload.
78 | process.viteDevServers[name] = server;
79 |
80 | server.httpServer?.once('listening', () => {
81 | const addressInfo = server.httpServer?.address() as AddressInfo;
82 | // Expose env constant for main process use.
83 | process.env[VITE_DEV_SERVER_URL] = `http://localhost:${addressInfo?.port}`;
84 | });
85 | }
86 | };
87 | }
88 |
89 | export function pluginHotRestart (command: 'reload' | 'restart'): Plugin {
90 | return {
91 | name: '@electron-forge/plugin-vite:hot-restart',
92 | closeBundle () {
93 | if (command === 'reload') {
94 | for (const server of Object.values(process.viteDevServers)) {
95 | // Preload scripts hot reload.
96 | server.ws.send({ type: 'full-reload' });
97 | }
98 | } else {
99 | // Main process hot restart.
100 | // https://github.com/electron/forge/blob/v7.2.0/packages/api/core/src/api/start.ts#L216-L223
101 | process.stdin.emit('data', 'rs');
102 | }
103 | }
104 | };
105 | }
106 |
--------------------------------------------------------------------------------
/config/vite.main.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, mergeConfig } from 'vite';
2 | import { checker } from 'vite-plugin-checker';
3 | import viteTsconfigPaths from 'vite-tsconfig-paths';
4 |
5 | import { external, getBuildConfig, getBuildDefine, pluginHotRestart } from './vite.base.config';
6 |
7 | import type { ConfigEnv, UserConfig } from 'vite';
8 |
9 | // https://vitejs.dev/config
10 | export default defineConfig((env) => {
11 | const forgeEnv = env as ConfigEnv<'build'>;
12 | const { forgeConfigSelf } = forgeEnv;
13 | const define = getBuildDefine(forgeEnv);
14 | const config: UserConfig = {
15 | build: {
16 | lib: {
17 | entry: forgeConfigSelf.entry,
18 | fileName: () => '[name].js',
19 | formats: ['es']
20 | },
21 | rollupOptions: {
22 | external
23 | }
24 | },
25 | plugins: [
26 | pluginHotRestart('restart'),
27 | viteTsconfigPaths(),
28 | checker({
29 | typescript: true,
30 | eslint: {
31 | lintCommand: 'eslint',
32 | useFlatConfig: true
33 | }
34 | })
35 | ],
36 | define,
37 | resolve: {
38 | // Load the Node.js entry.
39 | mainFields: [
40 | 'module',
41 | 'jsnext:main',
42 | 'jsnext'
43 | ]
44 | }
45 | };
46 |
47 | return mergeConfig(getBuildConfig(forgeEnv), config);
48 | });
49 |
--------------------------------------------------------------------------------
/config/vite.preload.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, mergeConfig } from 'vite';
2 |
3 | import { external, getBuildConfig, pluginHotRestart } from './vite.base.config';
4 |
5 | import type { ConfigEnv, UserConfig } from 'vite';
6 |
7 | // https://vitejs.dev/config
8 | export default defineConfig((env) => {
9 | const forgeEnv = env as ConfigEnv<'build'>;
10 | const { forgeConfigSelf } = forgeEnv;
11 | const config: UserConfig = {
12 | build: {
13 | rollupOptions: {
14 | external,
15 | // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
16 | input: forgeConfigSelf.entry,
17 | output: {
18 | format: 'es',
19 | // It should not be split chunks.
20 | inlineDynamicImports: true,
21 | entryFileNames: '[name].js',
22 | chunkFileNames: '[name].js',
23 | assetFileNames: '[name].[ext]'
24 | }
25 | }
26 | },
27 | plugins: [pluginHotRestart('reload')]
28 | };
29 |
30 | return mergeConfig(getBuildConfig(forgeEnv), config);
31 | });
32 |
--------------------------------------------------------------------------------
/config/vite.renderer.config.ts:
--------------------------------------------------------------------------------
1 | import tailwindcss from '@tailwindcss/vite';
2 | import { type ConfigEnv, defineConfig } from 'vite';
3 | import { checker } from 'vite-plugin-checker';
4 | import svgrPlugin from 'vite-plugin-svgr';
5 | import viteTsconfigPaths from 'vite-tsconfig-paths';
6 |
7 | import { pluginExposeRenderer } from './vite.base.config';
8 |
9 | import { productName, version } from '../package.json';
10 |
11 | // https://vitejs.dev/config
12 | export default defineConfig((env) => {
13 | const forgeEnv = env as ConfigEnv<'renderer'>;
14 | const { root, mode, forgeConfigSelf } = forgeEnv;
15 | const name = forgeConfigSelf.name ?? '';
16 |
17 | return {
18 | root,
19 | mode,
20 | base: './',
21 | build: {
22 | outDir: `.vite/renderer/${name}`
23 | },
24 | resolve: {
25 | preserveSymlinks: true
26 | },
27 | clearScreen: false,
28 | plugins: [
29 | pluginExposeRenderer(name),
30 | svgrPlugin(),
31 | viteTsconfigPaths(),
32 | checker({
33 | typescript: true,
34 | eslint: {
35 | lintCommand: 'eslint',
36 | useFlatConfig: true
37 | }
38 | }),
39 | tailwindcss()
40 | ],
41 | define: {
42 | __DARWIN__: process.platform === 'darwin',
43 | __WIN32__: process.platform === 'win32',
44 | __LINUX__: process.platform === 'linux',
45 | __APP_NAME__: JSON.stringify(productName),
46 | __APP_VERSION__: JSON.stringify(version),
47 | __DEV__: process.env.NODE_ENV === 'development'
48 | }
49 | };
50 | });
51 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import neo, { resolveIgnoresFromGitignore } from 'neostandard';
2 |
3 | export default [
4 | ...neo({
5 | ts: true,
6 | semi: true,
7 | ignores: resolveIgnoresFromGitignore()
8 | }),
9 | {
10 | rules: {
11 | 'import-x/order': [
12 | 'warn',
13 | {
14 | 'newlines-between': 'always',
15 | groups: [
16 | 'builtin',
17 | 'internal',
18 | 'external',
19 | 'sibling',
20 | 'parent',
21 | 'index',
22 | 'type'
23 | ],
24 | pathGroupsExcludedImportTypes: ['type'],
25 | alphabetize: {
26 | order: 'asc',
27 | caseInsensitive: true
28 | }
29 | }
30 | ]
31 | }
32 | }
33 | ];
34 |
--------------------------------------------------------------------------------
/forge.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | import { FuseV1Options, FuseVersion } from '@electron/fuses';
4 | import { MakerDeb } from '@electron-forge/maker-deb';
5 | import { MakerRpm } from '@electron-forge/maker-rpm';
6 | import { MakerSquirrel } from '@electron-forge/maker-squirrel';
7 | import { MakerZIP } from '@electron-forge/maker-zip';
8 | import { FusesPlugin } from '@electron-forge/plugin-fuses';
9 | import { VitePlugin } from '@electron-forge/plugin-vite';
10 |
11 | import { author, productName } from './package.json';
12 |
13 | import type { ForgeConfig } from '@electron-forge/shared-types';
14 |
15 | const rootDir = process.cwd();
16 |
17 | const config: ForgeConfig = {
18 | packagerConfig: {
19 | // Create asar archive for main, renderer process files
20 | asar: true,
21 | // Set executable name
22 | executableName: productName,
23 | // Set application copyright
24 | appCopyright: `Copyright © ${new Date().getFullYear()} ${author.name}`,
25 | // Set application icon
26 | icon: path.resolve(rootDir, 'assets/icons/icon')
27 | },
28 | rebuildConfig: {},
29 | makers: [
30 | new MakerSquirrel({ name: productName }),
31 | new MakerZIP({}, ['darwin']),
32 | new MakerRpm({}),
33 | new MakerDeb({})
34 | ],
35 | plugins: [
36 | new VitePlugin({
37 | // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
38 | // If you are familiar with Vite configuration, it will look really familiar.
39 | build: [
40 | {
41 | // `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
42 | entry: 'src/main.ts',
43 | config: 'config/vite.main.config.ts'
44 | },
45 | {
46 | entry: 'src/preload.ts',
47 | config: 'config/vite.preload.config.ts'
48 | }
49 | ],
50 | renderer: [
51 | {
52 | name: 'main_window',
53 | config: 'config/vite.renderer.config.ts'
54 | }
55 | ]
56 | }),
57 | // Fuses are used to enable/disable various Electron functionality
58 | // at package time, before code signing the application
59 | new FusesPlugin({
60 | version: FuseVersion.V1,
61 | [FuseV1Options.RunAsNode]: false,
62 | [FuseV1Options.EnableCookieEncryption]: true,
63 | [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
64 | [FuseV1Options.EnableNodeCliInspectArguments]: false,
65 | [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
66 | [FuseV1Options.OnlyLoadAppFromAsar]: true
67 | })
68 | ]
69 | };
70 |
71 | export default config;
72 |
--------------------------------------------------------------------------------
/forge.env.d.ts:
--------------------------------------------------------------------------------
1 | export {}; // Make this a module
2 |
3 | declare global {
4 | // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite
5 | // plugin that tells the Electron app where to look for the Vite-bundled app code (depending on
6 | // whether you're running in development or production).
7 | const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
8 | const MAIN_WINDOW_VITE_NAME: string;
9 |
10 | namespace NodeJS {
11 | interface Process {
12 | // Used for hot reload after preload scripts.
13 | viteDevServers: Record;
14 | }
15 | }
16 |
17 | type VitePluginConfig = ConstructorParameters<
18 | typeof import('@electron-forge/plugin-vite').VitePlugin
19 | >[0];
20 |
21 | interface VitePluginRuntimeKeys {
22 | VITE_DEV_SERVER_URL: `${string}_VITE_DEV_SERVER_URL`;
23 | VITE_NAME: `${string}_VITE_NAME`;
24 | }
25 | }
26 |
27 | declare module 'vite' {
28 | interface ConfigEnv {
29 | root: string;
30 | forgeConfig: VitePluginConfig;
31 | forgeConfigSelf: VitePluginConfig[K][number];
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Electron Vite React
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reactronite",
3 | "productName": "Reactronite",
4 | "version": "2.3.2",
5 | "description": "A modern, feature-rich Electron kit for building cross-platform desktop applications",
6 | "main": ".vite/build/main.js",
7 | "license": "MIT",
8 | "packageManager": "pnpm@10.18.3",
9 | "type": "module",
10 | "keywords": [
11 | "electron",
12 | "react",
13 | "typescript",
14 | "boilerplate",
15 | "electron-forge",
16 | "vite"
17 | ],
18 | "author": {
19 | "name": "Flavio Del Grosso"
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "https://github.com/flaviodelgrosso/reactronite.git"
24 | },
25 | "pnpm": {
26 | "onlyBuiltDependencies": [
27 | "electron"
28 | ]
29 | },
30 | "scripts": {
31 | "dev": "cross-env NODE_ENV=development electron-forge start",
32 | "package": "electron-forge package",
33 | "make": "electron-forge make",
34 | "publish": "electron-forge publish",
35 | "lint": "eslint",
36 | "lint:fix": "eslint --fix",
37 | "format": "prettier --write src",
38 | "clean": "rimraf node_modules .vite pnpm-lock.yaml",
39 | "postinstall": "husky",
40 | "prepack": "pinst --disable",
41 | "postpack": "pinst --enable",
42 | "release": "standard-version",
43 | "major": "npm run release -- --release-as major",
44 | "minor": "npm run release -- --release-as minor",
45 | "patch": "npm run release -- --release-as patch",
46 | "push-release": "git push --follow-tags origin master"
47 | },
48 | "dependencies": {
49 | "@radix-ui/react-dropdown-menu": "^2.1.15",
50 | "@radix-ui/react-slot": "^1.2.3",
51 | "class-variance-authority": "^0.7.1",
52 | "clsx": "^2.1.1",
53 | "electron-squirrel-startup": "^1.0.1",
54 | "electron-window-state": "^5.0.3",
55 | "lucide-react": "^0.544.0",
56 | "prettier": "^3.6.2",
57 | "prettier-plugin-space-before-function-paren": "^0.0.9",
58 | "react": "^19.1.1",
59 | "react-dom": "^19.1.1",
60 | "react-router-dom": "^7.7.1",
61 | "tailwind-merge": "^3.3.1",
62 | "tw-animate-css": "^1.3.6"
63 | },
64 | "devDependencies": {
65 | "@commitlint/cli": "^20.0.0",
66 | "@commitlint/config-conventional": "^20.0.0",
67 | "@electron-forge/cli": "^7.8.2",
68 | "@electron-forge/maker-deb": "^7.8.2",
69 | "@electron-forge/maker-rpm": "^7.8.2",
70 | "@electron-forge/maker-squirrel": "^7.8.2",
71 | "@electron-forge/maker-zip": "^7.8.2",
72 | "@electron-forge/plugin-auto-unpack-natives": "^7.8.2",
73 | "@electron-forge/plugin-fuses": "^7.8.2",
74 | "@electron-forge/plugin-vite": "^7.8.2",
75 | "@electron-forge/shared-types": "^7.8.2",
76 | "@electron/fuses": "^2.0.0",
77 | "@tailwindcss/vite": "^4.1.11",
78 | "@types/electron-squirrel-startup": "^1.0.2",
79 | "@types/react": "^19.1.9",
80 | "@types/react-dom": "^19.1.7",
81 | "@vitejs/plugin-react": "5.0.4",
82 | "classnames": "^2.5.1",
83 | "cross-env": "^10.0.0",
84 | "electron": "38.2.1",
85 | "electron-devtools-installer": "^4.0.0",
86 | "eslint": "^9.32.0",
87 | "husky": "9.1.7",
88 | "lint-staged": "^16.1.2",
89 | "neostandard": "^0.12.2",
90 | "pinst": "^3.0.0",
91 | "rimraf": "^6.0.1",
92 | "standard-version": "^9.5.0",
93 | "tailwindcss": "^4.1.11",
94 | "ts-node": "10.9.2",
95 | "typescript": "~5.9.0",
96 | "vite": "^7.0.6",
97 | "vite-plugin-checker": "^0.11.0",
98 | "vite-plugin-svgr": "4.5.0",
99 | "vite-tsconfig-paths": "^5.1.4"
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | arrowParens: 'always',
3 | bracketSameLine: false,
4 | bracketSpacing: true,
5 | embeddedLanguageFormatting: 'auto',
6 | endOfLine: 'lf',
7 | htmlWhitespaceSensitivity: 'css',
8 | insertPragma: false,
9 | jsxSingleQuote: true,
10 | printWidth: 120,
11 | proseWrap: 'preserve',
12 | quoteProps: 'as-needed',
13 | requirePragma: false,
14 | semi: true,
15 | singleAttributePerLine: false,
16 | singleQuote: true,
17 | tabWidth: 2,
18 | trailingComma: 'es5',
19 | useTabs: false,
20 | vueIndentScriptAndStyle: false,
21 | plugins: ['prettier-plugin-space-before-function-paren'],
22 | };
23 |
--------------------------------------------------------------------------------
/screenshots/window-mac.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flaviodelgrosso/reactronite/0d760ef14fc3f21baa9906609394f56b9527f837/screenshots/window-mac.png
--------------------------------------------------------------------------------
/screenshots/window-win.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flaviodelgrosso/reactronite/0d760ef14fc3f21baa9906609394f56b9527f837/screenshots/window-win.png
--------------------------------------------------------------------------------
/src/@types/electron-forge.d.ts:
--------------------------------------------------------------------------------
1 | // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite
2 | // plugin that tells the Electron app where to look for the Vite-bundled app code (depending on
3 | // whether you're running in development or production).
4 | declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
5 | declare const MAIN_WINDOW_VITE_NAME: string;
6 |
--------------------------------------------------------------------------------
/src/@types/globals.d.ts:
--------------------------------------------------------------------------------
1 | import type { globals } from 'src/preload';
2 |
3 | declare global {
4 | const electron: typeof globals;
5 | const __WIN32__: boolean;
6 | const __DARWIN__: boolean;
7 | const __LINUX__: boolean;
8 | const __DEV__: boolean;
9 | const __APP_NAME__: string;
10 | const __APP_VERSION__: string;
11 | }
12 |
--------------------------------------------------------------------------------
/src/@types/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.css';
2 | declare module '*.png';
3 | declare module '*.jpg';
4 | declare module '*.jpeg';
5 | declare module '*.svg';
6 |
--------------------------------------------------------------------------------
/src/app/App.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider } from '@/app/components/theme-provider';
2 | import Titlebar from '@/app/components/titlebar';
3 | import { useRendererListener } from '@/app/hooks';
4 | import { LandingScreen } from '@/app/screens/landing';
5 | import { MenuChannels } from '@/channels/menuChannels';
6 |
7 | import { Route, HashRouter as Router, Routes } from 'react-router-dom';
8 |
9 | const onMenuEvent = (_: Electron.IpcRendererEvent, channel: string, ...args: unknown[]) => {
10 | electron.ipcRenderer.invoke(channel, args);
11 | };
12 |
13 | export default function App () {
14 | useRendererListener(MenuChannels.MENU_EVENT, onMenuEvent);
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/components/control-button.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 |
3 | import type React from 'react';
4 |
5 | interface IControlButtonProps {
6 | name: string;
7 | onClick: React.MouseEventHandler | React.KeyboardEventHandler;
8 | path: string;
9 | }
10 |
11 | const ControlButton: React.FC = ({ name, onClick, path }) => {
12 | const className = classNames('control', name);
13 | const title = name[0].toUpperCase() + name.substring(1);
14 |
15 | return (
16 | }
21 | onKeyDown={onClick as React.KeyboardEventHandler}
22 | title={title}
23 | tabIndex={0}
24 | >
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default ControlButton;
33 |
--------------------------------------------------------------------------------
/src/app/components/menu-item.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 |
3 | import type React from 'react';
4 |
5 | interface IMenuItemProps {
6 | label: string;
7 | submenu?: Electron.MenuItemConstructorOptions[];
8 | onMenuClick: (e: React.MouseEvent) => void;
9 | onMenuMouseEnter: (e: React.MouseEvent) => void;
10 | onMenuMouseDown: (e: React.MouseEvent) => void;
11 | }
12 |
13 | const PopupItem = forwardRef>(({ submenu }, ref) => (
14 |
15 | {submenu?.map((menuItem, menuItemIndex) => {
16 | if (menuItem.type === 'separator') {
17 | return
;
18 | }
19 |
20 | return (
21 |
e.preventDefault()}
25 | onKeyDown={(e) => e.preventDefault()}
26 | type='button'
27 | tabIndex={0}
28 | >
29 | {menuItem.label}
30 | {menuItem.accelerator}
31 |
32 | );
33 | })}
34 |
35 | ));
36 |
37 | PopupItem.displayName = 'PopupItem';
38 |
39 | export const MenuItem: React.FC = ({
40 | label,
41 | submenu,
42 | onMenuClick,
43 | onMenuMouseDown,
44 | onMenuMouseEnter,
45 | }) => (
46 |
47 |
e.preventDefault()}
53 | type='button'
54 | tabIndex={0}
55 | >
56 | {label}
57 |
58 |
59 |
60 | );
61 |
--------------------------------------------------------------------------------
/src/app/components/menu.tsx:
--------------------------------------------------------------------------------
1 | import { useEventListener } from '@/app/hooks';
2 | import { MenuChannels } from '@/channels/menuChannels';
3 | import { fixAcceleratorText } from '@/menu/accelerators';
4 | import menuList from '@/menu/appMenu';
5 | import appLogo from 'assets/icons/icon.png';
6 |
7 | import { createRef, useMemo, useRef } from 'react';
8 |
9 | import type React from 'react';
10 |
11 | export default function Menu () {
12 | const activeMenuIndex = useRef(null);
13 | const menusRef = useMemo(() => menuList.map(() => createRef()), []);
14 |
15 | useEventListener('keydown', (event) => handleKeyDown(event));
16 |
17 | useEventListener('mousedown', (event) => handleClickOutside(event));
18 |
19 | const handleKeyDown = (e: KeyboardEvent) => {
20 | if (e.repeat) return;
21 | if (e.altKey) activeMenuIndex.current && closeActiveMenu();
22 | };
23 |
24 | const handleClickOutside = (event: MouseEvent) => {
25 | if (activeMenuIndex.current != null) {
26 | if (
27 | menusRef[activeMenuIndex.current].current &&
28 | !menusRef[activeMenuIndex.current].current?.contains(event.target as Node)
29 | ) {
30 | closeActiveMenu();
31 | }
32 | }
33 | };
34 |
35 | const showMenu = (index: number, e: React.MouseEvent | React.KeyboardEvent) => {
36 | e.stopPropagation();
37 | e.preventDefault();
38 |
39 | if (menusRef[index].current?.classList.contains('active')) {
40 | closeActiveMenu();
41 | } else {
42 | menusRef[index].current?.classList.add('active');
43 | menusRef[index].current?.parentElement?.classList.add('active');
44 | activeMenuIndex.current = index;
45 | }
46 | };
47 |
48 | const onMenuHover = (index: number) => {
49 | if (activeMenuIndex.current != null) {
50 | menusRef[activeMenuIndex.current].current?.classList.toggle('active');
51 | menusRef[index].current?.classList.toggle('active');
52 | menusRef[index].current?.parentElement?.classList.toggle('active');
53 | menusRef[activeMenuIndex.current].current?.parentElement?.classList.toggle('active');
54 |
55 | activeMenuIndex.current = index;
56 | }
57 | };
58 |
59 | const closeActiveMenu = () => {
60 | if (activeMenuIndex.current != null) {
61 | menusRef[activeMenuIndex.current].current?.classList.remove('active');
62 | menusRef[activeMenuIndex.current].current?.parentElement?.classList.remove('active');
63 | activeMenuIndex.current = null;
64 | }
65 | };
66 |
67 | const handleAction = (menuItem: Electron.MenuItemConstructorOptions) => {
68 | closeActiveMenu();
69 | const actionId = menuItem.id;
70 | if (actionId) {
71 | if (actionId === MenuChannels.OPEN_GITHUB_PROFILE) {
72 | return electron.ipcRenderer.invoke(actionId, menuItem.label);
73 | }
74 | return electron.ipcRenderer.send(MenuChannels.EXECUTE_MENU_ITEM_BY_ID, actionId);
75 | }
76 | };
77 |
78 | const renderItemAccelerator = (menuItem: Electron.MenuItemConstructorOptions) => {
79 | if (menuItem.id === MenuChannels.WEB_ZOOM_IN) {
80 | const firstKey = __DARWIN__ ? '⌘' : 'Ctrl';
81 | const plus = __DARWIN__ ? '' : '+';
82 | const thirdKey = '+';
83 | return `${firstKey}${plus}${thirdKey}`;
84 | }
85 |
86 | if (menuItem.accelerator) {
87 | return fixAcceleratorText(menuItem.accelerator);
88 | }
89 | };
90 |
91 | return (
92 |
93 | {/* Titlebar icon */}
94 |
95 |
96 |
97 |
98 | {menuList.map(({ label, submenu }, menuIndex) => {
99 | return (
100 |
101 |
showMenu(menuIndex, e)}
106 | onKeyDown={(e) => showMenu(menuIndex, e)}
107 | onMouseEnter={() => onMenuHover(menuIndex)}
108 | onDoubleClick={(e) => e.stopPropagation()}
109 | onMouseDown={(e) => e.preventDefault()}
110 | >
111 | {label}
112 |
113 |
114 | {Array.isArray(submenu) &&
115 | submenu.map((menuItem, menuItemIndex) => {
116 | if (menuItem.type === 'separator') {
117 | return (
118 |
119 | );
120 | }
121 |
122 | return (
123 |
e.preventDefault()}
127 | onKeyDown={(e) => e.preventDefault()}
128 | type='button'
129 | tabIndex={0}
130 | onClick={() => handleAction(menuItem)}
131 | >
132 | {menuItem.label}
133 | {renderItemAccelerator(menuItem)}
134 |
135 | );
136 | })}
137 |
138 |
139 | );
140 | })}
141 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/src/app/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from '@/app/components/theme-provider';
2 | import { Button } from '@/app/components/ui/button';
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuTrigger,
8 | } from '@/app/components/ui/dropdown-menu';
9 |
10 | import { Moon, Sun } from 'lucide-react';
11 |
12 | export function ModeToggle () {
13 | const { setTheme } = useTheme();
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | Toggle theme
22 |
23 |
24 |
25 | setTheme('light')}>Light
26 | setTheme('dark')}>Dark
27 | setTheme('system')}>System
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from 'react';
2 |
3 | type Theme = 'dark' | 'light' | 'system';
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode;
7 | defaultTheme?: Theme;
8 | storageKey?: string;
9 | };
10 |
11 | type ThemeProviderState = {
12 | theme: Theme;
13 | setTheme: (theme: Theme) => void;
14 | };
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: 'system',
18 | setTheme: () => null,
19 | };
20 |
21 | const ThemeProviderContext = createContext(initialState);
22 |
23 | export function ThemeProvider ({
24 | children,
25 | defaultTheme = 'system',
26 | storageKey = 'vite-ui-theme',
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme);
30 |
31 | useEffect(() => {
32 | const root = window.document.documentElement;
33 |
34 | root.classList.remove('light', 'dark');
35 |
36 | if (theme === 'system') {
37 | const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
38 |
39 | root.classList.add(systemTheme);
40 | return;
41 | }
42 |
43 | root.classList.add(theme);
44 | }, [theme]);
45 |
46 | const value = {
47 | theme,
48 | setTheme: (theme: Theme) => {
49 | localStorage.setItem(storageKey, theme);
50 | setTheme(theme);
51 | },
52 | };
53 |
54 | return (
55 |
56 | {children}
57 |
58 | );
59 | }
60 |
61 | export const useTheme = () => {
62 | const context = useContext(ThemeProviderContext);
63 |
64 | if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
65 |
66 | return context;
67 | };
68 |
--------------------------------------------------------------------------------
/src/app/components/titlebar.tsx:
--------------------------------------------------------------------------------
1 | import { useRendererListener } from '@/app/hooks';
2 | import { MenuChannels } from '@/channels/menuChannels';
3 |
4 | import { useState } from 'react';
5 |
6 | import Menu from './menu';
7 | import WindowControls from './window-controls';
8 |
9 | import type { WindowState } from '@/windowState';
10 |
11 | const handleDoubleClick = () => {
12 | electron.ipcRenderer.invoke(MenuChannels.WINDOW_TOGGLE_MAXIMIZE);
13 | };
14 |
15 | export default function Titlebar () {
16 | const [windowState, setWindowState] = useState('normal');
17 |
18 | useRendererListener('window-state-changed', (_, windowState: WindowState) => setWindowState(windowState));
19 |
20 | // Hide titlebar in full screen mode on macOS
21 | if (windowState === 'full-screen' && __DARWIN__) {
22 | return null;
23 | }
24 |
25 | return (
26 |
27 | {__WIN32__ && (
28 | <>
29 |
30 |
31 | >
32 | )}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/app/lib/utils';
2 |
3 | import { Slot } from '@radix-ui/react-slot';
4 | import { type VariantProps, cva } from 'class-variance-authority';
5 |
6 | import type * as React from 'react';
7 |
8 | const badgeVariants = cva(
9 | 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
10 | {
11 | variants: {
12 | variant: {
13 | default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
14 | secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
15 | destructive:
16 | 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
17 | outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | },
23 | }
24 | );
25 |
26 | function Badge ({
27 | className,
28 | variant,
29 | asChild = false,
30 | ...props
31 | }: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) {
32 | const Comp = asChild ? Slot : 'span';
33 |
34 | return ;
35 | }
36 |
37 | export { Badge, badgeVariants };
38 |
--------------------------------------------------------------------------------
/src/app/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/app/lib/utils';
2 |
3 | import { Slot } from '@radix-ui/react-slot';
4 | import { type VariantProps, cva } from 'class-variance-authority';
5 |
6 | import type * as React from 'react';
7 |
8 | const buttonVariants = cva(
9 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
10 | {
11 | variants: {
12 | variant: {
13 | default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
14 | destructive:
15 | 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
16 | outline:
17 | 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
18 | secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
19 | ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | },
22 | size: {
23 | default: 'h-9 px-4 py-2 has-[>svg]:px-3',
24 | sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
25 | lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
26 | icon: 'size-9',
27 | },
28 | },
29 | defaultVariants: {
30 | variant: 'default',
31 | size: 'default',
32 | },
33 | }
34 | );
35 |
36 | function Button ({
37 | className,
38 | variant,
39 | size,
40 | asChild = false,
41 | ...props
42 | }: React.ComponentProps<'button'> &
43 | VariantProps & {
44 | asChild?: boolean;
45 | }) {
46 | const Comp = asChild ? Slot : 'button';
47 |
48 | return ;
49 | }
50 |
51 | export { Button, buttonVariants };
52 |
--------------------------------------------------------------------------------
/src/app/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/app/lib/utils';
2 |
3 | import type * as React from 'react';
4 |
5 | function Card ({ className, ...props }: React.ComponentProps<'div'>) {
6 | return (
7 |
12 | );
13 | }
14 |
15 | function CardHeader ({ className, ...props }: React.ComponentProps<'div'>) {
16 | return (
17 |
25 | );
26 | }
27 |
28 | function CardTitle ({ className, ...props }: React.ComponentProps<'div'>) {
29 | return
;
30 | }
31 |
32 | function CardDescription ({ className, ...props }: React.ComponentProps<'div'>) {
33 | return
;
34 | }
35 |
36 | function CardAction ({ className, ...props }: React.ComponentProps<'div'>) {
37 | return (
38 |
43 | );
44 | }
45 |
46 | function CardContent ({ className, ...props }: React.ComponentProps<'div'>) {
47 | return
;
48 | }
49 |
50 | function CardFooter ({ className, ...props }: React.ComponentProps<'div'>) {
51 | return (
52 |
53 | );
54 | }
55 |
56 | export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
57 |
--------------------------------------------------------------------------------
/src/app/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/app/lib/utils';
2 |
3 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
4 | import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
5 |
6 | import type * as React from 'react';
7 |
8 | function DropdownMenu ({ ...props }: React.ComponentProps) {
9 | return ;
10 | }
11 |
12 | function DropdownMenuPortal ({ ...props }: React.ComponentProps) {
13 | return ;
14 | }
15 |
16 | function DropdownMenuTrigger ({ ...props }: React.ComponentProps) {
17 | return ;
18 | }
19 |
20 | function DropdownMenuContent ({
21 | className,
22 | sideOffset = 4,
23 | ...props
24 | }: React.ComponentProps) {
25 | return (
26 |
27 |
36 |
37 | );
38 | }
39 |
40 | function DropdownMenuGroup ({ ...props }: React.ComponentProps) {
41 | return ;
42 | }
43 |
44 | function DropdownMenuItem ({
45 | className,
46 | inset,
47 | variant = 'default',
48 | ...props
49 | }: React.ComponentProps & {
50 | inset?: boolean;
51 | variant?: 'default' | 'destructive';
52 | }) {
53 | return (
54 |
64 | );
65 | }
66 |
67 | function DropdownMenuCheckboxItem ({
68 | className,
69 | children,
70 | checked,
71 | ...props
72 | }: React.ComponentProps) {
73 | return (
74 |
83 |
84 |
85 |
86 |
87 |
88 | {children}
89 |
90 | );
91 | }
92 |
93 | function DropdownMenuRadioGroup ({ ...props }: React.ComponentProps) {
94 | return ;
95 | }
96 |
97 | function DropdownMenuRadioItem ({
98 | className,
99 | children,
100 | ...props
101 | }: React.ComponentProps) {
102 | return (
103 |
111 |
112 |
113 |
114 |
115 |
116 | {children}
117 |
118 | );
119 | }
120 |
121 | function DropdownMenuLabel ({
122 | className,
123 | inset,
124 | ...props
125 | }: React.ComponentProps & {
126 | inset?: boolean;
127 | }) {
128 | return (
129 |
135 | );
136 | }
137 |
138 | function DropdownMenuSeparator ({ className, ...props }: React.ComponentProps) {
139 | return (
140 |
145 | );
146 | }
147 |
148 | function DropdownMenuShortcut ({ className, ...props }: React.ComponentProps<'span'>) {
149 | return (
150 |
155 | );
156 | }
157 |
158 | function DropdownMenuSub ({ ...props }: React.ComponentProps) {
159 | return ;
160 | }
161 |
162 | function DropdownMenuSubTrigger ({
163 | className,
164 | inset,
165 | children,
166 | ...props
167 | }: React.ComponentProps & {
168 | inset?: boolean;
169 | }) {
170 | return (
171 |
180 | {children}
181 |
182 |
183 | );
184 | }
185 |
186 | function DropdownMenuSubContent ({
187 | className,
188 | ...props
189 | }: React.ComponentProps) {
190 | return (
191 |
199 | );
200 | }
201 |
202 | export {
203 | DropdownMenu,
204 | DropdownMenuPortal,
205 | DropdownMenuTrigger,
206 | DropdownMenuContent,
207 | DropdownMenuGroup,
208 | DropdownMenuLabel,
209 | DropdownMenuItem,
210 | DropdownMenuCheckboxItem,
211 | DropdownMenuRadioGroup,
212 | DropdownMenuRadioItem,
213 | DropdownMenuSeparator,
214 | DropdownMenuShortcut,
215 | DropdownMenuSub,
216 | DropdownMenuSubTrigger,
217 | DropdownMenuSubContent,
218 | };
219 |
--------------------------------------------------------------------------------
/src/app/components/window-controls.tsx:
--------------------------------------------------------------------------------
1 | import ControlButton from '@/app/components/control-button';
2 | import { MenuChannels } from '@/channels/menuChannels';
3 |
4 | import classNames from 'classnames';
5 | import { useCallback } from 'react';
6 |
7 | import type { WindowState } from '@/windowState';
8 |
9 | // These paths are all drawn to a 10x10 view box and replicate the symbols on Windows controls.
10 | const closePath = 'M 0,0 0,0.7 4.3,5 0,9.3 0,10 0.7,10 5,5.7 9.3,10 10,10 10,9.3 5.7,5 10,0.7 10,0 9.3,0 5,4.3 0.7,0 Z';
11 | const restorePath = 'm 2,1e-5 0,2 -2,0 0,8 8,0 0,-2 2,0 0,-8 z m 1,1 6,0 0,6 -1,0 0,-5 -5,0 z m -2,2 6,0 0,6 -6,0 z';
12 | const maximizePath = 'M 0,0 0,10 10,10 10,0 Z M 1,1 9,1 9,9 1,9 Z';
13 | const minimizePath = 'M 0,5 10,5 10,6 0,6 Z';
14 |
15 | interface IWindowControlsProps {
16 | readonly windowState: WindowState;
17 | }
18 |
19 | export default function WindowControls ({ windowState }: IWindowControlsProps) {
20 | const executeWindowCommand = useCallback(
21 | (command: string) => {
22 | electron.ipcRenderer.invoke(command, windowState);
23 | },
24 | [windowState]
25 | );
26 |
27 | return (
28 |
29 | executeWindowCommand(MenuChannels.WINDOW_MINIMIZE)}
32 | path={minimizePath}
33 | />
34 | executeWindowCommand(MenuChannels.WINDOW_TOGGLE_MAXIMIZE)}
37 | path={windowState === 'maximized' ? restorePath : maximizePath}
38 | />
39 | executeWindowCommand(MenuChannels.WINDOW_CLOSE)} path={closePath} />
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useEventListener';
2 | export * from './useRendererListener';
3 |
--------------------------------------------------------------------------------
/src/app/hooks/useEventListener.ts:
--------------------------------------------------------------------------------
1 | import { type RefObject, useEffect, useRef } from 'react';
2 |
3 | // Window Event based useEventListener interface
4 | function useEventListener (
5 | eventName: K,
6 | handler: (event: WindowEventMap[K]) => void,
7 | element?: undefined,
8 | options?: boolean | AddEventListenerOptions
9 | ): void;
10 |
11 | // Element Event based useEventListener interface
12 | function useEventListener (
13 | eventName: K,
14 | handler: (event: HTMLElementEventMap[K]) => void,
15 | element: RefObject,
16 | options?: boolean | AddEventListenerOptions
17 | ): void;
18 |
19 | // Document Event based useEventListener interface
20 | function useEventListener (
21 | eventName: K,
22 | handler: (event: DocumentEventMap[K]) => void,
23 | element: RefObject,
24 | options?: boolean | AddEventListenerOptions
25 | ): void;
26 |
27 | function useEventListener<
28 | KW extends keyof WindowEventMap,
29 | KH extends keyof HTMLElementEventMap,
30 | T extends HTMLElement | undefined = undefined,
31 | > (
32 | eventName: KW | KH,
33 | handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | Event) => void,
34 | element?: RefObject,
35 | options?: boolean | AddEventListenerOptions
36 | ) {
37 | // Create a ref that stores handler
38 | const savedHandler = useRef(handler);
39 |
40 | useEffect(() => {
41 | savedHandler.current = handler;
42 | }, [handler]);
43 |
44 | useEffect(() => {
45 | // Define the listening target
46 | const targetElement: T | Window = element?.current || window;
47 | if (!targetElement?.addEventListener) {
48 | return;
49 | }
50 |
51 | // Create event listener that calls handler function stored in ref
52 | const eventListener: typeof handler = (event) => savedHandler.current(event);
53 |
54 | targetElement.addEventListener(eventName, eventListener, options);
55 |
56 | // Remove event listener on cleanup
57 | return () => {
58 | targetElement.removeEventListener(eventName, eventListener);
59 | };
60 | }, [eventName, element, options]);
61 | }
62 |
63 | export { useEventListener };
64 |
--------------------------------------------------------------------------------
/src/app/hooks/useRendererListener.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import type { RendererListener } from '@/preload';
4 |
5 | export const useRendererListener = (channel: string, listener: RendererListener) => {
6 | useEffect(() => {
7 | electron.ipcRenderer.on(channel, listener);
8 | return () => {
9 | electron.ipcRenderer.removeListener(channel, listener);
10 | };
11 | }, [channel, listener]);
12 | };
13 |
--------------------------------------------------------------------------------
/src/app/index.tsx:
--------------------------------------------------------------------------------
1 | import '@/app/styles/globals.css';
2 |
3 | import App from '@/app/App';
4 |
5 | import { createRoot } from 'react-dom/client';
6 |
7 | const container = document.getElementById('root') as HTMLDivElement;
8 | const root = createRoot(container);
9 |
10 | root.render( );
11 |
--------------------------------------------------------------------------------
/src/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn (...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/screens/landing.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from '@/app/components/ui/badge';
2 | import { Button } from '@/app/components/ui/button';
3 |
4 | import { Github, Sparkles, Zap } from 'lucide-react';
5 |
6 | import { ModeToggle } from '../components/mode-toggle';
7 |
8 | export function LandingScreen () {
9 | return (
10 |
11 | {/* Header */}
12 |
13 |
14 |
15 |
16 |
17 |
18 |
Reactronite
19 |
20 |
21 |
22 |
23 |
24 | {/* Hero Section */}
25 |
26 |
27 |
28 |
29 |
30 |
31 | Modern Electron Development
32 |
33 |
34 | Build Desktop Apps with
35 |
36 | {' '}
37 | Lightning Speed
38 |
39 |
40 |
41 | The ultimate Electron-Forge boilerplate with React, Vite, and TypeScript. Get your desktop application up
42 | and running in minutes, not hours.
43 |
44 |
45 | {
49 | window.open('https://github.com/flaviodelgrosso/reactronite', '_blank');
50 | }}
51 | >
52 |
53 | View on GitHub
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/app/styles/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @import 'tw-animate-css';
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | :root {
7 | --background: oklch(1 0 0);
8 | --foreground: oklch(0.3211 0 0);
9 | --card: oklch(1 0 0);
10 | --card-foreground: oklch(0.3211 0 0);
11 | --popover: oklch(1 0 0);
12 | --popover-foreground: oklch(0.3211 0 0);
13 | --primary: oklch(0.6231 0.188 259.8145);
14 | --primary-foreground: oklch(1 0 0);
15 | --secondary: oklch(0.967 0.0029 264.5419);
16 | --secondary-foreground: oklch(0.4461 0.0263 256.8018);
17 | --muted: oklch(0.9846 0.0017 247.8389);
18 | --muted-foreground: oklch(0.551 0.0234 264.3637);
19 | --accent: oklch(0.9514 0.025 236.8242);
20 | --accent-foreground: oklch(0.3791 0.1378 265.5222);
21 | --destructive: oklch(0.6368 0.2078 25.3313);
22 | --destructive-foreground: oklch(1 0 0);
23 | --border: oklch(0.9276 0.0058 264.5313);
24 | --input: oklch(0.9276 0.0058 264.5313);
25 | --ring: oklch(0.6231 0.188 259.8145);
26 | --chart-1: oklch(0.6231 0.188 259.8145);
27 | --chart-2: oklch(0.5461 0.2152 262.8809);
28 | --chart-3: oklch(0.4882 0.2172 264.3763);
29 | --chart-4: oklch(0.4244 0.1809 265.6377);
30 | --chart-5: oklch(0.3791 0.1378 265.5222);
31 | --sidebar: oklch(0.9846 0.0017 247.8389);
32 | --sidebar-foreground: oklch(0.3211 0 0);
33 | --sidebar-primary: oklch(0.6231 0.188 259.8145);
34 | --sidebar-primary-foreground: oklch(1 0 0);
35 | --sidebar-accent: oklch(0.9514 0.025 236.8242);
36 | --sidebar-accent-foreground: oklch(0.3791 0.1378 265.5222);
37 | --sidebar-border: oklch(0.9276 0.0058 264.5313);
38 | --sidebar-ring: oklch(0.6231 0.188 259.8145);
39 | --font-sans: Inter, sans-serif;
40 | --font-serif: Source Serif 4, serif;
41 | --font-mono: JetBrains Mono, monospace;
42 | --radius: 0.375rem;
43 | --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
44 | --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
45 | --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
46 | --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
47 | --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
48 | --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
49 | --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
50 | --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
51 | }
52 |
53 | .dark {
54 | --background: oklch(0.2046 0 0);
55 | --foreground: oklch(0.9219 0 0);
56 | --card: oklch(0.2686 0 0);
57 | --card-foreground: oklch(0.9219 0 0);
58 | --popover: oklch(0.2686 0 0);
59 | --popover-foreground: oklch(0.9219 0 0);
60 | --primary: oklch(0.6231 0.188 259.8145);
61 | --primary-foreground: oklch(1 0 0);
62 | --secondary: oklch(0.2686 0 0);
63 | --secondary-foreground: oklch(0.9219 0 0);
64 | --muted: oklch(0.2686 0 0);
65 | --muted-foreground: oklch(0.7155 0 0);
66 | --accent: oklch(0.3791 0.1378 265.5222);
67 | --accent-foreground: oklch(0.8823 0.0571 254.1284);
68 | --destructive: oklch(0.6368 0.2078 25.3313);
69 | --destructive-foreground: oklch(1 0 0);
70 | --border: oklch(0.3715 0 0);
71 | --input: oklch(0.3715 0 0);
72 | --ring: oklch(0.6231 0.188 259.8145);
73 | --chart-1: oklch(0.7137 0.1434 254.624);
74 | --chart-2: oklch(0.6231 0.188 259.8145);
75 | --chart-3: oklch(0.5461 0.2152 262.8809);
76 | --chart-4: oklch(0.4882 0.2172 264.3763);
77 | --chart-5: oklch(0.4244 0.1809 265.6377);
78 | --sidebar: oklch(0.2046 0 0);
79 | --sidebar-foreground: oklch(0.9219 0 0);
80 | --sidebar-primary: oklch(0.6231 0.188 259.8145);
81 | --sidebar-primary-foreground: oklch(1 0 0);
82 | --sidebar-accent: oklch(0.3791 0.1378 265.5222);
83 | --sidebar-accent-foreground: oklch(0.8823 0.0571 254.1284);
84 | --sidebar-border: oklch(0.3715 0 0);
85 | --sidebar-ring: oklch(0.6231 0.188 259.8145);
86 | --font-sans: Inter, sans-serif;
87 | --font-serif: Source Serif 4, serif;
88 | --font-mono: JetBrains Mono, monospace;
89 | --radius: 0.375rem;
90 | --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
91 | --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
92 | --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
93 | --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
94 | --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
95 | --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
96 | --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
97 | --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
98 | }
99 |
100 | @theme inline {
101 | --color-background: var(--background);
102 | --color-foreground: var(--foreground);
103 | --color-card: var(--card);
104 | --color-card-foreground: var(--card-foreground);
105 | --color-popover: var(--popover);
106 | --color-popover-foreground: var(--popover-foreground);
107 | --color-primary: var(--primary);
108 | --color-primary-foreground: var(--primary-foreground);
109 | --color-secondary: var(--secondary);
110 | --color-secondary-foreground: var(--secondary-foreground);
111 | --color-muted: var(--muted);
112 | --color-muted-foreground: var(--muted-foreground);
113 | --color-accent: var(--accent);
114 | --color-accent-foreground: var(--accent-foreground);
115 | --color-destructive: var(--destructive);
116 | --color-destructive-foreground: var(--destructive-foreground);
117 | --color-border: var(--border);
118 | --color-input: var(--input);
119 | --color-ring: var(--ring);
120 | --color-chart-1: var(--chart-1);
121 | --color-chart-2: var(--chart-2);
122 | --color-chart-3: var(--chart-3);
123 | --color-chart-4: var(--chart-4);
124 | --color-chart-5: var(--chart-5);
125 | --color-sidebar: var(--sidebar);
126 | --color-sidebar-foreground: var(--sidebar-foreground);
127 | --color-sidebar-primary: var(--sidebar-primary);
128 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
129 | --color-sidebar-accent: var(--sidebar-accent);
130 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
131 | --color-sidebar-border: var(--sidebar-border);
132 | --color-sidebar-ring: var(--sidebar-ring);
133 |
134 | --font-sans: var(--font-sans);
135 | --font-mono: var(--font-mono);
136 | --font-serif: var(--font-serif);
137 |
138 | --radius-sm: calc(var(--radius) - 4px);
139 | --radius-md: calc(var(--radius) - 2px);
140 | --radius-lg: var(--radius);
141 | --radius-xl: calc(var(--radius) + 4px);
142 |
143 | --shadow-2xs: var(--shadow-2xs);
144 | --shadow-xs: var(--shadow-xs);
145 | --shadow-sm: var(--shadow-sm);
146 | --shadow: var(--shadow);
147 | --shadow-md: var(--shadow-md);
148 | --shadow-lg: var(--shadow-lg);
149 | --shadow-xl: var(--shadow-xl);
150 | --shadow-2xl: var(--shadow-2xl);
151 | }
152 |
153 | @layer base {
154 | * {
155 | @apply border-border outline-ring/50;
156 | }
157 |
158 | html,
159 | body,
160 | #root {
161 | @apply bg-background text-foreground h-full;
162 | }
163 | }
164 |
165 | @layer components {
166 | /* Window Titlebar Styles */
167 | .window-titlebar {
168 | @apply flex items-stretch h-8 bg-background select-none sticky top-0 z-50;
169 | -webkit-app-region: drag;
170 | }
171 |
172 | .window-titlebar > section {
173 | @apply flex items-center;
174 | }
175 |
176 | .window-titlebar-icon {
177 | @apply px-2;
178 | }
179 |
180 | .window-titlebar-icon img {
181 | @apply w-5 h-5;
182 | }
183 |
184 | /* Menu Styles */
185 | .menu-item {
186 | @apply relative;
187 | }
188 |
189 | .menu-item.active .menu-title {
190 | @apply bg-secondary;
191 | }
192 |
193 | .menu-title {
194 | @apply hover:bg-secondary px-2.5 py-2 text-xs;
195 | -webkit-app-region: no-drag;
196 | }
197 |
198 | .menu-popup {
199 | @apply hidden fixed bg-popover z-10000 min-w-20;
200 | }
201 |
202 | .menu-popup.active {
203 | @apply block;
204 | }
205 |
206 | .menu-popup-item {
207 | @apply flex py-2 px-4 justify-between hover:bg-accent hover:text-accent-foreground hover:cursor-pointer w-full text-xs;
208 | }
209 |
210 | .menu-popup-item:hover .popup-item-name {
211 | @apply text-accent-foreground;
212 | }
213 |
214 | .menu-popup-item:hover .popup-item-shortcut {
215 | @apply text-muted-foreground;
216 | }
217 |
218 | .popup-item-name {
219 | @apply pr-8 text-popover-foreground;
220 | }
221 |
222 | .popup-item-shortcut {
223 | @apply text-muted-foreground;
224 | }
225 |
226 | .popup-item-separator {
227 | @apply bg-border h-[1px] my-1;
228 | }
229 |
230 | /* Window Controls */
231 | .window-titlebar-controls {
232 | @apply flex items-stretch absolute right-0 top-0 bottom-0;
233 | }
234 |
235 | .control {
236 | @apply flex h-full items-center hover:bg-secondary px-4;
237 | -webkit-app-region: no-drag;
238 | }
239 |
240 | .control.close:hover {
241 | @apply bg-destructive;
242 | }
243 | }
244 |
--------------------------------------------------------------------------------
/src/appWindow.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | import { registerMenuIpc } from '@/ipc/menuIPC';
4 | import appMenu from '@/menu/appMenu';
5 | import { registerWindowStateChangedEvents } from '@/windowState';
6 |
7 | import { BrowserWindow, Menu, app } from 'electron';
8 | import windowStateKeeper from 'electron-window-state';
9 |
10 | let appWindow: BrowserWindow;
11 |
12 | /**
13 | * Create Application Window
14 | * @returns { BrowserWindow } Application Window Instance
15 | */
16 | export function createAppWindow (): BrowserWindow {
17 | const minWidth = 960;
18 | const minHeight = 660;
19 |
20 | const savedWindowState = windowStateKeeper({
21 | defaultWidth: minWidth,
22 | defaultHeight: minHeight,
23 | maximize: false,
24 | });
25 |
26 | const windowOptions: Electron.BrowserWindowConstructorOptions = {
27 | x: savedWindowState.x,
28 | y: savedWindowState.y,
29 | width: savedWindowState.width,
30 | height: savedWindowState.height,
31 | minWidth,
32 | minHeight,
33 | show: false,
34 | autoHideMenuBar: true,
35 | frame: false,
36 | backgroundColor: '#1a1a1a',
37 | webPreferences: {
38 | nodeIntegration: false,
39 | contextIsolation: true,
40 | nodeIntegrationInWorker: false,
41 | nodeIntegrationInSubFrames: false,
42 | preload: path.join(import.meta.dirname, 'preload.js'),
43 | },
44 | };
45 |
46 | if (process.platform === 'darwin') {
47 | windowOptions.titleBarStyle = 'hidden';
48 | }
49 |
50 | // Create new window instance
51 | appWindow = new BrowserWindow(windowOptions);
52 |
53 | // Load the index.html of the app window.
54 | if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
55 | appWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
56 | } else {
57 | appWindow.loadFile(path.join(import.meta.dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
58 | }
59 |
60 | // Build the application menu
61 | const menu = Menu.buildFromTemplate(appMenu);
62 | Menu.setApplicationMenu(menu);
63 |
64 | // Show window when is ready to
65 | appWindow.on('ready-to-show', () => {
66 | appWindow.show();
67 | });
68 |
69 | // Register Inter Process Communication for main process
70 | registerMainIPC();
71 |
72 | savedWindowState.manage(appWindow);
73 |
74 | // Close all windows when main window is closed
75 | appWindow.on('close', () => {
76 | appWindow = null;
77 | app.quit();
78 | });
79 |
80 | return appWindow;
81 | }
82 |
83 | /**
84 | * Register Inter Process Communication
85 | */
86 | function registerMainIPC () {
87 | /**
88 | * Here you can assign IPC related codes for the application window
89 | * to Communicate asynchronously from the main process to renderer processes.
90 | */
91 | registerWindowStateChangedEvents(appWindow);
92 | registerMenuIpc(appWindow);
93 | }
94 |
--------------------------------------------------------------------------------
/src/channels/menuChannels.ts:
--------------------------------------------------------------------------------
1 | export const MenuChannels = {
2 | WINDOW_MINIMIZE: 'window-minimize',
3 | WINDOW_MAXIMIZE: 'window-maximize',
4 | WINDOW_TOGGLE_MAXIMIZE: 'window-toggle-maximize',
5 | WINDOW_CLOSE: 'window-close',
6 | WEB_TOGGLE_DEVTOOLS: 'web-toggle-devtools',
7 | WEB_ACTUAL_SIZE: 'web-actual-size',
8 | WEB_ZOOM_IN: 'web-zoom-in',
9 | WEB_ZOOM_OUT: 'web-zoom-out',
10 | WEB_TOGGLE_FULLSCREEN: 'web-toggle-fullscreen',
11 | OPEN_GITHUB_PROFILE: 'open-github-profile',
12 | MENU_EVENT: 'menu-event',
13 | EXECUTE_MENU_ITEM_BY_ID: 'execute-menu-item-by-id',
14 | SHOW_CONTEXT_MENU: 'show-context-menu',
15 | } as const;
16 |
--------------------------------------------------------------------------------
/src/ipc/menuIPC.ts:
--------------------------------------------------------------------------------
1 | import { MenuChannels } from '@/channels/menuChannels';
2 |
3 | import { BrowserWindow, Menu, ipcMain, shell } from 'electron';
4 |
5 | export const registerMenuIpc = (mainWindow: BrowserWindow) => {
6 | ipcMain.on(MenuChannels.EXECUTE_MENU_ITEM_BY_ID, (event, id) => {
7 | const currentMenu = Menu.getApplicationMenu();
8 |
9 | if (currentMenu === null) {
10 | return;
11 | }
12 |
13 | const menuItem = currentMenu.getMenuItemById(id);
14 | if (menuItem) {
15 | const window = BrowserWindow.fromWebContents(event.sender) || undefined;
16 | menuItem.click(null, window, event.sender);
17 | }
18 | });
19 |
20 | ipcMain.on(MenuChannels.SHOW_CONTEXT_MENU, (event, template) => {
21 | const menu = Menu.buildFromTemplate(template);
22 | const window = BrowserWindow.fromWebContents(event.sender);
23 | if (window) {
24 | menu.popup({ window });
25 | }
26 | });
27 |
28 | ipcMain.handle(MenuChannels.WINDOW_MINIMIZE, () => {
29 | mainWindow.minimize();
30 | });
31 |
32 | ipcMain.handle(MenuChannels.WINDOW_MAXIMIZE, () => {
33 | mainWindow.maximize();
34 | });
35 |
36 | ipcMain.handle(MenuChannels.WINDOW_TOGGLE_MAXIMIZE, () => {
37 | if (mainWindow.isMaximized()) {
38 | mainWindow.unmaximize();
39 | } else {
40 | mainWindow.maximize();
41 | }
42 | });
43 |
44 | ipcMain.handle(MenuChannels.WINDOW_CLOSE, () => {
45 | mainWindow.close();
46 | });
47 |
48 | ipcMain.handle(MenuChannels.WEB_TOGGLE_DEVTOOLS, () => {
49 | mainWindow.webContents.toggleDevTools();
50 | });
51 |
52 | ipcMain.handle(MenuChannels.WEB_ACTUAL_SIZE, () => {
53 | mainWindow.webContents.setZoomLevel(0);
54 | });
55 |
56 | ipcMain.handle(MenuChannels.WEB_ZOOM_IN, () => {
57 | mainWindow.webContents.setZoomLevel(mainWindow.webContents.zoomLevel + 0.5);
58 | });
59 |
60 | ipcMain.handle(MenuChannels.WEB_ZOOM_OUT, () => {
61 | mainWindow.webContents.setZoomLevel(mainWindow.webContents.zoomLevel - 0.5);
62 | });
63 |
64 | ipcMain.handle(MenuChannels.WEB_TOGGLE_FULLSCREEN, () => {
65 | mainWindow.setFullScreen(!mainWindow.fullScreen);
66 | });
67 |
68 | ipcMain.handle(MenuChannels.OPEN_GITHUB_PROFILE, (_event, id) => {
69 | shell.openExternal(`https://github.com/${id}`);
70 | });
71 | };
72 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { BrowserWindow, app } from 'electron';
2 | import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
3 | import squirrelStartup from 'electron-squirrel-startup';
4 |
5 | import { createAppWindow } from './appWindow';
6 |
7 | process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
8 |
9 | /** Handle creating/removing shortcuts on Windows when installing/uninstalling. */
10 | if (squirrelStartup) {
11 | app.quit();
12 | }
13 |
14 | app.whenReady().then(() => {
15 | installExtension(REACT_DEVELOPER_TOOLS)
16 | .then((extension) => console.info(`Added Extension: ${extension.name}`))
17 | .catch((err) => console.info('An error occurred: ', err));
18 | });
19 |
20 | /**
21 | * This method will be called when Electron has finished
22 | * initialization and is ready to create browser windows.
23 | * Some APIs can only be used after this event occurs.
24 | */
25 | app.on('ready', createAppWindow);
26 |
27 | /**
28 | * Emitted when the application is activated. Various actions can
29 | * trigger this event, such as launching the application for the first time,
30 | * attempting to re-launch the application when it's already running,
31 | * or clicking on the application's dock or taskbar icon.
32 | */
33 | app.on('activate', () => {
34 | /**
35 | * On OS X it's common to re-create a window in the app when the
36 | * dock icon is clicked and there are no other windows open.
37 | */
38 | if (BrowserWindow.getAllWindows().length === 0) {
39 | createAppWindow();
40 | }
41 | });
42 |
43 | /**
44 | * Emitted when all windows have been closed.
45 | */
46 | app.on('window-all-closed', () => {
47 | /**
48 | * On OS X it is common for applications and their menu bar
49 | * to stay active until the user quits explicitly with Cmd + Q
50 | */
51 | if (process.platform !== 'darwin') {
52 | app.quit();
53 | }
54 | });
55 |
56 | /**
57 | * In this file you can include the rest of your app's specific main process code.
58 | * You can also put them in separate files and import them here.
59 | */
60 |
--------------------------------------------------------------------------------
/src/menu/accelerators.ts:
--------------------------------------------------------------------------------
1 | export function getPlatformAcceleratorSymbol (modifier: string) {
2 | switch (modifier.toLowerCase()) {
3 | case 'cmdorctrl':
4 | case 'commandorcontrol':
5 | return __DARWIN__ ? '⌘' : 'Ctrl';
6 |
7 | case 'ctrl':
8 | case 'control':
9 | return __DARWIN__ ? '⌃' : 'Ctrl';
10 |
11 | case 'shift':
12 | return __DARWIN__ ? '⇧' : 'Shift';
13 | case 'alt':
14 | return __DARWIN__ ? '⌥' : 'Alt';
15 |
16 | // Mac only
17 | case 'cmd':
18 | case 'command':
19 | return '⌘';
20 | case 'option':
21 | return '⌥';
22 |
23 | // Special case space because no one would be able to see it
24 | case ' ':
25 | return 'Space';
26 | }
27 |
28 | // Not a known modifier, likely a normal key
29 | return modifier;
30 | }
31 |
32 | export function fixAcceleratorText (accelerator: Electron.Accelerator) {
33 | return accelerator
34 | .split('+')
35 | .map(getPlatformAcceleratorSymbol)
36 | .join(__DARWIN__ ? '' : '+');
37 | }
38 |
--------------------------------------------------------------------------------
/src/menu/appMenu.ts:
--------------------------------------------------------------------------------
1 | import { MenuChannels } from '@/channels/menuChannels';
2 | import { emitEvent } from '@/webContents';
3 |
4 | const MenuItems: Electron.MenuItemConstructorOptions[] = [
5 | {
6 | label: 'Reactronite',
7 | submenu: [
8 | {
9 | label: 'About Reactronite',
10 | },
11 | {
12 | type: 'separator',
13 | },
14 | {
15 | id: MenuChannels.WINDOW_CLOSE,
16 | label: 'Exit',
17 | role: 'quit',
18 | accelerator: 'CmdOrCtrl+Q',
19 | },
20 | ],
21 | },
22 | {
23 | label: 'View',
24 | submenu: [
25 | {
26 | id: MenuChannels.WEB_ACTUAL_SIZE,
27 | label: 'Reset Zoom',
28 | role: 'resetZoom',
29 | accelerator: 'CmdOrCtrl+0',
30 | },
31 | {
32 | id: MenuChannels.WEB_ZOOM_IN,
33 | label: 'Zoom In',
34 | role: 'zoomIn',
35 | },
36 | {
37 | id: MenuChannels.WEB_ZOOM_OUT,
38 | label: 'Zoom Out',
39 | role: 'zoomOut',
40 | accelerator: 'CmdOrCtrl+-',
41 | },
42 | {
43 | type: 'separator',
44 | },
45 | {
46 | id: MenuChannels.WEB_TOGGLE_FULLSCREEN,
47 | label: 'Toggle Full Screen',
48 | role: 'togglefullscreen',
49 | },
50 | {
51 | type: 'separator',
52 | },
53 | {
54 | id: MenuChannels.WEB_TOGGLE_DEVTOOLS,
55 | label: 'Toogle Developer Tools',
56 | role: 'toggleDevTools',
57 | accelerator: 'CmdOrCtrl+Shift+I',
58 | },
59 | ],
60 | },
61 | {
62 | label: 'Authors',
63 | submenu: [
64 | {
65 | id: MenuChannels.OPEN_GITHUB_PROFILE,
66 | label: 'flaviodelgrosso',
67 | click: emitEvent(MenuChannels.OPEN_GITHUB_PROFILE, 'flaviodelgrosso'),
68 | },
69 | ],
70 | },
71 | ];
72 |
73 | export default MenuItems;
74 |
--------------------------------------------------------------------------------
/src/menu/contextMenu.ts:
--------------------------------------------------------------------------------
1 | import { ipcRenderer } from 'electron';
2 |
3 | import type { ClickHandler } from '@/webContents';
4 |
5 | export interface IMenuItem {
6 | readonly id?: string;
7 | /** The user-facing label. */
8 | readonly label?: string;
9 |
10 | readonly click?: ClickHandler;
11 | /** The action to invoke when the user selects the item. */
12 | readonly action?: () => void;
13 |
14 | /** The type of item. */
15 | readonly type?: 'separator';
16 |
17 | /** Is the menu item enabled? Defaults to true. */
18 | readonly enabled?: boolean;
19 |
20 | /**
21 | * The predefined behavior of the menu item.
22 | * When specified, the click property will be ignored.
23 | */
24 | readonly role?: Electron.MenuItemConstructorOptions['role'];
25 |
26 | /**
27 | * Submenu that will appear when hovering this menu item.
28 | */
29 | readonly submenu?: ReadonlyArray;
30 |
31 | readonly accelerator?: Electron.Accelerator;
32 | }
33 |
34 | /**
35 | * A menu item data structure that can be serialized and sent via IPC.
36 | */
37 | export interface ISerializableMenuItem extends IMenuItem {
38 | readonly action: undefined;
39 | }
40 |
41 | export async function showContextualMenu (items: ReadonlyArray) {
42 | const indices = await ipcRenderer.invoke('show-contextual-menu', serializeMenuItems(items));
43 |
44 | if (indices !== null) {
45 | const menuItem = findSubmenuItem(items, indices);
46 |
47 | if (menuItem !== undefined && menuItem.action !== undefined) {
48 | menuItem.action();
49 | }
50 | }
51 | }
52 |
53 | /**
54 | * Remove the menu items properties that can't be serializable in
55 | * order to pass them via IPC.
56 | */
57 | function serializeMenuItems (items: ReadonlyArray): ReadonlyArray {
58 | return items.map((item) => ({
59 | ...item,
60 | action: undefined as undefined,
61 | submenu: item.submenu ? serializeMenuItems(item.submenu) : undefined,
62 | }));
63 | }
64 |
65 | /**
66 | * Traverse the submenus of the context menu until we find the appropriate index.
67 | */
68 | function findSubmenuItem (
69 | currentContextualMenuItems: ReadonlyArray,
70 | indices: ReadonlyArray
71 | ): IMenuItem | undefined {
72 | let foundMenuItem: IMenuItem | undefined = {
73 | submenu: currentContextualMenuItems,
74 | };
75 |
76 | for (const index of indices) {
77 | if (foundMenuItem === undefined || foundMenuItem.submenu === undefined) {
78 | return undefined;
79 | }
80 |
81 | foundMenuItem = foundMenuItem.submenu[index];
82 | }
83 |
84 | return foundMenuItem;
85 | }
86 |
--------------------------------------------------------------------------------
/src/preload.ts:
--------------------------------------------------------------------------------
1 | import { type IpcRendererEvent, contextBridge, ipcRenderer } from 'electron';
2 |
3 | const versions: Record = {};
4 |
5 | // Process versions
6 | for (const type of ['chrome', 'node', 'electron']) {
7 | versions[type] = process.versions[type];
8 | }
9 |
10 | function validateIPC (channel: string) {
11 | if (!channel) {
12 | throw new Error(`Unsupported event IPC channel '${channel}'`);
13 | }
14 |
15 | return true;
16 | }
17 |
18 | export type RendererListener = (event: IpcRendererEvent, ...args: unknown[]) => void;
19 |
20 | export const globals = {
21 | /** Processes versions **/
22 | versions,
23 |
24 | /**
25 | * A minimal set of methods exposed from Electron's `ipcRenderer`
26 | * to support communication to main process.
27 | */
28 | ipcRenderer: {
29 | send (channel: string, ...args: unknown[]) {
30 | if (validateIPC(channel)) {
31 | ipcRenderer.send(channel, ...args);
32 | }
33 | },
34 |
35 | invoke (channel: string, ...args: unknown[]) {
36 | if (validateIPC(channel)) {
37 | return ipcRenderer.invoke(channel, ...args);
38 | }
39 | },
40 |
41 | on (channel: string, listener: RendererListener) {
42 | if (validateIPC(channel)) {
43 | ipcRenderer.on(channel, listener);
44 |
45 | return this;
46 | }
47 | },
48 |
49 | once (channel: string, listener: RendererListener) {
50 | if (validateIPC(channel)) {
51 | ipcRenderer.once(channel, listener);
52 |
53 | return this;
54 | }
55 | },
56 |
57 | removeListener (channel: string, listener: RendererListener) {
58 | if (validateIPC(channel)) {
59 | ipcRenderer.removeListener(channel, listener);
60 |
61 | return this;
62 | }
63 | },
64 | },
65 | };
66 |
67 | // Create a safe, bidirectional, synchronous bridge across isolated contexts
68 | // When contextIsolation is enabled in your webPreferences, your preload scripts run in an "Isolated World".
69 | contextBridge.exposeInMainWorld('electron', globals);
70 |
--------------------------------------------------------------------------------
/src/webContents.ts:
--------------------------------------------------------------------------------
1 | import { MenuChannels } from '@/channels/menuChannels';
2 |
3 | import type { WebContents } from 'electron';
4 |
5 | export type ClickHandler = (
6 | menuItem: Electron.MenuItem,
7 | browserWindow: Electron.BrowserWindow | undefined,
8 | event: Electron.KeyboardEvent
9 | ) => void;
10 |
11 | export function emitEvent (eventName: string, ...args: unknown[]): ClickHandler {
12 | return (_, focusedWindow) => {
13 | const mainWindow = focusedWindow ?? Electron.BrowserWindow.getAllWindows()[0];
14 | if (mainWindow !== undefined) {
15 | sendToRenderer(mainWindow.webContents, MenuChannels.MENU_EVENT, eventName, ...args);
16 | }
17 | };
18 | }
19 |
20 | export function sendToRenderer (webContents: WebContents, channel: string, ...args: unknown[]): void {
21 | if (webContents.isDestroyed()) {
22 | const msg = `failed to send on ${channel}, webContents was destroyed`;
23 | if (__DEV__) {
24 | throw new Error(msg);
25 | }
26 | console.error(msg);
27 | } else {
28 | webContents.send(channel, ...args);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/windowState.ts:
--------------------------------------------------------------------------------
1 | import { sendToRenderer } from '@/webContents';
2 |
3 | export type WindowState = 'minimized' | 'normal' | 'maximized' | 'full-screen' | 'hidden';
4 |
5 | export function getWindowState (window: Electron.BrowserWindow): WindowState {
6 | if (window.isFullScreen()) {
7 | return 'full-screen';
8 | }
9 | if (window.isMaximized()) {
10 | return 'maximized';
11 | }
12 | if (window.isMinimized()) {
13 | return 'minimized';
14 | }
15 | if (!window.isVisible()) {
16 | return 'hidden';
17 | }
18 | return 'normal';
19 | }
20 |
21 | /**
22 | * Registers event handlers for all window state transition events and
23 | * forwards those to the renderer process for a given window.
24 | */
25 | export function registerWindowStateChangedEvents (window: Electron.BrowserWindow) {
26 | window.on('enter-full-screen', () => sendWindowStateEvent(window, 'full-screen'));
27 |
28 | // So this is a bit of a hack. If we call window.isFullScreen directly after
29 | // receiving the leave-full-screen event it'll return true which isn't what
30 | // we're after. So we'll say that we're transitioning to 'normal' even though
31 | // we might be maximized. This works because electron will emit a 'maximized'
32 | // event after 'leave-full-screen' if the state prior to full-screen was maximized.
33 | window.on('leave-full-screen', () => sendWindowStateEvent(window, 'normal'));
34 |
35 | window.on('maximize', () => sendWindowStateEvent(window, 'maximized'));
36 | window.on('minimize', () => sendWindowStateEvent(window, 'minimized'));
37 | window.on('unmaximize', () => sendWindowStateEvent(window, 'normal'));
38 | window.on('restore', () => sendWindowStateEvent(window, 'normal'));
39 | window.on('hide', () => sendWindowStateEvent(window, 'hidden'));
40 | window.on('show', () => {
41 | // because the app can be maximized before being closed - which will restore it
42 | // maximized on the next launch - this function should inspect the current state
43 | // rather than always assume it is a 'normal' launch
44 | sendWindowStateEvent(window, getWindowState(window));
45 | });
46 | }
47 |
48 | function sendWindowStateEvent (window: Electron.BrowserWindow, state: WindowState) {
49 | sendToRenderer(window.webContents, 'window-state-changed', state);
50 | }
51 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "noImplicitAny": true,
9 | "sourceMap": true,
10 | "jsx": "react-jsx",
11 | "baseUrl": ".",
12 | "outDir": "dist",
13 | "moduleResolution": "bundler",
14 | "resolveJsonModule": true,
15 | "paths": {
16 | "@/*": ["./src/*"]
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------