Harden MVP runtime and fix packaged desktop white screen

This commit is contained in:
Ryan Lancaster
2026-03-18 13:05:14 -04:00
parent 9302a88aea
commit 4a7b3061ed
14 changed files with 359 additions and 29 deletions

9
.env
View File

@@ -4,3 +4,12 @@
PORT_BACKEND=4000 # Express REST API → http://localhost:4000
PORT_FRONTEND_DEV=5173 # Vite dev server → http://localhost:5173
PORT_FRONTEND_DEV_FALLBACK=5174 # Optional local fallback if 5173 is already in use
# Derived URLs for this project (kept explicit to avoid cross-project confusion)
APP_URL=http://localhost:5173
VITE_API_URL=http://localhost:4000/api
API_BASE=http://localhost:4000/api
# Browser origins allowed to call backend
CORS_ORIGIN=http://localhost:5173,http://localhost:5174,http://localhost:4000,http://10.0.0.252:5173,http://10.0.0.252:5174,null

View File

@@ -8,6 +8,75 @@ Quick links
- Frontend: `frontend/`
- Backend: `backend/`
Dedicated ports list (single source of truth)
This project keeps all port assignments in the root `.env` file so services do not get mixed with other projects:
- `PORT_BACKEND` (API)
- `PORT_FRONTEND_DEV` (Vite dev)
- `PORT_FRONTEND_DEV_FALLBACK` (optional fallback)
- `APP_URL`, `API_BASE`, `VITE_API_URL` (URL defaults tied to those ports)
View the current dedicated map and whether each port is already in use:
```bash
npm run ports:list
```
Canonical MVP startup (from project root)
1) Install dependencies
```bash
npm install
npm install --prefix backend
npm install --prefix frontend
```
2) Start backend API
```bash
docker compose up -d
curl http://localhost:4000/health
```
3) Run web app (browser)
```bash
npm run dev --prefix frontend
```
Or run backend + web frontend together from the root:
```bash
npm run dev:web
```
Stop backend + web frontend from the root:
```bash
npm run dev:web:stop
```
4) Run desktop app (Electron + Vite)
```bash
npm run dev
```
For production-style desktop launch from built files:
```bash
npm run build:renderer
npm run start
```
5) Build desktop app
```bash
npm run build:desktop
```
Development
1) Backend (fast, using Docker):
@@ -155,4 +224,18 @@ npm run test:wiring
Optional env vars for this test:
- `API_BASE` (default `http://localhost:4000/api`)
- `WRITE_API_KEY` (required if write endpoints are protected)
- `WRITE_API_KEY` (required if write endpoints are protected)
MVP readiness check
Run one command to verify backend health, route wiring, and persistence:
```bash
npm run test:mvp
```
To also verify persistence across a backend restart:
```bash
npm run test:mvp:restart
```

View File

