Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
release/
|
||||
site/
|
||||
@@ -0,0 +1,43 @@
|
||||
appId: org.openbatteryinformation.desktop
|
||||
productName: Open Battery Information
|
||||
copyright: "Copyright © 2026 Open Battery Information — GPL-3.0"
|
||||
|
||||
directories:
|
||||
output: release
|
||||
|
||||
files:
|
||||
- main.js
|
||||
- preload.js
|
||||
- preload-picker.js
|
||||
- site/**/*
|
||||
|
||||
asar: true
|
||||
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: "Open-Battery-Information-${version}-setup-${arch}.${ext}"
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
shortcutName: Open Battery Information
|
||||
|
||||
mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
category: public.app-category.utilities
|
||||
artifactName: "Open-Battery-Information-${version}-mac-${arch}.${ext}"
|
||||
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
category: Utility
|
||||
maintainer: Open Battery Information
|
||||
artifactName: "Open-Battery-Information-${version}-linux-${arch}.${ext}"
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
/* Open Battery Information — Electron main process.
|
||||
Loads the OBI-1 page and provides native serial access through the
|
||||
`serialport` package (no browser Web Serial needed). The renderer talks
|
||||
to the serial port over IPC via the preload bridge and a small
|
||||
navigator.serial shim. Everything stays local; no telemetry. */
|
||||
const { app, BrowserWindow, Menu, ipcMain, shell } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[main] uncaughtException:', err);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[main] unhandledRejection:', reason);
|
||||
});
|
||||
|
||||
let SerialPort = null;
|
||||
try {
|
||||
({ SerialPort } = require('serialport'));
|
||||
console.log('[main] serialport loaded');
|
||||
} catch (err) {
|
||||
console.error('[main] serialport failed to load:', err.message);
|
||||
}
|
||||
|
||||
const SITE_DIR = path.join(__dirname, 'site');
|
||||
const openPorts = new Map(); // port path -> { port, sender }
|
||||
let pickerPromise = null;
|
||||
|
||||
/* ---------- serialport helpers ---------- */
|
||||
|
||||
function toPortInfo(p) {
|
||||
return {
|
||||
path: p.path,
|
||||
vendorId: p.vendorId ? parseInt(p.vendorId, 16) : 0,
|
||||
productId: p.productId ? parseInt(p.productId, 16) : 0,
|
||||
manufacturer: p.manufacturer || '',
|
||||
serialNumber: p.serialNumber || '',
|
||||
};
|
||||
}
|
||||
|
||||
function openPort(info, baudRate) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = new SerialPort(
|
||||
{ path: info.path, baudRate: baudRate || 9600 },
|
||||
(err) => (err ? reject(err) : resolve(port))
|
||||
);
|
||||
port.on('error', () => {});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- IPC: serial ---------- */
|
||||
|
||||
ipcMain.handle('serial:list', async () => {
|
||||
const list = await SerialPort.list();
|
||||
return list.map(toPortInfo);
|
||||
});
|
||||
|
||||
ipcMain.handle('serial:requestPort', async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const info = await showPortPicker(win);
|
||||
if (!info) throw new Error('NotFoundError: No port selected');
|
||||
return info;
|
||||
});
|
||||
|
||||
ipcMain.handle('serial:open', async (event, info, baudRate) => {
|
||||
const existing = openPorts.get(info.path);
|
||||
if (existing && existing.port.isOpen) return { ok: true };
|
||||
const port = await openPort(info, baudRate);
|
||||
port.on('data', (buf) => {
|
||||
const entry = openPorts.get(info.path);
|
||||
if (entry && entry.sender && !entry.sender.isDestroyed()) {
|
||||
entry.sender.send('serial:data', info.path, buf);
|
||||
}
|
||||
});
|
||||
port.on('close', () => openPorts.delete(info.path));
|
||||
openPorts.set(info.path, { port, sender: event.sender });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('serial:setSignals', async (_event, info, dtr) => {
|
||||
const entry = openPorts.get(info.path);
|
||||
if (!entry) throw new Error('Serial port is not open');
|
||||
await new Promise((resolve, reject) =>
|
||||
entry.port.set({ dtr: !!dtr }, (err) => (err ? reject(err) : resolve()))
|
||||
);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('serial:write', async (_event, info, data) => {
|
||||
const entry = openPorts.get(info.path);
|
||||
if (!entry) throw new Error('Serial port is not open');
|
||||
await new Promise((resolve, reject) =>
|
||||
entry.port.write(Buffer.from(data), (err) => (err ? reject(err) : resolve()))
|
||||
);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('serial:close', async (_event, info) => {
|
||||
const entry = openPorts.get(info.path);
|
||||
if (!entry) return { ok: true };
|
||||
openPorts.delete(info.path);
|
||||
await new Promise((resolve) => entry.port.close(() => resolve()));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/* ---------- serial port picker ---------- */
|
||||
|
||||
const escapeHtml = (s) =>
|
||||
String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
|
||||
);
|
||||
|
||||
const PICKER_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: #1e1f22; color: #e6e6e6; padding: 20px; }
|
||||
h1 { font-size: 16px; margin: 0 0 4px; }
|
||||
p.sub { margin: 0 0 16px; color: #9b9b9b; font-size: 13px; }
|
||||
.port { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; margin-bottom: 8px; cursor: pointer; }
|
||||
.port:hover { border-color: #5f9bff; }
|
||||
.port input { margin: 0; }
|
||||
.path { font-weight: 600; font-family: ui-monospace, monospace; }
|
||||
.meta { margin-left: auto; color: #9b9b9b; font-size: 12px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
|
||||
button { padding: 8px 16px; border-radius: 8px; border: 1px solid #333; background: #2a2b2e; color: #e6e6e6; font: inherit; cursor: pointer; }
|
||||
button.primary { background: #3d7eff; border-color: #3d7eff; }
|
||||
button.primary:disabled { opacity: 0.5; cursor: default; }
|
||||
.empty { color: #9b9b9b; padding: 20px 0; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Select a serial port</h1>
|
||||
<p class="sub">Choose the Arduino running the OBI firmware.</p>
|
||||
<form id="form">__ROWS__</form>
|
||||
<div class="actions">
|
||||
<button id="cancel" type="button">Cancel</button>
|
||||
<button id="connect" class="primary" type="button" disabled>Connect</button>
|
||||
</div>
|
||||
<script>
|
||||
const radios = document.querySelectorAll('input[name="port"]');
|
||||
const connect = document.getElementById('connect');
|
||||
const cancel = document.getElementById('cancel');
|
||||
const form = document.getElementById('form');
|
||||
form.addEventListener('change', () => { connect.disabled = !document.querySelector('input[name="port"]:checked'); });
|
||||
connect.addEventListener('click', () => {
|
||||
const sel = document.querySelector('input[name="port"]:checked');
|
||||
if (sel) window.picker.select(sel.value);
|
||||
});
|
||||
cancel.addEventListener('click', () => window.picker.cancel());
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') window.picker.cancel(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function showPortPicker(parent) {
|
||||
if (pickerPromise) return pickerPromise;
|
||||
pickerPromise = (async () => {
|
||||
let list = [];
|
||||
try {
|
||||
list = await SerialPort.list();
|
||||
} catch (e) {
|
||||
list = [];
|
||||
}
|
||||
if (!list.length) return null;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const picker = new BrowserWindow({
|
||||
parent,
|
||||
modal: true,
|
||||
width: 540,
|
||||
height: 440,
|
||||
resizable: false,
|
||||
autoHideMenuBar: true,
|
||||
title: 'Select a serial port',
|
||||
backgroundColor: '#1e1f22',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload-picker.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
const rows = list
|
||||
.map(
|
||||
(p) =>
|
||||
'<label class="port"><input type="radio" name="port" value="' +
|
||||
escapeHtml(p.path) +
|
||||
'"><span class="path">' +
|
||||
escapeHtml(p.path) +
|
||||
'</span><span class="meta">' +
|
||||
escapeHtml(
|
||||
[p.manufacturer, p.serialNumber ? 'S/N ' + p.serialNumber : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
) +
|
||||
'</span></label>'
|
||||
)
|
||||
.join('');
|
||||
|
||||
ipcMain.once('picker:select', (_e, pickedPath) => {
|
||||
const info = list.find((p) => p.path === pickedPath);
|
||||
if (picker && !picker.isDestroyed()) picker.close();
|
||||
resolve(info ? toPortInfo(info) : null);
|
||||
});
|
||||
ipcMain.once('picker:cancel', () => {
|
||||
if (picker && !picker.isDestroyed()) picker.close();
|
||||
resolve(null);
|
||||
});
|
||||
picker.on('closed', () => resolve(null));
|
||||
|
||||
picker.loadURL(
|
||||
'data:text/html;charset=utf-8,' +
|
||||
encodeURIComponent(PICKER_HTML.replace('__ROWS__', rows))
|
||||
);
|
||||
picker.once('ready-to-show', () => picker.show());
|
||||
});
|
||||
})().finally(() => {
|
||||
pickerPromise = null;
|
||||
});
|
||||
return pickerPromise;
|
||||
}
|
||||
|
||||
/* ---------- window ---------- */
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 820,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#1e1f22',
|
||||
title: 'OBI-1 · Open Battery Information',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http')) shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.error('[main] did-fail-load', code, desc, url);
|
||||
});
|
||||
|
||||
const target = path.join(SITE_DIR, 'obi.html');
|
||||
console.log('[main] loading', target);
|
||||
win.loadFile(target).then(
|
||||
() => console.log('[main] page loaded'),
|
||||
(err) => console.error('[main] loadFile failed:', err)
|
||||
);
|
||||
win.once('ready-to-show', () => {
|
||||
console.log('[main] window ready-to-show');
|
||||
win.show();
|
||||
});
|
||||
return win;
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
console.log('[main] app ready');
|
||||
if (process.platform !== 'darwin') {
|
||||
Menu.setApplicationMenu(null);
|
||||
}
|
||||
try {
|
||||
createWindow();
|
||||
console.log('[main] window created');
|
||||
} catch (err) {
|
||||
console.error('[main] createWindow failed:', err);
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
Generated
+4085
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "open-battery-information-desktop",
|
||||
"productName": "Open Battery Information",
|
||||
"version": "1.0.7",
|
||||
"description": "Desktop build of the Open Battery Information OBI-1 tool — native serial battery diagnosis for Windows, macOS and Linux.",
|
||||
"main": "main.js",
|
||||
"license": "GPL-3.0",
|
||||
"author": "Open Battery Information",
|
||||
"homepage": "https://openbatteryinformation.github.io/",
|
||||
"dependencies": {
|
||||
"serialport": "^12.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare:site": "node scripts/prepare-site.mjs",
|
||||
"start": "node scripts/prepare-site.mjs && electron .",
|
||||
"dist": "node scripts/prepare-site.mjs && electron-builder",
|
||||
"dist:win": "node scripts/prepare-site.mjs && electron-builder --win",
|
||||
"dist:mac": "node scripts/prepare-site.mjs && electron-builder --mac",
|
||||
"dist:linux": "node scripts/prepare-site.mjs && electron-builder --linux"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^24.13.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Preload for the serial port picker window. */
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('picker', {
|
||||
select: (portPath) => ipcRenderer.send('picker:select', portPath),
|
||||
cancel: () => ipcRenderer.send('picker:cancel'),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Electron preload — bridges native serial access to the renderer.
|
||||
The page's serial-shim.js surfaces this through the navigator.serial API
|
||||
the OBI code already uses. */
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
function toInfo(info) {
|
||||
return {
|
||||
path: info.path,
|
||||
vendorId: info.vendorId || 0,
|
||||
productId: info.productId || 0,
|
||||
manufacturer: info.manufacturer || '',
|
||||
serialNumber: info.serialNumber || '',
|
||||
};
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('serialAPI', {
|
||||
requestPort: () => ipcRenderer.invoke('serial:requestPort').then(toInfo),
|
||||
open: (info, baudRate) => ipcRenderer.invoke('serial:open', toInfo(info), baudRate),
|
||||
close: (info) => ipcRenderer.invoke('serial:close', toInfo(info)),
|
||||
setSignals: (info, dtr) => ipcRenderer.invoke('serial:setSignals', toInfo(info), dtr),
|
||||
write: (info, bytes) => ipcRenderer.invoke('serial:write', toInfo(info), bytes),
|
||||
onData: (cb) => {
|
||||
const listener = (_e, portPath, data) => cb(portPath, data);
|
||||
ipcRenderer.on('serial:data', listener);
|
||||
return () => ipcRenderer.removeListener('serial:data', listener);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Builds desktop/site: a self-contained copy of just the OBI-1 page.
|
||||
Only the files obi.html references are copied. The nav is trimmed to the
|
||||
theme toggle (no links to pages that aren't bundled) and the Web Serial
|
||||
shim is injected so native serialport works through the OBI code. */
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const SITE = path.join(__dirname, '..', 'site');
|
||||
|
||||
const CSS_FILES = ['style.css', 'tool.css'];
|
||||
const JS_FILES = ['theme.js', 'reveal.js', 'common.js', 'obi.js'];
|
||||
|
||||
await rm(SITE, { recursive: true, force: true });
|
||||
await mkdir(path.join(SITE, 'css'), { recursive: true });
|
||||
await mkdir(path.join(SITE, 'js', 'modules'), { recursive: true });
|
||||
await mkdir(path.join(SITE, 'js', 'interfaces'), { recursive: true });
|
||||
|
||||
/* ---- obi.html, transformed for the desktop bundle ---- */
|
||||
const source = await readFile(path.join(ROOT, 'obi.html'), 'utf8');
|
||||
let html = source;
|
||||
html = html.replace(
|
||||
'<script src="js/theme.js"></script>',
|
||||
'<script src="js/theme.js"></script>\n <script src="js/serial-shim.js"></script>'
|
||||
);
|
||||
html = html.replace(
|
||||
'<a class="brand" href="index.html">',
|
||||
'<a class="brand" href="obi.html">'
|
||||
);
|
||||
html = html.replace(
|
||||
/(<div class="nav-links">)([\s\S]*?)(<\/div>)/,
|
||||
(_m, open, inner, close) => open + inner.replace(/<a [\s\S]*?<\/a>/g, '') + close
|
||||
);
|
||||
await writeFile(path.join(SITE, 'obi.html'), html);
|
||||
|
||||
/* ---- shared assets ---- */
|
||||
for (const file of CSS_FILES) {
|
||||
await cp(path.join(ROOT, 'css', file), path.join(SITE, 'css', file));
|
||||
}
|
||||
for (const file of JS_FILES) {
|
||||
await cp(path.join(ROOT, 'js', file), path.join(SITE, 'js', file));
|
||||
}
|
||||
await cp(path.join(ROOT, 'js', 'modules', 'makita_lxt.js'), path.join(SITE, 'js', 'modules', 'makita_lxt.js'));
|
||||
await cp(path.join(ROOT, 'js', 'interfaces', 'arduino_obi.js'), path.join(SITE, 'js', 'interfaces', 'arduino_obi.js'));
|
||||
await cp(path.resolve(__dirname, '..', 'serial-shim.js'), path.join(SITE, 'js', 'serial-shim.js'));
|
||||
|
||||
console.log(`OBI-only site prepared in ${SITE}`);
|
||||
@@ -0,0 +1,99 @@
|
||||
/* Desktop-only Web Serial shim.
|
||||
In the Electron app, the main process owns the serial port (via the
|
||||
`serialport` package) and streams bytes over IPC. This file re-exposes
|
||||
that transport through the same navigator.serial API the OBI web code
|
||||
uses, so js/common.js and js/interfaces/arduino_obi.js run unchanged. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (!window.serialAPI) return;
|
||||
|
||||
const api = window.serialAPI;
|
||||
|
||||
class DesktopSerialPort {
|
||||
constructor(info) {
|
||||
this._info = info;
|
||||
this._open = false;
|
||||
this._stream = null;
|
||||
this._controller = null;
|
||||
this._queue = [];
|
||||
this._unsubscribe = null;
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
return {
|
||||
usbVendorId: this._info.vendorId || 0,
|
||||
usbProductId: this._info.productId || 0,
|
||||
};
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return this._open;
|
||||
}
|
||||
|
||||
async open(options) {
|
||||
if (this._open) return;
|
||||
await api.open(this._info, (options && options.baudRate) || 9600);
|
||||
this._open = true;
|
||||
this._unsubscribe = api.onData((portPath, buf) => {
|
||||
if (portPath !== this._info.path) return;
|
||||
const data = new Uint8Array(buf);
|
||||
if (this._controller) this._controller.enqueue(data);
|
||||
else this._queue.push(data);
|
||||
});
|
||||
}
|
||||
|
||||
get readable() {
|
||||
if (!this._open) throw new Error('InvalidStateError: port not open');
|
||||
if (!this._stream) {
|
||||
this._stream = new ReadableStream({
|
||||
start: (controller) => {
|
||||
this._controller = controller;
|
||||
for (const chunk of this._queue) controller.enqueue(chunk);
|
||||
this._queue = [];
|
||||
},
|
||||
cancel: () => {
|
||||
this._controller = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
return this._stream;
|
||||
}
|
||||
|
||||
get writable() {
|
||||
if (!this._open) throw new Error('InvalidStateError: port not open');
|
||||
return new WritableStream({
|
||||
write: (chunk) =>
|
||||
api.write(
|
||||
this._info,
|
||||
chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async setSignals(signals) {
|
||||
await api.setSignals(this._info, !!(signals && signals.dataTerminalReady));
|
||||
}
|
||||
|
||||
async close() {
|
||||
await api.close(this._info);
|
||||
if (this._unsubscribe) this._unsubscribe();
|
||||
this._unsubscribe = null;
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
async forget() {}
|
||||
}
|
||||
|
||||
Object.defineProperty(navigator, 'serial', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: {
|
||||
getPorts: async () => [],
|
||||
requestPort: async () => {
|
||||
const info = await api.requestPort();
|
||||
return new DesktopSerialPort(info);
|
||||
},
|
||||
},
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user