From 0dfc8b731b1de9fd428fc4d4206b0a77c3f05339 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 13 Dec 2025 13:34:13 +0100 Subject: [PATCH] feature: Implement Web Worker-based pattern conversion with progress tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron/main.ts | 200 ++++++----- src/App.tsx | 2 +- src/components/FileUpload.tsx | 67 +++- src/stores/useUIStore.ts | 29 +- src/utils/patternConverterClient.ts | 248 +++++++++++++ src/utils/pystitchConverter.ts | 314 +--------------- src/workers/patternConverter.worker.ts | 474 +++++++++++++++++++++++++ vite.config.mts | 5 + 8 files changed, 926 insertions(+), 413 deletions(-) create mode 100644 src/utils/patternConverterClient.ts create mode 100644 src/workers/patternConverter.worker.ts diff --git a/electron/main.ts b/electron/main.ts index 5aab7fe..14b8c40 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,10 +1,10 @@ -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 { 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'; +import started from "electron-squirrel-startup"; // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) { @@ -14,13 +14,13 @@ if (started) { updateElectronApp({ updateSource: { type: UpdateSourceType.StaticStorage, - baseUrl: `https://jhbruhn.github.io/respira/update/${process.platform}/${process.arch}` - } -}) + 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'); +app.commandLine.appendSwitch("enable-web-bluetooth", "true"); +app.commandLine.appendSwitch("enable-experimental-web-platform-features"); const store = new Store(); @@ -33,13 +33,13 @@ function createWindow() { autoHideMenuBar: true, // Hide the menu bar (can be toggled with Alt key) title: `Respira v${app.getVersion()}`, webPreferences: { - preload: join(__dirname, 'preload.js'), + preload: join(__dirname, "preload.js"), nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, // Enable SharedArrayBuffer for Pyodide - additionalArguments: ['--enable-features=SharedArrayBuffer'], + additionalArguments: ["--enable-features=SharedArrayBuffer"], }, }); @@ -48,55 +48,82 @@ function createWindow() { // 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(); + 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 - }))); + 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; + // 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' - }))); - }); + // 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); + 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); + 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.'); + 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' */}); - }); + 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) => { - callback({ - responseHeaders: { + mainWindow.webContents.session.webRequest.onHeadersReceived( + (details, callback) => { + // Apply headers to ALL resources including workers + const headers: Record = { ...details.responseHeaders, - 'Cross-Origin-Opener-Policy': ['same-origin'], - 'Cross-Origin-Embedder-Policy': ['require-corp'], - }, - }); - }); + "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 @@ -105,19 +132,21 @@ function createWindow() { 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`)); + 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.on("window-all-closed", () => { + if (process.platform !== "darwin") { app.quit(); } }); -app.on('activate', () => { +app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } @@ -126,69 +155,76 @@ app.on('activate', () => { // IPC Handlers // Storage handlers (using electron-store) -ipcMain.handle('storage:savePattern', async (_event, pattern) => { +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); + 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); + console.error("[Storage] Failed to save pattern:", err); throw err; } }); -ipcMain.handle('storage:getPattern', async (_event, uuid) => { +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); + console.log("[Storage] Retrieved pattern for UUID:", uuid); } return pattern; } catch (err) { - console.error('[Storage] Failed to get pattern:', err); + console.error("[Storage] Failed to get pattern:", err); return null; } }); -ipcMain.handle('storage:getLatest', async () => { +ipcMain.handle("storage:getLatest", async () => { try { - const pattern = store.get('pattern:latest', null); + const pattern = store.get("pattern:latest", null); return pattern; } catch (err) { - console.error('[Storage] Failed to get latest pattern:', err); + console.error("[Storage] Failed to get latest pattern:", err); return null; } }); -ipcMain.handle('storage:deletePattern', async (_event, uuid) => { +ipcMain.handle("storage:deletePattern", async (_event, uuid) => { try { store.delete(`pattern:${uuid}`); - console.log('[Storage] Deleted pattern with UUID:', uuid); + console.log("[Storage] Deleted pattern with UUID:", uuid); return true; } catch (err) { - console.error('[Storage] Failed to delete pattern:', err); + console.error("[Storage] Failed to delete pattern:", err); throw err; } }); -ipcMain.handle('storage:clear', async () => { +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'); + 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); + console.error("[Storage] Failed to clear cache:", err); throw err; } }); // File dialog handlers -ipcMain.handle('dialog:openFile', async (_event, options) => { +ipcMain.handle("dialog:openFile", async (_event, options) => { try { const result = await dialog.showOpenDialog({ - properties: ['openFile'], + properties: ["openFile"], filters: options.filters, }); @@ -197,17 +233,17 @@ ipcMain.handle('dialog:openFile', async (_event, options) => { } const filePath = result.filePaths[0]; - const fileName = filePath.split(/[\\/]/).pop() || ''; + const fileName = filePath.split(/[\\/]/).pop() || ""; - console.log('[Dialog] File selected:', fileName); + console.log("[Dialog] File selected:", fileName); return { filePath, fileName }; } catch (err) { - console.error('[Dialog] Failed to open file:', err); + console.error("[Dialog] Failed to open file:", err); throw err; } }); -ipcMain.handle('dialog:saveFile', async (_event, options) => { +ipcMain.handle("dialog:saveFile", async (_event, options) => { try { const result = await dialog.showSaveDialog({ defaultPath: options.defaultPath, @@ -218,33 +254,33 @@ ipcMain.handle('dialog:saveFile', async (_event, options) => { return null; } - console.log('[Dialog] Save file selected:', result.filePath); + console.log("[Dialog] Save file selected:", result.filePath); return result.filePath; } catch (err) { - console.error('[Dialog] Failed to save file:', err); + console.error("[Dialog] Failed to save file:", err); throw err; } }); // File system handlers -ipcMain.handle('fs:readFile', async (_event, filePath) => { +ipcMain.handle("fs:readFile", async (_event, filePath) => { try { const buffer = await fs.readFile(filePath); - console.log('[FS] Read file:', filePath, 'Size:', buffer.length); + console.log("[FS] Read file:", filePath, "Size:", buffer.length); return buffer; } catch (err) { - console.error('[FS] Failed to read file:', err); + console.error("[FS] Failed to read file:", err); throw err; } }); -ipcMain.handle('fs:writeFile', async (_event, filePath, data) => { +ipcMain.handle("fs:writeFile", async (_event, filePath, data) => { try { await fs.writeFile(filePath, Buffer.from(data)); - console.log('[FS] Wrote file:', filePath); + console.log("[FS] Wrote file:", filePath); return true; } catch (err) { - console.error('[FS] Failed to write file:', err); + console.error("[FS] Failed to write file:", err); throw err; } }); diff --git a/src/App.tsx b/src/App.tsx index 98ea17f..6d0bdb6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -83,7 +83,7 @@ function App() { const errorPopoverRef = useRef(null); const errorButtonRef = useRef(null); - // Initialize Pyodide on mount + // Initialize Pyodide in background on mount (non-blocking thanks to worker) useEffect(() => { initializePyodide(); }, [initializePyodide]); diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 36fcf84..98db67c 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -52,7 +52,19 @@ export function FileUpload() { ); // UI store - const pyodideReady = useUIStore((state) => state.pyodideReady); + const { + pyodideReady, + pyodideProgress, + pyodideLoadingStep, + initializePyodide, + } = useUIStore( + useShallow((state) => ({ + pyodideReady: state.pyodideReady, + pyodideProgress: state.pyodideProgress, + pyodideLoadingStep: state.pyodideLoadingStep, + initializePyodide: state.initializePyodide, + })) + ); const [localPesData, setLocalPesData] = useState(null); const [fileName, setFileName] = useState(''); const [fileService] = useState(() => createFileService()); @@ -65,13 +77,15 @@ export function FileUpload() { const handleFileChange = useCallback( async (event?: React.ChangeEvent) => { - if (!pyodideReady) { - alert('Python environment is still loading. Please wait...'); - return; - } - setIsLoading(true); try { + // Wait for Pyodide if it's still loading + if (!pyodideReady) { + console.log('[FileUpload] Waiting for Pyodide to finish loading...'); + await initializePyodide(); + console.log('[FileUpload] Pyodide ready'); + } + let file: File | null = null; // In Electron, use native file dialogs @@ -101,7 +115,7 @@ export function FileUpload() { setIsLoading(false); } }, - [fileService, setPattern, pyodideReady] + [fileService, setPattern, pyodideReady, initializePyodide] ); const handleUpload = useCallback(() => { @@ -256,13 +270,13 @@ export function FileUpload() { onChange={handleFileChange} id="file-input" className="hidden" - disabled={!pyodideReady || isLoading || patternUploaded || isUploading} + disabled={isLoading || patternUploaded || isUploading} />