@@ -214,13 +214,16 @@ const app = express();
app.use(helmet());
app.use(morgan(process.env.MORGAN_FORMAT || 'combined'));
const defaultBackendPort = String(process.env.PORT || process.env.PORT_BACKEND || '4000');
const defaultFrontendPort = String(process.env.PORT_FRONTEND_DEV || '5173');
const configuredOrigins = (process.env.CORS_ORIGIN || '')
.split(',')
.map(o => o.trim())
.filter(Boolean);
const allowedOrigins = configuredOrigins.length
? configuredOrigins
: ['http://localhost:5173', 'http://localhost:4000', 'null'];
: [`http://localhost:${defaultFrontendPort}`, `http://localhost:${defaultBackendPort}`, 'null'];
const corsOptions = {
credentials: true,
@@ -544,7 +547,7 @@ app.post('/api/projects/:id/invites', limiter, requireApiKey, async (req, res) =
// Fire email if SMTP configured and email provided
if (req.body.email) {
const appUrl = process.env.APP_URL || 'http://localhost:5173';
const appUrl = process.env.APP_URL || `http://localhost:${defaultFrontendPort}`;
notify({
notifyEmail: req.body.email,
subject: `You've been invited to "${project.name}"`,
@@ -602,6 +605,6 @@ app.post('/api/invites/:token/accept', limiter, async (req, res) => {
res.json({ projectId: invite.projectId, projectName: invite.projectName });
});
const PORT = process.env.PORT || 4000;
const PORT = process.env.PORT || process.env.PORT_BACKEND || 4000;
const HOST = process.env.HOST || '0.0.0.0';
app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`));

View File

@@ -6,6 +6,8 @@ services:
- "${PORT_BACKEND:-4000}:${PORT_BACKEND:-4000}"
environment:
- PORT=${PORT_BACKEND:-4000}
- PORT_FRONTEND_DEV=${PORT_FRONTEND_DEV:-5173}
- APP_URL=${APP_URL:-http://localhost:5173}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5173,http://localhost:4000,null}
- WRITE_API_KEY=${WRITE_API_KEY:-}
volumes:

View File

@@ -2,12 +2,26 @@ const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')
const isDev = process.env.NODE_ENV !== 'production'
const devUrl = 'http://localhost:5173'
const devServerUrl = process.env.ELECTRON_DEV_SERVER_URL || process.env.VITE_DEV_SERVER_URL || ''
const isDev = !app.isPackaged && Boolean(devServerUrl)
const API_URL_KEY = 'runtime_api_url'
const DEFAULT_API_URL = process.env.VITE_API_URL || 'http://localhost:4000/api'
let mainWindow
function resolveRendererEntry() {
const candidates = [
path.join(__dirname, '..', 'dist', 'index.html'),
path.join(process.resourcesPath || '', 'app.asar', 'dist', 'index.html'),
path.join(process.resourcesPath || '', 'app.asar.unpacked', 'dist', 'index.html'),
]
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) return candidate
}
return candidates[0]
}
function normalizeApiUrl(value) {
if (!value || typeof value !== 'string') return null
const trimmed = value.trim().replace(/\/+$/, '')
@@ -88,11 +102,24 @@ function createWindow() {
}
})
mainWindow.webContents.on('did-fail-load', (_event, code, description, validatedUrl) => {
console.error(`Renderer failed to load (${code}): ${description} @ ${validatedUrl}`)
})
if (isDev) {
mainWindow.loadURL(devUrl)
mainWindow.loadURL(devServerUrl).catch(err => {
console.error('Failed to load dev server URL, falling back to local dist file.', err)
const rendererEntry = resolveRendererEntry()
mainWindow.loadFile(rendererEntry).catch(loadErr => {
console.error('Fallback renderer load failed.', loadErr)
})
})
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
const rendererEntry = resolveRendererEntry()
mainWindow.loadFile(rendererEntry).catch(err => {
console.error(`Failed to load renderer entry at ${rendererEntry}`, err)
})
}
}

View File

@@ -9,7 +9,7 @@
"build": "vite build",
"preview": "vite preview",
"build:renderer": "vite build",
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:${PORT_FRONTEND_DEV:-5173} && ELECTRON_DEV_SERVER_URL=http://localhost:${PORT_FRONTEND_DEV:-5173} electron .\"",
"electron:build": "vite build && electron-builder",
"start": "electron ."
},

View File

@@ -9,6 +9,7 @@ export default defineConfig(({ mode }) => {
base: './',
plugins: [react()],
server: {
host: env.VITE_DEV_HOST || '0.0.0.0',
port: parseInt(env.PORT_FRONTEND_DEV) || 5173,
},
}

17
main.js
View File

@@ -1,15 +1,2 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: { nodeIntegration: false },
titleBarStyle: 'hiddenInset', // mac-native title bar
icon: path.join(__dirname, 'icon.png')
});
win.loadFile(path.join(__dirname, 'dist', 'index.html'));
});
app.on('window-all-closed', () => process.platform !== 'darwin' && app.quit());
// Root Electron shim: delegate to the maintained process entrypoint.
require('./frontend/electron/main.cjs');

View File

@@ -3,7 +3,12 @@
"version": "1.0.0",
"main": "main.js",
"scripts": {
"dev": "npm run electron:dev --prefix frontend",
"dev:web": "docker compose up -d && npm run dev --prefix frontend -- --host ${VITE_DEV_HOST:-0.0.0.0} --port ${PORT_FRONTEND_DEV:-5173}",
"ports:list": "node scripts/ports-report.cjs",
"dev:web:stop": "lsof -tiTCP:5173 -sTCP:LISTEN | xargs kill 2>/dev/null || true && docker compose down",
"start": "electron .",
"start:desktop": "electron .",
"build": "electron-builder",
"build:desktop": "npm run build:renderer && npm run sync:dist && npm run build",
"build:renderer": "npm run build --prefix frontend",
@@ -11,6 +16,8 @@
"test:wiring": "node scripts/wiring-smoke-test.cjs",
"test:persistence": "node scripts/persistence-self-test.cjs",
"test:persistence:restart": "node scripts/persistence-self-test.cjs --restart-backend",
"test:mvp": "node scripts/mvp-check.cjs",
"test:mvp:restart": "node scripts/mvp-check.cjs --restart-backend",
"build:verified": "npm run test:persistence && npm run build",
"build:verified:restart": "npm run test:persistence:restart && npm run build",
"release:verified": "npm run test:persistence && npm run build:renderer && npm run sync:dist && npm run build",

View File

@@ -9,7 +9,30 @@ exports.default = async function (context) {
`${context.packager.appInfo.productFilename}.app`,
);
console.log(`Ad-hoc signing: ${appPath}`);
execSync(`xattr -cr "${appPath}"`);
execSync(`codesign --force --deep --sign - "${appPath}"`);
console.log('Ad-hoc signing complete.');
// Remove Finder/iCloud metadata that can make codesign fail with
// "resource fork, Finder information, or similar detritus not allowed".
const cleanupCmds = [
`xattr -cr "${appPath}"`,
`dot_clean -m "${appPath}"`,
`find "${appPath}" -name '._*' -delete`,
`xattr -r -d com.apple.FinderInfo "${appPath}"`,
`xattr -r -d com.apple.ResourceFork "${appPath}"`,
`xattr -r -d com.apple.quarantine "${appPath}"`,
];
for (const cmd of cleanupCmds) {
try {
execSync(cmd, { stdio: 'ignore' });
} catch {
// Cleanup commands are best-effort.
}
}
try {
execSync(`codesign --force --deep --sign - "${appPath}"`, { stdio: 'inherit' });
console.log('Ad-hoc signing complete.');
} catch (err) {
console.warn('Ad-hoc signing skipped due to codesign error. Build artifacts are still usable locally.');
}
};

97
scripts/mvp-check.cjs Normal file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env node
const { spawnSync } = require('child_process');
const args = new Set(process.argv.slice(2));
if (args.has('--help') || args.has('-h')) {
console.log('Project Hub MVP readiness check');
console.log('');
console.log('Usage:');
console.log(' node scripts/mvp-check.cjs [--restart-backend]');
console.log('');
console.log('Environment:');
console.log(' API_BASE default: http://localhost:4000/api');
console.log(' WRITE_API_KEY optional x-api-key for write endpoints');
process.exit(0);
}
const backendPort = process.env.PORT_BACKEND || '4000';
const API_BASE = process.env.API_BASE || `http://localhost:${backendPort}/api`;
const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
const RESTART_BACKEND = args.has('--restart-backend');
function log(msg) {
console.log(`[mvp-check] ${msg}`);
}
function formatCheck(status, name, detail = '') {
const suffix = detail ? `: ${detail}` : '';
console.log(`[mvp-check] ${status} ${name}${suffix}`);
}
async function checkHealth() {
try {
const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(5000) });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return { ok: true };
} catch (err) {
return { ok: false, detail: err.message };
}
}
function runNodeScript(scriptPath, scriptArgs = []) {
const result = spawnSync(process.execPath, [scriptPath, ...scriptArgs], {
env: process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
const output = [result.stdout || '', result.stderr || ''].join('').trim();
const lines = output ? output.split(/\r?\n/).filter(Boolean) : [];
const tail = lines.slice(-6).join(' | ');
if (result.error) {
return { ok: false, detail: result.error.message };
}
if (result.status !== 0) {
return { ok: false, detail: tail || `exit ${result.status}` };
}
return { ok: true, detail: tail };
}
async function run() {
log('Starting MVP hardening checks...');
log(`API base: ${API_BASE}`);
const results = [];
const health = await checkHealth();
results.push({ name: 'backend-health', ...health });
formatCheck(health.ok ? 'PASS' : 'FAIL', 'backend-health', health.detail || HEALTH_URL);
const wiring = runNodeScript('scripts/wiring-smoke-test.cjs');
results.push({ name: 'wiring-smoke', ...wiring });
formatCheck(wiring.ok ? 'PASS' : 'FAIL', 'wiring-smoke', wiring.detail);
const persistenceArgs = RESTART_BACKEND ? ['--restart-backend'] : [];
const persistence = runNodeScript('scripts/persistence-self-test.cjs', persistenceArgs);
results.push({ name: RESTART_BACKEND ? 'persistence-restart' : 'persistence', ...persistence });
formatCheck(persistence.ok ? 'PASS' : 'FAIL', RESTART_BACKEND ? 'persistence-restart' : 'persistence', persistence.detail);
const failed = results.filter(r => !r.ok);
if (failed.length > 0) {
log('NOT READY');
log(`Failed checks: ${failed.map(f => f.name).join(', ')}`);
process.exit(1);
}
log('READY');
log('All automated MVP hardening checks passed.');
}
run().catch(err => {
log(`NOT READY: ${err.message}`);
process.exit(1);
});

View File

@@ -15,7 +15,8 @@ if (args.has('--help') || args.has('-h')) {
process.exit(0);
}
const API_BASE = process.env.API_BASE || 'http://localhost:4000/api';
const backendPort = process.env.PORT_BACKEND || '4000';
const API_BASE = process.env.API_BASE || `http://localhost:${backendPort}/api`;
const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
const WRITE_API_KEY = process.env.WRITE_API_KEY || '';
const SHOULD_RESTART = args.has('--restart-backend');

89
scripts/ports-report.cjs Normal file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const net = require('net');
const root = process.cwd();
const envPath = path.join(root, '.env');
function parseEnv(filePath) {
if (!fs.existsSync(filePath)) return {};
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
const env = {};
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const idx = line.indexOf('=');
if (idx <= 0) continue;
const key = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim();
// Strip trailing comments for unquoted values.
if (!value.startsWith('"') && !value.startsWith("'")) {
const hashIdx = value.indexOf(' #');
if (hashIdx >= 0) value = value.slice(0, hashIdx).trim();
}
env[key] = value;
}
return env;
}
function checkPortOpen(port, host = '127.0.0.1', timeoutMs = 500) {
return new Promise(resolve => {
const socket = new net.Socket();
let settled = false;
const done = (isOpen) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(isOpen);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => done(true));
socket.once('timeout', () => done(false));
socket.once('error', () => done(false));
socket.connect(Number(port), host);
});
}
async function run() {
const env = parseEnv(envPath);
const backendPort = env.PORT_BACKEND || '4000';
const frontendPort = env.PORT_FRONTEND_DEV || '5173';
const frontendFallback = env.PORT_FRONTEND_DEV_FALLBACK || '5174';
const ports = [
{ name: 'Backend API', key: 'PORT_BACKEND', value: backendPort },
{ name: 'Frontend dev', key: 'PORT_FRONTEND_DEV', value: frontendPort },
{ name: 'Frontend fallback', key: 'PORT_FRONTEND_DEV_FALLBACK', value: frontendFallback },
];
console.log('[ports] Dedicated ports for this project');
console.log(`[ports] Source: ${envPath}`);
console.log('');
for (const p of ports) {
const isOpen = await checkPortOpen(p.value);
const state = isOpen ? 'IN USE' : 'free';
console.log(`[ports] ${p.key}=${p.value} (${p.name}) -> ${state}`);
}
console.log('');
console.log(`[ports] APP_URL=${env.APP_URL || `http://localhost:${frontendPort}`}`);
console.log(`[ports] API_BASE=${env.API_BASE || `http://localhost:${backendPort}/api`}`);
console.log(`[ports] VITE_API_URL=${env.VITE_API_URL || `http://localhost:${backendPort}/api`}`);
if (env.CORS_ORIGIN) console.log(`[ports] CORS_ORIGIN=${env.CORS_ORIGIN}`);
}
run().catch(err => {
console.error(`[ports] Failed: ${err.message}`);
process.exit(1);
});

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
const API_BASE = process.env.API_BASE || 'http://localhost:4000/api';
const backendPort = process.env.PORT_BACKEND || '4000';
const API_BASE = process.env.API_BASE || `http://localhost:${backendPort}/api`;
const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
const WRITE_API_KEY = process.env.WRITE_API_KEY || '';