Files
boilerplates-bootstraps/electron_boilerplate.sh

479 lines
12 KiB
Bash
Raw Permalink Normal View History

2025-12-04 11:26:05 +01:00
#!/bin/bash
set -euo pipefail
usage() {
echo "Usage: $0 [project-name]"
echo "If no project name is given, you will be prompted."
}
valid_name() {
[[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]
}
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage; exit 0
fi
PROJ="${1-}"
if [[ -n "$PROJ" ]] && ! valid_name "$PROJ"; then
echo "Invalid project name: $PROJ (use letters, numbers, hyphen, underscore)"
PROJ=""
fi
while [[ -z "$PROJ" ]]; do
read -r -p "Enter project name (letters, numbers, -, _ only): " PROJ
if [[ -z "$PROJ" ]]; then
continue
fi
if ! valid_name "$PROJ"; then
echo "Invalid project name. Use letters, numbers, hyphen, underscore."
PROJ=""
fi
done
echo ">> Creating Electron+React+FastAPI boilerplate in '$PROJ' ..."
# --- Directory Structure ---
mkdir -p "$PROJ/backend"
mkdir -p "$PROJ/frontend"
# --- Backend: Python FastAPI+SQLite ---
cat > "$PROJ/backend/requirements.txt" <<EOF
fastapi
uvicorn[standard]
EOF
cat > "$PROJ/backend/db.py" <<EOF
import sqlite3, os
from datetime import datetime
DB_FILE = os.environ.get("WS_DB_PATH", "appdata.db")
def get_conn():
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_conn()
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS likes (
id TEXT PRIMARY KEY,
title TEXT,
artist TEXT,
album TEXT,
url TEXT,
duration INTEGER,
is_new INTEGER DEFAULT 1,
last_seen TIMESTAMP
)''')
conn.commit()
conn.close()
def get_likes(is_new=None):
conn = get_conn()
c = conn.cursor()
if is_new is None:
rows = c.execute('SELECT * FROM likes ORDER BY last_seen DESC').fetchall()
else:
rows = c.execute('SELECT * FROM likes WHERE is_new=? ORDER BY last_seen DESC', (1 if is_new else 0,)).fetchall()
return [dict(row) for row in rows]
def add_demo_like():
conn = get_conn()
c = conn.cursor()
c.execute(
"INSERT OR IGNORE INTO likes (id, title, artist, album, url, duration, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?)",
("track1", "Test Track", "Some Artist", "Test Album", "http://test", 123, datetime.utcnow())
)
conn.commit()
conn.close()
EOF
cat > "$PROJ/backend/main.py" <<EOF
from fastapi import FastAPI, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from starlette.websockets import WebSocketDisconnect
import db
app = FastAPI(title="$PROJ React, FastAPI, SQLite, WebSocket")
db.init_db()
db.add_demo_like()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/likes/all")
def api_all_likes():
return db.get_likes()
@app.get("/likes/new")
def api_new_likes():
return db.get_likes(is_new=True)
# Example WebSocket endpoint (send a message on connection)
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
try:
await ws.send_json({"type": "hello", "msg": "WebSocket is live!"})
while True:
data = await ws.receive_text()
await ws.send_json({"type": "echo", "data": data})
except WebSocketDisconnect:
# Client disconnected; exit quietly.
pass
except Exception:
# Any other exception: close connection gracefully.
await ws.close()
EOF
# --- Frontend: Vite+React+ESLint+Electron ---
cd "$PROJ/frontend"
CI=true npm_config_yes=true npm create vite@5.2.0 . -- --template react
cat > electron.cjs <<'EOF'
const { app, BrowserWindow } = require('electron');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const distIndex = path.join(__dirname, 'dist', 'index.html');
const useDevServer = process.env.ELECTRON_DEV === 'true' ||
(!app.isPackaged && process.env.ELECTRON_DEV !== 'false' && !fs.existsSync(distIndex));
const skipBackend = ['1', 'true', 'yes'].includes(String(process.env.SKIP_ELECTRON_BACKEND || '').toLowerCase());
const skipFrontend = ['1', 'true', 'yes'].includes(String(process.env.SKIP_ELECTRON_FRONTEND || '').toLowerCase());
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const appTitle = process.env.APP_TITLE || process.env.npm_package_name || "React, FastAPI, SQLite, WebSocket";
let backendProc = null;
let frontendProc = null;
function waitFor(url, timeoutMs = 20000, intervalMs = 300) {
const http = require('http');
const start = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
http
.get(url, res => {
res.destroy();
resolve();
})
.on('error', () => {
if (Date.now() - start > timeoutMs) {
reject(new Error(`Timed out waiting for ${url}`));
} else {
setTimeout(check, intervalMs);
}
});
};
check();
});
}
function cleanup() {
if (backendProc) backendProc.kill();
if (frontendProc) frontendProc.kill();
}
async function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
title: appTitle,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
}
});
// Prevent the page title from overwriting our window title
win.on('page-title-updated', (event) => {
event.preventDefault();
if (win.getTitle() !== appTitle) {
win.setTitle(appTitle);
}
});
if (useDevServer) {
if (!skipFrontend) {
frontendProc = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['run', 'dev'], {
cwd: __dirname,
shell: false,
stdio: 'ignore'
});
}
try {
await waitFor(frontendUrl);
win.loadURL(frontendUrl);
} catch (err) {
console.error(err);
cleanup();
app.quit();
}
} else if (fs.existsSync(distIndex)) {
win.loadFile(distIndex);
} else {
console.error('dist/index.html not found. Run "npm run build" or set ELECTRON_DEV=true to use the dev server.');
cleanup();
app.quit();
}
}
function backendPython() {
const venvBin = process.platform === 'win32' ? '.venv/Scripts/python.exe' : '.venv/bin/python';
const candidate = path.join(__dirname, '../backend', venvBin);
if (fs.existsSync(candidate)) return candidate;
return process.platform === 'win32' ? 'python' : 'python3';
}
app.whenReady().then(() => {
if (!skipBackend) {
backendProc = spawn(backendPython(), ['-m', 'uvicorn', 'main:app', '--port', '8000', '--reload'], {
cwd: path.join(__dirname, '../backend'),
stdio: 'inherit',
shell: false,
});
backendProc.on('error', err => console.error('Failed to start backend:', err));
} else {
console.log('Skipping backend spawn (SKIP_ELECTRON_BACKEND set)');
}
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
cleanup();
});
process.on('SIGINT', () => { cleanup(); process.exit(0); });
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
EOF
cd src
cat > App.jsx <<'EOF'
import React, { useEffect, useState } from "react";
import axios from "axios";
const API_URL = "http://localhost:8000";
function App() {
const [allLikes, setAllLikes] = useState([]);
const [wsMsg, setWsMsg] = useState("");
const [error, setError] = useState("");
const [wsAttempts, setWsAttempts] = useState(0);
useEffect(() => {
axios.get(API_URL + "/likes/all")
.then(res => setAllLikes(res.data))
.catch(err => setError(err.message));
let socket;
let retryTimer;
const connectWs = (attempt = 0) => {
setWsAttempts(attempt + 1);
socket = new WebSocket("ws://localhost:8000/ws");
socket.onopen = () => {
setError("");
};
socket.onmessage = e => {
const msg = JSON.parse(e.data);
setWsMsg(JSON.stringify(msg));
};
socket.onerror = () => {
setError("WebSocket connection failed");
};
socket.onclose = () => {
if (attempt < 4) {
retryTimer = setTimeout(() => connectWs(attempt + 1), 1000 * (attempt + 1));
}
};
};
connectWs();
return () => {
if (retryTimer) clearTimeout(retryTimer);
if (socket) socket.close();
};
}, []);
return (
<div>
<h1>React · FastAPI · SQLite · WebSocket · ESLint</h1>
{error && <p style={{ color: "#f66" }}>Error: {error}</p>}
<p>Likes from backend:</p>
<ul>
{allLikes.map(t => (
<li key={t.id}>{t.title} {t.artist}</li>
))}
</ul>
<p>WebSocket (attempt {wsAttempts}): <code>{wsMsg}</code></p>
</div>
);
}
export default App;
EOF
cat > main.jsx <<'EOF'
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
EOF
cat > index.css <<'EOF'
body { font-family: sans-serif; margin: 2em; background: #202126; color: #fafafa; }
h1 { color: #6bf; }
EOF
cd ../
# --- Patch package.json for Electron & deps ---
PROJ_NAME="$PROJ" node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
pkg.name = process.env.PROJ_NAME || pkg.name;
pkg.main = "./electron.cjs";
pkg.scripts = { ...pkg.scripts, electron: "electron .", "electron-build": "npm run build && electron ." };
pkg.dependencies = pkg.dependencies || {};
pkg.devDependencies = pkg.devDependencies || {};
pkg.dependencies.axios = pkg.dependencies.axios || "latest";
pkg.devDependencies.electron = pkg.devDependencies.electron || "latest";
pkg.devDependencies["eslint-plugin-react"] = pkg.devDependencies["eslint-plugin-react"] || "latest";
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2));
'
cd ../..
cat > "$PROJ/.gitignore" <<'EOF'
backend/.venv
backend/__pycache__/
backend/appdata.db
frontend/node_modules
frontend/dist
.DS_Store
*.pyc
EOF
cat > "$PROJ/run.sh" <<'EOF'
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND="$ROOT/backend"
FRONTEND="$ROOT/frontend"
VENV="$BACKEND/.venv"
APP_TITLE="${APP_TITLE:-$(basename "$ROOT")}"
cleanup() {
[[ -n "${BACK_PID:-}" ]] && kill "$BACK_PID" 2>/dev/null || true
[[ -n "${FRONT_PID:-}" ]] && kill "$FRONT_PID" 2>/dev/null || true
[[ -n "${ELECTRON_PID:-}" ]] && kill "$ELECTRON_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
echo ">> Backend: ensure venv and deps"
python3 -m venv "$VENV"
source "$VENV/bin/activate"
pip install --upgrade pip
pip install -r "$BACKEND/requirements.txt"
echo ">> Starting backend (uvicorn)..."
cd "$BACKEND"
python -m uvicorn main:app --port 8000 --reload &
BACK_PID=$!
echo ">> Installing frontend deps (npm install)..."
cd "$FRONTEND"
npm install
echo ">> Starting frontend (Vite dev server)..."
npm run dev -- --host &
FRONT_PID=$!
echo ">> Starting Electron (UI)..."
SKIP_ELECTRON_BACKEND=1 SKIP_ELECTRON_FRONTEND=1 ELECTRON_DEV=true FRONTEND_URL=http://localhost:5173 APP_TITLE="$APP_TITLE" npm run electron &
ELECTRON_PID=$!
echo ">> Backend PID: $BACK_PID Frontend PID: $FRONT_PID Electron PID: $ELECTRON_PID"
wait $BACK_PID $FRONT_PID $ELECTRON_PID
EOF
chmod +x "$PROJ/run.sh"
cat > "$PROJ/README.md" <<EOF
# $PROJ
## Quickstart (dev)
1. **Backend venv + deps**
cd $PROJ/backend
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
2. **Install frontend deps**
cd ../frontend
npm install
3. **Run Electron (dev)**
npm run electron
- **Alt: run both servers without Electron**
./run.sh
## What this does
- Starts FastAPI backend (REST & WebSocket, SQLite, uvicorn --reload)
- Starts React+Vite dev server with ESLint (auto-reloads/HMR)
- Opens Electron window
- React frontend talks to backend via HTTP+WebSocket
## Production-ish build
1. cd frontend
2. npm run build
3. npm run electron
(Electron loads built frontend from dist/ and still spawns backend)
## WebSocket
Check out App.jsx and main.py for example WS code.
EOF
echo ">> Creating Python venv and installing backend requirements..."
cd "$PROJ/backend"
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
deactivate
echo ">> Installing frontend npm dependencies..."
cd ../frontend
npm install
echo ">> All dependencies installed."
cd ../..
echo ">> Boilerplate created at '$PROJ'. See $PROJ/README.md for next steps."
echo ">> Launching Electron app..."
cd "$PROJ/frontend"
APP_TITLE="$PROJ" npm run electron