first commit

This commit is contained in:
lucasdpt
2026-06-14 12:32:29 +02:00
commit cc4f90e840
32 changed files with 9729 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
import { shell } from 'electron'
import { Authflow, Titles, type MicrosoftAuthFlowOptions } from 'prismarine-auth'
import { paths } from './paths'
import { config } from '../shared/config'
import { emit } from './events'
import type { PlayerProfile } from '../shared/ipc'
/**
* Auth Microsoft via prismarine-auth (chaîne MS -> Xbox Live -> token Minecraft,
* avec cache + refresh automatique dans paths.authCache).
*
* Deux modes selon la config :
* - Azure non configuré (azureClientId = "CHANGE_ME") : flow "sisu" avec le
* title officiel Minecraft. Fonctionne sans app Azure. (On utilise "sisu" et
* pas "live" : le flow "live" fait un appel title-token séparé que Microsoft
* refuse désormais avec un 403 ; "sisu" combine device+title+user en un seul
* appel et passe.)
* - Azure configuré : flow "msal" avec TON clientId (le mode "propre" pour un
* launcher tiers, une fois l'app approuvée pour l'API Minecraft).
*
* Dans les deux cas on utilise le flow "device code" : on ouvre la page de
* vérification Microsoft et on affiche le code à l'utilisateur.
*/
/** Identifiant de compte local pour le cache (un seul compte par machine ici). */
const ACCOUNT_KEY = 'player'
/** Résultat complet du login, conservé en mémoire pour le lancement du jeu. */
export interface AuthResult {
profile: PlayerProfile
/** Token d'accès Minecraft (à passer à launch). */
accessToken: string
}
let current: AuthResult | null = null
function buildAuthflow(interactive: boolean): Authflow {
const hasAzureApp = !!config.azureClientId && config.azureClientId !== 'CHANGE_ME'
const options: MicrosoftAuthFlowOptions = hasAzureApp
? { flow: 'msal', authTitle: config.azureClientId as unknown as Titles, deviceType: 'Win32' }
: { flow: 'sisu', authTitle: Titles.MinecraftJava, deviceType: 'Win32' }
// codeCallback : appelé par prismarine-auth quand une connexion interactive
// (device code) est nécessaire.
// - Mode interactif : on pousse le code dans l'UI du launcher (+ ouverture
// de la page Microsoft dans le navigateur).
// - Mode silencieux (restauration) : on LÈVE une exception immédiatement.
// Ça coupe le flow AVANT le console.info et AVANT le polling de prismarine,
// donc aucun code ne fuite dans la console au démarrage : on retombe juste
// sur "pas de session" -> écran de login.
const codeCallback = (res: {
user_code: string
verification_uri: string
message: string
}): void => {
if (!interactive) {
throw new Error('interaction-required')
}
emit.authCode({
userCode: res.user_code,
verificationUri: res.verification_uri,
message: res.message
})
// Ouvre la page Microsoft dans le navigateur du système.
void shell.openExternal(res.verification_uri)
}
return new Authflow(ACCOUNT_KEY, paths.authCache, options, codeCallback)
}
function toProfile(p: { id: string; name: string; skins?: { url: string }[] }): PlayerProfile {
return {
uuid: p.id,
name: p.name,
skinUrl: p.skins?.[0]?.url
}
}
/** Login interactif (déclenche le device code si pas de session valide en cache). */
export async function login(): Promise<PlayerProfile> {
const flow = buildAuthflow(true)
const { token, profile } = await flow.getMinecraftJavaToken({ fetchProfile: true })
current = { profile: toProfile(profile), accessToken: token }
return current.profile
}
/**
* Tente de restaurer la session depuis le cache SANS interaction.
* Retourne null si aucun token valide/rafraîchissable n'est disponible.
*
* Si le cache est vide, on ne tente même pas (évite de déclencher un device
* code). Sinon, le refresh silencieux est une requête réseau rapide ; on la
* met en course contre un timeout pour ne jamais bloquer si prismarine-auth
* décidait malgré tout de demander une interaction.
*/
export async function restoreSession(): Promise<PlayerProfile | null> {
try {
const { readdir } = await import('fs/promises')
const cached = await readdir(paths.authCache).catch(() => [])
if (cached.length === 0) return null
const flow = buildAuthflow(false)
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('restore-timeout')), 20_000)
)
const { token, profile } = await Promise.race([
flow.getMinecraftJavaToken({ fetchProfile: true }),
timeout
])
current = { profile: toProfile(profile), accessToken: token }
return current.profile
} catch {
return null
}
}
/** Renvoie la session courante (token + profil) ou null. */
export function getCurrent(): AuthResult | null {
return current
}
/** Déconnexion : efface le cache de tokens. */
export async function logout(): Promise<void> {
current = null
const { rm } = await import('fs/promises')
await rm(paths.authCache, { recursive: true, force: true })
}
+52
View File
@@ -0,0 +1,52 @@
import { createWriteStream } from 'fs'
import { mkdir, rm } from 'fs/promises'
import { dirname } from 'path'
import { Readable, Transform } from 'stream'
import { pipeline } from 'stream/promises'
/**
* Télécharge une URL vers un fichier, en suivant les redirections (fetch le
* fait par défaut). Appelle onProgress(0..1) si la taille est connue.
*
* La progression est mesurée via un Transform intercalé dans le pipeline (et
* NON via un listener 'data', qui basculerait le stream en mode flowing et
* pourrait perdre des chunks -> archive tronquée).
*/
export async function downloadFile(
url: string,
dest: string,
onProgress?: (fraction: number) => void
): Promise<void> {
const res = await fetch(url, { redirect: 'follow' })
if (!res.ok || !res.body) {
throw new Error(`Téléchargement échoué (${res.status}) : ${url}`)
}
await mkdir(dirname(dest), { recursive: true })
const total = Number(res.headers.get('content-length') ?? 0)
let received = 0
const counter = new Transform({
transform(chunk: Buffer, _enc, cb): void {
received += chunk.length
if (total > 0 && onProgress) onProgress(Math.min(1, received / total))
cb(null, chunk)
}
})
const body = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0])
try {
await pipeline(body, counter, createWriteStream(dest))
} catch (e) {
await rm(dest, { force: true })
throw e
}
}
/** Récupère un JSON depuis une URL (manifeste, pack.toml résolu, etc.). */
export async function fetchText(url: string): Promise<string> {
const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) throw new Error(`Requête échouée (${res.status}) : ${url}`)
return res.text()
}
+29
View File
@@ -0,0 +1,29 @@
import { BrowserWindow } from 'electron'
import {
IPC_EVENT,
type ProgressEvent,
type GameLogLine,
type DeviceCodeInfo,
type UpdateStatus
} from '../shared/ipc'
/** Fenêtre principale, définie au démarrage (src/main/index.ts). */
let mainWindow: BrowserWindow | null = null
export function setMainWindow(win: BrowserWindow): void {
mainWindow = win
}
function send(channel: string, payload: unknown): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, payload)
}
}
export const emit = {
progress: (e: ProgressEvent): void => send(IPC_EVENT.progress, e),
gameLog: (l: GameLogLine): void => 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)
}
+77
View File
@@ -0,0 +1,77 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import { setMainWindow } from './events'
import { IPC, type UserSettings } from '../shared/ipc'
import { login, logout, restoreSession, getCurrent } from './auth'
import { play } from './play'
import { getSettings, setSettings } from './settings'
import { paths } from './paths'
import { initUpdater, quitAndInstallUpdate } from './updater'
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 980,
height: 640,
minWidth: 820,
minHeight: 540,
show: false,
autoHideMenuBar: true,
title: 'OFLauncher',
backgroundColor: '#0e1116',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
win.on('ready-to-show', () => win.show())
// Liens externes -> navigateur système.
win.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url)
return { action: 'deny' }
})
// En dev, electron-vite expose l'URL du serveur Vite ; en prod, on charge le HTML buildé.
if (process.env['ELECTRON_RENDERER_URL']) {
void win.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
void win.loadFile(join(__dirname, '../renderer/index.html'))
}
return win
}
function registerIpc(): void {
ipcMain.handle(IPC.authLogin, () => login())
ipcMain.handle(IPC.authLogout, () => logout())
ipcMain.handle(IPC.authGetProfile, async () => {
return getCurrent()?.profile ?? (await restoreSession())
})
ipcMain.handle(IPC.play, () => play())
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.updateInstall, () => quitAndInstallUpdate())
}
app.whenReady().then(() => {
registerIpc()
const win = createWindow()
setMainWindow(win)
initUpdater()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
const w = createWindow()
setMainWindow(w)
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+98
View File
@@ -0,0 +1,98 @@
import { existsSync } from 'fs'
import { join } from 'path'
import { getVersionList, install, installNeoForged } from '@xmcl/installer'
import { paths } from './paths'
import { emit } from './events'
import { downloadDispatcher, DOWNLOAD_CONCURRENCY, withRetries } from './net'
/** Options de téléchargement communes : dispatcher tolérant + concurrence bridée. */
const downloadOptions = {
dispatcher: downloadDispatcher,
assetsDownloadConcurrency: DOWNLOAD_CONCURRENCY,
librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY
}
/**
* Installe le runtime Minecraft (vanilla 1.21.1) puis NeoForge dans
* paths.gameRoot. Les deux étapes sont idempotentes : si la version est déjà
* présente sur le disque, on ne refait pas le travail.
*
* Retourne l'id de version NeoForge à lancer (ex. "neoforge-21.1.73").
*/
/** true si un dossier de version <id> avec son JSON existe déjà. */
function versionInstalled(id: string): boolean {
return existsSync(join(paths.gameRoot, 'versions', id, `${id}.json`))
}
/** Installe Minecraft vanilla <version> (json + jar + assets + libraries). */
export async function installMinecraft(version: string): Promise<void> {
if (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
})
// ~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),
5,
(attempt) =>
emit.progress({
phase: 'minecraft',
message: `Reprise des téléchargements (tentative ${attempt + 1})…`,
progress: undefined
})
)
}
/**
* Installe NeoForge <neoforge> par-dessus Minecraft <minecraft>.
* Nécessite un Java (les "processors" de l'installeur tournent en Java).
*/
export async function installNeoForge(
neoforge: string,
minecraft: string,
javaPath: string
): Promise<string> {
// @xmcl nomme la version installée "neoforge-<version>" (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)) {
void minecraft
return expectedId
}
emit.progress({ phase: 'neoforge', message: `Installation de NeoForge ${neoforge}`, progress: undefined })
// installNeoForged retourne l'id de version installée à lancer.
const versionId = await withRetries(
() =>
installNeoForged('neoforge', neoforge, paths.gameRoot, {
java: javaPath,
dispatcher: downloadDispatcher,
librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY
}),
3,
(attempt) =>
emit.progress({
phase: 'neoforge',
message: `Reprise de l'installation NeoForge (tentative ${attempt + 1})…`,
progress: undefined
})
)
if (!versionInstalled(versionId)) {
throw new Error(`NeoForge installé mais version "${versionId}" introuvable sur le disque.`)
}
void minecraft // (info de contexte ; la version NeoForge hérite déjà de Minecraft)
return versionId
}
+209
View File
@@ -0,0 +1,209 @@
import { spawn } from 'child_process'
import { join, basename } from 'path'
import { existsSync } from 'fs'
import { readdir, mkdir, rm, writeFile, readFile } from 'fs/promises'
import { paths } from './paths'
import { downloadFile } from './download'
import { emit } from './events'
/**
* Fournit un binaire Java 21+ (requis par ATM10 / NeoForge 1.21.1).
*
* Stratégie, du moins coûteux au plus coûteux :
* 1. Java déjà mémorisé dans java/managed.json (provisionné ou détecté avant).
* 2. Java >= 21 déjà présent sur la machine (JAVA_HOME, puis PATH).
* 3. Sinon : téléchargement d'un JRE Temurin (Adoptium) — archive autonome,
* extraite avec l'outil système (`tar` sur Linux/mac, `Expand-Archive`
* PowerShell sur Windows).
*
* Le chemin retenu est mémorisé pour les lancements suivants.
*/
const JAVA_MAJOR = 21
const MARKER = (): string => join(paths.javaDir, 'managed.json')
/** Exécute `<javaPath> -version` et renvoie la version majeure (ex. 21), ou null. */
async function javaMajorVersion(javaPath: string): Promise<number | null> {
return new Promise((resolve) => {
let out = ''
const child = spawn(javaPath, ['-version'])
child.stdout?.on('data', (d: Buffer) => (out += d.toString()))
child.stderr?.on('data', (d: Buffer) => (out += d.toString()))
child.on('error', () => resolve(null))
child.on('close', () => {
// ex: 'openjdk version "21.0.2"' ou '... "1.8.0_xyz"'
const m = out.match(/version "(\d+)(?:\.(\d+))?/)
if (!m) return resolve(null)
let major = parseInt(m[1], 10)
if (major === 1 && m[2]) major = parseInt(m[2], 10) // 1.8 -> 8
resolve(Number.isNaN(major) ? null : major)
})
})
}
/** Résout un exécutable via `where` (Windows) / `which` (unix). */
async function resolveOnPath(bin: string): Promise<string | null> {
const cmd = process.platform === 'win32' ? 'where' : 'which'
return new Promise((resolve) => {
let out = ''
const child = spawn(cmd, [bin])
child.stdout?.on('data', (d: Buffer) => (out += d.toString()))
child.on('error', () => resolve(null))
child.on('close', (code) => {
if (code !== 0) return resolve(null)
const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean)
resolve(first ?? null)
})
})
}
/**
* Cherche un Java >= JAVA_MAJOR déjà installé sur la machine.
* Retourne le chemin du binaire ou null.
*/
async function findSystemJava(): Promise<string | null> {
const javaBin = detectPlatform().javaBin
const candidates: string[] = []
if (process.env.JAVA_HOME) {
candidates.push(join(process.env.JAVA_HOME, 'bin', javaBin))
}
const onPath = await resolveOnPath(javaBin)
if (onPath) candidates.push(onPath)
for (const c of candidates) {
if (!existsSync(c)) continue
const major = await javaMajorVersion(c)
if (major !== null && major >= JAVA_MAJOR) return c
}
return null
}
interface Platform {
os: 'windows' | 'linux' | 'mac'
arch: 'x64' | 'aarch64'
archiveExt: 'zip' | 'tar.gz'
javaBin: string
}
function detectPlatform(): Platform {
const os =
process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'mac' : 'linux'
const arch = process.arch === 'arm64' ? 'aarch64' : 'x64'
return {
os,
arch,
archiveExt: os === 'windows' ? 'zip' : 'tar.gz',
javaBin: os === 'windows' ? 'java.exe' : 'java'
}
}
/** Cherche récursivement bin/<javaBin> sous un dossier extrait. */
async function findJavaBinary(root: string, javaBin: string): Promise<string | null> {
const stack = [root]
while (stack.length) {
const dir = stack.pop()!
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
for (const e of entries) {
const full = join(dir, e.name)
if (e.isDirectory()) {
stack.push(full)
} else if (e.name === javaBin && basename(dir) === 'bin') {
return full
}
}
}
return null
}
async function extractArchive(archive: string, destDir: string, ext: string): Promise<void> {
await mkdir(destDir, { recursive: true })
await new Promise<void>((resolve, reject) => {
const child =
ext === 'zip'
? spawn(
'powershell',
[
'-NoProfile',
'-Command',
`Expand-Archive -Path "${archive}" -DestinationPath "${destDir}" -Force`
],
{ stdio: 'ignore' }
)
: spawn('tar', ['-xzf', archive, '-C', destDir], { stdio: 'ignore' })
child.on('error', reject)
child.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`Extraction échouée (code ${code})`))
)
})
}
/**
* Retourne le chemin du binaire java géré, en l'installant si nécessaire.
* Idempotent : si déjà installé, retourne immédiatement le chemin mémorisé.
*/
export async function ensureJava(): Promise<string> {
// 1) Déjà mémorisé (provisionné OU détecté lors d'un lancement précédent) ?
if (existsSync(MARKER())) {
try {
const { javaPath } = JSON.parse(await readFile(MARKER(), 'utf-8')) as { javaPath: string }
if (javaPath && existsSync(javaPath)) return javaPath
} catch {
/* marqueur corrompu : on réinstalle */
}
}
// 2) Java >= 21 déjà présent sur la machine ? On l'utilise tel quel.
emit.progress({ phase: 'java', message: 'Recherche de Java sur la machine…', progress: undefined })
const system = await findSystemJava()
if (system) {
await rememberJava(system, 'system')
emit.progress({ phase: 'java', message: 'Java 21 détecté sur la machine.', progress: 1 })
return system
}
// 3) Sinon : on télécharge un JRE Temurin 21.
const plat = detectPlatform()
emit.progress({ phase: 'java', message: `Téléchargement de Java ${JAVA_MAJOR} (Temurin)…`, progress: 0 })
const url =
`https://api.adoptium.net/v3/binary/latest/${JAVA_MAJOR}/ga/` +
`${plat.os}/${plat.arch}/jre/hotspot/normal/eclipse`
const installRoot = join(paths.javaDir, `temurin-${JAVA_MAJOR}`)
await rm(installRoot, { recursive: true, force: true })
const archive = join(paths.javaDir, `temurin-${JAVA_MAJOR}.${plat.archiveExt}`)
await downloadFile(url, archive, (f) =>
emit.progress({ phase: 'java', message: `Téléchargement de Java ${JAVA_MAJOR}`, progress: f })
)
emit.progress({ phase: 'java', message: 'Extraction de Java…', progress: undefined })
await extractArchive(archive, installRoot, plat.archiveExt)
await rm(archive, { force: true })
const javaPath = await findJavaBinary(installRoot, plat.javaBin)
if (!javaPath) {
const listing = await readdir(installRoot).catch(() => [] as string[])
throw new Error(
`Binaire java introuvable après extraction. Contenu de ${installRoot} : ` +
(listing.length ? listing.join(', ') : '(vide)')
)
}
// Vérifie que le java extrait fonctionne et est bien >= 21.
const major = await javaMajorVersion(javaPath)
if (major === null || major < JAVA_MAJOR) {
throw new Error(`Java extrait inutilisable (version détectée : ${major ?? 'inconnue'}).`)
}
await rememberJava(javaPath, 'temurin')
emit.progress({ phase: 'java', message: 'Java prêt.', progress: 1 })
return javaPath
}
/** Mémorise le chemin du java retenu pour les prochains lancements. */
async function rememberJava(javaPath: string, source: 'system' | 'temurin'): Promise<void> {
await mkdir(paths.javaDir, { recursive: true })
await writeFile(MARKER(), JSON.stringify({ javaPath, source, min: JAVA_MAJOR }, null, 2))
}
+59
View File
@@ -0,0 +1,59 @@
import type { ChildProcess } from 'child_process'
import { launch } from '@xmcl/core'
import { paths } from './paths'
import { config } from '../shared/config'
import { emit } from './events'
import type { UserSettings } from '../shared/ipc'
import type { AuthResult } from './auth'
/**
* Lance Minecraft (version NeoForge installée) en process enfant.
*
* Séparation runtime / instance :
* - resourcePath = paths.gameRoot -> versions/, libraries/, assets/ (jamais touché par packwiz)
* - gamePath = paths.instanceDir -> mods/, config/, saves/ (synchronisé par packwiz)
*/
export async function launchGame(
versionId: string,
auth: AuthResult,
javaPath: string,
settings: UserSettings
): Promise<ChildProcess> {
emit.progress({ phase: 'launching', message: 'Démarrage de Minecraft…', progress: undefined })
const serverArg = parseServer(config.serverAddress)
const proc = await launch({
gamePath: paths.instanceDir,
resourcePath: paths.gameRoot,
javaPath,
version: versionId,
minMemory: Math.min(2048, settings.maxMemoryMb),
maxMemory: settings.maxMemoryMb,
gameProfile: { id: auth.profile.uuid, name: auth.profile.name },
accessToken: auth.accessToken,
// MC moderne attend user_type = "msa" (le typage @xmcl est plus restrictif).
userType: 'msa' as unknown as 'mojang',
extraJVMArgs: settings.extraJvmArgs,
...(serverArg ? { quickPlayMultiplayer: serverArg } : {})
})
// Streame les logs du jeu vers la console du renderer.
proc.stdout?.on('data', (b: Buffer) =>
emit.gameLog({ stream: 'stdout', line: b.toString() })
)
proc.stderr?.on('data', (b: Buffer) =>
emit.gameLog({ stream: 'stderr', line: b.toString() })
)
proc.on('spawn', () => emit.progress({ phase: 'running', message: 'En jeu.', progress: 1 }))
proc.on('close', (code) => emit.gameClosed(code))
return proc
}
/** "host:port" ou "host" -> chaîne quickPlayMultiplayer ; undefined si vide. */
function parseServer(addr?: string): string | undefined {
if (!addr || !addr.trim()) return undefined
return addr.trim()
}
+103
View File
@@ -0,0 +1,103 @@
import { spawn } from 'child_process'
import { parse as parseToml } from 'smol-toml'
import { paths } from './paths'
import { config } from '../shared/config'
import { fetchText } from './download'
import { emit } from './events'
/**
* Gestion du modpack côté launcher :
* - lecture du pack.toml packwiz distant (versions MC/NeoForge = source de vérité)
* - sync incrémentale des mods/configs via packwiz-installer-bootstrap.
*
* packwiz-installer garde un manifeste local (packwiz.json dans l'instance) et
* ne re-télécharge QUE les fichiers dont le hash a changé — c'est exactement le
* comportement "pas de re-download complet à chaque update" recherché.
*/
export interface PackMeta {
name: string
version: string
minecraft: string
neoforge: string
}
/** Télécharge et parse le pack.toml distant. */
export async function fetchPackMeta(): Promise<PackMeta> {
emit.progress({ phase: 'pack-meta', message: 'Lecture du modpack…', progress: undefined })
if (!config.packTomlUrl || config.packTomlUrl.includes('CHANGE_ME')) {
throw new Error(
'URL du pack.toml non configurée. Renseigne packTomlUrl dans src/shared/config.ts.'
)
}
const raw = await fetchText(config.packTomlUrl)
const data = parseToml(raw) as {
name?: string
version?: string
versions?: { minecraft?: string; neoforge?: string }
}
const minecraft = data.versions?.minecraft
const neoforge = data.versions?.neoforge
if (!minecraft || !neoforge) {
throw new Error(
'pack.toml invalide : [versions] doit contenir "minecraft" et "neoforge".'
)
}
return {
name: data.name ?? 'Modpack',
version: data.version ?? '0',
minecraft,
neoforge
}
}
/**
* Lance packwiz-installer-bootstrap pour synchroniser l'instance.
* `java` = chemin du binaire Java 21 géré (réutilisé pour faire tourner le jar).
*/
export async function syncModpack(javaPath: string): Promise<void> {
emit.progress({ phase: 'modpack', message: 'Synchronisation du modpack…', progress: undefined })
await new Promise<void>((resolve, reject) => {
const child = spawn(
javaPath,
['-jar', paths.packwizBootstrapJar, '-g', '-s', 'client', config.packTomlUrl],
{ cwd: paths.instanceDir }
)
// On garde les dernières lignes de sortie pour pouvoir afficher la VRAIE
// erreur de packwiz si le process échoue.
const recent: string[] = []
const onLine = (buf: Buffer): void => {
for (const line of buf.toString().split(/\r?\n/)) {
const text = line.trim()
if (!text) continue
recent.push(text)
if (recent.length > 25) recent.shift()
emit.progress({ phase: 'modpack', message: text, progress: undefined })
// Visible aussi dans la console du launcher.
emit.gameLog({ stream: 'stdout', line: `[packwiz] ${text}` })
}
}
child.stdout.on('data', onLine)
child.stderr.on('data', onLine)
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) return resolve()
const tail = recent.slice(-12).join('\n')
reject(
new Error(
`packwiz-installer a échoué (code ${code}).` +
(tail ? `\nDernières lignes :\n${tail}` : '')
)
)
})
})
emit.progress({ phase: 'modpack', message: 'Modpack à jour.', progress: 1 })
}
+57
View File
@@ -0,0 +1,57 @@
import { Agent, interceptors, type Dispatcher } from 'undici'
/**
* Dispatcher undici partagé pour TOUS les téléchargements d'install (@xmcl).
*
* Pourquoi : l'install de Minecraft 1.21 récupère ~3700 petits fichiers d'assets
* depuis resources.download.minecraft.net. Avec la concurrence par défaut, sur
* une connexion grand public, beaucoup de connexions dépassent le timeout de
* connexion d'undici (10 s) -> UND_ERR_CONNECT_TIMEOUT -> fichiers vides ->
* erreurs de checksum.
*
* On limite donc le nombre de connexions simultanées et on allonge les timeouts.
*
* On compose l'intercepteur `redirect` d'undici : c'est ce que fait le
* dispatcher par défaut de @xmcl. Il suit les redirections (l'installeur NeoForge
* sur maven.neoforged.net redirige vers un CDN) ET remplit `context.history`,
* que @xmcl/file-transfer lit (sans lui, `context` est undefined -> crash
* "Cannot use 'in' operator to search for 'history' in undefined").
*
* NB : on n'utilise PAS le RetryAgent d'undici. Il refait des requêtes Range qui
* entrent en conflit avec le téléchargement reprenable (par ranges) de
* @xmcl/file-transfer -> "content-range mismatch" + jars corrompus. Les reprises
* sont gérées au niveau au-dessus par withRetries(), qui relance l'install (les
* fichiers déjà valides sont ignorés).
*/
export const downloadDispatcher: Dispatcher = new Agent({
connections: 16, // sockets simultanés par origine (bride la concurrence réelle)
connect: { timeout: 60_000 }, // timeout de connexion (la cause du UND_ERR_CONNECT_TIMEOUT)
headersTimeout: 60_000,
bodyTimeout: 120_000
}).compose(interceptors.redirect({ maxRedirections: 5 }))
/** Concurrence de téléchargement des assets/libraries (volontairement modérée). */
export const DOWNLOAD_CONCURRENCY = 16
/**
* Réessaie une opération asynchrone. Pour les installs @xmcl, un nouvel appel
* reprend là où ça s'est arrêté : les fichiers déjà valides sont ignorés, seuls
* les manquants/invalides sont re-téléchargés. Donc quelques passes suffisent à
* absorber les pertes réseau sur les milliers de petits assets.
*/
export async function withRetries<T>(
fn: () => Promise<T>,
attempts: number,
onRetry?: (attempt: number, err: unknown) => void
): Promise<T> {
let lastErr: unknown
for (let i = 1; i <= attempts; i++) {
try {
return await fn()
} catch (err) {
lastErr = err
if (i < attempts) onRetry?.(i, err)
}
}
throw lastErr
}
+60
View File
@@ -0,0 +1,60 @@
import { app } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
/**
* Arborescence des données du launcher, rangée sous le userData d'Electron :
* Windows : %APPDATA%/OFLauncher
* Linux : ~/.config/OFLauncher
*
* On garde le runtime (MC/NeoForge/assets/libs/java) séparé de l'instance de
* jeu (mods/config/saves) pour que la sync packwiz ne touche jamais au runtime.
*/
class LauncherPaths {
/** Racine : userData d'Electron. */
get root(): string {
return app.getPath('userData')
}
/** Dossier "Minecraft" géré par @xmcl : versions/, libraries/, assets/. */
get gameRoot(): string {
return this.ensure(join(this.root, 'minecraft'))
}
/** Dossier d'instance du modpack : mods/, config/, saves/ (cible packwiz). */
get instanceDir(): string {
return this.ensure(join(this.root, 'instance'))
}
/** Runtimes Java téléchargés (un sous-dossier par composant Mojang). */
get javaDir(): string {
return this.ensure(join(this.root, 'java'))
}
/** Cache des tokens d'auth (prismarine-auth). */
get authCache(): string {
return this.ensure(join(this.root, 'auth-cache'))
}
/** Fichier de réglages utilisateur. */
get settingsFile(): string {
return join(this.root, 'settings.json')
}
/** jar packwiz-installer-bootstrap embarqué dans les resources. */
get packwizBootstrapJar(): string {
// En prod, electron-builder copie resources/ via extraResources.
// En dev, on lit directement le dossier resources/ du repo.
const base = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
return join(base, 'packwiz-installer-bootstrap.jar')
}
private ensure(dir: string): string {
mkdirSync(dir, { recursive: true })
return dir
}
}
export const paths = new LauncherPaths()
+50
View File
@@ -0,0 +1,50 @@
import type { ChildProcess } from 'child_process'
import { getCurrent } from './auth'
import { fetchPackMeta } from './modpack'
import { ensureJava } from './java'
import { installMinecraft, installNeoForge } from './install'
import { syncModpack } from './modpack'
import { launchGame } from './launch'
import { getSettings } from './settings'
import { emit } from './events'
/** Process de jeu courant (un seul à la fois). */
let gameProcess: ChildProcess | null = null
/**
* Séquence complète "Jouer" :
* auth (déjà fait) -> pack.toml -> Java 21 -> Minecraft -> NeoForge ->
* sync modpack (delta) -> lancement.
*
* Chaque étape émet sa progression vers le renderer. Les étapes d'install sont
* idempotentes, donc à partir du 2e lancement seules les nouveautés du modpack
* sont téléchargées.
*/
export async function play(): Promise<void> {
if (gameProcess) {
throw new Error('Le jeu est déjà en cours.')
}
const auth = getCurrent()
if (!auth) {
throw new Error('Non connecté. Connecte-toi avec ton compte Microsoft dabord.')
}
try {
const meta = await fetchPackMeta()
const javaPath = await ensureJava()
await installMinecraft(meta.minecraft)
const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath)
await syncModpack(javaPath)
const settings = await getSettings()
const proc = await launchGame(versionId, auth, javaPath, settings)
gameProcess = proc
proc.on('close', () => {
gameProcess = null
})
} catch (e) {
emit.progress({ phase: 'error', message: (e as Error).message, progress: undefined })
throw e
}
}
+23
View File
@@ -0,0 +1,23 @@
import { readFile, writeFile } from 'fs/promises'
import { paths } from './paths'
import { DEFAULT_SETTINGS, type UserSettings } from '../shared/ipc'
/** Lecture des réglages persistés (fusionnés avec les valeurs par défaut). */
export async function getSettings(): Promise<UserSettings> {
try {
const raw = await readFile(paths.settingsFile, 'utf-8')
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<UserSettings>) }
} catch {
return { ...DEFAULT_SETTINGS }
}
}
/** Écriture des réglages (validés/clampés). */
export async function setSettings(next: UserSettings): Promise<UserSettings> {
const clean: UserSettings = {
maxMemoryMb: Math.max(2048, Math.min(32768, Math.round(next.maxMemoryMb || DEFAULT_SETTINGS.maxMemoryMb))),
extraJvmArgs: Array.isArray(next.extraJvmArgs) ? next.extraJvmArgs : []
}
await writeFile(paths.settingsFile, JSON.stringify(clean, null, 2))
return clean
}
+59
View File
@@ -0,0 +1,59 @@
import { app } from 'electron'
import electronUpdater, { type UpdateInfo, type ProgressInfo } from 'electron-updater'
import { emit } from './events'
/**
* Auto-update du launcher via electron-updater (provider "generic" pointant sur
* une release Gitea à tag fixe, voir electron-builder.yml).
*
* UX : check au démarrage -> téléchargement en fond -> on relaie l'état vers le
* renderer (bandeau). L'install effective se fait quand l'utilisateur clique
* "Redémarrer pour installer" (quitAndInstallUpdate), ou à la fermeture.
*/
// electron-updater est CommonJS : on récupère autoUpdater via le default export.
const { autoUpdater } = electronUpdater
/** Re-check périodique (6 h) tant que le launcher reste ouvert. */
const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000
export function initUpdater(): void {
// L'updater ne fonctionne que sur une app packagée (sauf dev-app-update.yml).
if (!app.isPackaged) {
console.info('[updater] dev non packagé : auto-update désactivé.')
return
}
// On gère l'install manuellement (bouton "Redémarrer"), mais on installe
// quand même à la fermeture si la maj a été téléchargée.
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => {
emit.updateStatus({ state: 'checking' })
})
autoUpdater.on('update-available', (info: UpdateInfo) => {
emit.updateStatus({ state: 'available', version: info.version })
})
autoUpdater.on('update-not-available', () => {
emit.updateStatus({ state: 'none' })
})
autoUpdater.on('download-progress', (p: ProgressInfo) => {
emit.updateStatus({ state: 'downloading', progress: p.percent / 100 })
})
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
emit.updateStatus({ state: 'downloaded', version: info.version })
})
autoUpdater.on('error', (err: Error) => {
console.error('[updater]', err)
emit.updateStatus({ state: 'error', message: err.message })
})
void autoUpdater.checkForUpdates()
setInterval(() => void autoUpdater.checkForUpdates(), RECHECK_INTERVAL_MS)
}
/** Quitte et installe la mise à jour téléchargée (appelé par le bouton UI). */
export function quitAndInstallUpdate(): void {
autoUpdater.quitAndInstall()
}