const { app, BrowserWindow, clipboard, ipcMain, Menu, shell } = require('electron') const { execFile } = require('child_process') const path = require('path') const fs = require('fs') const { SensorController } = require('./src/business/sensor-controller') const { UsbSerialBridge } = require('./src/business/usb-serial-bridge') const pkg = require('./package.json') if (require('electron-squirrel-startup')) app.quit() const cacheDir = app.getPath('userData') const cacheFile = path.join(cacheDir, 'app-cache.json') const fileRoot = path.join(cacheDir, 'files') let mainWindow = null let usbBridge = null let sensorController = null let bluetoothSelectWindow = null const topBarState = { route: '', network: null, pwm: false, targetUrl: '' } const pwmPorts = new Set() function ensureDir(dir) { try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) } catch {} } function readCache() { try { if (fs.existsSync(cacheFile)) return JSON.parse(fs.readFileSync(cacheFile, 'utf-8')) } catch {} return {} } function writeCache(data) { try { ensureDir(cacheDir) fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2), 'utf-8') } catch {} } function resolveAppFile(inputPath) { const raw = String(inputPath || '').trim() if (!raw) return fileRoot if (path.isAbsolute(raw) && /^[a-zA-Z]:[\\/]/.test(raw)) return raw const relative = raw.replace(/^[/\\]+/, '') return path.join(fileRoot, relative) } function fileList(dir) { try { const target = resolveAppFile(dir) if (!fs.existsSync(target)) return '' const stat = fs.statSync(target) if (stat.isFile()) return target return fs.readdirSync(target).map(name => path.join(target, name)).join('\n') } catch { return '' } } function emit(channel, ...args) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, ...args) } } function parseBluetoothDevices(raw) { const lines = String(raw || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean) const devices = [] for (const line of lines) { const parts = line.split('\t') if (parts.length < 2) continue const [name, address = '', status = ''] = parts if (!name || name === 'Name') continue devices.push({ name, address, status }) } return devices } function listBluetoothDevices() { const script = [ '$ErrorActionPreference = "SilentlyContinue"', 'Get-PnpDevice -Class Bluetooth |', 'Where-Object { $_.FriendlyName -and $_.InstanceId } |', 'ForEach-Object {', ' $id = $_.InstanceId', ' $addr = ""', ' if ($id -match "DEV_([0-9A-Fa-f]{12})") {', ' $raw = $Matches[1].ToUpper()', ' $addr = (($raw -split "(.{2})" | Where-Object { $_ }) -join ":")', ' }', ' "$($_.FriendlyName)`t$addr`t$($_.Status)"', '}' ].join('\n') return new Promise(resolve => { execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true }, (err, stdout) => { if (err) { resolve([]) return } resolve(parseBluetoothDevices(stdout)) }) }) } function bluetoothSelectHtml(namePrefix) { const prefix = String(namePrefix || '') return ` 蓝牙连接
蓝牙连接
选择已配对蓝牙设备${prefix ? `,过滤前缀:${prefix}` : ''}
正在扫描已配对设备...
` } async function openBluetoothSelectWindow(sender, namePrefix) { if (bluetoothSelectWindow && !bluetoothSelectWindow.isDestroyed()) { bluetoothSelectWindow.focus() return } bluetoothSelectWindow = new BrowserWindow({ width: 560, height: 620, parent: mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined, modal: false, title: '蓝牙连接', webPreferences: { preload: path.join(__dirname, 'src', 'business', 'bluetooth-select-preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false } }) ipcMain.once('bluetooth-select-device', (_event, device) => { const address = String(device?.address || '') const success = address.length > 0 ? 'true' : 'false' if (sender && !sender.isDestroyed()) { sender.send('bluetooth-device-selected', success, address) } if (bluetoothSelectWindow && !bluetoothSelectWindow.isDestroyed()) { bluetoothSelectWindow.close() } }) bluetoothSelectWindow.on('closed', () => { bluetoothSelectWindow = null }) await bluetoothSelectWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(bluetoothSelectHtml(namePrefix))}`) } function hasSensor(sensorName) { return !!sensorController?.devices?.some(device => device.sensor === sensorName) } function routeSummary(route) { const raw = String(route || '#/') try { const [pathPart, queryPart = ''] = raw.split('?') const params = new URLSearchParams(queryPart) const picked = [] for (const key of ['id', 'taskId', 'pageType']) { const value = params.get(key) if (!value) continue const shortValue = value.length > 12 ? `${value.slice(0, 12)}...` : value picked.push(`${key}=${shortValue}`) } return picked.length ? `${pathPart}?${picked.join('&')}` : pathPart } catch { return raw.length > 60 ? `${raw.slice(0, 60)}...` : raw } } function connectionLabel(name, connected) { return `${name}${connected ? '✓' : '×'}` } function connectionItems() { return [ connectionLabel('网络', topBarState.network === true), connectionLabel('倾角', hasSensor('HWT6053')), connectionLabel('测距', hasSensor('SW_LDS20DA')), connectionLabel('全站仪', hasSensor('CHC_CGI_430')), connectionLabel('PWM', topBarState.pwm) ] } function updateAppMenu() { const fullRoute = topBarState.route || '#/' const template = [ { label: '连接', submenu: connectionItems().map(label => ({ label, enabled: false })) }, { label: `当前页面 ${routeSummary(fullRoute)}`, submenu: [ { label: fullRoute, enabled: false }, { label: '复制当前页面链接', click: () => clipboard.writeText(fullRoute) } ] }, { label: `壳地址 ${topBarState.targetUrl ? '已设置' : '本地包'}`, submenu: [ { label: topBarState.targetUrl || '本地包', enabled: false }, { label: '切换到本地包', click: () => loadShellTarget('') }, { label: '打开地址设置', click: () => emit('app-shell-open-url-dialog') } ] }, { label: '视图', submenu: [ { role: 'reload', label: '刷新' }, { role: 'toggleDevTools', label: '开发者工具' }, { type: 'separator' }, { role: 'resetZoom', label: '实际大小' }, { role: 'zoomIn', label: '放大' }, { role: 'zoomOut', label: '缩小' } ] }, { label: '窗口', submenu: [ { role: 'minimize', label: '最小化' }, { role: 'close', label: '关闭' } ] } ] Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } function updateWindowTitle() { if (!mainWindow || mainWindow.isDestroyed()) return const connections = connectionItems().join(' ') const route = routeSummary(topBarState.route || '#/') mainWindow.setTitle(`光伏智能建造调度管控系统 | ${connections} | ${route}`) updateAppMenu() } function setPwmPort(deviceName, connected) { const port = String(deviceName || '') if (!port) return if (connected) pwmPorts.add(port) else pwmPorts.delete(port) topBarState.pwm = pwmPorts.size > 0 } function normalizeShellUrl(input) { const raw = String(input || '').trim() if (!raw) return '' try { const parsed = new URL(raw) if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.toString() } catch {} return '' } function getStartupTarget() { const cache = readCache() return normalizeShellUrl(cache.shellTargetUrl || cache.appTargetUrl || '') } async function loadShellTarget(targetUrl) { if (!mainWindow || mainWindow.isDestroyed()) return const normalized = normalizeShellUrl(targetUrl) if (normalized) { topBarState.targetUrl = normalized writeCache({ ...readCache(), shellTargetUrl: normalized }) await mainWindow.loadURL(normalized) updateWindowTitle() return } topBarState.targetUrl = '' const localIndex = path.join(__dirname, 'dist', 'index.html') await mainWindow.loadFile(localIndex) updateWindowTitle() } function lastSensorValue(id, opId) { if (!sensorController) return null const device = sensorController.devices.find(d => d.id === id) || sensorController.devices.find(d => d.sensor === id) || sensorController.devices.find(d => d.sensor === String(id || '').split(':')[0]) if (!device) return null return sensorController.lastValues.get(`${device.sensor}:${opId}`) ?? null } function createSensorController() { sensorController = new SensorController({ onValue: data => emit('sensor-data', data), onDeviceAttached: device => { emit('sensor-attached', { id: device.id, name: device.name }) updateWindowTitle() }, onDeviceDetached: device => { emit('sensor-detached', { id: device.id, name: device.name }) updateWindowTitle() }, onLog: (level, msg) => emit('sensor-log', { level, msg }) }) sensorController.refresh() .then(() => updateWindowTitle()) .catch(err => console.error('[sensor] refresh failed:', err.message)) } function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, fullscreen: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false } }) createSensorController() usbBridge = new UsbSerialBridge(mainWindow, { onDeviceDiscovered: payload => { if (payload?.probeId === 'remote_control') setPwmPort(payload.deviceName, true) if (sensorController) sensorController.refresh().catch(() => {}) updateWindowTitle() }, onDeviceDetached: device => { if (device?.probeId === 'remote_control') { setPwmPort(device.deviceName, false) } updateWindowTitle() } }) usbBridge.startDiscovery({ keepAlive: true }).catch(err => console.error('[usb] startup discovery failed:', err.message)) const startupTarget = getStartupTarget() if (startupTarget) { topBarState.targetUrl = startupTarget mainWindow.loadURL(startupTarget) } else { mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html')) } updateWindowTitle() } function registerAppIpc() { ipcMain.on('app-clear-cache-sync', (event) => { writeCache({}) if (mainWindow && !mainWindow.isDestroyed()) mainWindow.reload() event.returnValue = true }) ipcMain.on('app-version-code-sync', (event) => { const major = Number(String(pkg.version || '1').split('.')[0]) || 1 event.returnValue = major }) ipcMain.on('get-cache-sync', (event, key) => { const cache = readCache() event.returnValue = key ? (cache[key] ?? null) : JSON.stringify(cache) }) ipcMain.on('set-cache-sync', (event, key, value) => { const cache = readCache() cache[key] = value writeCache(cache) event.returnValue = true }) ipcMain.handle('get-cache', (_event, key) => { const cache = readCache() return key ? (cache[key] ?? null) : cache }) ipcMain.handle('set-cache', (_event, key, value) => { const cache = readCache() cache[key] = value writeCache(cache) return true }) ipcMain.on('app-offline-page', () => emit('app-page-mode', 'offline')) ipcMain.on('app-online-page', () => emit('app-page-mode', 'online')) ipcMain.on('app-network-status', (_event, online) => { topBarState.network = !!online updateWindowTitle() }) ipcMain.on('app-route-changed', (_event, route) => { topBarState.route = String(route || '') updateWindowTitle() }) ipcMain.handle('app-shell-get-target-url', () => topBarState.targetUrl || getStartupTarget()) ipcMain.handle('app-shell-set-target-url', async (_event, targetUrl) => { await loadShellTarget(targetUrl) return topBarState.targetUrl }) ipcMain.on('app-shell-open-url', async (_event, targetUrl) => { await loadShellTarget(targetUrl) }) ipcMain.on('app-file-list-sync', (event, dir) => { event.returnValue = fileList(dir) }) ipcMain.on('app-file-write-string', (event, filePath, content, token) => { const target = resolveAppFile(filePath) fs.promises.mkdir(path.dirname(target), { recursive: true }) .then(() => fs.promises.writeFile(target, String(content ?? ''), 'utf-8')) .then(() => event.sender.send('app-file-write-callback', token, target, true, '')) .catch(err => event.sender.send('app-file-write-callback', token, target, false, err.message)) }) ipcMain.on('app-file-read-string', (event, filePath, token) => { const target = resolveAppFile(filePath) fs.promises.readFile(target, 'utf-8') .then(content => event.sender.send('app-file-read-callback', token, target, content)) .catch(() => event.sender.send('app-file-read-callback', token, target, null)) }) ipcMain.on('app-file-delete', (event, filePath, token) => { const target = resolveAppFile(filePath) fs.promises.unlink(target) .then(() => event.sender.send('app-file-delete-callback', token, target, true)) .catch(() => event.sender.send('app-file-delete-callback', token, target, false)) }) ipcMain.on('app-export-file', (event, filename, content, token) => { const target = resolveAppFile(filename || `export-${Date.now()}.txt`) fs.promises.mkdir(path.dirname(target), { recursive: true }) .then(() => fs.promises.writeFile(target, String(content ?? ''), 'utf-8')) .then(() => event.sender.send('app-file-write-callback', token || filename, target, true, '')) .catch(err => event.sender.send('app-file-write-callback', token || filename, target, false, err.message)) }) } function registerSensorIpc() { ipcMain.on('app-sensor-open-sync', (event) => { if (sensorController) sensorController.open().catch(() => {}) event.returnValue = true }) ipcMain.on('app-sensor-close-sync', (event) => { event.returnValue = sensorController ? sensorController.close() : true }) ipcMain.on('app-sensor-is-open-sync', (event) => { event.returnValue = sensorController ? sensorController.isOpen : false }) ipcMain.on('sensor-init-all-sync', (event) => { if (sensorController) sensorController.initAll().catch(() => {}) event.returnValue = true }) ipcMain.on('sensor-flush-sync', (event) => { if (sensorController) sensorController.refresh().catch(() => {}) event.returnValue = true }) ipcMain.on('sensor-list-sync', (event) => { event.returnValue = sensorController ? sensorController.list() : [] }) ipcMain.on('sensor-init-sync', (event, id) => { event.returnValue = sensorController ? sensorController.init(id) : false }) ipcMain.on('sensor-open-sync', (event) => { if (sensorController) sensorController.open().catch(() => {}) event.returnValue = true }) ipcMain.on('sensor-close-sync', (event) => { event.returnValue = sensorController ? sensorController.close() : true }) ipcMain.on('sensor-operation-sync', (event, id, opId) => { const cached = lastSensorValue(id, opId) if (sensorController) { sensorController.operation(id, opId).catch(() => {}) } event.returnValue = cached }) ipcMain.on('sensor-operation-async', (event, id, opId, args) => { if (!sensorController) { event.sender.send('sensor-operation-callback', id, opId, null) return } sensorController.operation(id, opId, args) .then(result => event.sender.send('sensor-operation-callback', id, opId, result)) .catch(err => event.sender.send('sensor-operation-callback', id, opId, err.message)) }) } function registerLocationAndBluetoothIpc() { ipcMain.on('app-location-is-support-sync', (event) => { event.returnValue = false }) ipcMain.on('app-location-is-open-sync', (event) => { event.returnValue = false }) ipcMain.on('app-location-open', (event) => { event.sender.send('app-location-opened', 1) }) ipcMain.on('app-location-close', () => {}) ipcMain.on('bluetooth-device-select', (event, namePrefix) => { openBluetoothSelectWindow(event.sender, namePrefix).catch(err => { console.error('[bluetooth] select window failed:', err.message) event.sender.send('bluetooth-device-selected', 'false', '') }) }) ipcMain.handle('bluetooth-select-list', () => listBluetoothDevices()) ipcMain.on('bluetooth-open-settings', () => { shell.openExternal('ms-settings:bluetooth') }) ipcMain.on('bluetooth-device-open', (event, mac) => { event.sender.send('bluetooth-device-opened', mac ? 'true' : 'false', mac || '') if (mac) event.sender.send('bluetooth-device-state-changed', mac, 'CONNECTED') }) ipcMain.on('bluetooth-device-write', () => {}) ipcMain.on('bluetooth-device-close', () => {}) } function registerIpc() { registerAppIpc() registerSensorIpc() registerLocationAndBluetoothIpc() } app.whenReady().then(() => { registerIpc() ensureDir(fileRoot) createWindow() app.on('before-quit', () => { if (sensorController) sensorController.close() if (usbBridge) usbBridge.disconnectAll() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })