├── 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 `