respira/electron/main.ts
2025-12-12 11:54:58 +01:00

249 lines
7.4 KiB
TypeScript

import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import { join } from 'path';
import { promises as fs } from 'fs';
import Store from 'electron-store';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import started from 'electron-squirrel-startup';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
app.quit();
}
updateElectronApp({
updateSource: {
type: UpdateSourceType.StaticStorage,
baseUrl: `https://jhbruhn.github.io/respira/update/${process.platform}/${process.arch}`
}
})
// Enable Web Bluetooth
app.commandLine.appendSwitch('enable-web-bluetooth', 'true');
app.commandLine.appendSwitch('enable-experimental-web-platform-features');
const store = new Store();
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1600,
height: 1000,
minWidth: 1280, // Prevent layout from breaking into single-column mobile view
minHeight: 800,
autoHideMenuBar: true, // Hide the menu bar (can be toggled with Alt key)
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;
}
});