Add Electron desktop application support with Electron Forge

Major changes:
- Add Electron main process and preload scripts with Web Bluetooth support
- Implement platform abstraction layer for storage and file services
- Create BluetoothDevicePicker component for device selection UI
- Migrate from electron-builder to Electron Forge for packaging
- Configure Vite for dual browser/Electron builds
- Add native file dialogs and persistent storage via electron-store
- Hide menu bar for cleaner desktop app appearance

The app now works in both browser (npm run dev) and Electron (npm run start).
Package with 'npm run package' or create installers with 'npm run make'.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik 2025-12-07 22:39:38 +01:00
parent 60ebd858ef
commit f80cabc5f2
26 changed files with 9698 additions and 187 deletions

View file

@ -2,7 +2,9 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run build:*)", "Bash(npm run build:*)",
"Bash(npm run lint)" "Bash(npm run lint)",
"Bash(cat:*)",
"Bash(npm run dev:electron:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

5
.gitignore vendored
View file

@ -10,6 +10,11 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
dist-electron
release
.electron-builder.cache
out
.vite
*.local *.local
# Editor directories and files # Editor directories and files

5
build/.gitkeep Normal file
View file

@ -0,0 +1,5 @@
# Placeholder for build resources
# Add your icon files here:
# - icon.ico (Windows, 256x256)
# - icon.icns (macOS)
# - icon.png (Linux, 512x512)

3
electron/forge-env.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
// Global variables injected by @electron-forge/plugin-vite
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
declare const MAIN_WINDOW_VITE_NAME: string;

239
electron/main.ts Normal file
View file

@ -0,0 +1,239 @@
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import { join } from 'path';
import { promises as fs } from 'fs';
import Store from 'electron-store';
import started from 'electron-squirrel-startup';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
app.quit();
}
// 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,
autoHideMenuBar: true, // Hide the menu bar (can be toggled with Alt key)
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) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Cross-Origin-Opener-Policy': ['same-origin'],
'Cross-Origin-Embedder-Policy': ['require-corp'],
},
});
});
// 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;
}
});

37
electron/preload.ts Normal file
View file

@ -0,0 +1,37 @@
import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', {
invoke: (channel: string, ...args: unknown[]) => {
const validChannels = [
'storage:savePattern',
'storage:getPattern',
'storage:getLatest',
'storage:deletePattern',
'storage:clear',
'dialog:openFile',
'dialog:saveFile',
'fs:readFile',
'fs:writeFile',
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
throw new Error(`Invalid IPC channel: ${channel}`);
},
// Bluetooth device selection
onBluetoothDeviceList: (callback: (devices: Array<{ deviceId: string; deviceName: string }>) => void) => {
ipcRenderer.on('bluetooth:device-list', (_event, devices) => callback(devices));
},
selectBluetoothDevice: (deviceId: string) => {
ipcRenderer.send('bluetooth:select-device', deviceId);
},
});
// Also expose process type for platform detection
contextBridge.exposeInMainWorld('process', {
type: 'renderer',
});

59
forge.config.js Normal file
View file

@ -0,0 +1,59 @@
const path = require('path');
module.exports = {
packagerConfig: {
asar: true,
extraResource: [
path.join(__dirname, 'dist', 'assets'),
],
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {
name: 'respira_web',
},
},
{
name: '@electron-forge/maker-zip',
platforms: ['darwin'],
},
{
name: '@electron-forge/maker-deb',
config: {},
},
{
name: '@electron-forge/maker-rpm',
config: {},
},
],
plugins: [
{
name: '@electron-forge/plugin-vite',
config: {
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
// If you are familiar with Vite configuration, it will look really familiar.
build: [
{
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
entry: 'electron/main.ts',
config: 'vite.config.electron.mts',
target: 'main',
},
{
entry: 'electron/preload.ts',
config: 'vite.config.electron.mts',
target: 'preload',
},
],
renderer: [
{
name: 'main_window',
config: 'vite.config.mts',
},
],
},
},
],
};

