From 2e1115c05f6a234b3638e4894d94a3805cc92fc3 Mon Sep 17 00:00:00 2001 From: Timon Home Date: Sat, 21 Mar 2026 15:03:17 +0100 Subject: [PATCH 1/4] Reworking Types, Config and Environment --- package-lock.json | 4 +- package.json | 2 +- src/main/JRCEnvironment.ts | 43 ++++ src/main/RestAPI.routes.ts | 131 +++++++++++ src/main/RestAPI.ts | 206 +++-------------- src/main/ipc/Dev.ipc.ts | 39 ++++ src/main/ipc/Environment.ipc.ts | 41 ++++ src/main/ipc/GitHub.ipc.ts | 2 +- src/main/ipc/Process.ipc.ts | 3 +- src/main/ipc/Profile.ipc.ts | 2 +- src/main/ipc/System.ipc.ts | 5 +- src/main/ipc/_index.ts | 15 +- src/main/main.ts | 67 ++++-- src/main/preload.ts | 5 +- src/main/processManager.ts | 18 +- src/main/shared/config/API.config.ts | 85 +++++++ src/main/shared/config/App.config.ts | 17 ++ src/main/shared/types.ts | 111 --------- src/main/shared/types/App.types.ts | 21 ++ src/main/shared/{ => types}/GitHub.types.ts | 0 src/main/shared/types/Process.types.ts | 39 ++++ src/main/shared/types/Profile.types.ts | 31 +++ src/main/store.ts | 26 +-- src/renderer/App.tsx | 9 +- src/renderer/components/MainLayout.tsx | 32 ++- .../components/console/ConsoleTab.tsx | 5 +- .../components/developer/DevApiExplorer.tsx | 216 ++++++++++++++++++ .../components/developer/DevDashboard.tsx | 164 +++++++++++++ .../components/developer/DevDiagnostics.tsx | 170 ++++++++++++++ .../components/developer/DevModeGate.tsx | 79 +++++++ .../components/developer/DevStorage.tsx | 174 ++++++++++++++ .../components/developer/DeveloperTab.tsx | 57 +++++ src/renderer/components/faq/FaqPanel.tsx | 4 +- .../components/profiles/ConfigTab.tsx | 6 +- .../components/profiles/ProfileSidebar.tsx | 40 +++- .../components/profiles/ProfileTab.tsx | 2 +- .../components/profiles/TemplateModal.tsx | 2 +- .../components/settings/SettingsTab.tsx | 64 +++++- .../settings/version/ReleaseModal.tsx | 2 +- .../settings/version/VersionChecker.tsx | 2 +- .../components/utils/ActivityLogPanel.tsx | 2 +- .../components/utils/ScannerPanel.tsx | 2 +- .../components/utils/UtilitiesTab.tsx | 7 +- src/renderer/global.d.ts | 9 + src/renderer/hooks/useDevMode.ts | 27 +++ src/renderer/index.html | 2 +- src/renderer/store/AppStore.tsx | 15 +- src/renderer/store/appReducer.ts | 4 +- src/renderer/store/sessionLogs.ts | 2 +- src/renderer/types/index.ts | 19 -- 50 files changed, 1610 insertions(+), 420 deletions(-) create mode 100644 src/main/JRCEnvironment.ts create mode 100644 src/main/RestAPI.routes.ts create mode 100644 src/main/ipc/Dev.ipc.ts create mode 100644 src/main/ipc/Environment.ipc.ts create mode 100644 src/main/shared/config/API.config.ts create mode 100644 src/main/shared/config/App.config.ts delete mode 100644 src/main/shared/types.ts create mode 100644 src/main/shared/types/App.types.ts rename src/main/shared/{ => types}/GitHub.types.ts (100%) create mode 100644 src/main/shared/types/Process.types.ts create mode 100644 src/main/shared/types/Profile.types.ts create mode 100644 src/renderer/components/developer/DevApiExplorer.tsx create mode 100644 src/renderer/components/developer/DevDashboard.tsx create mode 100644 src/renderer/components/developer/DevDiagnostics.tsx create mode 100644 src/renderer/components/developer/DevModeGate.tsx create mode 100644 src/renderer/components/developer/DevStorage.tsx create mode 100644 src/renderer/components/developer/DeveloperTab.tsx create mode 100644 src/renderer/global.d.ts create mode 100644 src/renderer/hooks/useDevMode.ts delete mode 100644 src/renderer/types/index.ts 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/package.json b/package.json index 406ee74..dfafd76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-runner-client", - "version": "2.1.3", + "version": "2.1.4", "description": "Run and manage Java processes with profiles, console I/O, and system tray support", "main": "dist/main/main.js", "scripts": { diff --git a/src/main/JRCEnvironment.ts b/src/main/JRCEnvironment.ts new file mode 100644 index 0000000..3d14f5c --- /dev/null +++ b/src/main/JRCEnvironment.ts @@ -0,0 +1,43 @@ +import { app, BrowserWindow } from 'electron' +import { JRCEnvironment } from './shared/types/App.types' +import { getSettings } from './Store' +import { EnvironmentIPC } from './ipc/Environment.ipc' + + +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 +} + +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/RestAPI.routes.ts b/src/main/RestAPI.routes.ts new file mode 100644 index 0000000..2c7853a --- /dev/null +++ b/src/main/RestAPI.routes.ts @@ -0,0 +1,131 @@ +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 = [ + 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, + }) + ), + + defineRoute('profiles_list', ({ res }) => ok(res, getAllProfiles())), + + defineRoute('profiles_get', ({ res, params }) => { + const p = getAllProfiles().find((p) => p.id === params.id) + console.log(p, params, getAllProfiles()) + p ? ok(res, p) : err(res, 'Profile not found', 404) + }), + + 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) + }), + + 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) + }), + + 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) + }), + + defineRoute('processes_list', ({ res }) => ok(res, processManager.getStates())), + + defineRoute('processes_log', ({ res }) => ok(res, processManager.getActivityLog())), + + 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)) + }), + + defineRoute('processes_stop', ({ res, params }) => ok(res, processManager.stop(params.id))), + + defineRoute('processes_clear', ({ res, params }) => { + processManager.clearConsoleForProfile(params.id) + ok(res) + }), + + defineRoute('settings_get', ({ res }) => ok(res, getSettings())), + + 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..b90a3df 100644 --- a/src/main/RestAPI.ts +++ b/src/main/RestAPI.ts @@ -1,50 +1,48 @@ 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 { routes } from './RestAPI.routes' +import { routeConfig } from './shared/config/API.config' +import { getSettings } from './Store' 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 +// ─── Types ──────────────────────────────────────────────────────────────────── -interface RestRoute { +type CompiledRoute = { method: string path: string - handler: RouteHandler + 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.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) { +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) { +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 - }) + req.on('data', (c) => (raw += c)) req.on('end', () => { try { resolve(JSON.parse(raw)) @@ -55,7 +53,7 @@ function readBody(req: http.IncomingMessage): Promise { }) } -function parsePattern(path: string): { pattern: RegExp; keys: string[] } { +function parsePattern(path: string) { const keys: string[] = [] const src = path.replace(/:([a-zA-Z]+)/g, (_m, k) => { keys.push(k) @@ -64,154 +62,18 @@ function parsePattern(path: string): { pattern: RegExp; keys: string[] } { 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 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 compiled = compileRoutes() start(port: number): void { if (this.server) return @@ -228,16 +90,20 @@ class RestApiServer { const url = req.url?.split('?')[0] ?? '/' const method = req.method ?? 'GET' - const body = ['POST', 'PUT', 'PATCH'].includes(method) ? await readBody(req) : {} + const body = + method === 'POST' || method === 'PUT' || method === 'PATCH' ? await readBody(req) : {} - for (const route of compiled) { + for (const route of this.compiled) { if (route.method !== method) continue + 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 } diff --git a/src/main/ipc/Dev.ipc.ts b/src/main/ipc/Dev.ipc.ts new file mode 100644 index 0000000..54f9dbd --- /dev/null +++ b/src/main/ipc/Dev.ipc.ts @@ -0,0 +1,39 @@ +import { BrowserWindow } from 'electron' +import { DEFAULT_SETTINGS } from '../shared/config/App.config' +import type { RouteMap } from '../shared/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, + 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..aa058a1 --- /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 '../shared/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..cf468fb 100644 --- a/src/main/ipc/GitHub.ipc.ts +++ b/src/main/ipc/GitHub.ipc.ts @@ -3,7 +3,7 @@ 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 type { GitHubRelease, ProfileTemplate } from '../shared/types/GitHub.types' function httpsGet(url: string): Promise { return new Promise((resolve, reject) => { diff --git a/src/main/ipc/Process.ipc.ts b/src/main/ipc/Process.ipc.ts index ebd6939..3f839c9 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 { Profile } from '../shared/types/Profile.types' +import { ConsoleLine, ProcessState } from '../shared/types/Process.types' export const ProcessIPC = { startProcess: { diff --git a/src/main/ipc/Profile.ipc.ts b/src/main/ipc/Profile.ipc.ts index 513081b..c6b46a5 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 { Profile } from '../shared/types/Profile.types' export const ProfileIPC = { getProfiles: { type: 'invoke', channel: 'profiles:getAll', handler: () => getAllProfiles() }, diff --git a/src/main/ipc/System.ipc.ts b/src/main/ipc/System.ipc.ts index 1e4f002..8d70f6f 100644 --- a/src/main/ipc/System.ipc.ts +++ b/src/main/ipc/System.ipc.ts @@ -1,8 +1,9 @@ import { dialog, shell } from 'electron' +import { restApiServer } from '../RestAPI' import type { RouteMap } from '../shared/IPCController' +import type { AppSettings, JRCEnvironment } from '../shared/types/App.types' import { getSettings, saveSettings } from '../Store' -import { restApiServer } from '../RestAPI' -import type { AppSettings } from '../shared/types' +import { getEnvironment } from './../JRCEnvironment' // mainWindow is needed for dialogs — set via initSystemIPC() called from main.ts let getWindow: () => Electron.BrowserWindow | null = () => null diff --git a/src/main/ipc/_index.ts b/src/main/ipc/_index.ts index bddd5f5..bfb7157 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. * @@ -12,17 +13,25 @@ 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 { DevIPC } from './Dev.ipc' import type { InferAPI } from '../shared/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..75799ae 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,23 +1,19 @@ -import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron' -import path from 'path' +import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron' import fs from 'fs' -import { getAllProfiles, getSettings } from './Store' +import path from 'path' +import { allRoutes, initDevIPC, initSystemIPC, initWindowIPC } from './ipc/_index' +import { EnvironmentIPC } from './ipc/Environment.ipc' +import { getEnvironment, loadEnvironment } from './JRCEnvironment' import { processManager } from './ProcessManager' import { restApiServer } from './RestAPI' import { registerIPC } from './shared/IPCController' -import { allRoutes, initSystemIPC, initWindowIPC } from './ipc/_index' +import { getAllProfiles, getSettings } from './Store' -const IS_DEV = !app.isPackaged -const RESOURCES = IS_DEV +loadEnvironment(); + +const RESOURCES = getEnvironment().type === '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) : () => {} function getIconImage(): Electron.NativeImage { const candidates = @@ -47,27 +43,28 @@ 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') + 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 + const shouldStartHidden = getSettings().startMinimized && getEnvironment().startUpSource === 'withSystem' if (shouldStartHidden) mainWindow?.hide() else mainWindow?.show() }) mainWindow.on('close', (e) => { if (forceQuit) return - if (getSettings().minimizeToTray && !isActiveLaunch) { + if (getSettings().minimizeToTray) { e.preventDefault() mainWindow?.hide() } @@ -118,6 +115,9 @@ function updateTrayMenu(): void { ) } +let devToolsPressCount = 0 +let devToolsTimer: NodeJS.Timeout | null = null + const gotLock = app.requestSingleInstanceLock() if (!gotLock) { @@ -132,8 +132,39 @@ if (!gotLock) { }) app.whenReady().then(() => { + if(getEnvironment().startUpSource === 'withSystem' && !getSettings().launchOnStartup) return; + createWindow() createTray() + mainWindow?.webContents.on('before-input-event', (event, input) => { + const isDevToolsShortcut = + input.key === 'F12' || + (input.control && input.shift && input.key === 'I') || + (input.meta && input.alt && input.key === 'I') + + if (!isDevToolsShortcut) return + + event.preventDefault() + + devToolsPressCount++ + + // reset counter after 1 second of inactivity + if (devToolsTimer) clearTimeout(devToolsTimer) + devToolsTimer = setTimeout(() => { + devToolsPressCount = 0 + }, 1000) + + if (devToolsPressCount >= 7) { + devToolsPressCount = 0 + mainWindow?.webContents.openDevTools({ mode: 'detach' }) + return + } + + // normal single-press behavior only if devModeEnabled + if (getEnvironment().devMode) { + mainWindow?.webContents.openDevTools() + } + }) // ── IPC ──────────────────────────────────────────────────────────────────── initSystemIPC(() => mainWindow) @@ -143,7 +174,9 @@ if (!gotLock) { forceQuit = true } ) + initDevIPC(() => mainWindow) registerIPC([...allRoutes]) + registerIPC([EnvironmentIPC]) // ────────────────────────────────────────────────────────────────────────── const settings = getSettings() diff --git a/src/main/preload.ts b/src/main/preload.ts index 5eedd04..a74723e 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 { EnvironmentIPC } from './ipc/Environment.ipc' +import { buildPreloadAPI } from './shared/IPCController' contextBridge.exposeInMainWorld('api', buildPreloadAPI([...allRoutes])) + +contextBridge.exposeInMainWorld('env', buildPreloadAPI([EnvironmentIPC])) diff --git a/src/main/processManager.ts b/src/main/processManager.ts index 14b7180..f3c87b0 100644 --- a/src/main/processManager.ts +++ b/src/main/processManager.ts @@ -2,15 +2,15 @@ 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 { 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' @@ -216,7 +216,7 @@ class ProcessManager { } 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 { @@ -454,7 +454,7 @@ class ProcessManager { 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, diff --git a/src/main/shared/config/API.config.ts b/src/main/shared/config/API.config.ts new file mode 100644 index 0000000..b445195 --- /dev/null +++ b/src/main/shared/config/API.config.ts @@ -0,0 +1,85 @@ +// ─── Base types ─────────────────────────────────────────────────────────────── + +export type RouteDefinition = { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' + path: string + description: string + bodyTemplate?: string +} + +// ─── Strongly typed route map ───────────────────────────────────────────────── + +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..afef533 --- /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 './RestApi.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/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..7244eb7 --- /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/GitHub.types.ts b/src/main/shared/types/GitHub.types.ts similarity index 100% rename from src/main/shared/GitHub.types.ts rename to src/main/shared/types/GitHub.types.ts diff --git a/src/main/shared/types/Process.types.ts b/src/main/shared/types/Process.types.ts new file mode 100644 index 0000000..a14e347 --- /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..70d4be6 --- /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/main/store.ts b/src/main/store.ts index eac34b3..8d18d46 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -1,26 +1,13 @@ import Store from 'electron-store' -import type { Profile, AppSettings } from './shared/types' -import { REST_API_CONFIG } from './shared/config/RestApi.config' +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, -} - const store = new Store({ name: 'java-runner-config', defaults: { profiles: [], settings: DEFAULT_SETTINGS }, @@ -59,9 +46,16 @@ export function reorderProfiles(orderedIds: string[]): void { 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) } } + export function saveSettings(settings: AppSettings): void { store.set('settings', settings) } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 90982de..98f2113 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 { 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 @@ -41,6 +41,7 @@ export default function App() { } />

