diff --git a/languages/en.json b/languages/en.json new file mode 100644 index 0000000..d5324c9 --- /dev/null +++ b/languages/en.json @@ -0,0 +1,22 @@ +{ + "id": "en", + "name": "English", + "version": 1, + "author": "JRC", + "strings": { + "general.save": "Save", + "general.saved": "Saved", + "general.cancel": "Cancel", + "general.delete": "Delete", + "general.close": "Close", + "general.noProfileSelected": "No profile selected", + "sidebar.newProfile": "New Profile", + "sidebar.fromTemplate": "From Template", + "sidebar.settings": "Settings", + "console.run": "Run", + "console.stop": "Stop", + "console.forceKill": "Force Kill", + "console.notRunning": "Process not running. Press Run to start.", + "settings.title": "Application Settings" + } +} diff --git a/package.json b/package.json index 5f03d9d..c22cfe8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-runner-client", - "version": "2.1.6", + "version": "2.2.1", "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/AssetManager.ts b/src/main/AssetManager.ts new file mode 100644 index 0000000..324490c --- /dev/null +++ b/src/main/AssetManager.ts @@ -0,0 +1,290 @@ +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import { GITHUB_CONFIG } from './shared/config/GitHub.config'; +import { BUILTIN_THEME, THEME_GITHUB_PATH } from './shared/config/Theme.config'; +import { LANGUAGE_GITHUB_PATH } from './shared/config/Language.config'; +import { ENGLISH } from './shared/config/DefaultLanguage.config'; +import type { ThemeDefinition, LocalThemeState } from './shared/types/Theme.types'; +import type { LanguageDefinition, LocalLanguageState } from './shared/types/Language.types'; + +function dataDir(): string { + return app.getPath('userData'); +} + +function httpsGetJson(url: string): Promise { + return new Promise((resolve, reject) => { + const options = { headers: { 'User-Agent': 'java-runner-client' } }; + const req = https.get(url, options, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + resolve(httpsGetJson(res.headers.location)); + return; + } + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch { reject(new Error('JSON parse error')); } + }); + }); + req.on('error', reject); + req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); + }); +} + +function rawUrl(ghPath: string, filename: string): string { + return `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/main/${ghPath}/${filename}`; +} + +function contentsUrl(ghPath: string): string { + return `${GITHUB_CONFIG.apiBase}/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/contents/${ghPath}`; +} + +// ─── Themes ─────────────────────────────────────────────────────────────────── + +const THEME_FILE = 'theme-state.json'; + +function themeFilePath(): string { + return path.join(dataDir(), THEME_FILE); +} + +export function loadThemeState(): LocalThemeState { + try { + const raw = fs.readFileSync(themeFilePath(), 'utf8'); + const state = JSON.parse(raw) as LocalThemeState; + // Ensure builtin is always present + if (!state.themes.find((t) => t.id === BUILTIN_THEME.id)) { + state.themes.unshift(BUILTIN_THEME); + } + return state; + } catch { + return { activeThemeId: BUILTIN_THEME.id, themes: [BUILTIN_THEME] }; + } +} + +export function saveThemeState(state: LocalThemeState): void { + fs.writeFileSync(themeFilePath(), JSON.stringify(state, null, 2), 'utf8'); +} + +export function getActiveTheme(): ThemeDefinition { + const state = loadThemeState(); + return state.themes.find((t) => t.id === state.activeThemeId) ?? BUILTIN_THEME; +} + +export function setActiveTheme(themeId: string): ThemeDefinition { + const state = loadThemeState(); + if (state.themes.find((t) => t.id === themeId)) { + state.activeThemeId = themeId; + saveThemeState(state); + } + return getActiveTheme(); +} + +export async function fetchRemoteThemes(): Promise<{ ok: boolean; themes?: ThemeDefinition[]; error?: string }> { + try { + const listing = await httpsGetJson(contentsUrl(THEME_GITHUB_PATH)); + if (!Array.isArray(listing)) return { ok: false, error: 'Themes folder not found' }; + const themes: ThemeDefinition[] = []; + for (const f of (listing as Array<{ name: string }>).filter((f) => f.name.endsWith('.json'))) { + try { + const theme = (await httpsGetJson(rawUrl(THEME_GITHUB_PATH, f.name))) as ThemeDefinition; + if (theme.id && theme.name && theme.colors) themes.push(theme); + } catch { /* skip */ } + } + return { ok: true, themes }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +export async function checkThemeUpdate(themeId: string): Promise<{ hasUpdate: boolean; remoteVersion: number; localVersion: number }> { + const state = loadThemeState(); + const local = state.themes.find((t) => t.id === themeId); + if (!local) return { hasUpdate: false, remoteVersion: 0, localVersion: 0 }; + + const result = await fetchRemoteThemes(); + if (!result.ok || !result.themes) return { hasUpdate: false, remoteVersion: local.version, localVersion: local.version }; + + const remote = result.themes.find((t) => t.id === themeId); + if (!remote) return { hasUpdate: false, remoteVersion: local.version, localVersion: local.version }; + + return { + hasUpdate: remote.version > local.version, + remoteVersion: remote.version, + localVersion: local.version, + }; +} + +export async function applyThemeUpdate(themeId: string): Promise<{ ok: boolean; error?: string }> { + const result = await fetchRemoteThemes(); + if (!result.ok || !result.themes) return { ok: false, error: result.error ?? 'Fetch failed' }; + + const remote = result.themes.find((t) => t.id === themeId); + if (!remote) return { ok: false, error: 'Theme not found on remote' }; + + const state = loadThemeState(); + const idx = state.themes.findIndex((t) => t.id === themeId); + if (idx >= 0) state.themes[idx] = remote; + else state.themes.push(remote); + saveThemeState(state); + return { ok: true }; +} + +export function installTheme(theme: ThemeDefinition): void { + const state = loadThemeState(); + const idx = state.themes.findIndex((t) => t.id === theme.id); + if (idx >= 0) state.themes[idx] = theme; + else state.themes.push(theme); + saveThemeState(state); +} + +// ─── Languages ──────────────────────────────────────────────────────────────── + +const LANG_FILE = 'language-state.json'; + +function langFilePath(): string { + return path.join(dataDir(), LANG_FILE); +} + +export function loadLanguageState(): LocalLanguageState { + try { + const raw = fs.readFileSync(langFilePath(), 'utf8'); + const state = JSON.parse(raw) as LocalLanguageState; + if (!state.languages.find((l) => l.id === ENGLISH.id)) { + state.languages.unshift(ENGLISH); + } + return state; + } catch { + return { activeLanguageId: ENGLISH.id, languages: [ENGLISH] }; + } +} + +export function saveLanguageState(state: LocalLanguageState): void { + fs.writeFileSync(langFilePath(), JSON.stringify(state, null, 2), 'utf8'); +} + +export function getActiveLanguage(): LanguageDefinition { + const state = loadLanguageState(); + return state.languages.find((l) => l.id === state.activeLanguageId) ?? ENGLISH; +} + +export function setActiveLanguage(langId: string): LanguageDefinition { + const state = loadLanguageState(); + if (state.languages.find((l) => l.id === langId)) { + state.activeLanguageId = langId; + saveLanguageState(state); + } + return getActiveLanguage(); +} + +export async function fetchRemoteLanguages(): Promise<{ ok: boolean; languages?: LanguageDefinition[]; error?: string }> { + try { + const listing = await httpsGetJson(contentsUrl(LANGUAGE_GITHUB_PATH)); + if (!Array.isArray(listing)) return { ok: false, error: 'Languages folder not found' }; + const languages: LanguageDefinition[] = []; + for (const f of (listing as Array<{ name: string }>).filter((f) => f.name.endsWith('.json'))) { + try { + const lang = (await httpsGetJson(rawUrl(LANGUAGE_GITHUB_PATH, f.name))) as LanguageDefinition; + if (lang.id && lang.name && lang.strings) languages.push(lang); + } catch { /* skip */ } + } + return { ok: true, languages }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +export async function checkLanguageUpdate(langId: string): Promise<{ hasUpdate: boolean; remoteVersion: number; localVersion: number }> { + const state = loadLanguageState(); + const local = state.languages.find((l) => l.id === langId); + if (!local) return { hasUpdate: false, remoteVersion: 0, localVersion: 0 }; + + const result = await fetchRemoteLanguages(); + if (!result.ok || !result.languages) return { hasUpdate: false, remoteVersion: local.version, localVersion: local.version }; + + const remote = result.languages.find((l) => l.id === langId); + if (!remote) return { hasUpdate: false, remoteVersion: local.version, localVersion: local.version }; + + return { + hasUpdate: remote.version > local.version, + remoteVersion: remote.version, + localVersion: local.version, + }; +} + +export async function applyLanguageUpdate(langId: string): Promise<{ ok: boolean; error?: string }> { + const result = await fetchRemoteLanguages(); + if (!result.ok || !result.languages) return { ok: false, error: result.error ?? 'Fetch failed' }; + + const remote = result.languages.find((l) => l.id === langId); + if (!remote) return { ok: false, error: 'Language not found on remote' }; + + const state = loadLanguageState(); + const idx = state.languages.findIndex((l) => l.id === langId); + if (idx >= 0) state.languages[idx] = remote; + else state.languages.push(remote); + saveLanguageState(state); + return { ok: true }; +} + +export function installLanguage(lang: LanguageDefinition): void { + const state = loadLanguageState(); + const idx = state.languages.findIndex((l) => l.id === lang.id); + if (idx >= 0) state.languages[idx] = lang; + else state.languages.push(lang); + saveLanguageState(state); +} + +// ─── Dev mode: load from local project directories ──────────────────────────── + +function projectRoot(): string { + return path.join(__dirname, '..', '..'); +} + +export function loadLocalDevThemes(): ThemeDefinition[] { + const dir = path.join(projectRoot(), 'themes'); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => { + try { + const raw = fs.readFileSync(path.join(dir, f), 'utf8'); + const theme = JSON.parse(raw) as ThemeDefinition; + if (theme.id && theme.name && theme.colors) return theme; + } catch { /* skip */ } + return null; + }) + .filter((t): t is ThemeDefinition => t !== null); +} + +export function loadLocalDevLanguages(): LanguageDefinition[] { + const dir = path.join(projectRoot(), 'languages'); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => { + try { + const raw = fs.readFileSync(path.join(dir, f), 'utf8'); + const lang = JSON.parse(raw) as LanguageDefinition; + if (lang.id && lang.name && lang.strings) return lang; + } catch { /* skip */ } + return null; + }) + .filter((l): l is LanguageDefinition => l !== null); +} + +export function syncLocalDevAssets(): { themes: number; languages: number } { + let tc = 0; + let lc = 0; + for (const theme of loadLocalDevThemes()) { + installTheme(theme); + tc++; + } + for (const lang of loadLocalDevLanguages()) { + installLanguage(lang); + lc++; + } + return { themes: tc, languages: lc }; +} + diff --git a/src/main/FileLogger.ts b/src/main/FileLogger.ts new file mode 100644 index 0000000..af22057 --- /dev/null +++ b/src/main/FileLogger.ts @@ -0,0 +1,140 @@ +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; + +const LOG_DIR_NAME = 'logs'; + +function getLogDir(profileId: string): string { + const base = path.join(app.getPath('userData'), LOG_DIR_NAME, profileId); + fs.mkdirSync(base, { recursive: true }); + return base; +} + +function formatTimestamp(ts: number): string { + const d = new Date(ts); + return d.toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); +} + +function formatLineTimestamp(ts: number): string { + const d = new Date(ts); + return d.toISOString().replace('T', ' ').slice(0, 23); +} + +export interface LogSession { + profileId: string; + startedAt: number; + filePath: string; + stream: fs.WriteStream; +} + +const activeSessions = new Map(); + +export function startLogSession(profileId: string): LogSession { + stopLogSession(profileId); + + const startedAt = Date.now(); + const dir = getLogDir(profileId); + const startStr = formatTimestamp(startedAt); + // Final filename will have stop timestamp appended on close + const tempName = `session_${startStr}.log`; + const filePath = path.join(dir, tempName); + + const stream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' }); + stream.write(`=== Session started at ${new Date(startedAt).toISOString()} ===\n`); + + const session: LogSession = { profileId, startedAt, filePath, stream }; + activeSessions.set(profileId, session); + return session; +} + +export function writeLogLine( + profileId: string, + text: string, + type: string, + timestamp: number +): void { + const session = activeSessions.get(profileId); + if (!session) return; + + const prefix = `[${formatLineTimestamp(timestamp)}] [${type.toUpperCase().padEnd(6)}]`; + session.stream.write(`${prefix} ${text}\n`); +} + +export function stopLogSession(profileId: string): void { + const session = activeSessions.get(profileId); + if (!session) return; + + const stoppedAt = Date.now(); + session.stream.write(`=== Session stopped at ${new Date(stoppedAt).toISOString()} ===\n`); + session.stream.end(); + + // Rename file to include stop timestamp + const dir = path.dirname(session.filePath); + const startStr = formatTimestamp(session.startedAt); + const stopStr = formatTimestamp(stoppedAt); + const finalName = `session_${startStr}_to_${stopStr}.log`; + const finalPath = path.join(dir, finalName); + + try { + fs.renameSync(session.filePath, finalPath); + } catch { + // File might be locked — leave as-is + } + + activeSessions.delete(profileId); +} + +export function isLogging(profileId: string): boolean { + return activeSessions.has(profileId); +} + +export interface LogFileInfo { + filename: string; + filePath: string; + size: number; + startedAt: string; + stoppedAt?: string; +} + +export function getLogFiles(profileId: string): LogFileInfo[] { + const dir = path.join(app.getPath('userData'), LOG_DIR_NAME, profileId); + if (!fs.existsSync(dir)) return []; + + return fs + .readdirSync(dir) + .filter((f) => f.endsWith('.log')) + .map((filename) => { + const filePath = path.join(dir, filename); + const stat = fs.statSync(filePath); + // Parse timestamps from filename: session_YYYY-MM-DD_HH-MM-SS_to_YYYY-MM-DD_HH-MM-SS.log + const match = filename.match( + /session_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})(?:_to_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}))?/ + ); + return { + filename, + filePath, + size: stat.size, + startedAt: match?.[1]?.replace(/_/g, ' ').replace(/-/g, ':').replace(/^(\d{4}):/, '$1-').replace(/:(\d{2}):/, '-$1 ') ?? '', + stoppedAt: match?.[2]?.replace(/_/g, ' ').replace(/-/g, ':').replace(/^(\d{4}):/, '$1-').replace(/:(\d{2}):/, '-$1 ') ?? undefined, + }; + }) + .sort((a, b) => b.filename.localeCompare(a.filename)); +} + +export function readLogFile(filePath: string): string { + if (!fs.existsSync(filePath)) return ''; + return fs.readFileSync(filePath, 'utf8'); +} + +export function deleteLogFile(filePath: string): boolean { + try { + fs.unlinkSync(filePath); + return true; + } catch { + return false; + } +} + +export function getLogsDirectory(profileId: string): string { + return path.join(app.getPath('userData'), LOG_DIR_NAME, profileId); +} diff --git a/src/main/GracefulStop.ts b/src/main/GracefulStop.ts new file mode 100644 index 0000000..c27ceed --- /dev/null +++ b/src/main/GracefulStop.ts @@ -0,0 +1,93 @@ +import { ChildProcess, execSync } from 'child_process'; + +const GRACEFUL_TIMEOUT_MS = 5000; +const FORCE_TIMEOUT_MS = 3000; + +/** + * Gracefully stop a process (like IntelliJ IDEA): + * 1. Send SIGINT (Ctrl+C equivalent) to let the process shut down cleanly + * 2. Wait up to GRACEFUL_TIMEOUT_MS + * 3. If still alive, send SIGTERM + * 4. Wait up to FORCE_TIMEOUT_MS + * 5. If still alive, force kill (SIGKILL / taskkill /F) + */ +export function gracefulStop( + proc: ChildProcess, + profileId: string, + onPhase: (phase: 'sigint' | 'sigterm' | 'sigkill') => void, + onDone: () => void +): void { + const pid = proc.pid; + if (!pid) { + onDone(); + return; + } + + let resolved = false; + const finish = () => { + if (resolved) return; + resolved = true; + onDone(); + }; + + // If the process exits on its own at any point, we're done + proc.once('exit', finish); + + // Phase 1: SIGINT (Ctrl+C) + onPhase('sigint'); + if (process.platform === 'win32') { + // Windows: Generate a Ctrl+C event via stdin or use taskkill without /F + try { + // taskkill without /F sends WM_CLOSE which is the closest to SIGINT on Windows + execSync(`taskkill /PID ${pid} /T`, { timeout: 2000, stdio: 'ignore' }); + } catch { + // Process might not accept WM_CLOSE — that's fine, we'll escalate + } + } else { + try { + proc.kill('SIGINT'); + } catch { + // Already exited + } + } + + // Phase 2: SIGTERM after graceful timeout + const termTimer = setTimeout(() => { + if (resolved) return; + onPhase('sigterm'); + + if (process.platform === 'win32') { + try { + execSync(`taskkill /PID ${pid} /T /F`, { timeout: 3000, stdio: 'ignore' }); + } catch { + /* ignore */ + } + } else { + try { + proc.kill('SIGTERM'); + } catch { + /* ignore */ + } + } + + // Phase 3: SIGKILL as last resort + const killTimer = setTimeout(() => { + if (resolved) return; + onPhase('sigkill'); + try { + if (process.platform === 'win32') { + execSync(`taskkill /PID ${pid} /T /F`, { timeout: 3000, stdio: 'ignore' }); + } else { + proc.kill('SIGKILL'); + } + } catch { + /* ignore */ + } + finish(); + }, FORCE_TIMEOUT_MS); + + proc.once('exit', () => clearTimeout(killTimer)); + }, GRACEFUL_TIMEOUT_MS); + + proc.once('exit', () => clearTimeout(termTimer)); +} diff --git a/src/main/ProcessManager.ts b/src/main/ProcessManager.ts index 99cce21..748b890 100644 --- a/src/main/ProcessManager.ts +++ b/src/main/ProcessManager.ts @@ -12,8 +12,10 @@ import { import { Profile } from './shared/types/Profile.types'; import { ProcessIPC } from './ipc/Process.ipc'; import { DEFAULT_JAR_RESOLUTION } from './shared/config/JarResolution.config'; +import { processChunk, stripAnsi } from './shared/utils/AnsiParser'; +import { gracefulStop } from './GracefulStop'; +import { startLogSession, writeLogLine, stopLogSession } from './FileLogger'; -// 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'; @@ -31,7 +33,8 @@ type SystemMessageType = | 'error-runtime' | 'info-pid' | 'info-workdir' - | 'info-restart'; + | 'info-restart' + | 'info-stop-phase'; interface ManagedProcess { process: ChildProcess; @@ -40,6 +43,8 @@ interface ManagedProcess { jarPath: string; startedAt: number; intentionallyStopped: boolean; + stdoutPartial: string; + stderrPartial: string; } function parseVersion(str: string): number[] { @@ -92,6 +97,7 @@ function resolveJarPath(profile: Profile): { jarPath: string; error?: string } { return { jarPath: '', error: 'Dynamic JAR: no files matched the pattern.' }; } + const candidates = matched.map((e) => e.name); let chosen: string; if (res.strategy === 'latest-modified') { @@ -105,12 +111,13 @@ function resolveJarPath(profile: Profile): { jarPath: string; error?: string } { }); chosen = withMtime.sort((a, b) => b.mtime - a.mtime)[0].name; } else if (res.strategy === 'regex') { - chosen = matched[0].name; + chosen = candidates[0]; } 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] ?? '') }; + const vStr = m?.[1] ?? ''; + return { name: e.name, version: parseVersion(vStr) }; }); withVersions.sort((a, b) => compareVersionArrays(a.version, b.version)); chosen = withVersions[0].name; @@ -120,20 +127,23 @@ function resolveJarPath(profile: Profile): { jarPath: string; error?: string } { } class ProcessManager { + private window: BrowserWindow | null = null; private processes = new Map(); + private lineCounters = new Map(); + private seenLineIds = new Map>(); private activityLog: ProcessLogEntry[] = []; - private window: BrowserWindow | null = null; - private restartTimers = new Map>(); private profileSnapshots = new Map(); - private lineCounters = new Map(); - private seenLineIds = new Map>(); - private onTrayUpdate?: () => void; + private restartTimers = new Map>(); + private onTrayUpdate: (() => void) | null = null; - setWindow(win: BrowserWindow): void { + setWindow(win: BrowserWindow) { this.window = win; } - private buildArgs(profile: Profile, resolvedJarPath: string): { 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()); @@ -145,6 +155,17 @@ class ProcessManager { return { cmd, args }; } + private buildEnv(profile: Profile): NodeJS.ProcessEnv { + const env = { ...process.env }; + const vars = profile.envVars ?? []; + for (const v of vars) { + if (v.enabled && v.key.trim()) { + env[v.key.trim()] = v.value; + } + } + return env; + } + start(profile: Profile): { ok: boolean; error?: string } { if (this.processes.has(profile.id)) return { ok: false, error: 'Process already running' }; @@ -157,15 +178,21 @@ class ProcessManager { const { cmd, args } = this.buildArgs(profile, jarPath); const cwd = profile.workingDir || path.dirname(jarPath); + const env = this.buildEnv(profile); this.pushSystem('start', profile.id, 'pending', `Starting: ${cmd} ${args.join(' ')}`); this.pushSystem('info-workdir', profile.id, 'pending', `Working dir: ${cwd}`); + // Start file logging if enabled + if (profile.fileLogging) { + startLogSession(profile.id); + } + let proc: ChildProcess; try { proc = spawn(cmd, args, { cwd, - env: process.env, + env, shell: false, detached: false, stdio: ['pipe', 'pipe', 'pipe'], @@ -173,6 +200,7 @@ class ProcessManager { } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); this.pushSystem('error-starting', profile.id, 'pending', `Failed to start: ${msg}`); + stopLogSession(profile.id); return { ok: false, error: msg }; } @@ -183,6 +211,8 @@ class ProcessManager { jarPath, startedAt: Date.now(), intentionallyStopped: false, + stdoutPartial: '', + stderrPartial: '', }; this.processes.set(profile.id, managed); @@ -217,6 +247,10 @@ class ProcessManager { this.pushSystem('error-runtime', profile.id, String(pid), `Error: ${err.message}`) ); proc.on('exit', (code, signal) => { + // Flush any remaining partial lines + this.flushPartial(profile.id, 'stdout', managed); + this.flushPartial(profile.id, 'stderr', managed); + this.processes.delete(profile.id); this.pushSystem( 'stopped', @@ -224,6 +258,10 @@ class ProcessManager { String(pid), `Process stopped (${signal ? `signal ${signal}` : `exit code ${code ?? '?'}`})` ); + + // Stop file logging + stopLogSession(profile.id); + const entry = this.activityLog.find((e) => e.profileId === profile.id && !e.stoppedAt); if (entry) { entry.stoppedAt = Date.now(); @@ -265,7 +303,37 @@ class ProcessManager { m.intentionallyStopped = true; this.cancelRestartTimer(profileId); - this.pushSystem('stopping', profileId, String(m.process.pid ?? 0), 'Stopping process...'); + this.pushSystem('stopping', profileId, String(m.process.pid ?? 0), 'Stopping process gracefully...'); + + gracefulStop( + m.process, + profileId, + (phase) => { + const messages: Record = { + sigint: 'Sending interrupt signal (Ctrl+C)...', + sigterm: 'Process did not exit cleanly. Sending terminate signal...', + sigkill: 'Force killing process...', + }; + this.pushSystem('info-stop-phase', profileId, String(m.process.pid ?? 0), messages[phase]); + }, + () => { + // Cleanup handled by the 'exit' handler above + } + ); + + this.updateTray(); + return { ok: true }; + } + + /** Force-kill a process immediately (bypasses graceful shutdown). */ + forceStop(profileId: string): { ok: boolean; error?: string } { + const m = this.processes.get(profileId); + if (!m) return { ok: false, error: 'Not running' }; + + m.intentionallyStopped = true; + this.cancelRestartTimer(profileId); + this.pushSystem('stopping', profileId, String(m.process.pid ?? 0), 'Force stopping process...'); + const pid = m.process.pid; if (process.platform === 'win32' && pid) { try { @@ -279,18 +347,10 @@ class ProcessManager { } } else { try { - m.process.kill('SIGTERM'); + m.process.kill('SIGKILL'); } catch { /* ignore */ } - setTimeout(() => { - if (this.processes.has(profileId)) - try { - m.process.kill('SIGKILL'); - } catch { - /* ignore */ - } - }, 5000); } this.updateTray(); @@ -396,23 +456,23 @@ class ProcessManager { const raw = JSON.parse(jsonLine.trim()); const procs = Array.isArray(raw) ? raw : [raw]; return procs - .map((p: Record) => { - const pid = Number(p.Id); - const name = String(p.Name ?? ''); - const cmd = String(p.Cmd ?? name); - if (isNaN(pid) || pid <= 0) return null; + .map((p: any) => { + const pid = p.Id ?? p.id; + const name = p.Name ?? p.name ?? ''; + const cmd = (p.Cmd ?? p.cmd ?? name).slice(0, 400); + if (typeof pid !== 'number' || isNaN(pid)) return null; if (this.isSelf(name, cmd)) return null; const isJava = /java/i.test(name) || /java/i.test(cmd); return { pid, name, - command: (cmd.length > 2 ? cmd : name).slice(0, 400), + command: cmd || name, isJava, managed: managedPids.has(pid), protected: this.isProtected(name, cmd), - memoryMB: typeof p.MemMB === 'number' ? p.MemMB : undefined, - threads: typeof p.Threads === 'number' ? p.Threads : undefined, - startTime: p.Start ? String(p.Start) : undefined, + memoryMB: p.MemMB ?? p.memMB, + startTime: p.Start ?? p.start, + threads: p.Threads ?? p.threads, jarName: this.parseJarName(cmd), } as JavaProcessInfo; }) @@ -425,28 +485,29 @@ class ProcessManager { private scanAllWindowsTasklist(managedPids: Set): JavaProcessInfo[] { try { - const out = execSync('tasklist /fo csv /nh', { encoding: 'utf8', timeout: 8000 }); - const results: JavaProcessInfo[] = []; - for (const line of out.split('\n')) { - const t = line.trim(); - if (!t) continue; - const parts = t.replace(/"/g, '').split(','); - if (parts.length < 2) continue; - const name = parts[0].trim(); - const pid = parseInt(parts[1].trim(), 10); - if (isNaN(pid) || pid <= 0) continue; - if (this.isSelf(name, name)) continue; - const isJava = /java/i.test(name); - results.push({ - pid, - name, - command: name, - isJava, - managed: managedPids.has(pid), - protected: this.isProtected(name, name), - }); - } - return results.sort((a, b) => (b.isJava ? 1 : 0) - (a.isJava ? 1 : 0)); + const out = execSync('tasklist /FO CSV /NH', { encoding: 'utf8', timeout: 10000 }); + return out + .split('\n') + .filter(Boolean) + .map((line) => { + const parts = line.split('","').map((p) => p.replace(/"/g, '')); + const name = parts[0] ?? ''; + const pid = parseInt(parts[1], 10); + if (isNaN(pid)) return null; + if (this.isSelf(name, name)) return null; + const isJava = /java/i.test(name); + return { + pid, + name, + command: name, + isJava, + managed: managedPids.has(pid), + protected: this.isProtected(name, name), + jarName: undefined, + } as JavaProcessInfo; + }) + .filter((x): x is JavaProcessInfo => x !== null) + .sort((a, b) => (b.isJava ? 1 : 0) - (a.isJava ? 1 : 0)); } catch { return []; } @@ -515,14 +576,35 @@ class ProcessManager { type: 'stdout' | 'stderr', m: ManagedProcess ) { - for (const [i, text] of chunk.split(/\r?\n/).entries()) { - if (i === chunk.split(/\r?\n/).length - 1 && text === '') continue; + const partialKey = type === 'stdout' ? 'stdoutPartial' : 'stderrPartial'; + const { lines, partial } = processChunk(chunk, m[partialKey]); + m[partialKey] = partial; + + for (const processed of lines) { const counter = (this.lineCounters.get(profileId) ?? 0) + 1; this.lineCounters.set(profileId, counter); - this.pushLine(profileId, text, type, counter); + this.pushLine(profileId, processed.text, type, counter); } } + private flushPartial( + profileId: string, + type: 'stdout' | 'stderr', + m: ManagedProcess + ) { + const partialKey = type === 'stdout' ? 'stdoutPartial' : 'stderrPartial'; + const text = m[partialKey].trim(); + if (!text) return; + m[partialKey] = ''; + + const stripped = stripAnsi(text); + if (!stripped) return; + + const counter = (this.lineCounters.get(profileId) ?? 0) + 1; + this.lineCounters.set(profileId, counter); + this.pushLine(profileId, stripped, type, counter); + } + private pushLine( profileId: string, text: string, @@ -539,11 +621,16 @@ class ProcessManager { this.seenLineIds.set(profileId, new Set([id])); } } + const timestamp = Date.now(); + + // Write to file log if active + writeLogLine(profileId, text, type, timestamp); + this.window?.webContents.send(ProcessIPC.consoleLine.channel, profileId, { id, text, type, - timestamp: Date.now(), + timestamp, }); } diff --git a/src/main/ipc/Asset.ipc.ts b/src/main/ipc/Asset.ipc.ts new file mode 100644 index 0000000..e352d1e --- /dev/null +++ b/src/main/ipc/Asset.ipc.ts @@ -0,0 +1,101 @@ +import type { RouteMap } from '../IPCController'; +import { + loadThemeState, + getActiveTheme, + setActiveTheme, + fetchRemoteThemes, + checkThemeUpdate, + applyThemeUpdate, + installTheme, + loadLanguageState, + getActiveLanguage, + setActiveLanguage, + fetchRemoteLanguages, + checkLanguageUpdate, + applyLanguageUpdate, + installLanguage, + syncLocalDevAssets, +} from '../AssetManager'; +import type { ThemeDefinition } from '../shared/types/Theme.types'; +import type { LanguageDefinition } from '../shared/types/Language.types'; + +export const AssetIPC = { + // Themes + getThemeState: { + type: 'invoke', + channel: 'asset:themeState', + handler: () => loadThemeState(), + }, + getActiveTheme: { + type: 'invoke', + channel: 'asset:activeTheme', + handler: () => getActiveTheme(), + }, + setActiveTheme: { + type: 'invoke', + channel: 'asset:setTheme', + handler: (_e: any, id: string) => setActiveTheme(id), + }, + fetchRemoteThemes: { + type: 'invoke', + channel: 'asset:fetchThemes', + handler: () => fetchRemoteThemes(), + }, + checkThemeUpdate: { + type: 'invoke', + channel: 'asset:checkThemeUpdate', + handler: (_e: any, id: string) => checkThemeUpdate(id), + }, + applyThemeUpdate: { + type: 'invoke', + channel: 'asset:applyThemeUpdate', + handler: (_e: any, id: string) => applyThemeUpdate(id), + }, + installTheme: { + type: 'invoke', + channel: 'asset:installTheme', + handler: (_e: any, theme: ThemeDefinition) => installTheme(theme), + }, + + // Languages + getLanguageState: { + type: 'invoke', + channel: 'asset:langState', + handler: () => loadLanguageState(), + }, + getActiveLanguage: { + type: 'invoke', + channel: 'asset:activeLang', + handler: () => getActiveLanguage(), + }, + setActiveLanguage: { + type: 'invoke', + channel: 'asset:setLang', + handler: (_e: any, id: string) => setActiveLanguage(id), + }, + fetchRemoteLanguages: { + type: 'invoke', + channel: 'asset:fetchLangs', + handler: () => fetchRemoteLanguages(), + }, + checkLanguageUpdate: { + type: 'invoke', + channel: 'asset:checkLangUpdate', + handler: (_e: any, id: string) => checkLanguageUpdate(id), + }, + applyLanguageUpdate: { + type: 'invoke', + channel: 'asset:applyLangUpdate', + handler: (_e: any, id: string) => applyLanguageUpdate(id), + }, + installLanguage: { + type: 'invoke', + channel: 'asset:installLang', + handler: (_e: any, lang: LanguageDefinition) => installLanguage(lang), + }, + syncLocalDevAssets: { + type: 'invoke', + channel: 'asset:syncDev', + handler: () => syncLocalDevAssets(), + }, +} satisfies RouteMap; diff --git a/src/main/ipc/Logging.ipc.ts b/src/main/ipc/Logging.ipc.ts new file mode 100644 index 0000000..6aaaa00 --- /dev/null +++ b/src/main/ipc/Logging.ipc.ts @@ -0,0 +1,35 @@ +import { shell } from 'electron'; +import type { RouteMap } from '../IPCController'; +import { + getLogFiles, + readLogFile, + deleteLogFile, + getLogsDirectory, + LogFileInfo, +} from '../FileLogger'; + +export const LoggingIPC = { + getLogFiles: { + type: 'invoke', + channel: 'logging:getFiles', + handler: (_e: any, profileId: string): LogFileInfo[] => getLogFiles(profileId), + }, + readLogFile: { + type: 'invoke', + channel: 'logging:readFile', + handler: (_e: any, filePath: string): string => readLogFile(filePath), + }, + deleteLogFile: { + type: 'invoke', + channel: 'logging:deleteFile', + handler: (_e: any, filePath: string): boolean => deleteLogFile(filePath), + }, + openLogsDirectory: { + type: 'invoke', + channel: 'logging:openDir', + handler: (_e: any, profileId: string): void => { + const dir = getLogsDirectory(profileId); + shell.openPath(dir); + }, + }, +} satisfies RouteMap; diff --git a/src/main/ipc/Process.ipc.ts b/src/main/ipc/Process.ipc.ts index c475dfb..a0c8d59 100644 --- a/src/main/ipc/Process.ipc.ts +++ b/src/main/ipc/Process.ipc.ts @@ -1,7 +1,9 @@ +import { shell } from 'electron'; import type { RouteMap } from '../IPCController'; import { processManager } from '../ProcessManager'; import { Profile } from '../shared/types/Profile.types'; import { ConsoleLine, ProcessState } from '../shared/types/Process.types'; +import { getAllProfiles } from '../Store'; export const ProcessIPC = { startProcess: { @@ -14,6 +16,11 @@ export const ProcessIPC = { channel: 'process:stop', handler: (_e: any, id: string) => processManager.stop(id), }, + forceStopProcess: { + type: 'invoke', + channel: 'process:forceStop', + handler: (_e: any, id: string) => processManager.forceStop(id), + }, sendInput: { type: 'invoke', channel: 'process:sendInput', @@ -49,9 +56,20 @@ export const ProcessIPC = { channel: 'process:killAllJava', handler: () => processManager.killAllJava(), }, + openWorkingDir: { + type: 'invoke', + channel: 'process:openWorkingDir', + handler: (_e: any, profileId: string) => { + const profile = getAllProfiles().find((p) => p.id === profileId); + if (!profile) return { ok: false, error: 'Profile not found' }; + const dir = profile.workingDir || (profile.jarPath ? require('path').dirname(profile.jarPath) : ''); + if (!dir) return { ok: false, error: 'No working directory configured' }; + shell.openPath(dir); + return { ok: true }; + }, + }, - // Push events (main → renderer via webContents.send) - // The _types field is a phantom used only for TypeScript inference — never called at runtime. + // Push events (main -> renderer via webContents.send) consoleLine: { type: 'on', channel: 'console:line', diff --git a/src/main/ipc/_index.ts b/src/main/ipc/_index.ts index b51f91f..ecf2a12 100644 --- a/src/main/ipc/_index.ts +++ b/src/main/ipc/_index.ts @@ -1,12 +1,4 @@ import { EnvironmentIPC } from './Environment.ipc'; -/** - * Central IPC registry. - * - * To add a new route: - * 1. Add it to the appropriate *.ipc.ts file (or create a new one) - * 2. If it's a new file, add it to `allRoutes` below - * 3. That's it — main, preload, and types all update automatically - */ export { GitHubIPC } from './GitHub.ipc'; export { ProcessIPC } from './Process.ipc'; @@ -15,6 +7,8 @@ export { SystemIPC, initSystemIPC } from './System.ipc'; export { WindowIPC, initWindowIPC } from './Window.ipc'; export { DevIPC, initDevIPC } from './Dev.ipc'; export { JarResolutionIPC } from './JarResolution.ipc'; +export { LoggingIPC } from './Logging.ipc'; +export { AssetIPC } from './Asset.ipc'; import { GitHubIPC } from './GitHub.ipc'; import { ProcessIPC } from './Process.ipc'; @@ -23,6 +17,8 @@ import { SystemIPC } from './System.ipc'; import { WindowIPC } from './Window.ipc'; import { DevIPC } from './Dev.ipc'; import { JarResolutionIPC } from './JarResolution.ipc'; +import { LoggingIPC } from './Logging.ipc'; +import { AssetIPC } from './Asset.ipc'; import type { InferAPI } from '../IPCController'; export const allRoutes = [ @@ -33,6 +29,8 @@ export const allRoutes = [ WindowIPC, DevIPC, JarResolutionIPC, + LoggingIPC, + AssetIPC, ] as const; export type API = InferAPI< @@ -42,7 +40,9 @@ export type API = InferAPI< typeof SystemIPC & typeof WindowIPC & typeof DevIPC & - typeof JarResolutionIPC + typeof JarResolutionIPC & + typeof LoggingIPC & + typeof AssetIPC >; export type Environment = InferAPI; diff --git a/src/main/rest-api/Log.routes.ts b/src/main/rest-api/Log.routes.ts new file mode 100644 index 0000000..5ee90cc --- /dev/null +++ b/src/main/rest-api/Log.routes.ts @@ -0,0 +1,24 @@ +import { err, ok } from '../RestAPI'; +import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; +import { getLogFiles, readLogFile, deleteLogFile } from '../FileLogger'; + +export const LogRoutes: RouteMap = { + logs_list: defineRoute('logs_list', ({ res, params }) => { + ok(res, getLogFiles(params.id)); + }), + + logs_read: defineRoute('logs_read', ({ res, params }) => { + const files = getLogFiles(params.id); + const file = files.find((f) => f.filename === params.filename); + if (!file) return err(res, 'Log file not found', 404); + ok(res, { filename: file.filename, content: readLogFile(file.filePath) }); + }), + + logs_delete: defineRoute('logs_delete', ({ res, params }) => { + const files = getLogFiles(params.id); + const file = files.find((f) => f.filename === params.filename); + if (!file) return err(res, 'Log file not found', 404); + deleteLogFile(file.filePath); + ok(res); + }), +}; diff --git a/src/main/rest-api/Process.routes.ts b/src/main/rest-api/Process.routes.ts index 5b6b239..69931ef 100644 --- a/src/main/rest-api/Process.routes.ts +++ b/src/main/rest-api/Process.routes.ts @@ -20,6 +20,10 @@ export const ProcessRoutes: RouteMap = { ok(res, processManager.stop(params.id)) ), + processes_force_stop: defineRoute('processes_force_stop', ({ res, params }) => + ok(res, processManager.forceStop(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 index a97afe2..3f1fb54 100644 --- a/src/main/rest-api/Profile.routes.ts +++ b/src/main/rest-api/Profile.routes.ts @@ -24,6 +24,7 @@ export const ProfileRoutes: RouteMap = { jvmArgs: b.jvmArgs ?? [], systemProperties: b.systemProperties ?? [], programArgs: b.programArgs ?? [], + envVars: b.envVars ?? [], javaPath: b.javaPath ?? '', autoStart: b.autoStart ?? false, autoRestart: b.autoRestart ?? false, diff --git a/src/main/rest-api/_index.ts b/src/main/rest-api/_index.ts index 5f4f474..f920998 100644 --- a/src/main/rest-api/_index.ts +++ b/src/main/rest-api/_index.ts @@ -4,11 +4,13 @@ import { RouteMap, BuiltRoute } from '../shared/types/RestAPI.types'; import { BaseRoutes } from './Base.routes'; import { ProfileRoutes } from './Profile.routes'; import { ProcessRoutes } from './Process.routes'; +import { LogRoutes } from './Log.routes'; const merged: RouteMap = { ...BaseRoutes, ...ProfileRoutes, ...ProcessRoutes, + ...LogRoutes, }; export const routes: { [K in RouteKey]: BuiltRoute } = merged as any; diff --git a/src/main/shared/config/API.config.ts b/src/main/shared/config/API.config.ts index 0daff94..dafa4a3 100644 --- a/src/main/shared/config/API.config.ts +++ b/src/main/shared/config/API.config.ts @@ -57,7 +57,12 @@ export const routeConfig = { processes_stop: { method: 'POST', path: '/api/processes/:id/stop', - description: 'Stop process', + description: 'Graceful stop process', + }, + processes_force_stop: { + method: 'POST', + path: '/api/processes/:id/force-stop', + description: 'Force kill process', }, processes_clear: { method: 'POST', @@ -65,6 +70,22 @@ export const routeConfig = { description: 'Clear console', }, + logs_list: { + method: 'GET', + path: '/api/logs/:id', + description: 'List log files for profile', + }, + logs_read: { + method: 'GET', + path: '/api/logs/:id/:filename', + description: 'Read a log file', + }, + logs_delete: { + method: 'DELETE', + path: '/api/logs/:id/:filename', + description: 'Delete a log file', + }, + settings_get: { method: 'GET', path: '/api/settings', diff --git a/src/main/shared/config/App.config.ts b/src/main/shared/config/App.config.ts index 22ab8e4..95c4b17 100644 --- a/src/main/shared/config/App.config.ts +++ b/src/main/shared/config/App.config.ts @@ -9,6 +9,7 @@ export const DEFAULT_SETTINGS: AppSettings = { consoleMaxLines: 5000, consoleWordWrap: false, consoleLineNumbers: false, + consoleTimestamps: false, consoleHistorySize: 200, theme: 'dark', restApiEnabled: false, diff --git a/src/main/shared/config/DefaultLanguage.config.ts b/src/main/shared/config/DefaultLanguage.config.ts new file mode 100644 index 0000000..fd6865a --- /dev/null +++ b/src/main/shared/config/DefaultLanguage.config.ts @@ -0,0 +1,231 @@ +import type { LanguageDefinition } from '../types/Language.types'; + +export const ENGLISH: LanguageDefinition = { + id: 'en', + name: 'English', + version: 1, + author: 'JRC', + strings: { + // General + 'general.save': 'Save', + 'general.saved': 'Saved', + 'general.cancel': 'Cancel', + 'general.delete': 'Delete', + 'general.close': 'Close', + 'general.retry': 'Retry', + 'general.copy': 'Copy', + 'general.enabled': 'Enabled', + 'general.disabled': 'Disabled', + 'general.on': 'On', + 'general.off': 'Off', + 'general.yes': 'Yes', + 'general.no': 'No', + 'general.add': 'Add', + 'general.noProfileSelected': 'No profile selected', + + // Sidebar + 'sidebar.newProfile': 'New Profile', + 'sidebar.fromTemplate': 'From Template', + 'sidebar.noProfiles': 'No profiles yet.', + 'sidebar.utilities': 'Utilities', + 'sidebar.faq': 'FAQ', + 'sidebar.settings': 'Settings', + 'sidebar.developer': 'Developer', + + // Profile tabs + 'tabs.console': 'Console', + 'tabs.configure': 'Configure', + 'tabs.logs': 'Logs', + 'tabs.profile': 'Profile', + + // Console + 'console.run': 'Run', + 'console.stop': 'Stop', + 'console.forceKill': 'Force Kill', + 'console.forceKillHint': 'Force kill (skip graceful shutdown)', + 'console.openWorkDir': 'Open working directory', + 'console.scrollToBottom': 'scroll to bottom', + 'console.search': 'Search (Ctrl+F)', + 'console.clear': 'Clear (Ctrl+L)', + 'console.lines': 'lines', + 'console.noMatches': 'No matches', + 'console.searchPlaceholder': 'Search console... (Enter next, Shift+Enter prev)', + 'console.inputPlaceholder': 'Send command... (up/down history, Ctrl+L clear, Ctrl+F search)', + 'console.inputDisabled': 'Start the process to send commands', + 'console.waiting': 'Waiting for output...', + 'console.notRunning': 'Process not running. Press Run to start.', + 'console.noJar': 'No JAR configured. Go to Configure to set one.', + 'console.copyLine': 'Copy line', + 'console.copyAll': 'Copy all output', + 'console.pid': 'PID', + + // Config sections + 'config.general': 'General', + 'config.files': 'Files & Paths', + 'config.jvm': 'JVM Args', + 'config.props': 'Properties (-D)', + 'config.args': 'Program Args', + 'config.env': 'Environment', + 'config.unsavedChanges': 'unsaved changes', + 'config.restartNeeded': 'restart needed', + 'config.autoStart': 'Auto-start', + 'config.autoStartLabel': 'Auto-start on app launch', + 'config.autoStartHint': 'Automatically run this profile when JRC starts', + 'config.autoRestart': 'Auto-restart', + 'config.autoRestartLabel': 'Automatically restart JAR on crash', + 'config.autoRestartHint': 'Restarts the process if it exits with a non-zero code', + 'config.restartDelay': 'Restart delay', + 'config.restartDelayHint': 'Seconds to wait before restarting', + 'config.logging': 'Logging', + 'config.fileLoggingLabel': 'Save session logs to file', + 'config.fileLoggingHint': 'Write console output to .log files in the config directory per session', + 'config.process': 'Process', + 'config.restartProcess': 'Restart process', + 'config.jarSelection': 'JAR Selection', + 'config.workingDir': 'Working Directory', + 'config.workingDirHint': 'Leave empty to use the directory containing the JAR', + 'config.workingDirPlaceholder': 'Defaults to JAR directory', + 'config.javaExec': 'Java Executable', + 'config.javaExecHint': 'Leave empty to use the java found on PATH', + 'config.javaExecPlaceholder': 'java (uses system PATH)', + 'config.jvmArgsTitle': 'JVM Arguments', + 'config.jvmArgsHint': 'Flags passed to the JVM before -jar, e.g. -Xmx2g -XX:+UseG1GC', + 'config.propsTitle': 'System Properties', + 'config.propsHint': 'Passed as -Dkey=value. Spring profiles, ports, logging levels, etc.', + 'config.progArgsTitle': 'Program Arguments', + 'config.progArgsHint': 'Appended after the JAR path, e.g. --nogui --world myWorld', + 'config.envTitle': 'Environment Variables', + 'config.envHint': 'Injected into the process environment. Overrides system env vars with the same key.', + 'config.commandPreview': 'Command preview', + 'config.pendingArgTitle': 'Unsaved argument input', + 'config.pendingArgMessage': 'You have text in the argument input that hasn\'t been added yet.\n\nClick "+ Add" first, otherwise it will not take effect.\n\nSwitch anyway?', + 'config.pendingArgConfirm': 'Switch', + 'config.pendingArgCancel': 'Stay', + + // Profile tab + 'profile.identity': 'Profile Identity', + 'profile.name': 'Name', + 'profile.accentColour': 'Accent Colour', + 'profile.accentColourHint': 'Used in the sidebar and as the tab highlight colour.', + 'profile.customColour': 'Pick custom colour', + 'profile.dangerZone': 'Danger Zone', + 'profile.deleteProfile': 'Delete profile', + 'profile.deleteHint': 'Permanently removes this profile and all its configuration. Hold Shift to skip confirmation.', + 'profile.deleteConfirmTitle': 'Delete profile?', + 'profile.deleteConfirmMessage': '"{name}" will be permanently removed. This cannot be undone.', + + // Logs tab + 'logs.title': 'Session Logs', + 'logs.files': 'files', + 'logs.refresh': 'Refresh', + 'logs.openDir': 'Open logs directory', + 'logs.noFiles': 'No log files yet. Start and stop a process to create one.', + 'logs.selectFile': 'Select a log file to view its contents', + 'logs.loading': 'Loading...', + 'logs.deleteHint': 'Delete log file (hold Shift to skip confirmation)', + 'logs.deleteTitle': 'Delete log file?', + 'logs.deleteMessage': '"{name}" will be permanently deleted.', + 'logs.disabled': 'File logging is disabled for this profile.', + 'logs.disabledHint': 'Enable it in Configure > General > Save session logs to file.', + + // Settings + 'settings.title': 'Application Settings', + 'settings.saved': 'Settings saved', + 'settings.unsaved': 'Unsaved changes', + 'settings.saveChanges': 'Save Changes', + + // Settings: General + 'settings.general': 'General', + 'settings.startup': 'Startup', + 'settings.launchOnStartup': 'Launch on Windows startup', + 'settings.launchOnStartupHint': 'Java Runner Client starts automatically when you log in', + 'settings.startMinimized': 'Start minimized to tray', + 'settings.startMinimizedHint': "Window won't appear on startup -- only the system tray icon", + 'settings.minimizeToTray': 'Minimize to tray on close', + 'settings.minimizeToTrayHint': 'Closing the window keeps the app and running JARs alive in the background', + + // Settings: Console + 'settings.console': 'Console', + 'settings.fontSize': 'Font size', + 'settings.fontSizeHint': 'Console output font size in pixels', + 'settings.lineNumbers': 'Show line numbers', + 'settings.lineNumbersHint': 'Display a line number gutter in console output', + 'settings.timestamps': 'Show timestamps', + 'settings.timestampsHint': 'Display a timestamp for each console line', + 'settings.wordWrap': 'Word wrap', + 'settings.wordWrapHint': 'Wrap long lines instead of horizontal scrolling', + 'settings.maxLines': 'Max lines in buffer', + 'settings.maxLinesHint': 'Older lines are discarded when the limit is reached', + 'settings.historySize': 'Command history size', + 'settings.historySizeHint': 'Commands stored per session (Up/Down to navigate)', + + // Settings: Appearance + 'settings.appearance': 'Appearance', + 'settings.theme': 'Theme', + 'settings.themeHint': 'Visual theme for the application', + 'settings.themeBuiltin': 'Built-in', + 'settings.themeCheckUpdate': 'Check for theme update', + 'settings.language': 'Language', + 'settings.languageHint': 'Application display language', + 'settings.languageCheckUpdate': 'Check for language update', + + // Settings: Advanced + 'settings.advanced': 'Advanced', + 'settings.devMode': 'Developer Options', + 'settings.devModeLabel': 'Toggle Developer Mode (Right-Shift + 7)', + 'settings.devModeHint': 'Enables the Developer tab and DevTools. Use with caution.', + 'settings.restApi': 'REST API', + 'settings.restApiLabel': 'Enable REST API', + 'settings.restApiHint': 'Exposes a local HTTP API for automation (default port {port})', + 'settings.restApiPort': 'Port', + 'settings.restApiPortHint': 'Restart required to change the port', + + // Settings: Updates + 'settings.updates': 'Updates', + 'settings.updateCenter': 'Update Center', + 'settings.checkAll': 'Check All', + 'settings.updateAll': 'Update All', + 'settings.upToDate': 'Up to date', + 'settings.updateAvailable': 'Update available', + 'settings.checking': 'Checking...', + 'settings.checkFailed': 'Check failed', + + // Settings: About + 'settings.about': 'About', + 'settings.version': 'Version', + 'settings.stack': 'Stack', + 'settings.configPath': 'Config', + + // Release modal + 'release.title': 'Release Details', + 'release.preRelease': 'Pre-release', + 'release.stable': 'Stable', + 'release.trustedDev': 'Trusted Developer', + 'release.automation': 'Automated Release', + 'release.unknownPublisher': 'Unknown Publisher', + 'release.unknownPublisherHint': 'This release was published by a GitHub user not in the trusted list. It was still permitted by GitHub repository security.', + 'release.downloads': 'downloads', + 'release.otherAssets': 'Other assets', + 'release.releaseNotes': 'Release notes', + 'release.viewOnGithub': 'View on GitHub', + + // Utilities + 'utilities.title': 'Utilities', + 'utilities.activityLog': 'Activity Log', + 'utilities.processScanner': 'Process Scanner', + + // Panel headers + 'panels.settings': 'Application Settings', + 'panels.faq': 'FAQ', + 'panels.utilities': 'Utilities', + 'panels.developer': 'Developer', + + // Developer + 'dev.mode': 'Developer Mode', + + // FAQ + 'faq.searchPlaceholder': 'Search FAQ...', + 'faq.noResults': 'No results found.', + 'faq.noItems': 'No items in this topic.', + }, +}; diff --git a/src/main/shared/config/FAQ.config.ts b/src/main/shared/config/FAQ.config.ts index b29fc72..0380c37 100644 --- a/src/main/shared/config/FAQ.config.ts +++ b/src/main/shared/config/FAQ.config.ts @@ -53,22 +53,78 @@ 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.', + a: 'Right-click a profile and press Delete. Hold Shift while clicking Delete to skip the confirmation and remove it instantly. The same Shift shortcut works on the Profile tab\'s Delete button.', }, { 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.', }, + { + q: 'How do I set environment variables for a profile?', + a: 'Go to Configure -> Environment. Add key=value pairs that will be injected into the process environment when it starts. These override system environment variables with the same key. Each variable can be individually toggled on or off.', + }, + { + q: 'How do I use a custom colour for my profile?', + a: 'On the Profile tab, click the "+" button at the end of the colour palette to open a native colour picker. Any hex colour is supported.', + }, ], }, { - id: 'usage', - label: 'Usage', + id: 'console', + label: 'Console', items: [ { q: 'How do I send commands to a running process?', a: 'On the Console tab, type in the input bar at the bottom and press Enter. Press Up/Down to navigate command history. Ctrl+L clears the output. Ctrl+F opens search.', }, + { + q: 'How do I copy a single console line?', + a: 'Right-click any line in the console to open a context menu with "Copy line" and "Copy all output" options.', + }, + { + q: 'How do I enable timestamps on console lines?', + a: 'Go to Settings -> Console and enable "Show timestamps". Each line will show an HH:MM:SS.mmm timestamp.', + }, + { + q: 'What is the difference between Stop and Force Kill?', + a: 'Stop sends a graceful shutdown signal (like pressing Ctrl+C in a terminal). The process gets a chance to save data and clean up. If it does not exit within a few seconds, it escalates to a forced termination.\n\nForce Kill immediately terminates the process without giving it any chance to shut down. Use this only if Stop is not working.', + }, + { + q: 'How do I open the working directory of a running process?', + a: 'Click the folder icon in the console toolbar. This opens the profile\'s working directory (or the JAR directory if none is set) in your system file explorer.', + }, + { + q: 'Why does console output look garbled with special characters?', + a: 'JRC processes ANSI escape sequences from terminal output. Most sequences (colours, cursor movement, progress bars) are handled automatically. If a tool uses very unusual terminal sequences, some characters may still leak through.', + }, + ], + }, + { + id: 'logging', + label: 'Logging', + items: [ + { + q: 'How do I save console output to a file?', + a: 'Go to Configure -> General and enable "Save session logs to file". Each time a process starts and stops, a .log file is written to the config directory under logs//.', + }, + { + q: 'Where are log files stored?', + a: 'Log files are stored at:\nWindows: %APPDATA%\\java-runner-client\\logs\\\\\nLinux: ~/.config/java-runner-client/logs//\n\nFilenames include start and stop timestamps.', + }, + { + q: 'How do I view past session logs?', + a: 'Go to the Logs tab (next to Console and Configure). Select a session from the sidebar to view its contents. You can also copy the full log or delete old files.', + }, + { + q: 'Can I delete old log files?', + a: 'Yes. In the Logs tab, select a log file and click the trash icon. Hold Shift to skip the confirmation dialog. You can also click the folder icon to open the logs directory and manage files manually.', + }, + ], + }, + { + id: 'usage', + label: 'Usage', + items: [ { q: 'How do I keep JARs running after closing the window?', a: 'Enable "Minimize to tray on close" in Settings. Closing the window hides it to the system tray instead of stopping processes.', @@ -99,6 +155,10 @@ export const FAQ_TOPICS: FaqTopic[] = [ q: 'How do I use a profile template?', a: 'Click "From Template" in the sidebar. Browse templates loaded from the GitHub repository, select one, and click "Create Profile". The new profile will have sensible defaults pre-filled for that use case.', }, + { + q: 'How do I inject environment variables like JAVA_HOME?', + a: 'Go to Configure -> Environment and add a row with key JAVA_HOME and the value pointing to your JDK installation. Toggle it on/off as needed without removing it.', + }, ], }, ]; diff --git a/src/main/shared/config/GitHub.config.ts b/src/main/shared/config/GitHub.config.ts index 2c9875d..dd186b4 100644 --- a/src/main/shared/config/GitHub.config.ts +++ b/src/main/shared/config/GitHub.config.ts @@ -2,6 +2,8 @@ export const GITHUB_CONFIG = { owner: 'timonmdy', repo: 'java-runner-client', templatesPath: 'profile-templates', + themesPath: 'themes', + languagesPath: 'languages', templateMinVersion: 1, apiBase: 'https://api.github.com', } as const; diff --git a/src/main/shared/config/Language.config.ts b/src/main/shared/config/Language.config.ts new file mode 100644 index 0000000..3b7ef07 --- /dev/null +++ b/src/main/shared/config/Language.config.ts @@ -0,0 +1 @@ +export const LANGUAGE_GITHUB_PATH = 'languages'; diff --git a/src/main/shared/config/Settings.config.ts b/src/main/shared/config/Settings.config.ts new file mode 100644 index 0000000..9e443f1 --- /dev/null +++ b/src/main/shared/config/Settings.config.ts @@ -0,0 +1,10 @@ +import type { SidebarTopic } from '../types/Sidebar.types'; + +export const SETTINGS_TOPICS: SidebarTopic[] = [ + { id: 'general', label: 'General' }, + { id: 'console', label: 'Console' }, + { id: 'appearance', label: 'Appearance' }, + { id: 'advanced', label: 'Advanced' }, + { id: 'updates', label: 'Updates' }, + { id: 'about', label: 'About' }, +]; diff --git a/src/main/shared/config/Theme.config.ts b/src/main/shared/config/Theme.config.ts new file mode 100644 index 0000000..6dee664 --- /dev/null +++ b/src/main/shared/config/Theme.config.ts @@ -0,0 +1,28 @@ +import type { ThemeDefinition } from '../types/Theme.types'; + +export const BUILTIN_THEME: ThemeDefinition = { + id: 'dark-default', + name: 'Dark (Default)', + version: 1, + author: 'JRC', + colors: { + accent: '#4ade80', + 'base-950': '#08090d', + 'base-900': '#0d0f14', + 'base-800': '#11141b', + 'surface-raised': '#1a1d26', + 'surface-border': '#242736', + 'text-primary': '#e8eaf2', + 'text-secondary': '#b0b4c8', + 'text-muted': '#6b7094', + 'console-error': '#f87171', + 'console-warn': '#fbbf24', + 'console-input': '#60a5fa', + 'console-system': '#6b7094', + }, +}; + +export const THEME_GITHUB_PATH = 'themes'; + +/** CSS variable prefix used in tailwind.config.js */ +export const CSS_VAR_PREFIX = '--c-'; diff --git a/src/main/shared/config/TrustedPublishers.config.ts b/src/main/shared/config/TrustedPublishers.config.ts new file mode 100644 index 0000000..a4d5e19 --- /dev/null +++ b/src/main/shared/config/TrustedPublishers.config.ts @@ -0,0 +1,25 @@ +export type TrustLevel = 'trusted' | 'automation' | 'unknown'; + +export interface TrustedPublisher { + login: string; + label: string; +} + +export const TRUSTED_PUBLISHERS: TrustedPublisher[] = [ + { login: 'timonmdy', label: 'Lead Developer' }, +]; + +export const AUTOMATION_ACCOUNTS = ['github-actions[bot]', 'github-actions']; + +export function getPublisherTrust(login: string): { level: TrustLevel; label: string } { + const trusted = TRUSTED_PUBLISHERS.find( + (p) => p.login.toLowerCase() === login.toLowerCase() + ); + if (trusted) return { level: 'trusted', label: trusted.label }; + + if (AUTOMATION_ACCOUNTS.some((a) => a.toLowerCase() === login.toLowerCase())) { + return { level: 'automation', label: 'GitHub Actions' }; + } + + return { level: 'unknown', label: 'Unknown publisher' }; +} diff --git a/src/main/shared/config/UpdateCenter.config.ts b/src/main/shared/config/UpdateCenter.config.ts new file mode 100644 index 0000000..056312a --- /dev/null +++ b/src/main/shared/config/UpdateCenter.config.ts @@ -0,0 +1,11 @@ +export interface UpdatableDefinition { + id: string; + label: string; + description: string; +} + +export const UPDATE_ITEMS: UpdatableDefinition[] = [ + { id: 'app', label: 'Application', description: 'Java Runner Client core application' }, + { id: 'theme', label: 'Theme', description: 'Currently active visual theme' }, + { id: 'language', label: 'Language', description: 'Currently active language pack' }, +]; diff --git a/src/main/shared/types/App.types.ts b/src/main/shared/types/App.types.ts index 06f77ff..38f77fe 100644 --- a/src/main/shared/types/App.types.ts +++ b/src/main/shared/types/App.types.ts @@ -6,6 +6,7 @@ export interface AppSettings { consoleMaxLines: number; consoleWordWrap: boolean; consoleLineNumbers: boolean; + consoleTimestamps: boolean; consoleHistorySize: number; theme: 'dark'; restApiEnabled: boolean; diff --git a/src/main/shared/types/Language.types.ts b/src/main/shared/types/Language.types.ts new file mode 100644 index 0000000..1dd0fac --- /dev/null +++ b/src/main/shared/types/Language.types.ts @@ -0,0 +1,12 @@ +export interface LanguageDefinition { + id: string; + name: string; + version: number; + author: string; + strings: Record; +} + +export interface LocalLanguageState { + activeLanguageId: string; + languages: LanguageDefinition[]; +} diff --git a/src/main/shared/types/Profile.types.ts b/src/main/shared/types/Profile.types.ts index 55f116a..836a429 100644 --- a/src/main/shared/types/Profile.types.ts +++ b/src/main/shared/types/Profile.types.ts @@ -14,6 +14,12 @@ export interface ProgramArgument { enabled: boolean; } +export interface EnvVariable { + key: string; + value: string; + enabled: boolean; +} + export interface Profile { id: string; name: string; @@ -22,6 +28,7 @@ export interface Profile { jvmArgs: JvmArgument[]; systemProperties: SystemProperty[]; programArgs: ProgramArgument[]; + envVars: EnvVariable[]; javaPath: string; autoStart: boolean; color: string; @@ -31,6 +38,7 @@ export interface Profile { autoRestartInterval: number; order?: number; jarResolution?: JarResolutionConfig; + fileLogging?: boolean; } export const hasJarConfigured = (p: Profile) => diff --git a/src/main/shared/types/Sidebar.types.ts b/src/main/shared/types/Sidebar.types.ts new file mode 100644 index 0000000..d191026 --- /dev/null +++ b/src/main/shared/types/Sidebar.types.ts @@ -0,0 +1,4 @@ +export interface SidebarTopic { + id: string; + label: string; +} diff --git a/src/main/shared/types/Theme.types.ts b/src/main/shared/types/Theme.types.ts new file mode 100644 index 0000000..4e777e1 --- /dev/null +++ b/src/main/shared/types/Theme.types.ts @@ -0,0 +1,28 @@ +export interface ThemeColors { + accent: string; + 'base-950': string; + 'base-900': string; + 'base-800': string; + 'surface-raised': string; + 'surface-border': string; + 'text-primary': string; + 'text-secondary': string; + 'text-muted': string; + 'console-error': string; + 'console-warn': string; + 'console-input': string; + 'console-system': string; +} + +export interface ThemeDefinition { + id: string; + name: string; + version: number; + author: string; + colors: ThemeColors; +} + +export interface LocalThemeState { + activeThemeId: string; + themes: ThemeDefinition[]; +} diff --git a/src/main/shared/types/UpdateCenter.types.ts b/src/main/shared/types/UpdateCenter.types.ts new file mode 100644 index 0000000..b411cb7 --- /dev/null +++ b/src/main/shared/types/UpdateCenter.types.ts @@ -0,0 +1,16 @@ +export type UpdateStatus = 'idle' | 'checking' | 'up-to-date' | 'update-available' | 'updating' | 'done' | 'error'; + +export interface UpdateCheckResult { + hasUpdate: boolean; + currentVersion: string | number; + remoteVersion: string | number; + error?: string; +} + +export interface Updatable { + id: string; + label: string; + description: string; + check: () => Promise; + apply: () => Promise<{ ok: boolean; error?: string }>; +} diff --git a/src/main/shared/utils/AnsiParser.ts b/src/main/shared/utils/AnsiParser.ts new file mode 100644 index 0000000..aaf6b3a --- /dev/null +++ b/src/main/shared/utils/AnsiParser.ts @@ -0,0 +1,139 @@ +/** + * Lightweight ANSI / terminal sequence processor. + * + * Handles: + * - Carriage return (\r) line rewrites + * - CSI cursor movement (up/down/column, erase line) + * - OSC sequences (window title, hyperlinks) — stripped or extracted + * - SGR (colors) — stripped (rendering is handled separately) + * - Wide/fullwidth character awareness for alignment + */ + +// Matches any ANSI escape: CSI (ESC[…), OSC (ESC]…BEL/ST), and simple ESC+char +const ANSI_RE = + /\x1b(?:\[[0-9;]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|[()][AB012]|[78DEHM])/g; + +// OSC 8 hyperlink: ESC]8;;url BEL text ESC]8;; BEL +const HYPERLINK_RE = /\x1b\]8;;([^\x07\x1b]*)\x07([\s\S]*?)\x1b\]8;;\x07/g; + +export interface AnsiHyperlink { + url: string; + text: string; + startIdx: number; + endIdx: number; +} + +export interface ProcessedLine { + text: string; + hyperlinks: AnsiHyperlink[]; +} + +/** Strip all ANSI escape sequences from a string. */ +export function stripAnsi(str: string): string { + return str.replace(ANSI_RE, ''); +} + +/** Extract hyperlinks before stripping. */ +function extractHyperlinks(str: string): { cleaned: string; hyperlinks: AnsiHyperlink[] } { + const hyperlinks: AnsiHyperlink[] = []; + let offset = 0; + + const cleaned = str.replace(HYPERLINK_RE, (match, url: string, text: string, idx: number) => { + const startIdx = idx - offset; + hyperlinks.push({ url, text, startIdx, endIdx: startIdx + text.length }); + offset += match.length - text.length; + return text; + }); + + return { cleaned, hyperlinks }; +} + +/** + * Process a raw chunk from stdout/stderr into display-ready lines. + * + * - Splits on \n (keeps \r for mid-line processing) + * - Handles \r as "return cursor to column 0" (overwrites current line) + * - Strips ANSI escapes after extracting hyperlinks + * - Does NOT handle multi-line cursor movement (up/down) — those are stripped + */ +export function processChunk( + chunk: string, + partialLine: string +): { lines: ProcessedLine[]; partial: string } { + const combined = partialLine + chunk; + const rawLines = combined.split('\n'); + const result: ProcessedLine[] = []; + + // Last segment may be a partial line (no trailing \n) + const partial = rawLines.pop() ?? ''; + + for (const raw of rawLines) { + result.push(processRawLine(raw)); + } + + return { lines: result, partial }; +} + +/** Handle \r within a single line: segments separated by \r overwrite from col 0. */ +function applyCarriageReturn(line: string): string { + if (!line.includes('\r')) return line; + + const segments = line.split('\r'); + let buffer = ''; + + for (const seg of segments) { + if (seg === '') continue; + // Overwrite from position 0 + const chars = [...buffer]; + const incoming = [...seg]; + for (let i = 0; i < incoming.length; i++) { + chars[i] = incoming[i]; + } + buffer = chars.join(''); + } + + return buffer; +} + +function processRawLine(raw: string): ProcessedLine { + const afterCR = applyCarriageReturn(raw); + const { cleaned, hyperlinks } = extractHyperlinks(afterCR); + const text = stripAnsi(cleaned); + + // Adjust hyperlink positions after ANSI stripping + // This is approximate — good enough for display purposes + return { text, hyperlinks }; +} + +/** + * Check if a character is a CJK fullwidth character (takes 2 columns). + * Used for alignment-aware column calculations. + */ +export function isFullwidth(char: string): boolean { + const code = char.codePointAt(0) ?? 0; + return ( + (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo + (code >= 0x2e80 && code <= 0x303e) || // CJK Radicals + (code >= 0x3040 && code <= 0x33bf) || // Japanese + (code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Ext A + (code >= 0x4e00 && code <= 0xa4cf) || // CJK Unified + (code >= 0xa960 && code <= 0xa97f) || // Hangul Jamo Extended + (code >= 0xac00 && code <= 0xd7ff) || // Hangul Syllables + (code >= 0xf900 && code <= 0xfaff) || // CJK Compat Ideographs + (code >= 0xfe30 && code <= 0xfe6f) || // CJK Compat Forms + (code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms + (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs + (code >= 0x1f000 && code <= 0x1fbff) || // Emoji/Symbols + (code >= 0x20000 && code <= 0x2ffff) || // CJK Ext B+ + (code >= 0x30000 && code <= 0x3ffff) // CJK Ext G+ + ); +} + +/** Calculate display width of a string accounting for fullwidth characters. */ +export function displayWidth(str: string): number { + let width = 0; + for (const char of str) { + width += isFullwidth(char) ? 2 : 1; + } + return width; +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 212bab4..f8c6595 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AppProvider } from './AppProvider'; +import { ThemeProvider } from './hooks/ThemeProvider'; +import { I18nProvider } from './i18n/I18nProvider'; import { TitleBar } from './components/layout/TitleBar'; import { MainLayout } from './components/MainLayout'; import { DevModeGate } from './components/developer/DevModeGate'; @@ -28,21 +30,23 @@ export default function App() { if (!window.api) return ; return ( - - - {/* Root: full viewport, flex column, no overflow */} -
- - {/* Content area: takes remaining height, no overflow — children manage their own */} -
- - } /> - } /> - -
-
- -
-
+ + + + +
+ +
+ + } /> + } /> + +
+
+ +
+
+
+
); } diff --git a/src/renderer/AppProvider.tsx b/src/renderer/AppProvider.tsx index a2ca714..247ab37 100644 --- a/src/renderer/AppProvider.tsx +++ b/src/renderer/AppProvider.tsx @@ -121,6 +121,7 @@ interface AppContextValue { reorderProfiles: (profiles: Profile[]) => Promise; startProcess: (p: Profile) => Promise<{ ok: boolean; error?: string }>; stopProcess: (id: string) => Promise<{ ok: boolean; error?: string }>; + forceStopProcess: (id: string) => Promise<{ ok: boolean; error?: string }>; sendInput: (profileId: string, input: string) => Promise; clearConsole: (profileId: string) => void; saveSettings: (s: AppSettings) => Promise; @@ -207,6 +208,7 @@ export function AppProvider({ children }: { children: ReactNode }) { jvmArgs: overrides.jvmArgs ?? [{ value: '-Xmx1g', enabled: true }], systemProperties: overrides.systemProperties ?? [], programArgs: overrides.programArgs ?? [], + envVars: overrides.envVars ?? [], javaPath: overrides.javaPath ?? '', autoStart: overrides.autoStart ?? false, autoRestart: overrides.autoRestart ?? false, @@ -230,6 +232,7 @@ export function AppProvider({ children }: { children: ReactNode }) { 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 sendInput = useCallback(async (profileId: string, input: string) => { await window.api.sendInput(profileId, input); @@ -264,6 +267,7 @@ export function AppProvider({ children }: { children: ReactNode }) { reorderProfiles, startProcess, stopProcess, + forceStopProcess, sendInput, clearConsole, saveSettings, diff --git a/src/renderer/components/MainLayout.tsx b/src/renderer/components/MainLayout.tsx index 50132e4..007a102 100644 --- a/src/renderer/components/MainLayout.tsx +++ b/src/renderer/components/MainLayout.tsx @@ -4,6 +4,7 @@ import { ProfileSidebar } from './profiles/ProfileSidebar'; import { ConsoleTab } from './console/ConsoleTab'; import { ConfigTab } from './profiles/ConfigTab'; import { ProfileTab } from './profiles/ProfileTab'; +import { LogsTab } from './logs/LogsTab'; import { SettingsTab } from './settings/SettingsTab'; import { UtilitiesTab } from './utils/UtilitiesTab'; import { FaqPanel } from './faq/FaqPanel'; @@ -12,7 +13,7 @@ import { PanelHeader } from './layout/PanelHeader'; import { useApp } from '../AppProvider'; import { useDevMode } from '../hooks/useDevMode'; import { VscTerminal, VscAccount } from 'react-icons/vsc'; -import { LuList } from 'react-icons/lu'; +import { LuList, LuScrollText } from 'react-icons/lu'; // Panels rendered in the side-panel view (replace main tabs area) const SIDE_PANELS = ['settings', 'faq', 'utilities', 'developer'] as const; @@ -33,6 +34,7 @@ function isSidePanel(seg: string): seg is SidePanel { const PROFILE_TABS = [ { path: 'console', label: 'Console', Icon: VscTerminal }, { path: 'config', label: 'Configure', Icon: LuList }, + { path: 'logs', label: 'Logs', Icon: LuScrollText }, { path: 'profile', label: 'Profile', Icon: VscAccount }, ] as const; @@ -126,6 +128,7 @@ export function MainLayout() { } /> } /> + } /> } /> } /> diff --git a/src/renderer/components/common/EnvVarList.tsx b/src/renderer/components/common/EnvVarList.tsx new file mode 100644 index 0000000..692d930 --- /dev/null +++ b/src/renderer/components/common/EnvVarList.tsx @@ -0,0 +1,112 @@ +import React, { useState, useRef } from 'react'; +import { VscTrash, VscAdd } from 'react-icons/vsc'; +import { Toggle } from './Toggle'; +import type { EnvVariable } from '../../../main/shared/types/Profile.types'; + +interface Props { + items: EnvVariable[]; + onChange: (items: EnvVariable[]) => void; + onPendingChange?: (pending: boolean) => void; +} + +export function EnvVarList({ items, onChange, onPendingChange }: Props) { + const [newKey, setNewKey] = useState(''); + const [newValue, setNewValue] = useState(''); + const keyRef = useRef(null); + + const hasPending = newKey.trim().length > 0 || newValue.trim().length > 0; + + const handleAdd = () => { + if (!newKey.trim()) return; + onChange([...items, { key: newKey.trim(), value: newValue, enabled: true }]); + setNewKey(''); + setNewValue(''); + onPendingChange?.(false); + keyRef.current?.focus(); + }; + + const handleRemove = (idx: number) => { + onChange(items.filter((_, i) => i !== idx)); + }; + + const handleToggle = (idx: number, enabled: boolean) => { + const next = [...items]; + next[idx] = { ...next[idx], enabled }; + onChange(next); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAdd(); + } + }; + + return ( +
+ {items.map((item, i) => ( +
+ handleToggle(i, v)} + /> + + {item.key} + + = + + {item.value} + + +
+ ))} + +
+ { + setNewKey(e.target.value); + onPendingChange?.(e.target.value.trim().length > 0 || newValue.trim().length > 0); + }} + onKeyDown={handleKeyDown} + placeholder="KEY" + className="w-28 bg-base-950 border border-surface-border rounded px-2 py-1.5 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40" + /> + = + { + setNewValue(e.target.value); + onPendingChange?.(newKey.trim().length > 0 || e.target.value.trim().length > 0); + }} + onKeyDown={handleKeyDown} + placeholder="value" + className="flex-1 bg-base-950 border border-surface-border rounded px-2 py-1.5 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40" + /> + +
+
+ ); +} diff --git a/src/renderer/components/console/ConsoleOutput.tsx b/src/renderer/components/console/ConsoleOutput.tsx index 265e5b4..3d1af0e 100644 --- a/src/renderer/components/console/ConsoleOutput.tsx +++ b/src/renderer/components/console/ConsoleOutput.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect, useCallback, useMemo } from 'react'; import type { ConsoleLine } from '../../../main/shared/types/Process.types'; +import { parseAnsi, hasAnsi, AnsiSpan } from '../../utils/ansi'; interface Props { lines: ConsoleLine[]; @@ -45,14 +46,12 @@ export function ConsoleOutput({ ? ((searchIdx % matchIndices.length) + matchIndices.length) % matchIndices.length : 0; - // Auto-scroll to bottom when new lines arrive useEffect(() => { if (autoScroll && !searchOpen) { bottomRef.current?.scrollIntoView({ behavior: 'instant' }); } }, [lines.length, autoScroll, searchOpen]); - // Scroll to current match useEffect(() => { if (matchIndices.length > 0) { matchRefs.current[clampedIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); @@ -132,8 +131,6 @@ const ConsoleLineRow = React.forwardRef< } >(({ line, lineNum, showLineNum, wordWrap, searchTerm, isCurrentMatch, isAnyMatch }, ref) => { const text = line.text || ' '; - const content = - searchTerm && isAnyMatch ? renderHighlighted(text, searchTerm, isCurrentMatch) : text; return (
- {content} + {renderContent(text, searchTerm, isCurrentMatch, isAnyMatch)}
); }); ConsoleLineRow.displayName = 'ConsoleLineRow'; +function renderContent( + text: string, + searchTerm: string, + isCurrentMatch: boolean, + isAnyMatch: boolean +): React.ReactNode { + if (hasAnsi(text)) { + const spans = parseAnsi(text); + if (!searchTerm || !isAnyMatch) { + return spans.map((span, i) => ); + } + return spans.map((span, i) => ( + + )); + } + + if (searchTerm && isAnyMatch) { + return renderHighlighted(text, searchTerm, isCurrentMatch); + } + + return text; +} + +function AnsiSpanNode({ + span, + searchTerm, + isCurrent, +}: { + span: AnsiSpan; + searchTerm?: string; + isCurrent?: boolean; +}) { + const style: React.CSSProperties = {}; + if (span.color) style.color = span.color; + if (span.bgColor) style.backgroundColor = span.bgColor; + if (span.bold) style.fontWeight = 'bold'; + if (span.dim) style.opacity = 0.6; + if (span.italic) style.fontStyle = 'italic'; + if (span.underline) style.textDecoration = 'underline'; + + const content = + searchTerm && span.text.toLowerCase().includes(searchTerm.toLowerCase()) + ? renderHighlighted(span.text, searchTerm, isCurrent ?? false) + : span.text; + + return {content}; +} + 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( diff --git a/src/renderer/components/console/ConsoleTab.tsx b/src/renderer/components/console/ConsoleTab.tsx index 4cc4ff0..a7bb639 100644 --- a/src/renderer/components/console/ConsoleTab.tsx +++ b/src/renderer/components/console/ConsoleTab.tsx @@ -1,13 +1,21 @@ 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 { ContextMenu, ContextMenuItem } from '../common/ContextMenu'; +import { VscSearch, VscChevronUp, VscChevronDown, VscClose, VscFolderOpened, VscClearAll } from 'react-icons/vsc'; import { ConsoleLine } from '../../../main/shared/types/Process.types'; import { hasJarConfigured } from '../../../main/shared/types/Profile.types'; +function formatTimestamp(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0'); +} + export function ConsoleTab() { - const { state, activeProfile, startProcess, stopProcess, sendInput, clearConsole, isRunning } = - useApp(); + const { + state, activeProfile, startProcess, stopProcess, forceStopProcess, + sendInput, clearConsole, isRunning, + } = useApp(); const profileId = activeProfile?.id ?? ''; const running = isRunning(profileId); @@ -26,6 +34,7 @@ export function ConsoleTab() { const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchIdx, setSearchIdx] = useState(0); + const [lineCtxMenu, setLineCtxMenu] = useState<{ x: number; y: number; text: string } | null>(null); const scrollRef = useRef(null); const inputRef = useRef(null); @@ -109,6 +118,16 @@ export function ConsoleTab() { } }, [activeProfile, running, profileId, stopProcess, startProcess]); + const handleForceStop = useCallback(async () => { + if (!profileId) return; + await forceStopProcess(profileId); + }, [profileId, forceStopProcess]); + + const handleOpenWorkDir = useCallback(async () => { + if (!profileId) return; + await window.api.openWorkingDir(profileId); + }, [profileId]); + const handleSend = useCallback(async () => { const cmd = inputValue.trim(); if (!cmd || !running) return; @@ -165,9 +184,15 @@ export function ConsoleTab() { return () => window.removeEventListener('keydown', handler); }, [openSearch, closeSearch, searchOpen]); + const handleLineContextMenu = useCallback((e: React.MouseEvent, text: string) => { + e.preventDefault(); + setLineCtxMenu({ x: e.clientX, y: e.clientY, text }); + }, []); + const fontSize = settings?.consoleFontSize ?? 13; const wordWrap = settings?.consoleWordWrap ?? false; const lineNums = settings?.consoleLineNumbers ?? false; + const showTimestamps = settings?.consoleTimestamps ?? false; if (!activeProfile) { return ( @@ -179,6 +204,20 @@ export function ConsoleTab() { matchRefs.current = new Array(matchIndices.length).fill(null); + const lineCtxItems: ContextMenuItem[] = lineCtxMenu + ? [ + { + label: 'Copy line', + onClick: () => navigator.clipboard.writeText(lineCtxMenu.text), + }, + { + label: 'Copy all output', + onClick: () => + navigator.clipboard.writeText(lines.map((l) => l.text).join('\n')), + }, + ] + : []; + return (
@@ -192,6 +231,12 @@ export function ConsoleTab() { {running ? 'Stop' : 'Run'} + {running && ( + + )} + {running && pid && ( + + {!autoScroll && !searchOpen && ( - + {lines.length.toLocaleString()} lines
@@ -325,10 +378,12 @@ export function ConsoleTab() { line={line} lineNum={i + 1} showLineNum={lineNums} + showTimestamp={showTimestamps} wordWrap={wordWrap} searchTerm={searchTerm} isCurrentMatch={isCurrentMatch} isAnyMatch={isAnyMatch} + onContextMenu={handleLineContextMenu} ref={ matchPos !== -1 ? (el: HTMLDivElement | null) => { @@ -344,7 +399,7 @@ export function ConsoleTab() {
- +
+ + {lineCtxMenu && lineCtxItems.length > 0 && ( + setLineCtxMenu(null)} + /> + )} ); } +// ─── Line row ───────────────────────────────────────────────────────────────── + const LINE_COLORS: Record = { stdout: 'text-text-primary', stderr: 'text-console-error', @@ -378,12 +444,14 @@ const ConsoleLineRow = React.forwardRef< line: ConsoleLine; lineNum: number; showLineNum: boolean; + showTimestamp: boolean; wordWrap: boolean; searchTerm: string; isCurrentMatch: boolean; isAnyMatch: boolean; + onContextMenu: (e: React.MouseEvent, text: string) => void; } ->(({ line, lineNum, showLineNum, wordWrap, searchTerm, isCurrentMatch, isAnyMatch }, ref) => { +>(({ line, lineNum, showLineNum, showTimestamp, wordWrap, searchTerm, isCurrentMatch, isAnyMatch, onContextMenu }, ref) => { const text = line.text || ' '; const content = searchTerm && isAnyMatch ? renderHighlighted(text, searchTerm, isCurrentMatch) : text; @@ -391,6 +459,7 @@ const ConsoleLineRow = React.forwardRef< return (
onContextMenu(e, line.text)} className={[ 'flex gap-0 px-2', LINE_COLORS[line.type], @@ -406,6 +475,11 @@ const ConsoleLineRow = React.forwardRef< {lineNum} )} + {showTimestamp && ( + + {formatTimestamp(line.timestamp)} + + )} s.running).length, settings: state.settings, + profileMeta: state.profiles.map((p) => ({ + id: p.id, + name: p.name, + envVars: (p.envVars ?? []).length, + fileLogging: p.fileLogging ?? false, + autoRestart: p.autoRestart, + })), consoleLogCounts: Object.fromEntries( Object.entries(state.consoleLogs).map(([id, lines]) => [id, lines.length]) ), @@ -51,6 +58,9 @@ export function DevDiagnostics() { const latest = perfSamples[perfSamples.length - 1]; const maxMem = Math.max(...perfSamples.map((s) => s.memMB), 1); + const profilesWithLogging = state.profiles.filter((p) => p.fileLogging).length; + const totalEnvVars = state.profiles.reduce((sum, p) => sum + (p.envVars ?? []).length, 0); + return (
@@ -71,7 +81,7 @@ export function DevDiagnostics() { )}
- {latest ? `${latest.memMB} MB used` : '—'} + {latest ? `${latest.memMB} MB used` : '---'} max {maxMem} MB
@@ -79,7 +89,7 @@ export function DevDiagnostics() {
- + + +
+ + + + + + +
+
+ {state.profiles.length === 0 ? (

No profiles

@@ -111,6 +132,10 @@ export function DevDiagnostics() { {p.name} + + {(p.envVars ?? []).length > 0 ? `${(p.envVars ?? []).length} env` : ''} + {p.fileLogging ? ' log' : ''} + {lines.toLocaleString()} lines diff --git a/src/renderer/components/developer/DevStorage.tsx b/src/renderer/components/developer/DevStorage.tsx index a525546..7d8efd5 100644 --- a/src/renderer/components/developer/DevStorage.tsx +++ b/src/renderer/components/developer/DevStorage.tsx @@ -40,13 +40,14 @@ export function DevStorage() { }, []); const totalSessionBytes = sessionEntries.reduce((a, b) => a + b.sizeBytes, 0); + const profilesWithLogging = state.profiles.filter((p) => p.fileLogging).length; return (
- + + +
+ <> + {items.map((item, i) => ( + onToggle(i)} + /> + ))} + ); } diff --git a/src/renderer/components/layout/SidebarLayout.tsx b/src/renderer/components/layout/SidebarLayout.tsx new file mode 100644 index 0000000..9b4b9f3 --- /dev/null +++ b/src/renderer/components/layout/SidebarLayout.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { SidebarTopic } from '../../../main/shared/types/Sidebar.types'; + +export type { SidebarTopic }; + +interface Props { + topics: SidebarTopic[]; + activeTopicId: string; + onTopicChange: (id: string) => void; + children: React.ReactNode; + sidebarWidth?: string; +} + +export function SidebarLayout({ + topics, + activeTopicId, + onTopicChange, + children, + sidebarWidth = 'w-36', +}: Props) { + return ( +
+
+ {topics.map((topic) => ( + onTopicChange(topic.id)} + /> + ))} +
+
{children}
+
+ ); +} + +function SidebarButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/renderer/components/logs/LogsTab.tsx b/src/renderer/components/logs/LogsTab.tsx new file mode 100644 index 0000000..e661bbc --- /dev/null +++ b/src/renderer/components/logs/LogsTab.tsx @@ -0,0 +1,214 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useApp } from '../../AppProvider'; +import { Button } from '../common/Button'; +import { Dialog } from '../common/Dialog'; +import { VscTrash, VscRefresh, VscFolderOpened } from 'react-icons/vsc'; + +interface LogFileInfo { + filename: string; + filePath: string; + size: number; + startedAt: string; + stoppedAt?: string; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +export function LogsTab() { + const { activeProfile } = useApp(); + const profileId = activeProfile?.id ?? ''; + const color = activeProfile?.color ?? '#4ade80'; + + const [logFiles, setLogFiles] = useState([]); + const [selectedFile, setSelectedFile] = useState(null); + const [logContent, setLogContent] = useState(null); + const [loading, setLoading] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const refresh = useCallback(async () => { + if (!profileId) return; + const files = await window.api.getLogFiles(profileId); + setLogFiles(files); + }, [profileId]); + + useEffect(() => { + refresh(); + setSelectedFile(null); + setLogContent(null); + }, [profileId, refresh]); + + const handleSelectFile = useCallback(async (file: LogFileInfo) => { + setSelectedFile(file); + setLoading(true); + const content = await window.api.readLogFile(file.filePath); + setLogContent(content); + setLoading(false); + }, []); + + const handleDelete = useCallback(async () => { + if (!deleteTarget) return; + await window.api.deleteLogFile(deleteTarget.filePath); + if (selectedFile?.filePath === deleteTarget.filePath) { + setSelectedFile(null); + setLogContent(null); + } + setDeleteTarget(null); + refresh(); + }, [deleteTarget, selectedFile, refresh]); + + const handleOpenDir = useCallback(async () => { + if (!profileId) return; + await window.api.openLogsDirectory(profileId); + }, [profileId]); + + if (!activeProfile) { + return ( +
+ No profile selected +
+ ); + } + + if (!activeProfile.fileLogging) { + return ( +
+

File logging is disabled for this profile.

+

+ Enable it in Configure > General > Save session logs to file. +

+
+ ); + } + + return ( +
+
+

+ Session Logs + + {logFiles.length} file{logFiles.length !== 1 ? 's' : ''} + +

+ + +
+ +
+
+ {logFiles.length === 0 && ( +

+ No log files yet. Start and stop a process to create one. +

+ )} + {logFiles.map((file) => ( + + ))} +
+ +
+ {!selectedFile && ( +
+ Select a log file to view its contents +
+ )} + {selectedFile && loading && ( +
+ Loading... +
+ )} + {selectedFile && !loading && logContent !== null && ( + <> +
+ + {selectedFile.filename} + + + +
+
+
+                  {logContent}
+                
+
+ + )} +
+
+ + setDeleteTarget(null)} + /> +
+ ); +} diff --git a/src/renderer/components/profiles/ConfigTab.tsx b/src/renderer/components/profiles/ConfigTab.tsx index b6a8b19..53b0f35 100644 --- a/src/renderer/components/profiles/ConfigTab.tsx +++ b/src/renderer/components/profiles/ConfigTab.tsx @@ -4,13 +4,14 @@ import { useApp } from '../../AppProvider'; import { ArgList } from '../common/ArgList'; import { Button } from '../common/Button'; import { Dialog } from '../common/Dialog'; +import { EnvVarList } from '../common/EnvVarList'; 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'; +type Section = 'general' | 'files' | 'jvm' | 'props' | 'args' | 'env'; const SECTIONS: { id: Section; label: string }[] = [ { id: 'general', label: 'General' }, @@ -18,13 +19,13 @@ const SECTIONS: { id: Section; label: string }[] = [ { id: 'jvm', label: 'JVM Args' }, { id: 'props', label: 'Properties (-D)' }, { id: 'args', label: 'Program Args' }, + { id: 'env', label: 'Environment' }, ]; export function ConfigTab() { const { activeProfile, saveProfile, isRunning, startProcess, stopProcess } = useApp(); const [draft, setDraft] = useState(null); - // savedSnapshot holds what was last persisted — used for dirty detection const [savedSnapshot, setSavedSnapshot] = useState(null); const [saved, setSaved] = useState(false); const [section, setSection] = useState
('general'); @@ -33,8 +34,8 @@ export function ConfigTab() { useEffect(() => { if (activeProfile) { - setDraft({ ...activeProfile }); - setSavedSnapshot({ ...activeProfile }); + setDraft({ ...activeProfile, envVars: activeProfile.envVars ?? [] }); + setSavedSnapshot({ ...activeProfile, envVars: activeProfile.envVars ?? [] }); setSaved(false); setPendingArg(false); } @@ -48,7 +49,6 @@ export function ConfigTab() { const handleSave = useCallback(async () => { if (!draft) return; await saveProfile(draft); - // Update the saved snapshot so dirty detection resets correctly setSavedSnapshot({ ...draft }); setSaved(true); setTimeout(() => setSaved(false), 1800); @@ -178,6 +178,18 @@ export function ConfigTab() { /> )} + {section === 'env' && ( + + update({ envVars })} + onPendingChange={setPendingArg} + /> + + )}

@@ -281,6 +293,22 @@ function GeneralSection({ )}

+
+

Logging

+
+
+

Save session logs to file

+

+ Write console output to .log files in the config directory per session +

+
+ update({ fileLogging: v })} + /> +
+
+ {running && (

Process

@@ -332,15 +360,18 @@ function FilesSection({ }; return ( -
- update({ jarPath })} - onResolutionChange={(jarResolution) => update({ jarResolution })} - onPickJar={handlePickJar} - onPickDir={handlePickResolutionDir} - /> +
+
+

JAR Selection

+ update({ jarPath })} + onResolutionChange={(jarResolution) => update({ jarResolution })} + onPickJar={handlePickJar} + onPickDir={handlePickResolutionDir} + /> +
void; + onContextMenu: (e: React.MouseEvent) => void; +} + +export function ProfileItem({ + profile, + active, + running, + isDragging, + onClick, + onContextMenu, +}: Props) { + const color = profile.color || PROFILE_COLORS[0]; + const jarName = profile.jarResolution?.enabled + ? '' + : (profile.jarPath?.split(/[/\\]/).pop() ?? ''); + + return ( + + ); +} diff --git a/src/renderer/components/profiles/ProfileSidebar.tsx b/src/renderer/components/profiles/ProfileSidebar.tsx index 9d72e88..2887dd4 100644 --- a/src/renderer/components/profiles/ProfileSidebar.tsx +++ b/src/renderer/components/profiles/ProfileSidebar.tsx @@ -282,7 +282,7 @@ function ProfileItem({ className={[ 'w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-left transition-colors', active ? 'bg-surface-raised' : 'hover:bg-surface-raised/50', - isDragging ? 'cursor-grabbing opacity-70' : 'cursor-default', + isDragging ? 'cursor-grabbing opacity-70' : 'cursor-pointer', ].join(' ')} > @@ -333,7 +333,7 @@ function FooterButton({
@@ -83,12 +107,19 @@ export function ProfileTab() {

Delete profile

Permanently removes this profile and all its configuration. + Hold Shift to skip confirmation.

+ ))} +
+
+ + t.id} + getVersion={(t) => t.version} + getName={(t) => t.name} + getAuthor={(t) => t.author} + emptyLabel="No themes found on GitHub." + errorLabel="Failed to fetch themes." + /> +
+ + +
+
+ {availableLanguages.map((l) => ( + + ))} +
+
+ + l.id} + getVersion={(l) => l.version} + getName={(l) => l.name} + getAuthor={(l) => l.author} + emptyLabel="No languages found on GitHub." + errorLabel="Failed to fetch languages." + /> +
+
+ + {devMode && ( +
+ + + +
+ )} + + ); +} + +function RemoteList({ + state, + items, + installed, + onInstall, + getId, + getVersion, + getName, + getAuthor, + emptyLabel, + errorLabel, +}: { + state: FetchState; + items: T[]; + installed: T[]; + onInstall: (item: T) => void; + getId: (item: T) => string; + getVersion: (item: T) => number; + getName: (item: T) => string; + getAuthor: (item: T) => string; + emptyLabel: string; + errorLabel: string; +}) { + if (state !== 'done' && state !== 'error') return null; + if (state === 'error') return

{errorLabel}

; + if (items.length === 0) return

{emptyLabel}

; + + return ( +
+ {items.map((item) => { + const inst = installed.find((i) => getId(i) === getId(item)); + const isNewer = inst ? getVersion(item) > getVersion(inst) : true; + return ( +
+
+

{getName(item)}

+

+ v{getVersion(item)} by {getAuthor(item)} +

+
+ {inst && !isNewer ? ( + + Installed + + ) : ( + + )} +
+ ); + })} +
+ ); +} diff --git a/src/renderer/components/settings/sections/ConsoleSection.tsx b/src/renderer/components/settings/sections/ConsoleSection.tsx new file mode 100644 index 0000000..a278684 --- /dev/null +++ b/src/renderer/components/settings/sections/ConsoleSection.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Toggle } from '../../common/Toggle'; +import { Section, Row, NumInput } from '../SettingsRow'; +import { AppSettings } from '../../../../main/shared/types/App.types'; + +interface Props { + draft: AppSettings; + set: (patch: Partial) => void; +} + +export function ConsoleSection({ draft, set }: Props) { + return ( +
+ +
+ set({ consoleFontSize: Number(e.target.value) })} + className="w-24 accent-accent cursor-pointer" + /> + + {draft.consoleFontSize}px + +
+
+ + set({ consoleLineNumbers: v })} + /> + + + set({ consoleTimestamps: v })} + /> + + + set({ consoleWordWrap: v })} /> + + + set({ consoleMaxLines: v })} + /> + + + set({ consoleHistorySize: v })} + /> + +
+ ); +} diff --git a/src/renderer/components/settings/sections/DeveloperSection.tsx b/src/renderer/components/settings/sections/DeveloperSection.tsx new file mode 100644 index 0000000..4ff39fa --- /dev/null +++ b/src/renderer/components/settings/sections/DeveloperSection.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Toggle } from '../../common/Toggle'; +import { Section, Row, NumInput } from '../SettingsRow'; +import { REST_API_CONFIG } from '../../../../main/shared/config/API.config'; +import { AppSettings } from '../../../../main/shared/types/App.types'; + +interface Props { + draft: AppSettings; + set: (patch: Partial) => void; +} + +export function DeveloperSection({ draft, set }: Props) { + return ( +
+ + set({ devModeEnabled: v })} /> + + + set({ restApiEnabled: v })} /> + + {draft.restApiEnabled && ( + + set({ restApiPort: v })} + /> + + )} + {draft.restApiEnabled && ( +
+

+ Listening on{' '} + + http://{REST_API_CONFIG.host}:{draft.restApiPort}/api + +

+

+ Endpoints: /status · /profiles · /processes · /settings +

+
+ )} +
+ ); +} diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx new file mode 100644 index 0000000..41c388c --- /dev/null +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Toggle } from '../../common/Toggle'; +import { Section, Row } from '../SettingsRow'; +import { AppSettings } from '../../../../main/shared/types/App.types'; + +interface Props { + draft: AppSettings; + set: (patch: Partial) => void; +} + +export function GeneralSection({ draft, set }: Props) { + return ( + <> +
+ + set({ launchOnStartup: v })} /> + + + set({ startMinimized: v })} + disabled={!draft.launchOnStartup} + /> + + + set({ minimizeToTray: v })} /> + +
+ + ); +} diff --git a/src/renderer/components/settings/sections/StartupSection.tsx b/src/renderer/components/settings/sections/StartupSection.tsx new file mode 100644 index 0000000..9d961b3 --- /dev/null +++ b/src/renderer/components/settings/sections/StartupSection.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Toggle } from '../../common/Toggle'; +import { Section, Row } from '../SettingsRow'; +import { AppSettings } from '../../../../main/shared/types/App.types'; + +interface Props { + draft: AppSettings; + set: (patch: Partial) => void; +} + +export function StartupSection({ draft, set }: Props) { + return ( +
+ + set({ launchOnStartup: v })} /> + + + set({ startMinimized: v })} + disabled={!draft.launchOnStartup} + /> + + + set({ minimizeToTray: v })} /> + +
+ ); +} diff --git a/src/renderer/components/settings/sections/UpdatesSection.tsx b/src/renderer/components/settings/sections/UpdatesSection.tsx new file mode 100644 index 0000000..05089ef --- /dev/null +++ b/src/renderer/components/settings/sections/UpdatesSection.tsx @@ -0,0 +1,175 @@ +import React, { useState, useCallback } from 'react'; +import { useUpdateRegistry } from '../../../hooks/useUpdateRegistry'; +import type { UpdateStatus, UpdateCheckResult } from '../../../../main/shared/types/UpdateCenter.types'; +import { Button } from '../../common/Button'; +import { Section } from '../SettingsRow'; +import { VscSync, VscCheck, VscWarning, VscCircleSlash, VscCloudDownload } from 'react-icons/vsc'; + +interface ItemState { + status: UpdateStatus; + result?: UpdateCheckResult; + error?: string; +} + +export function UpdatesSection() { + const registry = useUpdateRegistry(); + const [items, setItems] = useState>(() => + Object.fromEntries(registry.map((u) => [u.id, { status: 'idle' as UpdateStatus }])) + ); + const [globalChecking, setGlobalChecking] = useState(false); + const [globalUpdating, setGlobalUpdating] = useState(false); + + const updateItem = (id: string, patch: Partial) => { + setItems((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } })); + }; + + const checkOne = useCallback(async (id: string) => { + const updatable = registry.find((u) => u.id === id); + if (!updatable) return; + updateItem(id, { status: 'checking' }); + try { + const result = await updatable.check(); + updateItem(id, { + status: result.hasUpdate ? 'update-available' : 'up-to-date', + result, + error: result.error, + }); + } catch (e) { + updateItem(id, { status: 'error', error: String(e) }); + } + }, [registry]); + + const applyOne = useCallback(async (id: string) => { + const updatable = registry.find((u) => u.id === id); + if (!updatable) return; + updateItem(id, { status: 'updating' }); + try { + const res = await updatable.apply(); + updateItem(id, { status: res.ok ? 'done' : 'error', error: res.error }); + } catch (e) { + updateItem(id, { status: 'error', error: String(e) }); + } + }, [registry]); + + const checkAll = useCallback(async () => { + setGlobalChecking(true); + for (const u of registry) await checkOne(u.id); + setGlobalChecking(false); + }, [registry, checkOne]); + + const updateAll = useCallback(async () => { + setGlobalUpdating(true); + for (const u of registry) { + if (items[u.id]?.status === 'update-available') await applyOne(u.id); + } + setGlobalUpdating(false); + }, [registry, items, applyOne]); + + const hasAnyUpdate = Object.values(items).some((s) => s.status === 'update-available'); + const allChecked = Object.values(items).every( + (s) => s.status !== 'idle' && s.status !== 'checking' + ); + + return ( +
+
+

+ Check for updates to the app, themes, and language packs +

+
+ + {hasAnyUpdate && ( + + )} +
+
+ +
+ {registry.map((updatable) => { + const state = items[updatable.id] ?? { status: 'idle' }; + return ( + checkOne(updatable.id)} + onApply={() => applyOne(updatable.id)} + /> + ); + })} +
+ + {allChecked && !hasAnyUpdate && ( +
+ +

Everything is up to date.

+
+ )} +
+ ); +} + +function UpdateItem({ + label, + description, + state, + onCheck, + onApply, +}: { + label: string; + description: string; + state: ItemState; + onCheck: () => void; + onApply: () => void; +}) { + const { status, result, error } = state; + + const StatusIcon = { + idle: () => , + checking: () => , + 'up-to-date': () => , + 'update-available': () => , + updating: () => , + done: () => , + error: () => , + }[status]; + + return ( +
+
+
+

{label}

+

+ {status === 'up-to-date' && result && `v${result.currentVersion} -- latest`} + {status === 'update-available' && result && `v${result.currentVersion} -> v${result.remoteVersion}`} + {status === 'done' && 'Updated successfully'} + {status === 'error' && (error ?? 'Check failed')} + {status === 'idle' && description} + {status === 'checking' && 'Checking...'} + {status === 'updating' && 'Applying update...'} +

+
+
+ {(status === 'idle' || status === 'error' || status === 'up-to-date') && ( + + )} + {status === 'update-available' && ( + + )} +
+
+ ); +} diff --git a/src/renderer/config/UpdateCenter.config.ts b/src/renderer/config/UpdateCenter.config.ts new file mode 100644 index 0000000..7192c8d --- /dev/null +++ b/src/renderer/config/UpdateCenter.config.ts @@ -0,0 +1,56 @@ +import type { Updatable } from '../../main/shared/types/UpdateCenter.types'; +import { version } from '../../../package.json'; + +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; +} + +const appUpdatable: Updatable = { + id: 'app', + label: 'Application', + description: 'Java Runner Client core application', + check: async () => { + const res = await window.api.fetchLatestRelease(); + if (!res.ok || !res.data) return { hasUpdate: false, currentVersion: version, remoteVersion: version, error: res.error }; + const remote = (res.data.tag_name ?? '').replace(/^v/, ''); + return { hasUpdate: semverGt(remote, version), currentVersion: version, remoteVersion: remote }; + }, + apply: async () => ({ ok: false, error: 'Use the release modal to download the installer' }), +}; + +const themeUpdatable: Updatable = { + id: 'theme', + label: 'Theme', + description: 'Currently active visual theme', + check: async () => { + const state = await window.api.getThemeState(); + const res = await window.api.checkThemeUpdate(state.activeThemeId); + return { hasUpdate: res.hasUpdate, currentVersion: res.localVersion, remoteVersion: res.remoteVersion }; + }, + apply: async () => { + const state = await window.api.getThemeState(); + return window.api.applyThemeUpdate(state.activeThemeId); + }, +}; + +const languageUpdatable: Updatable = { + id: 'language', + label: 'Language', + description: 'Currently active language pack', + check: async () => { + const state = await window.api.getLanguageState(); + const res = await window.api.checkLanguageUpdate(state.activeLanguageId); + return { hasUpdate: res.hasUpdate, currentVersion: res.localVersion, remoteVersion: res.remoteVersion }; + }, + apply: async () => { + const state = await window.api.getLanguageState(); + return window.api.applyLanguageUpdate(state.activeLanguageId); + }, +}; + +export const UPDATE_REGISTRY: Updatable[] = [appUpdatable, themeUpdatable, languageUpdatable]; diff --git a/src/renderer/hooks/ThemeProvider.tsx b/src/renderer/hooks/ThemeProvider.tsx new file mode 100644 index 0000000..2f276aa --- /dev/null +++ b/src/renderer/hooks/ThemeProvider.tsx @@ -0,0 +1,121 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import type { ThemeDefinition, ThemeColors } from '../../main/shared/types/Theme.types'; +import { BUILTIN_THEME } from '../../main/shared/config/Theme.config'; + +interface ThemeContextValue { + theme: ThemeDefinition; + setTheme: (id: string) => Promise; + availableThemes: ThemeDefinition[]; + refreshThemes: () => Promise; +} + +const ThemeContext = createContext(null); + +const STYLE_ID = 'jrc-theme-override'; + +/** + * Generates a