11 Commits

Author SHA1 Message Date
gitea-actions 0299b53592 ci: release v3.1.0 2026-06-19 09:25:42 +00:00
lucasdpt 3a9a555f19 fix: fix release 2026-06-19 11:24:26 +02:00
lucasdpt 81f66e25eb fix: fix auth problem 2026-06-19 11:04:12 +02:00
gitea-actions c0d7d7ce4f ci: release v3.0.1 2026-06-17 20:15:31 +00:00
lucasdpt a7a4bce19f fix: fix problem when launcher is closed 2026-06-17 22:14:09 +02:00
gitea-actions 48f99f62c4 ci: release v3.0.0 2026-06-17 18:44:16 +00:00
lucasdpt 161ea50234 feat: add way to select game dir 2026-06-17 20:43:27 +02:00
lucasdpt b8204c80bd feat: add java update for linux 2026-06-17 20:30:35 +02:00
gitea-actions 7d57ea0ca9 ci: release v2.0.0 2026-06-14 12:24:21 +00:00
lucasdpt 4756420f8d feat: add some funcs 2026-06-14 14:23:37 +02:00
lucasdpt 48fa508540 feat: logo/icône custom (monogramme OF voxel)
Badge sombre + "OF" en blocs biseautés (thème vert). electron-builder
dérive l'ICO Windows et les PNG Linux depuis build/icon.png.
Généré par scripts/gen-logo.mjs (SVG sans dépendance ; --png via resvg).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:55:21 +02:00
23 changed files with 870 additions and 112 deletions
-1
View File
@@ -55,4 +55,3 @@ jobs:
GITEA_URL: ${{ github.server_url }} GITEA_URL: ${{ github.server_url }}
GITEA_OWNER: ${{ github.repository_owner }} GITEA_OWNER: ${{ github.repository_owner }}
GITEA_REPO: ${{ github.event.repository.name }} GITEA_REPO: ${{ github.event.repository.name }}
GITEA_TAG: latest
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+12
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.6 KiB

