Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0d7d7ce4f | |||
| a7a4bce19f | |||
| 48f99f62c4 | |||
| 161ea50234 | |||
| b8204c80bd | |||
| 7d57ea0ca9 | |||
| 4756420f8d | |||
| 48fa508540 | |||
| 5214d042af | |||
| 073f0b2349 |
@@ -21,7 +21,7 @@ on:
|
|||||||
options: [none, patch, minor, major]
|
options: [none, patch, minor, major]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-win:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: electronuserland/builder:20-wine
|
image: electronuserland/builder:20-wine
|
||||||
@@ -45,6 +45,9 @@ jobs:
|
|||||||
- name: Build Windows installer
|
- name: Build Windows installer
|
||||||
run: npm run build:win
|
run: npm run build:win
|
||||||
|
|
||||||
|
- name: Build Linux installers
|
||||||
|
run: npm run build:linux
|
||||||
|
|
||||||
- name: Publish to Gitea release
|
- name: Publish to Gitea release
|
||||||
run: node scripts/publish-gitea.mjs
|
run: node scripts/publish-gitea.mjs
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -113,21 +113,24 @@ Publier une nouvelle version :
|
|||||||
```bash
|
```bash
|
||||||
npm version patch # bump 0.1.0 -> 0.1.1 (la version EST la source de vérité)
|
npm version patch # bump 0.1.0 -> 0.1.1 (la version EST la source de vérité)
|
||||||
export GITEA_TOKEN=xxxxx # token Gitea (scope write:repository)
|
export GITEA_TOKEN=xxxxx # token Gitea (scope write:repository)
|
||||||
npm run publish:win # build l'installeur + upload latest.yml/installeur/.blockmap
|
npm run publish # build Windows + Linux puis upload de tous les artefacts
|
||||||
```
|
```
|
||||||
|
|
||||||
`scripts/publish-gitea.mjs` crée la release `latest` si besoin, supprime les
|
`scripts/publish-gitea.mjs` crée la release `latest` si besoin, supprime **tous**
|
||||||
anciens assets puis uploade les nouveaux. URL/owner/repo/tag sont surchargeables
|
les anciens assets puis uploade les nouveaux (Windows + Linux). C'est pourquoi on
|
||||||
via les variables `GITEA_URL` / `GITEA_OWNER` / `GITEA_REPO` / `GITEA_TAG`.
|
build les deux plateformes avant de publier (`build:all`) : une publication ne
|
||||||
|
contenant qu'une plateforme effacerait l'autre. URL/owner/repo/tag sont
|
||||||
|
surchargeables via `GITEA_URL` / `GITEA_OWNER` / `GITEA_REPO` / `GITEA_TAG`.
|
||||||
|
|
||||||
**Publier via Gitea Actions (CI, déclenchement manuel)** — au lieu de builder en
|
**Publier via Gitea Actions (CI, déclenchement manuel)** — au lieu de builder en
|
||||||
local, tu peux lancer le workflow `.gitea/workflows/publish.yml` depuis l'onglet
|
local, tu peux lancer le workflow `.gitea/workflows/publish.yml` depuis l'onglet
|
||||||
*Actions* du repo (bouton « Run workflow »). Il build l'installeur Windows sous
|
*Actions* du repo (bouton « Run workflow »). Sur un runner Linux (image
|
||||||
Linux via Wine (image `electronuserland/builder`) puis publie sur la release
|
`electronuserland/builder`), il build l'**installeur Windows** (NSIS, via Wine) et
|
||||||
|
les **paquets Linux** (AppImage + .deb), puis publie le tout sur la release
|
||||||
`latest`. Optionnellement, l'input `bump` (patch/minor/major) incrémente la
|
`latest`. Optionnellement, l'input `bump` (patch/minor/major) incrémente la
|
||||||
version et pousse le commit avant le build. Pré-requis : Gitea Actions activé +
|
version et pousse le commit avant le build. Pré-requis : Gitea Actions activé +
|
||||||
un act_runner enregistré (label `ubuntu-latest`, à adapter) ; le token auto
|
un act_runner enregistré (label `ubuntu-latest`, à adapter) ; un secret repo
|
||||||
`secrets.GITEA_TOKEN` suffit s'il a le droit d'écrire les releases.
|
`RELEASE_TOKEN` (scope `write:repository`) pour publier la release.
|
||||||
|
|
||||||
> Tester le flux en dev : `dev-app-update.yml` (déjà présent) pointe sur la même
|
> Tester le flux en dev : `dev-app-update.yml` (déjà présent) pointe sur la même
|
||||||
> URL ; avec une version distante > version locale, le bandeau de maj apparaît
|
> URL ; avec une version distante > version locale, le bandeau de maj apparaît
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.6 KiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oflauncher",
|
"name": "oflauncher",
|
||||||
"version": "1.0.0",
|
"version": "3.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oflauncher",
|
"name": "oflauncher",
|
||||||
"version": "1.0.0",
|
"version": "3.0.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+4
-2
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "oflauncher",
|
"name": "oflauncher",
|
||||||
"version": "1.0.0",
|
"version": "3.0.1",
|
||||||
"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",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"homepage": "https://gitea.ldpt.fr/zertus/OFLauncher",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
@@ -14,7 +15,8 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"build:win": "npm run build && electron-builder --win --config electron-builder.yml",
|
"build:win": "npm run build && electron-builder --win --config electron-builder.yml",
|
||||||
"build:linux": "npm run build && electron-builder --linux --config electron-builder.yml",
|
"build:linux": "npm run build && electron-builder --linux --config electron-builder.yml",
|
||||||
"publish:win": "npm run build:win && node scripts/publish-gitea.mjs",
|
"build:all": "npm run build && electron-builder --win --linux --config electron-builder.yml",
|
||||||
|
"publish": "npm run build:all && node scripts/publish-gitea.mjs",
|
||||||
"postinstall": "electron-builder install-app-deps"
|
"postinstall": "electron-builder install-app-deps"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -31,12 +31,22 @@ if (!TOKEN) {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fichiers de dist/ à publier pour l'auto-update Windows. */
|
/**
|
||||||
|
* Fichiers de dist/ à publier pour l'auto-update.
|
||||||
|
* - Windows : latest.yml + l'installeur NSIS (+ .blockmap)
|
||||||
|
* - Linux : latest-linux.yml + l'AppImage (+ .blockmap) ; le .deb est publié
|
||||||
|
* 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 (
|
||||||
name === 'latest.yml' ||
|
name === 'latest.yml' ||
|
||||||
|
name === 'latest-linux.yml' ||
|
||||||
name.endsWith('-setup.exe') ||
|
name.endsWith('-setup.exe') ||
|
||||||
name.endsWith('-setup.exe.blockmap')
|
name.endsWith('-setup.exe.blockmap') ||
|
||||||
|
name.endsWith('.AppImage') ||
|
||||||
|
name.endsWith('.AppImage.blockmap') ||
|
||||||
|
name.endsWith('.deb')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
+49
-19
@@ -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(
|
||||||
java: javaPath,
|
'neoforge',
|
||||||
dispatcher: downloadDispatcher,
|
label,
|
||||||
librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY
|
installNeoForgedTask('neoforge', neoforge, paths.gameRoot, {
|
||||||
}),
|
java: javaPath,
|
||||||
|
dispatcher: downloadDispatcher,
|
||||||
|
librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY
|
||||||
|
})
|
||||||
|
),
|
||||||
3,
|
3,
|
||||||
(attempt) =>
|
(attempt) =>
|
||||||
emit.progress({
|
emit.progress({
|
||||||
|
|||||||
+8
-2
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+32
-9
@@ -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/. */
|
||||||
@@ -41,10 +56,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')
|
||||||
|
|||||||
+24
-3
@@ -1,4 +1,5 @@
|
|||||||
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'
|
||||||
@@ -7,6 +8,8 @@ import { syncModpack } from './modpack'
|
|||||||
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 +23,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,11 +33,14 @@ export async function play(): Promise<void> {
|
|||||||
throw new Error('Non connecté. Connecte-toi avec ton compte Microsoft d’abord.')
|
throw new Error('Non connecté. Connecte-toi avec ton compte Microsoft d’abord.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
@@ -42,9 +48,24 @@ export async function play(): Promise<void> {
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
+18
-2
@@ -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)
|
||||||
|
|||||||
+123
-13
@@ -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 où 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 s’est 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}>
|
||||||
</button>
|
Arrêter le jeu
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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
@@ -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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user