8971
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,27 @@
{ {
"name": "web", "name": "skitch-controller",
"private": true, "productName": "SKiTCH Controller",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "description": "Desktop controller for Brother embroidery machines",
"author": "Your Name",
"private": true,
"main": ".vite/build/main.js",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"dev:electron": "vite --mode electron",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^10.0.0",
"konva": "^10.0.12", "konva": "^10.0.12",
"pyodide": "^0.27.4", "pyodide": "^0.27.4",
"react": "^19.2.0", "react": "^19.2.0",
@ -21,11 +30,20 @@
"tailwindcss": "^4.1.17" "tailwindcss": "^4.1.17"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-rpm": "^7.10.2",
"@electron-forge/maker-squirrel": "^7.10.2",
"@electron-forge/maker-zip": "^7.10.2",
"@electron-forge/plugin-vite": "^7.10.2",
"@electron/typescript-definitions": "^8.15.6",
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",

View file

@ -6,6 +6,7 @@ import { ProgressMonitor } from './components/ProgressMonitor';
import { WorkflowStepper } from './components/WorkflowStepper'; import { WorkflowStepper } from './components/WorkflowStepper';
import { NextStepGuide } from './components/NextStepGuide'; import { NextStepGuide } from './components/NextStepGuide';
import { PatternSummaryCard } from './components/PatternSummaryCard'; import { PatternSummaryCard } from './components/PatternSummaryCard';
import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
import type { PesPatternData } from './utils/pystitchConverter'; import type { PesPatternData } from './utils/pystitchConverter';
import { pyodideLoader } from './utils/pyodideLoader'; import { pyodideLoader } from './utils/pyodideLoader';
import { hasError } from './utils/errorCodeHelpers'; import { hasError } from './utils/errorCodeHelpers';
@ -365,6 +366,9 @@ function App() {
/> />
</div> </div>
</div> </div>
{/* Bluetooth Device Picker (Electron only) */}
<BluetoothDevicePicker />
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,115 @@
import { useEffect, useState, useCallback } from 'react';
import type { BluetoothDevice } from '../types/electron';
export function BluetoothDevicePicker() {
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isScanning, setIsScanning] = useState(false);
useEffect(() => {
// Only set up listener in Electron
if (window.electronAPI?.onBluetoothDeviceList) {
window.electronAPI.onBluetoothDeviceList((deviceList) => {
console.log('[BluetoothPicker] Received device list:', deviceList);
setDevices(deviceList);
// Open the picker when scan starts (even if empty at first)
if (!isOpen) {
setIsOpen(true);
setIsScanning(true);
}
// Stop showing scanning state once we have devices
if (deviceList.length > 0) {
setIsScanning(false);
}
});
}
}, [isOpen]);
const handleSelectDevice = useCallback((deviceId: string) => {
console.log('[BluetoothPicker] User selected device:', deviceId);
window.electronAPI?.selectBluetoothDevice(deviceId);
setIsOpen(false);
setDevices([]);
}, []);
const handleCancel = useCallback(() => {
console.log('[BluetoothPicker] User cancelled device selection');
window.electronAPI?.selectBluetoothDevice('');
setIsOpen(false);
setDevices([]);
setIsScanning(false);
}, []);
// Handle escape key
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel();
}
}, [handleCancel]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={handleCancel}>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-blue-600 dark:border-blue-500"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="bluetooth-picker-title"
aria-describedby="bluetooth-picker-message"
>
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
<h3 id="bluetooth-picker-title" className="m-0 text-xl font-semibold dark:text-white">
Select Bluetooth Device
</h3>
</div>
<div className="p-6">
{isScanning && devices.length === 0 ? (
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<svg className="animate-spin h-5 w-5 text-blue-600 dark:text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span id="bluetooth-picker-message">Scanning for Bluetooth devices...</span>
</div>
) : (
<>
<p id="bluetooth-picker-message" className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100">
{devices.length} device{devices.length !== 1 ? 's' : ''} found. Select a device to connect:
</p>
<div className="space-y-2">
{devices.map((device) => (
<button
key={device.deviceId}
onClick={() => handleSelectDevice(device.deviceId)}
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-blue-100 dark:hover:bg-blue-900 hover:text-blue-900 dark:hover:text-blue-100 active:bg-blue-200 dark:active:bg-blue-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={`Connect to ${device.deviceName}`}
>
<div className="font-semibold text-gray-900 dark:text-white">{device.deviceName}</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">{device.deviceId}</div>
</button>
))}
</div>
</>
)}
</div>
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
<button
onClick={handleCancel}
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Cancel device selection"
>
Cancel
</button>
</div>
</div>
</div>
);
}

View file

