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()
}
+9
View File
@@ -0,0 +1,9 @@
import type { LauncherApi } from './index'
declare global {
interface Window {
api: LauncherApi
}
}
export {}
+65
View File
@@ -0,0 +1,65 @@
import { contextBridge, ipcRenderer } from 'electron'
import {
IPC,
IPC_EVENT,
type PlayerProfile,
type ProgressEvent,
type GameLogLine,
type UserSettings,
type DeviceCodeInfo,
type UpdateStatus
} from '../shared/ipc'
/** API typée exposée au renderer via window.api. */
const api = {
// --- Auth ---
login: (): Promise<PlayerProfile> => ipcRenderer.invoke(IPC.authLogin),
logout: (): Promise<void> => ipcRenderer.invoke(IPC.authLogout),
getProfile: (): Promise<PlayerProfile | null> => ipcRenderer.invoke(IPC.authGetProfile),
// --- Jouer (install + sync + launch) ---
play: (): Promise<void> => ipcRenderer.invoke(IPC.play),
// --- Réglages ---
getSettings: (): Promise<UserSettings> => ipcRenderer.invoke(IPC.settingsGet),
setSettings: (s: UserSettings): Promise<UserSettings> => ipcRenderer.invoke(IPC.settingsSet, s),
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.appVersion),
/** Ouvre le dossier d'instance (mods/config/saves) dans l'explorateur. */
openInstanceDir: (): Promise<string> => ipcRenderer.invoke(IPC.openInstanceDir),
/** Quitte et installe la mise à jour téléchargée. */
installUpdate: (): Promise<void> => ipcRenderer.invoke(IPC.updateInstall),
// --- Abonnements aux événements (retournent une fonction de désabonnement) ---
onProgress: (cb: (e: ProgressEvent) => void): (() => void) => {
const handler = (_: unknown, e: ProgressEvent): void => cb(e)
ipcRenderer.on(IPC_EVENT.progress, handler)
return () => ipcRenderer.removeListener(IPC_EVENT.progress, handler)
},
onGameLog: (cb: (l: GameLogLine) => void): (() => void) => {
const handler = (_: unknown, l: GameLogLine): void => cb(l)
ipcRenderer.on(IPC_EVENT.gameLog, handler)
return () => ipcRenderer.removeListener(IPC_EVENT.gameLog, handler)
},
onGameClosed: (cb: (code: number | null) => void): (() => void) => {
const handler = (_: unknown, code: number | null): void => cb(code)
ipcRenderer.on(IPC_EVENT.gameClosed, handler)
return () => ipcRenderer.removeListener(IPC_EVENT.gameClosed, handler)
},
onAuthCode: (cb: (info: DeviceCodeInfo) => void): (() => void) => {
const handler = (_: unknown, info: DeviceCodeInfo): void => cb(info)
ipcRenderer.on(IPC_EVENT.authCode, handler)
return () => ipcRenderer.removeListener(IPC_EVENT.authCode, handler)
},
onUpdateStatus: (cb: (s: UpdateStatus) => void): (() => void) => {
const handler = (_: unknown, s: UpdateStatus): void => cb(s)
ipcRenderer.on(IPC_EVENT.updateStatus, handler)
return () => ipcRenderer.removeListener(IPC_EVENT.updateStatus, handler)
}
}
export type LauncherApi = typeof api
contextBridge.exposeInMainWorld('api', api)
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'"
/>
<title>OFLauncher</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useRef, useState } from 'react'
import type {
PlayerProfile,
ProgressEvent,
GameLogLine,
DeviceCodeInfo,
UpdateStatus
} from '../../shared/ipc'
type Status = 'loading' | 'logged-out' | 'logged-in' | 'working' | 'running'
export default function App(): JSX.Element {
const [status, setStatus] = useState<Status>('loading')
const [profile, setProfile] = useState<PlayerProfile | null>(null)
const [progress, setProgress] = useState<ProgressEvent | null>(null)
const [logs, setLogs] = useState<GameLogLine[]>([])
const [error, setError] = useState<string | null>(null)
const [authCode, setAuthCode] = useState<DeviceCodeInfo | null>(null)
const [maxMemoryMb, setMaxMemoryMb] = useState(8192)
const [appVersion, setAppVersion] = useState('')
const [update, setUpdate] = useState<UpdateStatus | null>(null)
const consoleRef = useRef<HTMLDivElement>(null)
// Restaure la session + réglages au démarrage.
useEffect(() => {
;(async () => {
const [p, s, v] = await Promise.all([
window.api.getProfile(),
window.api.getSettings(),
window.api.getAppVersion()
])
setMaxMemoryMb(s.maxMemoryMb)
setAppVersion(v)
setProfile(p)
setStatus(p ? 'logged-in' : 'logged-out')
})()
}, [])
// Abonnements aux événements main -> renderer.
useEffect(() => {
const offProgress = window.api.onProgress((e) => {
setProgress(e)
if (e.phase === 'running') setStatus('running')
if (e.phase === 'error') {
setError(e.message)
setStatus(profile ? 'logged-in' : 'logged-out')
}
})
const offLog = window.api.onGameLog((l) =>
setLogs((prev) => [...prev.slice(-800), l])
)
const offClosed = window.api.onGameClosed((code) => {
setStatus('logged-in')
setProgress(null)
setLogs((prev) => [
...prev,
{ stream: 'stdout', line: `\n— Jeu fermé (code ${code ?? '?'}) —` }
])
})
const offAuthCode = window.api.onAuthCode((info) => setAuthCode(info))
const offUpdate = window.api.onUpdateStatus((s) => setUpdate(s))
return () => {
offProgress()
offLog()
offClosed()
offAuthCode()
offUpdate()
}
}, [profile])
// Auto-scroll de la console.
useEffect(() => {
consoleRef.current?.scrollTo(0, consoleRef.current.scrollHeight)
}, [logs])
async function handleLogin(): Promise<void> {
setError(null)
setAuthCode(null)
try {
const p = await window.api.login()
setProfile(p)
setStatus('logged-in')
} catch (e) {
setError(`Échec de connexion : ${(e as Error).message}`)
} finally {
setAuthCode(null)
}
}
async function handleLogout(): Promise<void> {
await window.api.logout()
setProfile(null)
setStatus('logged-out')
}
function handleOpenInstance(): void {
void window.api.openInstanceDir()
}
async function handlePlay(): Promise<void> {
setError(null)
setLogs([])
setStatus('working')
await window.api.setSettings({ maxMemoryMb, extraJvmArgs: [] })
try {
await window.api.play()
} catch (e) {
setError(`Échec du lancement : ${(e as Error).message}`)
setStatus('logged-in')
}
}
if (status === 'loading') {
return (
<div className="app">
<div className="center muted">Chargement</div>
</div>
)
}
if (status === 'logged-out') {
return (
<div className="app">
<div className="center">
<h1>OFLauncher</h1>
<p className="muted">Connecte-toi avec ton compte Microsoft pour jouer.</p>
<button className="play" onClick={handleLogin} disabled={!!authCode}>
{authCode ? 'En attente…' : 'Se connecter'}
</button>
{authCode && (
<div className="authcode">
<p>
Va sur{' '}
<a href={authCode.verificationUri} target="_blank" rel="noreferrer">
{authCode.verificationUri}
</a>{' '}
et entre le code :
</p>
<div className="code">{authCode.userCode}</div>
<p className="muted" style={{ fontSize: 12 }}>
La page sest ouverte dans ton navigateur. Reviens ici une fois connecté.
</p>
</div>
)}
{error && <div className="error">{error}</div>}
<div className="muted" style={{ fontSize: 12 }}>
v{appVersion}
</div>
</div>
</div>
)
}
const busy = status === 'working' || status === 'running'
const pct = progress?.progress
const indeterminate = busy && (pct === undefined || pct === null)
const updateBanner =
update?.state === 'downloading' ? (
<div className="updatebar">
<span>
Téléchargement de la mise à jour
{update.version ? ` ${update.version}` : ''}{' '}
{update.progress != null ? `${Math.round(update.progress * 100)} %` : ''}
</span>
</div>
) : update?.state === 'downloaded' ? (
<div className="updatebar ready">
<span>Mise à jour {update.version ?? ''} prête.</span>
<button className="linkbtn" onClick={() => void window.api.installUpdate()}>
Redémarrer pour installer
</button>
</div>
) : null
return (
<div className="app">
<div className="topbar">
<div className="brand">
OFLauncher
<span className="pack">All The Mods 10 · 1.21.1</span>
</div>
<div className="profile">
<img src={`https://mc-heads.net/avatar/${profile?.uuid}`} alt="" />
<span>{profile?.name}</span>
<button className="linkbtn" onClick={handleLogout} disabled={busy}>
Déconnexion
</button>
</div>
</div>
{updateBanner}
<div className="main">
<div className="console" ref={consoleRef}>
{logs.map((l, i) => (
<div key={i} className={l.stream === 'stderr' ? 'stderr' : undefined}>
{l.line}
</div>
))}
</div>
<div className="footer">
<div className="progress">
<div className="label">{busy ? progress?.message ?? 'Préparation…' : ''}</div>
<div className={`bar${indeterminate ? ' indeterminate' : ''}`}>
<div style={{ width: indeterminate ? undefined : `${Math.round((pct ?? 0) * 100)}%` }} />
</div>
</div>
<div className="settings">
RAM
<input
type="number"
min={2048}
max={32768}
step={1024}
value={maxMemoryMb}
disabled={busy}
onChange={(e) => setMaxMemoryMb(Number(e.target.value))}
/>
Mo
<button className="linkbtn" onClick={handleOpenInstance}>
Ouvrir le dossier de l'instance
</button>
</div>
<button className="play" onClick={handlePlay} disabled={busy}>
{status === 'running' ? 'En jeu' : busy ? 'Patiente' : 'Jouer'}
</button>
</div>
{error && <div className="error">{error}</div>}
</div>
</div>
)
}
+257
View File
@@ -0,0 +1,257 @@
:root {
--bg: #0e1116;
--bg-soft: #161b22;
--border: #283040;
--text: #e6edf3;
--text-dim: #8b97a7;
--accent: #3fb950;
--accent-hover: #46c95a;
--danger: #f85149;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
#root {
height: 100vh;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-soft);
}
.brand {
font-weight: 700;
letter-spacing: 0.5px;
}
.brand .pack {
color: var(--text-dim);
font-weight: 400;
margin-left: 8px;
font-size: 0.85em;
}
.profile {
display: flex;
align-items: center;
gap: 10px;
}
.updatebar {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 20px;
font-size: 0.85em;
background: var(--bg-soft);
border-bottom: 1px solid var(--border);
color: var(--text-dim);
}
.updatebar.ready {
color: var(--text);
border-bottom-color: var(--accent);
}
.profile img {
width: 28px;
height: 28px;
border-radius: 4px;
image-rendering: pixelated;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px;
gap: 16px;
min-height: 0;
}
.console {
flex: 1;
background: #07090d;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.45;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.console .stderr {
color: #f0883e;
}
.console:empty::before {
content: 'La console du jeu saffichera ici…';
color: var(--text-dim);
}
.footer {
display: flex;
align-items: center;
gap: 16px;
}
.progress {
flex: 1;
}
.progress .label {
font-size: 13px;
color: var(--text-dim);
margin-bottom: 6px;
min-height: 18px;
}
.bar {
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.bar > div {
height: 100%;
background: var(--accent);
transition: width 0.2s ease;
}
.bar.indeterminate > div {
width: 35% !important;
animation: slide 1.1s ease-in-out infinite;
}
@keyframes slide {
0% {
margin-left: -35%;
}
100% {
margin-left: 100%;
}
}
button.play {
background: var(--accent);
color: #04250c;
border: none;
padding: 14px 34px;
font-size: 16px;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
min-width: 160px;
}
button.play:hover:not(:disabled) {
background: var(--accent-hover);
}
button.play:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
}
.center h1 {
margin: 0;
}
.muted {
color: var(--text-dim);
}
.linkbtn {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.linkbtn:hover {
color: var(--text);
}
.settings {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-dim);
}
.settings input {
width: 80px;
background: var(--bg-soft);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 4px 6px;
}
.error {
color: var(--danger);
font-size: 13px;
}
.authcode {
text-align: center;
max-width: 420px;
}
.authcode a {
color: var(--accent);
}
.authcode .code {
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 28px;
letter-spacing: 4px;
font-weight: 700;
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 18px;
margin: 10px auto;
display: inline-block;
user-select: all;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+39
View File
@@ -0,0 +1,39 @@
/**
* Configuration statique du launcher.
*
* Ces valeurs sont les seules choses que TOI (l'admin) dois ajuster pour ton
* serveur. Tout le reste (versions MC/NeoForge, liste des mods) vient du
* `pack.toml` packwiz hébergé, donc tu n'as PAS à toucher au code pour mettre
* à jour le modpack.
*/
export interface LauncherConfig {
/** Nom affiché du launcher. */
appName: string
/**
* URL publique de ton `pack.toml` packwiz (la source de vérité du modpack).
* Exemple GitHub Pages : https://<user>.github.io/OFModpack/pack.toml
* Exemple GitHub raw : https://raw.githubusercontent.com/<user>/OFModpack/main/pack.toml
*/
packTomlUrl: string
/**
* Client ID de TON application Azure AD (compte personnel / "consumers").
* Scope requis : XboxLive.signin. Voir README pour l'enregistrement.
* Tant que l'app Azure n'est pas approuvée pour l'API Minecraft, utilise un
* client ID de test connu (ex. celui du launcher officiel) en dev uniquement.
*/
azureClientId: string
/** Adresse du serveur à pré-remplir dans le bouton "Jouer" (optionnel). */
serverAddress?: string
}
export const config: LauncherConfig = {
appName: 'OFLauncher',
// TODO: remplace par l'URL de ton pack.toml une fois l'hébergement choisi.
packTomlUrl: 'https://gitea.ldpt.fr/zertus/OFModpack/raw/branch/main/pack.toml',
// TODO: remplace par le client ID de ton app Azure.
azureClientId: 'CHANGE_ME',
serverAddress: 'mc.ldpt.fr'
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Contrats partagés entre le process main et le renderer.
* Tout passe par le bridge `window.api` exposé dans le preload.
*/
/** Profil du joueur après login Microsoft. */
export interface PlayerProfile {
uuid: string
name: string
/** URL du skin/tête si disponible. */
skinUrl?: string
}
/** Phase courante de la séquence "Jouer". */
export type LaunchPhase =
| 'idle'
| 'auth'
| 'pack-meta'
| 'java'
| 'minecraft'
| 'neoforge'
| 'modpack'
| 'launching'
| 'running'
| 'done'
| 'error'
/** Événement de progression envoyé main -> renderer pendant "Jouer". */
export interface ProgressEvent {
phase: LaunchPhase
/** Libellé lisible à afficher. */
message: string
/** 0..1 si déterminable, sinon undefined (barre indéterminée). */
progress?: number
}
/** Ligne de log du jeu (stdout/stderr) streamée vers la console du renderer. */
export interface GameLogLine {
stream: 'stdout' | 'stderr'
line: string
}
/** Infos du flow "device code" Microsoft à présenter à l'utilisateur. */
export interface DeviceCodeInfo {
userCode: string
verificationUri: string
message: string
}
/** État de la mise à jour automatique du launcher (electron-updater). */
export type UpdateState =
| 'checking'
| 'available'
| 'downloading'
| 'downloaded'
| 'none'
| 'error'
/** Statut d'update envoyé main -> renderer pour piloter le bandeau de maj. */
export interface UpdateStatus {
state: UpdateState
/** Version distante (ex. "0.2.0") quand connue. */
version?: string
/** 0..1 pendant le téléchargement. */
progress?: number
/** Message d'erreur éventuel (state === 'error'). */
message?: string
}
/** Réglages utilisateur persistés localement. */
export interface UserSettings {
/** RAM max allouée à la JVM, en Mo. */
maxMemoryMb: number
/** Args JVM additionnels (avancé). */
extraJvmArgs: string[]
}
export const DEFAULT_SETTINGS: UserSettings = {
maxMemoryMb: 8192,
extraJvmArgs: []
}
/** Noms de canaux IPC (invoke renderer -> main). */
export const IPC = {
authLogin: 'auth:login',
authLogout: 'auth:logout',
authGetProfile: 'auth:getProfile',
play: 'play:start',
settingsGet: 'settings:get',
settingsSet: 'settings:set',
appVersion: 'app:version',
openInstanceDir: 'instance:open',
updateInstall: 'update:install'
} as const
/** Noms d'événements (send main -> renderer). */
export const IPC_EVENT = {
progress: 'evt:progress',
gameLog: 'evt:gameLog',
gameClosed: 'evt:gameClosed',
authCode: 'evt:authCode',
updateStatus: 'evt:updateStatus'
} as const