Initial commit, basic functionality in place.

This commit is contained in:
Agent
2026-03-11 23:58:39 +00:00
commit 9af3805f87
8 changed files with 6199 additions and 0 deletions

199
main.js Normal file
View File

@@ -0,0 +1,199 @@
'use strict';
const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
function parseCliArgs() {
const args = process.argv.slice(2);
const overrides = {};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--host':
if (args[i + 1]) overrides.host = args[++i];
break;
case '--port': {
const p = parseInt(args[++i], 10);
if (!isNaN(p)) overrides.port = p;
break;
}
case '--volume': {
const v = parseInt(args[++i], 10);
if (!isNaN(v)) overrides.volume = Math.min(100, Math.max(0, v));
break;
}
}
}
return overrides;
}
// ---------------------------------------------------------------------------
// Config persistence
// ---------------------------------------------------------------------------
const CONFIG_DEFAULTS = { host: 'localhost', port: 8888, volume: 80 };
function getConfigPath() {
return path.join(app.getPath('userData'), 'config.json');
}
function loadConfig() {
try {
const raw = fs.readFileSync(getConfigPath(), 'utf8');
return Object.assign({}, CONFIG_DEFAULTS, JSON.parse(raw));
} catch {
return Object.assign({}, CONFIG_DEFAULTS);
}
}
function writeConfig(cfg) {
try {
fs.writeFileSync(getConfigPath(), JSON.stringify(cfg, null, 2), 'utf8');
} catch (e) {
console.error('Failed to write config:', e);
}
}
// ---------------------------------------------------------------------------
// App state
// ---------------------------------------------------------------------------
let mainWindow = null;
let tray = null;
const cliArgs = parseCliArgs();
// savedConfig loaded in app.whenReady() — app.getPath('userData') not available before then
let savedConfig = { ...CONFIG_DEFAULTS };
// Effective runtime config = saved + CLI overrides (CLI does not persist).
// Only include cliArgs keys that are explicitly set (not undefined).
function getRuntimeConfig() {
const definedCli = Object.fromEntries(
Object.entries(cliArgs).filter(([, v]) => v !== undefined)
);
return Object.assign({}, savedConfig, definedCli);
}
// Show and focus the main window from any context.
function showWindow() {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
}
// ---------------------------------------------------------------------------
// Tray icon — generated programmatically from raw RGBA pixels (16×16 cyan circle)
// ---------------------------------------------------------------------------
function createTrayIcon() {
// Build a 16×16 RGBA buffer with a filled circle in --cyan (#12c2e9)
const SIZE = 16;
const buf = Buffer.alloc(SIZE * SIZE * 4, 0); // fully transparent
const cx = 7.5, cy = 7.5, r = 6.5;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy <= r * r) {
const i = (y * SIZE + x) * 4;
buf[i] = 0x12; // R
buf[i + 1] = 0xc2; // G
buf[i + 2] = 0xe9; // B
buf[i + 3] = 0xff; // A
}
}
}
return nativeImage.createFromBuffer(buf, { width: SIZE, height: SIZE });
}
// ---------------------------------------------------------------------------
// Window creation
// ---------------------------------------------------------------------------
function createWindow() {
mainWindow = new BrowserWindow({
width: 360,
height: 540,
resizable: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
title: 'Kokoro Widget',
backgroundColor: '#0d1220',
});
mainWindow.setMenuBarVisibility(false);
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
// Hide to tray on close instead of quitting
mainWindow.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault();
mainWindow.hide();
}
});
}
// ---------------------------------------------------------------------------
// Tray creation
// ---------------------------------------------------------------------------
function createTray() {
const icon = createTrayIcon();
tray = new Tray(icon);
tray.setToolTip('Kokoro Widget');
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show',
click: showWindow,
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
app.isQuitting = true;
app.quit();
},
},
]);
tray.setContextMenu(contextMenu);
tray.on('double-click', showWindow);
// On macOS, single click shows the window
if (process.platform === 'darwin') {
tray.on('click', showWindow);
}
}
// ---------------------------------------------------------------------------
// IPC handlers
// ---------------------------------------------------------------------------
ipcMain.handle('get-config', () => {
return getRuntimeConfig();
});
ipcMain.handle('save-config', (_event, newConfig) => {
// Merge new values into savedConfig
savedConfig = Object.assign({}, savedConfig, newConfig);
writeConfig(savedConfig);
// Return effective runtime config (saved + CLI overrides)
return getRuntimeConfig();
});
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
app.whenReady().then(() => {
savedConfig = loadConfig(); // safe here — app.getPath('userData') now available
createWindow();
createTray();
app.on('activate', showWindow);
});
// Do NOT quit when all windows are closed — tray keeps the app alive
app.on('window-all-closed', () => {
// intentionally empty — tray icon keeps the process running
});