diff --git a/package-lock.json b/package-lock.json index a306611..d594d9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "java-runner-client", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "java-runner-client", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "electron-store": "^8.1.0", "framer-motion": "^12.38.0", @@ -25,7 +25,7 @@ "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.0", "concurrently": "^8.2.0", - "electron": "^35.7.5", + "electron": "^41.1.1", "electron-builder": "^26.8.1", "eslint": "^10.1.0", "postcss": "^8.4.0", @@ -3149,14 +3149,15 @@ } }, "node_modules/electron": { - "version": "35.7.5", - "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz", - "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==", + "version": "41.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.1.1.tgz", + "integrity": "sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", + "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -3339,14 +3340,22 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "dev": true, diff --git a/package.json b/package.json index d6b319e..c726137 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-runner-client", - "version": "2.2.2", + "version": "2.2.3", "description": "Run and manage Java processes with profiles, console I/O, and system tray support", "main": "dist/main/main.js", "scripts": { @@ -34,7 +34,7 @@ "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.0", "concurrently": "^8.2.0", - "electron": "^35.7.5", + "electron": "^41.1.1", "electron-builder": "^26.8.1", "eslint": "^10.1.0", "postcss": "^8.4.0", diff --git a/src/main/api/Base.routes.ts b/src/main/api/Base.routes.ts index 190ef66..33538b0 100644 --- a/src/main/api/Base.routes.ts +++ b/src/main/api/Base.routes.ts @@ -3,7 +3,7 @@ import { ok } from '../core/RestAPI'; import { getAllProfiles, getSettings, saveSettings } from '../core/Store'; import { processManager } from '../core/process/ProcessManager'; import { AppSettings } from '../shared/config/Settings.config'; -import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; +import { defineRoute, RouteMap } from '../shared/types/API.types'; export const BaseRoutes: RouteMap = { status: defineRoute('status', ({ res }) => diff --git a/src/main/api/Log.routes.ts b/src/main/api/Log.routes.ts index 46804f5..5d80881 100644 --- a/src/main/api/Log.routes.ts +++ b/src/main/api/Log.routes.ts @@ -1,6 +1,6 @@ import { deleteLogFile, getLogFiles, readLogFile } from '../core/process/FileLogger'; import { err, ok } from '../core/RestAPI'; -import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; +import { defineRoute, RouteMap } from '../shared/types/API.types'; export const LogRoutes: RouteMap = { logs_list: defineRoute('logs_list', ({ res, params }) => { diff --git a/src/main/api/Process.routes.ts b/src/main/api/Process.routes.ts index 091ade3..2fa3e46 100644 --- a/src/main/api/Process.routes.ts +++ b/src/main/api/Process.routes.ts @@ -1,7 +1,7 @@ import { processManager } from '../core/process/ProcessManager'; import { err, ok } from '../core/RestAPI'; import { getAllProfiles } from '../core/Store'; -import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; +import { defineRoute, RouteMap } from '../shared/types/API.types'; export const ProcessRoutes: RouteMap = { processes_list: defineRoute('processes_list', ({ res }) => ok(res, processManager.getStates())), diff --git a/src/main/api/Profile.routes.ts b/src/main/api/Profile.routes.ts index c153388..3edd0dc 100644 --- a/src/main/api/Profile.routes.ts +++ b/src/main/api/Profile.routes.ts @@ -2,8 +2,8 @@ import { v4 as uuidv4 } from 'uuid'; import { processManager } from '../core/process/ProcessManager'; import { err, ok } from '../core/RestAPI'; import { deleteProfile, getAllProfiles, saveProfile } from '../core/Store'; +import { defineRoute, RouteMap } from '../shared/types/API.types'; import { Profile } from '../shared/types/Profile.types'; -import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; export const ProfileRoutes: RouteMap = { profiles_list: defineRoute('profiles_list', ({ res }) => ok(res, getAllProfiles())), diff --git a/src/main/api/_index.ts b/src/main/api/_index.ts index 780f128..c5a216f 100644 --- a/src/main/api/_index.ts +++ b/src/main/api/_index.ts @@ -1,5 +1,5 @@ import { RouteKey } from '../shared/config/API.config'; -import { BuiltRoute, RouteMap } from '../shared/types/RestAPI.types'; +import { BuiltRoute, RouteMap } from '../shared/types/API.types'; import { BaseRoutes } from './Base.routes'; import { LogRoutes } from './Log.routes'; diff --git a/src/main/core/IPCController.ts b/src/main/core/IPCController.ts index c0dd394..6bc6061 100644 --- a/src/main/core/IPCController.ts +++ b/src/main/core/IPCController.ts @@ -3,13 +3,13 @@ * * Defining a route here automatically: * 1. Registers it on ipcMain (in main.ts via `registerIPC`) - * 2. Exposes it on window.api (in preload.ts via `buildPreloadAPI`) - * 3. Types window.api (via the InferAPI utility below) + * 2. Exposes it on jrc.api (in preload.ts via `buildPreloadAPI`) + * 3. Types jrc.api (via the InferAPI utility below) * * Route shapes: - * invoke → renderer calls window.api.foo(...args) → Promise - * send → renderer calls window.api.foo(...args) → void (fire & forget) - * on → renderer calls window.api.onFoo(cb) → unsubscribe fn + * invoke → renderer calls jrc.api.foo(...args) → Promise + * send → renderer calls jrc.api.foo(...args) → void (fire & forget) + * on → renderer calls jrc.api.onFoo(cb) → unsubscribe fn * main pushes via webContents.send(channel, ...args) */ @@ -33,7 +33,7 @@ type SendRoute = { type OnRoute = { type: 'on'; channel: string; - /** Cast a function signature here to type the callback args on window.api.onFoo. + /** Cast a function signature here to type the callback args on jrc.api.onFoo. * e.g. `args: {} as (profileId: string, line: ConsoleLine) => void` * Never called at runtime — purely a compile-time phantom. */ args?: (...args: any[]) => void; @@ -43,7 +43,7 @@ type Route = InvokeRoute | SendRoute | OnRoute; export type RouteMap = Record; -// ─── Type inference: RouteMap → window.api shape ────────────────────────────── +// ─── Type inference: RouteMap → jrc.api shape ───────────────────────────────── type InvokeAPI = R['handler'] extends ( _e: any, @@ -60,7 +60,7 @@ type OnAPI = R extends { args: (...args: in ? { [key in `on${Capitalize}`]: (cb: (...args: A) => void) => () => void } : { [key in `on${Capitalize}`]: (cb: (...args: any[]) => void) => () => void }; -/** Derives the full window.api type from a RouteMap. */ +/** Derives the full jrc.api type from a RouteMap. */ export type InferAPI = { [K in keyof M as M[K]['type'] extends 'on' ? never : K]: M[K] extends InvokeRoute ? InvokeAPI @@ -89,7 +89,7 @@ export function registerIPC(routes: RouteMap[]): void { } } -// ─── Preload: build the window.api object ──────────────────────────────────── +// ─── Preload: build the jrc.api object ─────────────────────────────────────── export function buildPreloadAPI(routes: RouteMap[]): Record { const api: Record = {}; diff --git a/src/main/core/JRCEnvironment.ts b/src/main/core/JRCEnvironment.ts index e475cd6..b0ec635 100644 --- a/src/main/core/JRCEnvironment.ts +++ b/src/main/core/JRCEnvironment.ts @@ -7,7 +7,7 @@ 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'], + launchContext: null as unknown as JRCEnvironment['launchContext'], }; export function loadEnvironment() { @@ -15,7 +15,7 @@ export function loadEnvironment() { isReady: true, devMode: getSettings().devModeEnabled, type: app.isPackaged ? 'prod' : 'dev', - startUpSource: detectStartupSource(), + launchContext: detectLaunchContext(), }; broadcast(); @@ -33,7 +33,7 @@ function broadcast(channel: string = EnvironmentIPC.change.channel) { BrowserWindow.getAllWindows().forEach((w) => w.webContents.send(channel, env)); } -function detectStartupSource(): JRCEnvironment['startUpSource'] { +function detectLaunchContext(): JRCEnvironment['launchContext'] { if (!app.isPackaged) return 'development'; const login = app.getLoginItemSettings(); diff --git a/src/main/core/RestAPI.ts b/src/main/core/RestAPI.ts index ef7fe9d..2690864 100644 --- a/src/main/core/RestAPI.ts +++ b/src/main/core/RestAPI.ts @@ -1,7 +1,7 @@ import http from 'http'; import { routes } from '../api/_index'; import { REST_API_CONFIG } from '../shared/config/API.config'; -import { CompiledRoute, Params } from '../shared/types/RestAPI.types'; +import { CompiledRoute, Params } from '../shared/types/API.types'; // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/src/main/core/Store.ts b/src/main/core/Store.ts index fb6b701..cbd74b3 100644 --- a/src/main/core/Store.ts +++ b/src/main/core/Store.ts @@ -1,7 +1,6 @@ import { app } from 'electron'; import Store from 'electron-store'; -import { DEFAULT_SETTINGS } from '../shared/config/Settings.config'; -import { AppSettings } from '../shared/config/Settings.config'; +import { AppSettings, DEFAULT_SETTINGS } from '../shared/config/Settings.config'; import { Profile } from '../shared/types/Profile.types'; interface StoreSchema { diff --git a/src/main/core/WindowManager.ts b/src/main/core/WindowManager.ts index 57e7e0c..e18d66b 100644 --- a/src/main/core/WindowManager.ts +++ b/src/main/core/WindowManager.ts @@ -15,7 +15,7 @@ export function createWindow(onClose: (e: Electron.Event) => void): BrowserWindo backgroundColor: (ALL_THEMES.find((t) => t.id === getSettings().themeId) ?? BUILTIN_THEME) .colors['base-950'], icon: getIconImage(), - show: getEnvironment().startUpSource !== 'withSystem', + show: getEnvironment().launchContext !== 'withSystem', webPreferences: { preload: path.join(__dirname, '../preload.js'), contextIsolation: true, diff --git a/src/main/core/process/ProcessManager.ts b/src/main/core/process/ProcessManager.ts index a3d90ea..8225ef1 100644 --- a/src/main/core/process/ProcessManager.ts +++ b/src/main/core/process/ProcessManager.ts @@ -3,7 +3,6 @@ import { BrowserWindow } from 'electron'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { ProcessIPC } from '../../ipc/Process.ipc'; -import { DEFAULT_JAR_RESOLUTION } from '../../shared/config/JarResolution.config'; import { PROTECTED_PROCESS_NAMES } from '../../shared/config/Scanner.config'; import { ConsoleLine, @@ -17,10 +16,15 @@ import { startLogSession, stopLogSession, writeLogLine } from './FileLogger'; import { gracefulStop } from './GracefulStop'; import fs from 'fs'; -import { patternToRegex } from '../../shared/config/JarResolution.config'; - const SELF_PROCESS_NAME = 'Java Client Runner'; +function patternToRegex(pattern: string): RegExp { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, (c) => + c === '{' || c === '}' ? c : `\\${c}` + ); + return new RegExp(`^${escaped.replace(/\{version\}/g, '(.+)')}$`, 'i'); +} + type SystemMessageType = | 'start' | 'stopping' @@ -61,7 +65,13 @@ function compareVersionArrays(a: number[], b: number[]): number { } function resolveJarPath(profile: Profile): { jarPath: string; error?: string } { - const res = profile.jarResolution ?? DEFAULT_JAR_RESOLUTION; + const res = profile.jarResolution ?? { + enabled: false, + baseDir: '', + pattern: 'app-{version}.jar', + strategy: 'highest-version' as const, + regexOverride: '', + }; if (!res.enabled) { return { jarPath: profile.jarPath }; diff --git a/src/main/ipc/Dev.ipc.ts b/src/main/ipc/Dev.ipc.ts index 95b9489..0857978 100644 --- a/src/main/ipc/Dev.ipc.ts +++ b/src/main/ipc/Dev.ipc.ts @@ -1,4 +1,6 @@ -import { BrowserWindow } from 'electron'; +import { app, BrowserWindow, shell } from 'electron'; +import { readFileSync } from 'fs'; +import { join } from 'path'; import type { RouteMap } from '../core/IPCController'; import { getAllProfiles } from '../core/Store'; import { DEFAULT_SETTINGS } from '../shared/config/Settings.config'; @@ -37,4 +39,26 @@ export const DevIPC = { store.set('settings', DEFAULT_SETTINGS); }, }, + + getStoreJson: { + type: 'invoke', + channel: 'dev:getStoreJson', + handler: () => { + const storePath = join(app.getPath('userData'), 'java-runner-config.json'); + try { + return readFileSync(storePath, 'utf-8'); + } catch { + return '{}'; + } + }, + }, + + openStoreFile: { + type: 'invoke', + channel: 'dev:openStoreFile', + handler: () => { + const storePath = join(app.getPath('userData'), 'java-runner-config.json'); + shell.showItemInFolder(storePath); + }, + }, } satisfies RouteMap; diff --git a/src/main/ipc/JarResolution.ipc.ts b/src/main/ipc/JarResolution.ipc.ts index fb19cf2..1381fcc 100644 --- a/src/main/ipc/JarResolution.ipc.ts +++ b/src/main/ipc/JarResolution.ipc.ts @@ -1,8 +1,14 @@ import fs from 'fs'; import path from 'path'; import type { RouteMap } from '../core/IPCController'; -import { patternToRegex } from '../shared/config/JarResolution.config'; -import type { JarResolutionConfig, JarResolutionResult } from '../shared/types/JarResolution.types'; +import type { JarResolutionConfig, JarResolutionResult } from '../shared/types/Profile.types'; + +function patternToRegex(pattern: string): RegExp { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, (c) => + c === '{' || c === '}' ? c : `\\${c}` + ); + return new RegExp(`^${escaped.replace(/\{version\}/g, '(.+)')}$`, 'i'); +} function parseVersion(str: string): number[] { return str diff --git a/src/main/main.ts b/src/main/main.ts index 3955c4d..9856910 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -31,7 +31,8 @@ const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); } else { - app.on('second-instance', () => { + app.on('second-instance', (_event, argv) => { + if (argv.includes('--autostart')) return; if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); diff --git a/src/main/preload.ts b/src/main/preload.ts index da04c88..abdaa7e 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -3,6 +3,7 @@ import { buildPreloadAPI } from './core/IPCController'; import { allRoutes } from './ipc/_index'; import { EnvironmentIPC } from './ipc/Environment.ipc'; -contextBridge.exposeInMainWorld('api', buildPreloadAPI([...allRoutes])); +const api = buildPreloadAPI([...allRoutes]); +const env = buildPreloadAPI([EnvironmentIPC]); -contextBridge.exposeInMainWorld('env', buildPreloadAPI([EnvironmentIPC])); +contextBridge.exposeInMainWorld('jrc', { api, env }); diff --git a/src/main/shared/config/API.config.ts b/src/main/shared/config/API.config.ts index 5313810..1ea74fd 100644 --- a/src/main/shared/config/API.config.ts +++ b/src/main/shared/config/API.config.ts @@ -1,10 +1,65 @@ -import { RouteDefinition } from '../types/RestAPI.types'; +import { BodyParams, BodySchemaFor, RouteDefinition } from '../types/API.types'; +import type { Profile } from '../types/Profile.types'; +import type { AppSettings } from './Settings.config'; export const REST_API_CONFIG = { defaultPort: 4444, host: '127.0.0.1', } as const; +type ProfileServerKeys = 'id' | 'createdAt' | 'updatedAt' | 'order'; + +const PROFILE_BODY = { + name: { type: 'string', placeholder: 'My Server' }, + jarPath: { type: 'string', placeholder: '/path/to/app.jar' }, + workingDir: { + type: 'string', + placeholder: '/path/to/workdir', + hint: 'Defaults to JAR directory', + }, + javaPath: { type: 'string', placeholder: 'java', hint: 'Leave empty to use system PATH' }, + jvmArgs: { type: 'json', hint: 'Array of { value, enabled }' }, + systemProperties: { type: 'json', hint: 'Array of { key, value, enabled }' }, + programArgs: { type: 'json', hint: 'Array of { value, enabled }' }, + envVars: { type: 'json', hint: 'Array of { key, value, enabled }' }, + autoStart: { type: 'boolean', hint: 'Start automatically on app launch' }, + autoRestart: { type: 'boolean', hint: 'Restart automatically on crash' }, + autoRestartInterval: { + type: 'number', + placeholder: '10', + hint: 'Seconds to wait before restarting', + }, + color: { type: 'string', placeholder: '#4ade80', hint: 'Profile accent color (hex)' }, + fileLogging: { type: 'boolean', hint: 'Save session logs to file' }, + jarResolution: { type: 'json', hint: 'Jar resolution config object' }, +} as const satisfies BodySchemaFor; + +const SETTINGS_BODY = { + launchOnStartup: { type: 'boolean', hint: 'Launch on Windows startup' }, + startMinimized: { type: 'boolean', hint: 'Start minimized to tray' }, + minimizeToTray: { type: 'boolean', hint: 'Minimize to tray on close' }, + consoleFontSize: { type: 'number', placeholder: '13', hint: 'Console font size in px' }, + consoleMaxLines: { type: 'number', placeholder: '5000', hint: 'Max lines in buffer' }, + consoleWordWrap: { type: 'boolean', hint: 'Wrap long lines' }, + consoleLineNumbers: { type: 'boolean', hint: 'Show line numbers' }, + consoleTimestamps: { type: 'boolean', hint: 'Show timestamps' }, + consoleHistorySize: { type: 'number', placeholder: '100', hint: 'Command history size' }, + themeId: { type: 'string', placeholder: 'dark-default', hint: 'Theme ID' }, + languageId: { type: 'string', placeholder: 'en', hint: 'Language ID (e.g. en, de)' }, + restApiEnabled: { type: 'boolean', hint: 'Enable REST API' }, + restApiPort: { type: 'number', placeholder: '4444', hint: 'REST API port (restart required)' }, + devModeEnabled: { type: 'boolean', hint: 'Enable developer mode' }, +} as const satisfies BodySchemaFor; + +function bodyFrom(model: T, required?: (keyof T)[]): BodyParams { + const requiredSet = new Set(required as string[]); + const out: BodyParams = {}; + for (const [k, def] of Object.entries(model)) { + out[k] = { ...def, required: requiredSet.has(k) }; + } + return out; +} + export const routeConfig = { status: { method: 'GET', @@ -26,49 +81,13 @@ export const routeConfig = { method: 'POST', path: '/api/profiles', description: 'Create profile', - bodyParams: { - name: { type: 'string', required: true, placeholder: 'My Server' }, - jarPath: { type: 'string', placeholder: '/path/to/app.jar' }, - workingDir: { - type: 'string', - placeholder: '/path/to/workdir', - hint: 'Defaults to JAR directory', - }, - javaPath: { type: 'string', placeholder: 'java', hint: 'Leave empty to use system PATH' }, - autoStart: { type: 'boolean', hint: 'Start automatically on app launch' }, - autoRestart: { type: 'boolean', hint: 'Restart automatically on crash' }, - autoRestartInterval: { - type: 'number', - placeholder: '10', - hint: 'Seconds to wait before restarting', - }, - color: { type: 'string', placeholder: '#4ade80', hint: 'Profile accent color (hex)' }, - fileLogging: { type: 'boolean', hint: 'Save session logs to file' }, - }, + bodyParams: bodyFrom(PROFILE_BODY, ['name']), }, profiles_update: { method: 'PUT', path: '/api/profiles/:id', description: 'Update profile', - bodyParams: { - name: { type: 'string', placeholder: 'My Server' }, - jarPath: { type: 'string', placeholder: '/path/to/app.jar' }, - workingDir: { - type: 'string', - placeholder: '/path/to/workdir', - hint: 'Defaults to JAR directory', - }, - javaPath: { type: 'string', placeholder: 'java', hint: 'Leave empty to use system PATH' }, - autoStart: { type: 'boolean', hint: 'Start automatically on app launch' }, - autoRestart: { type: 'boolean', hint: 'Restart automatically on crash' }, - autoRestartInterval: { - type: 'number', - placeholder: '10', - hint: 'Seconds to wait before restarting', - }, - color: { type: 'string', placeholder: '#4ade80', hint: 'Profile accent color (hex)' }, - fileLogging: { type: 'boolean', hint: 'Save session logs to file' }, - }, + bodyParams: bodyFrom(PROFILE_BODY), }, profiles_delete: { method: 'DELETE', @@ -132,26 +151,7 @@ export const routeConfig = { method: 'PUT', path: '/api/settings', description: 'Update settings', - bodyParams: { - launchOnStartup: { type: 'boolean', hint: 'Launch on Windows startup' }, - startMinimized: { type: 'boolean', hint: 'Start minimized to tray' }, - minimizeToTray: { type: 'boolean', hint: 'Minimize to tray on close' }, - consoleFontSize: { type: 'number', placeholder: '13', hint: 'Console font size in px' }, - consoleMaxLines: { type: 'number', placeholder: '5000', hint: 'Max lines in buffer' }, - consoleWordWrap: { type: 'boolean', hint: 'Wrap long lines' }, - consoleLineNumbers: { type: 'boolean', hint: 'Show line numbers' }, - consoleTimestamps: { type: 'boolean', hint: 'Show timestamps' }, - consoleHistorySize: { type: 'number', placeholder: '100', hint: 'Command history size' }, - themeId: { type: 'string', placeholder: 'dark-default', hint: 'Theme ID' }, - languageId: { type: 'string', placeholder: 'en', hint: 'Language ID (e.g. en, de)' }, - restApiEnabled: { type: 'boolean', hint: 'Enable REST API' }, - restApiPort: { - type: 'number', - placeholder: '4444', - hint: 'REST API port (restart required)', - }, - devModeEnabled: { type: 'boolean', hint: 'Enable developer mode' }, - }, + bodyParams: bodyFrom(SETTINGS_BODY), }, } as const satisfies Record; diff --git a/src/main/shared/config/App.config.ts b/src/main/shared/config/App.config.ts new file mode 100644 index 0000000..ed9452f --- /dev/null +++ b/src/main/shared/config/App.config.ts @@ -0,0 +1,32 @@ +/** + * Single source of truth for Content-Security-Policy origins. + * The Vite build reads this to inject the CSP meta tag into index.html. + * + * To allow a new external resource, add it here — no other file needs updating. + */ +export const ALLOWED_ORIGINS = { + /** Image sources (CSP img-src) */ + img: ['https://flagcdn.com', 'https://avatars.githubusercontent.com'], + + /** Stylesheet sources (CSP style-src) */ + style: ['https://fonts.googleapis.com'], + + /** Font file sources (CSP font-src) */ + font: ['https://fonts.gstatic.com'], + + /** Network requests (CSP connect-src) */ + connect: ['http://127.0.0.1:*', 'http://localhost:*', 'https://fonts.googleapis.com'], +} as const; + +/** Builds the full CSP string from ALLOWED_ORIGINS. */ +export function buildCSP(): string { + const directives = [ + `default-src 'self'`, + `script-src 'self'`, + `style-src 'self' 'unsafe-inline' ${ALLOWED_ORIGINS.style.join(' ')}`, + `img-src 'self' data: ${ALLOWED_ORIGINS.img.join(' ')}`, + `font-src 'self' ${ALLOWED_ORIGINS.font.join(' ')}`, + `connect-src 'self' ${ALLOWED_ORIGINS.connect.join(' ')}`, + ]; + return directives.join('; '); +} diff --git a/src/main/shared/config/JarResolution.config.ts b/src/main/shared/config/JarResolution.config.ts deleted file mode 100644 index 07e13a5..0000000 --- a/src/main/shared/config/JarResolution.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { JarResolutionConfig } from '../types/JarResolution.types'; - -export const JAR_RESOLUTION_STRATEGIES = [ - { - id: 'highest-version' as const, - label: 'Highest Version', - hint: 'Picks the JAR with the highest semantic or numeric version extracted from the filename.', - }, - { - id: 'latest-modified' as const, - label: 'Latest Modified', - hint: 'Picks the most recently modified JAR in the directory matching the pattern.', - }, - { - id: 'regex' as const, - label: 'Regex Match', - hint: 'Picks the first JAR whose filename matches the custom regular expression.', - }, -] as const; - -export const DEFAULT_JAR_RESOLUTION: JarResolutionConfig = { - enabled: false, - baseDir: '', - pattern: 'app-{version}.jar', - strategy: 'highest-version', - regexOverride: '', -}; - -/** Converts a user-facing pattern like "app-{version}.jar" into a RegExp. */ -export function patternToRegex(pattern: string): RegExp { - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, (c) => - c === '{' || c === '}' ? c : `\\${c}` - ); - const src = escaped.replace(/\{version\}/g, '(.+)'); - return new RegExp(`^${src}$`, 'i'); -} diff --git a/src/main/shared/config/Settings.config.ts b/src/main/shared/config/Settings.config.ts index e1cfae3..2adc1e8 100644 --- a/src/main/shared/config/Settings.config.ts +++ b/src/main/shared/config/Settings.config.ts @@ -1,4 +1,3 @@ -import { REST_API_CONFIG } from './API.config'; import { AnyFieldDef, extractDefaults, @@ -10,9 +9,7 @@ import { TextDef, ToggleDef, } from '../types/Settings.types'; - -// ─── Section registry ────────────────────────────────────────────────────── -// Add a new entry here when adding a new settings section. +import { REST_API_CONFIG } from './API.config'; export type SettingSection = 'general' | 'console' | 'appearance' | 'advanced' | 'about'; @@ -24,10 +21,7 @@ export const SETTINGS_TOPICS: SettingSidebarTopic[] = [ { id: 'about', label: 'About' }, ]; -// ─── Schema ──────────────────────────────────────────────────────────────── - export const SETTINGS_SCHEMA = { - // ── General › Startup ──────────────────────────────────────────────────── launchOnStartup: { type: 'toggle', default: false, @@ -57,7 +51,6 @@ export const SETTINGS_SCHEMA = { hint: 'settings.minimizeToTrayHint', } as ToggleDef, - // ── Console ─────────────────────────────────────────────────────────────── consoleFontSize: { type: 'range', default: 13, @@ -122,7 +115,6 @@ export const SETTINGS_SCHEMA = { step: 10, } as NumberDef, - // ── Appearance (managed by ThemeProvider / I18nProvider) ────────────────── themeId: { type: 'text', default: 'dark-default', @@ -139,7 +131,6 @@ export const SETTINGS_SCHEMA = { label: 'settings.language', } as TextDef, - // ── Advanced › Dev Mode ─────────────────────────────────────────────────── devModeEnabled: { type: 'toggle', default: false, @@ -149,7 +140,6 @@ export const SETTINGS_SCHEMA = { hint: 'settings.devModeHint', } as ToggleDef, - // ── Advanced › REST API ─────────────────────────────────────────────────── restApiEnabled: { type: 'toggle', default: false, diff --git a/src/main/shared/config/languages/de.lang.ts b/src/main/shared/config/languages/de.lang.ts index f6782ce..1a06824 100644 --- a/src/main/shared/config/languages/de.lang.ts +++ b/src/main/shared/config/languages/de.lang.ts @@ -3,6 +3,7 @@ import type { LanguageDefinition } from '../../types/Language.types'; export const GERMAN: LanguageDefinition = { id: 'de', name: 'Deutsch', + countryCode: 'de', strings: { 'general.save': 'Speichern', 'general.saved': 'Gespeichert', diff --git a/src/main/shared/config/languages/en.lang.ts b/src/main/shared/config/languages/en.lang.ts index a379949..6a3f128 100644 --- a/src/main/shared/config/languages/en.lang.ts +++ b/src/main/shared/config/languages/en.lang.ts @@ -383,6 +383,7 @@ const ENGLISH_STRINGS = { export const ENGLISH: LanguageDefinition = { id: 'en', name: 'English', + countryCode: 'gb', strings: ENGLISH_STRINGS, }; diff --git a/src/main/shared/types/RestAPI.types.ts b/src/main/shared/types/API.types.ts similarity index 77% rename from src/main/shared/types/RestAPI.types.ts rename to src/main/shared/types/API.types.ts index 5df6a5e..787262a 100644 --- a/src/main/shared/types/RestAPI.types.ts +++ b/src/main/shared/types/API.types.ts @@ -5,7 +5,7 @@ export type Params = Record; type RouteMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; -export type BodyParamType = 'string' | 'number' | 'boolean'; +export type BodyParamType = 'string' | 'number' | 'boolean' | 'json'; export type BodyParamDef = { type: BodyParamType; @@ -16,6 +16,14 @@ export type BodyParamDef = { export type BodyParams = Record; +/** + * Enforces that a body schema covers every key in `T` except those in `Excluded`. + * If a field is added to the source type and not to the schema, TypeScript will error. + */ +export type BodySchemaFor = { + [K in Exclude]: BodyParamDef; +}; + export type RouteDefinition = { method: RouteMethod; path: string; diff --git a/src/main/shared/types/App.types.ts b/src/main/shared/types/App.types.ts index 5522d08..f745722 100644 --- a/src/main/shared/types/App.types.ts +++ b/src/main/shared/types/App.types.ts @@ -2,5 +2,5 @@ export interface JRCEnvironment { isReady: boolean; devMode: boolean; type: 'dev' | 'prod'; - startUpSource: 'userRequest' | 'withSystem' | 'development'; + launchContext: 'userRequest' | 'withSystem' | 'development'; } diff --git a/src/main/shared/types/JarResolution.types.ts b/src/main/shared/types/JarResolution.types.ts deleted file mode 100644 index 69da177..0000000 --- a/src/main/shared/types/JarResolution.types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type JarResolutionStrategy = 'highest-version' | 'latest-modified' | 'regex'; - -export interface JarResolutionConfig { - enabled: boolean; - baseDir: string; - pattern: string; // filename pattern, e.g. "myapp-{version}.jar" or a raw regex - strategy: JarResolutionStrategy; - regexOverride?: string; // only used when strategy === 'regex' -} - -export interface JarResolutionResult { - ok: boolean; - resolvedPath?: string; - candidates?: string[]; - error?: string; -} diff --git a/src/main/shared/types/Language.types.ts b/src/main/shared/types/Language.types.ts index 433eaf5..4cb4d7a 100644 --- a/src/main/shared/types/Language.types.ts +++ b/src/main/shared/types/Language.types.ts @@ -1,5 +1,7 @@ export interface LanguageDefinition { id: string; name: string; + /** ISO 3166-1 alpha-2 country code for the flag image (e.g. 'gb', 'de') */ + countryCode?: string; strings: Record; } diff --git a/src/main/shared/types/Profile.types.ts b/src/main/shared/types/Profile.types.ts index 836a429..7cd1971 100644 --- a/src/main/shared/types/Profile.types.ts +++ b/src/main/shared/types/Profile.types.ts @@ -1,4 +1,23 @@ -import type { JarResolutionConfig } from './JarResolution.types'; +// ─── Jar Resolution ───────────────────────────────────────────────────────── + +export type JarResolutionStrategy = 'highest-version' | 'latest-modified' | 'regex'; + +export interface JarResolutionConfig { + enabled: boolean; + baseDir: string; + pattern: string; // filename pattern, e.g. "myapp-{version}.jar" or a raw regex + strategy: JarResolutionStrategy; + regexOverride?: string; // only used when strategy === 'regex' +} + +export interface JarResolutionResult { + ok: boolean; + resolvedPath?: string; + candidates?: string[]; + error?: string; +} + +// ─── Profile ──────────────────────────────────────────────────────────────── export interface SystemProperty { key: string; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6c3e435..3319768 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,7 +1,7 @@ import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AppProvider } from './AppProvider'; +import { TitleBar } from './components/common/layout/shell'; import { DevModeGate } from './components/developer/DevModeGate'; -import { TitleBar } from './components/layout/shell'; import { MainLayout } from './components/MainLayout'; import { ThemeProvider } from './hooks/ThemeProvider'; import { I18nProvider } from './i18n/I18nProvider'; @@ -19,14 +19,14 @@ function Fallback() { Make sure you launch the app in the official Java Runner Client environment.

- window.api is undefined + jrc.api is undefined
); } export default function App() { - if (!window.api) return ; + if (!jrc.api) return ; return ( diff --git a/src/renderer/AppProvider.tsx b/src/renderer/AppProvider.tsx index d3f003c..96d1ab3 100644 --- a/src/renderer/AppProvider.tsx +++ b/src/renderer/AppProvider.tsx @@ -145,12 +145,12 @@ export function AppProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(reducer, INITIAL_STATE); useEffect(() => { - if (!window.api) return; + if (!jrc.api) return; async function init() { const [profiles, settings, states] = await Promise.all([ - window.api.getProfiles(), - window.api.getSettings(), - window.api.getStates(), + jrc.api.getProfiles(), + jrc.api.getSettings(), + jrc.api.getStates(), ]); dispatch({ type: 'INIT', profiles, settings, states }); const max = settings?.consoleMaxLines ?? 5000; @@ -163,35 +163,35 @@ export function AppProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { - if (!window.api) return; + if (!jrc.api) return; const max = state.settings?.consoleMaxLines ?? 5000; - return window.api.onConsoleLine((profileId: string, line: unknown) => { + return jrc.api.onConsoleLine((profileId: string, line: unknown) => { dispatch({ type: 'APPEND_LOG', profileId, line: line as ConsoleLine, maxLines: max }); }); }, [state.settings?.consoleMaxLines]); useEffect(() => { - if (!window.api) return; - return window.api.onConsoleClear((profileId) => dispatch({ type: 'CLEAR_LOG', profileId })); + if (!jrc.api) return; + return jrc.api.onConsoleClear((profileId) => dispatch({ type: 'CLEAR_LOG', profileId })); }, []); useEffect(() => { - if (!window.api) return; - return window.api.onStatesUpdate((states) => dispatch({ type: 'SET_STATES', states })); + if (!jrc.api) return; + return jrc.api.onStatesUpdate((states) => dispatch({ type: 'SET_STATES', states })); }, []); const setActiveProfile = useCallback((id: string) => dispatch({ type: 'SET_ACTIVE', id }), []); const saveProfile = useCallback(async (p: Profile) => { - await window.api.saveProfile(p); - dispatch({ type: 'SET_PROFILES', profiles: await window.api.getProfiles() }); + await jrc.api.saveProfile(p); + dispatch({ type: 'SET_PROFILES', profiles: await jrc.api.getProfiles() }); }, []); const deleteProfile = useCallback( async (id: string) => { clearLogs(id); - await window.api.deleteProfile(id); - const profiles = await window.api.getProfiles(); + await jrc.api.deleteProfile(id); + const profiles = await jrc.api.getProfiles(); dispatch({ type: 'SET_PROFILES', profiles }); if (state.activeProfileId === id) dispatch({ type: 'SET_ACTIVE', id: profiles[0]?.id ?? '' }); }, @@ -220,22 +220,22 @@ export function AppProvider({ children }: { children: ReactNode }) { }; dispatch({ type: 'SET_PROFILES', profiles: [...state.profiles, p] }); dispatch({ type: 'SET_ACTIVE', id: p.id }); - window.api.saveProfile(p); + jrc.api.saveProfile(p); }, [state.profiles] ); const reorderProfiles = useCallback(async (profiles: Profile[]) => { dispatch({ type: 'SET_PROFILES', profiles }); - await window.api.reorderProfiles(profiles.map((p) => p.id)); + await jrc.api.reorderProfiles(profiles.map((p) => p.id)); }, []); - const startProcess = useCallback((p: Profile) => window.api.startProcess(p), []); - const stopProcess = useCallback((id: string) => window.api.stopProcess(id), []); - const forceStopProcess = useCallback((id: string) => window.api.forceStopProcess(id), []); + const startProcess = useCallback((p: Profile) => jrc.api.startProcess(p), []); + const stopProcess = useCallback((id: string) => jrc.api.stopProcess(id), []); + const forceStopProcess = useCallback((id: string) => jrc.api.forceStopProcess(id), []); const sendInput = useCallback(async (profileId: string, input: string) => { - await window.api.sendInput(profileId, input); + await jrc.api.sendInput(profileId, input); }, []); const clearConsole = useCallback( @@ -244,7 +244,7 @@ export function AppProvider({ children }: { children: ReactNode }) { ); const saveSettings = useCallback(async (s: AppSettings) => { - await window.api.saveSettings(s); + await jrc.api.saveSettings(s); dispatch({ type: 'SET_SETTINGS', settings: s }); }, []); diff --git a/src/renderer/components/MainLayout.tsx b/src/renderer/components/MainLayout.tsx index b54200c..571d6d4 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, useMemo } from 'react'; import { LuList, LuScrollText } from 'react-icons/lu'; import { VscAccount, VscTerminal } from 'react-icons/vsc'; import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; @@ -7,10 +7,10 @@ import { useDevMode } from '../hooks/useDevMode'; import { useTranslation } from '../i18n/I18nProvider'; import type { TranslationKey } from '../i18n/TranslationKeys'; import { StatusDot } from './common/display'; +import { PanelHeader, TabBar } from './common/layout/navigation'; import { ConsoleTab } from './console/ConsoleTab'; import { DeveloperTab } from './developer/DeveloperTab'; import { FaqPanel } from './faq/FaqPanel'; -import { PanelHeader } from './layout/navigation'; import { ConfigTab } from './profiles/ConfigTab'; import { LogsTab } from './profiles/LogsTab'; import { ProfileSidebar } from './profiles/ProfileSidebar'; @@ -54,6 +54,17 @@ export function MainLayout() { const color = activeProfile?.color ?? '#4ade80'; const running = activeProfile ? isRunning(activeProfile.id) : false; + const profileTabs = useMemo( + () => + PROFILE_TABS.map((tab) => ({ + id: tab.path, + label: t(tab.labelKey), + Icon: tab.Icon, + badge: tab.path === 'console' && running ? : undefined, + })), + [t, running, color] + ); + useEffect(() => { if (!devMode && activePanel === 'developer') { navigate('/console', { replace: true }); @@ -88,34 +99,19 @@ export function MainLayout() { ) : ( <> -
- {PROFILE_TABS.map((tab) => { - const isActive = activeTab === tab.path; - return ( - - ); - })} -
- {activeProfile && ( - - {activeProfile.name} - - )} -
+ navigate(`/${id}`)} + accentColor={color} + trailing={ + activeProfile && ( + + {activeProfile.name} + + ) + } + />
diff --git a/src/renderer/components/common/index.ts b/src/renderer/components/common/index.ts index 24bf0dc..6cb32f6 100644 --- a/src/renderer/components/common/index.ts +++ b/src/renderer/components/common/index.ts @@ -1,4 +1,5 @@ export * from './display'; export * from './inputs'; +export * from './layout'; export * from './lists'; export * from './overlays'; diff --git a/src/renderer/components/layout/containers/Card.tsx b/src/renderer/components/common/layout/containers/Card.tsx similarity index 100% rename from src/renderer/components/layout/containers/Card.tsx rename to src/renderer/components/common/layout/containers/Card.tsx diff --git a/src/renderer/components/layout/containers/DataRow.tsx b/src/renderer/components/common/layout/containers/DataRow.tsx similarity index 100% rename from src/renderer/components/layout/containers/DataRow.tsx rename to src/renderer/components/common/layout/containers/DataRow.tsx diff --git a/src/renderer/components/layout/containers/ScrollContent.tsx b/src/renderer/components/common/layout/containers/ScrollContent.tsx similarity index 100% rename from src/renderer/components/layout/containers/ScrollContent.tsx rename to src/renderer/components/common/layout/containers/ScrollContent.tsx diff --git a/src/renderer/components/layout/containers/Section.tsx b/src/renderer/components/common/layout/containers/Section.tsx similarity index 100% rename from src/renderer/components/layout/containers/Section.tsx rename to src/renderer/components/common/layout/containers/Section.tsx diff --git a/src/renderer/components/layout/containers/index.ts b/src/renderer/components/common/layout/containers/index.ts similarity index 100% rename from src/renderer/components/layout/containers/index.ts rename to src/renderer/components/common/layout/containers/index.ts diff --git a/src/renderer/components/layout/index.ts b/src/renderer/components/common/layout/index.ts similarity index 100% rename from src/renderer/components/layout/index.ts rename to src/renderer/components/common/layout/index.ts diff --git a/src/renderer/components/layout/navigation/PanelHeader.tsx b/src/renderer/components/common/layout/navigation/PanelHeader.tsx similarity index 91% rename from src/renderer/components/layout/navigation/PanelHeader.tsx rename to src/renderer/components/common/layout/navigation/PanelHeader.tsx index a31e796..75afc75 100644 --- a/src/renderer/components/layout/navigation/PanelHeader.tsx +++ b/src/renderer/components/common/layout/navigation/PanelHeader.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { useTranslation } from '../../../i18n/I18nProvider'; +import { useTranslation } from '../../../../i18n/I18nProvider'; interface Props { title: string; @@ -30,7 +30,7 @@ export function PanelHeader({ title, backTo = '/console' }: Props) { {t('general.back')}
- {title} + {title}
); diff --git a/src/renderer/components/layout/navigation/SidebarLayout.tsx b/src/renderer/components/common/layout/navigation/SidebarLayout.tsx similarity index 100% rename from src/renderer/components/layout/navigation/SidebarLayout.tsx rename to src/renderer/components/common/layout/navigation/SidebarLayout.tsx diff --git a/src/renderer/components/common/layout/navigation/TabBar.tsx b/src/renderer/components/common/layout/navigation/TabBar.tsx new file mode 100644 index 0000000..02d3c91 --- /dev/null +++ b/src/renderer/components/common/layout/navigation/TabBar.tsx @@ -0,0 +1,113 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { VscChevronLeft, VscChevronRight } from 'react-icons/vsc'; + +export interface Tab { + id: string; + label: string; + icon?: React.ReactNode; + badge?: React.ReactNode; + Icon?: React.ElementType; +} + +interface TabBarProps { + tabs: Tab[]; + active: string; + onChange: (id: string) => void; + accentColor?: string; + className?: string; + dotTab?: string; + /** Content rendered after the tabs, pushed to the right edge */ + trailing?: React.ReactNode; +} + +export function TabBar({ + tabs, + active, + onChange, + accentColor = 'var(--tw-theme-accent, #4ade80)', + className = '', + dotTab, + trailing, +}: TabBarProps) { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateOverflow = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 1); + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }, []); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + updateOverflow(); + const ro = new ResizeObserver(updateOverflow); + ro.observe(el); + return () => ro.disconnect(); + }, [updateOverflow, tabs.length]); + + const scroll = (dir: -1 | 1) => { + scrollRef.current?.scrollBy({ left: dir * 120, behavior: 'smooth' }); + }; + + return ( +
+ {canScrollLeft && ( + + )} + +
+ {tabs.map((tab) => { + const isActive = tab.id === active; + return ( + + ); + })} +
+ + {trailing &&
{trailing}
} + + {canScrollRight && ( + + )} +
+ ); +} diff --git a/src/renderer/components/layout/navigation/index.ts b/src/renderer/components/common/layout/navigation/index.ts similarity index 100% rename from src/renderer/components/layout/navigation/index.ts rename to src/renderer/components/common/layout/navigation/index.ts diff --git a/src/renderer/components/layout/shell/TitleBar.tsx b/src/renderer/components/common/layout/shell/TitleBar.tsx similarity index 90% rename from src/renderer/components/layout/shell/TitleBar.tsx rename to src/renderer/components/common/layout/shell/TitleBar.tsx index bc2d1f0..dd45fed 100644 --- a/src/renderer/components/layout/shell/TitleBar.tsx +++ b/src/renderer/components/common/layout/shell/TitleBar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { VscChromeClose, VscChromeMinimize } from 'react-icons/vsc'; -import { useApp } from '../../../AppProvider'; -import { StatusDot } from '../../common/display/StatusDot'; +import { useApp } from '../../../../AppProvider'; +import { StatusDot } from '../../display/StatusDot'; export function TitleBar() { const { state } = useApp(); @@ -41,7 +41,7 @@ export function TitleBar() { opacity="0.5" /> - + Java Runner Client {runningCount > 0 && ( @@ -59,14 +59,14 @@ export function TitleBar() { style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} >