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}
>