respira/electron/main.ts
Jan-Henrik Bruhn 0dfc8b731b feature: Implement Web Worker-based pattern conversion with progress tracking
Move Pyodide/PyStitch pattern conversion to a dedicated Web Worker for
non-blocking UI performance. Pyodide now loads in the background on app
startup with real-time progress indicator showing download and initialization
status.

Key changes:
- Create patternConverter.worker.ts with full PES to PEN conversion logic
- Add patternConverterClient.ts singleton for worker management
- Implement progress tracking (0-100%) with step descriptions
- Add inline progress bar in FileUpload component with contextual messaging
- Configure COEP headers for Electron to support Web Workers
- Pass dynamic asset URLs to worker for file:// protocol support
- Update UIStore with progress state management
- Simplify pystitchConverter.ts to delegate to worker client

Benefits:
- Truly non-blocking UI (heavy Python/WASM runs off main thread)
- Real progress feedback during 15MB Pyodide download
- Works in both browser and Electron (dev and production)
- Faster app startup perception with background loading
- Better UX with "waiting" state if user clicks before ready

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 13:34:13 +01:00

286 lines
8.1 KiB
TypeScript

import { app, BrowserWindow, ipcMain, dialog } from "electron";
import { join } from "path";
import { promises as fs } from "fs";
import Store from "electron-store";
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
import started from "electron-squirrel-startup";
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
app.quit();
}
updateElectronApp({
updateSource: {
type: UpdateSourceType.StaticStorage,
baseUrl: `https://jhbruhn.github.io/respira/update/${process.platform}/${process.arch}`,
},
});
// Enable Web Bluetooth
app.commandLine.appendSwitch("enable-web-bluetooth", "true");
app.commandLine.appendSwitch("enable-experimental-web-platform-features");
const store = new Store();
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1600,
height: 1000,
minWidth: 1280, // Prevent layout from breaking into single-column mobile view
minHeight: 800,
autoHideMenuBar: true, // Hide the menu bar (can be toggled with Alt key)
title: `Respira v${app.getVersion()}`,
webPreferences: {
preload: join(__dirname, "preload.js"),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
// Enable SharedArrayBuffer for Pyodide
additionalArguments: ["--enable-features=SharedArrayBuffer"],
},
});
// Handle Web Bluetooth device selection
// This is CRITICAL - Electron doesn't show browser's native picker
// The handler may be called multiple times as new devices are discovered
let deviceSelectionCallback: ((deviceId: string) => void) | null = null;
mainWindow.webContents.on(
"select-bluetooth-device",
(event, deviceList, callback) => {
event.preventDefault();
console.log(
"[Bluetooth] Device list updated:",
deviceList.map((d) => ({
name: d.deviceName,
id: d.deviceId,
})),
);
// Store the callback for later use (may be called multiple times as devices are discovered)
console.log(
"[Bluetooth] Storing new callback, previous callback existed:",
!!deviceSelectionCallback,
);
deviceSelectionCallback = callback;
// Always send device list to renderer (even if empty) to show scanning UI
console.log("[Bluetooth] Sending device list to renderer for selection");
mainWindow.webContents.send(
"bluetooth:device-list",
deviceList.map((d) => ({
deviceId: d.deviceId,
deviceName: d.deviceName || "Unknown Device",
})),
);
},
);
// Handle device selection from renderer
ipcMain.on("bluetooth:select-device", (_event, deviceId: string) => {
console.log("[Bluetooth] Renderer selected device:", deviceId);
if (deviceSelectionCallback) {
console.log("[Bluetooth] Calling callback with deviceId:", deviceId);
deviceSelectionCallback(deviceId);
deviceSelectionCallback = null;
} else {
console.error(
"[Bluetooth] No callback available! Device selection may have timed out.",
);
}
});
// Optional: Handle Bluetooth pairing for Windows/Linux PIN validation
mainWindow.webContents.session.setBluetoothPairingHandler(
(details, callback) => {
console.log("[Bluetooth] Pairing request:", details);
// Auto-confirm pairing
callback({ confirmed: true /*pairingKind: 'confirm' */ });
},
);
// Set COOP/COEP headers for Pyodide SharedArrayBuffer support
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
// Apply headers to ALL resources including workers
const headers: Record<string, string[]> = {
...details.responseHeaders,
"Cross-Origin-Opener-Policy": ["unsafe-none"],
"Cross-Origin-Embedder-Policy": ["unsafe-none"],
};
// For same-origin resources (including workers), add CORP header
if (
details.url.startsWith("http://localhost") ||
details.url.startsWith("file://")
) {
headers["Cross-Origin-Resource-Policy"] = ["same-origin"];
}
callback({ responseHeaders: headers });
},
);
// Load the app
// MAIN_WINDOW_VITE_DEV_SERVER_URL is provided by @electron-forge/plugin-vite
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
mainWindow.webContents.openDevTools();
} else {
// MAIN_WINDOW_VITE_NAME is the renderer name from forge.config.js
mainWindow.loadFile(
join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
);
}
}
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// IPC Handlers
// Storage handlers (using electron-store)
ipcMain.handle("storage:savePattern", async (_event, pattern) => {
try {
store.set(`pattern:${pattern.uuid}`, pattern);
store.set("pattern:latest", pattern);
console.log(
"[Storage] Saved pattern:",
pattern.fileName,
"UUID:",
pattern.uuid,
);
return true;
} catch (err) {
console.error("[Storage] Failed to save pattern:", err);
throw err;
}
});
ipcMain.handle("storage:getPattern", async (_event, uuid) => {
try {
const pattern = store.get(`pattern:${uuid}`, null);
if (pattern) {
console.log("[Storage] Retrieved pattern for UUID:", uuid);
}
return pattern;
} catch (err) {
console.error("[Storage] Failed to get pattern:", err);
return null;
}
});
ipcMain.handle("storage:getLatest", async () => {
try {
const pattern = store.get("pattern:latest", null);
return pattern;
} catch (err) {
console.error("[Storage] Failed to get latest pattern:", err);
return null;
}
});
ipcMain.handle("storage:deletePattern", async (_event, uuid) => {
try {
store.delete(`pattern:${uuid}`);
console.log("[Storage] Deleted pattern with UUID:", uuid);
return true;
} catch (err) {
console.error("[Storage] Failed to delete pattern:", err);
throw err;
}
});
ipcMain.handle("storage:clear", async () => {
try {
const keys = Object.keys(store.store).filter((k) =>
k.startsWith("pattern:"),
);
keys.forEach((k) => store.delete(k));
console.log("[Storage] Cleared all patterns");
return true;
} catch (err) {
console.error("[Storage] Failed to clear cache:", err);
throw err;
}
});
// File dialog handlers
ipcMain.handle("dialog:openFile", async (_event, options) => {
try {
const result = await dialog.showOpenDialog({
properties: ["openFile"],
filters: options.filters,
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
const filePath = result.filePaths[0];
const fileName = filePath.split(/[\\/]/).pop() || "";
console.log("[Dialog] File selected:", fileName);
return { filePath, fileName };
} catch (err) {
console.error("[Dialog] Failed to open file:", err);
throw err;
}
});
ipcMain.handle("dialog:saveFile", async (_event, options) => {
try {
const result = await dialog.showSaveDialog({
defaultPath: options.defaultPath,
filters: options.filters,
});
if (result.canceled) {
return null;
}
console.log("[Dialog] Save file selected:", result.filePath);
return result.filePath;
} catch (err) {
console.error("[Dialog] Failed to save file:", err);
throw err;
}
});
// File system handlers
ipcMain.handle("fs:readFile", async (_event, filePath) => {
try {
const buffer = await fs.readFile(filePath);
console.log("[FS] Read file:", filePath, "Size:", buffer.length);
return buffer;
} catch (err) {
console.error("[FS] Failed to read file:", err);
throw err;
}
});
ipcMain.handle("fs:writeFile", async (_event, filePath, data) => {
try {
await fs.writeFile(filePath, Buffer.from(data));
console.log("[FS] Wrote file:", filePath);
return true;
} catch (err) {
console.error("[FS] Failed to write file:", err);
throw err;
}
});