diff --git a/package.json b/package.json index dfafd76..b57c18c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-runner-client", - "version": "2.1.4", + "version": "2.1.5", "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/ProcessManager.ts b/src/main/ProcessManager.ts index 92386f6..99cce21 100644 --- a/src/main/ProcessManager.ts +++ b/src/main/ProcessManager.ts @@ -11,6 +11,14 @@ import { } from './shared/types/Process.types'; import { Profile } from './shared/types/Profile.types'; import { ProcessIPC } from './ipc/Process.ipc'; +import { DEFAULT_JAR_RESOLUTION } from './shared/config/JarResolution.config'; + +// Inline resolution to avoid circular IPC dependency +import fs from 'fs'; +import { patternToRegex } from './shared/config/JarResolution.config'; +import type { JarResolutionConfig } from './shared/types/JarResolution.types'; +import { ProfileIPC } from './ipc/Profile.ipc'; +import { saveProfile } from './Store'; const SELF_PROCESS_NAME = 'Java Client Runner'; @@ -34,6 +42,83 @@ interface ManagedProcess { intentionallyStopped: boolean; } +function parseVersion(str: string): number[] { + return str + .split(/[.\-_]/) + .map((p) => parseInt(p, 10)) + .filter((n) => !isNaN(n)); +} + +function compareVersionArrays(a: number[], b: number[]): number { + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const diff = (b[i] ?? 0) - (a[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +function resolveJarPath(profile: Profile): { jarPath: string; error?: string } { + const res = profile.jarResolution ?? DEFAULT_JAR_RESOLUTION; + + if (!res.enabled) { + return { jarPath: profile.jarPath }; + } + + if (!res.baseDir) return { jarPath: '', error: 'Dynamic JAR: no base directory set.' }; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(res.baseDir, { withFileTypes: true }); + } catch { + return { jarPath: '', error: `Dynamic JAR: cannot read directory "${res.baseDir}".` }; + } + + const jars = entries.filter((e) => e.isFile() && e.name.endsWith('.jar')); + + let matchRegex: RegExp; + if (res.strategy === 'regex' && res.regexOverride?.trim()) { + try { + matchRegex = new RegExp(res.regexOverride.trim(), 'i'); + } catch { + return { jarPath: '', error: 'Dynamic JAR: invalid regular expression.' }; + } + } else { + matchRegex = patternToRegex(res.pattern); + } + + const matched = jars.filter((e) => matchRegex.test(e.name)); + if (matched.length === 0) { + return { jarPath: '', error: 'Dynamic JAR: no files matched the pattern.' }; + } + + let chosen: string; + + if (res.strategy === 'latest-modified') { + const withMtime = matched.map((e) => { + const full = path.join(res.baseDir, e.name); + try { + return { name: e.name, mtime: fs.statSync(full).mtimeMs }; + } catch { + return { name: e.name, mtime: 0 }; + } + }); + chosen = withMtime.sort((a, b) => b.mtime - a.mtime)[0].name; + } else if (res.strategy === 'regex') { + chosen = matched[0].name; + } else { + const versionRegex = patternToRegex(res.pattern); + const withVersions = matched.map((e) => { + const m = versionRegex.exec(e.name); + return { name: e.name, version: parseVersion(m?.[1] ?? '') }; + }); + withVersions.sort((a, b) => compareVersionArrays(a.version, b.version)); + chosen = withVersions[0].name; + } + + return { jarPath: path.join(res.baseDir, chosen) }; +} + class ProcessManager { private processes = new Map(); private activityLog: ProcessLogEntry[] = []; @@ -48,27 +133,30 @@ class ProcessManager { this.window = win; } - private buildArgs(profile: Profile): { cmd: string; args: string[] } { + private buildArgs(profile: Profile, resolvedJarPath: string): { cmd: string; args: string[] } { const cmd = profile.javaPath || 'java'; const args: string[] = []; for (const a of profile.jvmArgs) if (a.enabled && a.value.trim()) args.push(a.value.trim()); for (const p of profile.systemProperties) if (p.enabled && p.key.trim()) args.push(p.value.trim() ? `-D${p.key.trim()}=${p.value.trim()}` : `-D${p.key.trim()}`); - args.push('-jar', profile.jarPath); + args.push('-jar', resolvedJarPath); for (const a of profile.programArgs) if (a.enabled && a.value.trim()) args.push(a.value.trim()); return { cmd, args }; } start(profile: Profile): { ok: boolean; error?: string } { if (this.processes.has(profile.id)) return { ok: false, error: 'Process already running' }; - if (!profile.jarPath) return { ok: false, error: 'No JAR file specified' }; + + const { jarPath, error: resolveError } = resolveJarPath(profile); + if (resolveError) return { ok: false, error: resolveError }; + if (!jarPath) return { ok: false, error: 'No JAR file specified' }; this.cancelRestartTimer(profile.id); this.profileSnapshots.set(profile.id, profile); - const { cmd, args } = this.buildArgs(profile); - const cwd = profile.workingDir || path.dirname(profile.jarPath); + const { cmd, args } = this.buildArgs(profile, jarPath); + const cwd = profile.workingDir || path.dirname(jarPath); this.pushSystem('start', profile.id, 'pending', `Starting: ${cmd} ${args.join(' ')}`); this.pushSystem('info-workdir', profile.id, 'pending', `Working dir: ${cwd}`); @@ -92,7 +180,7 @@ class ProcessManager { process: proc, profileId: profile.id, profileName: profile.name, - jarPath: profile.jarPath, + jarPath, startedAt: Date.now(), intentionallyStopped: false, }; @@ -110,7 +198,7 @@ class ProcessManager { id: uuidv4(), profileId: profile.id, profileName: profile.name, - jarPath: profile.jarPath, + jarPath, pid, startedAt: managed.startedAt, }; @@ -253,8 +341,6 @@ class ProcessManager { this.activityLog = []; } - // ── Process Scanner ────────────────────────────────────────────────────────── - private isProtected(name: string, cmd: string): boolean { return PROTECTED_PROCESS_NAMES.some( (n) => @@ -416,7 +502,6 @@ class ProcessManager { } } - // Only kills non-protected java processes killAllJava(): { ok: boolean; killed: number } { const procs = this.scanAllProcesses().filter((p) => p.isJava && !p.protected); let killed = 0; diff --git a/src/main/RestAPI.routes.ts b/src/main/RestAPI.routes.ts deleted file mode 100644 index cb82f2c..0000000 --- a/src/main/RestAPI.routes.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { routeConfig, type RouteKey } from './shared/config/API.config'; -import { ok, err } from './RestAPI'; -import { getAllProfiles, saveProfile, deleteProfile, getSettings, saveSettings } from './Store'; -import { processManager } from './ProcessManager'; -import { v4 as uuidv4 } from 'uuid'; -import type http from 'http'; -import { Profile } from './shared/types/Profile.types'; -import { AppSettings } from './shared/types/App.types'; - -// ─── Context ────────────────────────────────────────────────────────────────── - -type Params = Record; - -export interface Context { - req: http.IncomingMessage; - res: http.ServerResponse; - params: Params; - body: unknown; -} - -type RouteHandler = (ctx: Context) => void | Promise; - -// ─── Typed route builder ────────────────────────────────────────────────────── - -type BuiltRoute = (typeof routeConfig)[K] & { - handler: RouteHandler; -}; - -function defineRoute(key: K, handler: RouteHandler): BuiltRoute { - return { - ...routeConfig[key], - handler, - }; -} - -// ─── Routes ─────────────────────────────────────────────────────────────────── - -export const routes: { [K in RouteKey]: BuiltRoute } = { - status: defineRoute('status', ({ res }) => - ok(res, { - ok: true, - version: process.env.npm_package_version ?? 'unknown', - profiles: getAllProfiles().length, - running: processManager.getStates().filter((s) => s.running).length, - }) - ), - - profiles_list: defineRoute('profiles_list', ({ res }) => ok(res, getAllProfiles())), - - profiles_get: defineRoute('profiles_get', ({ res, params }) => { - const p = getAllProfiles().find((p) => p.id === params.id); - p ? ok(res, p) : err(res, 'Profile not found', 404); - }), - - profiles_create: defineRoute('profiles_create', ({ res, body }) => { - const b = body as Partial; - - const p: Profile = { - id: uuidv4(), - name: b.name ?? 'New Profile', - jarPath: b.jarPath ?? '', - workingDir: b.workingDir ?? '', - jvmArgs: b.jvmArgs ?? [], - systemProperties: b.systemProperties ?? [], - programArgs: b.programArgs ?? [], - javaPath: b.javaPath ?? '', - autoStart: b.autoStart ?? false, - autoRestart: b.autoRestart ?? false, - autoRestartInterval: b.autoRestartInterval ?? 10, - color: b.color ?? '#4ade80', - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - saveProfile(p); - ok(res, p, 201); - }), - - profiles_update: defineRoute('profiles_update', ({ res, params, body }) => { - const existing = getAllProfiles().find((p) => p.id === params.id); - if (!existing) return err(res, 'Profile not found', 404); - - const updated: Profile = { - ...existing, - ...(body as Partial), - id: params.id, - updatedAt: Date.now(), - }; - - saveProfile(updated); - processManager.updateProfileSnapshot(updated); - ok(res, updated); - }), - - profiles_delete: defineRoute('profiles_delete', ({ res, params }) => { - if (!getAllProfiles().find((p) => p.id === params.id)) - return err(res, 'Profile not found', 404); - - deleteProfile(params.id); - ok(res); - }), - - processes_list: defineRoute('processes_list', ({ res }) => ok(res, processManager.getStates())), - - processes_log: defineRoute('processes_log', ({ res }) => - ok(res, processManager.getActivityLog()) - ), - - processes_start: defineRoute('processes_start', ({ res, params }) => { - const p = getAllProfiles().find((p) => p.id === params.id); - if (!p) return err(res, 'Profile not found', 404); - ok(res, processManager.start(p)); - }), - - processes_stop: defineRoute('processes_stop', ({ res, params }) => - ok(res, processManager.stop(params.id)) - ), - - processes_clear: defineRoute('processes_clear', ({ res, params }) => { - processManager.clearConsoleForProfile(params.id); - ok(res); - }), - - settings_get: defineRoute('settings_get', ({ res }) => ok(res, getSettings())), - - settings_update: defineRoute('settings_update', ({ res, body }) => { - const updated: AppSettings = { - ...getSettings(), - ...(body as Partial), - }; - - saveSettings(updated); - ok(res, updated); - }), -}; diff --git a/src/main/RestAPI.ts b/src/main/RestAPI.ts index dde996d..7c8445d 100644 --- a/src/main/RestAPI.ts +++ b/src/main/RestAPI.ts @@ -1,25 +1,7 @@ import http from 'http'; -import { routes } from './RestAPI.routes'; -import { routeConfig } from './shared/config/API.config'; -import { getSettings } from './Store'; -import { REST_API_CONFIG } from './shared/config/API.config'; - -type Params = Record; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -type CompiledRoute = { - method: string; - path: string; - pattern: RegExp; - keys: string[]; - handler: (ctx: { - req: http.IncomingMessage; - res: http.ServerResponse; - params: Params; - body: unknown; - }) => void | Promise; -}; +import { routes } from './rest-api/_index'; +import { REST_API_CONFIG, routeConfig, RouteKey } from './shared/config/API.config'; +import { CompiledRoute, Params } from './shared/types/RestAPI.types'; // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/src/main/ipc/JarResolution.ipc.ts b/src/main/ipc/JarResolution.ipc.ts new file mode 100644 index 0000000..3f6817f --- /dev/null +++ b/src/main/ipc/JarResolution.ipc.ts @@ -0,0 +1,99 @@ +import fs from 'fs'; +import path from 'path'; +import type { RouteMap } from '../IPCController'; +import type { JarResolutionConfig, JarResolutionResult } from '../shared/types/JarResolution.types'; +import { patternToRegex } from '../shared/config/JarResolution.config'; + +function parseVersion(str: string): number[] { + return str + .split(/[.\-_]/) + .map((p) => parseInt(p, 10)) + .filter((n) => !isNaN(n)); +} + +function compareVersionArrays(a: number[], b: number[]): number { + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const diff = (b[i] ?? 0) - (a[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +function resolveJar(config: JarResolutionConfig): JarResolutionResult { + const { baseDir, pattern, strategy, regexOverride } = config; + + if (!baseDir) return { ok: false, error: 'No base directory specified.' }; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(baseDir, { withFileTypes: true }); + } catch { + return { ok: false, error: `Cannot read directory: ${baseDir}` }; + } + + const fileEntries = entries.filter((e) => e.isFile() && e.name.endsWith('.jar')); + + let matchRegex: RegExp; + if (strategy === 'regex' && regexOverride?.trim()) { + try { + matchRegex = new RegExp(regexOverride.trim(), 'i'); + } catch { + return { ok: false, error: 'Invalid regular expression.' }; + } + } else { + matchRegex = patternToRegex(pattern); + } + + const matched = fileEntries.filter((e) => matchRegex.test(e.name)); + if (matched.length === 0) { + return { ok: false, error: 'No JAR files matched the pattern.', candidates: [] }; + } + + const candidates = matched.map((e) => e.name); + let chosen: string; + + if (strategy === 'latest-modified') { + const withMtime = matched.map((e) => { + const full = path.join(baseDir, e.name); + try { + return { name: e.name, mtime: fs.statSync(full).mtimeMs }; + } catch { + return { name: e.name, mtime: 0 }; + } + }); + chosen = withMtime.sort((a, b) => b.mtime - a.mtime)[0].name; + } else if (strategy === 'regex') { + chosen = candidates[0]; + } else { + // highest-version: extract version capture group from pattern regex + const versionRegex = patternToRegex(pattern); + const withVersions = matched.map((e) => { + const m = versionRegex.exec(e.name); + const vStr = m?.[1] ?? ''; + return { name: e.name, version: parseVersion(vStr) }; + }); + withVersions.sort((a, b) => compareVersionArrays(a.version, b.version)); + chosen = withVersions[0].name; + } + + return { + ok: true, + resolvedPath: path.join(baseDir, chosen), + candidates, + }; +} + +export const JarResolutionIPC = { + resolveJar: { + type: 'invoke', + channel: 'jarResolution:resolve', + handler: (_e: Electron.IpcMainInvokeEvent, config: JarResolutionConfig) => resolveJar(config), + }, + + previewCandidates: { + type: 'invoke', + channel: 'jarResolution:preview', + handler: (_e: Electron.IpcMainInvokeEvent, config: JarResolutionConfig) => resolveJar(config), + }, +} satisfies RouteMap; diff --git a/src/main/ipc/_index.ts b/src/main/ipc/_index.ts index dfec313..b51f91f 100644 --- a/src/main/ipc/_index.ts +++ b/src/main/ipc/_index.ts @@ -14,6 +14,7 @@ export { ProfileIPC } from './Profile.ipc'; export { SystemIPC, initSystemIPC } from './System.ipc'; export { WindowIPC, initWindowIPC } from './Window.ipc'; export { DevIPC, initDevIPC } from './Dev.ipc'; +export { JarResolutionIPC } from './JarResolution.ipc'; import { GitHubIPC } from './GitHub.ipc'; import { ProcessIPC } from './Process.ipc'; @@ -21,9 +22,18 @@ import { ProfileIPC } from './Profile.ipc'; import { SystemIPC } from './System.ipc'; import { WindowIPC } from './Window.ipc'; import { DevIPC } from './Dev.ipc'; +import { JarResolutionIPC } from './JarResolution.ipc'; import type { InferAPI } from '../IPCController'; -export const allRoutes = [GitHubIPC, ProcessIPC, ProfileIPC, SystemIPC, WindowIPC, DevIPC] as const; +export const allRoutes = [ + GitHubIPC, + ProcessIPC, + ProfileIPC, + SystemIPC, + WindowIPC, + DevIPC, + JarResolutionIPC, +] as const; export type API = InferAPI< typeof GitHubIPC & @@ -31,7 +41,8 @@ export type API = InferAPI< typeof ProfileIPC & typeof SystemIPC & typeof WindowIPC & - typeof DevIPC + typeof DevIPC & + typeof JarResolutionIPC >; export type Environment = InferAPI; diff --git a/src/main/main.ts b/src/main/main.ts index 2fbc4cc..795808d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -8,6 +8,7 @@ import { processManager } from './ProcessManager'; import { restApiServer } from './RestAPI'; import { registerIPC } from './IPCController'; import { getAllProfiles, getSettings, syncLoginItem } from './Store'; +import { hasJarConfigured } from './shared/types/Profile.types'; loadEnvironment(); @@ -160,7 +161,8 @@ if (!gotLock) { const settings = getSettings(); if (settings.restApiEnabled) restApiServer.start(settings.restApiPort); - for (const p of getAllProfiles()) if (p.autoStart && p.jarPath) processManager.start(p); + for (const p of getAllProfiles()) + if (p.autoStart && hasJarConfigured(p)) processManager.start(p); mainWindow?.webContents.on('did-finish-load', updateTrayMenu); processManager.setTrayUpdater(updateTrayMenu); diff --git a/src/main/rest-api/Base.routes.ts b/src/main/rest-api/Base.routes.ts new file mode 100644 index 0000000..de3292b --- /dev/null +++ b/src/main/rest-api/Base.routes.ts @@ -0,0 +1,29 @@ +import { ok } from '../RestAPI'; +import { processManager } from '../ProcessManager'; +import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; +import { getAllProfiles, getSettings, saveSettings } from '../Store'; +import { AppSettings } from '../shared/types/App.types'; +import { version } from '../../../package.json'; + +export const BaseRoutes: RouteMap = { + status: defineRoute('status', ({ res }) => + ok(res, { + ok: true, + version: version, + profiles: getAllProfiles().length, + running: processManager.getStates().filter((s) => s.running).length, + }) + ), + + settings_get: defineRoute('settings_get', ({ res }) => ok(res, getSettings())), + + settings_update: defineRoute('settings_update', ({ res, body }) => { + const updated: AppSettings = { + ...getSettings(), + ...(body as Partial), + }; + + saveSettings(updated); + ok(res, updated); + }), +}; diff --git a/src/main/rest-api/Process.routes.ts b/src/main/rest-api/Process.routes.ts new file mode 100644 index 0000000..5b6b239 --- /dev/null +++ b/src/main/rest-api/Process.routes.ts @@ -0,0 +1,27 @@ +import { err, ok } from '../RestAPI'; +import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; +import { processManager } from '../ProcessManager'; +import { getAllProfiles } from '../Store'; + +export const ProcessRoutes: RouteMap = { + processes_list: defineRoute('processes_list', ({ res }) => ok(res, processManager.getStates())), + + processes_log: defineRoute('processes_log', ({ res }) => + ok(res, processManager.getActivityLog()) + ), + + processes_start: defineRoute('processes_start', ({ res, params }) => { + const p = getAllProfiles().find((p) => p.id === params.id); + if (!p) return err(res, 'Profile not found', 404); + ok(res, processManager.start(p)); + }), + + processes_stop: defineRoute('processes_stop', ({ res, params }) => + ok(res, processManager.stop(params.id)) + ), + + processes_clear: defineRoute('processes_clear', ({ res, params }) => { + processManager.clearConsoleForProfile(params.id); + ok(res); + }), +}; diff --git a/src/main/rest-api/Profile.routes.ts b/src/main/rest-api/Profile.routes.ts new file mode 100644 index 0000000..a97afe2 --- /dev/null +++ b/src/main/rest-api/Profile.routes.ts @@ -0,0 +1,63 @@ +import { processManager } from '../ProcessManager'; +import { err, ok } from '../RestAPI'; +import { Profile } from '../shared/types/Profile.types'; +import { deleteProfile, getAllProfiles, saveProfile } from '../Store'; +import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; +import { v4 as uuidv4 } from 'uuid'; + +export const ProfileRoutes: RouteMap = { + profiles_list: defineRoute('profiles_list', ({ res }) => ok(res, getAllProfiles())), + + profiles_get: defineRoute('profiles_get', ({ res, params }) => { + const p = getAllProfiles().find((p) => p.id === params.id); + p ? ok(res, p) : err(res, 'Profile not found', 404); + }), + + profiles_create: defineRoute('profiles_create', ({ res, body }) => { + const b = body as Partial; + + const p: Profile = { + id: uuidv4(), + name: b.name ?? 'New Profile', + jarPath: b.jarPath ?? '', + workingDir: b.workingDir ?? '', + jvmArgs: b.jvmArgs ?? [], + systemProperties: b.systemProperties ?? [], + programArgs: b.programArgs ?? [], + javaPath: b.javaPath ?? '', + autoStart: b.autoStart ?? false, + autoRestart: b.autoRestart ?? false, + autoRestartInterval: b.autoRestartInterval ?? 10, + color: b.color ?? '#4ade80', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + saveProfile(p); + ok(res, p, 201); + }), + + profiles_update: defineRoute('profiles_update', ({ res, params, body }) => { + const existing = getAllProfiles().find((p) => p.id === params.id); + if (!existing) return err(res, 'Profile not found', 404); + + const updated: Profile = { + ...existing, + ...(body as Partial), + id: params.id, + updatedAt: Date.now(), + }; + + saveProfile(updated); + processManager.updateProfileSnapshot(updated); + ok(res, updated); + }), + + profiles_delete: defineRoute('profiles_delete', ({ res, params }) => { + if (!getAllProfiles().find((p) => p.id === params.id)) + return err(res, 'Profile not found', 404); + + deleteProfile(params.id); + ok(res); + }), +}; diff --git a/src/main/rest-api/_index.ts b/src/main/rest-api/_index.ts new file mode 100644 index 0000000..5f4f474 --- /dev/null +++ b/src/main/rest-api/_index.ts @@ -0,0 +1,19 @@ +import { RouteKey } from '../shared/config/API.config'; +import { RouteMap, BuiltRoute } from '../shared/types/RestAPI.types'; + +import { BaseRoutes } from './Base.routes'; +import { ProfileRoutes } from './Profile.routes'; +import { ProcessRoutes } from './Process.routes'; + +const merged: RouteMap = { + ...BaseRoutes, + ...ProfileRoutes, + ...ProcessRoutes, +}; + +export const routes: { [K in RouteKey]: BuiltRoute } = merged as any; + +const missing = Object.keys(merged).length !== Object.keys(merged).length; +if (missing) { + throw new Error('Some routes are missing handlers'); +} diff --git a/src/main/shared/config/API.config.ts b/src/main/shared/config/API.config.ts index fd683ca..0daff94 100644 --- a/src/main/shared/config/API.config.ts +++ b/src/main/shared/config/API.config.ts @@ -1,15 +1,10 @@ +import { RouteDefinition } from '../types/RestAPI.types'; + export const REST_API_CONFIG = { defaultPort: 4444, host: '127.0.0.1', } as const; -export type RouteDefinition = { - method: 'GET' | 'POST' | 'PUT' | 'DELETE'; - path: string; - description: string; - bodyTemplate?: string; -}; - export const routeConfig = { status: { method: 'GET', diff --git a/src/main/shared/config/FAQ.config.ts b/src/main/shared/config/FAQ.config.ts index feb5611..b29fc72 100644 --- a/src/main/shared/config/FAQ.config.ts +++ b/src/main/shared/config/FAQ.config.ts @@ -55,6 +55,10 @@ export const FAQ_TOPICS: FaqTopic[] = [ q: 'How can I quickly delete a profile?', a: 'Right-click a profile and press Delete. Hold Shift while clicking Delete to skip the confirmation and remove it instantly.', }, + { + q: 'How do I use "dynamic" JAR resolution?', + a: 'In Configure -> Files & Paths, select "Dynamic" as the JAR selection method. This enables automatic JAR detection in the working directory and lets you customize the search pattern. This is useful for projects that produce versioned JARs or have changing filenames. Change the "app" part in the filename pattern to the (static) prefix of your app and select the type of versioning to be used. You can also use regular expressions (RegExp) to gain full control over file discovery.', + }, ], }, { diff --git a/src/main/shared/config/JarResolution.config.ts b/src/main/shared/config/JarResolution.config.ts new file mode 100644 index 0000000..07e13a5 --- /dev/null +++ b/src/main/shared/config/JarResolution.config.ts @@ -0,0 +1,36 @@ +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/types/JarResolution.types.ts b/src/main/shared/types/JarResolution.types.ts new file mode 100644 index 0000000..69da177 --- /dev/null +++ b/src/main/shared/types/JarResolution.types.ts @@ -0,0 +1,16 @@ +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/Profile.types.ts b/src/main/shared/types/Profile.types.ts index 6a11c49..55f116a 100644 --- a/src/main/shared/types/Profile.types.ts +++ b/src/main/shared/types/Profile.types.ts @@ -1,3 +1,5 @@ +import type { JarResolutionConfig } from './JarResolution.types'; + export interface SystemProperty { key: string; value: string; @@ -28,4 +30,8 @@ export interface Profile { autoRestart: boolean; autoRestartInterval: number; order?: number; + jarResolution?: JarResolutionConfig; } + +export const hasJarConfigured = (p: Profile) => + p.jarResolution?.enabled ? !!p.jarResolution.baseDir : !!p.jarPath; diff --git a/src/main/shared/types/RestAPI.types.ts b/src/main/shared/types/RestAPI.types.ts new file mode 100644 index 0000000..4be8d59 --- /dev/null +++ b/src/main/shared/types/RestAPI.types.ts @@ -0,0 +1,50 @@ +import http from 'http'; +import { routeConfig, RouteKey } from '../config/API.config'; + +export type Params = Record; + +type RouteMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +export type RouteDefinition = { + method: RouteMethod; + path: string; + description: string; + bodyTemplate?: string; +}; + +export type CompiledRoute = { + method: RouteMethod; + path: string; + pattern: RegExp; + keys: string[]; + handler: (ctx: { + req: http.IncomingMessage; + res: http.ServerResponse; + params: Params; + body: unknown; + }) => void | Promise; +}; + +export interface Context { + req: http.IncomingMessage; + res: http.ServerResponse; + params: Params; + body: unknown; +} + +export type RouteHandler = (ctx: Context) => void | Promise; + +export type BuiltRoute = (typeof routeConfig)[K] & { + handler: RouteHandler; +}; + +export function defineRoute(key: K, handler: RouteHandler): BuiltRoute { + return { + ...routeConfig[key], + handler, + }; +} + +export type RouteMap = { + [K in RouteKey]?: BuiltRoute; +}; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 49e47ec..212bab4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { AppProvider } from './store/AppStore'; +import { AppProvider } from './AppProvider'; import { TitleBar } from './components/layout/TitleBar'; import { MainLayout } from './components/MainLayout'; import { DevModeGate } from './components/developer/DevModeGate'; diff --git a/src/renderer/store/AppStore.tsx b/src/renderer/AppProvider.tsx similarity index 97% rename from src/renderer/store/AppStore.tsx rename to src/renderer/AppProvider.tsx index 79ce447..a2ca714 100644 --- a/src/renderer/store/AppStore.tsx +++ b/src/renderer/AppProvider.tsx @@ -1,15 +1,15 @@ -import React, { +import { createContext, + useCallback, useContext, - useReducer, useEffect, - useCallback, + useReducer, type ReactNode, } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { AppSettings } from '../../main/shared/types/App.types'; -import { ConsoleLine, ProcessState } from '../../main/shared/types/Process.types'; -import { Profile } from '../../main/shared/types/Profile.types'; +import { AppSettings } from '../main/shared/types/App.types'; +import { ConsoleLine, ProcessState } from '../main/shared/types/Process.types'; +import { Profile } from '../main/shared/types/Profile.types'; // ─── Session log helpers ────────────────────────────────────────────────────── diff --git a/src/renderer/components/MainLayout.tsx b/src/renderer/components/MainLayout.tsx index b458e53..50132e4 100644 --- a/src/renderer/components/MainLayout.tsx +++ b/src/renderer/components/MainLayout.tsx @@ -9,7 +9,7 @@ import { UtilitiesTab } from './utils/UtilitiesTab'; import { FaqPanel } from './faq/FaqPanel'; import { DeveloperTab } from './developer/DeveloperTab'; import { PanelHeader } from './layout/PanelHeader'; -import { useApp } from '../store/AppStore'; +import { useApp } from '../AppProvider'; import { useDevMode } from '../hooks/useDevMode'; import { VscTerminal, VscAccount } from 'react-icons/vsc'; import { LuList } from 'react-icons/lu'; diff --git a/src/renderer/components/common/TitleBar.tsx b/src/renderer/components/common/TitleBar.tsx index 7168620..3edec18 100644 --- a/src/renderer/components/common/TitleBar.tsx +++ b/src/renderer/components/common/TitleBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useApp } from '../../store/AppStore'; +import { useApp } from '../../AppProvider'; import { VscChromeMinimize, VscChromeClose } from 'react-icons/vsc'; export function TitleBar() { diff --git a/src/renderer/components/console/ConsoleTab.tsx b/src/renderer/components/console/ConsoleTab.tsx index ac5e9f7..4cc4ff0 100644 --- a/src/renderer/components/console/ConsoleTab.tsx +++ b/src/renderer/components/console/ConsoleTab.tsx @@ -1,10 +1,9 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { useApp } from '../../store/AppStore'; -import { ConsoleToolbar } from './ConsoleToolbar'; -import { ConsoleSearch } from './ConsoleSearch'; -import { ConsoleOutput } from './ConsoleOutput'; -import { ConsoleInput } from './ConsoleInput'; -import { VscClose } from 'react-icons/vsc'; +import React, { useRef, useEffect, useState, useCallback, useMemo, KeyboardEvent } from 'react'; +import { useApp } from '../../AppProvider'; +import { Button } from '../common/Button'; +import { VscSearch, VscChevronUp, VscChevronDown, VscClose } from 'react-icons/vsc'; +import { ConsoleLine } from '../../../main/shared/types/Process.types'; +import { hasJarConfigured } from '../../../main/shared/types/Profile.types'; export function ConsoleTab() { const { state, activeProfile, startProcess, stopProcess, sendInput, clearConsole, isRunning } = @@ -16,44 +15,71 @@ export function ConsoleTab() { const settings = state.settings; const color = activeProfile?.color ?? '#4ade80'; const processState = state.processStates.find((s) => s.profileId === profileId); + const pid = processState?.pid; + const [inputValue, setInputValue] = useState(''); + const [historyIdx, setHistoryIdx] = useState(-1); + const [cmdHistory, setCmdHistory] = useState([]); + const [autoScroll, setAutoScroll] = useState(true); const [starting, setStarting] = useState(false); const [errorMsg, setErrorMsg] = useState(null); - const [cmdHistory, setCmdHistory] = useState([]); - - // Search state const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchIdx, setSearchIdx] = useState(0); - // Auto-scroll state (owned here, passed down) - const [autoScroll, setAutoScroll] = useState(true); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const searchRef = useRef(null); + const bottomRef = useRef(null); + const matchRefs = useRef<(HTMLDivElement | null)[]>([]); - // Reset on profile change useEffect(() => { + setInputValue(''); + setHistoryIdx(-1); setErrorMsg(null); setSearchOpen(false); setSearchQuery(''); setSearchIdx(0); - setAutoScroll(true); }, [profileId]); - // Global keyboard shortcuts useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.ctrlKey && e.key === 'f') { - e.preventDefault(); - openSearch(); - } - if (e.key === 'Escape' && searchOpen) closeSearch(); - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [searchOpen]); + if (autoScroll && !searchOpen) bottomRef.current?.scrollIntoView({ behavior: 'instant' }); + }, [lines.length, autoScroll, searchOpen]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 40); + }, []); + + const searchTerm = searchQuery.trim().toLowerCase(); + + const matchIndices = useMemo(() => { + if (!searchTerm) return []; + return lines.reduce((acc, line, i) => { + if (line.text.toLowerCase().includes(searchTerm)) acc.push(i); + return acc; + }, []); + }, [lines, searchTerm]); + + const clampedIdx = + matchIndices.length > 0 + ? ((searchIdx % matchIndices.length) + matchIndices.length) % matchIndices.length + : 0; + + const scrollToMatch = useCallback((idx: number) => { + const el = matchRefs.current[idx]; + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, []); + + useEffect(() => { + if (matchIndices.length > 0) scrollToMatch(clampedIdx); + }, [clampedIdx, matchIndices, scrollToMatch]); const openSearch = useCallback(() => { setSearchOpen(true); setAutoScroll(false); + setTimeout(() => searchRef.current?.focus(), 50); }, []); const closeSearch = useCallback(() => { @@ -63,14 +89,17 @@ export function ConsoleTab() { setAutoScroll(true); }, []); + const goNext = useCallback(() => setSearchIdx((i) => i + 1), []); + const goPrev = useCallback(() => setSearchIdx((i) => i - 1), []); + const handleToggle = useCallback(async () => { if (!activeProfile) return; setErrorMsg(null); if (running) { await stopProcess(profileId); } else { - if (!activeProfile.jarPath) { - setErrorMsg('No JAR file selected. Go to Configure to set one.'); + if (!hasJarConfigured(activeProfile)) { + setErrorMsg('No JAR configured. Go to Configure to set one.'); return; } setStarting(true); @@ -80,17 +109,65 @@ export function ConsoleTab() { } }, [activeProfile, running, profileId, stopProcess, startProcess]); - const handleSend = useCallback( - async (cmd: string) => { - await sendInput(profileId, cmd); - setCmdHistory((prev) => - [cmd, ...prev.filter((c) => c !== cmd)].slice(0, settings?.consoleHistorySize ?? 200) - ); + const handleSend = useCallback(async () => { + const cmd = inputValue.trim(); + if (!cmd || !running) return; + await sendInput(profileId, cmd); + setCmdHistory((prev) => + [cmd, ...prev.filter((c) => c !== cmd)].slice(0, settings?.consoleHistorySize ?? 200) + ); + setInputValue(''); + setHistoryIdx(-1); + }, [inputValue, running, profileId, sendInput, settings]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSend(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + const n = Math.min(historyIdx + 1, cmdHistory.length - 1); + setHistoryIdx(n); + setInputValue(cmdHistory[n] ?? ''); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + const n = Math.max(historyIdx - 1, -1); + setHistoryIdx(n); + setInputValue(n === -1 ? '' : (cmdHistory[n] ?? '')); + return; + } + if (e.key === 'l' && e.ctrlKey) { + e.preventDefault(); + clearConsole(profileId); + } + if (e.key === 'f' && e.ctrlKey) { + e.preventDefault(); + openSearch(); + } }, - [profileId, sendInput, settings] + [handleSend, historyIdx, cmdHistory, clearConsole, profileId, openSearch] ); - const handleClear = useCallback(() => clearConsole(profileId), [clearConsole, profileId]); + useEffect(() => { + const handler = (e: globalThis.KeyboardEvent) => { + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + openSearch(); + } + if (e.key === 'Escape' && searchOpen) closeSearch(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [openSearch, closeSearch, searchOpen]); + + const fontSize = settings?.consoleFontSize ?? 13; + const wordWrap = settings?.consoleWordWrap ?? false; + const lineNums = settings?.consoleLineNumbers ?? false; if (!activeProfile) { return ( @@ -100,45 +177,119 @@ export function ConsoleTab() { ); } - const fontSize = settings?.consoleFontSize ?? 13; - const wordWrap = settings?.consoleWordWrap ?? false; - const lineNumbers = settings?.consoleLineNumbers ?? false; - - // Calculate match count for search display - const searchTerm = searchQuery.trim().toLowerCase(); - const matchCount = searchTerm - ? lines.filter((l) => l.text.toLowerCase().includes(searchTerm)).length - : 0; - const clampedIdx = matchCount > 0 ? ((searchIdx % matchCount) + matchCount) % matchCount : 0; + matchRefs.current = new Array(matchIndices.length).fill(null); return ( -
- setAutoScroll(true)} - /> +
+
+ + + {running && pid && ( + + + PID {pid} + + )} + +
+ + {!autoScroll && !searchOpen && ( + + )} + + + + + + + {lines.length.toLocaleString()} lines + +
{searchOpen && ( - { - setSearchQuery(q); - setSearchIdx(0); - }} - onNext={() => setSearchIdx((i) => i + 1)} - onPrev={() => setSearchIdx((i) => i - 1)} - onClose={closeSearch} - /> +
+ { + setSearchQuery(e.target.value); + setSearchIdx(0); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.shiftKey ? goPrev() : goNext(); + } + if (e.key === 'Escape') closeSearch(); + }} + placeholder="Search console... (Enter next, Shift+Enter prev)" + className="flex-1 bg-base-950 border border-surface-border rounded px-2.5 py-1 + text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40" + /> + {searchTerm && ( + + {matchIndices.length === 0 + ? 'No matches' + : `${clampedIdx + 1} / ${matchIndices.length}`} + + )} + + + +
)} {errorMsg && ( @@ -150,27 +301,148 @@ export function ConsoleTab() {
)} - - - +
!searchOpen && inputRef.current?.focus()} + className="flex-1 overflow-y-auto overflow-x-auto bg-base-950 select-text" + style={{ fontSize, lineHeight: 1.6, fontFamily: 'monospace' }} + > +
+ {lines.length === 0 && ( +
+ {running ? 'Waiting for output...' : 'Process not running. Press Run to start.'} +
+ )} + {lines.map((line, i) => { + const matchPos = matchIndices.indexOf(i); + const isCurrentMatch = matchPos === clampedIdx && matchPos !== -1; + const isAnyMatch = matchPos !== -1; + + return ( + { + matchRefs.current[matchPos] = el; + } + : undefined + } + /> + ); + })} +
+
+
+ +
+ + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + disabled={!running} + placeholder={ + running + ? 'Send command... (up/down history, Ctrl+L clear, Ctrl+F search)' + : 'Start the process to send commands' + } + className="flex-1 bg-transparent text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none disabled:opacity-40" + style={{ fontSize }} + /> +
+
+ ); +} + +const LINE_COLORS: Record = { + stdout: 'text-text-primary', + stderr: 'text-console-error', + input: 'text-console-input', + system: 'text-text-muted', +}; + +const ConsoleLineRow = React.forwardRef< + HTMLDivElement, + { + line: ConsoleLine; + lineNum: number; + showLineNum: boolean; + wordWrap: boolean; + searchTerm: string; + isCurrentMatch: boolean; + isAnyMatch: boolean; + } +>(({ line, lineNum, showLineNum, wordWrap, searchTerm, isCurrentMatch, isAnyMatch }, ref) => { + const text = line.text || ' '; + const content = + searchTerm && isAnyMatch ? renderHighlighted(text, searchTerm, isCurrentMatch) : text; + + return ( +
+ {showLineNum && ( + + {lineNum} + + )} + + {content} +
); +}); +ConsoleLineRow.displayName = 'ConsoleLineRow'; + +function renderHighlighted(text: string, term: string, isCurrent: boolean): React.ReactNode { + const parts: React.ReactNode[] = []; + const lower = text.toLowerCase(); + let last = 0; + let idx = lower.indexOf(term); + let key = 0; + + while (idx !== -1) { + if (idx > last) parts.push(text.slice(last, idx)); + parts.push( + + {text.slice(idx, idx + term.length)} + + ); + last = idx + term.length; + idx = lower.indexOf(term, last); + } + if (last < text.length) parts.push(text.slice(last)); + return parts; } diff --git a/src/renderer/components/developer/DevApiExplorer.tsx b/src/renderer/components/developer/DevApiExplorer.tsx index fd57fef..c2a788f 100644 --- a/src/renderer/components/developer/DevApiExplorer.tsx +++ b/src/renderer/components/developer/DevApiExplorer.tsx @@ -1,13 +1,10 @@ import React, { useState, useCallback } from 'react'; import { VscCheck, VscCopy, VscPlay, VscEdit, VscCode } from 'react-icons/vsc'; -import { - routeConfig, - RouteDefinition, - REST_API_CONFIG, -} from '../../../main/shared/config/API.config'; -import { useApp } from '../../store/AppStore'; +import { routeConfig, REST_API_CONFIG } from '../../../main/shared/config/API.config'; +import { useApp } from '../../AppProvider'; import { Button } from '../common/Button'; import { ContextMenu, ContextMenuItem } from '../common/ContextMenu'; +import { RouteDefinition } from '../../..//main/shared/types/RestAPI.types'; const METHOD_COLORS: Record = { GET: 'text-accent border-accent/30 bg-accent/10', diff --git a/src/renderer/components/developer/DevDashboard.tsx b/src/renderer/components/developer/DevDashboard.tsx index b2fb2bb..b0e7209 100644 --- a/src/renderer/components/developer/DevDashboard.tsx +++ b/src/renderer/components/developer/DevDashboard.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useApp } from '../../store/AppStore'; +import { useApp } from '../../AppProvider'; import { VscCircleFilled, VscCircle } from 'react-icons/vsc'; import { JRCEnvironment } from '../../../main/shared/types/App.types'; diff --git a/src/renderer/components/developer/DevDiagnostics.tsx b/src/renderer/components/developer/DevDiagnostics.tsx index 6184014..e1fc0f3 100644 --- a/src/renderer/components/developer/DevDiagnostics.tsx +++ b/src/renderer/components/developer/DevDiagnostics.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { VscCheck, VscCopy } from 'react-icons/vsc'; -import { useApp } from '../../store/AppStore'; +import { useApp } from '../../AppProvider'; import { Button } from '../common/Button'; declare const __APP_VERSION__: string; diff --git a/src/renderer/components/developer/DevStorage.tsx b/src/renderer/components/developer/DevStorage.tsx index d12af24..a525546 100644 --- a/src/renderer/components/developer/DevStorage.tsx +++ b/src/renderer/components/developer/DevStorage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Button } from '../common/Button'; import { Dialog } from '../common/Dialog'; -import { useApp } from '../../store/AppStore'; +import { useApp } from '../../AppProvider'; import { VscRefresh, VscTrash } from 'react-icons/vsc'; interface SessionEntry { diff --git a/src/renderer/components/layout/TitleBar.tsx b/src/renderer/components/layout/TitleBar.tsx index 5ce1d08..a10f774 100644 --- a/src/renderer/components/layout/TitleBar.tsx +++ b/src/renderer/components/layout/TitleBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useApp } from '../../store/AppStore'; +import { useApp } from '../../AppProvider'; import { VscChromeMinimize, VscChromeClose } from 'react-icons/vsc'; export function TitleBar() { diff --git a/src/renderer/components/profiles/ConfigTab.tsx b/src/renderer/components/profiles/ConfigTab.tsx index d5b948a..af0a65b 100644 --- a/src/renderer/components/profiles/ConfigTab.tsx +++ b/src/renderer/components/profiles/ConfigTab.tsx @@ -1,13 +1,14 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useApp } from '../../store/AppStore'; -import { Button } from '../common/Button'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Profile } from '../../../main/shared/types/Profile.types'; +import { useApp } from '../../AppProvider'; import { ArgList } from '../common/ArgList'; -import { PropList } from '../common/PropList'; +import { Button } from '../common/Button'; import { Dialog } from '../common/Dialog'; -import { GeneralSection } from './GeneralSection'; -import { FilesSection } from './FilesSection'; -import { Profile } from '../../../main/shared/types/Profile.types'; +import { Input } from '../common/Input'; +import { PropList } from '../common/PropList'; +import { Toggle } from '../common/Toggle'; +import { FolderBtn } from './jar/FolderBtn'; +import { JarSelector } from './jar/JarSelector'; type Section = 'general' | 'files' | 'jvm' | 'props' | 'args'; @@ -19,20 +20,8 @@ const SECTIONS: { id: Section; label: string }[] = [ { id: 'args', label: 'Program Args' }, ]; -function buildCmdPreview(p: Profile): string { - const parts: string[] = [p.javaPath || 'java']; - p.jvmArgs.filter((a) => a.enabled && a.value).forEach((a) => parts.push(a.value)); - p.systemProperties - .filter((a) => a.enabled && a.key) - .forEach((a) => parts.push(a.value ? `-D${a.key}=${a.value}` : `-D${a.key}`)); - parts.push('-jar', p.jarPath || ''); - p.programArgs.filter((a) => a.enabled && a.value).forEach((a) => parts.push(a.value)); - return parts.join(' '); -} - export function ConfigTab() { const { activeProfile, saveProfile, isRunning, startProcess, stopProcess } = useApp(); - const navigate = useNavigate(); const [draft, setDraft] = useState(null); const [saved, setSaved] = useState(false); @@ -96,8 +85,7 @@ export function ConfigTab() { return ( <> -
- {/* Header */} +

{draft.name}

{isDirty && !saved && ( @@ -116,7 +104,6 @@ export function ConfigTab() {
- {/* Section tabs */}
{SECTIONS.map((s) => ( +
+ )} +
+ ); +} + +function FilesSection({ + draft, + update, +}: { + draft: Profile; + update: (p: Partial) => void; +}) { + const handlePickJar = async () => { + const p = await window.api.pickJar(); + if (p) update({ jarPath: p }); + }; + const handlePickDir = async () => { + const p = await window.api.pickDir(); + if (p) update({ workingDir: p }); + }; + const handlePickResolutionDir = async () => { + const p = await window.api.pickDir(); + if (p) + update({ + jarResolution: { + ...(draft.jarResolution ?? { + enabled: true, + pattern: 'app-{version}.jar', + strategy: 'highest-version', + regexOverride: '', + }), + baseDir: p, + }, + }); + }; + const handlePickJava = async () => { + const p = await window.api.pickJava(); + if (p) update({ javaPath: p }); + }; + + return ( +
+ update({ jarPath })} + onResolutionChange={(jarResolution) => update({ jarResolution })} + onPickJar={handlePickJar} + onPickDir={handlePickResolutionDir} + /> + update({ workingDir: e.target.value })} + placeholder="Defaults to JAR directory" + hint="Leave empty to use the directory containing the JAR" + rightElement={} + /> + update({ javaPath: e.target.value })} + placeholder="java (uses system PATH)" + hint="Leave empty to use the java found on PATH" + rightElement={} + /> +
+ ); +} + function ArgSection({ title, hint, @@ -240,3 +376,19 @@ function ArgSection({
); } + +function buildCmdPreview(p: Profile): string { + const isDynamic = p.jarResolution?.enabled; + const jarDisplay = isDynamic + ? `` + : p.jarPath || ''; + + const parts: string[] = [p.javaPath || 'java']; + p.jvmArgs.filter((a) => a.enabled && a.value).forEach((a) => parts.push(a.value)); + p.systemProperties + .filter((a) => a.enabled && a.key) + .forEach((a) => parts.push(a.value ? `-D${a.key}=${a.value}` : `-D${a.key}`)); + parts.push('-jar', jarDisplay); + p.programArgs.filter((a) => a.enabled && a.value).forEach((a) => parts.push(a.value)); + return parts.join(' '); +} diff --git a/src/renderer/components/profiles/ProfileSidebar.tsx b/src/renderer/components/profiles/ProfileSidebar.tsx index 8498af0..7f72149 100644 --- a/src/renderer/components/profiles/ProfileSidebar.tsx +++ b/src/renderer/components/profiles/ProfileSidebar.tsx @@ -14,12 +14,12 @@ import { VscLayout, VscCode, } from 'react-icons/vsc'; -import { useApp, PROFILE_COLORS } from '../../store/AppStore'; +import { useApp, PROFILE_COLORS } from '../../AppProvider'; import { useDevMode } from '../../hooks/useDevMode'; import { Dialog } from '../common/Dialog'; import { ContextMenu, ContextMenuItem } from '../common/ContextMenu'; import { TemplateModal } from './TemplateModal'; -import { Profile } from '../../../main/shared/types/Profile.types'; +import { hasJarConfigured, Profile } from '../../../main/shared/types/Profile.types'; interface Props { activeSidePanel: string | null; @@ -65,7 +65,7 @@ export function ProfileSidebar({ activeSidePanel }: Props) { const handleStart = useCallback( async (profile: Profile) => { - if (!profile.jarPath) { + if (!hasJarConfigured(profile)) { setActionError(`"${profile.name}" has no JAR configured.`); return; } @@ -95,7 +95,7 @@ export function ProfileSidebar({ activeSidePanel }: Props) { : { label: 'Start', icon: , - disabled: !ctxProfile.jarPath, + disabled: !hasJarConfigured(ctxProfile), onClick: () => handleStart(ctxProfile), }, { type: 'separator' }, @@ -268,7 +268,10 @@ function ProfileItem({ onContextMenu: (e: React.MouseEvent) => void; }) { const color = profile.color || PROFILE_COLORS[0]; - const jarName = profile.jarPath?.split(/[/\\]/).pop() ?? ''; + const jarName = profile.jarResolution?.enabled + ? 'iwin' + : (profile.jarPath?.split(/[/\\]/).pop() ?? ''); + return ( + ))} +
+
+ + {config.strategy !== 'regex' && ( + onChange({ pattern: e.target.value })} + placeholder="app-{version}.jar" + hint='Use {version} as a placeholder — e.g. "myapp-{version}.jar"' + /> + )} + + {config.strategy === 'regex' && ( + onChange({ regexOverride: e.target.value })} + placeholder="myapp-\d+\.\d+\.jar" + hint="Matched against filenames in the base directory (case-insensitive)" + /> + )} + + + + ); +} diff --git a/src/renderer/components/profiles/jar/FolderBtn.tsx b/src/renderer/components/profiles/jar/FolderBtn.tsx new file mode 100644 index 0000000..80889eb --- /dev/null +++ b/src/renderer/components/profiles/jar/FolderBtn.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +interface Props { + onClick: () => void; +} + +export function FolderBtn({ onClick }: Props) { + return ( + + ); +} diff --git a/src/renderer/components/profiles/jar/JarSelector.tsx b/src/renderer/components/profiles/jar/JarSelector.tsx new file mode 100644 index 0000000..00b8496 --- /dev/null +++ b/src/renderer/components/profiles/jar/JarSelector.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { StaticJarPicker } from './StaticJarPicker'; +import { DynamicJarConfig } from './DynamicJarConfig'; +import { DEFAULT_JAR_RESOLUTION } from '../../../../main/shared/config/JarResolution.config'; +import type { JarResolutionConfig } from '../../../../main/shared/types/JarResolution.types'; + +interface Props { + jarPath: string; + resolution: JarResolutionConfig | undefined; + onJarPathChange: (path: string) => void; + onResolutionChange: (config: JarResolutionConfig) => void; + onPickJar: () => void; + onPickDir: () => void; +} + +export function JarSelector({ + jarPath, + resolution, + onJarPathChange, + onResolutionChange, + onPickJar, + onPickDir, +}: Props) { + const config = resolution ?? DEFAULT_JAR_RESOLUTION; + const isDynamic = config.enabled; + + const setDynamic = (enabled: boolean) => { + onResolutionChange({ ...config, enabled }); + }; + + const patchResolution = (patch: Partial) => { + onResolutionChange({ ...config, ...patch }); + }; + + return ( +
+
+ +
+ {(['static', 'dynamic'] as const).map((mode) => { + const active = mode === 'dynamic' ? isDynamic : !isDynamic; + return ( + + ); + })} +
+
+ + {isDynamic ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/renderer/components/profiles/jar/ResolutionPreview.tsx b/src/renderer/components/profiles/jar/ResolutionPreview.tsx new file mode 100644 index 0000000..8a07488 --- /dev/null +++ b/src/renderer/components/profiles/jar/ResolutionPreview.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { VscCheck, VscWarning, VscSync } from 'react-icons/vsc'; +import type { JarResolutionResult } from '../../../../main/shared/types/JarResolution.types'; + +interface Props { + result: JarResolutionResult | null; + loading: boolean; +} + +export function ResolutionPreview({ result, loading }: Props) { + if (loading) { + return ( +
+ + Resolving... +
+ ); + } + + if (!result) return null; + + if (!result.ok) { + return ( +
+ + {result.error ?? 'No match found'} +
+ ); + } + + const filename = result.resolvedPath?.split(/[/\\]/).pop() ?? ''; + const otherCount = (result.candidates?.length ?? 1) - 1; + + return ( +
+
+ + + {filename} + + {otherCount > 0 && ( + + +{otherCount} other{otherCount !== 1 ? 's' : ''} + + )} +
+ {result.candidates && result.candidates.length > 1 && ( +
+ {result.candidates.map((c) => ( +

+ {c === filename ? '> ' : ' '} + {c} +

+ ))} +
+ )} +
+ ); +} diff --git a/src/renderer/components/profiles/jar/StaticJarPicker.tsx b/src/renderer/components/profiles/jar/StaticJarPicker.tsx new file mode 100644 index 0000000..9c39063 --- /dev/null +++ b/src/renderer/components/profiles/jar/StaticJarPicker.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Input } from '../../common/Input'; +import { FolderBtn } from './FolderBtn'; + +interface Props { + jarPath: string; + onChange: (path: string) => void; + onPick: () => void; +} + +export function StaticJarPicker({ jarPath, onChange, onPick }: Props) { + return ( + onChange(e.target.value)} + placeholder="Path to your .jar file" + hint="The JAR file to execute" + rightElement={} + /> + ); +} diff --git a/src/renderer/components/settings/SettingsTab.tsx b/src/renderer/components/settings/SettingsTab.tsx index 8f66d17..161ee64 100644 --- a/src/renderer/components/settings/SettingsTab.tsx +++ b/src/renderer/components/settings/SettingsTab.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo } from 'react'; -import { useApp } from '../../store/AppStore'; +import { useApp } from '../../AppProvider'; import { Button } from '../common/Button'; import { Toggle } from '../common/Toggle'; import { VersionChecker } from './VersionChecker'; diff --git a/src/renderer/components/settings/version/ReleaseModal.tsx b/src/renderer/components/settings/version/ReleaseModal.tsx deleted file mode 100644 index fd7439c..0000000 --- a/src/renderer/components/settings/version/ReleaseModal.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Modal } from '../../common/Modal'; -import { Button } from '../../common/Button'; -import { - VscPackage, - VscGithub, - VscCalendar, - VscTag, - VscVerified, - VscBeaker, - VscDebugPause, - VscDebugContinue, - VscClose, -} from 'react-icons/vsc'; -import { LuDownload, LuExternalLink, LuCheck, LuRotateCcw } from 'react-icons/lu'; -import { GitHubAsset, GitHubRelease } from '../../../../main/shared/types/GitHub.types'; - -interface Props { - release: GitHubRelease; - open: boolean; - onClose: () => void; -} - -interface DownloadProgress { - filename: string; - bytesWritten: number; - totalBytes: number; - percent: number; - status: 'downloading' | 'paused' | 'done' | 'error' | 'cancelled'; - error?: string; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; -} - -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { - year: 'numeric', - month: 'long', - day: 'numeric', - }); -} - -function getPlatformAsset(assets?: GitHubAsset[]): GitHubAsset | undefined { - if (!assets || !Array.isArray(assets)) return undefined; - return ( - assets.find((a) => a.name.endsWith('.exe') || a.name.endsWith('.msi')) ?? - assets.find((a) => a.name.endsWith('.dmg') || a.name.endsWith('.pkg')) ?? - assets.find((a) => a.name.endsWith('.AppImage') || a.name.endsWith('.deb')) - ); -} - -function DownloadProgressBar({ progress }: { progress: DownloadProgress }) { - const { status, percent, bytesWritten, totalBytes, error } = progress; - const isDone = status === 'done'; - const isError = status === 'error'; - const isPaused = status === 'paused'; - const isCancelled = status === 'cancelled'; - - const barColor = - isError || isCancelled - ? 'bg-red-500' - : isDone - ? 'bg-green-500' - : isPaused - ? 'bg-yellow-400' - : 'bg-accent'; - - const label = isError - ? (error ?? 'Error') - : isCancelled - ? 'Cancelled' - : isDone - ? 'Complete' - : isPaused - ? 'Paused' - : `${formatBytes(bytesWritten)}${totalBytes > 0 ? ` / ${formatBytes(totalBytes)}` : ''}`; - - const percentLabel = isDone ? '100%' : `${percent}%`; - - const percentColor = isDone - ? 'text-green-400' - : isError || isCancelled - ? 'text-red-400' - : isPaused - ? 'text-yellow-400' - : 'text-accent'; - - return ( -
-
-
-
-
- {label} - {percentLabel} -
-
- ); -} - -export function ReleaseModal({ release, open, onClose }: Props) { - const [downloads, setDownloads] = useState>(new Map()); - - const updateDownload = useCallback((payload: DownloadProgress) => { - setDownloads((prev) => new Map(prev).set(payload.filename, payload)); - }, []); - - const resetDownload = useCallback((filename: string) => { - setDownloads((prev) => { - const next = new Map(prev); - next.delete(filename); - return next; - }); - }, []); - - useEffect(() => { - const unsub = window.api.onDownloadProgress((progress: DownloadProgress) => { - updateDownload(progress); - }); - return () => unsub(); - }, [updateDownload]); - - const platformAsset = getPlatformAsset(release.assets); - - const handleDownload = async (asset: GitHubAsset) => { - // Don't touch download state here — main process drives everything via events - await window.api.downloadAsset(asset.browser_download_url, asset.name); - }; - - const handlePause = async (filename: string) => { - const dl = downloads.get(filename); - if (!dl) return; - if (dl.status === 'paused') { - await window.api.resumeDownload(filename); - } else { - await window.api.pauseDownload(filename); - } - // State update comes from the progress event sent by main - }; - - const handleCancel = async (filename: string) => { - await window.api.cancelDownload(filename); - // State update comes from the progress event sent by main - }; - - const getDl = (name: string) => downloads.get(name); - const isActive = (name: string) => { - const s = downloads.get(name)?.status; - return s === 'downloading' || s === 'paused'; - }; - - const renderControls = (asset: GitHubAsset, variant: 'primary' | 'ghost') => { - const dl = getDl(asset.name); - const active = isActive(asset.name); - - return ( -
- {/* Active: pause/resume + cancel */} - {active && ( - <> - - - - )} - - {/* Done: saved label + reset */} - {dl?.status === 'done' && ( -
- - Saved - - -
- )} - - {/* Cancelled or error: retry */} - {(dl?.status === 'cancelled' || dl?.status === 'error') && ( - - )} - - {/* No download yet */} - {!dl && !active && ( - - )} -
- ); - }; - - const bodyLines = (release.body ?? '').split('\n'); - - return ( - -
- {/* Header */} -
- {release.author && ( - {release.author.login} - )} -
-
-

- {release.name ?? release.tag_name} -

- - - {release.tag_name} - - {release.prerelease ? ( - - - Pre-release - - ) : ( - - - Stable - - )} -
-
- {release.author && ( - - - {release.author.login} - - )} - {release.published_at && ( - - - {formatDate(release.published_at)} - - )} -
-
-
- - {/* Quick download — platform asset */} - {platformAsset && ( -
-
-
- -
-

{platformAsset.name}

-

- {formatBytes(platformAsset.size)} ·{' '} - {platformAsset.download_count.toLocaleString()} downloads -

-
-
- {renderControls(platformAsset, 'primary')} -
- {getDl(platformAsset.name) && ( - - )} -
- )} - - {/* Release notes */} - {release.body && ( -
-

- Release Notes -

-
- {bodyLines.map((line, i) => { - const h2 = line.startsWith('## '); - const h3 = line.startsWith('### '); - const li = line.startsWith('- ') || line.startsWith('* '); - if (!line.trim()) return
; - if (h2) - return ( -

- {line.slice(3)} -

- ); - if (h3) - return ( -

- {line.slice(4)} -

- ); - if (li) - return ( -
- · - {line.slice(2)} -
- ); - return ( -

- {line} -

- ); - })} -
-
- )} - - {/* All assets */} - {(release.assets ?? []).length > 0 && ( -
-

- All Assets -

-
- {(release.assets ?? []).map((asset) => ( -
-
- -
-

{asset.name}

-

- {formatBytes(asset.size)} · {asset.download_count.toLocaleString()}{' '} - downloads -

-
- {renderControls(asset, 'ghost')} -
- {getDl(asset.name) && } -
- ))} -
-
- )} - - {/* Footer */} -
- {release.html_url ? ( - - ) : ( -
- )} - -
-
- - ); -} diff --git a/src/renderer/components/settings/version/VersionChecker.tsx b/src/renderer/components/settings/version/VersionChecker.tsx deleted file mode 100644 index 10a23fa..0000000 --- a/src/renderer/components/settings/version/VersionChecker.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { Button } from '../../common/Button'; -import { Tooltip } from '../../common/Tooltip'; -import { ReleaseModal } from './ReleaseModal'; -import { VscCheck, VscWarning, VscSync, VscCircleSlash } from 'react-icons/vsc'; -import { GitHubRelease } from '../../../../main/shared/types/GitHub.types'; - -interface Props { - currentVersion: string; -} - -type CheckState = 'idle' | 'checking' | 'up-to-date' | 'update-available' | 'error'; - -function semverGt(a: string, b: string): boolean { - const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number); - const [am, an, ap] = parse(a); - const [bm, bn, bp] = parse(b); - if (am !== bm) return am > bm; - if (an !== bn) return an > bn; - return ap > bp; -} - -export function VersionChecker({ currentVersion }: Props) { - const [checkState, setCheckState] = useState('idle'); - const [release, setRelease] = useState(null); - const [modalOpen, setModalOpen] = useState(false); - const [errorMsg, setErrorMsg] = useState(null); - - const check = useCallback(async () => { - setCheckState('checking'); - setErrorMsg(null); - const res = await window.api.fetchLatestRelease(); - if (!res.ok || !res.data) { - setCheckState('error'); - setErrorMsg(res.error ?? 'Could not reach GitHub'); - return; - } - setRelease(res.data); - const remoteVersion = (res.data.tag_name ?? '').replace(/^v/, ''); - setCheckState(semverGt(remoteVersion, currentVersion) ? 'update-available' : 'up-to-date'); - }, [currentVersion]); - - const Icon = { - idle: VscSync, - checking: VscSync, - 'up-to-date': VscCheck, - 'update-available': VscWarning, - error: VscCircleSlash, - }[checkState]; - - const iconColor = { - idle: 'text-text-muted', - checking: 'text-text-muted animate-spin', - 'up-to-date': 'text-accent', - 'update-available': 'text-yellow-400', - error: 'text-red-400', - }[checkState]; - - const tooltipContent = { - idle: 'Click to check for updates', - checking: 'Checking...', - 'up-to-date': `You are on the latest version (${currentVersion})`, - 'update-available': release - ? `${release.tag_name} is available — click for details` - : 'Update available', - error: errorMsg ?? 'Check failed', - }[checkState]; - - const handleClick = () => { - if (checkState === 'idle' || checkState === 'error') { - check(); - return; - } - if ((checkState === 'up-to-date' || checkState === 'update-available') && release) - setModalOpen(true); - }; - - return ( - <> -
-
-

Version

-

- Current: {currentVersion} - {checkState === 'update-available' && release && ( - → {release.tag_name} available - )} -

-
- - - -
- - {release && ( - setModalOpen(false)} /> - )} - - ); -} diff --git a/src/renderer/hooks/useDevMode.ts b/src/renderer/hooks/useDevMode.ts index 7392446..353d223 100644 --- a/src/renderer/hooks/useDevMode.ts +++ b/src/renderer/hooks/useDevMode.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { JRCEnvironment } from 'src/main/shared/types/App.types'; +import { JRCEnvironment } from '../../main/shared/types/App.types'; export function useDevMode(): boolean { const [enabled, setEnabled] = useState(false); diff --git a/src/renderer/hooks/useJarResolutionPreview.ts b/src/renderer/hooks/useJarResolutionPreview.ts new file mode 100644 index 0000000..59cef3b --- /dev/null +++ b/src/renderer/hooks/useJarResolutionPreview.ts @@ -0,0 +1,28 @@ +import { useState, useCallback, useEffect } from 'react'; +import type { + JarResolutionConfig, + JarResolutionResult, +} from '../../main/shared/types/JarResolution.types'; + +export function useJarResolutionPreview(config: JarResolutionConfig, enabled: boolean) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + const preview = useCallback(async () => { + if (!enabled || !config.baseDir || !config.pattern) { + setResult(null); + return; + } + setLoading(true); + const res = await window.api.previewCandidates(config); + setResult(res); + setLoading(false); + }, [config, enabled]); + + useEffect(() => { + const t = setTimeout(preview, 400); + return () => clearTimeout(t); + }, [preview]); + + return { result, loading, refresh: preview }; +} diff --git a/src/renderer/store/appReducer.ts b/src/renderer/store/appReducer.ts deleted file mode 100644 index ff0195e..0000000 --- a/src/renderer/store/appReducer.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Profile } from '../../main/shared/types/Profile.types'; -import { saveLogs, clearLogs } from './sessionLogs'; -import { ConsoleLine, ProcessState } from '../../main/shared/types/Process.types'; -import { AppSettings } from '../../main/shared/types/App.types'; - -export interface AppState { - profiles: Profile[]; - activeProfileId: string; - processStates: ProcessState[]; - settings: AppSettings | null; - consoleLogs: Record; - loading: boolean; -} - -export const INITIAL_STATE: AppState = { - profiles: [], - activeProfileId: '', - processStates: [], - settings: null, - consoleLogs: {}, - loading: true, -}; - -export type Action = - | { type: 'INIT'; profiles: Profile[]; settings: AppSettings; states: ProcessState[] } - | { type: 'SET_PROFILES'; profiles: Profile[] } - | { type: 'SET_ACTIVE'; id: string } - | { type: 'SET_STATES'; states: ProcessState[] } - | { type: 'SET_SETTINGS'; settings: AppSettings } - | { type: 'LOAD_LOG'; profileId: string; lines: ConsoleLine[] } - | { type: 'APPEND_LOG'; profileId: string; line: ConsoleLine; maxLines: number } - | { type: 'CLEAR_LOG'; profileId: string }; - -export function reducer(state: AppState, action: Action): AppState { - switch (action.type) { - case 'INIT': - return { - ...state, - profiles: action.profiles, - activeProfileId: action.profiles[0]?.id ?? '', - processStates: action.states, - settings: action.settings, - loading: false, - }; - - case 'SET_PROFILES': - return { ...state, profiles: action.profiles }; - - case 'SET_ACTIVE': - return { ...state, activeProfileId: action.id }; - - case 'SET_STATES': - return { ...state, processStates: action.states }; - - case 'SET_SETTINGS': - return { ...state, settings: action.settings }; - - case 'LOAD_LOG': - return { ...state, consoleLogs: { ...state.consoleLogs, [action.profileId]: action.lines } }; - - case 'APPEND_LOG': { - const prev = state.consoleLogs[action.profileId] ?? []; - const next = [...prev, action.line]; - const trimmed = - next.length > action.maxLines ? next.slice(next.length - action.maxLines) : next; - saveLogs(action.profileId, trimmed); - return { ...state, consoleLogs: { ...state.consoleLogs, [action.profileId]: trimmed } }; - } - - case 'CLEAR_LOG': - clearLogs(action.profileId); - return { ...state, consoleLogs: { ...state.consoleLogs, [action.profileId]: [] } }; - - default: - return state; - } -} diff --git a/src/renderer/store/sessionLogs.ts b/src/renderer/store/sessionLogs.ts deleted file mode 100644 index 5398d59..0000000 --- a/src/renderer/store/sessionLogs.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ConsoleLine } from '../../main/shared/types/Process.types'; - -const key = (id: string) => `jrc:console:${id}`; - -export function loadLogs(id: string, max: number): ConsoleLine[] { - try { - const raw = sessionStorage.getItem(key(id)); - return raw ? (JSON.parse(raw) as ConsoleLine[]).slice(-max) : []; - } catch { - return []; - } -} - -export function saveLogs(id: string, lines: ConsoleLine[]): void { - try { - sessionStorage.setItem(key(id), JSON.stringify(lines)); - } catch { - /* quota */ - } -} - -export function clearLogs(id: string): void { - try { - sessionStorage.removeItem(key(id)); - } catch { - /* ignore */ - } -}