+3 -1
View File
@@ -31,7 +31,9 @@ linux:
- deb - deb
maintainer: oflauncher maintainer: oflauncher
category: Game category: Game
artifactName: ${productName}-${version}.${ext} # Pas de version dans le nom : OFLauncher.AppImage est toujours écrasé sur
# place par l'auto-update, les raccourcis bureau restent valides après maj.
artifactName: ${productName}.${ext}
# Publication des binaires du launcher (auto-update). # Publication des binaires du launcher (auto-update).
# Provider "generic" : electron-updater lit latest.yml à cette URL fixe. # Provider "generic" : electron-updater lit latest.yml à cette URL fixe.
# Les artefacts (latest.yml + installeur + .blockmap) sont uploadés sur une # Les artefacts (latest.yml + installeur + .blockmap) sont uploadés sur une
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oflauncher", "name": "oflauncher",
"version": "1.1.0", "version": "3.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oflauncher", "name": "oflauncher",
"version": "1.1.0", "version": "3.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oflauncher", "name": "oflauncher",
"version": "1.1.0", "version": "3.1.0",
"description": "Launcher Minecraft custom pour le modpack ATM10 (1.21.1 / NeoForge)", "description": "Launcher Minecraft custom pour le modpack ATM10 (1.21.1 / NeoForge)",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "OFLauncher", "author": "OFLauncher",
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* Génère le logo/icône du launcher : monogramme "OF" en style voxel (blocs
* biseautés façon Minecraft) sur un badge sombre arrondi, accent vert du thème.
*
* node scripts/gen-logo.mjs -> build/icon.svg
* node scripts/gen-logo.mjs --png -> + build/icon.png (1024px) via @resvg/resvg-js
*
* electron-builder dérive ensuite l'ICO Windows et les PNG Linux depuis
* build/icon.png (directories.buildResources = build).
*/
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const SIZE = 512
const OUT = join(process.cwd(), 'build')
// Palette (cohérente avec src/renderer/src/index.css).
const C = {
bgTop: '#1b2330',
bgBottom: '#0d1014',
border: '#2b3444',
base: '#3fb950', // vert accent
light: '#74e08a', // arête haut/gauche (lumière)
dark: '#218a3b' // arête bas/droite (ombre)
}
// Matrices pixel des lettres (1 = bloc plein).
const O = ['01110', '10001', '10001', '10001', '10001', '10001', '01110']
const F = ['1111', '1000', '1000', '1110', '1000', '1000', '1000']
const ROWS = 7
const GAP_COLS = 1
const COLS = O[0].length + GAP_COLS + F[0].length // 5 + 1 + 4 = 10
const cell = 40 // pas de la grille
const blockGap = 6 // espace entre blocs (effet grille pixel)
const bs = cell - blockGap // taille d'un bloc
const bevel = Math.round(bs * 0.18) // épaisseur du biseau
const gridW = COLS * cell
const gridH = ROWS * cell
const x0 = (SIZE - gridW) / 2
const y0 = (SIZE - gridH) / 2
/** Un bloc voxel : base + arêtes claires (haut/gauche) et sombres (bas/droite). */
function block(col, row) {
const x = x0 + col * cell + blockGap / 2
const y = y0 + row * cell + blockGap / 2
const t = bevel
return [
`<rect x="${x}" y="${y}" width="${bs}" height="${bs}" fill="${C.base}"/>`,
`<rect x="${x}" y="${y}" width="${bs}" height="${t}" fill="${C.light}"/>`,
`<rect x="${x}" y="${y}" width="${t}" height="${bs}" fill="${C.light}"/>`,
`<rect x="${x}" y="${y + bs - t}" width="${bs}" height="${t}" fill="${C.dark}"/>`,
`<rect x="${x + bs - t}" y="${y}" width="${t}" height="${bs}" fill="${C.dark}"/>`
].join('')
}
/** Rend une matrice à un décalage de colonnes donné. */
function letter(matrix, colOffset) {
const out = []
matrix.forEach((line, r) => {
;[...line].forEach((px, c) => {
if (px === '1') out.push(block(colOffset + c, r))
})
})
return out.join('')
}
const blocks = letter(O, 0) + letter(F, O[0].length + GAP_COLS)
const radius = 112
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${C.bgTop}"/>
<stop offset="1" stop-color="${C.bgBottom}"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="${SIZE - 12}" height="${SIZE - 12}" rx="${radius}" ry="${radius}" fill="url(#bg)" stroke="${C.border}" stroke-width="6"/>
<g shape-rendering="crispEdges">
${blocks}
</g>
</svg>
`
async function main() {
await writeFile(join(OUT, 'icon.svg'), svg)
console.log('✓ build/icon.svg')
if (process.argv.includes('--png')) {
const { Resvg } = await import('@resvg/resvg-js')
const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1024 } })
.render()
.asPng()
await writeFile(join(OUT, 'icon.png'), png)
console.log('✓ build/icon.png (1024px)')
}
}
main().catch((e) => {
console.error(e.message)
process.exit(1)
})
+68 -34
View File
@@ -1,18 +1,18 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Publie les artefacts d'auto-update du launcher sur une release Gitea à tag * Publie les artefacts du launcher sur deux releases Gitea :
* fixe ("latest"). electron-updater (provider generic) lit ensuite latest.yml
* à l'URL configurée dans electron-builder.yml.
* *
* Pré-requis : * 1. Release à tag fixe "latest" — electron-updater (provider generic) lit
* - `npm run build:win` a produit dist/ (latest.yml + installeur + .blockmap) * latest.yml à cette URL pour proposer les mises à jour automatiques.
* - variable d'env GITEA_TOKEN (scope write:repository)
* *
* Config par variables d'env (avec valeurs par défaut) : * 2. Release versionnée "vX.Y.Z" — archive permanente consultable dans l'UI
* Gitea, utile pour le suivi des versions et les téléchargements manuels.
*
* Config par variables d'env :
* GITEA_URL base de l'instance (def. https://gitea.ldpt.fr) * GITEA_URL base de l'instance (def. https://gitea.ldpt.fr)
* GITEA_OWNER propriétaire du repo (def. zertus) * GITEA_OWNER propriétaire du repo (def. zertus)
* GITEA_REPO nom du repo launcher (def. OFLauncher) * GITEA_REPO nom du repo launcher (def. OFLauncher)
* GITEA_TAG tag fixe de la release (def. latest) * GITEA_TOKEN token write:repository (obligatoire)
*/ */
import { readdir, readFile } from 'node:fs/promises' import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
@@ -20,7 +20,6 @@ import { join } from 'node:path'
const BASE = process.env.GITEA_URL ?? 'https://gitea.ldpt.fr' const BASE = process.env.GITEA_URL ?? 'https://gitea.ldpt.fr'
const OWNER = process.env.GITEA_OWNER ?? 'zertus' const OWNER = process.env.GITEA_OWNER ?? 'zertus'
const REPO = process.env.GITEA_REPO ?? 'OFLauncher' const REPO = process.env.GITEA_REPO ?? 'OFLauncher'
const TAG = process.env.GITEA_TAG ?? 'latest'
const TOKEN = process.env.GITEA_TOKEN const TOKEN = process.env.GITEA_TOKEN
const DIST = join(process.cwd(), 'dist') const DIST = join(process.cwd(), 'dist')
@@ -31,12 +30,16 @@ if (!TOKEN) {
process.exit(1) process.exit(1)
} }
/** Lit la version courante depuis package.json. */
async function readVersion() {
const pkg = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8'))
return pkg.version // ex. "3.1.0"
}
/** /**
* Fichiers de dist/ à publier pour l'auto-update. * Fichiers de dist/ à publier.
* - Windows : latest.yml + l'installeur NSIS (+ .blockmap) * - Windows : latest.yml + installeur NSIS (+ .blockmap)
* - Linux : latest-linux.yml + l'AppImage (+ .blockmap) ; le .deb est publié * - Linux : latest-linux.yml + AppImage (+ .blockmap) + .deb
* pour téléchargement manuel (electron-updater ne l'utilise pas).
* electron-updater choisit le bon latest*.yml selon la plateforme.
*/ */
function isUpdateArtifact(name) { function isUpdateArtifact(name) {
return ( return (
@@ -61,65 +64,96 @@ async function api(path, init = {}) {
}) })
if (!res.ok) { if (!res.ok) {
const body = await res.text().catch(() => '') const body = await res.text().catch(() => '')
throw new Error(`Gitea ${init.method ?? 'GET'} ${path} -> ${res.status} ${body}`) throw new Error(`Gitea ${init.method ?? 'GET'} ${path} ${res.status} ${body}`)
} }
return res.status === 204 ? null : res.json() return res.status === 204 ? null : res.json()
} }
/** Récupère la release au tag fixe, ou la crée si absente. */ /** Récupère la release par tag, ou la crée avec les options données. */
async function getOrCreateRelease() { async function getOrCreateRelease(tag, createBody) {
const res = await fetch(`${API}/repos/${OWNER}/${REPO}/releases/tags/${TAG}`, { const res = await fetch(`${API}/repos/${OWNER}/${REPO}/releases/tags/${tag}`, {
headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json' } headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json' }
}) })
if (res.ok) return res.json() if (res.ok) return res.json()
if (res.status !== 404) { if (res.status !== 404) {
throw new Error(`Gitea GET release -> ${res.status} ${await res.text()}`) throw new Error(`Gitea GET release/${tag} ${res.status} ${await res.text()}`)
} }
console.log(`Création de la release "${TAG}"…`) console.log(`Création de la release "${tag}"…`)
return api(`/repos/${OWNER}/${REPO}/releases`, { return api(`/repos/${OWNER}/${REPO}/releases`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(createBody)
tag_name: TAG,
name: 'Dernière version',
body: 'Artefacts dauto-update du launcher (écrasés à chaque publication).'
})
}) })
} }
async function deleteExistingAssets(releaseId) { async function deleteExistingAssets(releaseId) {
const assets = await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets`) const assets = await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets`)
for (const a of assets) { for (const a of assets) {
console.log(`Suppression de l'ancien asset ${a.name}`) console.log(` Suppression de l'ancien asset "${a.name}"`)
await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets/${a.id}`, { await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets/${a.id}`, {
method: 'DELETE' method: 'DELETE'
}) })
} }
} }
async function uploadAsset(releaseId, name) { async function uploadAssets(releaseId, files) {
for (const name of files) {
const buf = await readFile(join(DIST, name)) const buf = await readFile(join(DIST, name))
const form = new FormData() const form = new FormData()
form.append('attachment', new Blob([buf]), name) form.append('attachment', new Blob([buf]), name)
console.log(`Upload de ${name} (${(buf.length / 1e6).toFixed(1)} Mo)…`) console.log(` Upload "${name}" (${(buf.length / 1e6).toFixed(1)} Mo)…`)
await api( await api(
`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`, `/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
{ method: 'POST', body: form } { method: 'POST', body: form }
) )
} }
}
async function publishRelease(tag, createBody, files) {
console.log(`\n── Release "${tag}" ──`)
const release = await getOrCreateRelease(tag, createBody)
await deleteExistingAssets(release.id)
await uploadAssets(release.id, files)
console.log(` ✓ Release "${tag}" publiée.`)
}
async function main() { async function main() {
const version = await readVersion()
const versionTag = `v${version}`
const files = (await readdir(DIST)).filter(isUpdateArtifact) const files = (await readdir(DIST)).filter(isUpdateArtifact)
if (!files.some((f) => f === 'latest.yml')) { if (!files.some((f) => f === 'latest.yml')) {
throw new Error('dist/latest.yml introuvable — lance dabord `npm run build:win`.') throw new Error('dist/latest.yml introuvable — lance d'abord `npm run build:win`.')
} }
console.log(`Publication sur ${OWNER}/${REPO} (tag ${TAG}) : ${files.join(', ')}`) console.log(`Version : ${versionTag}`)
console.log(`Artefacts : ${files.join(', ')}`)
console.log(`Dépôt : ${OWNER}/${REPO}`)
const release = await getOrCreateRelease() // 1. Release fixe "latest" — point d'ancrage de l'auto-update.
await deleteExistingAssets(release.id) await publishRelease(
for (const name of files) await uploadAsset(release.id, name) 'latest',
{
tag_name: 'latest',
name: 'Auto-update (dernière version)',
body: 'Artefacts d'auto-update écrasés à chaque publication. Version courante : ' + versionTag,
prerelease: false
},
files
)
console.log('✓ Publication terminée.') // 2. Release versionnée — archive permanente par version.
await publishRelease(
versionTag,
{
tag_name: versionTag,
name: `OFLauncher ${versionTag}`,
body: '',
prerelease: false
},
files
)
console.log('\n✓ Publication terminée.')
} }
main().catch((e) => { main().catch((e) => {
+9 -2
View File
@@ -6,6 +6,7 @@ import {
type DeviceCodeInfo, type DeviceCodeInfo,
type UpdateStatus type UpdateStatus
} from '../shared/ipc' } from '../shared/ipc'
import * as logger from './logger'
/** Fenêtre principale, définie au démarrage (src/main/index.ts). */ /** Fenêtre principale, définie au démarrage (src/main/index.ts). */
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
@@ -21,8 +22,14 @@ function send(channel: string, payload: unknown): void {
} }
export const emit = { export const emit = {
progress: (e: ProgressEvent): void => send(IPC_EVENT.progress, e), progress: (e: ProgressEvent): void => {
gameLog: (l: GameLogLine): void => send(IPC_EVENT.gameLog, l), if (e.phase === 'error') logger.write(`[ERREUR] ${e.message}`)
send(IPC_EVENT.progress, e)
},
gameLog: (l: GameLogLine): void => {
logger.write(l.line)
send(IPC_EVENT.gameLog, l)
},
gameClosed: (code: number | null): void => send(IPC_EVENT.gameClosed, code), gameClosed: (code: number | null): void => send(IPC_EVENT.gameClosed, code),
authCode: (info: DeviceCodeInfo): void => send(IPC_EVENT.authCode, info), authCode: (info: DeviceCodeInfo): void => send(IPC_EVENT.authCode, info),
updateStatus: (s: UpdateStatus): void => send(IPC_EVENT.updateStatus, s) updateStatus: (s: UpdateStatus): void => send(IPC_EVENT.updateStatus, s)
+49 -4
View File
@@ -1,12 +1,19 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron' import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
import { join } from 'path' import { join } from 'path'
import { setMainWindow } from './events' import { setMainWindow } from './events'
import { IPC, type UserSettings } from '../shared/ipc' import { IPC, type UserSettings, type PlayOptions } from '../shared/ipc'
import { login, logout, restoreSession, getCurrent } from './auth' import { login, logout, restoreSession, getCurrent } from './auth'
import { play } from './play' import { play, stopGame, isGameRunning } from './play'
import { getPackMetaCached } from './modpack'
import { getSettings, setSettings } from './settings' import { getSettings, setSettings } from './settings'
import { paths } from './paths' import { paths } from './paths'
import { initUpdater, quitAndInstallUpdate } from './updater' import { initUpdater, quitAndInstallUpdate } from './updater'
import {
isFirstLaunch,
writeLauncherConfig,
defaultDataDir,
readLauncherConfigSync
} from './launcher-config'
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const win = new BrowserWindow({ const win = new BrowserWindow({
@@ -28,6 +35,13 @@ function createWindow(): BrowserWindow {
win.on('ready-to-show', () => win.show()) win.on('ready-to-show', () => win.show())
win.on('close', (e) => {
if (isGameRunning()) {
e.preventDefault()
win.hide()
}
})
// Liens externes -> navigateur système. // Liens externes -> navigateur système.
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url) void shell.openExternal(url)
@@ -50,12 +64,43 @@ function registerIpc(): void {
ipcMain.handle(IPC.authGetProfile, async () => { ipcMain.handle(IPC.authGetProfile, async () => {
return getCurrent()?.profile ?? (await restoreSession()) return getCurrent()?.profile ?? (await restoreSession())
}) })
ipcMain.handle(IPC.play, () => play()) ipcMain.handle(IPC.play, (_e, opts: PlayOptions | undefined) => play(opts))
ipcMain.handle(IPC.playStop, () => stopGame())
ipcMain.handle(IPC.packGet, () => getPackMetaCached())
ipcMain.handle(IPC.settingsGet, () => getSettings()) ipcMain.handle(IPC.settingsGet, () => getSettings())
ipcMain.handle(IPC.settingsSet, (_e, s: UserSettings) => setSettings(s)) ipcMain.handle(IPC.settingsSet, (_e, s: UserSettings) => setSettings(s))
ipcMain.handle(IPC.appVersion, () => app.getVersion()) ipcMain.handle(IPC.appVersion, () => app.getVersion())
ipcMain.handle(IPC.openInstanceDir, () => shell.openPath(paths.instanceDir)) ipcMain.handle(IPC.openInstanceDir, () => shell.openPath(paths.instanceDir))
ipcMain.handle(IPC.openLogsDir, () => shell.openPath(paths.logsDir))
ipcMain.handle(IPC.updateInstall, () => quitAndInstallUpdate()) ipcMain.handle(IPC.updateInstall, () => quitAndInstallUpdate())
ipcMain.handle(IPC.isFirstLaunch, () => isFirstLaunch())
ipcMain.handle(IPC.dataDirGet, () => {
const { dataDir } = readLauncherConfigSync()
const defaultSuggestion = defaultDataDir()
return { current: dataDir ?? defaultSuggestion, defaultSuggestion }
})
ipcMain.handle(IPC.dataDirBrowse, async () => {
const { dataDir } = readLauncherConfigSync()
const { canceled, filePaths } = await dialog.showOpenDialog({
title: 'Choisir le dossier de données',
defaultPath: dataDir ?? defaultDataDir(),
properties: ['openDirectory', 'createDirectory']
})
return canceled ? null : filePaths[0]
})
ipcMain.handle(IPC.dataDirSet, async (_e, dir: string, relaunch: boolean) => {
await writeLauncherConfig({ dataDir: dir })
paths.invalidate()
if (relaunch) {
app.relaunch()
app.exit(0)
}
return dir
})
} }
app.whenReady().then(() => { app.whenReady().then(() => {
+46 -16
View File
@@ -1,8 +1,10 @@
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { getVersionList, install, installNeoForged } from '@xmcl/installer' import { getVersionList, installTask, installNeoForgedTask } from '@xmcl/installer'
import type { Task } from '@xmcl/task'
import { paths } from './paths' import { paths } from './paths'
import { emit } from './events' import { emit } from './events'
import { type LaunchPhase } from '../shared/ipc'
import { downloadDispatcher, DOWNLOAD_CONCURRENCY, withRetries } from './net' import { downloadDispatcher, DOWNLOAD_CONCURRENCY, withRetries } from './net'
/** Options de téléchargement communes : dispatcher tolérant + concurrence bridée. */ /** Options de téléchargement communes : dispatcher tolérant + concurrence bridée. */
@@ -12,6 +14,25 @@ const downloadOptions = {
librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY
} }
/**
* Exécute une task @xmcl en émettant sa progression réelle (0..1) vers le
* renderer. On lit la progression cumulée de la task racine ; on n'émet qu'au
* changement de pourcentage entier pour ne pas inonder l'IPC.
*/
async function runWithProgress<T>(phase: LaunchPhase, message: string, task: Task<T>): Promise<T> {
let lastPct = -1
return task.startAndWait({
onUpdate() {
if (task.total <= 0) return
const ratio = task.progress / task.total
const pct = Math.floor(ratio * 100)
if (pct === lastPct) return
lastPct = pct
emit.progress({ phase, message, progress: Math.min(1, ratio) })
}
})
}
/** /**
* Installe le runtime Minecraft (vanilla 1.21.1) puis NeoForge dans * Installe le runtime Minecraft (vanilla 1.21.1) puis NeoForge dans
* paths.gameRoot. Les deux étapes sont idempotentes : si la version est déjà * paths.gameRoot. Les deux étapes sont idempotentes : si la version est déjà
@@ -25,25 +46,27 @@ function versionInstalled(id: string): boolean {
return existsSync(join(paths.gameRoot, 'versions', id, `${id}.json`)) 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> { * Installe Minecraft vanilla <version> (json + jar + assets + libraries).
if (versionInstalled(version)) return * `force` saute le court-circuit "déjà installé" pour revalider/réparer les
* fichiers (l'install @xmcl ignore les fichiers déjà valides par checksum).
*/
export async function installMinecraft(version: string, force = false): Promise<void> {
if (!force && versionInstalled(version)) return
emit.progress({ phase: 'minecraft', message: `Minecraft ${version} : métadonnées…`, progress: undefined }) emit.progress({ phase: 'minecraft', message: `Minecraft ${version} : métadonnées…`, progress: undefined })
const list = await getVersionList() const list = await getVersionList()
const meta = list.versions.find((v) => v.id === version) const meta = list.versions.find((v) => v.id === version)
if (!meta) throw new Error(`Version Minecraft introuvable : ${version}`) if (!meta) throw new Error(`Version Minecraft introuvable : ${version}`)
emit.progress({ const label = force
phase: 'minecraft', ? `Vérification de Minecraft ${version}`
message: `Installation de Minecraft ${version} (assets + libs)…`, : `Installation de Minecraft ${version} (assets + libs)…`
progress: undefined
})
// ~3700 assets : on réessaie l'install entière plusieurs fois. Chaque passe // ~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. // ignore les fichiers déjà valides et ne reprend que les manquants.
await withRetries( await withRetries(
() => install(meta, paths.gameRoot, downloadOptions), () => runWithProgress('minecraft', label, installTask(meta, paths.gameRoot, downloadOptions)),
5, 5,
(attempt) => (attempt) =>
emit.progress({ emit.progress({
@@ -61,26 +84,33 @@ export async function installMinecraft(version: string): Promise<void> {
export async function installNeoForge( export async function installNeoForge(
neoforge: string, neoforge: string,
minecraft: string, minecraft: string,
javaPath: string javaPath: string,
force = false
): Promise<string> { ): Promise<string> {
// @xmcl nomme la version installée "neoforge-<version>" (ex. neoforge-21.1.224). // @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). // Si elle est déjà présente, on saute l'install (idempotent, comme Minecraft).
const expectedId = `neoforge-${neoforge}` const expectedId = `neoforge-${neoforge}`
if (versionInstalled(expectedId)) { if (!force && versionInstalled(expectedId)) {
void minecraft void minecraft
return expectedId return expectedId
} }
emit.progress({ phase: 'neoforge', message: `Installation de NeoForge ${neoforge}`, progress: undefined }) const label = force
? `Vérification de NeoForge ${neoforge}`
: `Installation de NeoForge ${neoforge}`
// installNeoForged retourne l'id de version installée à lancer. // installNeoForgedTask retourne l'id de version installée à lancer.
const versionId = await withRetries( const versionId = await withRetries(
() => () =>
installNeoForged('neoforge', neoforge, paths.gameRoot, { runWithProgress(
'neoforge',
label,
installNeoForgedTask('neoforge', neoforge, paths.gameRoot, {
java: javaPath, java: javaPath,
dispatcher: downloadDispatcher, dispatcher: downloadDispatcher,
librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY
}), })
),
3, 3,
(attempt) => (attempt) =>
emit.progress({ emit.progress({
+8 -2
View File
@@ -20,6 +20,7 @@ import { emit } from './events'
*/ */
const JAVA_MAJOR = 21 const JAVA_MAJOR = 21
const JAVA_MAX = 21
const MARKER = (): string => join(paths.javaDir, 'managed.json') const MARKER = (): string => join(paths.javaDir, 'managed.json')
/** Exécute `<javaPath> -version` et renvoie la version majeure (ex. 21), ou null. */ /** Exécute `<javaPath> -version` et renvoie la version majeure (ex. 21), ou null. */
@@ -74,7 +75,7 @@ async function findSystemJava(): Promise<string | null> {
for (const c of candidates) { for (const c of candidates) {
if (!existsSync(c)) continue if (!existsSync(c)) continue
const major = await javaMajorVersion(c) const major = await javaMajorVersion(c)
if (major !== null && major >= JAVA_MAJOR) return c if (major !== null && major >= JAVA_MAJOR && major <= JAVA_MAX) return c
} }
return null return null
} }
@@ -147,7 +148,12 @@ export async function ensureJava(): Promise<string> {
if (existsSync(MARKER())) { if (existsSync(MARKER())) {
try { try {
const { javaPath } = JSON.parse(await readFile(MARKER(), 'utf-8')) as { javaPath: string } const { javaPath } = JSON.parse(await readFile(MARKER(), 'utf-8')) as { javaPath: string }
if (javaPath && existsSync(javaPath)) return javaPath if (javaPath && existsSync(javaPath)) {
const major = await javaMajorVersion(javaPath)
if (major !== null && major >= JAVA_MAJOR && major <= JAVA_MAX) return javaPath
// Version hors plage (ex: Java 22+) : invalider le cache et re-détecter
await rm(MARKER(), { force: true })
}
} catch { } catch {
/* marqueur corrompu : on réinstalle */ /* marqueur corrompu : on réinstalle */
} }
+33
View File
@@ -0,0 +1,33 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readFileSync } from 'fs'
import { writeFile } from 'fs/promises'
interface LauncherConfig {
dataDir?: string
}
function configPath(): string {
return join(app.getPath('userData'), 'launcher.json')
}
export function readLauncherConfigSync(): LauncherConfig {
try {
return JSON.parse(readFileSync(configPath(), 'utf-8')) as LauncherConfig
} catch {
return {}
}
}
export function isFirstLaunch(): boolean {
return !existsSync(configPath())
}
export async function writeLauncherConfig(cfg: LauncherConfig): Promise<void> {
const current = readLauncherConfigSync()
await writeFile(configPath(), JSON.stringify({ ...current, ...cfg }, null, 2))
}
export function defaultDataDir(): string {
return join(app.getPath('home'), 'Games', 'OFLauncher')
}
+30
View File
@@ -0,0 +1,30 @@
import { createWriteStream, type WriteStream } from 'fs'
import { paths } from './paths'
/**
* Journalisation sur disque du launcher (logs/launcher.log).
*
* Capture tout ce qui transite par `events.ts` (sortie jeu + packwiz + messages
* de phase + erreurs), pour pouvoir dépanner un joueur à distance. Le fichier
* est tronqué au début de chaque session "Jouer".
*/
let stream: WriteStream | null = null
function ts(): string {
return new Date().toISOString()
}
/** Ouvre (en tronquant) un nouveau fichier de log et écrit un en-tête. */
export function startSession(): void {
stream?.end()
stream = createWriteStream(paths.launcherLogFile, { flags: 'w' })
stream.write(`=== Session OFLauncher ${ts()} ===\n`)
}
/** Ajoute une ligne au log courant (no-op si aucune session ouverte). */
export function write(line: string): void {
if (!stream) return
const text = line.endsWith('\n') ? line : `${line}\n`
stream.write(text)
}
+16 -6
View File
@@ -4,6 +4,9 @@ import { paths } from './paths'
import { config } from '../shared/config' import { config } from '../shared/config'
import { fetchText } from './download' import { fetchText } from './download'
import { emit } from './events' import { emit } from './events'
import type { PackMeta } from '../shared/ipc'
export type { PackMeta }
/** /**
* Gestion du modpack côté launcher : * Gestion du modpack côté launcher :
@@ -15,11 +18,17 @@ import { emit } from './events'
* comportement "pas de re-download complet à chaque update" recherché. * comportement "pas de re-download complet à chaque update" recherché.
*/ */
export interface PackMeta { /** Dernière PackMeta lue avec succès (pour l'affichage UI, y compris hors flux Jouer). */
name: string let lastMeta: PackMeta | null = null
version: string
minecraft: string /** Renvoie la dernière PackMeta connue, ou la récupère si jamais lue. Tolérant au offline. */
neoforge: string export async function getPackMetaCached(): Promise<PackMeta | null> {
if (lastMeta) return lastMeta
try {
return await fetchPackMeta()
} catch {
return null
}
} }
/** Télécharge et parse le pack.toml distant. */ /** Télécharge et parse le pack.toml distant. */
@@ -47,12 +56,13 @@ export async function fetchPackMeta(): Promise<PackMeta> {
) )
} }
return { lastMeta = {
name: data.name ?? 'Modpack', name: data.name ?? 'Modpack',
version: data.version ?? '0', version: data.version ?? '0',
minecraft, minecraft,
neoforge neoforge
} }
return lastMeta
} }
/** /**
+36 -11
View File
@@ -1,19 +1,34 @@
import { app } from 'electron' import { app } from 'electron'
import { join } from 'path' import { join } from 'path'
import { mkdirSync } from 'fs' import { mkdirSync } from 'fs'
import { readLauncherConfigSync } from './launcher-config'
/** /**
* Arborescence des données du launcher, rangée sous le userData d'Electron : * Arborescence des données du launcher.
* Windows : %APPDATA%/OFLauncher
* Linux : ~/.config/OFLauncher
* *
* On garde le runtime (MC/NeoForge/assets/libs/java) séparé de l'instance de * La racine est configurable via launcher.json (userData/launcher.json) :
* jeu (mods/config/saves) pour que la sync packwiz ne touche jamais au runtime. * { "dataDir": "/chemin/choisi/par/l/utilisateur" }
* Par défaut : userData d'Electron (%APPDATA%/OFLauncher sur Windows,
* ~/.config/OFLauncher sur Linux).
*
* La config launcher.json elle-même reste toujours dans userData
* (point d'ancrage fixe, indépendant du dataDir choisi).
*/ */
class LauncherPaths { class LauncherPaths {
/** Racine : userData d'Electron. */ private _root: string | null = null
/** Racine des données du jeu (configurable). */
get root(): string { get root(): string {
return app.getPath('userData') if (!this._root) {
const { dataDir } = readLauncherConfigSync()
this._root = dataDir ?? app.getPath('userData')
}
return this._root
}
/** Invalide le cache de root (à appeler après écriture d'un nouveau dataDir). */
invalidate(): void {
this._root = null
} }
/** Dossier "Minecraft" géré par @xmcl : versions/, libraries/, assets/. */ /** Dossier "Minecraft" géré par @xmcl : versions/, libraries/, assets/. */
@@ -31,9 +46,11 @@ class LauncherPaths {
return this.ensure(join(this.root, 'java')) return this.ensure(join(this.root, 'java'))
} }
/** Cache des tokens d'auth (prismarine-auth). */ /** Cache des tokens d'auth (prismarine-auth). Toujours dans userData,
* indépendamment de dataDir — les tokens survivent ainsi aux changements
* de dossier de données et aux mises à jour du launcher. */
get authCache(): string { get authCache(): string {
return this.ensure(join(this.root, 'auth-cache')) return this.ensure(join(app.getPath('userData'), 'auth-cache'))
} }
/** Fichier de réglages utilisateur. */ /** Fichier de réglages utilisateur. */
@@ -41,10 +58,18 @@ class LauncherPaths {
return join(this.root, 'settings.json') return join(this.root, 'settings.json')
} }
/** Dossier des logs du launcher (install/Java/packwiz/jeu). */
get logsDir(): string {
return this.ensure(join(this.root, 'logs'))
}
/** Fichier de log courant du launcher. */
get launcherLogFile(): string {
return join(this.logsDir, 'launcher.log')
}
/** jar packwiz-installer-bootstrap embarqué dans les resources. */ /** jar packwiz-installer-bootstrap embarqué dans les resources. */
get packwizBootstrapJar(): string { 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 const base = app.isPackaged
? join(process.resourcesPath, 'resources') ? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources') : join(app.getAppPath(), 'resources')
+26 -3
View File
@@ -1,12 +1,16 @@
import type { ChildProcess } from 'child_process' import type { ChildProcess } from 'child_process'
import { app, BrowserWindow } from 'electron'
import { getCurrent } from './auth' import { getCurrent } from './auth'
import { fetchPackMeta } from './modpack' import { fetchPackMeta } from './modpack'
import { ensureJava } from './java' import { ensureJava } from './java'
import { installMinecraft, installNeoForge } from './install' import { installMinecraft, installNeoForge } from './install'
import { syncModpack } from './modpack' import { syncModpack } from './modpack'
import { ensureServerInList } from './server-list'
import { launchGame } from './launch' import { launchGame } from './launch'
import { getSettings } from './settings' import { getSettings } from './settings'
import { emit } from './events' import { emit } from './events'
import * as logger from './logger'
import type { PlayOptions } from '../shared/ipc'
/** Process de jeu courant (un seul à la fois). */ /** Process de jeu courant (un seul à la fois). */
let gameProcess: ChildProcess | null = null let gameProcess: ChildProcess | null = null
@@ -20,7 +24,7 @@ let gameProcess: ChildProcess | null = null
* idempotentes, donc à partir du 2e lancement seules les nouveautés du modpack * idempotentes, donc à partir du 2e lancement seules les nouveautés du modpack
* sont téléchargées. * sont téléchargées.
*/ */
export async function play(): Promise<void> { export async function play(opts?: PlayOptions): Promise<void> {
if (gameProcess) { if (gameProcess) {
throw new Error('Le jeu est déjà en cours.') throw new Error('Le jeu est déjà en cours.')
} }
@@ -30,21 +34,40 @@ export async function play(): Promise<void> {
throw new Error('Non connecté. Connecte-toi avec ton compte Microsoft dabord.') throw new Error('Non connecté. Connecte-toi avec ton compte Microsoft dabord.')
} }
const repair = opts?.repair ?? false
logger.startSession()
try { try {
const meta = await fetchPackMeta() const meta = await fetchPackMeta()
const javaPath = await ensureJava() const javaPath = await ensureJava()
await installMinecraft(meta.minecraft) await installMinecraft(meta.minecraft, repair)
const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath) const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath, repair)
await syncModpack(javaPath) await syncModpack(javaPath)
await ensureServerInList()
const settings = await getSettings() const settings = await getSettings()
const proc = await launchGame(versionId, auth, javaPath, settings) const proc = await launchGame(versionId, auth, javaPath, settings)
gameProcess = proc gameProcess = proc
proc.on('close', () => { proc.on('close', () => {
gameProcess = null gameProcess = null
const allHidden = BrowserWindow.getAllWindows().every((w) => !w.isVisible())
if (allHidden) {
app.quit()
}
}) })
} catch (e) { } catch (e) {
emit.progress({ phase: 'error', message: (e as Error).message, progress: undefined }) emit.progress({ phase: 'error', message: (e as Error).message, progress: undefined })
throw e throw e
} }
} }
/** Tue le process de jeu courant. Renvoie true si un process tournait. */
export function stopGame(): boolean {
if (!gameProcess) return false
gameProcess.kill()
return true
}
export function isGameRunning(): boolean {
return gameProcess !== null
}
+188
View File
@@ -0,0 +1,188 @@
import { readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import { paths } from './paths'
import { config } from '../shared/config'
/**
* Assure que le serveur configuré est présent dans servers.dat de l'instance.
* Si le fichier n'existe pas, il est créé. Si le serveur est déjà listé (même
* IP), rien n'est modifié pour ne pas écraser les préférences utilisateur.
*
* servers.dat : NBT non compressé (contrairement à level.dat).
* Structure : TAG_Compound root -> TAG_List "servers" -> TAG_Compound[] serveurs.
*/
export async function ensureServerInList(): Promise<void> {
if (!config.serverAddress?.trim()) return
const ip = config.serverAddress.trim()
const serversDat = join(paths.instanceDir, 'servers.dat')
let servers: ServerEntry[] = []
try {
const buf = await readFile(serversDat)
servers = decodeServersDat(buf)
} catch {
// Fichier absent ou illisible : on part d'une liste vide.
}
if (servers.some((s) => s.ip === ip)) return
// Notre serveur en tête de liste.
servers = [{ name: config.appName, ip }, ...servers]
await writeFile(serversDat, encodeServersDat(servers))
}
// ---------------------------------------------------------------------------
// Types internes
// ---------------------------------------------------------------------------
interface ServerEntry {
name: string
ip: string
}
// ---------------------------------------------------------------------------
// Encodeur NBT minimal
// ---------------------------------------------------------------------------
function b1(n: number): Buffer {
return Buffer.from([n])
}
function b2(n: number): Buffer {
const b = Buffer.allocUnsafe(2)
b.writeUInt16BE(n)
return b
}
function b4(n: number): Buffer {
const b = Buffer.allocUnsafe(4)
b.writeInt32BE(n)
return b
}
function encStr(s: string): Buffer {
const d = Buffer.from(s, 'utf8')
return Buffer.concat([b2(d.length), d])
}
function encTag(type: number, name: string, payload: Buffer): Buffer {
return Buffer.concat([b1(type), encStr(name), payload])
}
function encodeServersDat(servers: ServerEntry[]): Buffer {
const entries = servers.flatMap((s) => [
encTag(8, 'name', encStr(s.name)),
encTag(8, 'ip', encStr(s.ip)),
encTag(1, 'acceptTextures', b1(1)),
b1(0) // TAG_End clôture le compound dans la liste
])
const listPayload = Buffer.concat([b1(10), b4(servers.length), ...entries])
const rootPayload = Buffer.concat([encTag(9, 'servers', listPayload), b1(0)])
return Buffer.concat([b1(10), encStr(''), rootPayload])
}
// ---------------------------------------------------------------------------
// Décodeur NBT minimal
// ---------------------------------------------------------------------------
interface Reader {
buf: Buffer
pos: number
}
function ru8(r: Reader): number {
return r.buf[r.pos++]
}
function ru16(r: Reader): number {
const v = r.buf.readUInt16BE(r.pos)
r.pos += 2
return v
}
function ri32(r: Reader): number {
const v = r.buf.readInt32BE(r.pos)
r.pos += 4
return v
}
function rstr(r: Reader): string {
const len = ru16(r)
const s = r.buf.subarray(r.pos, r.pos + len).toString('utf8')
r.pos += len
return s
}
function skipPayload(r: Reader, type: number): void {
switch (type) {
case 1: r.pos++; return // TAG_Byte
case 2: r.pos += 2; return // TAG_Short
case 3: r.pos += 4; return // TAG_Int
case 4: r.pos += 8; return // TAG_Long
case 5: r.pos += 4; return // TAG_Float
case 6: r.pos += 8; return // TAG_Double
case 7: r.pos += ri32(r); return // TAG_Byte_Array
case 8: r.pos += ru16(r); return // TAG_String
case 9: { // TAG_List
const et = ru8(r)
const n = ri32(r)
for (let i = 0; i < n; i++) skipPayload(r, et)
return
}
case 10: { // TAG_Compound
while (r.pos < r.buf.length) {
const t = ru8(r)
if (t === 0) return
r.pos += ru16(r) // skip name
skipPayload(r, t)
}
return
}
case 11: r.pos += ri32(r) * 4; return // TAG_Int_Array
case 12: r.pos += ri32(r) * 8; return // TAG_Long_Array
}
}
function decodeServersDat(buf: Buffer): ServerEntry[] {
const r: Reader = { buf, pos: 0 }
if (ru8(r) !== 10) return [] // root doit être TAG_Compound
rstr(r) // nom de la racine (vide)
const servers: ServerEntry[] = []
while (r.pos < buf.length) {
const type = ru8(r)
if (type === 0) break
const key = rstr(r)
if (type === 9 && key === 'servers') {
const elemType = ru8(r)
const count = ri32(r)
if (elemType !== 10) {
// Type inattendu : skip
for (let i = 0; i < count; i++) skipPayload(r, elemType)
continue
}
for (let i = 0; i < count; i++) {
const entry: Partial<ServerEntry> = {}
while (r.pos < buf.length) {
const t = ru8(r)
if (t === 0) break
const k = rstr(r)
if (t === 8) {
const val = rstr(r)
if (k === 'name') entry.name = val
else if (k === 'ip') entry.ip = val
} else {
skipPayload(r, t)
}
}
if (entry.ip) servers.push({ name: entry.name ?? '', ip: entry.ip })
}
} else {
skipPayload(r, type)
}
}
return servers
}
+5 -3
View File
@@ -24,10 +24,12 @@ export function initUpdater(): void {
return 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.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true // Sur Linux (AppImage), l'install automatique au quit utilise execFileSync
// et bloque le processus en attendant que le nouveau AppImage se ferme —
// comportement très inattendu. On désactive : l'utilisateur clique le bouton
// "Redémarrer pour installer" qui, lui, utilise spawnLog (async) et fonctionne.
autoUpdater.autoInstallOnAppQuit = process.platform !== 'linux'
autoUpdater.on('checking-for-update', () => { autoUpdater.on('checking-for-update', () => {
emit.updateStatus({ state: 'checking' }) emit.updateStatus({ state: 'checking' })
+18 -2
View File
@@ -7,7 +7,10 @@ import {
type GameLogLine, type GameLogLine,
type UserSettings, type UserSettings,
type DeviceCodeInfo, type DeviceCodeInfo,
type UpdateStatus type UpdateStatus,
type PlayOptions,
type PackMeta,
type DataDirInfo
} from '../shared/ipc' } from '../shared/ipc'
/** API typée exposée au renderer via window.api. */ /** API typée exposée au renderer via window.api. */
@@ -18,7 +21,9 @@ const api = {
getProfile: (): Promise<PlayerProfile | null> => ipcRenderer.invoke(IPC.authGetProfile), getProfile: (): Promise<PlayerProfile | null> => ipcRenderer.invoke(IPC.authGetProfile),
// --- Jouer (install + sync + launch) --- // --- Jouer (install + sync + launch) ---
play: (): Promise<void> => ipcRenderer.invoke(IPC.play), play: (opts?: PlayOptions): Promise<void> => ipcRenderer.invoke(IPC.play, opts),
stopGame: (): Promise<boolean> => ipcRenderer.invoke(IPC.playStop),
getPackMeta: (): Promise<PackMeta | null> => ipcRenderer.invoke(IPC.packGet),
// --- Réglages --- // --- Réglages ---
getSettings: (): Promise<UserSettings> => ipcRenderer.invoke(IPC.settingsGet), getSettings: (): Promise<UserSettings> => ipcRenderer.invoke(IPC.settingsGet),
@@ -29,9 +34,20 @@ const api = {
/** Ouvre le dossier d'instance (mods/config/saves) dans l'explorateur. */ /** Ouvre le dossier d'instance (mods/config/saves) dans l'explorateur. */
openInstanceDir: (): Promise<string> => ipcRenderer.invoke(IPC.openInstanceDir), openInstanceDir: (): Promise<string> => ipcRenderer.invoke(IPC.openInstanceDir),
/** Ouvre le dossier des logs du launcher. */
openLogsDir: (): Promise<string> => ipcRenderer.invoke(IPC.openLogsDir),
/** Quitte et installe la mise à jour téléchargée. */ /** Quitte et installe la mise à jour téléchargée. */
installUpdate: (): Promise<void> => ipcRenderer.invoke(IPC.updateInstall), installUpdate: (): Promise<void> => ipcRenderer.invoke(IPC.updateInstall),
// --- Dossier de données ---
isFirstLaunch: (): Promise<boolean> => ipcRenderer.invoke(IPC.isFirstLaunch),
getDataDir: (): Promise<DataDirInfo> => ipcRenderer.invoke(IPC.dataDirGet),
browseDataDir: (): Promise<string | null> => ipcRenderer.invoke(IPC.dataDirBrowse),
/** relaunch=true : relance l'app (pour un changement post-installation). */
setDataDir: (dir: string, relaunch: boolean): Promise<string> =>
ipcRenderer.invoke(IPC.dataDirSet, dir, relaunch),
// --- Abonnements aux événements (retournent une fonction de désabonnement) --- // --- Abonnements aux événements (retournent une fonction de désabonnement) ---
onProgress: (cb: (e: ProgressEvent) => void): (() => void) => { onProgress: (cb: (e: ProgressEvent) => void): (() => void) => {
const handler = (_: unknown, e: ProgressEvent): void => cb(e) const handler = (_: unknown, e: ProgressEvent): void => cb(e)
+122 -12
View File
@@ -4,10 +4,17 @@ import type {
ProgressEvent, ProgressEvent,
GameLogLine, GameLogLine,
DeviceCodeInfo, DeviceCodeInfo,
UpdateStatus UpdateStatus,
PackMeta,
DataDirInfo
} from '../../shared/ipc' } from '../../shared/ipc'
type Status = 'loading' | 'logged-out' | 'logged-in' | 'working' | 'running' /** Découpe une chaîne d'args JVM en tableau (espaces, vides ignorés). */
function parseArgs(s: string): string[] {
return s.split(/\s+/).filter(Boolean)
}
type Status = 'loading' | 'setup' | 'logged-out' | 'logged-in' | 'working' | 'running'
export default function App(): JSX.Element { export default function App(): JSX.Element {
const [status, setStatus] = useState<Status>('loading') const [status, setStatus] = useState<Status>('loading')
@@ -17,23 +24,39 @@ export default function App(): JSX.Element {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [authCode, setAuthCode] = useState<DeviceCodeInfo | null>(null) const [authCode, setAuthCode] = useState<DeviceCodeInfo | null>(null)
const [maxMemoryMb, setMaxMemoryMb] = useState(8192) const [maxMemoryMb, setMaxMemoryMb] = useState(8192)
const [jvmArgs, setJvmArgs] = useState('')
const [appVersion, setAppVersion] = useState('') const [appVersion, setAppVersion] = useState('')
const [update, setUpdate] = useState<UpdateStatus | null>(null) const [update, setUpdate] = useState<UpdateStatus | null>(null)
const [pack, setPack] = useState<PackMeta | null>(null)
const [dataDir, setDataDir] = useState<DataDirInfo | null>(null)
const [setupDir, setSetupDir] = useState('')
const consoleRef = useRef<HTMLDivElement>(null) const consoleRef = useRef<HTMLDivElement>(null)
// Restaure la session + réglages au démarrage. // Restaure la session + réglages au démarrage.
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
const [p, s, v] = await Promise.all([ const [firstLaunch, dirInfo, s, v] = await Promise.all([
window.api.getProfile(), window.api.isFirstLaunch(),
window.api.getDataDir(),
window.api.getSettings(), window.api.getSettings(),
window.api.getAppVersion() window.api.getAppVersion()
]) ])
setDataDir(dirInfo)
setMaxMemoryMb(s.maxMemoryMb) setMaxMemoryMb(s.maxMemoryMb)
setJvmArgs(s.extraJvmArgs.join(' '))
setAppVersion(v) setAppVersion(v)
if (firstLaunch) {
setSetupDir(dirInfo.defaultSuggestion)
setStatus('setup')
return
}
const p = await window.api.getProfile()
setProfile(p) setProfile(p)
setStatus(p ? 'logged-in' : 'logged-out') setStatus(p ? 'logged-in' : 'logged-out')
})() })()
void window.api.getPackMeta().then((m) => m && setPack(m))
}, []) }, [])
// Abonnements aux événements main -> renderer. // Abonnements aux événements main -> renderer.
@@ -97,19 +120,51 @@ export default function App(): JSX.Element {
void window.api.openInstanceDir() void window.api.openInstanceDir()
} }
async function handlePlay(): Promise<void> { function handleOpenLogs(): void {
void window.api.openLogsDir()
}
function handleStop(): void {
void window.api.stopGame()
}
async function handlePlay(opts?: { repair?: boolean }): Promise<void> {
setError(null) setError(null)
setLogs([]) setLogs([])
setStatus('working') setStatus('working')
await window.api.setSettings({ maxMemoryMb, extraJvmArgs: [] }) await window.api.setSettings({ maxMemoryMb, extraJvmArgs: parseArgs(jvmArgs) })
try { try {
await window.api.play() await window.api.play(opts)
} catch (e) { } catch (e) {
setError(`Échec du lancement : ${(e as Error).message}`) setError(`Échec du lancement : ${(e as Error).message}`)
setStatus('logged-in') setStatus('logged-in')
} }
} }
async function handleSetupBrowse(): Promise<void> {
const picked = await window.api.browseDataDir()
if (picked) setSetupDir(picked)
}
async function handleSetupConfirm(): Promise<void> {
const dir = setupDir.trim()
if (!dir) return
await window.api.setDataDir(dir, false)
const updated = await window.api.getDataDir()
setDataDir(updated)
const p = await window.api.getProfile()
setProfile(p)
setStatus(p ? 'logged-in' : 'logged-out')
}
async function handleChangeDataDir(): Promise<void> {
const picked = await window.api.browseDataDir()
if (!picked) return
// Relaunch = true : les fichiers existants restent dans l'ancien dossier,
// le launcher redémarre et tout se retélécharge dans le nouveau.
await window.api.setDataDir(picked, true)
}
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="app"> <div className="app">
@@ -118,6 +173,36 @@ export default function App(): JSX.Element {
) )
} }
if (status === 'setup') {
return (
<div className="app">
<div className="center setup">
<h1>OFLauncher</h1>
<p className="muted">Bienvenue ! Choisis seront stockés les fichiers du jeu.</p>
<p className="muted" style={{ fontSize: 12 }}>
Minecraft, Java, mods et sauvegardes iront dans ce dossier.
</p>
<div className="datadir-row">
<input
type="text"
className="datadir-input"
value={setupDir}
onChange={(e) => setSetupDir(e.target.value)}
spellCheck={false}
/>
<button className="linkbtn" onClick={() => void handleSetupBrowse()}>
Parcourir
</button>
</div>
<button className="play" onClick={() => void handleSetupConfirm()} disabled={!setupDir.trim()}>
Continuer
</button>
<div className="muted" style={{ fontSize: 12 }}>v{appVersion}</div>
</div>
</div>
)
}
if (status === 'logged-out') { if (status === 'logged-out') {
return ( return (
<div className="app"> <div className="app">
@@ -138,7 +223,7 @@ export default function App(): JSX.Element {
</p> </p>
<div className="code">{authCode.userCode}</div> <div className="code">{authCode.userCode}</div>
<p className="muted" style={{ fontSize: 12 }}> <p className="muted" style={{ fontSize: 12 }}>
La page sest ouverte dans ton navigateur. Reviens ici une fois connecté. La page s'est ouverte dans ton navigateur. Reviens ici une fois connecté.
</p> </p>
</div> </div>
)} )}
@@ -178,7 +263,9 @@ export default function App(): JSX.Element {
<div className="topbar"> <div className="topbar">
<div className="brand"> <div className="brand">
OFLauncher OFLauncher
<span className="pack">All The Mods 10 · 1.21.1</span> <span className="pack">
{pack ? `${pack.name} · MC ${pack.minecraft}` : 'All The Mods 10 · 1.21.1'}
</span>
</div> </div>
<div className="profile"> <div className="profile">
<img src={`https://mc-heads.net/avatar/${profile?.uuid}`} alt="" /> <img src={`https://mc-heads.net/avatar/${profile?.uuid}`} alt="" />
@@ -220,14 +307,37 @@ export default function App(): JSX.Element {
onChange={(e) => setMaxMemoryMb(Number(e.target.value))} onChange={(e) => setMaxMemoryMb(Number(e.target.value))}
/> />
Mo Mo
<input
className="jvm"
type="text"
placeholder="Args JVM (avancé)"
value={jvmArgs}
disabled={busy}
onChange={(e) => setJvmArgs(e.target.value)}
/>
<button className="linkbtn" onClick={() => void handlePlay({ repair: true })} disabled={busy}>
Vérifier les fichiers
</button>
<button className="linkbtn" onClick={handleOpenInstance}> <button className="linkbtn" onClick={handleOpenInstance}>
Ouvrir le dossier de l'instance Ouvrir l'instance
</button>
<button className="linkbtn" onClick={handleOpenLogs}>
Ouvrir les logs
</button>
<button className="linkbtn" onClick={() => void handleChangeDataDir()} disabled={busy} title={dataDir?.current}>
Dossier de données
</button> </button>
</div> </div>
<button className="play" onClick={handlePlay} disabled={busy}> {status === 'running' ? (
{status === 'running' ? 'En jeu' : busy ? 'Patiente' : 'Jouer'} <button className="play stop" onClick={handleStop}>
Arrêter le jeu
</button> </button>
) : (
<button className="play" onClick={() => void handlePlay()} disabled={busy}>
{busy ? 'Patiente…' : 'Jouer'}
</button>
)}
</div> </div>
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
</div> </div>
+52
View File
@@ -123,6 +123,20 @@ body {
.progress { .progress {
flex: 1; flex: 1;
min-width: 0;
}
/* Fenêtre réduite : la barre de progression passe sur sa propre ligne
(sinon les réglages la compriment jusqu'à la rendre invisible). */
@media (max-width: 1200px) {
.footer {
flex-wrap: wrap;
}
.progress {
order: -1;
flex-basis: 100%;
}
} }
.progress .label { .progress .label {
@@ -180,6 +194,15 @@ button.play:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
button.play.stop {
background: var(--danger);
color: #2a0606;
}
button.play.stop:hover:not(:disabled) {
background: #ff6258;
}
.center { .center {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -214,6 +237,7 @@ button.play:disabled {
.settings { .settings {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
color: var(--text-dim); color: var(--text-dim);
@@ -228,6 +252,10 @@ button.play:disabled {
padding: 4px 6px; padding: 4px 6px;
} }
.settings input.jvm {
width: 200px;
}
.error { .error {
color: var(--danger); color: var(--danger);
font-size: 13px; font-size: 13px;
@@ -255,3 +283,27 @@ button.play:disabled {
display: inline-block; display: inline-block;
user-select: all; user-select: all;
} }
.setup {
max-width: 560px;
margin: 0 auto;
text-align: center;
}
.datadir-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.datadir-input {
flex: 1;
background: var(--bg-soft);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
font-family: 'Cascadia Code', 'Consolas', monospace;
}
+30 -1
View File
@@ -67,6 +67,20 @@ export interface UpdateStatus {
message?: string message?: string
} }
/** Métadonnées du modpack lues depuis le pack.toml packwiz distant. */
export interface PackMeta {
name: string
version: string
minecraft: string
neoforge: string
}
/** Options de la séquence "Jouer". */
export interface PlayOptions {
/** Force la revérification/redownload des fichiers (réparation). */
repair?: boolean
}
/** Réglages utilisateur persistés localement. */ /** Réglages utilisateur persistés localement. */
export interface UserSettings { export interface UserSettings {
/** RAM max allouée à la JVM, en Mo. */ /** RAM max allouée à la JVM, en Mo. */
@@ -80,6 +94,14 @@ export const DEFAULT_SETTINGS: UserSettings = {
extraJvmArgs: [] extraJvmArgs: []
} }
/** Informations sur le dossier de données actuel. */
export interface DataDirInfo {
/** Chemin actuellement utilisé. */
current: string
/** Suggestion par défaut (~Games/OFLauncher). */
defaultSuggestion: string
}
/** Noms de canaux IPC (invoke renderer -> main). */ /** Noms de canaux IPC (invoke renderer -> main). */
export const IPC = { export const IPC = {
authLogin: 'auth:login', authLogin: 'auth:login',
@@ -90,7 +112,14 @@ export const IPC = {
settingsSet: 'settings:set', settingsSet: 'settings:set',
appVersion: 'app:version', appVersion: 'app:version',
openInstanceDir: 'instance:open', openInstanceDir: 'instance:open',
updateInstall: 'update:install' openLogsDir: 'logs:open',
playStop: 'play:stop',
packGet: 'pack:get',
updateInstall: 'update:install',
isFirstLaunch: 'app:isFirstLaunch',
dataDirGet: 'dataDir:get',
dataDirBrowse: 'dataDir:browse',
dataDirSet: 'dataDir:set'
} as const } as const
/** Noms d'événements (send main -> renderer). */ /** Noms d'événements (send main -> renderer). */