diff --git a/.prettierrc.json b/.prettierrc.json index 583ce10..3cbe1bf 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,8 +1,10 @@ { - "semi": false, + "semi": true, "singleQuote": true, "trailingComma": "es5", "printWidth": 100, "tabWidth": 2, - "endOfLine": "lf" + "endOfLine": "lf", + "objectWrap": "preserve", + "bracketSameLine": false } diff --git a/package-lock.json b/package-lock.json index e89fe35..d62714c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "java-runner-client", - "version": "2.1.2", + "version": "2.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "java-runner-client", - "version": "2.1.2", + "version": "2.1.4", "dependencies": { "electron-store": "^8.1.0", "framer-motion": "^12.38.0", diff --git a/postcss.config.js b/postcss.config.js index 2959567..cce4985 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1 +1 @@ -module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } } +module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/src/main/shared/IPCController.ts b/src/main/IPCController.ts similarity index 83% rename from src/main/shared/IPCController.ts rename to src/main/IPCController.ts index 2f5dfba..cc4f542 100644 --- a/src/main/shared/IPCController.ts +++ b/src/main/IPCController.ts @@ -13,35 +13,35 @@ * main pushes via webContents.send(channel, ...args) */ -import { ipcMain, ipcRenderer, IpcMainInvokeEvent, IpcMainEvent, IpcRendererEvent } from 'electron' +import { ipcMain, ipcRenderer, IpcMainInvokeEvent, IpcMainEvent, IpcRendererEvent } from 'electron'; // ─── Route descriptors ──────────────────────────────────────────────────────── type InvokeRoute = { - type: 'invoke' - channel: string - handler: (event: IpcMainInvokeEvent, ...args: any[]) => any -} + type: 'invoke'; + channel: string; + handler: (event: IpcMainInvokeEvent, ...args: any[]) => any; +}; type SendRoute = { - type: 'send' - channel: string - handler: (event: IpcMainEvent, ...args: any[]) => void -} + type: 'send'; + channel: string; + handler: (event: IpcMainEvent, ...args: any[]) => void; +}; /** No handler — main pushes via webContents.send(channel, ...) */ type OnRoute = { - type: 'on' - channel: string + type: 'on'; + channel: string; /** Cast a function signature here to type the callback args on window.api.onFoo. * e.g. `args: {} as (profileId: string, line: ConsoleLine) => void` * Never called at runtime — purely a compile-time phantom. */ - args?: (...args: any[]) => void -} + args?: (...args: any[]) => void; +}; -type Route = InvokeRoute | SendRoute | OnRoute +type Route = InvokeRoute | SendRoute | OnRoute; -export type RouteMap = Record +export type RouteMap = Record; // ─── Type inference: RouteMap → window.api shape ────────────────────────────── @@ -50,15 +50,15 @@ type InvokeAPI = R['handler'] extends ( ...args: infer A ) => infer Ret ? (...args: A) => Promise> - : never + : never; type SendAPI = R['handler'] extends (_e: any, ...args: infer A) => any ? (...args: A) => void - : never + : never; type OnAPI = R extends { args: (...args: infer A) => void } ? { [key in `on${Capitalize}`]: (cb: (...args: A) => void) => () => void } - : { [key in `on${Capitalize}`]: (cb: (...args: any[]) => void) => () => void } + : { [key in `on${Capitalize}`]: (cb: (...args: any[]) => void) => () => void }; /** Derives the full window.api type from a RouteMap. */ export type InferAPI = { @@ -66,7 +66,7 @@ export type InferAPI = { ? InvokeAPI : M[K] extends SendRoute ? SendAPI - : never + : never; } & { [K in keyof M as M[K]['type'] extends 'on' ? `on${Capitalize}` @@ -74,16 +74,16 @@ export type InferAPI = { ? M[K] extends { args: (...args: infer A) => void } ? (cb: (...args: A) => void) => () => void : (cb: (...args: any[]) => void) => () => void - : never -} + : never; +}; // ─── Main-process: register all routes onto ipcMain ────────────────────────── export function registerIPC(routes: RouteMap[]): void { for (const map of routes) { for (const route of Object.values(map)) { - if (route.type === 'invoke') ipcMain.handle(route.channel, route.handler) - if (route.type === 'send') ipcMain.on(route.channel, route.handler) + if (route.type === 'invoke') ipcMain.handle(route.channel, route.handler); + if (route.type === 'send') ipcMain.on(route.channel, route.handler); // 'on' routes are push-only from main — no listener to register } } @@ -92,26 +92,26 @@ export function registerIPC(routes: RouteMap[]): void { // ─── Preload: build the window.api object ──────────────────────────────────── export function buildPreloadAPI(routes: RouteMap[]): Record { - const api: Record = {} + const api: Record = {}; for (const map of routes) { for (const [key, route] of Object.entries(map)) { if (route.type === 'invoke') { - api[key] = (...args: unknown[]) => ipcRenderer.invoke(route.channel, ...args) + api[key] = (...args: unknown[]) => ipcRenderer.invoke(route.channel, ...args); } if (route.type === 'send') { - api[key] = (...args: unknown[]) => ipcRenderer.send(route.channel, ...args) + api[key] = (...args: unknown[]) => ipcRenderer.send(route.channel, ...args); } if (route.type === 'on') { - const cbKey = `on${key[0].toUpperCase()}${key.slice(1)}` + const cbKey = `on${key[0].toUpperCase()}${key.slice(1)}`; api[cbKey] = (cb: (...args: unknown[]) => void) => { - const handler = (_e: IpcRendererEvent, ...args: unknown[]) => cb(...args) - ipcRenderer.on(route.channel, handler) - return () => ipcRenderer.off(route.channel, handler) - } + const handler = (_e: IpcRendererEvent, ...args: unknown[]) => cb(...args); + ipcRenderer.on(route.channel, handler); + return () => ipcRenderer.off(route.channel, handler); + }; } } } - return api + return api; } diff --git a/src/main/JRCEnvironment.ts b/src/main/JRCEnvironment.ts new file mode 100644 index 0000000..b544fcc --- /dev/null +++ b/src/main/JRCEnvironment.ts @@ -0,0 +1,44 @@ +import { app, BrowserWindow } from 'electron'; +import { EnvironmentIPC } from './ipc/Environment.ipc'; +import { JRCEnvironment } from './shared/types/App.types'; +import { getSettings } from './Store'; + +let env: JRCEnvironment = { + isReady: false, + devMode: null as unknown as JRCEnvironment['devMode'], + type: null as unknown as JRCEnvironment['type'], + startUpSource: null as unknown as JRCEnvironment['startUpSource'], +}; + +export function loadEnvironment() { + env = { + isReady: true, + devMode: getSettings().devModeEnabled, + type: app.isPackaged ? 'prod' : 'dev', + startUpSource: detectStartupSource(), + }; + + broadcast(); +} + +export function getEnvironment() { + return env; +} + +export function shouldStartMinimized(): boolean { + return process.argv.includes('--minimized'); +} + +function broadcast(channel: string = EnvironmentIPC.change.channel) { + BrowserWindow.getAllWindows().forEach((w) => w.webContents.send(channel, env)); +} + +function detectStartupSource(): JRCEnvironment['startUpSource'] { + if (!app.isPackaged) return 'development'; + + const login = app.getLoginItemSettings(); + + if (login.wasOpenedAtLogin || process.argv.includes('--autostart')) return 'withSystem'; + + return 'userRequest'; +} diff --git a/src/main/ProcessManager.ts b/src/main/ProcessManager.ts index 14b7180..92386f6 100644 --- a/src/main/ProcessManager.ts +++ b/src/main/ProcessManager.ts @@ -1,18 +1,18 @@ -import { spawn, execSync, ChildProcess } from 'child_process' -import { BrowserWindow } from 'electron' -import path from 'path' -import { v4 as uuidv4 } from 'uuid' -import type { - Profile, - ProcessState, +import { spawn, execSync, ChildProcess } from 'child_process'; +import { BrowserWindow } from 'electron'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { PROTECTED_PROCESS_NAMES } from './shared/config/Scanner.config'; +import { ConsoleLine, - ProcessLogEntry, JavaProcessInfo, -} from './shared/types' -import { IPC } from './shared/types' -import { PROTECTED_PROCESS_NAMES } from './shared/config/Scanner.config' + ProcessLogEntry, + ProcessState, +} from './shared/types/Process.types'; +import { Profile } from './shared/types/Profile.types'; +import { ProcessIPC } from './ipc/Process.ipc'; -const SELF_PROCESS_NAME = 'Java Client Runner' +const SELF_PROCESS_NAME = 'Java Client Runner'; type SystemMessageType = | 'start' @@ -23,57 +23,57 @@ type SystemMessageType = | 'error-runtime' | 'info-pid' | 'info-workdir' - | 'info-restart' + | 'info-restart'; interface ManagedProcess { - process: ChildProcess - profileId: string - profileName: string - jarPath: string - startedAt: number - intentionallyStopped: boolean + process: ChildProcess; + profileId: string; + profileName: string; + jarPath: string; + startedAt: number; + intentionallyStopped: boolean; } class ProcessManager { - private processes = new Map() - private activityLog: ProcessLogEntry[] = [] - private window: BrowserWindow | null = null - private restartTimers = new Map>() - private profileSnapshots = new Map() - private lineCounters = new Map() - private seenLineIds = new Map>() - private onTrayUpdate?: () => void + private processes = new Map(); + private activityLog: ProcessLogEntry[] = []; + private window: BrowserWindow | null = null; + private restartTimers = new Map>(); + private profileSnapshots = new Map(); + private lineCounters = new Map(); + private seenLineIds = new Map>(); + private onTrayUpdate?: () => void; setWindow(win: BrowserWindow): void { - this.window = win + this.window = win; } private buildArgs(profile: Profile): { cmd: string; args: string[] } { - const cmd = profile.javaPath || 'java' - const args: string[] = [] - for (const a of profile.jvmArgs) if (a.enabled && a.value.trim()) args.push(a.value.trim()) + const cmd = profile.javaPath || 'java'; + const args: string[] = []; + for (const a of profile.jvmArgs) if (a.enabled && a.value.trim()) args.push(a.value.trim()); for (const p of profile.systemProperties) if (p.enabled && p.key.trim()) - args.push(p.value.trim() ? `-D${p.key.trim()}=${p.value.trim()}` : `-D${p.key.trim()}`) - args.push('-jar', profile.jarPath) - for (const a of profile.programArgs) if (a.enabled && a.value.trim()) args.push(a.value.trim()) - return { cmd, args } + args.push(p.value.trim() ? `-D${p.key.trim()}=${p.value.trim()}` : `-D${p.key.trim()}`); + args.push('-jar', profile.jarPath); + for (const a of profile.programArgs) if (a.enabled && a.value.trim()) args.push(a.value.trim()); + return { cmd, args }; } start(profile: Profile): { ok: boolean; error?: string } { - if (this.processes.has(profile.id)) return { ok: false, error: 'Process already running' } - if (!profile.jarPath) return { ok: false, error: 'No JAR file specified' } + if (this.processes.has(profile.id)) return { ok: false, error: 'Process already running' }; + if (!profile.jarPath) return { ok: false, error: 'No JAR file specified' }; - this.cancelRestartTimer(profile.id) - this.profileSnapshots.set(profile.id, profile) + this.cancelRestartTimer(profile.id); + this.profileSnapshots.set(profile.id, profile); - const { cmd, args } = this.buildArgs(profile) - const cwd = profile.workingDir || path.dirname(profile.jarPath) + const { cmd, args } = this.buildArgs(profile); + const cwd = profile.workingDir || path.dirname(profile.jarPath); - this.pushSystem('start', profile.id, 'pending', `Starting: ${cmd} ${args.join(' ')}`) - this.pushSystem('info-workdir', profile.id, 'pending', `Working dir: ${cwd}`) + this.pushSystem('start', profile.id, 'pending', `Starting: ${cmd} ${args.join(' ')}`); + this.pushSystem('info-workdir', profile.id, 'pending', `Working dir: ${cwd}`); - let proc: ChildProcess + let proc: ChildProcess; try { proc = spawn(cmd, args, { cwd, @@ -81,11 +81,11 @@ class ProcessManager { shell: false, detached: false, stdio: ['pipe', 'pipe', 'pipe'], - }) + }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err) - this.pushSystem('error-starting', profile.id, 'pending', `Failed to start: ${msg}`) - return { ok: false, error: msg } + const msg = err instanceof Error ? err.message : String(err); + this.pushSystem('error-starting', profile.id, 'pending', `Failed to start: ${msg}`); + return { ok: false, error: msg }; } const managed: ManagedProcess = { @@ -95,16 +95,16 @@ class ProcessManager { jarPath: profile.jarPath, startedAt: Date.now(), intentionallyStopped: false, - } - this.processes.set(profile.id, managed) + }; + this.processes.set(profile.id, managed); if (!this.lineCounters.has(profile.id)) { - this.lineCounters.set(profile.id, 0) - this.seenLineIds.set(profile.id, new Set()) + this.lineCounters.set(profile.id, 0); + this.seenLineIds.set(profile.id, new Set()); } - const pid = proc.pid ?? 0 - this.pushSystem('info-pid', profile.id, String(pid), `PID: ${pid}`) + const pid = proc.pid ?? 0; + this.pushSystem('info-pid', profile.id, String(pid), `PID: ${pid}`); const logEntry: ProcessLogEntry = { id: uuidv4(), @@ -113,128 +113,128 @@ class ProcessManager { jarPath: profile.jarPath, pid, startedAt: managed.startedAt, - } - this.activityLog.unshift(logEntry) - if (this.activityLog.length > 500) this.activityLog.pop() + }; + this.activityLog.unshift(logEntry); + if (this.activityLog.length > 500) this.activityLog.pop(); - proc.stdout?.setEncoding('utf8') + proc.stdout?.setEncoding('utf8'); proc.stdout?.on('data', (chunk: string) => this.pushOutput(profile.id, chunk, 'stdout', managed) - ) - proc.stderr?.setEncoding('utf8') + ); + proc.stderr?.setEncoding('utf8'); proc.stderr?.on('data', (chunk: string) => this.pushOutput(profile.id, chunk, 'stderr', managed) - ) + ); proc.on('error', (err) => this.pushSystem('error-runtime', profile.id, String(pid), `Error: ${err.message}`) - ) + ); proc.on('exit', (code, signal) => { - this.processes.delete(profile.id) + this.processes.delete(profile.id); this.pushSystem( 'stopped', profile.id, String(pid), `Process stopped (${signal ? `signal ${signal}` : `exit code ${code ?? '?'}`})` - ) - const entry = this.activityLog.find((e) => e.profileId === profile.id && !e.stoppedAt) + ); + const entry = this.activityLog.find((e) => e.profileId === profile.id && !e.stoppedAt); if (entry) { - entry.stoppedAt = Date.now() - entry.exitCode = code ?? undefined - entry.signal = signal ?? undefined + entry.stoppedAt = Date.now(); + entry.exitCode = code ?? undefined; + entry.signal = signal ?? undefined; } - this.broadcastStates() - this.updateTray() + this.broadcastStates(); + this.updateTray(); if (!managed.intentionallyStopped && code !== 0) { - const snapshot = this.profileSnapshots.get(profile.id) + const snapshot = this.profileSnapshots.get(profile.id); if (snapshot?.autoRestart) { - const delaySec = Math.max(1, snapshot.autoRestartInterval ?? 10) + const delaySec = Math.max(1, snapshot.autoRestartInterval ?? 10); this.pushSystem( 'info-restart', profile.id, String(pid), `Auto-restart in ${delaySec}s...` - ) + ); const timer = setTimeout(() => { - this.restartTimers.delete(profile.id) - const latest = this.profileSnapshots.get(profile.id) ?? snapshot - this.start(latest) - }, delaySec * 1000) - this.restartTimers.set(profile.id, timer) + this.restartTimers.delete(profile.id); + const latest = this.profileSnapshots.get(profile.id) ?? snapshot; + this.start(latest); + }, delaySec * 1000); + this.restartTimers.set(profile.id, timer); } } - }) + }); - this.broadcastStates() - this.updateTray() - return { ok: true } + this.broadcastStates(); + this.updateTray(); + return { ok: true }; } stop(profileId: string): { ok: boolean; error?: string } { - const m = this.processes.get(profileId) - if (!m) return { ok: false, error: 'Not running' } + const m = this.processes.get(profileId); + if (!m) return { ok: false, error: 'Not running' }; - m.intentionallyStopped = true - this.cancelRestartTimer(profileId) + m.intentionallyStopped = true; + this.cancelRestartTimer(profileId); - this.pushSystem('stopping', profileId, String(m.process.pid ?? 0), 'Stopping process...') - const pid = m.process.pid + this.pushSystem('stopping', profileId, String(m.process.pid ?? 0), 'Stopping process...'); + const pid = m.process.pid; if (process.platform === 'win32' && pid) { try { - execSync(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }) + execSync(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }); } catch { try { - m.process.kill('SIGTERM') + m.process.kill('SIGTERM'); } catch { /* ignore */ } } } else { try { - m.process.kill('SIGTERM') + m.process.kill('SIGTERM'); } catch { /* ignore */ } setTimeout(() => { if (this.processes.has(profileId)) try { - m.process.kill('SIGKILL') + m.process.kill('SIGKILL'); } catch { /* ignore */ } - }, 5000) + }, 5000); } - this.updateTray() - return { ok: true } + this.updateTray(); + return { ok: true }; } updateProfileSnapshot(profile: Profile): void { if (this.profileSnapshots.has(profile.id)) { - this.profileSnapshots.set(profile.id, profile) + this.profileSnapshots.set(profile.id, profile); } } clearConsoleForProfile(profileId: string): void { - this.window?.webContents.send(IPC.CONSOLE_CLEAR, profileId) + this.window?.webContents.send(ProcessIPC.consoleClear.channel, profileId); } private cancelRestartTimer(profileId: string): void { - const t = this.restartTimers.get(profileId) + const t = this.restartTimers.get(profileId); if (t) { - clearTimeout(t) - this.restartTimers.delete(profileId) + clearTimeout(t); + this.restartTimers.delete(profileId); } } sendInput(profileId: string, input: string): { ok: boolean; error?: string } { - const m = this.processes.get(profileId) - if (!m) return { ok: false, error: 'Not running' } - m.process.stdin?.write(input.endsWith('\n') ? input : `${input}\n`) - const counter = (this.lineCounters.get(profileId) ?? 0) + 1 - this.lineCounters.set(profileId, counter) - this.pushLine(profileId, input, 'input', counter) - return { ok: true } + const m = this.processes.get(profileId); + if (!m) return { ok: false, error: 'Not running' }; + m.process.stdin?.write(input.endsWith('\n') ? input : `${input}\n`); + const counter = (this.lineCounters.get(profileId) ?? 0) + 1; + this.lineCounters.set(profileId, counter); + this.pushLine(profileId, input, 'input', counter); + return { ok: true }; } getStates(): ProcessState[] { @@ -243,14 +243,14 @@ class ProcessManager { running: true, pid: m.process.pid, startedAt: m.startedAt, - })) + })); } getActivityLog(): ProcessLogEntry[] { - return this.activityLog + return this.activityLog; } clearActivityLog(): void { - this.activityLog = [] + this.activityLog = []; } // ── Process Scanner ────────────────────────────────────────────────────────── @@ -259,17 +259,17 @@ class ProcessManager { return PROTECTED_PROCESS_NAMES.some( (n) => name.toLowerCase().includes(n.toLowerCase()) || cmd.toLowerCase().includes(n.toLowerCase()) - ) + ); } private isSelf(name: string, cmd: string): boolean { - return cmd.includes(SELF_PROCESS_NAME) || name.includes(SELF_PROCESS_NAME) + return cmd.includes(SELF_PROCESS_NAME) || name.includes(SELF_PROCESS_NAME); } private parseJarName(cmd: string): string | undefined { - const m = cmd.match(/-jar\s+([^\s]+)/i) - if (!m) return undefined - return m[1].split(/[/\\]/).pop() + const m = cmd.match(/-jar\s+([^\s]+)/i); + if (!m) return undefined; + return m[1].split(/[/\\]/).pop(); } scanAllProcesses(): JavaProcessInfo[] { @@ -277,9 +277,9 @@ class ProcessManager { Array.from(this.processes.values()) .map((m) => m.process.pid) .filter((p): p is number => p != null) - ) - if (process.platform === 'win32') return this.scanAllWindows(managedPids) - return this.scanAllUnix(managedPids) + ); + if (process.platform === 'win32') return this.scanAllWindows(managedPids); + return this.scanAllUnix(managedPids); } private scanAllWindows(managedPids: Set): JavaProcessInfo[] { @@ -293,30 +293,30 @@ class ProcessManager { ' $st = if ($_.StartTime) { $_.StartTime.ToString("yyyy-MM-dd HH:mm:ss") } else { "" }', ' [PSCustomObject]@{ Id=$_.Id; Name=$_.ProcessName; Cmd=$cmd; MemMB=$mem; Threads=$thr; Start=$st }', '} | ConvertTo-Json -Compress -Depth 2', - ].join('; ') + ].join('; '); - const encoded = Buffer.from(psScript, 'utf16le').toString('base64') + const encoded = Buffer.from(psScript, 'utf16le').toString('base64'); try { const raw_out = execSync( `powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand ${encoded}`, { encoding: 'utf8', timeout: 20000 } - ) + ); const jsonLine = raw_out.split('\n').find((l) => { - const t = l.trim() - return t.startsWith('[') || t.startsWith('{') - }) - if (!jsonLine) return this.scanAllWindowsTasklist(managedPids) + const t = l.trim(); + return t.startsWith('[') || t.startsWith('{'); + }); + if (!jsonLine) return this.scanAllWindowsTasklist(managedPids); - const raw = JSON.parse(jsonLine.trim()) - const procs = Array.isArray(raw) ? raw : [raw] + const raw = JSON.parse(jsonLine.trim()); + const procs = Array.isArray(raw) ? raw : [raw]; return procs .map((p: Record) => { - const pid = Number(p.Id) - const name = String(p.Name ?? '') - const cmd = String(p.Cmd ?? name) - if (isNaN(pid) || pid <= 0) return null - if (this.isSelf(name, cmd)) return null - const isJava = /java/i.test(name) || /java/i.test(cmd) + const pid = Number(p.Id); + const name = String(p.Name ?? ''); + const cmd = String(p.Cmd ?? name); + if (isNaN(pid) || pid <= 0) return null; + if (this.isSelf(name, cmd)) return null; + const isJava = /java/i.test(name) || /java/i.test(cmd); return { pid, name, @@ -328,29 +328,29 @@ class ProcessManager { threads: typeof p.Threads === 'number' ? p.Threads : undefined, startTime: p.Start ? String(p.Start) : undefined, jarName: this.parseJarName(cmd), - } as JavaProcessInfo + } as JavaProcessInfo; }) .filter((x): x is JavaProcessInfo => x !== null) - .sort((a, b) => (b.isJava ? 1 : 0) - (a.isJava ? 1 : 0)) + .sort((a, b) => (b.isJava ? 1 : 0) - (a.isJava ? 1 : 0)); } catch { - return this.scanAllWindowsTasklist(managedPids) + return this.scanAllWindowsTasklist(managedPids); } } private scanAllWindowsTasklist(managedPids: Set): JavaProcessInfo[] { try { - const out = execSync('tasklist /fo csv /nh', { encoding: 'utf8', timeout: 8000 }) - const results: JavaProcessInfo[] = [] + const out = execSync('tasklist /fo csv /nh', { encoding: 'utf8', timeout: 8000 }); + const results: JavaProcessInfo[] = []; for (const line of out.split('\n')) { - const t = line.trim() - if (!t) continue - const parts = t.replace(/"/g, '').split(',') - if (parts.length < 2) continue - const name = parts[0].trim() - const pid = parseInt(parts[1].trim(), 10) - if (isNaN(pid) || pid <= 0) continue - if (this.isSelf(name, name)) continue - const isJava = /java/i.test(name) + const t = line.trim(); + if (!t) continue; + const parts = t.replace(/"/g, '').split(','); + if (parts.length < 2) continue; + const name = parts[0].trim(); + const pid = parseInt(parts[1].trim(), 10); + if (isNaN(pid) || pid <= 0) continue; + if (this.isSelf(name, name)) continue; + const isJava = /java/i.test(name); results.push({ pid, name, @@ -358,29 +358,29 @@ class ProcessManager { isJava, managed: managedPids.has(pid), protected: this.isProtected(name, name), - }) + }); } - return results.sort((a, b) => (b.isJava ? 1 : 0) - (a.isJava ? 1 : 0)) + return results.sort((a, b) => (b.isJava ? 1 : 0) - (a.isJava ? 1 : 0)); } catch { - return [] + return []; } } private scanAllUnix(managedPids: Set): JavaProcessInfo[] { try { - const out = execSync('ps -eo pid,comm,args', { encoding: 'utf8', timeout: 5000 }) + const out = execSync('ps -eo pid,comm,args', { encoding: 'utf8', timeout: 5000 }); return out .split('\n') .slice(1) .filter(Boolean) .map((line) => { - const parts = line.trim().split(/\s+/) - const pid = parseInt(parts[0], 10) - const name = parts[1] ?? '' - const cmd = parts.slice(2).join(' ').slice(0, 400) - if (isNaN(pid)) return null - if (this.isSelf(name, cmd)) return null - const isJava = /java/i.test(name) || /java/i.test(cmd) + const parts = line.trim().split(/\s+/); + const pid = parseInt(parts[0], 10); + const name = parts[1] ?? ''; + const cmd = parts.slice(2).join(' ').slice(0, 400); + if (isNaN(pid)) return null; + if (this.isSelf(name, cmd)) return null; + const isJava = /java/i.test(name) || /java/i.test(cmd); return { pid, name, @@ -389,39 +389,39 @@ class ProcessManager { managed: managedPids.has(pid), protected: this.isProtected(name, cmd), jarName: this.parseJarName(cmd), - } as JavaProcessInfo + } as JavaProcessInfo; }) .filter((x): x is JavaProcessInfo => x !== null) - .sort((a, b) => (b.isJava ? 1 : 0) - (a.isJava ? 1 : 0)) + .sort((a, b) => (b.isJava ? 1 : 0) - (a.isJava ? 1 : 0)); } catch { - return [] + return []; } } killPid(pid: number): { ok: boolean; error?: string } { try { - if (process.platform === 'win32') execSync(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }) - else process.kill(pid, 'SIGKILL') + if (process.platform === 'win32') execSync(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }); + else process.kill(pid, 'SIGKILL'); for (const [id, m] of this.processes.entries()) { if (m.process.pid === pid) { - this.processes.delete(id) - break + this.processes.delete(id); + break; } } - this.broadcastStates() - this.updateTray() - return { ok: true } + this.broadcastStates(); + this.updateTray(); + return { ok: true }; } catch (err: unknown) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } + return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } // Only kills non-protected java processes killAllJava(): { ok: boolean; killed: number } { - const procs = this.scanAllProcesses().filter((p) => p.isJava && !p.protected) - let killed = 0 - for (const p of procs) if (this.killPid(p.pid).ok) killed++ - return { ok: true, killed } + const procs = this.scanAllProcesses().filter((p) => p.isJava && !p.protected); + let killed = 0; + for (const p of procs) if (this.killPid(p.pid).ok) killed++; + return { ok: true, killed }; } private pushOutput( @@ -431,10 +431,10 @@ class ProcessManager { m: ManagedProcess ) { for (const [i, text] of chunk.split(/\r?\n/).entries()) { - if (i === chunk.split(/\r?\n/).length - 1 && text === '') continue - const counter = (this.lineCounters.get(profileId) ?? 0) + 1 - this.lineCounters.set(profileId, counter) - this.pushLine(profileId, text, type, counter) + if (i === chunk.split(/\r?\n/).length - 1 && text === '') continue; + const counter = (this.lineCounters.get(profileId) ?? 0) + 1; + this.lineCounters.set(profileId, counter); + this.pushLine(profileId, text, type, counter); } } @@ -444,41 +444,41 @@ class ProcessManager { type: ConsoleLine['type'], id: number | string ) { - const seenIds = this.seenLineIds.get(profileId) + const seenIds = this.seenLineIds.get(profileId); if (seenIds?.has(id)) { - throw new Error(`Duplicate line ID detected for profile ${profileId}: ${id}`) + throw new Error(`Duplicate line ID detected for profile ${profileId}: ${id}`); } if (seenIds) { - seenIds.add(id) + seenIds.add(id); if (seenIds.size > 10000) { - this.seenLineIds.set(profileId, new Set([id])) + this.seenLineIds.set(profileId, new Set([id])); } } - this.window?.webContents.send(IPC.CONSOLE_LINE, profileId, { + this.window?.webContents.send(ProcessIPC.consoleLine.channel, profileId, { id, text, type, timestamp: Date.now(), - }) + }); } private pushSystem(_type: SystemMessageType, profileId: string, _pid: string, text: string) { - const counter = (this.lineCounters.get(profileId) ?? 0) + 1 - this.lineCounters.set(profileId, counter) - this.pushLine(profileId, text, 'system', counter) + const counter = (this.lineCounters.get(profileId) ?? 0) + 1; + this.lineCounters.set(profileId, counter); + this.pushLine(profileId, text, 'system', counter); } private broadcastStates() { - this.window?.webContents.send('process:statesUpdate', this.getStates()) + this.window?.webContents.send('process:statesUpdate', this.getStates()); } setTrayUpdater(fn: () => void) { - this.onTrayUpdate = fn + this.onTrayUpdate = fn; } private updateTray() { - this.onTrayUpdate?.() + this.onTrayUpdate?.(); } } -export const processManager = new ProcessManager() +export const processManager = new ProcessManager(); diff --git a/src/main/RestAPI.routes.ts b/src/main/RestAPI.routes.ts new file mode 100644 index 0000000..cb82f2c --- /dev/null +++ b/src/main/RestAPI.routes.ts @@ -0,0 +1,135 @@ +import { routeConfig, type RouteKey } from './shared/config/API.config'; +import { ok, err } from './RestAPI'; +import { getAllProfiles, saveProfile, deleteProfile, getSettings, saveSettings } from './Store'; +import { processManager } from './ProcessManager'; +import { v4 as uuidv4 } from 'uuid'; +import type http from 'http'; +import { Profile } from './shared/types/Profile.types'; +import { AppSettings } from './shared/types/App.types'; + +// ─── Context ────────────────────────────────────────────────────────────────── + +type Params = Record; + +export interface Context { + req: http.IncomingMessage; + res: http.ServerResponse; + params: Params; + body: unknown; +} + +type RouteHandler = (ctx: Context) => void | Promise; + +// ─── Typed route builder ────────────────────────────────────────────────────── + +type BuiltRoute = (typeof routeConfig)[K] & { + handler: RouteHandler; +}; + +function defineRoute(key: K, handler: RouteHandler): BuiltRoute { + return { + ...routeConfig[key], + handler, + }; +} + +// ─── Routes ─────────────────────────────────────────────────────────────────── + +export const routes: { [K in RouteKey]: BuiltRoute } = { + status: defineRoute('status', ({ res }) => + ok(res, { + ok: true, + version: process.env.npm_package_version ?? 'unknown', + profiles: getAllProfiles().length, + running: processManager.getStates().filter((s) => s.running).length, + }) + ), + + profiles_list: defineRoute('profiles_list', ({ res }) => ok(res, getAllProfiles())), + + profiles_get: defineRoute('profiles_get', ({ res, params }) => { + const p = getAllProfiles().find((p) => p.id === params.id); + p ? ok(res, p) : err(res, 'Profile not found', 404); + }), + + profiles_create: defineRoute('profiles_create', ({ res, body }) => { + const b = body as Partial; + + const p: Profile = { + id: uuidv4(), + name: b.name ?? 'New Profile', + jarPath: b.jarPath ?? '', + workingDir: b.workingDir ?? '', + jvmArgs: b.jvmArgs ?? [], + systemProperties: b.systemProperties ?? [], + programArgs: b.programArgs ?? [], + javaPath: b.javaPath ?? '', + autoStart: b.autoStart ?? false, + autoRestart: b.autoRestart ?? false, + autoRestartInterval: b.autoRestartInterval ?? 10, + color: b.color ?? '#4ade80', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + saveProfile(p); + ok(res, p, 201); + }), + + profiles_update: defineRoute('profiles_update', ({ res, params, body }) => { + const existing = getAllProfiles().find((p) => p.id === params.id); + if (!existing) return err(res, 'Profile not found', 404); + + const updated: Profile = { + ...existing, + ...(body as Partial), + id: params.id, + updatedAt: Date.now(), + }; + + saveProfile(updated); + processManager.updateProfileSnapshot(updated); + ok(res, updated); + }), + + profiles_delete: defineRoute('profiles_delete', ({ res, params }) => { + if (!getAllProfiles().find((p) => p.id === params.id)) + return err(res, 'Profile not found', 404); + + deleteProfile(params.id); + ok(res); + }), + + processes_list: defineRoute('processes_list', ({ res }) => ok(res, processManager.getStates())), + + processes_log: defineRoute('processes_log', ({ res }) => + ok(res, processManager.getActivityLog()) + ), + + processes_start: defineRoute('processes_start', ({ res, params }) => { + const p = getAllProfiles().find((p) => p.id === params.id); + if (!p) return err(res, 'Profile not found', 404); + ok(res, processManager.start(p)); + }), + + processes_stop: defineRoute('processes_stop', ({ res, params }) => + ok(res, processManager.stop(params.id)) + ), + + processes_clear: defineRoute('processes_clear', ({ res, params }) => { + processManager.clearConsoleForProfile(params.id); + ok(res); + }), + + settings_get: defineRoute('settings_get', ({ res }) => ok(res, getSettings())), + + settings_update: defineRoute('settings_update', ({ res, body }) => { + const updated: AppSettings = { + ...getSettings(), + ...(body as Partial), + }; + + saveSettings(updated); + ok(res, updated); + }), +}; diff --git a/src/main/RestAPI.ts b/src/main/RestAPI.ts index a4dc53f..dde996d 100644 --- a/src/main/RestAPI.ts +++ b/src/main/RestAPI.ts @@ -1,220 +1,82 @@ -import http from 'http' -import { v4 as uuidv4 } from 'uuid' -import type { Profile, AppSettings } from './shared/types' -import { getAllProfiles, saveProfile, deleteProfile, getSettings, saveSettings } from './Store' -import { processManager } from './ProcessManager' -import { REST_API_CONFIG } from './shared/config/RestApi.config' - -// ─── Primitives ─────────────────────────────────────────────────────────────── - -type Params = Record - -interface Context { - req: http.IncomingMessage - res: http.ServerResponse - params: Params - body: unknown -} - -type RouteHandler = (ctx: Context) => void | Promise - -interface RestRoute { - method: string - path: string - handler: RouteHandler -} +import http from 'http'; +import { routes } from './RestAPI.routes'; +import { routeConfig } from './shared/config/API.config'; +import { getSettings } from './Store'; +import { REST_API_CONFIG } from './shared/config/API.config'; + +type Params = Record; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type CompiledRoute = { + method: string; + path: string; + pattern: RegExp; + keys: string[]; + handler: (ctx: { + req: http.IncomingMessage; + res: http.ServerResponse; + params: Params; + body: unknown; + }) => void | Promise; +}; // ─── Helpers ────────────────────────────────────────────────────────────────── function json(res: http.ServerResponse, data: unknown, status = 200) { - res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) - res.end(JSON.stringify(data)) + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }); + res.end(JSON.stringify(data)); } -function ok(res: http.ServerResponse, data: unknown = { ok: true }, status = 200) { - json(res, data, status) +export function ok(res: http.ServerResponse, data: unknown = { ok: true }, status = 200) { + json(res, data, status); } -function err(res: http.ServerResponse, msg: string, status = 400) { - json(res, { error: msg }, status) +export function err(res: http.ServerResponse, msg: string, status = 400) { + json(res, { error: msg }, status); } -function readBody(req: http.IncomingMessage): Promise { +async function readBody(req: http.IncomingMessage): Promise { return new Promise((resolve) => { - let raw = '' - req.on('data', (c) => { - raw += c - }) + let raw = ''; + req.on('data', (c) => (raw += c)); req.on('end', () => { try { - resolve(JSON.parse(raw)) + resolve(JSON.parse(raw)); } catch { - resolve({}) + resolve({}); } - }) - }) + }); + }); } -function parsePattern(path: string): { pattern: RegExp; keys: string[] } { - const keys: string[] = [] +function parsePattern(path: string) { + const keys: string[] = []; const src = path.replace(/:([a-zA-Z]+)/g, (_m, k) => { - keys.push(k) - return '([^/]+)' - }) - return { pattern: new RegExp(`^${src}$`), keys } + keys.push(k); + return '([^/]+)'; + }); + return { pattern: new RegExp(`^${src}$`), keys }; } -// ─── Route definitions ──────────────────────────────────────────────────────── -// -// Add a new endpoint here — nothing else to touch. -// -// ctx.params → path params (e.g. :id) -// ctx.body → parsed JSON body (POST/PUT/PATCH only, otherwise {}) -// Use ok() / err() / json() to respond. - -const routes: RestRoute[] = [ - // ── Status ───────────────────────────────────────────────────────────────── - - { - method: 'GET', - path: '/api/status', - handler: ({ res }) => - ok(res, { - ok: true, - version: process.env.npm_package_version ?? 'unknown', - profiles: getAllProfiles().length, - running: processManager.getStates().filter((s) => s.running).length, - }), - }, - - // ── Profiles ─────────────────────────────────────────────────────────────── - - { - method: 'GET', - path: '/api/profiles', - handler: ({ res }) => ok(res, getAllProfiles()), - }, - { - method: 'GET', - path: '/api/profiles/:id', - handler: ({ res, params }) => { - const p = getAllProfiles().find((p) => p.id === params.id) - p ? ok(res, p) : err(res, 'Profile not found', 404) - }, - }, - { - method: 'POST', - path: '/api/profiles', - handler: ({ res, body }) => { - const b = body as Partial - const p: Profile = { - id: uuidv4(), - name: b.name ?? 'New Profile', - jarPath: b.jarPath ?? '', - workingDir: b.workingDir ?? '', - jvmArgs: b.jvmArgs ?? [], - systemProperties: b.systemProperties ?? [], - programArgs: b.programArgs ?? [], - javaPath: b.javaPath ?? '', - autoStart: b.autoStart ?? false, - autoRestart: b.autoRestart ?? false, - autoRestartInterval: b.autoRestartInterval ?? 10, - color: b.color ?? '#4ade80', - createdAt: Date.now(), - updatedAt: Date.now(), - } - saveProfile(p) - ok(res, p, 201) - }, - }, - { - method: 'PUT', - path: '/api/profiles/:id', - handler: ({ res, params, body }) => { - const existing = getAllProfiles().find((p) => p.id === params.id) - if (!existing) return err(res, 'Profile not found', 404) - const updated: Profile = { - ...existing, - ...(body as Partial), - id: params.id, - updatedAt: Date.now(), - } - saveProfile(updated) - processManager.updateProfileSnapshot(updated) - ok(res, updated) - }, - }, - { - method: 'DELETE', - path: '/api/profiles/:id', - handler: ({ res, params }) => { - if (!getAllProfiles().find((p) => p.id === params.id)) - return err(res, 'Profile not found', 404) - deleteProfile(params.id) - ok(res) - }, - }, - - // ── Processes ────────────────────────────────────────────────────────────── - - { - method: 'GET', - path: '/api/processes', - handler: ({ res }) => ok(res, processManager.getStates()), - }, - { - method: 'GET', - path: '/api/processes/log', - handler: ({ res }) => ok(res, processManager.getActivityLog()), - }, - { - method: 'POST', - path: '/api/processes/:id/start', - handler: ({ res, params }) => { - const p = getAllProfiles().find((p) => p.id === params.id) - if (!p) return err(res, 'Profile not found', 404) - ok(res, processManager.start(p)) - }, - }, - { - method: 'POST', - path: '/api/processes/:id/stop', - handler: ({ res, params }) => ok(res, processManager.stop(params.id)), - }, - { - method: 'POST', - path: '/api/processes/:id/console/clear', - handler: ({ res, params }) => { - processManager.clearConsoleForProfile(params.id) - ok(res) - }, - }, - - // ── Settings ─────────────────────────────────────────────────────────────── - - { - method: 'GET', - path: '/api/settings', - handler: ({ res }) => ok(res, getSettings()), - }, - { - method: 'PUT', - path: '/api/settings', - handler: ({ res, body }) => { - const updated: AppSettings = { ...getSettings(), ...(body as Partial) } - saveSettings(updated) - ok(res, updated) - }, - }, -] +function compileRoutes(): CompiledRoute[] { + return Object.entries(routes).map(([_, r]) => ({ + ...r, + ...parsePattern(r.path), + })); +} -const compiled = routes.map((r) => ({ ...r, ...parsePattern(r.path) })) +// ─── Server ─────────────────────────────────────────────────────────────────── class RestApiServer { - private server: http.Server | null = null + private server: http.Server | null = null; + private compiled = compileRoutes(); start(port: number): void { - if (this.server) return + if (this.server) return; this.server = http.createServer(async (req, res) => { if (req.method === 'OPTIONS') { @@ -222,38 +84,42 @@ class RestApiServer { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', - }) - return res.end() + }); + return res.end(); } - const url = req.url?.split('?')[0] ?? '/' - const method = req.method ?? 'GET' - const body = ['POST', 'PUT', 'PATCH'].includes(method) ? await readBody(req) : {} + const url = req.url?.split('?')[0] ?? '/'; + const method = req.method ?? 'GET'; + const body = + method === 'POST' || method === 'PUT' || method === 'PATCH' ? await readBody(req) : {}; + + for (const route of this.compiled) { + if (route.method !== method) continue; - for (const route of compiled) { - if (route.method !== method) continue - const match = url.match(route.pattern) - if (!match) continue - const params: Params = {} + const match = url.match(route.pattern); + if (!match) continue; + + const params: Params = {}; route.keys.forEach((k, i) => { - params[k] = match[i + 1] - }) - await route.handler({ req, res, params, body }) - return + params[k] = match[i + 1]; + }); + + await route.handler({ req, res, params, body }); + return; } - err(res, 'Not found', 404) - }) + err(res, 'Not found', 404); + }); this.server.listen(port, REST_API_CONFIG.host, () => { - console.log(`[JRC REST] Listening on ${REST_API_CONFIG.host}:${port}`) - }) + console.log(`[JRC REST] Listening on ${REST_API_CONFIG.host}:${port}`); + }); } stop(): void { - this.server?.close() - this.server = null + this.server?.close(); + this.server = null; } } -export const restApiServer = new RestApiServer() +export const restApiServer = new RestApiServer(); diff --git a/src/main/Store.ts b/src/main/Store.ts index eac34b3..6a10ea8 100644 --- a/src/main/Store.ts +++ b/src/main/Store.ts @@ -1,67 +1,78 @@ -import Store from 'electron-store' -import type { Profile, AppSettings } from './shared/types' -import { REST_API_CONFIG } from './shared/config/RestApi.config' +import { app } from 'electron'; +import Store from 'electron-store'; +import { DEFAULT_SETTINGS } from './shared/config/App.config'; +import { Profile } from './shared/types/Profile.types'; +import { AppSettings } from './shared/types/App.types'; interface StoreSchema { - profiles: Profile[] - settings: AppSettings -} - -const DEFAULT_SETTINGS: AppSettings = { - launchOnStartup: false, - startMinimized: false, - minimizeToTray: true, - consoleFontSize: 13, - consoleMaxLines: 5000, - consoleWordWrap: false, - consoleLineNumbers: false, - consoleHistorySize: 200, - theme: 'dark', - restApiEnabled: false, - restApiPort: REST_API_CONFIG.defaultPort, + profiles: Profile[]; + settings: AppSettings; } const store = new Store({ name: 'java-runner-config', defaults: { profiles: [], settings: DEFAULT_SETTINGS }, -}) +}); export function getAllProfiles(): Profile[] { - const profiles = store.get('profiles', []) - return [...profiles].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + const profiles = store.get('profiles', []); + return [...profiles].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } export function saveProfile(profile: Profile): void { - const profiles = getAllProfiles() - const idx = profiles.findIndex((p) => p.id === profile.id) - profile.updatedAt = Date.now() - if (idx >= 0) profiles[idx] = profile + const profiles = getAllProfiles(); + const idx = profiles.findIndex((p) => p.id === profile.id); + profile.updatedAt = Date.now(); + if (idx >= 0) profiles[idx] = profile; else { - profile.order = profiles.length - profiles.push(profile) + profile.order = profiles.length; + profiles.push(profile); } - store.set('profiles', profiles) + store.set('profiles', profiles); } export function deleteProfile(id: string): void { store.set( 'profiles', getAllProfiles().filter((p) => p.id !== id) - ) + ); } export function reorderProfiles(orderedIds: string[]): void { - const profiles = getAllProfiles() + const profiles = getAllProfiles(); const updated = profiles.map((p) => ({ ...p, order: orderedIds.indexOf(p.id), - })) - store.set('profiles', updated) + })); + store.set('profiles', updated); +} + +export function toggleDevMode(enabled: boolean): void { + const settings = getSettings(); + settings.devModeEnabled = enabled; + saveSettings(settings); } export function getSettings(): AppSettings { - return { ...DEFAULT_SETTINGS, ...store.get('settings', DEFAULT_SETTINGS) } + return { ...DEFAULT_SETTINGS, ...store.get('settings', DEFAULT_SETTINGS) }; } + export function saveSettings(settings: AppSettings): void { - store.set('settings', settings) + const prev = getSettings(); + store.set('settings', settings); + if ( + settings.launchOnStartup !== prev.launchOnStartup || + settings.startMinimized !== prev.startMinimized + ) { + syncLoginItem(settings.launchOnStartup, settings.startMinimized); + } +} + +export function syncLoginItem(openAtLogin: boolean, startMinimized: boolean): void { + if (!app.isPackaged) return; + const args = ['--autostart', startMinimized && '--minimized'].filter(Boolean) as string[]; + app.setLoginItemSettings({ + openAtLogin, + args, + }); } diff --git a/src/main/ipc/Dev.ipc.ts b/src/main/ipc/Dev.ipc.ts new file mode 100644 index 0000000..840ff7b --- /dev/null +++ b/src/main/ipc/Dev.ipc.ts @@ -0,0 +1,40 @@ +import { BrowserWindow } from 'electron'; +import { DEFAULT_SETTINGS } from '../shared/config/App.config'; +import type { RouteMap } from '../IPCController'; +import { getAllProfiles, getSettings, toggleDevMode } from '../Store'; + +let getWindow: () => BrowserWindow | null = () => null; + +export function initDevIPC(windowGetter: () => BrowserWindow | null) { + getWindow = windowGetter; +} + +export const DevIPC = { + getSysInfo: { + type: 'invoke', + channel: 'dev:getSysInfo', + handler: () => ({ + platform: process.platform, + arch: process.arch, + nodeVersion: process.versions.node, + electronVersion: process.versions.electron, + argv: process.argv, + chromeVersion: process.versions.chrome, + uptime: Math.floor(process.uptime()), + memoryUsageMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), + }), + }, + + resetStore: { + type: 'invoke', + channel: 'dev:resetStore', + handler: async () => { + // Remove all profiles and reset settings to defaults + const profiles = getAllProfiles(); + const Store = (await import('electron-store')).default; + const store = new Store({ name: 'java-runner-config' }); + store.set('profiles', []); + store.set('settings', DEFAULT_SETTINGS); + }, + }, +} satisfies RouteMap; diff --git a/src/main/ipc/Environment.ipc.ts b/src/main/ipc/Environment.ipc.ts new file mode 100644 index 0000000..3d9ebbb --- /dev/null +++ b/src/main/ipc/Environment.ipc.ts @@ -0,0 +1,41 @@ +import { BrowserWindow } from 'electron'; +import { getEnvironment, loadEnvironment } from '../JRCEnvironment'; +import { RouteMap } from '../IPCController'; +import { JRCEnvironment } from '../shared/types/App.types'; +import { toggleDevMode } from '../Store'; + +export const EnvironmentIPC = { + get: { + type: 'invoke', + channel: 'env:get', + handler: () => getEnvironment(), + }, + + reload: { + type: 'send', + channel: 'env:reload', + handler: () => loadEnvironment(), + }, + + change: { + type: 'on', + channel: 'env:changed', + args: {} as (env: JRCEnvironment) => void, + }, + + toggleDevMode: { + type: 'invoke', + channel: 'env:toggleDevMode', + handler: (_e: Electron.IpcMainInvokeEvent, enabled: boolean) => { + const win = BrowserWindow.getAllWindows()[0]; + if (!win) return; + + toggleDevMode(enabled); + loadEnvironment(); + + if (!enabled) { + win.webContents.closeDevTools(); + } + }, + }, +} satisfies RouteMap; diff --git a/src/main/ipc/GitHub.ipc.ts b/src/main/ipc/GitHub.ipc.ts index 905e9ec..9245349 100644 --- a/src/main/ipc/GitHub.ipc.ts +++ b/src/main/ipc/GitHub.ipc.ts @@ -1,49 +1,49 @@ -import fs from 'fs' -import https from 'https' -import { dialog, shell, BrowserWindow } from 'electron' -import type { RouteMap } from '../shared/IPCController' -import { latestReleaseUrl, templateListUrl, rawTemplateUrl } from '../shared/config/GitHub.config' -import type { GitHubRelease, ProfileTemplate } from '../shared/GitHub.types' +import fs from 'fs'; +import https from 'https'; +import { dialog, shell, BrowserWindow } from 'electron'; +import type { RouteMap } from '../IPCController'; +import { latestReleaseUrl, templateListUrl, rawTemplateUrl } from '../shared/config/GitHub.config'; +import type { GitHubRelease, ProfileTemplate } from '../shared/types/GitHub.types'; function httpsGet(url: string): Promise { return new Promise((resolve, reject) => { - const options = { headers: { 'User-Agent': 'java-runner-client' } } + const options = { headers: { 'User-Agent': 'java-runner-client' } }; const req = https.get(url, options, (res) => { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - resolve(httpsGet(res.headers.location)) - return + resolve(httpsGet(res.headers.location)); + return; } - let data = '' + let data = ''; res.on('data', (c) => { - data += c - }) + data += c; + }); res.on('end', () => { try { - resolve(JSON.parse(data)) + resolve(JSON.parse(data)); } catch { - reject(new Error('JSON parse error')) + reject(new Error('JSON parse error')); } - }) - }) - req.on('error', reject) + }); + }); + req.on('error', reject); req.setTimeout(10000, () => { - req.destroy() - reject(new Error('Timeout')) - }) - }) + req.destroy(); + reject(new Error('Timeout')); + }); + }); } interface ActiveDownload { - req: ReturnType - res: import('http').IncomingMessage - fileStream: fs.WriteStream - filePath: string - bytesWritten: number - totalBytes: number - paused: boolean + req: ReturnType; + res: import('http').IncomingMessage; + fileStream: fs.WriteStream; + filePath: string; + bytesWritten: number; + totalBytes: number; + paused: boolean; } -const activeDownloads = new Map() +const activeDownloads = new Map(); export const GitHubIPC = { fetchLatestRelease: { @@ -51,9 +51,9 @@ export const GitHubIPC = { channel: 'github:latestRelease', handler: async () => { try { - return { ok: true, data: (await httpsGet(latestReleaseUrl())) as GitHubRelease } + return { ok: true, data: (await httpsGet(latestReleaseUrl())) as GitHubRelease }; } catch (e) { - return { ok: false, error: String(e) } + return { ok: false, error: String(e) }; } }, }, @@ -63,23 +63,23 @@ export const GitHubIPC = { channel: 'github:templates', handler: async () => { try { - const raw = await httpsGet(templateListUrl()) + const raw = await httpsGet(templateListUrl()); if (!Array.isArray(raw)) - return { ok: false, error: 'Templates folder not found or repo not configured' } - const templates: Array<{ filename: string; template: ProfileTemplate }> = [] + return { ok: false, error: 'Templates folder not found or repo not configured' }; + const templates: Array<{ filename: string; template: ProfileTemplate }> = []; for (const f of (raw as Array<{ name: string }>).filter((f) => f.name.endsWith('.json'))) { try { templates.push({ filename: f.name, template: (await httpsGet(rawTemplateUrl(f.name))) as ProfileTemplate, - }) + }); } catch { /* skip malformed */ } } - return { ok: true, data: templates } + return { ok: true, data: templates }; } catch (e) { - return { ok: false, error: String(e) } + return { ok: false, error: String(e) }; } }, }, @@ -91,17 +91,17 @@ export const GitHubIPC = { const { canceled, filePath } = await dialog.showSaveDialog({ defaultPath: filename, filters: [{ name: 'Installer', extensions: ['exe', 'dmg', 'AppImage', 'deb', '*'] }], - }) - if (canceled || !filePath) return { ok: false } + }); + if (canceled || !filePath) return { ok: false }; - const { sender } = e + const { sender } = e; const sendProgress = ( dl: Pick, status: 'downloading' | 'paused' | 'done' | 'error' | 'cancelled', error?: string ) => { - if (sender.isDestroyed()) return - const { bytesWritten, totalBytes } = dl + if (sender.isDestroyed()) return; + const { bytesWritten, totalBytes } = dl; sender.send('github:downloadProgress', { filename, bytesWritten, @@ -109,12 +109,12 @@ export const GitHubIPC = { percent: totalBytes > 0 ? Math.round((bytesWritten / totalBytes) * 100) : 0, status, error, - }) - } + }); + }; return new Promise<{ ok: boolean; error?: string }>((resolve) => { const doRequest = (requestUrl: string) => { - const options = { headers: { 'User-Agent': 'java-runner-client' } } + const options = { headers: { 'User-Agent': 'java-runner-client' } }; const req = https.get(requestUrl, options, (res) => { // Follow redirect if ( @@ -123,12 +123,12 @@ export const GitHubIPC = { res.statusCode < 400 && res.headers.location ) { - doRequest(res.headers.location) - return + doRequest(res.headers.location); + return; } - const totalBytes = parseInt(res.headers['content-length'] ?? '0', 10) - const file = fs.createWriteStream(filePath) + const totalBytes = parseInt(res.headers['content-length'] ?? '0', 10); + const file = fs.createWriteStream(filePath); const dl: ActiveDownload = { req, @@ -138,47 +138,47 @@ export const GitHubIPC = { bytesWritten: 0, totalBytes, paused: false, - } - activeDownloads.set(filename, dl) + }; + activeDownloads.set(filename, dl); - sendProgress(dl, 'downloading') + sendProgress(dl, 'downloading'); res.on('data', (chunk: Buffer) => { - dl.bytesWritten += chunk.length - file.write(chunk) - sendProgress(dl, dl.paused ? 'paused' : 'downloading') - }) + dl.bytesWritten += chunk.length; + file.write(chunk); + sendProgress(dl, dl.paused ? 'paused' : 'downloading'); + }); res.on('end', () => { - file.end() - }) + file.end(); + }); file.on('finish', () => { - file.close() - activeDownloads.delete(filename) - sendProgress({ bytesWritten: dl.totalBytes, totalBytes: dl.totalBytes }, 'done') - shell.showItemInFolder(filePath) - resolve({ ok: true }) - }) + file.close(); + activeDownloads.delete(filename); + sendProgress({ bytesWritten: dl.totalBytes, totalBytes: dl.totalBytes }, 'done'); + shell.showItemInFolder(filePath); + resolve({ ok: true }); + }); req.on('error', (err) => { - activeDownloads.delete(filename) - fs.unlink(filePath, () => {}) - sendProgress(dl, 'error', err.message) - resolve({ ok: false, error: err.message }) - }) + activeDownloads.delete(filename); + fs.unlink(filePath, () => {}); + sendProgress(dl, 'error', err.message); + resolve({ ok: false, error: err.message }); + }); file.on('error', (err) => { - activeDownloads.delete(filename) - fs.unlink(filePath, () => {}) - sendProgress(dl, 'error', err.message) - resolve({ ok: false, error: err.message }) - }) - }) - } + activeDownloads.delete(filename); + fs.unlink(filePath, () => {}); + sendProgress(dl, 'error', err.message); + resolve({ ok: false, error: err.message }); + }); + }); + }; - doRequest(url) - }) + doRequest(url); + }); }, }, @@ -186,10 +186,10 @@ export const GitHubIPC = { type: 'invoke', channel: 'github:pauseDownload', handler: async (e: Electron.IpcMainInvokeEvent, filename: string) => { - const dl = activeDownloads.get(filename) - if (!dl) return { ok: false, error: 'No active download' } - dl.res.pause() - dl.paused = true + const dl = activeDownloads.get(filename); + if (!dl) return { ok: false, error: 'No active download' }; + dl.res.pause(); + dl.paused = true; if (!e.sender.isDestroyed()) { e.sender.send('github:downloadProgress', { filename, @@ -197,9 +197,9 @@ export const GitHubIPC = { totalBytes: dl.totalBytes, percent: dl.totalBytes > 0 ? Math.round((dl.bytesWritten / dl.totalBytes) * 100) : 0, status: 'paused', - }) + }); } - return { ok: true } + return { ok: true }; }, }, @@ -207,10 +207,10 @@ export const GitHubIPC = { type: 'invoke', channel: 'github:resumeDownload', handler: async (e: Electron.IpcMainInvokeEvent, filename: string) => { - const dl = activeDownloads.get(filename) - if (!dl) return { ok: false, error: 'No active download' } - dl.res.resume() - dl.paused = false + const dl = activeDownloads.get(filename); + if (!dl) return { ok: false, error: 'No active download' }; + dl.res.resume(); + dl.paused = false; if (!e.sender.isDestroyed()) { e.sender.send('github:downloadProgress', { filename, @@ -218,9 +218,9 @@ export const GitHubIPC = { totalBytes: dl.totalBytes, percent: dl.totalBytes > 0 ? Math.round((dl.bytesWritten / dl.totalBytes) * 100) : 0, status: 'downloading', - }) + }); } - return { ok: true } + return { ok: true }; }, }, @@ -228,12 +228,12 @@ export const GitHubIPC = { type: 'invoke', channel: 'github:cancelDownload', handler: async (e: Electron.IpcMainInvokeEvent, filename: string) => { - const dl = activeDownloads.get(filename) - if (!dl) return { ok: false, error: 'No active download' } - dl.res.destroy() - dl.fileStream.close() - fs.unlink(dl.filePath, () => {}) - activeDownloads.delete(filename) + const dl = activeDownloads.get(filename); + if (!dl) return { ok: false, error: 'No active download' }; + dl.res.destroy(); + dl.fileStream.close(); + fs.unlink(dl.filePath, () => {}); + activeDownloads.delete(filename); if (!e.sender.isDestroyed()) { e.sender.send('github:downloadProgress', { filename, @@ -241,9 +241,9 @@ export const GitHubIPC = { totalBytes: dl.totalBytes, percent: dl.totalBytes > 0 ? Math.round((dl.bytesWritten / dl.totalBytes) * 100) : 0, status: 'cancelled', - }) + }); } - return { ok: true } + return { ok: true }; }, }, @@ -251,12 +251,12 @@ export const GitHubIPC = { type: 'on', channel: 'github:downloadProgress', args: {} as (progress: { - filename: string - bytesWritten: number - totalBytes: number - percent: number - status: 'downloading' | 'paused' | 'done' | 'error' | 'cancelled' - error?: string + filename: string; + bytesWritten: number; + totalBytes: number; + percent: number; + status: 'downloading' | 'paused' | 'done' | 'error' | 'cancelled'; + error?: string; }) => void, }, -} satisfies RouteMap +} satisfies RouteMap; diff --git a/src/main/ipc/Process.ipc.ts b/src/main/ipc/Process.ipc.ts index ebd6939..c475dfb 100644 --- a/src/main/ipc/Process.ipc.ts +++ b/src/main/ipc/Process.ipc.ts @@ -1,6 +1,7 @@ -import type { RouteMap } from '../shared/IPCController' -import { processManager } from '../ProcessManager' -import type { Profile, ProcessState, ConsoleLine } from '../shared/types' +import type { RouteMap } from '../IPCController'; +import { processManager } from '../ProcessManager'; +import { Profile } from '../shared/types/Profile.types'; +import { ConsoleLine, ProcessState } from '../shared/types/Process.types'; export const ProcessIPC = { startProcess: { @@ -62,4 +63,4 @@ export const ProcessIPC = { channel: 'process:statesUpdate', args: {} as (states: ProcessState[]) => void, }, -} satisfies RouteMap +} satisfies RouteMap; diff --git a/src/main/ipc/Profile.ipc.ts b/src/main/ipc/Profile.ipc.ts index 513081b..f69036b 100644 --- a/src/main/ipc/Profile.ipc.ts +++ b/src/main/ipc/Profile.ipc.ts @@ -1,7 +1,7 @@ -import type { RouteMap } from '../shared/IPCController' -import { getAllProfiles, saveProfile, deleteProfile, reorderProfiles } from '../Store' -import { processManager } from '../ProcessManager' -import type { Profile } from '../shared/types' +import type { RouteMap } from '../IPCController'; +import { getAllProfiles, saveProfile, deleteProfile, reorderProfiles } from '../Store'; +import { processManager } from '../ProcessManager'; +import type { Profile } from '../shared/types/Profile.types'; export const ProfileIPC = { getProfiles: { type: 'invoke', channel: 'profiles:getAll', handler: () => getAllProfiles() }, @@ -20,8 +20,8 @@ export const ProfileIPC = { type: 'invoke', channel: 'profiles:save', handler: (_e: any, profile: Profile) => { - saveProfile(profile) - processManager.updateProfileSnapshot(profile) + saveProfile(profile); + processManager.updateProfileSnapshot(profile); }, }, -} satisfies RouteMap +} satisfies RouteMap; diff --git a/src/main/ipc/System.ipc.ts b/src/main/ipc/System.ipc.ts index 1e4f002..5c88b69 100644 --- a/src/main/ipc/System.ipc.ts +++ b/src/main/ipc/System.ipc.ts @@ -1,13 +1,14 @@ -import { dialog, shell } from 'electron' -import type { RouteMap } from '../shared/IPCController' -import { getSettings, saveSettings } from '../Store' -import { restApiServer } from '../RestAPI' -import type { AppSettings } from '../shared/types' +import { dialog, shell } from 'electron'; +import { restApiServer } from '../RestAPI'; +import type { RouteMap } from '../IPCController'; +import type { AppSettings, JRCEnvironment } from '../shared/types/App.types'; +import { getSettings, saveSettings } from '../Store'; +import { getEnvironment } from './../JRCEnvironment'; // mainWindow is needed for dialogs — set via initSystemIPC() called from main.ts -let getWindow: () => Electron.BrowserWindow | null = () => null +let getWindow: () => Electron.BrowserWindow | null = () => null; export function initSystemIPC(windowGetter: () => Electron.BrowserWindow | null) { - getWindow = windowGetter + getWindow = windowGetter; } export const SystemIPC = { @@ -17,13 +18,13 @@ export const SystemIPC = { type: 'invoke', channel: 'settings:save', handler: (_e: any, next: AppSettings) => { - const prev = getSettings() - saveSettings(next) - if (!next.restApiEnabled && prev.restApiEnabled) restApiServer.stop() - else if (next.restApiEnabled && !prev.restApiEnabled) restApiServer.start(next.restApiPort) + const prev = getSettings(); + saveSettings(next); + if (!next.restApiEnabled && prev.restApiEnabled) restApiServer.stop(); + else if (next.restApiEnabled && !prev.restApiEnabled) restApiServer.start(next.restApiPort); else if (next.restApiEnabled && next.restApiPort !== prev.restApiPort) { - restApiServer.stop() - restApiServer.start(next.restApiPort) + restApiServer.stop(); + restApiServer.start(next.restApiPort); } }, }, @@ -35,16 +36,16 @@ export const SystemIPC = { const r = await dialog.showOpenDialog(getWindow()!, { filters: [{ name: 'JAR', extensions: ['jar'] }], properties: ['openFile'], - }) - return r.canceled ? null : r.filePaths[0] + }); + return r.canceled ? null : r.filePaths[0]; }, }, pickDir: { type: 'invoke', channel: 'dialog:pickDir', handler: async () => { - const r = await dialog.showOpenDialog(getWindow()!, { properties: ['openDirectory'] }) - return r.canceled ? null : r.filePaths[0] + const r = await dialog.showOpenDialog(getWindow()!, { properties: ['openDirectory'] }); + return r.canceled ? null : r.filePaths[0]; }, }, pickJava: { @@ -54,8 +55,8 @@ export const SystemIPC = { const r = await dialog.showOpenDialog(getWindow()!, { filters: [{ name: 'Executable', extensions: ['exe', '*'] }], properties: ['openFile'], - }) - return r.canceled ? null : r.filePaths[0] + }); + return r.canceled ? null : r.filePaths[0]; }, }, @@ -64,4 +65,4 @@ export const SystemIPC = { channel: 'shell:openExternal', handler: (_e: any, url: string) => shell.openExternal(url), }, -} satisfies RouteMap +} satisfies RouteMap; diff --git a/src/main/ipc/Window.ipc.ts b/src/main/ipc/Window.ipc.ts index b303d4b..7cc2eb6 100644 --- a/src/main/ipc/Window.ipc.ts +++ b/src/main/ipc/Window.ipc.ts @@ -1,17 +1,17 @@ -import { app } from 'electron' -import type { RouteMap } from '../shared/IPCController' -import { getSettings } from '../Store' +import { app } from 'electron'; +import type { RouteMap } from '../IPCController'; +import { getSettings } from '../Store'; // Injected from main.ts — avoids a circular import on mainWindow/forceQuit -let getWindow: () => Electron.BrowserWindow | null = () => null -let setForceQuit: () => void = () => {} +let getWindow: () => Electron.BrowserWindow | null = () => null; +let setForceQuit: () => void = () => {}; export function initWindowIPC( windowGetter: () => Electron.BrowserWindow | null, forceQuitSetter: () => void ) { - getWindow = windowGetter - setForceQuit = forceQuitSetter + getWindow = windowGetter; + setForceQuit = forceQuitSetter; } export const WindowIPC = { @@ -25,11 +25,11 @@ export const WindowIPC = { type: 'send', channel: 'window:close', handler: () => { - if (getSettings().minimizeToTray) getWindow()?.hide() + if (getSettings().minimizeToTray) getWindow()?.hide(); else { - setForceQuit() - app.quit() + setForceQuit(); + app.quit(); } }, }, -} satisfies RouteMap +} satisfies RouteMap; diff --git a/src/main/ipc/_index.ts b/src/main/ipc/_index.ts index bddd5f5..dfec313 100644 --- a/src/main/ipc/_index.ts +++ b/src/main/ipc/_index.ts @@ -1,3 +1,4 @@ +import { EnvironmentIPC } from './Environment.ipc'; /** * Central IPC registry. * @@ -7,22 +8,30 @@ * 3. That's it — main, preload, and types all update automatically */ -export { GitHubIPC } from './GitHub.ipc' -export { ProcessIPC } from './Process.ipc' -export { ProfileIPC } from './Profile.ipc' -export { SystemIPC, initSystemIPC } from './System.ipc' -export { WindowIPC, initWindowIPC } from './Window.ipc' +export { GitHubIPC } from './GitHub.ipc'; +export { ProcessIPC } from './Process.ipc'; +export { ProfileIPC } from './Profile.ipc'; +export { SystemIPC, initSystemIPC } from './System.ipc'; +export { WindowIPC, initWindowIPC } from './Window.ipc'; +export { DevIPC, initDevIPC } from './Dev.ipc'; -import { GitHubIPC } from './GitHub.ipc' -import { ProcessIPC } from './Process.ipc' -import { ProfileIPC } from './Profile.ipc' -import { SystemIPC } from './System.ipc' -import { WindowIPC } from './Window.ipc' -import type { InferAPI } from '../shared/IPCController' +import { GitHubIPC } from './GitHub.ipc'; +import { ProcessIPC } from './Process.ipc'; +import { ProfileIPC } from './Profile.ipc'; +import { SystemIPC } from './System.ipc'; +import { WindowIPC } from './Window.ipc'; +import { DevIPC } from './Dev.ipc'; +import type { InferAPI } from '../IPCController'; -export const allRoutes = [GitHubIPC, ProcessIPC, ProfileIPC, SystemIPC, WindowIPC] as const +export const allRoutes = [GitHubIPC, ProcessIPC, ProfileIPC, SystemIPC, WindowIPC, DevIPC] as const; -// The full inferred window.api type — used in global.d.ts export type API = InferAPI< - typeof GitHubIPC & typeof ProcessIPC & typeof ProfileIPC & typeof SystemIPC & typeof WindowIPC -> + typeof GitHubIPC & + typeof ProcessIPC & + typeof ProfileIPC & + typeof SystemIPC & + typeof WindowIPC & + typeof DevIPC +>; + +export type Environment = InferAPI; diff --git a/src/main/main.ts b/src/main/main.ts index 4aa275a..2fbc4cc 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,42 +1,39 @@ -import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron' -import path from 'path' -import fs from 'fs' -import { getAllProfiles, getSettings } from './Store' -import { processManager } from './ProcessManager' -import { restApiServer } from './RestAPI' -import { registerIPC } from './shared/IPCController' -import { allRoutes, initSystemIPC, initWindowIPC } from './ipc/_index' - -const IS_DEV = !app.isPackaged -const RESOURCES = IS_DEV - ? path.join(__dirname, '../../resources') - : path.join(app.getAppPath(), 'resources') -const isActiveLaunch = - !process.argv.includes('--hidden') && !process.argv.includes('--squirrel-firstrun') -const DEBUG = true - -const actualLog = console.log -console.log = - IS_DEV && DEBUG ? (...args) => actualLog(`[${new Date().toISOString()}]`, ...args) : () => {} +import { app, BrowserWindow, Input, Menu, nativeImage, Tray } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import { allRoutes, initDevIPC, initSystemIPC, initWindowIPC } from './ipc/_index'; +import { EnvironmentIPC } from './ipc/Environment.ipc'; +import { getEnvironment, loadEnvironment, shouldStartMinimized } from './JRCEnvironment'; +import { processManager } from './ProcessManager'; +import { restApiServer } from './RestAPI'; +import { registerIPC } from './IPCController'; +import { getAllProfiles, getSettings, syncLoginItem } from './Store'; + +loadEnvironment(); + +const RESOURCES = + getEnvironment().type === 'dev' + ? path.join(__dirname, '../../resources') + : path.join(app.getAppPath(), 'resources'); function getIconImage(): Electron.NativeImage { const candidates = - process.platform === 'win32' ? ['icon.ico', 'icon.png'] : ['icon.png', 'icon.ico'] + process.platform === 'win32' ? ['icon.ico', 'icon.png'] : ['icon.png', 'icon.ico']; for (const name of candidates) { - const p = path.join(RESOURCES, name) + const p = path.join(RESOURCES, name); if (fs.existsSync(p)) { - const img = nativeImage.createFromPath(p) - if (!img.isEmpty()) return img + const img = nativeImage.createFromPath(p); + if (!img.isEmpty()) return img; } } return nativeImage.createFromDataURL( 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' - ) + ); } -let mainWindow: BrowserWindow | null = null -let tray: Tray | null = null -let forceQuit = false +let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; +let forceQuit = false; function createWindow(): void { mainWindow = new BrowserWindow({ @@ -47,60 +44,61 @@ function createWindow(): void { frame: false, backgroundColor: '#08090d', icon: getIconImage(), - show: IS_DEV ? true : false, + show: getEnvironment().startUpSource !== 'withSystem', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false, + devTools: true, }, - }) + }); - if (IS_DEV) mainWindow.loadURL('http://localhost:5173') - else mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) + if (getEnvironment().type === 'dev') mainWindow.loadURL('http://localhost:5173'); + else mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); mainWindow.once('ready-to-show', () => { - const shouldStartHidden = getSettings().startMinimized && !IS_DEV && !isActiveLaunch - if (shouldStartHidden) mainWindow?.hide() - else mainWindow?.show() - }) + const shouldStartHidden = shouldStartMinimized(); + if (shouldStartHidden) mainWindow?.hide(); + else mainWindow?.show(); + }); mainWindow.on('close', (e) => { - if (forceQuit) return - if (getSettings().minimizeToTray && !isActiveLaunch) { - e.preventDefault() - mainWindow?.hide() + if (forceQuit) return; + if (getSettings().minimizeToTray) { + e.preventDefault(); + mainWindow?.hide(); } - }) + }); - processManager.setWindow(mainWindow) + processManager.setWindow(mainWindow); } function createTray(): void { - tray = new Tray(getIconImage().resize({ width: 16, height: 16 })) - tray.setToolTip('Java Runner Client') - updateTrayMenu() + tray = new Tray(getIconImage().resize({ width: 16, height: 16 })); + tray.setToolTip('Java Runner Client'); + updateTrayMenu(); tray.on('double-click', () => { - mainWindow?.show() - mainWindow?.focus() - }) + mainWindow?.show(); + mainWindow?.focus(); + }); } function updateTrayMenu(): void { - if (!tray) return - const states = processManager.getStates() - const profiles = getAllProfiles() + if (!tray) return; + const states = processManager.getStates(); + const profiles = getAllProfiles(); const items = states.map((s) => ({ label: ` ${profiles.find((p) => p.id === s.profileId)?.name ?? s.profileId} (PID ${s.pid ?? '?'})`, enabled: false, - })) + })); tray.setContextMenu( Menu.buildFromTemplate([ { label: 'Open Java Runner Client', click: () => { - mainWindow?.show() - mainWindow?.focus() + mainWindow?.show(); + mainWindow?.focus(); }, }, { type: 'separator' }, @@ -110,57 +108,97 @@ function updateTrayMenu(): void { { label: 'Quit', click: () => { - forceQuit = true - app.quit() + forceQuit = true; + app.quit(); }, }, ]) - ) + ); } -const gotLock = app.requestSingleInstanceLock() +let devToolsPressCount = 0; +let devToolsTimer: NodeJS.Timeout | null = null; + +const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { - app.quit() + app.quit(); } else { app.on('second-instance', () => { if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.show() - mainWindow.focus() + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); } - }) + }); app.whenReady().then(() => { - createWindow() - createTray() + // If launched by the OS at login but the user has since disabled autostart, bail out. + // This handles the edge case where the registry entry outlives the setting. + if (getEnvironment().startUpSource === 'withSystem' && !getSettings().launchOnStartup) return; + + // Ensure the OS login item always reflects the stored setting + syncLoginItem(getSettings().launchOnStartup, getSettings().startMinimized); + + createWindow(); + createTray(); + + mainWindow?.webContents.on('before-input-event', handleBeforeInputEvent); // ── IPC ──────────────────────────────────────────────────────────────────── - initSystemIPC(() => mainWindow) + initSystemIPC(() => mainWindow); initWindowIPC( () => mainWindow, () => { - forceQuit = true + forceQuit = true; } - ) - registerIPC([...allRoutes]) + ); + initDevIPC(() => mainWindow); + registerIPC([...allRoutes]); + registerIPC([EnvironmentIPC]); // ────────────────────────────────────────────────────────────────────────── - const settings = getSettings() - if (settings.restApiEnabled) restApiServer.start(settings.restApiPort) - for (const p of getAllProfiles()) if (p.autoStart && p.jarPath) processManager.start(p) + const settings = getSettings(); + if (settings.restApiEnabled) restApiServer.start(settings.restApiPort); + for (const p of getAllProfiles()) if (p.autoStart && p.jarPath) processManager.start(p); - mainWindow?.webContents.on('did-finish-load', updateTrayMenu) - processManager.setTrayUpdater(updateTrayMenu) - }) + mainWindow?.webContents.on('did-finish-load', updateTrayMenu); + processManager.setTrayUpdater(updateTrayMenu); + }); } app.on('window-all-closed', () => { /* keep alive in tray */ -}) +}); app.on('before-quit', () => { - forceQuit = true -}) + forceQuit = true; +}); app.on('activate', () => { - mainWindow?.show() -}) + mainWindow?.show(); +}); + +const handleBeforeInputEvent = (event: Electron.Event, input: Input) => { + const isDevToolsShortcut = + input.key === 'F12' || + (input.control && input.shift && input.key.toUpperCase() === 'I') || + (input.meta && input.alt && input.key.toUpperCase() === 'I'); + + if (!isDevToolsShortcut) return; + + event.preventDefault(); + + devToolsPressCount++; + + if (devToolsTimer) clearTimeout(devToolsTimer); + devToolsTimer = setTimeout(() => (devToolsPressCount = 0), 1000); + + if (devToolsPressCount >= 7) { + devToolsPressCount = 0; + mainWindow?.webContents.openDevTools({ mode: 'detach' }); + return; + } + + if (getEnvironment().devMode) { + mainWindow?.webContents.openDevTools(); + } +}; diff --git a/src/main/preload.ts b/src/main/preload.ts index 5eedd04..49769fa 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,5 +1,8 @@ -import { contextBridge } from 'electron' -import { buildPreloadAPI } from './shared/IPCController' -import { allRoutes } from './ipc/_index' +import { contextBridge } from 'electron'; +import { allRoutes } from './ipc/_index'; +import { EnvironmentIPC } from './ipc/Environment.ipc'; +import { buildPreloadAPI } from './IPCController'; -contextBridge.exposeInMainWorld('api', buildPreloadAPI([...allRoutes])) +contextBridge.exposeInMainWorld('api', buildPreloadAPI([...allRoutes])); + +contextBridge.exposeInMainWorld('env', buildPreloadAPI([EnvironmentIPC])); diff --git a/src/main/shared/GitHub.types.ts b/src/main/shared/GitHub.types.ts deleted file mode 100644 index 016069f..0000000 --- a/src/main/shared/GitHub.types.ts +++ /dev/null @@ -1,60 +0,0 @@ -export interface GitHubAsset { - id: number - name: string - label: string | null - content_type: string - state: string - size: number - download_count: number - browser_download_url: string - created_at: string - updated_at: string -} - -export interface GitHubRelease { - id: number - tag_name: string - name: string | null - body: string | null - draft: boolean - prerelease: boolean - created_at: string - published_at: string - html_url: string - tarball_url: string - zipball_url: string - assets: GitHubAsset[] - author: { - login: string - avatar_url: string - html_url: string - } -} - -export interface ProfileTemplate { - templateVersion: number - minAppVersion: string - name: string - description: string - category: string - tags: string[] - defaults: { - jvmArgs: { value: string; enabled: boolean }[] - systemProperties: { key: string; value: string; enabled: boolean }[] - programArgs: { value: string; enabled: boolean }[] - javaPath: string - autoStart: boolean - autoRestart: boolean - autoRestartInterval: number - color: string - } -} - -export interface DownloadProgress { - filename: string - bytesWritten: number - totalBytes: number - percent: number - status: 'downloading' | 'paused' | 'done' | 'error' | 'cancelled' - error?: string -} diff --git a/src/main/shared/config/API.config.ts b/src/main/shared/config/API.config.ts new file mode 100644 index 0000000..fd683ca --- /dev/null +++ b/src/main/shared/config/API.config.ts @@ -0,0 +1,86 @@ +export const REST_API_CONFIG = { + defaultPort: 4444, + host: '127.0.0.1', +} as const; + +export type RouteDefinition = { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + description: string; + bodyTemplate?: string; +}; + +export const routeConfig = { + status: { + method: 'GET', + path: '/api/status', + description: 'App status + version', + }, + + profiles_list: { + method: 'GET', + path: '/api/profiles', + description: 'List all profiles', + }, + profiles_get: { + method: 'GET', + path: '/api/profiles/:id', + description: 'Get profile by ID', + }, + profiles_create: { + method: 'POST', + path: '/api/profiles', + description: 'Create profile', + bodyTemplate: '{ "name": "New Profile", "jarPath": "" }', + }, + profiles_update: { + method: 'PUT', + path: '/api/profiles/:id', + description: 'Update profile', + }, + profiles_delete: { + method: 'DELETE', + path: '/api/profiles/:id', + description: 'Delete profile', + }, + + processes_list: { + method: 'GET', + path: '/api/processes', + description: 'Running process states', + }, + processes_log: { + method: 'GET', + path: '/api/processes/log', + description: 'Activity log', + }, + processes_start: { + method: 'POST', + path: '/api/processes/:id/start', + description: 'Start process', + }, + processes_stop: { + method: 'POST', + path: '/api/processes/:id/stop', + description: 'Stop process', + }, + processes_clear: { + method: 'POST', + path: '/api/processes/:id/console/clear', + description: 'Clear console', + }, + + settings_get: { + method: 'GET', + path: '/api/settings', + description: 'Get settings', + }, + settings_update: { + method: 'PUT', + path: '/api/settings', + description: 'Update settings', + bodyTemplate: '{ "consoleFontSize": 13 }', + }, +} as const satisfies Record; + +export type RouteKey = keyof typeof routeConfig; diff --git a/src/main/shared/config/App.config.ts b/src/main/shared/config/App.config.ts new file mode 100644 index 0000000..22ab8e4 --- /dev/null +++ b/src/main/shared/config/App.config.ts @@ -0,0 +1,17 @@ +import type { AppSettings } from '../types/App.types'; +import { REST_API_CONFIG } from './API.config'; + +export const DEFAULT_SETTINGS: AppSettings = { + launchOnStartup: false, + startMinimized: false, + minimizeToTray: true, + consoleFontSize: 13, + consoleMaxLines: 5000, + consoleWordWrap: false, + consoleLineNumbers: false, + consoleHistorySize: 200, + theme: 'dark', + restApiEnabled: false, + restApiPort: REST_API_CONFIG.defaultPort, + devModeEnabled: false, +}; diff --git a/src/main/shared/config/FAQ.config.ts b/src/main/shared/config/FAQ.config.ts index 1349bff..feb5611 100644 --- a/src/main/shared/config/FAQ.config.ts +++ b/src/main/shared/config/FAQ.config.ts @@ -1,11 +1,11 @@ export interface FaqItem { - q: string - a: string + q: string; + a: string; } export interface FaqTopic { - id: string - label: string - items: FaqItem[] + id: string; + label: string; + items: FaqItem[]; } export const FAQ_TOPICS: FaqTopic[] = [ @@ -97,4 +97,4 @@ export const FAQ_TOPICS: FaqTopic[] = [ }, ], }, -] +]; diff --git a/src/main/shared/config/GitHub.config.ts b/src/main/shared/config/GitHub.config.ts index 7b3d6f7..2c9875d 100644 --- a/src/main/shared/config/GitHub.config.ts +++ b/src/main/shared/config/GitHub.config.ts @@ -4,20 +4,20 @@ export const GITHUB_CONFIG = { templatesPath: 'profile-templates', templateMinVersion: 1, apiBase: 'https://api.github.com', -} as const +} as const; export function releasesUrl(): string { - return `${GITHUB_CONFIG.apiBase}/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases` + return `${GITHUB_CONFIG.apiBase}/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases`; } export function latestReleaseUrl(): string { - return `${GITHUB_CONFIG.apiBase}/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest` + return `${GITHUB_CONFIG.apiBase}/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`; } export function templateListUrl(): string { - return `${GITHUB_CONFIG.apiBase}/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/contents/${GITHUB_CONFIG.templatesPath}` + return `${GITHUB_CONFIG.apiBase}/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/contents/${GITHUB_CONFIG.templatesPath}`; } export function rawTemplateUrl(filename: string): string { - return `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/main/${GITHUB_CONFIG.templatesPath}/${filename}` + return `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/main/${GITHUB_CONFIG.templatesPath}/${filename}`; } diff --git a/src/main/shared/config/RestApi.config.ts b/src/main/shared/config/RestApi.config.ts deleted file mode 100644 index 9271f4a..0000000 --- a/src/main/shared/config/RestApi.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const REST_API_CONFIG = { - defaultPort: 4444, - host: '127.0.0.1', -} as const - -export const REST_ROUTES = { - // Info - status: '/api/status', - profiles: '/api/profiles', - profile: '/api/profiles/:id', - settings: '/api/settings', - processStates: '/api/processes', - processLog: '/api/processes/log', - - // Actions - profileCreate: '/api/profiles', - profileUpdate: '/api/profiles/:id', - profileDelete: '/api/profiles/:id', - processStart: '/api/processes/:id/start', - processStop: '/api/processes/:id/stop', - consoleClear: '/api/processes/:id/console/clear', - settingsUpdate: '/api/settings', -} as const diff --git a/src/main/shared/config/Scanner.config.ts b/src/main/shared/config/Scanner.config.ts index eb7444a..7adc8ac 100644 --- a/src/main/shared/config/Scanner.config.ts +++ b/src/main/shared/config/Scanner.config.ts @@ -3,4 +3,4 @@ * and excluded from "Kill All Java". * Add entries by exact process name (case-insensitive match). */ -export const PROTECTED_PROCESS_NAMES: string[] = ['Java Runner Client'] +export const PROTECTED_PROCESS_NAMES: string[] = ['Java Runner Client']; diff --git a/src/main/shared/types.ts b/src/main/shared/types.ts deleted file mode 100644 index b746d5f..0000000 --- a/src/main/shared/types.ts +++ /dev/null @@ -1,111 +0,0 @@ -export interface SystemProperty { - key: string - value: string - enabled: boolean -} -export interface JvmArgument { - value: string - enabled: boolean -} -export interface ProgramArgument { - value: string - enabled: boolean -} - -export interface Profile { - id: string - name: string - jarPath: string - workingDir: string - jvmArgs: JvmArgument[] - systemProperties: SystemProperty[] - programArgs: ProgramArgument[] - javaPath: string - autoStart: boolean - color: string - createdAt: number - updatedAt: number - autoRestart: boolean - autoRestartInterval: number - order?: number -} - -export interface AppSettings { - launchOnStartup: boolean - startMinimized: boolean - minimizeToTray: boolean - consoleFontSize: number - consoleMaxLines: number - consoleWordWrap: boolean - consoleLineNumbers: boolean - consoleHistorySize: number - theme: 'dark' - restApiEnabled: boolean - restApiPort: number -} - -export interface ConsoleLine { - id: number - text: string - type: 'stdout' | 'stderr' | 'input' | 'system' - timestamp: number -} - -export interface ProcessState { - profileId: string - running: boolean - pid?: number - startedAt?: number - exitCode?: number -} - -export interface ProcessLogEntry { - id: string - profileId: string - profileName: string - jarPath: string - pid: number - startedAt: number - stoppedAt?: number - exitCode?: number - signal?: string -} - -export interface JavaProcessInfo { - pid: number - name: string - command: string - isJava: boolean - managed: boolean - protected: boolean - memoryMB?: number - startTime?: string - threads?: number - jarName?: string -} - -export const IPC = { - PROFILES_GET_ALL: 'profiles:getAll', - PROFILES_SAVE: 'profiles:save', - PROFILES_DELETE: 'profiles:delete', - PROFILES_REORDER: 'profiles:reorder', - PROCESS_START: 'process:start', - PROCESS_STOP: 'process:stop', - PROCESS_SEND_INPUT: 'process:sendInput', - PROCESS_GET_STATES: 'process:getStates', - PROCESS_GET_LOG: 'process:getLog', - PROCESS_CLEAR_LOG: 'process:clearLog', - PROCESS_SCAN_ALL: 'process:scanAll', - PROCESS_KILL_PID: 'process:killPid', - PROCESS_KILL_ALL_JAVA: 'process:killAllJava', - CONSOLE_LINE: 'console:line', - CONSOLE_CLEAR: 'console:clear', - SETTINGS_GET: 'settings:get', - SETTINGS_SAVE: 'settings:save', - DIALOG_PICK_JAR: 'dialog:pickJar', - DIALOG_PICK_DIR: 'dialog:pickDir', - DIALOG_PICK_JAVA: 'dialog:pickJava', - WINDOW_MINIMIZE: 'window:minimize', - WINDOW_CLOSE: 'window:close', - TRAY_SHOW: 'tray:show', -} as const diff --git a/src/main/shared/types/App.types.ts b/src/main/shared/types/App.types.ts new file mode 100644 index 0000000..06f77ff --- /dev/null +++ b/src/main/shared/types/App.types.ts @@ -0,0 +1,21 @@ +export interface AppSettings { + launchOnStartup: boolean; + startMinimized: boolean; + minimizeToTray: boolean; + consoleFontSize: number; + consoleMaxLines: number; + consoleWordWrap: boolean; + consoleLineNumbers: boolean; + consoleHistorySize: number; + theme: 'dark'; + restApiEnabled: boolean; + restApiPort: number; + devModeEnabled: boolean; +} + +export interface JRCEnvironment { + isReady: boolean; + devMode: boolean; + type: 'dev' | 'prod'; + startUpSource: 'userRequest' | 'withSystem' | 'development'; +} diff --git a/src/main/shared/types/GitHub.types.ts b/src/main/shared/types/GitHub.types.ts new file mode 100644 index 0000000..44cfa76 --- /dev/null +++ b/src/main/shared/types/GitHub.types.ts @@ -0,0 +1,60 @@ +export interface GitHubAsset { + id: number; + name: string; + label: string | null; + content_type: string; + state: string; + size: number; + download_count: number; + browser_download_url: string; + created_at: string; + updated_at: string; +} + +export interface GitHubRelease { + id: number; + tag_name: string; + name: string | null; + body: string | null; + draft: boolean; + prerelease: boolean; + created_at: string; + published_at: string; + html_url: string; + tarball_url: string; + zipball_url: string; + assets: GitHubAsset[]; + author: { + login: string; + avatar_url: string; + html_url: string; + }; +} + +export interface ProfileTemplate { + templateVersion: number; + minAppVersion: string; + name: string; + description: string; + category: string; + tags: string[]; + defaults: { + jvmArgs: { value: string; enabled: boolean }[]; + systemProperties: { key: string; value: string; enabled: boolean }[]; + programArgs: { value: string; enabled: boolean }[]; + javaPath: string; + autoStart: boolean; + autoRestart: boolean; + autoRestartInterval: number; + color: string; + }; +} + +export interface DownloadProgress { + filename: string; + bytesWritten: number; + totalBytes: number; + percent: number; + status: 'downloading' | 'paused' | 'done' | 'error' | 'cancelled'; + error?: string; +} diff --git a/src/main/shared/types/Process.types.ts b/src/main/shared/types/Process.types.ts new file mode 100644 index 0000000..5bd8f7e --- /dev/null +++ b/src/main/shared/types/Process.types.ts @@ -0,0 +1,39 @@ +export interface ProcessState { + profileId: string; + running: boolean; + pid?: number; + startedAt?: number; + exitCode?: number; +} + +export interface ProcessLogEntry { + id: string; + profileId: string; + profileName: string; + jarPath: string; + pid: number; + startedAt: number; + stoppedAt?: number; + exitCode?: number; + signal?: string; +} + +export interface JavaProcessInfo { + pid: number; + name: string; + command: string; + isJava: boolean; + managed: boolean; + protected: boolean; + memoryMB?: number; + startTime?: string; + threads?: number; + jarName?: string; +} + +export interface ConsoleLine { + id: number; + text: string; + type: 'stdout' | 'stderr' | 'input' | 'system'; + timestamp: number; +} diff --git a/src/main/shared/types/Profile.types.ts b/src/main/shared/types/Profile.types.ts new file mode 100644 index 0000000..6a11c49 --- /dev/null +++ b/src/main/shared/types/Profile.types.ts @@ -0,0 +1,31 @@ +export interface SystemProperty { + key: string; + value: string; + enabled: boolean; +} +export interface JvmArgument { + value: string; + enabled: boolean; +} +export interface ProgramArgument { + value: string; + enabled: boolean; +} + +export interface Profile { + id: string; + name: string; + jarPath: string; + workingDir: string; + jvmArgs: JvmArgument[]; + systemProperties: SystemProperty[]; + programArgs: ProgramArgument[]; + javaPath: string; + autoStart: boolean; + color: string; + createdAt: number; + updatedAt: number; + autoRestart: boolean; + autoRestartInterval: number; + order?: number; +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 90982de..2c6e703 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,13 +1,13 @@ -import React from 'react' -import { HashRouter, Routes, Route, Navigate } from 'react-router-dom' -import { AppProvider } from './store/AppStore' -import { TitleBar } from './components/common/TitleBar' -import { MainLayout } from './components/MainLayout' +import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { TitleBar } from './components/common/TitleBar'; +import { DevModeGate } from './components/developer/DevModeGate'; +import { MainLayout } from './components/MainLayout'; +import { AppProvider } from './store/AppStore'; function JavaRunnerFallback() { return (
-
⚠️
+
!

App cannot run

The preload script failed to load, or you are trying to open this app in an incompatible @@ -20,11 +20,11 @@ function JavaRunnerFallback() { window.api is undefined

- ) + ); } export default function App() { - if (!window.api) return + if (!window.api) return ; return ( @@ -34,14 +34,15 @@ export default function App() { v7_relativeSplatPath: false, }} > -
+
} /> } />
+ - ) + ); } diff --git a/src/renderer/components/MainLayout.tsx b/src/renderer/components/MainLayout.tsx index bb1c53e..97b1829 100644 --- a/src/renderer/components/MainLayout.tsx +++ b/src/renderer/components/MainLayout.tsx @@ -1,73 +1,91 @@ -import React, { useEffect } from 'react' -import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom' -import { ProfileSidebar } from './profiles/ProfileSidebar' -import { ConsoleTab } from './console/ConsoleTab' -import { ConfigTab } from './profiles/ConfigTab' -import { ProfileTab } from './profiles/ProfileTab' -import { SettingsTab } from './settings/SettingsTab' -import { UtilitiesTab } from './utils/UtilitiesTab' -import { FaqPanel } from './faq/FaqPanel' -import { useApp } from '../store/AppStore' -import { VscTerminal, VscAccount } from 'react-icons/vsc' -import { LuList } from 'react-icons/lu' +import React, { useEffect, useState } from 'react'; +import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; +import { ProfileSidebar } from './profiles/ProfileSidebar'; +import { ConsoleTab } from './console/ConsoleTab'; +import { ConfigTab } from './profiles/ConfigTab'; +import { ProfileTab } from './profiles/ProfileTab'; +import { SettingsTab } from './settings/SettingsTab'; +import { UtilitiesTab } from './utils/UtilitiesTab'; +import { FaqPanel } from './faq/FaqPanel'; +import { DeveloperTab } from './developer/DeveloperTab'; +import { useApp } from '../store/AppStore'; +import { useDevMode } from '../hooks/useDevMode'; +import { VscTerminal, VscAccount } from 'react-icons/vsc'; +import { LuList } from 'react-icons/lu'; +import { JRCEnvironment } from 'src/main/shared/types/App.types'; const MAIN_TABS = [ { path: 'console', label: 'Console', Icon: VscTerminal }, { path: 'config', label: 'Configure', Icon: LuList }, { path: 'profile', label: 'Profile', Icon: VscAccount }, -] as const +] as const; -const SIDE_PANELS = ['settings', 'faq', 'utilities'] as const -type SidePanel = (typeof SIDE_PANELS)[number] +const SIDE_PANELS = ['settings', 'faq', 'utilities', 'developer'] as const; +type SidePanel = (typeof SIDE_PANELS)[number]; function isSidePanel(seg: string): seg is SidePanel { - return (SIDE_PANELS as readonly string[]).includes(seg) + return (SIDE_PANELS as readonly string[]).includes(seg); } +const PANEL_LABELS: Record = { + settings: 'Application Settings', + faq: 'FAQ', + utilities: 'Utilities', + developer: 'Developer', +}; + export function MainLayout() { - const { state, activeProfile, isRunning, setActiveProfile } = useApp() - const navigate = useNavigate() - const location = useLocation() + const { state, activeProfile, isRunning, setActiveProfile } = useApp(); + const devMode = useDevMode(); + const navigate = useNavigate(); + const location = useLocation(); + + const segments = location.pathname.replace(/^\//, '').split('/'); + const firstSeg = segments[0] ?? 'console'; + const activePanel = isSidePanel(firstSeg) ? firstSeg : null; + const activeTab = activePanel ? 'console' : firstSeg; - const segments = location.pathname.replace(/^\//, '').split('/') - const firstSeg = segments[0] ?? 'console' - const activePanel = isSidePanel(firstSeg) ? firstSeg : null - const activeTab = activePanel ? 'console' : firstSeg + const color = activeProfile?.color ?? '#4ade80'; + const running = activeProfile ? isRunning(activeProfile.id) : false; - const color = activeProfile?.color ?? '#4ade80' - const running = activeProfile ? isRunning(activeProfile.id) : false + // Redirect away from developer panel if dev mode is turned off + useEffect(() => { + if (!devMode && activePanel === 'developer') { + navigate('console', { replace: true }); + } + }, [devMode, activePanel, navigate]); // When profile changes, go to console - const prevIdRef = React.useRef(state.activeProfileId) + const prevIdRef = React.useRef(state.activeProfileId); useEffect(() => { if (state.activeProfileId !== prevIdRef.current) { - prevIdRef.current = state.activeProfileId - if (!activePanel) navigate('console', { replace: true }) + prevIdRef.current = state.activeProfileId; + if (!activePanel) navigate('console', { replace: true }); } - }, [state.activeProfileId, activePanel, navigate]) + }, [state.activeProfileId, activePanel, navigate]); const openPanel = (panel: SidePanel) => { - navigate(activePanel === panel ? 'console' : panel) - } + navigate(activePanel === panel ? 'console' : panel); + }; const handleProfileClick = () => { - if (activePanel) navigate('console') - } + if (activePanel) navigate('console'); + }; return ( -
+
openPanel('settings')} onOpenFaq={() => openPanel('faq')} onOpenUtilities={() => openPanel('utilities')} + onOpenDeveloper={() => openPanel('developer')} onProfileClick={handleProfileClick} activeSidePanel={activePanel} /> -
+
{activePanel ? ( <> - {/* Panel header with back navigation */}
- ) + ); })}
{activeProfile && ( @@ -144,7 +158,7 @@ export function MainLayout() { )}
-
+
} /> } /> @@ -156,5 +170,5 @@ export function MainLayout() { )}
- ) + ); } diff --git a/src/renderer/components/common/ArgList.tsx b/src/renderer/components/common/ArgList.tsx index 7f3dcef..9c3a22d 100644 --- a/src/renderer/components/common/ArgList.tsx +++ b/src/renderer/components/common/ArgList.tsx @@ -1,39 +1,39 @@ -import React, { useState } from 'react' -import { Button } from './Button' +import React, { useState } from 'react'; +import { Button } from './Button'; export interface ArgItem { - value: string - enabled: boolean + value: string; + enabled: boolean; } interface Props { - items: ArgItem[] - onChange: (items: ArgItem[]) => void - onPendingChange?: (hasPending: boolean) => void - placeholder?: string + items: ArgItem[]; + onChange: (items: ArgItem[]) => void; + onPendingChange?: (hasPending: boolean) => void; + placeholder?: string; } export function ArgList({ items, onChange, onPendingChange, placeholder = '--arg' }: Props) { - const [draft, setDraft] = useState('') + const [draft, setDraft] = useState(''); const setDraftAndNotify = (v: string) => { - setDraft(v) - onPendingChange?.(v.trim().length > 0) - } + setDraft(v); + onPendingChange?.(v.trim().length > 0); + }; const add = () => { - const v = draft.trim() - if (!v) return - onChange([...items, { value: v, enabled: true }]) - setDraft('') - onPendingChange?.(false) - } + const v = draft.trim(); + if (!v) return; + onChange([...items, { value: v, enabled: true }]); + setDraft(''); + onPendingChange?.(false); + }; - const remove = (i: number) => onChange(items.filter((_, idx) => idx !== i)) + const remove = (i: number) => onChange(items.filter((_, idx) => idx !== i)); const toggle = (i: number) => - onChange(items.map((it, idx) => (idx === i ? { ...it, enabled: !it.enabled } : it))) + onChange(items.map((it, idx) => (idx === i ? { ...it, enabled: !it.enabled } : it))); const edit = (i: number, value: string) => - onChange(items.map((it, idx) => (idx === i ? { ...it, value } : it))) + onChange(items.map((it, idx) => (idx === i ? { ...it, value } : it))); return (
@@ -104,5 +104,5 @@ export function ArgList({ items, onChange, onPendingChange, placeholder = '--arg
- ) + ); } diff --git a/src/renderer/components/common/Button.tsx b/src/renderer/components/common/Button.tsx index dcec728..5307bdc 100644 --- a/src/renderer/components/common/Button.tsx +++ b/src/renderer/components/common/Button.tsx @@ -1,9 +1,9 @@ -import React from 'react' +import React from 'react'; interface Props extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'ghost' | 'danger' - size?: 'sm' | 'md' - loading?: boolean + variant?: 'primary' | 'ghost' | 'danger'; + size?: 'sm' | 'md'; + loading?: boolean; } export function Button({ @@ -16,14 +16,14 @@ export function Button({ ...rest }: Props) { const base = - 'inline-flex items-center justify-center gap-1.5 rounded-md border font-mono transition-colors focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed' - const sizes = { sm: 'px-2.5 py-1 text-xs', md: 'px-3.5 py-1.5 text-sm' } + 'inline-flex items-center justify-center gap-1.5 rounded-md border font-mono transition-colors focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed'; + const sizes = { sm: 'px-2.5 py-1 text-xs', md: 'px-3.5 py-1.5 text-sm' }; const variants = { primary: 'bg-accent border-accent text-base-950 hover:brightness-110', ghost: 'bg-transparent border-surface-border text-text-secondary hover:text-text-primary hover:border-text-muted', danger: 'bg-transparent border-red-500/30 text-red-400 hover:border-red-400 hover:text-red-300', - } + }; return ( - ) + ); } diff --git a/src/renderer/components/common/ContextMenu.tsx b/src/renderer/components/common/ContextMenu.tsx index 50ae5b2..60f53a4 100644 --- a/src/renderer/components/common/ContextMenu.tsx +++ b/src/renderer/components/common/ContextMenu.tsx @@ -1,61 +1,61 @@ -import React, { useEffect, useRef } from 'react' +import React, { useEffect, useRef } from 'react'; export interface ContextMenuItem { - label?: string - icon?: React.ReactNode - danger?: boolean - disabled?: boolean - type?: 'separator' - onClick?: (e: React.MouseEvent) => void + label?: string; + icon?: React.ReactNode; + danger?: boolean; + disabled?: boolean; + type?: 'separator'; + onClick?: (e: React.MouseEvent) => void; } interface Props { - x: number - y: number - items: ContextMenuItem[] - onClose: () => void + x: number; + y: number; + items: ContextMenuItem[]; + onClose: () => void; } export function ContextMenu({ x, y, items, onClose }: Props) { - const ref = useRef(null) + const ref = useRef(null); useEffect(() => { const handleClick = (e: MouseEvent) => { - if (!ref.current) return + if (!ref.current) return; // only close if clicking OUTSIDE if (!ref.current.contains(e.target as Node)) { - onClose() + onClose(); } - } + }; const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } + if (e.key === 'Escape') onClose(); + }; - document.addEventListener('click', handleClick) - document.addEventListener('keydown', handleKey) + document.addEventListener('click', handleClick); + document.addEventListener('keydown', handleKey); return () => { - document.removeEventListener('click', handleClick) - document.removeEventListener('keydown', handleKey) - } - }, [onClose]) + document.removeEventListener('click', handleClick); + document.removeEventListener('keydown', handleKey); + }; + }, [onClose]); - const style: React.CSSProperties = { position: 'fixed', zIndex: 1000 } + const style: React.CSSProperties = { position: 'fixed', zIndex: 1000 }; - if (x + 180 > window.innerWidth) style.right = window.innerWidth - x - else style.left = x + if (x + 180 > window.innerWidth) style.right = window.innerWidth - x; + else style.left = x; - if (y + items.length * 32 > window.innerHeight) style.bottom = window.innerHeight - y - else style.top = y + if (y + items.length * 32 > window.innerHeight) style.bottom = window.innerHeight - y; + else style.top = y; return (
{items.map((item, i) => { if (item.type === 'separator') { - return
+ return
; } return ( @@ -64,9 +64,9 @@ export function ContextMenu({ x, y, items, onClose }: Props) { disabled={item.disabled} onMouseDown={(e) => e.preventDefault()} // prevent focus jump onClick={(e) => { - if (item.disabled || !item.onClick) return - item.onClick(e) - onClose() + if (item.disabled || !item.onClick) return; + item.onClick(e); + onClose(); }} className={[ 'w-full flex items-center gap-2.5 px-3 py-1.5 text-xs text-left transition-colors', @@ -80,9 +80,9 @@ export function ContextMenu({ x, y, items, onClose }: Props) { {item.icon && {item.icon}} {item.label} - ) + ); })}
- ) + ); } diff --git a/src/renderer/components/common/Dialog.tsx b/src/renderer/components/common/Dialog.tsx index 26aee4e..fbffe54 100644 --- a/src/renderer/components/common/Dialog.tsx +++ b/src/renderer/components/common/Dialog.tsx @@ -1,15 +1,15 @@ -import React from 'react' -import { Button } from './Button' +import React from 'react'; +import { Button } from './Button'; interface Props { - open: boolean - title: string - message: string - confirmLabel?: string - cancelLabel?: string - danger?: boolean - onConfirm: () => void - onCancel: () => void + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; } export function Dialog({ @@ -22,7 +22,7 @@ export function Dialog({ onConfirm, onCancel, }: Props) { - if (!open) return null + if (!open) return null; return (
@@ -38,5 +38,5 @@ export function Dialog({
- ) + ); } diff --git a/src/renderer/components/common/Input.tsx b/src/renderer/components/common/Input.tsx index eef09e4..cafb17c 100644 --- a/src/renderer/components/common/Input.tsx +++ b/src/renderer/components/common/Input.tsx @@ -1,9 +1,9 @@ -import React from 'react' +import React from 'react'; interface Props extends React.InputHTMLAttributes { - label?: string - hint?: string - rightElement?: React.ReactNode + label?: string; + hint?: string; + rightElement?: React.ReactNode; } export function Input({ label, hint, rightElement, className = '', ...rest }: Props) { @@ -26,5 +26,5 @@ export function Input({ label, hint, rightElement, className = '', ...rest }: Pr
{hint &&

{hint}

}
- ) + ); } diff --git a/src/renderer/components/common/Modal.tsx b/src/renderer/components/common/Modal.tsx index b44560d..7477a1f 100644 --- a/src/renderer/components/common/Modal.tsx +++ b/src/renderer/components/common/Modal.tsx @@ -1,12 +1,12 @@ -import React, { useEffect, useRef } from 'react' -import { VscClose } from 'react-icons/vsc' +import React, { useEffect, useRef } from 'react'; +import { VscClose } from 'react-icons/vsc'; interface Props { - open: boolean - title: string - onClose: () => void - width?: 'sm' | 'md' | 'lg' | 'xl' - children: React.ReactNode + open: boolean; + title: string; + onClose: () => void; + width?: 'sm' | 'md' | 'lg' | 'xl'; + children: React.ReactNode; } const WIDTHS = { @@ -14,27 +14,27 @@ const WIDTHS = { md: 'max-w-md', lg: 'max-w-lg', xl: 'max-w-2xl', -} +}; export function Modal({ open, title, onClose, width = 'md', children }: Props) { - const ref = useRef(null) + const ref = useRef(null); useEffect(() => { - if (!open) return + if (!open) return; const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [open, onClose]) + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onClose]); - if (!open) return null + if (!open) return null; return (
{ - if (e.target === e.currentTarget) onClose() + if (e.target === e.currentTarget) onClose(); }} >
{children}
- ) + ); } diff --git a/src/renderer/components/common/PropList.tsx b/src/renderer/components/common/PropList.tsx index 3c4d901..474b0ff 100644 --- a/src/renderer/components/common/PropList.tsx +++ b/src/renderer/components/common/PropList.tsx @@ -1,48 +1,48 @@ -import React, { useState } from 'react' -import { Button } from './Button' +import React, { useState } from 'react'; +import { Button } from './Button'; export interface PropItem { - key: string - value: string - enabled: boolean + key: string; + value: string; + enabled: boolean; } interface Props { - items: PropItem[] - onChange: (items: PropItem[]) => void - onPendingChange?: (hasPending: boolean) => void + items: PropItem[]; + onChange: (items: PropItem[]) => void; + onPendingChange?: (hasPending: boolean) => void; } export function PropList({ items, onChange, onPendingChange }: Props) { - const [draftKey, setDraftKey] = useState('') - const [draftValue, setDraftValue] = useState('') + const [draftKey, setDraftKey] = useState(''); + const [draftValue, setDraftValue] = useState(''); const notify = (k: string, v: string) => - onPendingChange?.(k.trim().length > 0 || v.trim().length > 0) + onPendingChange?.(k.trim().length > 0 || v.trim().length > 0); const setKey = (v: string) => { - setDraftKey(v) - notify(v, draftValue) - } + setDraftKey(v); + notify(v, draftValue); + }; const setVal = (v: string) => { - setDraftValue(v) - notify(draftKey, v) - } + setDraftValue(v); + notify(draftKey, v); + }; const add = () => { - if (!draftKey.trim()) return - onChange([...items, { key: draftKey.trim(), value: draftValue.trim(), enabled: true }]) - setDraftKey('') - setDraftValue('') - onPendingChange?.(false) - } + if (!draftKey.trim()) return; + onChange([...items, { key: draftKey.trim(), value: draftValue.trim(), enabled: true }]); + setDraftKey(''); + setDraftValue(''); + onPendingChange?.(false); + }; - const remove = (i: number) => onChange(items.filter((_, idx) => idx !== i)) + const remove = (i: number) => onChange(items.filter((_, idx) => idx !== i)); const toggle = (i: number) => - onChange(items.map((it, idx) => (idx === i ? { ...it, enabled: !it.enabled } : it))) + onChange(items.map((it, idx) => (idx === i ? { ...it, enabled: !it.enabled } : it))); const editKey = (i: number, key: string) => - onChange(items.map((it, idx) => (idx === i ? { ...it, key } : it))) + onChange(items.map((it, idx) => (idx === i ? { ...it, key } : it))); const editValue = (i: number, value: string) => - onChange(items.map((it, idx) => (idx === i ? { ...it, value } : it))) + onChange(items.map((it, idx) => (idx === i ? { ...it, value } : it))); return (
@@ -146,5 +146,5 @@ export function PropList({ items, onChange, onPendingChange }: Props) {
- ) + ); } diff --git a/src/renderer/components/common/TitleBar.tsx b/src/renderer/components/common/TitleBar.tsx index a68ff7a..7168620 100644 --- a/src/renderer/components/common/TitleBar.tsx +++ b/src/renderer/components/common/TitleBar.tsx @@ -1,10 +1,10 @@ -import React from 'react' -import { useApp } from '../../store/AppStore' -import { VscChromeMinimize, VscChromeClose } from 'react-icons/vsc' +import React from 'react'; +import { useApp } from '../../store/AppStore'; +import { VscChromeMinimize, VscChromeClose } from 'react-icons/vsc'; export function TitleBar() { - const { state } = useApp() - const runningCount = state.processStates.filter((s) => s.running).length + const { state } = useApp(); + const runningCount = state.processStates.filter((s) => s.running).length; return (
- ) + ); } diff --git a/src/renderer/components/common/Toggle.tsx b/src/renderer/components/common/Toggle.tsx index 463f731..ed22c64 100644 --- a/src/renderer/components/common/Toggle.tsx +++ b/src/renderer/components/common/Toggle.tsx @@ -1,9 +1,9 @@ -import React from 'react' +import React from 'react'; interface Props { - checked: boolean - onChange: (v: boolean) => void - disabled?: boolean + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; } export function Toggle({ checked, onChange, disabled }: Props) { @@ -25,5 +25,5 @@ export function Toggle({ checked, onChange, disabled }: Props) { ].join(' ')} /> - ) + ); } diff --git a/src/renderer/components/common/Tooltip.tsx b/src/renderer/components/common/Tooltip.tsx index a44a2c0..1eacc2c 100644 --- a/src/renderer/components/common/Tooltip.tsx +++ b/src/renderer/components/common/Tooltip.tsx @@ -1,99 +1,99 @@ -import React, { useState, useRef, useEffect } from 'react' +import React, { useState, useRef, useEffect } from 'react'; interface Props { - content: React.ReactNode - children: React.ReactElement - delay?: number - side?: 'top' | 'bottom' | 'left' | 'right' + content: React.ReactNode; + children: React.ReactElement; + delay?: number; + side?: 'top' | 'bottom' | 'left' | 'right'; } export function Tooltip({ content, children, delay = 400, side = 'top' }: Props) { - const [visible, setVisible] = useState(false) - const [pos, setPos] = useState({ x: 0, y: 0 }) - const timerRef = useRef | null>(null) - const targetRef = useRef(null) - const tipRef = useRef(null) + const [visible, setVisible] = useState(false); + const [pos, setPos] = useState({ x: 0, y: 0 }); + const timerRef = useRef | null>(null); + const targetRef = useRef(null); + const tipRef = useRef(null); const show = (e: React.MouseEvent) => { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() - targetRef.current = e.currentTarget as HTMLElement + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + targetRef.current = e.currentTarget as HTMLElement; timerRef.current = setTimeout(() => { - const tip = tipRef.current - const tw = tip?.offsetWidth ?? 0 - const th = tip?.offsetHeight ?? 0 - let x = 0 - let y = 0 + const tip = tipRef.current; + const tw = tip?.offsetWidth ?? 0; + const th = tip?.offsetHeight ?? 0; + let x = 0; + let y = 0; if (side === 'top') { - x = rect.left + rect.width / 2 - tw / 2 - y = rect.top - th - 6 + x = rect.left + rect.width / 2 - tw / 2; + y = rect.top - th - 6; } if (side === 'bottom') { - x = rect.left + rect.width / 2 - tw / 2 - y = rect.bottom + 6 + x = rect.left + rect.width / 2 - tw / 2; + y = rect.bottom + 6; } if (side === 'left') { - x = rect.left - tw - 6 - y = rect.top + rect.height / 2 - th / 2 + x = rect.left - tw - 6; + y = rect.top + rect.height / 2 - th / 2; } if (side === 'right') { - x = rect.right + 6 - y = rect.top + rect.height / 2 - th / 2 + x = rect.right + 6; + y = rect.top + rect.height / 2 - th / 2; } // Clamp to viewport - x = Math.max(6, Math.min(window.innerWidth - tw - 6, x)) - y = Math.max(6, Math.min(window.innerHeight - th - 6, y)) - setPos({ x, y }) - setVisible(true) - }, delay) - } + x = Math.max(6, Math.min(window.innerWidth - tw - 6, x)); + y = Math.max(6, Math.min(window.innerHeight - th - 6, y)); + setPos({ x, y }); + setVisible(true); + }, delay); + }; const hide = () => { - if (timerRef.current) clearTimeout(timerRef.current) - setVisible(false) - } + if (timerRef.current) clearTimeout(timerRef.current); + setVisible(false); + }; useEffect( () => () => { - if (timerRef.current) clearTimeout(timerRef.current) + if (timerRef.current) clearTimeout(timerRef.current); }, [] - ) + ); // Recalculate position when tip dimensions are known useEffect(() => { - if (!visible || !targetRef.current) return - const rect = targetRef.current.getBoundingClientRect() - const tip = tipRef.current - if (!tip) return - const tw = tip.offsetWidth - const th = tip.offsetHeight - let x = 0 - let y = 0 + if (!visible || !targetRef.current) return; + const rect = targetRef.current.getBoundingClientRect(); + const tip = tipRef.current; + if (!tip) return; + const tw = tip.offsetWidth; + const th = tip.offsetHeight; + let x = 0; + let y = 0; if (side === 'top') { - x = rect.left + rect.width / 2 - tw / 2 - y = rect.top - th - 6 + x = rect.left + rect.width / 2 - tw / 2; + y = rect.top - th - 6; } if (side === 'bottom') { - x = rect.left + rect.width / 2 - tw / 2 - y = rect.bottom + 6 + x = rect.left + rect.width / 2 - tw / 2; + y = rect.bottom + 6; } if (side === 'left') { - x = rect.left - tw - 6 - y = rect.top + rect.height / 2 - th / 2 + x = rect.left - tw - 6; + y = rect.top + rect.height / 2 - th / 2; } if (side === 'right') { - x = rect.right + 6 - y = rect.top + rect.height / 2 - th / 2 + x = rect.right + 6; + y = rect.top + rect.height / 2 - th / 2; } - x = Math.max(6, Math.min(window.innerWidth - tw - 6, x)) - y = Math.max(6, Math.min(window.innerHeight - th - 6, y)) - setPos({ x, y }) - }, [visible, side]) + x = Math.max(6, Math.min(window.innerWidth - tw - 6, x)); + y = Math.max(6, Math.min(window.innerHeight - th - 6, y)); + setPos({ x, y }); + }, [visible, side]); const child = React.cloneElement(children, { onMouseEnter: show, onMouseLeave: hide, - }) + }); return ( <> @@ -110,5 +110,5 @@ export function Tooltip({ content, children, delay = 400, side = 'top' }: Props) {content}
- ) + ); } diff --git a/src/renderer/components/console/ConsoleTab.tsx b/src/renderer/components/console/ConsoleTab.tsx index 8841968..a7faded 100644 --- a/src/renderer/components/console/ConsoleTab.tsx +++ b/src/renderer/components/console/ConsoleTab.tsx @@ -1,185 +1,182 @@ -/** - * ConsoleTab — live output, stdin, history, Ctrl+L clear, Ctrl+F search. - */ -import React, { useRef, useEffect, useState, useCallback, useMemo, KeyboardEvent } from 'react' -import { useApp } from '../../store/AppStore' -import { Button } from '../common/Button' -import { VscSearch, VscChevronUp, VscChevronDown, VscClose } from 'react-icons/vsc' -import type { ConsoleLine } from '../../types' +import React, { useRef, useEffect, useState, useCallback, useMemo, KeyboardEvent } from 'react'; +import { useApp } from '../../store/AppStore'; +import { Button } from '../common/Button'; +import { VscSearch, VscChevronUp, VscChevronDown, VscClose } from 'react-icons/vsc'; +import { ConsoleLine } from '../../../main/shared/types/Process.types'; export function ConsoleTab() { const { state, activeProfile, startProcess, stopProcess, sendInput, clearConsole, isRunning } = - useApp() - - const profileId = activeProfile?.id ?? '' - const running = isRunning(profileId) - const lines = state.consoleLogs[profileId] ?? [] - const settings = state.settings - const color = activeProfile?.color ?? '#4ade80' - const processState = state.processStates.find((s) => s.profileId === profileId) - const pid = processState?.pid - - const [inputValue, setInputValue] = useState('') - const [historyIdx, setHistoryIdx] = useState(-1) - const [cmdHistory, setCmdHistory] = useState([]) - const [autoScroll, setAutoScroll] = useState(true) - const [starting, setStarting] = useState(false) - const [errorMsg, setErrorMsg] = useState(null) - const [searchOpen, setSearchOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [searchIdx, setSearchIdx] = useState(0) - - const scrollRef = useRef(null) - const inputRef = useRef(null) - const searchRef = useRef(null) - const bottomRef = useRef(null) - const matchRefs = useRef<(HTMLDivElement | null)[]>([]) + useApp(); + + const profileId = activeProfile?.id ?? ''; + const running = isRunning(profileId); + const lines = state.consoleLogs[profileId] ?? []; + const settings = state.settings; + const color = activeProfile?.color ?? '#4ade80'; + const processState = state.processStates.find((s) => s.profileId === profileId); + const pid = processState?.pid; + + const [inputValue, setInputValue] = useState(''); + const [historyIdx, setHistoryIdx] = useState(-1); + const [cmdHistory, setCmdHistory] = useState([]); + const [autoScroll, setAutoScroll] = useState(true); + const [starting, setStarting] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchIdx, setSearchIdx] = useState(0); + + const scrollRef = useRef(null); + const inputRef = useRef(null); + const searchRef = useRef(null); + const bottomRef = useRef(null); + const matchRefs = useRef<(HTMLDivElement | null)[]>([]); useEffect(() => { - setInputValue('') - setHistoryIdx(-1) - setErrorMsg(null) - setSearchOpen(false) - setSearchQuery('') - setSearchIdx(0) - }, [profileId]) + setInputValue(''); + setHistoryIdx(-1); + setErrorMsg(null); + setSearchOpen(false); + setSearchQuery(''); + setSearchIdx(0); + }, [profileId]); useEffect(() => { - if (autoScroll && !searchOpen) bottomRef.current?.scrollIntoView({ behavior: 'instant' }) - }, [lines.length, autoScroll, searchOpen]) + if (autoScroll && !searchOpen) bottomRef.current?.scrollIntoView({ behavior: 'instant' }); + }, [lines.length, autoScroll, searchOpen]); const handleScroll = useCallback(() => { - const el = scrollRef.current - if (!el) return - setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 40) - }, []) + const el = scrollRef.current; + if (!el) return; + setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 40); + }, []); - const searchTerm = searchQuery.trim().toLowerCase() + const searchTerm = searchQuery.trim().toLowerCase(); const matchIndices = useMemo(() => { - if (!searchTerm) return [] + if (!searchTerm) return []; return lines.reduce((acc, line, i) => { - if (line.text.toLowerCase().includes(searchTerm)) acc.push(i) - return acc - }, []) - }, [lines, searchTerm]) + if (line.text.toLowerCase().includes(searchTerm)) acc.push(i); + return acc; + }, []); + }, [lines, searchTerm]); const clampedIdx = matchIndices.length > 0 ? ((searchIdx % matchIndices.length) + matchIndices.length) % matchIndices.length - : 0 + : 0; const scrollToMatch = useCallback((idx: number) => { - const el = matchRefs.current[idx] - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) - }, []) + const el = matchRefs.current[idx]; + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, []); useEffect(() => { - if (matchIndices.length > 0) scrollToMatch(clampedIdx) - }, [clampedIdx, matchIndices, scrollToMatch]) + if (matchIndices.length > 0) scrollToMatch(clampedIdx); + }, [clampedIdx, matchIndices, scrollToMatch]); const openSearch = useCallback(() => { - setSearchOpen(true) - setAutoScroll(false) - setTimeout(() => searchRef.current?.focus(), 50) - }, []) + setSearchOpen(true); + setAutoScroll(false); + setTimeout(() => searchRef.current?.focus(), 50); + }, []); const closeSearch = useCallback(() => { - setSearchOpen(false) - setSearchQuery('') - setSearchIdx(0) - setAutoScroll(true) - }, []) + setSearchOpen(false); + setSearchQuery(''); + setSearchIdx(0); + setAutoScroll(true); + }, []); - const goNext = useCallback(() => setSearchIdx((i) => i + 1), []) - const goPrev = useCallback(() => setSearchIdx((i) => i - 1), []) + const goNext = useCallback(() => setSearchIdx((i) => i + 1), []); + const goPrev = useCallback(() => setSearchIdx((i) => i - 1), []); const handleToggle = useCallback(async () => { - if (!activeProfile) return - setErrorMsg(null) + if (!activeProfile) return; + setErrorMsg(null); if (running) { - await stopProcess(profileId) + await stopProcess(profileId); } else { if (!activeProfile.jarPath) { - setErrorMsg('No JAR file selected. Go to Configure to set one.') - return + setErrorMsg('No JAR file selected. Go to Configure to set one.'); + return; } - setStarting(true) - const res = await startProcess(activeProfile) - setStarting(false) - if (!res.ok) setErrorMsg(res.error ?? 'Failed to start') + setStarting(true); + const res = await startProcess(activeProfile); + setStarting(false); + if (!res.ok) setErrorMsg(res.error ?? 'Failed to start'); } - }, [activeProfile, running, profileId, stopProcess, startProcess]) + }, [activeProfile, running, profileId, stopProcess, startProcess]); const handleSend = useCallback(async () => { - const cmd = inputValue.trim() - if (!cmd || !running) return - await sendInput(profileId, cmd) + const cmd = inputValue.trim(); + if (!cmd || !running) return; + await sendInput(profileId, cmd); setCmdHistory((prev) => [cmd, ...prev.filter((c) => c !== cmd)].slice(0, settings?.consoleHistorySize ?? 200) - ) - setInputValue('') - setHistoryIdx(-1) - }, [inputValue, running, profileId, sendInput, settings]) + ); + setInputValue(''); + setHistoryIdx(-1); + }, [inputValue, running, profileId, sendInput, settings]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter') { - e.preventDefault() - handleSend() - return + e.preventDefault(); + handleSend(); + return; } if (e.key === 'ArrowUp') { - e.preventDefault() - const n = Math.min(historyIdx + 1, cmdHistory.length - 1) - setHistoryIdx(n) - setInputValue(cmdHistory[n] ?? '') - return + e.preventDefault(); + const n = Math.min(historyIdx + 1, cmdHistory.length - 1); + setHistoryIdx(n); + setInputValue(cmdHistory[n] ?? ''); + return; } if (e.key === 'ArrowDown') { - e.preventDefault() - const n = Math.max(historyIdx - 1, -1) - setHistoryIdx(n) - setInputValue(n === -1 ? '' : (cmdHistory[n] ?? '')) - return + e.preventDefault(); + const n = Math.max(historyIdx - 1, -1); + setHistoryIdx(n); + setInputValue(n === -1 ? '' : (cmdHistory[n] ?? '')); + return; } if (e.key === 'l' && e.ctrlKey) { - e.preventDefault() - clearConsole(profileId) + e.preventDefault(); + clearConsole(profileId); } if (e.key === 'f' && e.ctrlKey) { - e.preventDefault() - openSearch() + e.preventDefault(); + openSearch(); } }, [handleSend, historyIdx, cmdHistory, clearConsole, profileId, openSearch] - ) + ); useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { if (e.ctrlKey && e.key === 'f') { - e.preventDefault() - openSearch() + e.preventDefault(); + openSearch(); } - if (e.key === 'Escape' && searchOpen) closeSearch() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [openSearch, closeSearch, searchOpen]) + if (e.key === 'Escape' && searchOpen) closeSearch(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [openSearch, closeSearch, searchOpen]); - const fontSize = settings?.consoleFontSize ?? 13 - const wordWrap = settings?.consoleWordWrap ?? false - const lineNums = settings?.consoleLineNumbers ?? false + const fontSize = settings?.consoleFontSize ?? 13; + const wordWrap = settings?.consoleWordWrap ?? false; + const lineNums = settings?.consoleLineNumbers ?? false; if (!activeProfile) { return (
No profile selected
- ) + ); } - matchRefs.current = new Array(matchIndices.length).fill(null) + matchRefs.current = new Array(matchIndices.length).fill(null); return (
@@ -209,8 +206,8 @@ export function ConsoleTab() { {!autoScroll && !searchOpen && (
)} {lines.map((line, i) => { - const matchPos = matchIndices.indexOf(i) - const isCurrentMatch = matchPos === clampedIdx && matchPos !== -1 - const isAnyMatch = matchPos !== -1 + const matchPos = matchIndices.indexOf(i); + const isCurrentMatch = matchPos === clampedIdx && matchPos !== -1; + const isAnyMatch = matchPos !== -1; return ( { - matchRefs.current[matchPos] = el + matchRefs.current[matchPos] = el; } : undefined } /> - ) + ); })}
@@ -364,7 +361,7 @@ export function ConsoleTab() { />
- ) + ); } const LINE_COLORS: Record = { @@ -372,24 +369,24 @@ const LINE_COLORS: Record = { stderr: 'text-console-error', input: 'text-console-input', system: 'text-text-muted', -} +}; const ConsoleLineRow = React.forwardRef< HTMLDivElement, { - line: ConsoleLine - lineNum: number - showLineNum: boolean - wordWrap: boolean - searchTerm: string - isCurrentMatch: boolean - isAnyMatch: boolean + line: ConsoleLine; + lineNum: number; + showLineNum: boolean; + wordWrap: boolean; + searchTerm: string; + isCurrentMatch: boolean; + isAnyMatch: boolean; } >(({ line, lineNum, showLineNum, wordWrap, searchTerm, isCurrentMatch, isAnyMatch }, ref) => { - const text = line.text || ' ' + const text = line.text || ' '; const content = - searchTerm && isAnyMatch ? renderHighlighted(text, searchTerm, isCurrentMatch) : text + searchTerm && isAnyMatch ? renderHighlighted(text, searchTerm, isCurrentMatch) : text; return (
- ) -}) -ConsoleLineRow.displayName = 'ConsoleLineRow' + ); +}); +ConsoleLineRow.displayName = 'ConsoleLineRow'; function renderHighlighted(text: string, term: string, isCurrent: boolean): React.ReactNode { - const parts: React.ReactNode[] = [] - const lower = text.toLowerCase() - let last = 0 - let idx = lower.indexOf(term) - let key = 0 + const parts: React.ReactNode[] = []; + const lower = text.toLowerCase(); + let last = 0; + let idx = lower.indexOf(term); + let key = 0; while (idx !== -1) { - if (idx > last) parts.push(text.slice(last, idx)) + if (idx > last) parts.push(text.slice(last, idx)); parts.push( {text.slice(idx, idx + term.length)} - ) - last = idx + term.length - idx = lower.indexOf(term, last) + ); + last = idx + term.length; + idx = lower.indexOf(term, last); } - if (last < text.length) parts.push(text.slice(last)) - return parts + if (last < text.length) parts.push(text.slice(last)); + return parts; } diff --git a/src/renderer/components/developer/DevApiExplorer.tsx b/src/renderer/components/developer/DevApiExplorer.tsx new file mode 100644 index 0000000..e00cdd6 --- /dev/null +++ b/src/renderer/components/developer/DevApiExplorer.tsx @@ -0,0 +1,438 @@ +import { useState, useCallback } from 'react'; +import { VscCheck, VscCopy, VscPlay, VscEdit, VscCode } from 'react-icons/vsc'; +import { routeConfig, RouteDefinition } from '../../../main/shared/config/API.config'; +import { useApp } from '../../store/AppStore'; +import { Button } from '../common/Button'; +import { REST_API_CONFIG } from '../../../main/shared/config/API.config'; +import { ContextMenu, ContextMenuItem } from '../common/ContextMenu'; + +const METHOD_COLORS: Record = { + GET: 'text-accent border-accent/30 bg-accent/10', + POST: 'text-blue-400 border-blue-400/30 bg-blue-400/10', + PUT: 'text-yellow-400 border-yellow-400/30 bg-yellow-400/10', + DELETE: 'text-red-400 border-red-400/30 bg-red-400/10', +}; + +// ─── JSON Syntax Highlighter ──────────────────────────────────────────────── + +type Token = + | { type: 'key'; value: string } + | { type: 'string'; value: string } + | { type: 'number'; value: string } + | { type: 'boolean'; value: string } + | { type: 'null'; value: string } + | { type: 'punct'; value: string } + | { type: 'plain'; value: string }; + +function tokenizeJson(text: string): Token[] { + const tokens: Token[] = []; + let i = 0; + + while (i < text.length) { + const ws = text.slice(i).match(/^[\s,:\[\]{}]+/); + if (ws) { + const chunk = ws[0]; + for (const ch of chunk) { + if ('{}[],:'.includes(ch)) tokens.push({ type: 'punct', value: ch }); + else tokens.push({ type: 'plain', value: ch }); + } + i += chunk.length; + continue; + } + + if (text[i] === '"') { + let j = i + 1; + while (j < text.length) { + if (text[j] === '\\') { + j += 2; + continue; + } + if (text[j] === '"') { + j++; + break; + } + j++; + } + const raw = text.slice(i, j); + const afterStr = text.slice(j).match(/^\s*:/); + tokens.push({ type: afterStr ? 'key' : 'string', value: raw }); + i = j; + continue; + } + + const num = text.slice(i).match(/^-?\d+(\.\d+)?([eE][+-]?\d+)?/); + if (num) { + tokens.push({ type: 'number', value: num[0] }); + i += num[0].length; + continue; + } + + const keyword = text.slice(i).match(/^(true|false|null)/); + if (keyword) { + tokens.push({ type: keyword[0] === 'null' ? 'null' : 'boolean', value: keyword[0] }); + i += keyword[0].length; + continue; + } + + tokens.push({ type: 'plain', value: text[i] }); + i++; + } + + return tokens; +} + +const TOKEN_CLASS: Record = { + key: 'text-blue-300', + string: 'text-emerald-400', + number: 'text-amber-400', + boolean: 'text-purple-400', + null: 'text-red-400/80', + punct: 'text-text-muted', + plain: 'text-text-secondary', +}; + +function JsonHighlight({ text }: { text: string }) { + let isJson = false; + try { + JSON.parse(text); + isJson = true; + } catch { + /* not JSON */ + } + + if (!isJson) return {text}; + + const tokens = tokenizeJson(text); + return ( + <> + {tokens.map((tok, idx) => ( + + {tok.value} + + ))} + + ); +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export function DevApiExplorer() { + const { state } = useApp(); + + const [selected, setSelected] = useState(null); + const [pathParams, setPathParams] = useState>({}); + const [body, setBody] = useState(''); + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + + const [urlCopied, setUrlCopied] = useState(false); + const [responseCopied, setResponseCopied] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const [ctxMenu, setCtxMenu] = useState<{ + x: number; + y: number; + items: ContextMenuItem[]; + } | null>(null); + + const port = state.settings?.restApiPort ?? 4444; + const restEnabled = state.settings?.restApiEnabled ?? false; + + // ── Helpers ────────────────────────────────────────────────────────────── + + const handleContextMenu = useCallback( + (e: React.MouseEvent, extraItems: ContextMenuItem[] = []) => { + const selection = window.getSelection()?.toString() ?? ''; + + const items: ContextMenuItem[] = [ + { + label: 'Copy', + icon: , + disabled: !selection, + onClick: () => navigator.clipboard.writeText(selection), + }, + ...extraItems, + ]; + + e.preventDefault(); + setCtxMenu({ x: e.clientX, y: e.clientY, items }); + }, + [] + ); + + const handleSelect = (route: RouteDefinition) => { + setSelected(route); + setResponse(null); + setIsEditing(false); + setBody(route.bodyTemplate ?? ''); + + const params: Record = {}; + const matches = route.path.matchAll(/:([a-zA-Z]+)/g); + for (const m of matches) params[m[1]] = ''; + setPathParams(params); + }; + + const buildUrl = () => { + if (!selected) return ''; + let path = selected.path; + for (const [k, v] of Object.entries(pathParams)) { + path = path.replace(`:${k}`, v || `:${k}`); + } + return `http://${REST_API_CONFIG.host}:${port}${path}`; + }; + + const handleCall = async () => { + if (!selected) return; + setLoading(true); + setResponse(null); + setIsEditing(false); + + try { + const url = buildUrl(); + const opts: RequestInit = { method: selected.method }; + + if (body.trim() && ['POST', 'PUT', 'PATCH'].includes(selected.method)) { + opts.headers = { 'Content-Type': 'application/json' }; + opts.body = body; + } + + const res = await fetch(url, opts); + const text = await res.text(); + + try { + setResponse(JSON.stringify(JSON.parse(text), null, 2)); + } catch { + setResponse(text); + } + } catch (err) { + setResponse(`Error: ${err instanceof Error ? err.message : String(err)}`); + } + + setLoading(false); + }; + + const copyUrl = () => { + navigator.clipboard.writeText(buildUrl()); + setUrlCopied(true); + setTimeout(() => setUrlCopied(false), 1500); + }; + + const copyResponse = useCallback(() => { + if (!response) return; + navigator.clipboard.writeText(response); + setResponseCopied(true); + setTimeout(() => setResponseCopied(false), 1500); + }, [response]); + + const toggleEdit = () => setIsEditing((v) => !v); + + // ── Response context menu items (extend here in the future) ────────────── + + const responseCtxItems = useCallback( + (): ContextMenuItem[] => + response + ? [ + { type: 'separator' }, + { + label: 'Copy all', + icon: , + onClick: () => navigator.clipboard.writeText(response), + }, + ] + : [], + [response] + ); + + // ───────────────────────────────────────────────────────────────────────── + + return ( +
+ {/* ── Route list ──────────────────────────────────────────────────── */} +
+ {!restEnabled && ( +
+ REST API disabled in Settings +
+ )} + + {Object.entries(routeConfig).map(([key, route]) => ( + + ))} +
+ + {/* ── Request + response ──────────────────────────────────────────── */} +
+ {!selected ? ( +
+ Select a route to inspect and call it +
+ ) : ( + <> + {/* URL bar */} +
+
+ + {selected.method} + + + handleContextMenu(e)} + className="flex-1 text-xs font-mono text-text-primary bg-base-950 border border-surface-border rounded px-2.5 py-1.5 truncate select-text" + > + {buildUrl()} + + + + + +
+ + {/* Path params */} + {Object.keys(pathParams).length > 0 && ( +
+ {Object.entries(pathParams).map(([k, v]) => ( +
+ :{k} + setPathParams((p) => ({ ...p, [k]: e.target.value }))} + placeholder="value" + className="w-32 bg-base-950 border border-surface-border rounded px-2 py-1 text-xs font-mono text-text-primary focus:outline-none focus:border-accent/40" + /> +
+ ))} +
+ )} +
+ +
+ {/* Body */} + {['POST', 'PUT', 'PATCH'].includes(selected.method) && ( +
+
+ Request Body (JSON) +
+