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()
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { LauncherApi } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: LauncherApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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 s’est 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>
|
||||
)
|
||||
}
|
||||
@@ -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 s’affichera 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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user