mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
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:
parent
60ebd858ef
commit
f80cabc5f2
26 changed files with 9698 additions and 187 deletions
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
5
build/.gitkeep
Normal 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
3
electron/forge-env.d.ts
vendored
Normal 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
239
electron/main.ts
Normal 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
37
electron/preload.ts
Normal 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
59
forge.config.js
Normal 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
8971
package-lock.json
generated
File diff suppressed because it is too large
Load diff
26
package.json
26
package.json
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
115
src/components/BluetoothDevicePicker.tsx
Normal file
115
src/components/BluetoothDevicePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
34
src/platform/browser/BrowserFileService.ts
Normal file
34
src/platform/browser/BrowserFileService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/platform/browser/BrowserStorageService.ts
Normal file
42
src/platform/browser/BrowserStorageService.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/platform/electron/ElectronFileService.ts
Normal file
60
src/platform/electron/ElectronFileService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/platform/electron/ElectronStorageService.ts
Normal file
99
src/platform/electron/ElectronStorageService.ts
Normal 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
35
src/platform/index.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/platform/interfaces/IFileService.ts
Normal file
21
src/platform/interfaces/IFileService.ts
Normal 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;
|
||||||
|
}
|
||||||
25
src/platform/interfaces/IStorageService.ts
Normal file
25
src/platform/interfaces/IStorageService.ts
Normal 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
21
src/types/electron.d.ts
vendored
Normal 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 {};
|
||||||
|
|
@ -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
9
tsconfig.electron.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"outDir": "dist-electron",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["electron/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -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
11
vite.config.electron.mts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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 {
|
||||||
Loading…
Reference in a new issue