diff --git a/src/main/events.ts b/src/main/events.ts index 8ee67ea..a203050 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -6,6 +6,7 @@ import { type DeviceCodeInfo, type UpdateStatus } from '../shared/ipc' +import * as logger from './logger' /** Fenêtre principale, définie au démarrage (src/main/index.ts). */ let mainWindow: BrowserWindow | null = null @@ -21,8 +22,14 @@ function send(channel: string, payload: unknown): void { } export const emit = { - progress: (e: ProgressEvent): void => send(IPC_EVENT.progress, e), - gameLog: (l: GameLogLine): void => send(IPC_EVENT.gameLog, l), + progress: (e: ProgressEvent): void => { + if (e.phase === 'error') logger.write(`[ERREUR] ${e.message}`) + send(IPC_EVENT.progress, e) + }, + gameLog: (l: GameLogLine): void => { + logger.write(l.line) + send(IPC_EVENT.gameLog, l) + }, gameClosed: (code: number | null): void => send(IPC_EVENT.gameClosed, code), authCode: (info: DeviceCodeInfo): void => send(IPC_EVENT.authCode, info), updateStatus: (s: UpdateStatus): void => send(IPC_EVENT.updateStatus, s) diff --git a/src/main/index.ts b/src/main/index.ts index 3c4c65f..36c85e2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,10 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron' import { join } from 'path' import { setMainWindow } from './events' -import { IPC, type UserSettings } from '../shared/ipc' +import { IPC, type UserSettings, type PlayOptions } from '../shared/ipc' import { login, logout, restoreSession, getCurrent } from './auth' -import { play } from './play' +import { play, stopGame } from './play' +import { getPackMetaCached } from './modpack' import { getSettings, setSettings } from './settings' import { paths } from './paths' import { initUpdater, quitAndInstallUpdate } from './updater' @@ -50,11 +51,14 @@ function registerIpc(): void { ipcMain.handle(IPC.authGetProfile, async () => { return getCurrent()?.profile ?? (await restoreSession()) }) - ipcMain.handle(IPC.play, () => play()) + ipcMain.handle(IPC.play, (_e, opts: PlayOptions | undefined) => play(opts)) + ipcMain.handle(IPC.playStop, () => stopGame()) + ipcMain.handle(IPC.packGet, () => getPackMetaCached()) ipcMain.handle(IPC.settingsGet, () => getSettings()) ipcMain.handle(IPC.settingsSet, (_e, s: UserSettings) => setSettings(s)) ipcMain.handle(IPC.appVersion, () => app.getVersion()) ipcMain.handle(IPC.openInstanceDir, () => shell.openPath(paths.instanceDir)) + ipcMain.handle(IPC.openLogsDir, () => shell.openPath(paths.logsDir)) ipcMain.handle(IPC.updateInstall, () => quitAndInstallUpdate()) } diff --git a/src/main/install.ts b/src/main/install.ts index 7f57c5d..2df6ffd 100644 --- a/src/main/install.ts +++ b/src/main/install.ts @@ -1,8 +1,10 @@ import { existsSync } from 'fs' import { join } from 'path' -import { getVersionList, install, installNeoForged } from '@xmcl/installer' +import { getVersionList, installTask, installNeoForgedTask } from '@xmcl/installer' +import type { Task } from '@xmcl/task' import { paths } from './paths' import { emit } from './events' +import { type LaunchPhase } from '../shared/ipc' import { downloadDispatcher, DOWNLOAD_CONCURRENCY, withRetries } from './net' /** Options de téléchargement communes : dispatcher tolérant + concurrence bridée. */ @@ -12,6 +14,25 @@ const downloadOptions = { librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY } +/** + * Exécute une task @xmcl en émettant sa progression réelle (0..1) vers le + * renderer. On lit la progression cumulée de la task racine ; on n'émet qu'au + * changement de pourcentage entier pour ne pas inonder l'IPC. + */ +async function runWithProgress(phase: LaunchPhase, message: string, task: Task): Promise { + let lastPct = -1 + return task.startAndWait({ + onUpdate() { + if (task.total <= 0) return + const ratio = task.progress / task.total + const pct = Math.floor(ratio * 100) + if (pct === lastPct) return + lastPct = pct + emit.progress({ phase, message, progress: Math.min(1, ratio) }) + } + }) +} + /** * Installe le runtime Minecraft (vanilla 1.21.1) puis NeoForge dans * paths.gameRoot. Les deux étapes sont idempotentes : si la version est déjà @@ -25,25 +46,27 @@ function versionInstalled(id: string): boolean { return existsSync(join(paths.gameRoot, 'versions', id, `${id}.json`)) } -/** Installe Minecraft vanilla (json + jar + assets + libraries). */ -export async function installMinecraft(version: string): Promise { - if (versionInstalled(version)) return +/** + * Installe Minecraft vanilla (json + jar + assets + libraries). + * `force` saute le court-circuit "déjà installé" pour revalider/réparer les + * fichiers (l'install @xmcl ignore les fichiers déjà valides par checksum). + */ +export async function installMinecraft(version: string, force = false): Promise { + if (!force && versionInstalled(version)) return emit.progress({ phase: 'minecraft', message: `Minecraft ${version} : métadonnées…`, progress: undefined }) const list = await getVersionList() const meta = list.versions.find((v) => v.id === version) if (!meta) throw new Error(`Version Minecraft introuvable : ${version}`) - emit.progress({ - phase: 'minecraft', - message: `Installation de Minecraft ${version} (assets + libs)…`, - progress: undefined - }) + const label = force + ? `Vérification de Minecraft ${version}…` + : `Installation de Minecraft ${version} (assets + libs)…` // ~3700 assets : on réessaie l'install entière plusieurs fois. Chaque passe // ignore les fichiers déjà valides et ne reprend que les manquants. await withRetries( - () => install(meta, paths.gameRoot, downloadOptions), + () => runWithProgress('minecraft', label, installTask(meta, paths.gameRoot, downloadOptions)), 5, (attempt) => emit.progress({ @@ -61,26 +84,33 @@ export async function installMinecraft(version: string): Promise { export async function installNeoForge( neoforge: string, minecraft: string, - javaPath: string + javaPath: string, + force = false ): Promise { // @xmcl nomme la version installée "neoforge-" (ex. neoforge-21.1.224). // Si elle est déjà présente, on saute l'install (idempotent, comme Minecraft). const expectedId = `neoforge-${neoforge}` - if (versionInstalled(expectedId)) { + if (!force && versionInstalled(expectedId)) { void minecraft return expectedId } - emit.progress({ phase: 'neoforge', message: `Installation de NeoForge ${neoforge}…`, progress: undefined }) + const label = force + ? `Vérification de NeoForge ${neoforge}…` + : `Installation de NeoForge ${neoforge}…` - // installNeoForged retourne l'id de version installée à lancer. + // installNeoForgedTask retourne l'id de version installée à lancer. const versionId = await withRetries( () => - installNeoForged('neoforge', neoforge, paths.gameRoot, { - java: javaPath, - dispatcher: downloadDispatcher, - librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY - }), + runWithProgress( + 'neoforge', + label, + installNeoForgedTask('neoforge', neoforge, paths.gameRoot, { + java: javaPath, + dispatcher: downloadDispatcher, + librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY + }) + ), 3, (attempt) => emit.progress({ diff --git a/src/main/logger.ts b/src/main/logger.ts new file mode 100644 index 0000000..1383ca9 --- /dev/null +++ b/src/main/logger.ts @@ -0,0 +1,30 @@ +import { createWriteStream, type WriteStream } from 'fs' +import { paths } from './paths' + +/** + * Journalisation sur disque du launcher (logs/launcher.log). + * + * Capture tout ce qui transite par `events.ts` (sortie jeu + packwiz + messages + * de phase + erreurs), pour pouvoir dépanner un joueur à distance. Le fichier + * est tronqué au début de chaque session "Jouer". + */ + +let stream: WriteStream | null = null + +function ts(): string { + return new Date().toISOString() +} + +/** Ouvre (en tronquant) un nouveau fichier de log et écrit un en-tête. */ +export function startSession(): void { + stream?.end() + stream = createWriteStream(paths.launcherLogFile, { flags: 'w' }) + stream.write(`=== Session OFLauncher ${ts()} ===\n`) +} + +/** Ajoute une ligne au log courant (no-op si aucune session ouverte). */ +export function write(line: string): void { + if (!stream) return + const text = line.endsWith('\n') ? line : `${line}\n` + stream.write(text) +} diff --git a/src/main/modpack.ts b/src/main/modpack.ts index 0e5b5a6..938f1cb 100644 --- a/src/main/modpack.ts +++ b/src/main/modpack.ts @@ -4,6 +4,9 @@ import { paths } from './paths' import { config } from '../shared/config' import { fetchText } from './download' import { emit } from './events' +import type { PackMeta } from '../shared/ipc' + +export type { PackMeta } /** * Gestion du modpack côté launcher : @@ -15,11 +18,17 @@ import { emit } from './events' * comportement "pas de re-download complet à chaque update" recherché. */ -export interface PackMeta { - name: string - version: string - minecraft: string - neoforge: string +/** Dernière PackMeta lue avec succès (pour l'affichage UI, y compris hors flux Jouer). */ +let lastMeta: PackMeta | null = null + +/** Renvoie la dernière PackMeta connue, ou la récupère si jamais lue. Tolérant au offline. */ +export async function getPackMetaCached(): Promise { + if (lastMeta) return lastMeta + try { + return await fetchPackMeta() + } catch { + return null + } } /** Télécharge et parse le pack.toml distant. */ @@ -47,12 +56,13 @@ export async function fetchPackMeta(): Promise { ) } - return { + lastMeta = { name: data.name ?? 'Modpack', version: data.version ?? '0', minecraft, neoforge } + return lastMeta } /** diff --git a/src/main/paths.ts b/src/main/paths.ts index 9e1f276..15d5070 100644 --- a/src/main/paths.ts +++ b/src/main/paths.ts @@ -41,6 +41,16 @@ class LauncherPaths { return join(this.root, 'settings.json') } + /** Dossier des logs du launcher (install/Java/packwiz/jeu). */ + get logsDir(): string { + return this.ensure(join(this.root, 'logs')) + } + + /** Fichier de log courant du launcher. */ + get launcherLogFile(): string { + return join(this.logsDir, 'launcher.log') + } + /** jar packwiz-installer-bootstrap embarqué dans les resources. */ get packwizBootstrapJar(): string { // En prod, electron-builder copie resources/ via extraResources. diff --git a/src/main/play.ts b/src/main/play.ts index 4b26fe2..9c11310 100644 --- a/src/main/play.ts +++ b/src/main/play.ts @@ -7,6 +7,8 @@ import { syncModpack } from './modpack' import { launchGame } from './launch' import { getSettings } from './settings' import { emit } from './events' +import * as logger from './logger' +import type { PlayOptions } from '../shared/ipc' /** Process de jeu courant (un seul à la fois). */ let gameProcess: ChildProcess | null = null @@ -20,7 +22,7 @@ let gameProcess: ChildProcess | null = null * idempotentes, donc à partir du 2e lancement seules les nouveautés du modpack * sont téléchargées. */ -export async function play(): Promise { +export async function play(opts?: PlayOptions): Promise { if (gameProcess) { throw new Error('Le jeu est déjà en cours.') } @@ -30,11 +32,14 @@ export async function play(): Promise { throw new Error('Non connecté. Connecte-toi avec ton compte Microsoft d’abord.') } + const repair = opts?.repair ?? false + logger.startSession() + try { const meta = await fetchPackMeta() const javaPath = await ensureJava() - await installMinecraft(meta.minecraft) - const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath) + await installMinecraft(meta.minecraft, repair) + const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath, repair) await syncModpack(javaPath) const settings = await getSettings() @@ -48,3 +53,10 @@ export async function play(): Promise { throw e } } + +/** Tue le process de jeu courant. Renvoie true si un process tournait. */ +export function stopGame(): boolean { + if (!gameProcess) return false + gameProcess.kill() + return true +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b6f2bea..3a8c85c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,7 +7,9 @@ import { type GameLogLine, type UserSettings, type DeviceCodeInfo, - type UpdateStatus + type UpdateStatus, + type PlayOptions, + type PackMeta } from '../shared/ipc' /** API typée exposée au renderer via window.api. */ @@ -18,7 +20,9 @@ const api = { getProfile: (): Promise => ipcRenderer.invoke(IPC.authGetProfile), // --- Jouer (install + sync + launch) --- - play: (): Promise => ipcRenderer.invoke(IPC.play), + play: (opts?: PlayOptions): Promise => ipcRenderer.invoke(IPC.play, opts), + stopGame: (): Promise => ipcRenderer.invoke(IPC.playStop), + getPackMeta: (): Promise => ipcRenderer.invoke(IPC.packGet), // --- Réglages --- getSettings: (): Promise => ipcRenderer.invoke(IPC.settingsGet), @@ -29,6 +33,9 @@ const api = { /** Ouvre le dossier d'instance (mods/config/saves) dans l'explorateur. */ openInstanceDir: (): Promise => ipcRenderer.invoke(IPC.openInstanceDir), + /** Ouvre le dossier des logs du launcher. */ + openLogsDir: (): Promise => ipcRenderer.invoke(IPC.openLogsDir), + /** Quitte et installe la mise à jour téléchargée. */ installUpdate: (): Promise => ipcRenderer.invoke(IPC.updateInstall), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ba24f80..9ae7d77 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,9 +4,15 @@ import type { ProgressEvent, GameLogLine, DeviceCodeInfo, - UpdateStatus + UpdateStatus, + PackMeta } from '../../shared/ipc' +/** Découpe une chaîne d'args JVM en tableau (espaces, vides ignorés). */ +function parseArgs(s: string): string[] { + return s.split(/\s+/).filter(Boolean) +} + type Status = 'loading' | 'logged-out' | 'logged-in' | 'working' | 'running' export default function App(): JSX.Element { @@ -17,8 +23,10 @@ export default function App(): JSX.Element { const [error, setError] = useState(null) const [authCode, setAuthCode] = useState(null) const [maxMemoryMb, setMaxMemoryMb] = useState(8192) + const [jvmArgs, setJvmArgs] = useState('') const [appVersion, setAppVersion] = useState('') const [update, setUpdate] = useState(null) + const [pack, setPack] = useState(null) const consoleRef = useRef(null) // Restaure la session + réglages au démarrage. @@ -30,10 +38,13 @@ export default function App(): JSX.Element { window.api.getAppVersion() ]) setMaxMemoryMb(s.maxMemoryMb) + setJvmArgs(s.extraJvmArgs.join(' ')) setAppVersion(v) setProfile(p) setStatus(p ? 'logged-in' : 'logged-out') })() + // Récupère le nom/version réels du pack (tolérant au offline). + void window.api.getPackMeta().then((m) => m && setPack(m)) }, []) // Abonnements aux événements main -> renderer. @@ -97,13 +108,21 @@ export default function App(): JSX.Element { void window.api.openInstanceDir() } - async function handlePlay(): Promise { + function handleOpenLogs(): void { + void window.api.openLogsDir() + } + + function handleStop(): void { + void window.api.stopGame() + } + + async function handlePlay(opts?: { repair?: boolean }): Promise { setError(null) setLogs([]) setStatus('working') - await window.api.setSettings({ maxMemoryMb, extraJvmArgs: [] }) + await window.api.setSettings({ maxMemoryMb, extraJvmArgs: parseArgs(jvmArgs) }) try { - await window.api.play() + await window.api.play(opts) } catch (e) { setError(`Échec du lancement : ${(e as Error).message}`) setStatus('logged-in') @@ -178,7 +197,9 @@ export default function App(): JSX.Element {
OFLauncher - All The Mods 10 · 1.21.1 + + {pack ? `${pack.name} · MC ${pack.minecraft}` : 'All The Mods 10 · 1.21.1'} +
@@ -220,14 +241,34 @@ export default function App(): JSX.Element { onChange={(e) => setMaxMemoryMb(Number(e.target.value))} /> Mo + setJvmArgs(e.target.value)} + /> + +
- + {status === 'running' ? ( + + ) : ( + + )}
{error &&
{error}
} diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 35832cf..d048731 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -123,6 +123,20 @@ body { .progress { flex: 1; + min-width: 0; +} + +/* Fenêtre réduite : la barre de progression passe sur sa propre ligne + (sinon les réglages la compriment jusqu'à la rendre invisible). */ +@media (max-width: 1200px) { + .footer { + flex-wrap: wrap; + } + + .progress { + order: -1; + flex-basis: 100%; + } } .progress .label { @@ -180,6 +194,15 @@ button.play:disabled { cursor: not-allowed; } +button.play.stop { + background: var(--danger); + color: #2a0606; +} + +button.play.stop:hover:not(:disabled) { + background: #ff6258; +} + .center { flex: 1; display: flex; @@ -214,6 +237,7 @@ button.play:disabled { .settings { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; font-size: 13px; color: var(--text-dim); @@ -228,6 +252,10 @@ button.play:disabled { padding: 4px 6px; } +.settings input.jvm { + width: 200px; +} + .error { color: var(--danger); font-size: 13px; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index c818433..b93a81c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -67,6 +67,20 @@ export interface UpdateStatus { message?: string } +/** Métadonnées du modpack lues depuis le pack.toml packwiz distant. */ +export interface PackMeta { + name: string + version: string + minecraft: string + neoforge: string +} + +/** Options de la séquence "Jouer". */ +export interface PlayOptions { + /** Force la revérification/redownload des fichiers (réparation). */ + repair?: boolean +} + /** Réglages utilisateur persistés localement. */ export interface UserSettings { /** RAM max allouée à la JVM, en Mo. */ @@ -90,6 +104,9 @@ export const IPC = { settingsSet: 'settings:set', appVersion: 'app:version', openInstanceDir: 'instance:open', + openLogsDir: 'logs:open', + playStop: 'play:stop', + packGet: 'pack:get', updateInstall: 'update:install' } as const