Convert to use typescript, implement eslint

This commit is contained in:
2026-06-09 02:46:55 -06:00
parent d39e202de2
commit d40be3a919
13 changed files with 1551 additions and 187 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/
build/
dist/
out/
release/

View File

@@ -18,7 +18,7 @@ cd corsair-lcd-control
npm install
```
`postinstall` automatically rebuilds native modules for Electron using `@electron/rebuild`.
`postinstall` rebuilds native USB modules for Electron.
## Development
@@ -26,13 +26,15 @@ npm install
npm start
```
Compiles TypeScript to `build/`, then launches Electron with the compiled output.
## Build
```bash
npm run build
```
Produces platform-specific packages:
Compiles TypeScript, then produces platform-specific packages via electron-builder:
| Platform | Format |
|----------|--------|
@@ -40,6 +42,14 @@ Produces platform-specific packages:
| macOS | `.dmg` |
| Windows | `.exe` (NSIS) |
## Lint
```bash
npm run lint
```
Runs ESLint with `typescript-eslint` (`recommendedTypeChecked`) across all `src/` TypeScript files.
## Rebuild Native Modules
If native modules fail to load after a Node.js or Electron upgrade:
@@ -48,9 +58,26 @@ If native modules fail to load after a Node.js or Electron upgrade:
npm run rebuild
```
## Project Structure
```
├── src/
│ ├── main.ts # Electron main process
│ ├── preload.ts # Context bridge for IPC
│ ├── renderer.ts # Renderer UI logic
│ ├── index.html # Main window HTML
│ └── styles.css # Dark theme styles
├── assets/
│ └── icon.png # System tray icon
├── build/ # Compiled JS output (gitignored)
├── tsconfig.json # TypeScript config
├── eslint.config.js # ESLint config
└── package.json
```
## Capabilities
- **USB** — `usb` module enumerates and connects to devices
- **USB** — enumerates and connects to devices via `usb`
- **System Tray** — background process with context menu (Show/Hide/Quit)
- **Preferences** — persisted via `electron-store` (JSON)
- **File Pickers** — native OS dialogs via Electron's `dialog` API

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
module.exports = tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
},
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
'no-useless-return': 'error',
'no-var': 'error',
'prefer-const': 'error',
'no-console': 'off',
},
},
{
files: ['src/renderer.ts'],
rules: {
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
},
},
{
ignores: ['build/', 'dist/', 'node_modules/', 'assets/'],
}
);

1221
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,20 +4,21 @@
"author": "xerotacovix <xerotacovix@proton.me>",
"homepage": "https://github.com/xerotacovix/corsair-lcd-control",
"description": "Electron app to control Corsair LCD displays",
"main": "main.js",
"main": "build/main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder",
"build:ts": "tsc && cp src/index.html src/styles.css build/",
"start": "npm run build:ts && electron .",
"build": "npm run build:ts && electron-builder",
"postinstall": "CXXFLAGS='-std=c++17' npx @electron/rebuild",
"rebuild": "CXXFLAGS='-std=c++17' npx @electron/rebuild"
"rebuild": "CXXFLAGS='-std=c++17' npx @electron/rebuild",
"lint": "eslint src/"
},
"build": {
"appId": "com.corsair-lcd-control.app",
"productName": "Corsair LCD Control",
"files": [
"main.js",
"preload.js",
"src/**/*"
"build/**/*",
"assets/**/*"
],
"linux": {
"target": [
@@ -42,8 +43,16 @@
"usb": "^2.9.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.9.2",
"@types/usb": "^2.0.4",
"@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/parser": "^8.61.0",
"electron": "^42.3.3",
"electron-builder": "^24.9.0"
"electron-builder": "^24.9.0",
"eslint": "^10.4.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.61.0"
},
"allowScripts": {
"usb@2.18.0": true,

View File

@@ -1,21 +0,0 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// USB
usbListDevices: () => ipcRenderer.invoke('usb:listDevices'),
usbConnect: (vendorId, productId) => ipcRenderer.invoke('usb:connect', { vendorId, productId }),
// Preferences
prefsGet: (key) => ipcRenderer.invoke('prefs:get', key),
prefsSet: (key, value) => ipcRenderer.invoke('prefs:set', key, value),
prefsDelete: (key) => ipcRenderer.invoke('prefs:delete', key),
// File Picker
dialogOpenFile: (options) => ipcRenderer.invoke('dialog:openFile', options),
dialogSaveFile: (options) => ipcRenderer.invoke('dialog:saveFile', options),
// System Info
systemCpuTemp: () => ipcRenderer.invoke('system:cpuTemp'),
systemRam: () => ipcRenderer.invoke('system:ram'),
systemGpu: () => ipcRenderer.invoke('system:gpu'),
});

View File

@@ -41,7 +41,7 @@
<section id="preferences-section">
<h2>Preferences</h2>
<div>
<label>Refresh Interval (ms):</label>
<label for="refresh-interval">Refresh Interval (ms):</label>
<input type="number" id="refresh-interval" value="5000" />
</div>
<div>

View File

@@ -1,38 +1,34 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog } = require('electron');
const path = require('path');
const Store = require('electron-store');
const si = require('systeminformation');
const usb = require('usb');
import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog } from 'electron';
import path = require('path');
import Store from 'electron-store';
import si = require('systeminformation');
import usb = require('usb');
const store = new Store({ defaults: { preferences: {} } });
let mainWindow = null;
let tray = null;
function createTrayIcon() {
const size = 16;
const canvas = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const cx = x - size / 2, cy = y - size / 2;
if (Math.sqrt(cx * cx + cy * cy) < size / 2 - 1) {
canvas[idx] = 0;
canvas[idx + 1] = 120;
canvas[idx + 2] = 255;
canvas[idx + 3] = 255;
} else {
canvas[idx] = 0;
canvas[idx + 1] = 0;
canvas[idx + 2] = 0;
canvas[idx + 3] = 0;
}
}
}
return nativeImage.createFromBuffer(canvas, { width: size, height: size });
interface DeviceEntry {
vendorId: number;
productId: number;
displayName: string;
manufacturer: string;
product: string;
serialNumber: string;
}
function createWindow() {
interface VendorMap {
[key: number]: string;
}
const store = new Store<Record<string, unknown>>({ defaults: { preferences: {} } });
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let isQuitting = false;
function createTrayIcon(): Electron.NativeImage {
const iconPath = path.join(__dirname, 'assets', 'icon.png');
return nativeImage.createFromPath(iconPath);
}
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 900,
height: 600,
@@ -42,29 +38,29 @@ function createWindow() {
nodeIntegration: false,
},
});
mainWindow.loadFile(path.join(__dirname, 'src', 'index.html'));
mainWindow.on('close', (e) => {
if (!app.isQuitting) {
void mainWindow.loadFile(path.join(__dirname, 'index.html'));
mainWindow.on('close', (e: Electron.Event) => {
if (!isQuitting) {
e.preventDefault();
mainWindow.hide();
mainWindow!.hide();
}
});
}
function createTray() {
function createTray(): void {
tray = new Tray(createTrayIcon());
tray.setToolTip('Corsair LCD Control');
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show', click: () => mainWindow.show() },
{ label: 'Hide', click: () => mainWindow.hide() },
{ label: 'Show', click: () => { mainWindow!.show(); } },
{ label: 'Hide', click: () => { mainWindow!.hide(); } },
{ type: 'separator' },
{ label: 'Quit', click: () => { app.isQuitting = true; app.quit(); } },
{ label: 'Quit', click: () => { isQuitting = true; app.quit(); } },
]);
tray.setContextMenu(contextMenu);
tray.on('double-click', () => mainWindow.show());
tray.on('double-click', () => { mainWindow!.show(); });
}
const VENDORS = {
const VENDORS: VendorMap = {
0x1B1C: 'Corsair',
0x046D: 'Logitech',
0x0A5C: 'Broadcom',
@@ -77,18 +73,18 @@ const VENDORS = {
0x5964: 'Asmedia',
};
function formatHexId(vendorId, productId) {
function formatHexId(vendorId: number, productId: number): string {
const hex = `${vendorId.toString(16).padStart(4, '0')}:${productId.toString(16).padStart(4, '0')}`;
const name = VENDORS[vendorId] || '';
return name ? `${name} [${hex}]` : hex;
}
function readStringDescriptor(device, index) {
function readStringDescriptor(device: usb.Device, index: number): Promise<string> {
return new Promise((resolve) => {
if (!index) return resolve('');
if (!index) { resolve(''); return; }
try {
device.getStringDescriptor(index, (err, desc) => {
resolve(err ? '' : desc);
device.getStringDescriptor(index, (err: usb.LibUSBException | undefined, desc?: string) => {
resolve(err ? '' : desc ?? '');
});
} catch {
resolve('');
@@ -98,12 +94,12 @@ function readStringDescriptor(device, index) {
// --- USB ---
ipcMain.handle('usb:listDevices', async () => {
ipcMain.handle('usb:listDevices', async (): Promise<DeviceEntry[] | { error: string }> => {
try {
const devices = usb.getDeviceList();
const results = [];
const results: DeviceEntry[] = [];
for (const d of devices) {
const entry = {
const entry: DeviceEntry = {
vendorId: d.deviceDescriptor.idVendor,
productId: d.deviceDescriptor.idProduct,
displayName: formatHexId(d.deviceDescriptor.idVendor, d.deviceDescriptor.idProduct),
@@ -132,58 +128,61 @@ ipcMain.handle('usb:listDevices', async () => {
}
return results;
} catch (err) {
return { error: err.message };
return { error: (err as Error).message };
}
});
ipcMain.handle('usb:connect', async (_e, { vendorId, productId }) => {
ipcMain.handle('usb:connect', (_e: Electron.IpcMainInvokeEvent, { vendorId, productId }: { vendorId: number; productId: number }) => {
try {
const device = usb.findByIds(vendorId, productId);
if (!device) throw new Error('Device not found');
device.open();
device.open(true);
return { success: true };
} catch (err) {
return { error: err.message };
return { error: (err as Error).message };
}
});
// --- Preferences ---
ipcMain.handle('prefs:get', (_e, key) => {
ipcMain.handle('prefs:get', (_e: Electron.IpcMainInvokeEvent, key?: string) => {
if (key) return store.get(key);
return store.store;
});
ipcMain.handle('prefs:set', (_e, key, value) => {
ipcMain.handle('prefs:set', (_e: Electron.IpcMainInvokeEvent, key: string, value: unknown) => {
store.set(key, value);
return { success: true };
});
ipcMain.handle('prefs:delete', (_e, key) => {
ipcMain.handle('prefs:delete', (_e: Electron.IpcMainInvokeEvent, key: string) => {
store.delete(key);
return { success: true };
});
// --- File Picker ---
ipcMain.handle('dialog:openFile', async (_e, options) => {
const result = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('dialog:openFile', async (_e: Electron.IpcMainInvokeEvent, options?: Electron.OpenDialogOptions) => {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile'],
...options,
});
return result;
});
ipcMain.handle('dialog:saveFile', async (_e, options) => {
const result = await dialog.showSaveDialog(mainWindow, options);
ipcMain.handle('dialog:saveFile', async (_e: Electron.IpcMainInvokeEvent, options?: Electron.SaveDialogOptions) => {
const result = await dialog.showSaveDialog(mainWindow!, options ?? {});
return result;
});
// --- System Info ---
ipcMain.handle('system:cpuTemp', async () => {
try {
const data = await si.cpuTemperature();
return data;
} catch (err) {
return { error: err.message };
return { error: (err as Error).message };
}
});
@@ -197,7 +196,7 @@ ipcMain.handle('system:ram', async () => {
usedPercent: ((data.used / data.total) * 100).toFixed(1),
};
} catch (err) {
return { error: err.message };
return { error: (err as Error).message };
}
});
@@ -213,11 +212,11 @@ ipcMain.handle('system:gpu', async () => {
}));
return controllers;
} catch (err) {
return { error: err.message };
return { error: (err as Error).message };
}
});
app.whenReady().then(() => {
void app.whenReady().then(() => {
createWindow();
createTray();
});

17
src/preload.ts Normal file
View File

@@ -0,0 +1,17 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
usbListDevices: (): Promise<unknown> => ipcRenderer.invoke('usb:listDevices'),
usbConnect: (vendorId: number, productId: number): Promise<unknown> => ipcRenderer.invoke('usb:connect', { vendorId, productId }),
prefsGet: (key?: string): Promise<unknown> => ipcRenderer.invoke('prefs:get', key),
prefsSet: (key: string, value: unknown): Promise<unknown> => ipcRenderer.invoke('prefs:set', key, value),
prefsDelete: (key: string): Promise<unknown> => ipcRenderer.invoke('prefs:delete', key),
dialogOpenFile: (options?: unknown): Promise<unknown> => ipcRenderer.invoke('dialog:openFile', options),
dialogSaveFile: (options?: unknown): Promise<unknown> => ipcRenderer.invoke('dialog:saveFile', options),
systemCpuTemp: (): Promise<unknown> => ipcRenderer.invoke('system:cpuTemp'),
systemRam: (): Promise<unknown> => ipcRenderer.invoke('system:ram'),
systemGpu: (): Promise<unknown> => ipcRenderer.invoke('system:gpu'),
});

View File

@@ -1,78 +0,0 @@
const api = window.electronAPI;
// --- System Info ---
async function refreshSystemInfo() {
const [cpuTemp, ram, gpu] = await Promise.all([
api.systemCpuTemp(),
api.systemRam(),
api.systemGpu(),
]);
document.getElementById('cpu-temp').textContent =
cpuTemp.error ? `Error: ${cpuTemp.error}` :
cpuTemp.main ? `${cpuTemp.main}°C` : 'N/A';
document.getElementById('ram-usage').textContent =
ram.error ? `Error: ${ram.error}` :
`${ram.usedPercent}%`;
document.getElementById('gpu-temp').textContent =
gpu.error ? `Error: ${gpu.error}` :
gpu.length > 0 && gpu[0].temperatureGpu != null ? `${gpu[0].temperatureGpu}°C` :
'N/A';
}
document.getElementById('refresh-sysinfo').addEventListener('click', refreshSystemInfo);
// --- USB ---
document.getElementById('scan-usb').addEventListener('click', async () => {
const result = await api.usbListDevices();
const list = document.getElementById('usb-list');
list.innerHTML = '';
if (result.error) {
list.innerHTML = `<li>Error: ${result.error}</li>`;
return;
}
if (result.length === 0) {
list.innerHTML = '<li>No USB devices found</li>';
return;
}
for (const dev of result) {
const li = document.createElement('li');
li.textContent = dev.displayName;
list.appendChild(li);
}
});
// --- Preferences ---
document.getElementById('save-prefs').addEventListener('click', async () => {
const interval = document.getElementById('refresh-interval').value;
await api.prefsSet('refreshInterval', parseInt(interval, 10));
});
document.getElementById('load-prefs').addEventListener('click', async () => {
const interval = await api.prefsGet('refreshInterval');
if (interval != null) {
document.getElementById('refresh-interval').value = interval;
}
});
// --- File Picker ---
document.getElementById('open-file').addEventListener('click', async () => {
const result = await api.dialogOpenFile({
filters: [{ name: 'All Files', extensions: ['*'] }],
});
document.getElementById('file-result').textContent =
result.canceled ? 'Cancelled' : `Selected: ${result.filePaths.join(', ')}`;
});
document.getElementById('save-file').addEventListener('click', async () => {
const result = await api.dialogSaveFile({
filters: [{ name: 'All Files', extensions: ['*'] }],
});
document.getElementById('file-result').textContent =
result.canceled ? 'Cancelled' : `Save path: ${result.filePath}`;
});
// Auto-refresh on load
refreshSystemInfo();

148
src/renderer.ts Normal file
View File

@@ -0,0 +1,148 @@
interface DeviceEntry {
vendorId: number;
productId: number;
displayName: string;
manufacturer: string;
product: string;
serialNumber: string;
}
interface CpuTempResult {
main: number;
cores: number[];
max: number;
error?: string;
}
interface RamResult {
total: number;
free: number;
used: number;
usedPercent: string;
error?: string;
}
interface GpuController {
vendor: string;
model: string;
temperatureGpu: number | null;
memoryUsed: number | null;
memoryTotal: number | null;
}
interface ElectronAPI {
systemCpuTemp: () => Promise<CpuTempResult>;
systemRam: () => Promise<RamResult>;
systemGpu: () => Promise<GpuController[] | { error: string }>;
usbListDevices: () => Promise<DeviceEntry[] | { error: string }>;
usbConnect: (vendorId: number, productId: number) => Promise<{ success: boolean } | { error: string }>;
prefsGet: (key?: string) => Promise<unknown>;
prefsSet: (key: string, value: unknown) => Promise<unknown>;
prefsDelete: (key: string) => Promise<unknown>;
dialogOpenFile: (options?: unknown) => Promise<{ canceled: boolean; filePaths: string[] }>;
dialogSaveFile: (options?: unknown) => Promise<{ canceled: boolean; filePath: string }>;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};
const api: ElectronAPI = window.electronAPI;
function isError<T>(result: T | { error: string }): result is { error: string } {
return result !== null && typeof result === 'object' && 'error' in result;
}
function formatError(result: { error: string }): string {
return `Error: ${result.error}`;
}
// --- System Info ---
async function refreshSystemInfo(): Promise<void> {
const [cpuTemp, ram, gpu] = await Promise.all([
api.systemCpuTemp(),
api.systemRam(),
api.systemGpu(),
]);
const cpuEl = document.getElementById('cpu-temp');
const ramEl = document.getElementById('ram-usage');
const gpuEl = document.getElementById('gpu-temp');
if (!cpuEl || !ramEl || !gpuEl) return;
cpuEl.textContent = isError(cpuTemp) ? formatError(cpuTemp) :
cpuTemp.main ? `${cpuTemp.main}°C` : 'N/A';
ramEl.textContent = isError(ram) ? formatError(ram) :
`${ram.usedPercent}%`;
gpuEl.textContent = isError(gpu) ? formatError(gpu) :
gpu.length > 0 && gpu[0].temperatureGpu != null ? `${gpu[0].temperatureGpu}°C` :
'N/A';
}
document.getElementById('refresh-sysinfo')?.addEventListener('click', refreshSystemInfo);
// --- USB ---
document.getElementById('scan-usb')?.addEventListener('click', async () => {
const result = await api.usbListDevices();
const list = document.getElementById('usb-list');
if (!list) return;
list.innerHTML = '';
if (isError(result)) {
list.innerHTML = `<li>${formatError(result)}</li>`;
return;
}
if (result.length === 0) {
list.innerHTML = '<li>No USB devices found</li>';
return;
}
for (const dev of result) {
const li = document.createElement('li');
li.textContent = dev.displayName;
list.appendChild(li);
}
});
// --- Preferences ---
document.getElementById('save-prefs')?.addEventListener('click', async () => {
const intervalEl = document.getElementById('refresh-interval') as HTMLInputElement | null;
if (!intervalEl) return;
await api.prefsSet('refreshInterval', parseInt(intervalEl.value, 10));
});
document.getElementById('load-prefs')?.addEventListener('click', async () => {
const interval = await api.prefsGet('refreshInterval');
const intervalEl = document.getElementById('refresh-interval') as HTMLInputElement | null;
if (interval != null && intervalEl) {
intervalEl.value = typeof interval === 'number' ? String(interval) : '';
}
});
// --- File Picker ---
document.getElementById('open-file')?.addEventListener('click', async () => {
const result = await api.dialogOpenFile({
filters: [{ name: 'All Files', extensions: ['*'] }],
});
const fileEl = document.getElementById('file-result');
if (!fileEl) return;
fileEl.textContent =
result.canceled ? 'Cancelled' : `Selected: ${result.filePaths.join(', ')}`;
});
document.getElementById('save-file')?.addEventListener('click', async () => {
const result = await api.dialogSaveFile({
filters: [{ name: 'All Files', extensions: ['*'] }],
});
const fileEl = document.getElementById('file-result');
if (!fileEl) return;
fileEl.textContent =
result.canceled ? 'Cancelled' : `Save path: ${result.filePath}`;
});
// Auto-refresh on load
void refreshSystemInfo();

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true
},
"include": ["src/**/*.ts"]
}