first commit
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 d’abord.')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user