diff --git a/.gitignore b/.gitignore index 08c27c0c..e67d4235 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +package-lock.json + +# build files +release/** +bun.lockb diff --git a/electron/README.md b/electron/README.md new file mode 100644 index 00000000..77698417 --- /dev/null +++ b/electron/README.md @@ -0,0 +1,149 @@ +# Electron Desktop App + +This document explains how to build and run the flash.comma.ai app as a cross-platform desktop application using Electron and Bun. + +## Development + +### Prerequisites +- Node.js >= 20.11.0 +- Bun (latest version) + +### Installation +```bash +bun install +``` + +### Development Mode +To run the app in development mode with hot reload: + +```bash +bun run electron-dev +``` + +This will: +1. Start the Vite dev server with Bun +2. Wait for it to be ready +3. Launch Electron pointing to the dev server + +### Building for Production + +#### Build for current platform +```bash +bun run electron-build +``` + +#### Build for specific platforms +```bash +bun run electron-build-win # Windows +bun run electron-build-mac # macOS +bun run electron-build-linux # Linux +``` + +#### Build for all platforms +```bash +bun run electron-build-all +``` + +## Platform-Specific Notes + +### Windows +- The app will be packaged as an NSIS installer +- USB drivers may need to be installed separately using Zadig +- Admin privileges may be required for USB device access + +### macOS +- The app will be packaged as a DMG +- Code signing may be required for distribution +- USB device access should work out of the box + +### Linux +- The app will be packaged as both AppImage and .deb +- USB device permissions may need to be configured via udev rules +- Run with `sudo` if USB access fails + +## USB Device Access + +The Electron app automatically handles USB device permissions for QDL devices (VID: 0x05C6, PID: 0x9008). However, some platforms may require additional setup: + +### Linux USB Permissions +Create a udev rule to allow access to QDL devices: + +```bash +# Create udev rule +sudo tee /etc/udev/rules.d/51-comma-qdl.rules > /dev/null < { + return app.getVersion() +}) + +ipcMain.handle('show-open-dialog', async (event, options) => { + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, options) + if (canceled) { + return { canceled: true } + } + return { canceled: false, filePaths } +}) + +ipcMain.handle('show-save-dialog', async (event, options) => { + const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, options) + if (canceled) { + return { canceled: true } + } + return { canceled: false, filePath } +}) + +const createWindow = () => { + // Create the browser window. + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + icon: join(__dirname, '../src/app/icon.png'), + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + enableRemoteModule: false, + preload: join(__dirname, 'preload.cjs'), + webSecurity: true, + }, + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + show: false, + }) + + // Load the app + if (isDev) { + mainWindow.loadURL('http://localhost:5173') + // Open DevTools in development + mainWindow.webContents.openDevTools() + } else { + mainWindow.loadFile(join(__dirname, '../dist/index.html')) + } + + // Show window when ready to prevent visual flash + mainWindow.once('ready-to-show', () => { + mainWindow.show() + }) + + // Handle external links + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url) + return { action: 'deny' } + }) + + // Prevent navigation to external websites + mainWindow.webContents.on('will-navigate', (event, navigationUrl) => { + const parsedUrl = new URL(navigationUrl) + if (parsedUrl.origin !== 'http://localhost:5173' && !navigationUrl.startsWith('file://')) { + event.preventDefault() + } + }) +} + +// This method will be called when Electron has finished initialization +app.whenReady().then(() => { + // Set app user model ID for Windows + if (process.platform === 'win32') { + app.setAppUserModelId('ai.comma.flash') + } + + createWindow() + + // Handle USB device permissions + mainWindow.webContents.session.on('select-usb-device', (event, details, callback) => { + // Add USB device selection logic here + event.preventDefault() + + // Look for QDL devices (vendor ID 0x05C6, product ID 0x9008) + const qdlDevice = details.deviceList.find(device => + device.vendorId === 0x05C6 && device.productId === 0x9008 + ) + + if (qdlDevice) { + callback(qdlDevice.deviceId) + } else { + callback() + } + }) + + // Handle USB device access requests + mainWindow.webContents.session.on('usb-device-added', (event, device) => { + console.log('USB device added:', device) + mainWindow.webContents.send('usb-device-added', device) + }) + + mainWindow.webContents.session.on('usb-device-removed', (event, device) => { + console.log('USB device removed:', device) + mainWindow.webContents.send('usb-device-removed', device) + }) + + // macOS specific behavior + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } + }) + +}) + +// Quit when all windows are closed, except on macOS +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } +}) + +// Security: Prevent new window creation +app.on('web-contents-created', (event, contents) => { + contents.on('new-window', (event, navigationUrl) => { + event.preventDefault() + shell.openExternal(navigationUrl) + }) +}) + +// Handle certificate errors +app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { + if (isDev) { + // In development, ignore certificate errors + event.preventDefault() + callback(true) + } else { + // In production, use default behavior + callback(false) + } +}) + +// Handle app protocol for auto-updater +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('flash-comma', process.execPath, [process.argv[1]]) + } +} else { + app.setAsDefaultProtocolClient('flash-comma') +} \ No newline at end of file diff --git a/electron/preload.cjs b/electron/preload.cjs new file mode 100644 index 00000000..cb5c558d --- /dev/null +++ b/electron/preload.cjs @@ -0,0 +1,29 @@ +const { contextBridge, ipcRenderer } = require('electron') + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +contextBridge.exposeInMainWorld('electronAPI', { + platform: process.platform, + versions: process.versions, + + // USB device management + onUsbDeviceAdded: (callback) => { + ipcRenderer.on('usb-device-added', callback) + }, + + onUsbDeviceRemoved: (callback) => { + ipcRenderer.on('usb-device-removed', callback) + }, + + // App management + getAppVersion: () => ipcRenderer.invoke('get-app-version'), + + // File system operations (if needed) + showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options), + showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options), +}) + +// Security: Remove global Node.js APIs +delete window.require +delete window.exports +delete window.module \ No newline at end of file diff --git a/package.json b/package.json index ccb9f288..cbf7b365 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,30 @@ { "name": "@commaai/flash", "version": "0.1.0", + "description": "flash.comma.ai - The official flash tool for comma devices", "private": true, "type": "module", + "main": "electron/main.js", + "homepage": "./", + "author": { + "name": "comma.ai", + "email": "support@comma.ai", + "url": "https://comma.ai" + }, "scripts": { "dev": "vite", "build": "vite build", "start": "vite preview", - "test": "vitest" + "test": "vitest", + "electron": "npm run electron-npm", + "electron-npm": "cross-env NODE_ENV=development electron .", + "electron-dev": "concurrently \"bun run dev\" \"wait-on http://localhost:5173 && npm run electron-npm\"", + "electron-build": "bun run build && npm run electron-builder", + "electron-build-win": "bun run build && electron-builder --win", + "electron-build-mac": "bun run build && electron-builder --mac", + "electron-build-linux": "bun run build && electron-builder --linux", + "electron-build-all": "bun run build && electron-builder --mac --win --linux", + "dist": "bun run build && electron-builder --publish=never" }, "engines": { "node": ">=20.11.0" @@ -16,11 +33,13 @@ "@commaai/qdl": "git+https://github.com/commaai/qdl.js.git#9ef437bed19a70fe54f44421ae5fc9a057b4b563", "@fontsource-variable/inter": "^5.2.5", "@fontsource-variable/jetbrains-mono": "^5.2.5", + "electron-updater": "^6.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", "xz-decompress": "^0.2.2" }, "devDependencies": { + "electron": "^37.2.4", "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -28,16 +47,83 @@ "@types/react-dom": "^18.3.6", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "10.4.21", + "concurrently": "^9.2.0", + "cross-env": "^7.0.3", + "electron-builder": "^26.0.12", + "electron-icon-builder": "^2.0.1", + "electron-squirrel-startup": "^1.0.1", "jsdom": "^26.0.0", "postcss": "^8.5.3", "tailwindcss": "^3.4.17", "vite": "^6.2.6", "vite-svg-loader": "^5.1.0", - "vitest": "^3.1.1" + "vitest": "^3.1.1", + "wait-on": "^8.0.3" }, "trustedDependencies": [ "@commaai/qdl", "esbuild", "usb" - ] + ], + "build": { + "appId": "ai.comma.flash", + "productName": "flash.comma.ai", + "directories": { + "output": "release" + }, + "files": [ + "dist/**/*", + "electron/**/*", + "node_modules/**/*", + "package.json" + ], + "extraResources": [ + { + "from": "src/assets", + "to": "assets" + } + ], + "mac": { + "category": "public.app-category.utilities", + "target": [ + { + "target": "dmg", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "src/app/icon_xl.png" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "icon": "src/app/icon.ico" + }, + "linux": { + "target": [ + { + "target": "tar.gz", + "arch": [ + "x64" + ] + } + ], + "category": "Utility", + "icon": "src/app/icon_large.png" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + } + } } diff --git a/src/app/icon_large.png b/src/app/icon_large.png new file mode 100644 index 00000000..89b1c424 Binary files /dev/null and b/src/app/icon_large.png differ diff --git a/src/app/icon_xl.png b/src/app/icon_xl.png new file mode 100644 index 00000000..2e981ef8 Binary files /dev/null and b/src/app/icon_xl.png differ diff --git a/src/utils/electron.js b/src/utils/electron.js new file mode 100644 index 00000000..602280ce --- /dev/null +++ b/src/utils/electron.js @@ -0,0 +1,52 @@ +/** + * Check if the app is running in Electron + * @returns {boolean} + */ +export const isElectron = () => { + return typeof window !== 'undefined' && + typeof window.electronAPI !== 'undefined' +} + +/** + * Get the current platform + * @returns {string} + */ +export const getPlatform = () => { + if (isElectron()) { + return window.electronAPI.platform + } + return navigator.platform +} + +/** + * Get app version (Electron only) + * @returns {Promise} + */ +export const getAppVersion = async () => { + if (isElectron()) { + try { + return await window.electronAPI.getAppVersion() + } catch (error) { + console.error('Failed to get app version:', error) + return null + } + } + return null +} + +/** + * Show native file dialogs (Electron only) + */ +export const showOpenDialog = async (options) => { + if (isElectron()) { + return await window.electronAPI.showOpenDialog(options) + } + throw new Error('File dialogs are only available in Electron') +} + +export const showSaveDialog = async (options) => { + if (isElectron()) { + return await window.electronAPI.showSaveDialog(options) + } + throw new Error('File dialogs are only available in Electron') +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 17ac32a0..dd7afae6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,23 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + base: './', + build: { + outDir: 'dist', + assetsDir: 'assets', + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom'], + qdl: ['@commaai/qdl'], + }, + }, + }, + }, + server: { + port: 5173, + host: 'localhost', + }, test: { globals: true, environment: 'jsdom',