├── .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 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) 7 | [![React](https://img.shields.io/badge/React-20232A?style=flat&logo=react&logoColor=61DAFB)](https://reactjs.org/) 8 | [![Electron](https://img.shields.io/badge/Electron-191970?style=flat&logo=Electron&logoColor=white)](https://electronjs.org/) 9 | [![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat&logo=vite&logoColor=white)](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 | ![Windows Screenshot](./screenshots/window-win.png) 18 | ![macOS Screenshot](./screenshots/window-mac.png) 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 | 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 | 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 | 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 | App logo 96 |
97 | 98 | {menuList.map(({ label, submenu }, menuIndex) => { 99 | return ( 100 |
101 | 113 |
114 | {Array.isArray(submenu) && 115 | submenu.map((menuItem, menuItemIndex) => { 116 | if (menuItem.type === 'separator') { 117 | return ( 118 |
119 | ); 120 | } 121 | 122 | return ( 123 | 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 | 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 | 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 | --------------------------------------------------------------------------------