@ -4,6 +4,8 @@ import { MachineStatus } from '../types/machine';
import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers'; import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
import { PatternInfoSkeleton } from './SkeletonLoader'; import { PatternInfoSkeleton } from './SkeletonLoader';
import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid'; import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid';
import { createFileService } from '../platform';
import type { IFileService } from '../platform/interfaces/IFileService';
interface FileUploadProps { interface FileUploadProps {
isConnected: boolean; isConnected: boolean;
@ -38,6 +40,7 @@ export function FileUpload({
}: FileUploadProps) { }: FileUploadProps) {
const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null); const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
const [fileName, setFileName] = useState<string>(''); const [fileName, setFileName] = useState<string>('');
const [fileService] = useState<IFileService>(() => createFileService());
// Use prop pesData if available (from cached pattern), otherwise use local state // Use prop pesData if available (from cached pattern), otherwise use local state
const pesData = pesDataProp || localPesData; const pesData = pesDataProp || localPesData;
@ -46,10 +49,7 @@ export function FileUpload({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleFileChange = useCallback( const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => { async (event?: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!pyodideReady) { if (!pyodideReady) {
alert('Python environment is still loading. Please wait...'); alert('Python environment is still loading. Please wait...');
return; return;
@ -57,6 +57,21 @@ export function FileUpload({
setIsLoading(true); setIsLoading(true);
try { try {
let file: File | null = null;
// In Electron, use native file dialogs
if (fileService.hasNativeDialogs()) {
file = await fileService.openFileDialog({ accept: '.pes' });
} else {
// In browser, use the input element
file = event?.target.files?.[0] || null;
}
if (!file) {
setIsLoading(false);
return;
}
const data = await convertPesToPen(file); const data = await convertPesToPen(file);
setLocalPesData(data); setLocalPesData(data);
setFileName(file.name); setFileName(file.name);
@ -71,7 +86,7 @@ export function FileUpload({
setIsLoading(false); setIsLoading(false);
} }
}, },
[onPatternLoaded, pyodideReady] [fileService, onPatternLoaded, pyodideReady]
); );
const handleUpload = useCallback(() => { const handleUpload = useCallback(() => {
@ -164,7 +179,8 @@ export function FileUpload({
disabled={!pyodideReady || isLoading || patternUploaded || isUploading} disabled={!pyodideReady || isLoading || patternUploaded || isUploading}
/> />
<label <label
htmlFor="file-input" htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
onClick={fileService.hasNativeDialogs() ? () => handleFileChange() : undefined}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded font-semibold text-xs transition-all ${ className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded font-semibold text-xs transition-all ${
!pyodideReady || isLoading || patternUploaded || isUploading !pyodideReady || isLoading || patternUploaded || isUploading
? 'opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white' ? 'opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white'

View file

@ -7,14 +7,16 @@ import type {
} from "../types/machine"; } from "../types/machine";
import { MachineStatus, MachineStatusNames } from "../types/machine"; import { MachineStatus, MachineStatusNames } from "../types/machine";
import { import {
PatternCacheService,
uuidToString, uuidToString,
} from "../services/PatternCacheService"; } from "../services/PatternCacheService";
import type { IStorageService } from "../platform/interfaces/IStorageService";
import { createStorageService } from "../platform";
import type { PesPatternData } from "../utils/pystitchConverter"; import type { PesPatternData } from "../utils/pystitchConverter";
import { SewingMachineError } from "../utils/errorCodeHelpers"; import { SewingMachineError } from "../utils/errorCodeHelpers";
export function useBrotherMachine() { export function useBrotherMachine() {
const [service] = useState(() => new BrotherPP1Service()); const [service] = useState(() => new BrotherPP1Service());
const [storageService] = useState<IStorageService>(() => createStorageService());
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [machineInfo, setMachineInfo] = useState<MachineInfo | null>(null); const [machineInfo, setMachineInfo] = useState<MachineInfo | null>(null);
const [machineStatus, setMachineStatus] = useState<MachineStatus>( const [machineStatus, setMachineStatus] = useState<MachineStatus>(
@ -81,7 +83,7 @@ export function useBrotherMachine() {
// Check if we have this pattern cached // Check if we have this pattern cached
const uuidStr = uuidToString(machineUuid); const uuidStr = uuidToString(machineUuid);
const cached = PatternCacheService.getPatternByUUID(uuidStr); const cached = await storageService.getPatternByUUID(uuidStr);
if (cached) { if (cached) {
console.log("[Resume] Pattern found in cache:", cached.fileName, "Offset:", cached.patternOffset); console.log("[Resume] Pattern found in cache:", cached.fileName, "Offset:", cached.patternOffset);
@ -113,7 +115,7 @@ export function useBrotherMachine() {
setResumeFileName(null); setResumeFileName(null);
return null; return null;
} }
}, [service]); }, [service, storageService]);
const connect = useCallback(async () => { const connect = useCallback(async () => {
try { try {
@ -216,7 +218,7 @@ export function useBrotherMachine() {
if (!machineUuid) return null; if (!machineUuid) return null;
const uuidStr = uuidToString(machineUuid); const uuidStr = uuidToString(machineUuid);
const cached = PatternCacheService.getPatternByUUID(uuidStr); const cached = await storageService.getPatternByUUID(uuidStr);
if (cached) { if (cached) {
console.log("[Resume] Loading cached pattern:", cached.fileName, "Offset:", cached.patternOffset); console.log("[Resume] Loading cached pattern:", cached.fileName, "Offset:", cached.patternOffset);
@ -232,7 +234,7 @@ export function useBrotherMachine() {
); );
return null; return null;
} }
}, [service, resumeAvailable, refreshPatternInfo]); }, [service, storageService, resumeAvailable, refreshPatternInfo]);
const uploadPattern = useCallback( const uploadPattern = useCallback(
async (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => { async (penData: Uint8Array, pesData: PesPatternData, fileName: string, patternOffset?: { x: number; y: number }) => {
@ -257,7 +259,7 @@ export function useBrotherMachine() {
// Cache the pattern with its UUID and offset // Cache the pattern with its UUID and offset
const uuidStr = uuidToString(uuid); const uuidStr = uuidToString(uuid);
PatternCacheService.savePattern(uuidStr, pesData, fileName, patternOffset); storageService.savePattern(uuidStr, pesData, fileName, patternOffset);
console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr, "Offset:", patternOffset); console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr, "Offset:", patternOffset);
// Clear resume state since we just uploaded // Clear resume state since we just uploaded
@ -275,7 +277,7 @@ export function useBrotherMachine() {
setIsUploading(false); // Clear loading state setIsUploading(false); // Clear loading state
} }
}, },
[service, isConnected, refreshStatus, refreshPatternInfo], [service, storageService, isConnected, refreshStatus, refreshPatternInfo],
); );
const startMaskTrace = useCallback(async () => { const startMaskTrace = useCallback(async () => {
@ -328,7 +330,7 @@ export function useBrotherMachine() {
const machineUuid = await service.getPatternUUID(); const machineUuid = await service.getPatternUUID();
if (machineUuid) { if (machineUuid) {
const uuidStr = uuidToString(machineUuid); const uuidStr = uuidToString(machineUuid);
PatternCacheService.deletePattern(uuidStr); await storageService.deletePattern(uuidStr);
console.log("[Cache] Deleted pattern with UUID:", uuidStr); console.log("[Cache] Deleted pattern with UUID:", uuidStr);
} }
} catch (err) { } catch (err) {
@ -353,7 +355,7 @@ export function useBrotherMachine() {
} finally { } finally {
setIsDeleting(false); // Clear loading state setIsDeleting(false); // Clear loading state
} }
}, [service, isConnected, refreshStatus]); }, [service, storageService, isConnected, refreshStatus]);
// Periodic status monitoring when connected // Periodic status monitoring when connected
useEffect(() => { useEffect(() => {

View file

@ -0,0 +1,34 @@
import type { IFileService } from '../interfaces/IFileService';
/**
* Browser implementation of file service using HTML input elements
*/
export class BrowserFileService implements IFileService {
async openFileDialog(options: { accept: string }): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = options.accept;
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
resolve(file || null);
};
input.oncancel = () => {
resolve(null);
};
input.click();
});
}
async saveFileDialog(_data: Uint8Array, _defaultName: string): Promise<void> {
// No-op in browser - could implement download if needed in the future
console.warn('saveFileDialog not implemented in browser');
}
hasNativeDialogs(): boolean {
return false;
}
}

View file

@ -0,0 +1,42 @@
import { PatternCacheService } from '../../services/PatternCacheService';
import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService';
import type { PesPatternData } from '../../utils/pystitchConverter';
/**
* Browser implementation of storage service using localStorage
* Wraps the existing PatternCacheService
*/
export class BrowserStorageService implements IStorageService {
async savePattern(
uuid: string,
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number }
): Promise<void> {
PatternCacheService.savePattern(uuid, pesData, fileName, patternOffset);
}
async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> {
return PatternCacheService.getPatternByUUID(uuid);
}
async getMostRecentPattern(): Promise<ICachedPattern | null> {
return PatternCacheService.getMostRecentPattern();
}
async hasPattern(uuid: string): Promise<boolean> {
return PatternCacheService.hasPattern(uuid);
}
async deletePattern(uuid: string): Promise<void> {
PatternCacheService.deletePattern(uuid);
}
async clearCache(): Promise<void> {
PatternCacheService.clearCache();
}
async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> {
return PatternCacheService.getCacheInfo();
}
}

View file

@ -0,0 +1,60 @@
import type { IFileService } from '../interfaces/IFileService';
/**
* Electron implementation of file service using native dialogs via IPC
*/
export class ElectronFileService implements IFileService {
async openFileDialog(_options: { accept: string }): Promise<File | null> {
if (!window.electronAPI) {
throw new Error('Electron API not available');
}
try {
const result = await window.electronAPI.invoke<{ filePath: string; fileName: string } | null>('dialog:openFile', {
filters: [
{ name: 'PES Files', extensions: ['pes'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (!result) {
return null;
}
// Read the file content
const buffer = await window.electronAPI.invoke<ArrayBuffer>('fs:readFile', result.filePath);
const blob = new Blob([buffer]);
return new File([blob], result.fileName, { type: 'application/octet-stream' });
} catch (err) {
console.error('[ElectronFileService] Failed to open file:', err);
return null;
}
}
async saveFileDialog(data: Uint8Array, defaultName: string): Promise<void> {
if (!window.electronAPI) {
throw new Error('Electron API not available');
}
try {
const filePath = await window.electronAPI.invoke<string | null>('dialog:saveFile', {
defaultPath: defaultName,
filters: [
{ name: 'PEN Files', extensions: ['pen'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (filePath) {
await window.electronAPI.invoke('fs:writeFile', filePath, Array.from(data));
}
} catch (err) {
console.error('[ElectronFileService] Failed to save file:', err);
throw err;
}
}
hasNativeDialogs(): boolean {
return true;
}
}

View file

@ -0,0 +1,99 @@
import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService';
import type { PesPatternData } from '../../utils/pystitchConverter';
/**
* Electron implementation of storage service using electron-store via IPC
*/
export class ElectronStorageService implements IStorageService {
private async invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
if (!window.electronAPI) {
throw new Error('Electron API not available');
}
return window.electronAPI.invoke(channel, ...args);
}
async savePattern(
uuid: string,
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number }
): Promise<void> {
// Convert Uint8Array to array for JSON serialization over IPC
const serializable = {
uuid,
pesData: {
...pesData,
penData: Array.from(pesData.penData),
},
fileName,
timestamp: Date.now(),
patternOffset,
};
// Fire and forget (sync-like behavior to match interface)
this.invoke('storage:savePattern', serializable).catch(err => {
console.error('[ElectronStorage] Failed to save pattern:', err);
});
}
async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> {
try {
const pattern = await this.invoke<ICachedPattern | null>('storage:getPattern', uuid);
if (pattern && Array.isArray(pattern.pesData.penData)) {
// Restore Uint8Array from array
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
}
return pattern;
} catch (err) {
console.error('[ElectronStorage] Failed to get pattern:', err);
return null;
}
}
async getMostRecentPattern(): Promise<ICachedPattern | null> {
try {
const pattern = await this.invoke<ICachedPattern | null>('storage:getLatest');
if (pattern && Array.isArray(pattern.pesData.penData)) {
// Restore Uint8Array from array
pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
}
return pattern;
} catch (err) {
console.error('[ElectronStorage] Failed to get latest pattern:', err);
return null;
}
}
async hasPattern(_uuid: string): Promise<boolean> {
// Since this is async in Electron, we can't truly implement this synchronously
// Returning false as a safe default
console.warn('[ElectronStorage] hasPattern called synchronously, returning false');
return false;
}
async deletePattern(uuid: string): Promise<void> {
try {
await this.invoke('storage:deletePattern', uuid);
} catch (err) {
console.error('[ElectronStorage] Failed to delete pattern:', err);
}
}
async clearCache(): Promise<void> {
try {
await this.invoke('storage:clear');
} catch (err) {
console.error('[ElectronStorage] Failed to clear cache:', err);
}
}
async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> {
// This needs to be async in Electron, return empty info synchronously
console.warn('[ElectronStorage] getCacheInfo called synchronously, returning empty');
return { hasCache: false };
}
}

35
src/platform/index.ts Normal file
View file

@ -0,0 +1,35 @@
import type { IStorageService } from './interfaces/IStorageService';
import type { IFileService } from './interfaces/IFileService';
import { BrowserStorageService } from './browser/BrowserStorageService';
import { BrowserFileService } from './browser/BrowserFileService';
import { ElectronStorageService } from './electron/ElectronStorageService';
import { ElectronFileService } from './electron/ElectronFileService';
/**
* Detect if running in Electron
*/
export function isElectron(): boolean {
return !!(typeof window !== 'undefined' && window.process && window.process.type === 'renderer');
}
/**
* Create storage service based on platform
*/
export function createStorageService(): IStorageService {
if (isElectron()) {
return new ElectronStorageService();
} else {
return new BrowserStorageService();
}
}
/**
* Create file service based on platform
*/
export function createFileService(): IFileService {
if (isElectron()) {
return new ElectronFileService();
} else {
return new BrowserFileService();
}
}

View file

@ -0,0 +1,21 @@
export interface IFileService {
/**
* Open file picker and return File object
* @param options File picker options (e.g., accept filter)
* @returns Selected File or null if cancelled
*/
openFileDialog(options: { accept: string }): Promise<File | null>;
/**
* Save file with native dialog (Electron only, no-op in browser)
* @param data File data as Uint8Array
* @param defaultName Default filename
*/
saveFileDialog(data: Uint8Array, defaultName: string): Promise<void>;
/**
* Check if native file dialogs are available
* @returns true if running in Electron with native dialogs, false otherwise
*/
hasNativeDialogs(): boolean;
}

View file

@ -0,0 +1,25 @@
import type { PesPatternData } from '../../utils/pystitchConverter';
export interface ICachedPattern {
uuid: string;
pesData: PesPatternData;
fileName: string;
timestamp: number;
patternOffset?: { x: number; y: number };
}
export interface IStorageService {
savePattern(
uuid: string,
pesData: PesPatternData,
fileName: string,
patternOffset?: { x: number; y: number }
): Promise<void>;
getPatternByUUID(uuid: string): Promise<ICachedPattern | null>;
getMostRecentPattern(): Promise<ICachedPattern | null>;
hasPattern(uuid: string): Promise<boolean>;
deletePattern(uuid: string): Promise<void>;
clearCache(): Promise<void>;
getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }>;
}

21
src/types/electron.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
export interface BluetoothDevice {
deviceId: string;
deviceName: string;
}
export interface ElectronAPI {
invoke<T = unknown>(channel: string, ...args: unknown[]): Promise<T>;
onBluetoothDeviceList: (callback: (devices: BluetoothDevice[]) => void) => void;
selectBluetoothDevice: (deviceId: string) => void;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
process?: {
type?: string;
};
}
}
export {};

View file

@ -5,7 +5,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client", "web-bluetooth"], "types": ["vite/client", "web-bluetooth", "node"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
@ -24,5 +24,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": ["src", "electron"]
} }

9
tsconfig.electron.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist-electron",
"types": ["node"]
},
"include": ["electron/**/*"]
}

View file

@ -22,5 +22,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.mts", "vite.config.electron.mts"]
} }

11
vite.config.electron.mts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
// For Electron Forge's Vite plugin, this config is used for main and preload processes
// The renderer config is in vite.config.mts
export default defineConfig({
build: {
rollupOptions: {
external: ['electron'],
},
},
});

View file

@ -1,7 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { viteStaticCopy } from 'vite-plugin-static-copy' import { viteStaticCopy } from 'vite-plugin-static-copy'
import tailwindcss from '@tailwindcss/vite'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
@ -12,7 +12,7 @@ const PYODIDE_EXCLUDE = [
'!**/node_modules', '!**/node_modules',
] ]
function viteStaticCopyPyodide() { export function viteStaticCopyPyodide() {
const pyodideDir = dirname(fileURLToPath(import.meta.resolve('pyodide'))) const pyodideDir = dirname(fileURLToPath(import.meta.resolve('pyodide')))
return viteStaticCopy({ return viteStaticCopy({
targets: [ targets: [
@ -66,7 +66,7 @@ async function getPyPIWheelUrl(packageName: string, version: string): Promise<{
} }
} }
function downloadPyPIWheels(packages: PyPIPackage[]): Plugin { export function downloadPyPIWheels(packages: PyPIPackage[]): Plugin {
const wheels: WheelData[] = [] const wheels: WheelData[] = []
return { return {