+ ) diff --git a/src/renderer/components/MainLayout.tsx b/src/renderer/components/MainLayout.tsx index bb1c53e..51a6f02 100644 --- a/src/renderer/components/MainLayout.tsx +++ b/src/renderer/components/MainLayout.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +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' @@ -7,9 +7,12 @@ 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 }, @@ -17,15 +20,23 @@ const MAIN_TABS = [ { path: 'profile', label: 'Profile', Icon: VscAccount }, ] as const -const SIDE_PANELS = ['settings', 'faq', 'utilities'] as const +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) } +const PANEL_LABELS: Record = { + settings: 'Application Settings', + faq: 'FAQ', + utilities: 'Utilities', + developer: 'Developer', +} + export function MainLayout() { const { state, activeProfile, isRunning, setActiveProfile } = useApp() + const devMode = useDevMode() const navigate = useNavigate() const location = useLocation() @@ -37,6 +48,13 @@ export function MainLayout() { 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) useEffect(() => { @@ -60,6 +78,7 @@ export function MainLayout() { onOpenSettings={() => openPanel('settings')} onOpenFaq={() => openPanel('faq')} onOpenUtilities={() => openPanel('utilities')} + onOpenDeveloper={() => openPanel('developer')} onProfileClick={handleProfileClick} activeSidePanel={activePanel} /> @@ -67,7 +86,6 @@ export function MainLayout() {
{activePanel ? ( <> - {/* Panel header with back navigation */}
+ ))} +
+ + {/* Request + response */} +
+ {!selected ? ( +
+ Select a route to inspect and call it +
+ ) : ( + <> + {/* URL bar */} +
+
+ + {selected.method} + + + + {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) +
+ +