├── renderer ├── src │ ├── vite-env.d.ts │ ├── utils │ │ └── trpc.ts │ ├── main.tsx │ ├── App.css │ ├── index.css │ ├── Home.tsx │ ├── App.tsx │ └── assets │ │ └── react.svg ├── tsconfig.json ├── index.html ├── .eslintrc.cjs ├── public │ └── vite.svg └── vite.config.ts ├── .prettierignore ├── .eslintignore ├── .gitignore ├── types ├── index.ts ├── exposedInMainWorld.d.ts └── main-env.d.ts ├── tsconfig.json ├── scripts ├── tsconfig.json ├── watchWeb.ts ├── update-electron-vendors.mjs ├── compile.ts └── watchDesktop.ts ├── api ├── context.ts ├── router │ ├── index.ts │ └── example.ts ├── tsconfig.json └── db │ └── client.ts ├── prisma └── schema.prisma ├── tsconfig.node.json ├── preload ├── tsconfig.json ├── index.ts └── vite.config.ts ├── main ├── tsconfig.json ├── vite.config.ts ├── mainWindow.ts ├── security-restrictions.ts └── index.ts ├── .eslintrc.cjs ├── README.md └── package.json /renderer/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | package-lock.json 4 | .electron-vendors.cache.json -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | package-lock.json 4 | .electron-vendors.cache.json 5 | .eslintrc.cjs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | generated 4 | dist 5 | .browserslistrc 6 | .electron-vendors.cache.json 7 | db.sqlite 8 | 9 | .vscode -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | import { Operation } from "@trpc/client"; 2 | import { TRPCResponse } from "@trpc/server/rpc"; 3 | 4 | export interface IPCResponse { 5 | response: TRPCResponse; 6 | } 7 | 8 | export type IPCRequestOptions = Operation; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "Node", 5 | "allowSyntheticDefaultImports": true, 6 | "esModuleInterop": true, 7 | "noEmit": true, 8 | "strict": true, 9 | "strictNullChecks": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /types/exposedInMainWorld.d.ts: -------------------------------------------------------------------------------- 1 | // https://www.electronjs.org/docs/latest/tutorial/context-isolation#usage-with-typescript 2 | interface Window { 3 | readonly electronTRPC: { 4 | rpc: ( 5 | op: import("./index").IPCRequestOptions 6 | ) => Promise; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /renderer/src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from "../../../api/router/index"; 2 | import { createTRPCReact } from "@trpc/react-query"; 3 | import type { GetInferenceHelpers } from "@trpc/server"; 4 | 5 | export const trpc = createTRPCReact(); 6 | 7 | export type InferProcedures = GetInferenceHelpers; 8 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ESNext", 5 | "moduleResolution": "NodeNext", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "strictNullChecks": true 10 | }, 11 | "ts-node": { 12 | "esm": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /api/context.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import { prisma } from "./db/client"; 3 | 4 | export const createContext = async () => { 5 | return Promise.resolve({ 6 | prisma, 7 | }); 8 | }; 9 | 10 | export type Context = trpc.inferAsyncReturnType; 11 | 12 | export const t = trpc.initTRPC.context().create(); 13 | -------------------------------------------------------------------------------- /renderer/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | const rootEl = document.getElementById("root"); 7 | 8 | if (rootEl) { 9 | ReactDOM.createRoot(rootEl).render( 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = "file:./../buildResources/db.sqlite" 11 | } 12 | 13 | model Example { 14 | id String @id @default(uuid()) 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true, 7 | "lib": ["ESNext"], 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "resolveJsonModule": true 11 | }, 12 | "include": [ 13 | "renderer/vite.config.ts", 14 | "main/vite.config.ts", 15 | "preload/vite.config.ts", 16 | ".electron-vendors.cache.json" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /api/router/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { t } from "../context"; 3 | import { exampleRouter } from "./example"; 4 | 5 | export const appRouter = t.router({ 6 | example: exampleRouter, 7 | greeting: t.procedure 8 | .input(z.object({ name: z.string() }).nullish()) 9 | .query(({ input }) => { 10 | return `hello tRPC v10, ${input?.name ?? "world"}!`; 11 | }), 12 | }); 13 | 14 | // export type definition of API 15 | export type AppRouter = typeof appRouter; 16 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "sourceMap": false, 6 | "moduleResolution": "Node", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "isolatedModules": true, 11 | "types": ["node"], 12 | "allowSyntheticDefaultImports": true, 13 | "baseUrl": ".", 14 | "noEmit": true 15 | }, 16 | "include": ["./**/*.ts", "./**/*.d.ts", "./../types/main-env.d.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /preload/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "sourceMap": false, 6 | "moduleResolution": "Node", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "isolatedModules": true, 11 | "types": ["node"], 12 | "baseUrl": ".", 13 | "resolveJsonModule": true, 14 | "noEmit": true 15 | }, 16 | "include": ["./**/*.ts", "./**/*.d.ts"], 17 | "references": [{ "path": "../tsconfig.node.json" }] 18 | } 19 | -------------------------------------------------------------------------------- /api/router/example.ts: -------------------------------------------------------------------------------- 1 | import { t } from "../context"; 2 | import { randomUUID } from "crypto"; 3 | import { z } from "zod"; 4 | 5 | export const exampleRouter = t.router({ 6 | getAll: t.procedure.query(({ ctx }) => { 7 | return ctx.prisma.example.findMany(); 8 | }), 9 | add: t.procedure.mutation(({ ctx }) => { 10 | return ctx.prisma.example.create({ 11 | data: { id: randomUUID() }, 12 | }); 13 | }), 14 | remove: t.procedure 15 | .input(z.object({ id: z.string().uuid() })) 16 | .mutation(({ ctx, input }) => { 17 | return ctx.prisma.example.delete({ 18 | where: { id: input.id }, 19 | }); 20 | }), 21 | }); 22 | -------------------------------------------------------------------------------- /main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "sourceMap": false, 6 | "moduleResolution": "Node", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "esModuleInterop": true, 11 | "isolatedModules": true, 12 | "lib": ["esnext"], 13 | "types": ["node", "vite/client"], 14 | "baseUrl": ".", 15 | "paths": { 16 | "/@/*": ["./*"] 17 | }, 18 | "resolveJsonModule": true, 19 | "noEmit": true 20 | }, 21 | "include": ["./**/*.ts", "./**/*.d.ts", "./../types/main-env.d.ts"], 22 | "references": [{ "path": "../tsconfig.node.json" }] 23 | } 24 | -------------------------------------------------------------------------------- /scripts/watchWeb.ts: -------------------------------------------------------------------------------- 1 | import { createServer, createLogger } from "vite"; 2 | 3 | // process.env.MODE is used in various vite config files 4 | const mode = (process.env.MODE = process.env.MODE ?? "development"); 5 | 6 | /** 7 | * Setup server for `web` 8 | * On file changes: hot reload 9 | */ 10 | function createWebWatchServer() { 11 | const server = createServer({ 12 | mode, 13 | customLogger: createLogger("info", { prefix: `[web]` }), 14 | configFile: "renderer/vite.config.ts", 15 | }); 16 | 17 | return server; 18 | } 19 | 20 | // start webserver 21 | const server = await createWebWatchServer(); 22 | await server.listen(); 23 | 24 | export { server as listeningWebServer }; 25 | -------------------------------------------------------------------------------- /renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "strictNullChecks": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "ESNext", 14 | "moduleResolution": "Node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": ["src", "../types/exposedInMainWorld.d.ts"], 21 | "references": [{ "path": "../tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /scripts/update-electron-vendors.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | /** 3 | * This script should be run in electron context 4 | * @example 5 | * ELECTRON_RUN_AS_NODE=1 electron scripts/update-electron-vendors.mjs 6 | */ 7 | 8 | import { writeFileSync } from "fs"; 9 | import path from "path"; 10 | 11 | const electronRelease = process.versions; 12 | 13 | const node = electronRelease.node.split(".")[0]; 14 | const chrome = electronRelease.v8.split(".").splice(0, 2).join(""); 15 | 16 | const browserslistrcPath = path.resolve(process.cwd(), ".browserslistrc"); 17 | 18 | writeFileSync( 19 | "./.electron-vendors.cache.json", 20 | JSON.stringify({ chrome, node }) 21 | ); 22 | writeFileSync(browserslistrcPath, `Chrome ${chrome}`, "utf8"); 23 | -------------------------------------------------------------------------------- /renderer/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | } 13 | .logo:hover { 14 | filter: drop-shadow(0 0 2em #646cffaa); 15 | } 16 | .logo.react:hover { 17 | filter: drop-shadow(0 0 2em #61dafbaa); 18 | } 19 | 20 | @keyframes logo-spin { 21 | from { 22 | transform: rotate(0deg); 23 | } 24 | to { 25 | transform: rotate(360deg); 26 | } 27 | } 28 | 29 | @media (prefers-reduced-motion: no-preference) { 30 | a:nth-of-type(2) .logo { 31 | animation: logo-spin infinite 20s linear; 32 | } 33 | } 34 | 35 | .example:hover { 36 | cursor: pointer; 37 | color: #f87171; 38 | } 39 | -------------------------------------------------------------------------------- /renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | Vite + React + TS 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | const eslintConfig = { 5 | root: true, 6 | parser: "@typescript-eslint/parser", 7 | parserOptions: { 8 | tsconfigRootDir: __dirname, 9 | project: ["./*/tsconfig.json", "./tsconfig.node.json", "./tsconfig.json"], 10 | }, 11 | plugins: ["@typescript-eslint"], 12 | extends: [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/eslint-recommended", // disable core eslint rules that conflict with replacement @typescript-eslint rules 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 17 | "plugin:@typescript-eslint/strict", 18 | "prettier", // config-prettier disables eslint rules that conflict with prettier 19 | ], 20 | rules: {}, 21 | }; 22 | 23 | module.exports = eslintConfig; 24 | -------------------------------------------------------------------------------- /types/main-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Describes all existing environment variables and their types. 5 | * Required for Code completion and type checking 6 | * 7 | * Note: To prevent accidentally leaking env variables to the client, only variables prefixed with `VITE_` are exposed to your Vite-processed code 8 | * 9 | * @see https://github.com/vitejs/vite/blob/cab55b32de62e0de7d7789e8c2a1f04a8eae3a3f/packages/vite/types/importMeta.d.ts#L62-L69 Base Interface 10 | * @see https://vitejs.dev/guide/env-and-mode.html#env-files Vite Env Variables Doc 11 | */ 12 | interface ImportMetaEnv { 13 | /** 14 | * The value of the variable is set in scripts/watchDesktop.ts and depend on main/vite.config.ts 15 | */ 16 | readonly VITE_DEV_SERVER_URL: undefined | string; 17 | } 18 | 19 | interface ImportMeta { 20 | readonly env: ImportMetaEnv; 21 | } 22 | -------------------------------------------------------------------------------- /preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | import type { IpcRenderer, ContextBridge } from "electron"; 3 | import type { IPCRequestOptions } from "../types"; 4 | 5 | export const exposeElectronTRPC = ({ 6 | contextBridge, 7 | ipcRenderer, 8 | }: { 9 | contextBridge: ContextBridge; 10 | ipcRenderer: IpcRenderer; 11 | }) => { 12 | return contextBridge.exposeInMainWorld("electronTRPC", { 13 | rpc: (opts: IPCRequestOptions) => ipcRenderer.invoke("electron-trpc", opts), 14 | }); 15 | }; 16 | 17 | process.once("loaded", () => { 18 | exposeElectronTRPC({ contextBridge, ipcRenderer }); 19 | // If you expose something here, you get window.something in the React app 20 | // type it in types/exposedInMainWorld.d.ts to add it to the window type 21 | // contextBridge.exposeInMainWorld("something", { 22 | // exposedThing: "this value was exposed via the preload file", 23 | // }); 24 | }); 25 | -------------------------------------------------------------------------------- /scripts/compile.ts: -------------------------------------------------------------------------------- 1 | import builder from "electron-builder"; 2 | 3 | if (process.env.VITE_APP_VERSION === undefined) { 4 | const now = new Date(); 5 | process.env.VITE_APP_VERSION = `${now.getUTCFullYear() - 2000}.${ 6 | now.getUTCMonth() + 1 7 | }.${now.getUTCDate()}-${now.getUTCHours() * 60 + now.getUTCMinutes()}`; 8 | } 9 | 10 | const config: builder.Configuration = { 11 | directories: { 12 | output: "dist", 13 | buildResources: "buildResources", 14 | }, 15 | files: ["main/dist/**", "preload/dist/**", "renderer/dist/**"], 16 | extraMetadata: { 17 | version: process.env.VITE_APP_VERSION, 18 | }, 19 | extraResources: [ 20 | "buildResources/db.sqlite", 21 | "node_modules/.prisma/**/*", 22 | "node_modules/@prisma/client/**/*", 23 | ], 24 | }; 25 | 26 | builder 27 | .build({ 28 | config, 29 | dir: true, 30 | }) 31 | .then((result) => { 32 | console.log(JSON.stringify(result)); 33 | }) 34 | .catch((error) => { 35 | console.error(error); 36 | }); 37 | -------------------------------------------------------------------------------- /renderer/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | const eslintConfig = { 5 | root: false, 6 | env: { 7 | browser: true, 8 | es2021: true, 9 | }, 10 | extends: [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", // disable core eslint rules that conflict with replacement @typescript-eslint rules 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 15 | "plugin:@typescript-eslint/strict", 16 | "plugin:react/recommended", 17 | "plugin:react/jsx-runtime", 18 | "plugin:react-hooks/recommended", 19 | "prettier", // config-prettier disables eslint rules that conflict with prettier 20 | ], 21 | settings: { 22 | react: { 23 | version: "detect", 24 | }, 25 | }, 26 | parser: "@typescript-eslint/parser", 27 | parserOptions: { 28 | tsconfigRootDir: __dirname, 29 | ecmaVersion: "latest", 30 | sourceType: "module", 31 | project: ["./tsconfig.json", "../tsconfig.node.json"], 32 | }, 33 | plugins: ["react", "react-hooks", "@typescript-eslint"], 34 | rules: {}, 35 | overrides: [], 36 | }; 37 | 38 | module.exports = eslintConfig; 39 | -------------------------------------------------------------------------------- /renderer/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /renderer/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: #242424; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-text-size-adjust: 100%; 16 | } 17 | 18 | a { 19 | font-weight: 500; 20 | color: #646cff; 21 | text-decoration: inherit; 22 | } 23 | a:hover { 24 | color: #535bf2; 25 | } 26 | 27 | body { 28 | margin: 0; 29 | display: flex; 30 | min-width: 320px; 31 | min-height: 100vh; 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | button { 40 | border-radius: 8px; 41 | border: 1px solid transparent; 42 | padding: 0.6em 1.2em; 43 | font-size: 1em; 44 | font-weight: 500; 45 | font-family: inherit; 46 | background-color: #1a1a1a; 47 | cursor: pointer; 48 | transition: border-color 0.25s; 49 | } 50 | button:hover { 51 | border-color: #646cff; 52 | } 53 | button:focus, 54 | button:focus-visible { 55 | outline: 4px auto -webkit-focus-ring-color; 56 | } 57 | 58 | @media (prefers-color-scheme: light) { 59 | :root { 60 | color: #213547; 61 | background-color: #ffffff; 62 | } 63 | a:hover { 64 | color: #747bff; 65 | } 66 | button { 67 | background-color: #f9f9f9; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Based on: https://github.com/cawa-93/vite-electron-builder 2 | The tRPC over IPC code is based on [the electron-trpc package](https://github.com/jsonnull/electron-trpc), adapted to support tRPC v10 by using [tRPC source](https://github.com/trpc/trpc/tree/next). 3 | 4 | ## Running locally 5 | 6 | - Run `npm run bootstrap`. 7 | This installs the dependencies and sets up the database. 8 | 9 | - Run `npm run dev` 10 | This starts a development watch process using `vite`. 11 | It hot reloads on changes to `renderer/` 12 | It reloads the web page on changes to `preload/` 13 | It fully reloads the Electron app on changes to `main/` 14 | 15 | ## Packaging the app 16 | 17 | `electron-builder` is used to compile this codebase into an executable. 18 | 19 | - Run `npm run compile` 20 | 21 | This executes the `scripts/compile.ts` file. 22 | It uses the `electron-builder` programmatic API. 23 | 24 | If you want to compile an installable executable, change `dir` to `false` in the call to `build()`. 25 | 26 | [`electron-builder` API docs](https://www.electron.build/api/electron-builder) 27 | 28 | ## Notes 29 | 30 | The `resolve.alias` stuff in `vite.config.ts` files is needed because https://github.com/vitejs/vite/issues/6828 31 | 32 | By default, the Content-Security-Policy allows inline `