first commit
This commit is contained in:
@@ -0,0 +1,165 @@
|
|||||||
|
# OFLauncher
|
||||||
|
|
||||||
|
Launcher Minecraft custom pour jouer à **All The Mods 10** (Minecraft 1.21.1 /
|
||||||
|
NeoForge / Java 21) entre potes, avec **mise à jour incrémentale du modpack** :
|
||||||
|
quand tu changes le pack, les joueurs ne re-téléchargent que ce qui a changé.
|
||||||
|
|
||||||
|
- **UI** : Electron + React + TypeScript
|
||||||
|
- **Auth Microsoft** : `prismarine-auth` (cache + refresh automatique)
|
||||||
|
- **Runtime MC / NeoForge** : `@xmcl/core` + `@xmcl/installer`
|
||||||
|
- **Java 21** : JRE Temurin (Adoptium) téléchargé et géré automatiquement
|
||||||
|
- **Sync du modpack** : `packwiz-installer` (delta par hash, conforme CurseForge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # une fois
|
||||||
|
npm run dev # lance le launcher en mode dev (hot reload)
|
||||||
|
npm run typecheck # vérifie les types main + renderer
|
||||||
|
npm run build # build de production (out/)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Configuration (la seule chose à éditer dans le code)
|
||||||
|
|
||||||
|
Tout est dans **`src/shared/config.ts`** :
|
||||||
|
|
||||||
|
| Champ | À mettre |
|
||||||
|
| --------------- | -------------------------------------------------------------------- |
|
||||||
|
| `packTomlUrl` | URL publique de ton `pack.toml` packwiz (voir §4) |
|
||||||
|
| `azureClientId` | Client ID de ton app Azure (voir §3). Laisse `CHANGE_ME` pour tester |
|
||||||
|
| `serverAddress` | (optionnel) `ip:port` du serveur pour rejoindre en un clic |
|
||||||
|
|
||||||
|
Les **versions Minecraft et NeoForge ne sont PAS dans le code** : elles viennent
|
||||||
|
du `pack.toml`. Pour changer de version, tu mets à jour le pack, pas le launcher.
|
||||||
|
|
||||||
|
## 3. Auth Microsoft
|
||||||
|
|
||||||
|
Par défaut (`azureClientId = "CHANGE_ME"`), le launcher utilise le flow `live`
|
||||||
|
qui **fonctionne tout de suite** pour tester, sans app Azure.
|
||||||
|
|
||||||
|
Pour la version « propre » d'un launcher tiers (recommandé en prod) :
|
||||||
|
|
||||||
|
1. Crée une app sur [Azure Portal](https://portal.azure.com) → *App registrations*
|
||||||
|
(comptes personnels / *consumers*), avec le scope `XboxLive.signin`.
|
||||||
|
2. **Demande l'accès à l'API Minecraft** via le formulaire Microsoft — cette
|
||||||
|
approbation peut prendre du temps, fais-le tôt. Réf :
|
||||||
|
<https://learn.microsoft.com/answers/questions/5768276/>
|
||||||
|
3. Mets le Client ID dans `config.azureClientId`. Le launcher bascule alors
|
||||||
|
automatiquement sur le flow `msal`.
|
||||||
|
|
||||||
|
Le login se fait en **device code** : le launcher ouvre la page Microsoft et
|
||||||
|
affiche un code à saisir. Le token est mis en cache (pas de re-login ensuite).
|
||||||
|
|
||||||
|
## 4. Créer et héberger le modpack (côté admin = toi)
|
||||||
|
|
||||||
|
Le modpack est géré avec [**packwiz**](https://packwiz.infra.link/) (CLI).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Récupère une clé API CurseForge (https://console.curseforge.com) -> CF_API_KEY
|
||||||
|
# 2. Crée le dépôt packwiz à partir d'ATM10 (depuis le .zip CurseForge d'ATM10) :
|
||||||
|
packwiz init # renseigne MC 1.21.1 + NeoForge
|
||||||
|
packwiz cf import All-the-Mods-10-x.y.z.zip # importe les mods + configs
|
||||||
|
|
||||||
|
# 3. À chaque modif du pack :
|
||||||
|
packwiz cf add <mod> # ajouter un mod CurseForge
|
||||||
|
packwiz update --all # mettre à jour les mods
|
||||||
|
packwiz remove <mod> # retirer un mod
|
||||||
|
packwiz refresh # régénère index.toml (le manifeste hashé)
|
||||||
|
git add -A && git commit -m "update pack" && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hébergement** (à décider) : le plus simple est **GitHub**.
|
||||||
|
|
||||||
|
- Pousse le dossier packwiz dans un repo (ex. `OFModpack`).
|
||||||
|
- `packTomlUrl` = lien *raw* vers `pack.toml`, ex.
|
||||||
|
`https://raw.githubusercontent.com/<user>/OFModpack/main/pack.toml`.
|
||||||
|
- (ou GitHub Pages pour une URL plus propre.)
|
||||||
|
|
||||||
|
À chaque lancement, le launcher relit `pack.toml`/`index.toml` et **ne télécharge
|
||||||
|
que les fichiers dont le hash a changé** ; les fichiers retirés du pack sont
|
||||||
|
supprimés de l'instance du joueur.
|
||||||
|
|
||||||
|
> **Mods CurseForge non-redistribuables** : la grande majorité d'ATM10 se
|
||||||
|
> télécharge automatiquement. Pour les rares mods qui interdisent la
|
||||||
|
> redistribution, packwiz les signalera ; au besoin tu peux les héberger
|
||||||
|
> toi-même (si la licence le permet) via un bloc `[download]` dans le `.pw.toml`.
|
||||||
|
|
||||||
|
## 5. Build des binaires
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:win # installeur Windows (.exe NSIS) -> dist/
|
||||||
|
npm run build:linux # AppImage + .deb -> dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-update du launcher (via Gitea)
|
||||||
|
|
||||||
|
Le launcher se met à jour tout seul : au démarrage il lit `latest.yml` à l'URL
|
||||||
|
configurée dans `electron-builder.yml` (`publish.url`), télécharge la nouvelle
|
||||||
|
version en fond et propose un bouton **« Redémarrer pour installer »**.
|
||||||
|
|
||||||
|
Les artefacts sont hébergés sur une **release Gitea à tag fixe `latest`**
|
||||||
|
(provider `generic` d'electron-updater — il n'y a pas de provider Gitea natif).
|
||||||
|
À chaque publication, les assets de cette release sont **écrasés**.
|
||||||
|
|
||||||
|
Pré-requis côté Gitea (une fois) :
|
||||||
|
|
||||||
|
- un repo launcher, ex. `gitea.ldpt.fr/zertus/OFLauncher` ;
|
||||||
|
- un **token d'accès** avec le scope `write:repository`.
|
||||||
|
|
||||||
|
Publier une nouvelle version :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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)
|
||||||
|
npm run publish:win # build l'installeur + upload latest.yml/installeur/.blockmap
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/publish-gitea.mjs` crée la release `latest` si besoin, supprime les
|
||||||
|
anciens assets puis uploade les nouveaux. URL/owner/repo/tag sont surchargeables
|
||||||
|
via les variables `GITEA_URL` / `GITEA_OWNER` / `GITEA_REPO` / `GITEA_TAG`.
|
||||||
|
|
||||||
|
**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
|
||||||
|
*Actions* du repo (bouton « Run workflow »). Il build l'installeur Windows sous
|
||||||
|
Linux via Wine (image `electronuserland/builder`) puis publie sur la release
|
||||||
|
`latest`. Optionnellement, l'input `bump` (patch/minor/major) incrémente la
|
||||||
|
version et pousse le commit avant le build. Pré-requis : Gitea Actions activé +
|
||||||
|
un act_runner enregistré (label `ubuntu-latest`, à adapter) ; le token auto
|
||||||
|
`secrets.GITEA_TOKEN` suffit s'il a le droit d'écrire les releases.
|
||||||
|
|
||||||
|
> 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
|
||||||
|
> en `npm run dev` (l'install réelle ne se fait toutefois qu'en build packagé).
|
||||||
|
|
||||||
|
## 6. Où vivent les fichiers
|
||||||
|
|
||||||
|
Sous le dossier userData d'Electron (`%APPDATA%/OFLauncher` sur Windows,
|
||||||
|
`~/.config/OFLauncher` sur Linux) :
|
||||||
|
|
||||||
|
```
|
||||||
|
minecraft/ runtime géré par @xmcl (versions, libraries, assets) — jamais touché par packwiz
|
||||||
|
instance/ mods/, config/, saves/ — cible de la sync packwiz
|
||||||
|
java/ JRE Temurin 21 géré
|
||||||
|
auth-cache/ tokens Microsoft (refresh auto)
|
||||||
|
settings.json réglages (RAM, args JVM)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture (code)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── shared/ config + contrats IPC partagés main/renderer
|
||||||
|
├── main/
|
||||||
|
│ ├── index.ts bootstrap Electron + handlers IPC
|
||||||
|
│ ├── auth.ts login/refresh/logout Microsoft
|
||||||
|
│ ├── java.ts provisioning Temurin 21
|
||||||
|
│ ├── install.ts install MC + NeoForge (@xmcl)
|
||||||
|
│ ├── modpack.ts pack.toml + sync packwiz
|
||||||
|
│ ├── launch.ts lancement du jeu (@xmcl)
|
||||||
|
│ ├── play.ts orchestration de la séquence "Jouer"
|
||||||
|
│ └── ...
|
||||||
|
├── preload/ pont IPC typé (window.api)
|
||||||
|
└── renderer/ UI React
|
||||||
|
```
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Lu par electron-updater UNIQUEMENT en dev (app non packagée) pour tester le
|
||||||
|
# flux de mise à jour sans build complet. Doit refléter le bloc `publish` de
|
||||||
|
# electron-builder.yml. Ignoré en production (le vrai app-update.yml est injecté
|
||||||
|
# dans le build).
|
||||||
|
provider: generic
|
||||||
|
url: https://gitea.ldpt.fr/zertus/OFLauncher/releases/download/latest/
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
appId: com.oflauncher.app
|
||||||
|
productName: OFLauncher
|
||||||
|
directories:
|
||||||
|
buildResources: build
|
||||||
|
output: dist
|
||||||
|
files:
|
||||||
|
- '!**/.vscode/*'
|
||||||
|
- '!src/*'
|
||||||
|
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||||
|
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||||
|
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,package-lock.json}'
|
||||||
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
|
asarUnpack:
|
||||||
|
- resources/**
|
||||||
|
extraResources:
|
||||||
|
- from: resources/
|
||||||
|
to: resources/
|
||||||
|
filter:
|
||||||
|
- '**/*'
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- nsis
|
||||||
|
artifactName: ${productName}-${version}-setup.${ext}
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
perMachine: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
linux:
|
||||||
|
target:
|
||||||
|
- AppImage
|
||||||
|
- deb
|
||||||
|
maintainer: oflauncher
|
||||||
|
category: Game
|
||||||
|
artifactName: ${productName}-${version}.${ext}
|
||||||
|
# Publication des binaires du launcher (auto-update).
|
||||||
|
# Provider "generic" : electron-updater lit latest.yml à cette URL fixe.
|
||||||
|
# Les artefacts (latest.yml + installeur + .blockmap) sont uploadés sur une
|
||||||
|
# release Gitea à tag fixe "latest" via scripts/publish-gitea.mjs.
|
||||||
|
publish:
|
||||||
|
provider: generic
|
||||||
|
url: https://gitea.ldpt.fr/zertus/OFLauncher/releases/download/latest/
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/main/index.ts')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/preload/index.ts')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve('src/renderer/src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react()]
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+7529
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "oflauncher",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Launcher Minecraft custom pour le modpack ATM10 (1.21.1 / NeoForge)",
|
||||||
|
"main": "./out/main/index.js",
|
||||||
|
"author": "OFLauncher",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build",
|
||||||
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
"start": "electron-vite preview",
|
||||||
|
"build:win": "npm run build && electron-builder --win --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",
|
||||||
|
"postinstall": "electron-builder install-app-deps"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xmcl/core": "^2.15.1",
|
||||||
|
"@xmcl/installer": "^6.1.2",
|
||||||
|
"electron-updater": "^6.8.9",
|
||||||
|
"prismarine-auth": "^3.1.1",
|
||||||
|
"smol-toml": "^1.6.1",
|
||||||
|
"undici": "^7.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.17.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"electron": "^33.2.0",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"electron-vite": "^2.3.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Publie les artefacts d'auto-update du launcher sur une release Gitea à tag
|
||||||
|
* fixe ("latest"). electron-updater (provider generic) lit ensuite latest.yml
|
||||||
|
* à l'URL configurée dans electron-builder.yml.
|
||||||
|
*
|
||||||
|
* Pré-requis :
|
||||||
|
* - `npm run build:win` a produit dist/ (latest.yml + installeur + .blockmap)
|
||||||
|
* - variable d'env GITEA_TOKEN (scope write:repository)
|
||||||
|
*
|
||||||
|
* Config par variables d'env (avec valeurs par défaut) :
|
||||||
|
* GITEA_URL base de l'instance (def. https://gitea.ldpt.fr)
|
||||||
|
* GITEA_OWNER propriétaire du repo (def. zertus)
|
||||||
|
* GITEA_REPO nom du repo launcher (def. OFLauncher)
|
||||||
|
* GITEA_TAG tag fixe de la release (def. latest)
|
||||||
|
*/
|
||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const BASE = process.env.GITEA_URL ?? 'https://gitea.ldpt.fr'
|
||||||
|
const OWNER = process.env.GITEA_OWNER ?? 'zertus'
|
||||||
|
const REPO = process.env.GITEA_REPO ?? 'OFLauncher'
|
||||||
|
const TAG = process.env.GITEA_TAG ?? 'latest'
|
||||||
|
const TOKEN = process.env.GITEA_TOKEN
|
||||||
|
|
||||||
|
const DIST = join(process.cwd(), 'dist')
|
||||||
|
const API = `${BASE}/api/v1`
|
||||||
|
|
||||||
|
if (!TOKEN) {
|
||||||
|
console.error('GITEA_TOKEN manquant (scope write:repository).')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fichiers de dist/ à publier pour l'auto-update Windows. */
|
||||||
|
function isUpdateArtifact(name) {
|
||||||
|
return (
|
||||||
|
name === 'latest.yml' ||
|
||||||
|
name.endsWith('-setup.exe') ||
|
||||||
|
name.endsWith('-setup.exe.blockmap')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, init = {}) {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${TOKEN}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(init.headers ?? {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '')
|
||||||
|
throw new Error(`Gitea ${init.method ?? 'GET'} ${path} -> ${res.status} ${body}`)
|
||||||
|
}
|
||||||
|
return res.status === 204 ? null : res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Récupère la release au tag fixe, ou la crée si absente. */
|
||||||
|
async function getOrCreateRelease() {
|
||||||
|
const res = await fetch(`${API}/repos/${OWNER}/${REPO}/releases/tags/${TAG}`, {
|
||||||
|
headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json' }
|
||||||
|
})
|
||||||
|
if (res.ok) return res.json()
|
||||||
|
if (res.status !== 404) {
|
||||||
|
throw new Error(`Gitea GET release -> ${res.status} ${await res.text()}`)
|
||||||
|
}
|
||||||
|
console.log(`Création de la release "${TAG}"…`)
|
||||||
|
return api(`/repos/${OWNER}/${REPO}/releases`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tag_name: TAG,
|
||||||
|
name: 'Dernière version',
|
||||||
|
body: 'Artefacts d’auto-update du launcher (écrasés à chaque publication).'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteExistingAssets(releaseId) {
|
||||||
|
const assets = await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets`)
|
||||||
|
for (const a of assets) {
|
||||||
|
console.log(`Suppression de l'ancien asset ${a.name}…`)
|
||||||
|
await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets/${a.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAsset(releaseId, name) {
|
||||||
|
const buf = await readFile(join(DIST, name))
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('attachment', new Blob([buf]), name)
|
||||||
|
console.log(`Upload de ${name} (${(buf.length / 1e6).toFixed(1)} Mo)…`)
|
||||||
|
await api(
|
||||||
|
`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
|
||||||
|
{ method: 'POST', body: form }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const files = (await readdir(DIST)).filter(isUpdateArtifact)
|
||||||
|
if (!files.some((f) => f === 'latest.yml')) {
|
||||||
|
throw new Error('dist/latest.yml introuvable — lance d’abord `npm run build:win`.')
|
||||||
|
}
|
||||||
|
console.log(`Publication sur ${OWNER}/${REPO} (tag ${TAG}) : ${files.join(', ')}`)
|
||||||
|
|
||||||
|
const release = await getOrCreateRelease()
|
||||||
|
await deleteExistingAssets(release.id)
|
||||||
|
for (const name of files) await uploadAsset(release.id, name)
|
||||||
|
|
||||||
|
console.log('✓ Publication terminée.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e.message)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { shell } from 'electron'
|
||||||
|
import { Authflow, Titles, type MicrosoftAuthFlowOptions } from 'prismarine-auth'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { config } from '../shared/config'
|
||||||
|
import { emit } from './events'
|
||||||
|
import type { PlayerProfile } from '../shared/ipc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth Microsoft via prismarine-auth (chaîne MS -> Xbox Live -> token Minecraft,
|
||||||
|
* avec cache + refresh automatique dans paths.authCache).
|
||||||
|
*
|
||||||
|
* Deux modes selon la config :
|
||||||
|
* - Azure non configuré (azureClientId = "CHANGE_ME") : flow "sisu" avec le
|
||||||
|
* title officiel Minecraft. Fonctionne sans app Azure. (On utilise "sisu" et
|
||||||
|
* pas "live" : le flow "live" fait un appel title-token séparé que Microsoft
|
||||||
|
* refuse désormais avec un 403 ; "sisu" combine device+title+user en un seul
|
||||||
|
* appel et passe.)
|
||||||
|
* - Azure configuré : flow "msal" avec TON clientId (le mode "propre" pour un
|
||||||
|
* launcher tiers, une fois l'app approuvée pour l'API Minecraft).
|
||||||
|
*
|
||||||
|
* Dans les deux cas on utilise le flow "device code" : on ouvre la page de
|
||||||
|
* vérification Microsoft et on affiche le code à l'utilisateur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Identifiant de compte local pour le cache (un seul compte par machine ici). */
|
||||||
|
const ACCOUNT_KEY = 'player'
|
||||||
|
|
||||||
|
/** Résultat complet du login, conservé en mémoire pour le lancement du jeu. */
|
||||||
|
export interface AuthResult {
|
||||||
|
profile: PlayerProfile
|
||||||
|
/** Token d'accès Minecraft (à passer à launch). */
|
||||||
|
accessToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: AuthResult | null = null
|
||||||
|
|
||||||
|
function buildAuthflow(interactive: boolean): Authflow {
|
||||||
|
const hasAzureApp = !!config.azureClientId && config.azureClientId !== 'CHANGE_ME'
|
||||||
|
|
||||||
|
const options: MicrosoftAuthFlowOptions = hasAzureApp
|
||||||
|
? { flow: 'msal', authTitle: config.azureClientId as unknown as Titles, deviceType: 'Win32' }
|
||||||
|
: { flow: 'sisu', authTitle: Titles.MinecraftJava, deviceType: 'Win32' }
|
||||||
|
|
||||||
|
// codeCallback : appelé par prismarine-auth quand une connexion interactive
|
||||||
|
// (device code) est nécessaire.
|
||||||
|
// - Mode interactif : on pousse le code dans l'UI du launcher (+ ouverture
|
||||||
|
// de la page Microsoft dans le navigateur).
|
||||||
|
// - Mode silencieux (restauration) : on LÈVE une exception immédiatement.
|
||||||
|
// Ça coupe le flow AVANT le console.info et AVANT le polling de prismarine,
|
||||||
|
// donc aucun code ne fuite dans la console au démarrage : on retombe juste
|
||||||
|
// sur "pas de session" -> écran de login.
|
||||||
|
const codeCallback = (res: {
|
||||||
|
user_code: string
|
||||||
|
verification_uri: string
|
||||||
|
message: string
|
||||||
|
}): void => {
|
||||||
|
if (!interactive) {
|
||||||
|
throw new Error('interaction-required')
|
||||||
|
}
|
||||||
|
emit.authCode({
|
||||||
|
userCode: res.user_code,
|
||||||
|
verificationUri: res.verification_uri,
|
||||||
|
message: res.message
|
||||||
|
})
|
||||||
|
// Ouvre la page Microsoft dans le navigateur du système.
|
||||||
|
void shell.openExternal(res.verification_uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Authflow(ACCOUNT_KEY, paths.authCache, options, codeCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfile(p: { id: string; name: string; skins?: { url: string }[] }): PlayerProfile {
|
||||||
|
return {
|
||||||
|
uuid: p.id,
|
||||||
|
name: p.name,
|
||||||
|
skinUrl: p.skins?.[0]?.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login interactif (déclenche le device code si pas de session valide en cache). */
|
||||||
|
export async function login(): Promise<PlayerProfile> {
|
||||||
|
const flow = buildAuthflow(true)
|
||||||
|
const { token, profile } = await flow.getMinecraftJavaToken({ fetchProfile: true })
|
||||||
|
current = { profile: toProfile(profile), accessToken: token }
|
||||||
|
return current.profile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tente de restaurer la session depuis le cache SANS interaction.
|
||||||
|
* Retourne null si aucun token valide/rafraîchissable n'est disponible.
|
||||||
|
*
|
||||||
|
* Si le cache est vide, on ne tente même pas (évite de déclencher un device
|
||||||
|
* code). Sinon, le refresh silencieux est une requête réseau rapide ; on la
|
||||||
|
* met en course contre un timeout pour ne jamais bloquer si prismarine-auth
|
||||||
|
* décidait malgré tout de demander une interaction.
|
||||||
|
*/
|
||||||
|
export async function restoreSession(): Promise<PlayerProfile | null> {
|
||||||
|
try {
|
||||||
|
const { readdir } = await import('fs/promises')
|
||||||
|
const cached = await readdir(paths.authCache).catch(() => [])
|
||||||
|
if (cached.length === 0) return null
|
||||||
|
|
||||||
|
const flow = buildAuthflow(false)
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('restore-timeout')), 20_000)
|
||||||
|
)
|
||||||
|
const { token, profile } = await Promise.race([
|
||||||
|
flow.getMinecraftJavaToken({ fetchProfile: true }),
|
||||||
|
timeout
|
||||||
|
])
|
||||||
|
current = { profile: toProfile(profile), accessToken: token }
|
||||||
|
return current.profile
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renvoie la session courante (token + profil) ou null. */
|
||||||
|
export function getCurrent(): AuthResult | null {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déconnexion : efface le cache de tokens. */
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
current = null
|
||||||
|
const { rm } = await import('fs/promises')
|
||||||
|
await rm(paths.authCache, { recursive: true, force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createWriteStream } from 'fs'
|
||||||
|
import { mkdir, rm } from 'fs/promises'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
import { Readable, Transform } from 'stream'
|
||||||
|
import { pipeline } from 'stream/promises'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharge une URL vers un fichier, en suivant les redirections (fetch le
|
||||||
|
* fait par défaut). Appelle onProgress(0..1) si la taille est connue.
|
||||||
|
*
|
||||||
|
* La progression est mesurée via un Transform intercalé dans le pipeline (et
|
||||||
|
* NON via un listener 'data', qui basculerait le stream en mode flowing et
|
||||||
|
* pourrait perdre des chunks -> archive tronquée).
|
||||||
|
*/
|
||||||
|
export async function downloadFile(
|
||||||
|
url: string,
|
||||||
|
dest: string,
|
||||||
|
onProgress?: (fraction: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(url, { redirect: 'follow' })
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
throw new Error(`Téléchargement échoué (${res.status}) : ${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(dirname(dest), { recursive: true })
|
||||||
|
|
||||||
|
const total = Number(res.headers.get('content-length') ?? 0)
|
||||||
|
let received = 0
|
||||||
|
|
||||||
|
const counter = new Transform({
|
||||||
|
transform(chunk: Buffer, _enc, cb): void {
|
||||||
|
received += chunk.length
|
||||||
|
if (total > 0 && onProgress) onProgress(Math.min(1, received / total))
|
||||||
|
cb(null, chunk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0])
|
||||||
|
try {
|
||||||
|
await pipeline(body, counter, createWriteStream(dest))
|
||||||
|
} catch (e) {
|
||||||
|
await rm(dest, { force: true })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Récupère un JSON depuis une URL (manifeste, pack.toml résolu, etc.). */
|
||||||
|
export async function fetchText(url: string): Promise<string> {
|
||||||
|
const res = await fetch(url, { redirect: 'follow' })
|
||||||
|
if (!res.ok) throw new Error(`Requête échouée (${res.status}) : ${url}`)
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { BrowserWindow } from 'electron'
|
||||||
|
import {
|
||||||
|
IPC_EVENT,
|
||||||
|
type ProgressEvent,
|
||||||
|
type GameLogLine,
|
||||||
|
type DeviceCodeInfo,
|
||||||
|
type UpdateStatus
|
||||||
|
} from '../shared/ipc'
|
||||||
|
|
||||||
|
/** Fenêtre principale, définie au démarrage (src/main/index.ts). */
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
export function setMainWindow(win: BrowserWindow): void {
|
||||||
|
mainWindow = win
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(channel: string, payload: unknown): void {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send(channel, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emit = {
|
||||||
|
progress: (e: ProgressEvent): void => send(IPC_EVENT.progress, e),
|
||||||
|
gameLog: (l: GameLogLine): void => send(IPC_EVENT.gameLog, l),
|
||||||
|
gameClosed: (code: number | null): void => send(IPC_EVENT.gameClosed, code),
|
||||||
|
authCode: (info: DeviceCodeInfo): void => send(IPC_EVENT.authCode, info),
|
||||||
|
updateStatus: (s: UpdateStatus): void => send(IPC_EVENT.updateStatus, s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { setMainWindow } from './events'
|
||||||
|
import { IPC, type UserSettings } from '../shared/ipc'
|
||||||
|
import { login, logout, restoreSession, getCurrent } from './auth'
|
||||||
|
import { play } from './play'
|
||||||
|
import { getSettings, setSettings } from './settings'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { initUpdater, quitAndInstallUpdate } from './updater'
|
||||||
|
|
||||||
|
function createWindow(): BrowserWindow {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 980,
|
||||||
|
height: 640,
|
||||||
|
minWidth: 820,
|
||||||
|
minHeight: 540,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
title: 'OFLauncher',
|
||||||
|
backgroundColor: '#0e1116',
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
win.on('ready-to-show', () => win.show())
|
||||||
|
|
||||||
|
// Liens externes -> navigateur système.
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
void shell.openExternal(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// En dev, electron-vite expose l'URL du serveur Vite ; en prod, on charge le HTML buildé.
|
||||||
|
if (process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
void win.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
void win.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return win
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpc(): void {
|
||||||
|
ipcMain.handle(IPC.authLogin, () => login())
|
||||||
|
ipcMain.handle(IPC.authLogout, () => logout())
|
||||||
|
ipcMain.handle(IPC.authGetProfile, async () => {
|
||||||
|
return getCurrent()?.profile ?? (await restoreSession())
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.play, () => play())
|
||||||
|
ipcMain.handle(IPC.settingsGet, () => getSettings())
|
||||||
|
ipcMain.handle(IPC.settingsSet, (_e, s: UserSettings) => setSettings(s))
|
||||||
|
ipcMain.handle(IPC.appVersion, () => app.getVersion())
|
||||||
|
ipcMain.handle(IPC.openInstanceDir, () => shell.openPath(paths.instanceDir))
|
||||||
|
ipcMain.handle(IPC.updateInstall, () => quitAndInstallUpdate())
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
registerIpc()
|
||||||
|
const win = createWindow()
|
||||||
|
setMainWindow(win)
|
||||||
|
initUpdater()
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
const w = createWindow()
|
||||||
|
setMainWindow(w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { getVersionList, install, installNeoForged } from '@xmcl/installer'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { emit } from './events'
|
||||||
|
import { downloadDispatcher, DOWNLOAD_CONCURRENCY, withRetries } from './net'
|
||||||
|
|
||||||
|
/** Options de téléchargement communes : dispatcher tolérant + concurrence bridée. */
|
||||||
|
const downloadOptions = {
|
||||||
|
dispatcher: downloadDispatcher,
|
||||||
|
assetsDownloadConcurrency: DOWNLOAD_CONCURRENCY,
|
||||||
|
librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installe le runtime Minecraft (vanilla 1.21.1) puis NeoForge dans
|
||||||
|
* paths.gameRoot. Les deux étapes sont idempotentes : si la version est déjà
|
||||||
|
* présente sur le disque, on ne refait pas le travail.
|
||||||
|
*
|
||||||
|
* Retourne l'id de version NeoForge à lancer (ex. "neoforge-21.1.73").
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** true si un dossier de version <id> avec son JSON existe déjà. */
|
||||||
|
function versionInstalled(id: string): boolean {
|
||||||
|
return existsSync(join(paths.gameRoot, 'versions', id, `${id}.json`))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installe Minecraft vanilla <version> (json + jar + assets + libraries). */
|
||||||
|
export async function installMinecraft(version: string): Promise<void> {
|
||||||
|
if (versionInstalled(version)) return
|
||||||
|
|
||||||
|
emit.progress({ phase: 'minecraft', message: `Minecraft ${version} : métadonnées…`, progress: undefined })
|
||||||
|
const list = await getVersionList()
|
||||||
|
const meta = list.versions.find((v) => v.id === version)
|
||||||
|
if (!meta) throw new Error(`Version Minecraft introuvable : ${version}`)
|
||||||
|
|
||||||
|
emit.progress({
|
||||||
|
phase: 'minecraft',
|
||||||
|
message: `Installation de Minecraft ${version} (assets + libs)…`,
|
||||||
|
progress: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// ~3700 assets : on réessaie l'install entière plusieurs fois. Chaque passe
|
||||||
|
// ignore les fichiers déjà valides et ne reprend que les manquants.
|
||||||
|
await withRetries(
|
||||||
|
() => install(meta, paths.gameRoot, downloadOptions),
|
||||||
|
5,
|
||||||
|
(attempt) =>
|
||||||
|
emit.progress({
|
||||||
|
phase: 'minecraft',
|
||||||
|
message: `Reprise des téléchargements (tentative ${attempt + 1})…`,
|
||||||
|
progress: undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installe NeoForge <neoforge> par-dessus Minecraft <minecraft>.
|
||||||
|
* Nécessite un Java (les "processors" de l'installeur tournent en Java).
|
||||||
|
*/
|
||||||
|
export async function installNeoForge(
|
||||||
|
neoforge: string,
|
||||||
|
minecraft: string,
|
||||||
|
javaPath: string
|
||||||
|
): Promise<string> {
|
||||||
|
// @xmcl nomme la version installée "neoforge-<version>" (ex. neoforge-21.1.224).
|
||||||
|
// Si elle est déjà présente, on saute l'install (idempotent, comme Minecraft).
|
||||||
|
const expectedId = `neoforge-${neoforge}`
|
||||||
|
if (versionInstalled(expectedId)) {
|
||||||
|
void minecraft
|
||||||
|
return expectedId
|
||||||
|
}
|
||||||
|
|
||||||
|
emit.progress({ phase: 'neoforge', message: `Installation de NeoForge ${neoforge}…`, progress: undefined })
|
||||||
|
|
||||||
|
// installNeoForged retourne l'id de version installée à lancer.
|
||||||
|
const versionId = await withRetries(
|
||||||
|
() =>
|
||||||
|
installNeoForged('neoforge', neoforge, paths.gameRoot, {
|
||||||
|
java: javaPath,
|
||||||
|
dispatcher: downloadDispatcher,
|
||||||
|
librariesDownloadConcurrency: DOWNLOAD_CONCURRENCY
|
||||||
|
}),
|
||||||
|
3,
|
||||||
|
(attempt) =>
|
||||||
|
emit.progress({
|
||||||
|
phase: 'neoforge',
|
||||||
|
message: `Reprise de l'installation NeoForge (tentative ${attempt + 1})…`,
|
||||||
|
progress: undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!versionInstalled(versionId)) {
|
||||||
|
throw new Error(`NeoForge installé mais version "${versionId}" introuvable sur le disque.`)
|
||||||
|
}
|
||||||
|
void minecraft // (info de contexte ; la version NeoForge hérite déjà de Minecraft)
|
||||||
|
return versionId
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { join, basename } from 'path'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { readdir, mkdir, rm, writeFile, readFile } from 'fs/promises'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { downloadFile } from './download'
|
||||||
|
import { emit } from './events'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fournit un binaire Java 21+ (requis par ATM10 / NeoForge 1.21.1).
|
||||||
|
*
|
||||||
|
* Stratégie, du moins coûteux au plus coûteux :
|
||||||
|
* 1. Java déjà mémorisé dans java/managed.json (provisionné ou détecté avant).
|
||||||
|
* 2. Java >= 21 déjà présent sur la machine (JAVA_HOME, puis PATH).
|
||||||
|
* 3. Sinon : téléchargement d'un JRE Temurin (Adoptium) — archive autonome,
|
||||||
|
* extraite avec l'outil système (`tar` sur Linux/mac, `Expand-Archive`
|
||||||
|
* PowerShell sur Windows).
|
||||||
|
*
|
||||||
|
* Le chemin retenu est mémorisé pour les lancements suivants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const JAVA_MAJOR = 21
|
||||||
|
const MARKER = (): string => join(paths.javaDir, 'managed.json')
|
||||||
|
|
||||||
|
/** Exécute `<javaPath> -version` et renvoie la version majeure (ex. 21), ou null. */
|
||||||
|
async function javaMajorVersion(javaPath: string): Promise<number | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let out = ''
|
||||||
|
const child = spawn(javaPath, ['-version'])
|
||||||
|
child.stdout?.on('data', (d: Buffer) => (out += d.toString()))
|
||||||
|
child.stderr?.on('data', (d: Buffer) => (out += d.toString()))
|
||||||
|
child.on('error', () => resolve(null))
|
||||||
|
child.on('close', () => {
|
||||||
|
// ex: 'openjdk version "21.0.2"' ou '... "1.8.0_xyz"'
|
||||||
|
const m = out.match(/version "(\d+)(?:\.(\d+))?/)
|
||||||
|
if (!m) return resolve(null)
|
||||||
|
let major = parseInt(m[1], 10)
|
||||||
|
if (major === 1 && m[2]) major = parseInt(m[2], 10) // 1.8 -> 8
|
||||||
|
resolve(Number.isNaN(major) ? null : major)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résout un exécutable via `where` (Windows) / `which` (unix). */
|
||||||
|
async function resolveOnPath(bin: string): Promise<string | null> {
|
||||||
|
const cmd = process.platform === 'win32' ? 'where' : 'which'
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let out = ''
|
||||||
|
const child = spawn(cmd, [bin])
|
||||||
|
child.stdout?.on('data', (d: Buffer) => (out += d.toString()))
|
||||||
|
child.on('error', () => resolve(null))
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code !== 0) return resolve(null)
|
||||||
|
const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean)
|
||||||
|
resolve(first ?? null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cherche un Java >= JAVA_MAJOR déjà installé sur la machine.
|
||||||
|
* Retourne le chemin du binaire ou null.
|
||||||
|
*/
|
||||||
|
async function findSystemJava(): Promise<string | null> {
|
||||||
|
const javaBin = detectPlatform().javaBin
|
||||||
|
const candidates: string[] = []
|
||||||
|
|
||||||
|
if (process.env.JAVA_HOME) {
|
||||||
|
candidates.push(join(process.env.JAVA_HOME, 'bin', javaBin))
|
||||||
|
}
|
||||||
|
const onPath = await resolveOnPath(javaBin)
|
||||||
|
if (onPath) candidates.push(onPath)
|
||||||
|
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (!existsSync(c)) continue
|
||||||
|
const major = await javaMajorVersion(c)
|
||||||
|
if (major !== null && major >= JAVA_MAJOR) return c
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Platform {
|
||||||
|
os: 'windows' | 'linux' | 'mac'
|
||||||
|
arch: 'x64' | 'aarch64'
|
||||||
|
archiveExt: 'zip' | 'tar.gz'
|
||||||
|
javaBin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPlatform(): Platform {
|
||||||
|
const os =
|
||||||
|
process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'mac' : 'linux'
|
||||||
|
const arch = process.arch === 'arm64' ? 'aarch64' : 'x64'
|
||||||
|
return {
|
||||||
|
os,
|
||||||
|
arch,
|
||||||
|
archiveExt: os === 'windows' ? 'zip' : 'tar.gz',
|
||||||
|
javaBin: os === 'windows' ? 'java.exe' : 'java'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cherche récursivement bin/<javaBin> sous un dossier extrait. */
|
||||||
|
async function findJavaBinary(root: string, javaBin: string): Promise<string | null> {
|
||||||
|
const stack = [root]
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = join(dir, e.name)
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
} else if (e.name === javaBin && basename(dir) === 'bin') {
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractArchive(archive: string, destDir: string, ext: string): Promise<void> {
|
||||||
|
await mkdir(destDir, { recursive: true })
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child =
|
||||||
|
ext === 'zip'
|
||||||
|
? spawn(
|
||||||
|
'powershell',
|
||||||
|
[
|
||||||
|
'-NoProfile',
|
||||||
|
'-Command',
|
||||||
|
`Expand-Archive -Path "${archive}" -DestinationPath "${destDir}" -Force`
|
||||||
|
],
|
||||||
|
{ stdio: 'ignore' }
|
||||||
|
)
|
||||||
|
: spawn('tar', ['-xzf', archive, '-C', destDir], { stdio: 'ignore' })
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) =>
|
||||||
|
code === 0 ? resolve() : reject(new Error(`Extraction échouée (code ${code})`))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le chemin du binaire java géré, en l'installant si nécessaire.
|
||||||
|
* Idempotent : si déjà installé, retourne immédiatement le chemin mémorisé.
|
||||||
|
*/
|
||||||
|
export async function ensureJava(): Promise<string> {
|
||||||
|
// 1) Déjà mémorisé (provisionné OU détecté lors d'un lancement précédent) ?
|
||||||
|
if (existsSync(MARKER())) {
|
||||||
|
try {
|
||||||
|
const { javaPath } = JSON.parse(await readFile(MARKER(), 'utf-8')) as { javaPath: string }
|
||||||
|
if (javaPath && existsSync(javaPath)) return javaPath
|
||||||
|
} catch {
|
||||||
|
/* marqueur corrompu : on réinstalle */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Java >= 21 déjà présent sur la machine ? On l'utilise tel quel.
|
||||||
|
emit.progress({ phase: 'java', message: 'Recherche de Java sur la machine…', progress: undefined })
|
||||||
|
const system = await findSystemJava()
|
||||||
|
if (system) {
|
||||||
|
await rememberJava(system, 'system')
|
||||||
|
emit.progress({ phase: 'java', message: 'Java 21 détecté sur la machine.', progress: 1 })
|
||||||
|
return system
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Sinon : on télécharge un JRE Temurin 21.
|
||||||
|
const plat = detectPlatform()
|
||||||
|
emit.progress({ phase: 'java', message: `Téléchargement de Java ${JAVA_MAJOR} (Temurin)…`, progress: 0 })
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://api.adoptium.net/v3/binary/latest/${JAVA_MAJOR}/ga/` +
|
||||||
|
`${plat.os}/${plat.arch}/jre/hotspot/normal/eclipse`
|
||||||
|
|
||||||
|
const installRoot = join(paths.javaDir, `temurin-${JAVA_MAJOR}`)
|
||||||
|
await rm(installRoot, { recursive: true, force: true })
|
||||||
|
const archive = join(paths.javaDir, `temurin-${JAVA_MAJOR}.${plat.archiveExt}`)
|
||||||
|
|
||||||
|
await downloadFile(url, archive, (f) =>
|
||||||
|
emit.progress({ phase: 'java', message: `Téléchargement de Java ${JAVA_MAJOR}…`, progress: f })
|
||||||
|
)
|
||||||
|
|
||||||
|
emit.progress({ phase: 'java', message: 'Extraction de Java…', progress: undefined })
|
||||||
|
await extractArchive(archive, installRoot, plat.archiveExt)
|
||||||
|
await rm(archive, { force: true })
|
||||||
|
|
||||||
|
const javaPath = await findJavaBinary(installRoot, plat.javaBin)
|
||||||
|
if (!javaPath) {
|
||||||
|
const listing = await readdir(installRoot).catch(() => [] as string[])
|
||||||
|
throw new Error(
|
||||||
|
`Binaire java introuvable après extraction. Contenu de ${installRoot} : ` +
|
||||||
|
(listing.length ? listing.join(', ') : '(vide)')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie que le java extrait fonctionne et est bien >= 21.
|
||||||
|
const major = await javaMajorVersion(javaPath)
|
||||||
|
if (major === null || major < JAVA_MAJOR) {
|
||||||
|
throw new Error(`Java extrait inutilisable (version détectée : ${major ?? 'inconnue'}).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await rememberJava(javaPath, 'temurin')
|
||||||
|
emit.progress({ phase: 'java', message: 'Java prêt.', progress: 1 })
|
||||||
|
return javaPath
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mémorise le chemin du java retenu pour les prochains lancements. */
|
||||||
|
async function rememberJava(javaPath: string, source: 'system' | 'temurin'): Promise<void> {
|
||||||
|
await mkdir(paths.javaDir, { recursive: true })
|
||||||
|
await writeFile(MARKER(), JSON.stringify({ javaPath, source, min: JAVA_MAJOR }, null, 2))
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { ChildProcess } from 'child_process'
|
||||||
|
import { launch } from '@xmcl/core'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { config } from '../shared/config'
|
||||||
|
import { emit } from './events'
|
||||||
|
import type { UserSettings } from '../shared/ipc'
|
||||||
|
import type { AuthResult } from './auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance Minecraft (version NeoForge installée) en process enfant.
|
||||||
|
*
|
||||||
|
* Séparation runtime / instance :
|
||||||
|
* - resourcePath = paths.gameRoot -> versions/, libraries/, assets/ (jamais touché par packwiz)
|
||||||
|
* - gamePath = paths.instanceDir -> mods/, config/, saves/ (synchronisé par packwiz)
|
||||||
|
*/
|
||||||
|
export async function launchGame(
|
||||||
|
versionId: string,
|
||||||
|
auth: AuthResult,
|
||||||
|
javaPath: string,
|
||||||
|
settings: UserSettings
|
||||||
|
): Promise<ChildProcess> {
|
||||||
|
emit.progress({ phase: 'launching', message: 'Démarrage de Minecraft…', progress: undefined })
|
||||||
|
|
||||||
|
const serverArg = parseServer(config.serverAddress)
|
||||||
|
|
||||||
|
const proc = await launch({
|
||||||
|
gamePath: paths.instanceDir,
|
||||||
|
resourcePath: paths.gameRoot,
|
||||||
|
javaPath,
|
||||||
|
version: versionId,
|
||||||
|
minMemory: Math.min(2048, settings.maxMemoryMb),
|
||||||
|
maxMemory: settings.maxMemoryMb,
|
||||||
|
gameProfile: { id: auth.profile.uuid, name: auth.profile.name },
|
||||||
|
accessToken: auth.accessToken,
|
||||||
|
// MC moderne attend user_type = "msa" (le typage @xmcl est plus restrictif).
|
||||||
|
userType: 'msa' as unknown as 'mojang',
|
||||||
|
extraJVMArgs: settings.extraJvmArgs,
|
||||||
|
...(serverArg ? { quickPlayMultiplayer: serverArg } : {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Streame les logs du jeu vers la console du renderer.
|
||||||
|
proc.stdout?.on('data', (b: Buffer) =>
|
||||||
|
emit.gameLog({ stream: 'stdout', line: b.toString() })
|
||||||
|
)
|
||||||
|
proc.stderr?.on('data', (b: Buffer) =>
|
||||||
|
emit.gameLog({ stream: 'stderr', line: b.toString() })
|
||||||
|
)
|
||||||
|
|
||||||
|
proc.on('spawn', () => emit.progress({ phase: 'running', message: 'En jeu.', progress: 1 }))
|
||||||
|
proc.on('close', (code) => emit.gameClosed(code))
|
||||||
|
|
||||||
|
return proc
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "host:port" ou "host" -> chaîne quickPlayMultiplayer ; undefined si vide. */
|
||||||
|
function parseServer(addr?: string): string | undefined {
|
||||||
|
if (!addr || !addr.trim()) return undefined
|
||||||
|
return addr.trim()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { parse as parseToml } from 'smol-toml'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { config } from '../shared/config'
|
||||||
|
import { fetchText } from './download'
|
||||||
|
import { emit } from './events'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestion du modpack côté launcher :
|
||||||
|
* - lecture du pack.toml packwiz distant (versions MC/NeoForge = source de vérité)
|
||||||
|
* - sync incrémentale des mods/configs via packwiz-installer-bootstrap.
|
||||||
|
*
|
||||||
|
* packwiz-installer garde un manifeste local (packwiz.json dans l'instance) et
|
||||||
|
* ne re-télécharge QUE les fichiers dont le hash a changé — c'est exactement le
|
||||||
|
* comportement "pas de re-download complet à chaque update" recherché.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PackMeta {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
minecraft: string
|
||||||
|
neoforge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Télécharge et parse le pack.toml distant. */
|
||||||
|
export async function fetchPackMeta(): Promise<PackMeta> {
|
||||||
|
emit.progress({ phase: 'pack-meta', message: 'Lecture du modpack…', progress: undefined })
|
||||||
|
|
||||||
|
if (!config.packTomlUrl || config.packTomlUrl.includes('CHANGE_ME')) {
|
||||||
|
throw new Error(
|
||||||
|
'URL du pack.toml non configurée. Renseigne packTomlUrl dans src/shared/config.ts.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await fetchText(config.packTomlUrl)
|
||||||
|
const data = parseToml(raw) as {
|
||||||
|
name?: string
|
||||||
|
version?: string
|
||||||
|
versions?: { minecraft?: string; neoforge?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const minecraft = data.versions?.minecraft
|
||||||
|
const neoforge = data.versions?.neoforge
|
||||||
|
if (!minecraft || !neoforge) {
|
||||||
|
throw new Error(
|
||||||
|
'pack.toml invalide : [versions] doit contenir "minecraft" et "neoforge".'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: data.name ?? 'Modpack',
|
||||||
|
version: data.version ?? '0',
|
||||||
|
minecraft,
|
||||||
|
neoforge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance packwiz-installer-bootstrap pour synchroniser l'instance.
|
||||||
|
* `java` = chemin du binaire Java 21 géré (réutilisé pour faire tourner le jar).
|
||||||
|
*/
|
||||||
|
export async function syncModpack(javaPath: string): Promise<void> {
|
||||||
|
emit.progress({ phase: 'modpack', message: 'Synchronisation du modpack…', progress: undefined })
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
javaPath,
|
||||||
|
['-jar', paths.packwizBootstrapJar, '-g', '-s', 'client', config.packTomlUrl],
|
||||||
|
{ cwd: paths.instanceDir }
|
||||||
|
)
|
||||||
|
|
||||||
|
// On garde les dernières lignes de sortie pour pouvoir afficher la VRAIE
|
||||||
|
// erreur de packwiz si le process échoue.
|
||||||
|
const recent: string[] = []
|
||||||
|
const onLine = (buf: Buffer): void => {
|
||||||
|
for (const line of buf.toString().split(/\r?\n/)) {
|
||||||
|
const text = line.trim()
|
||||||
|
if (!text) continue
|
||||||
|
recent.push(text)
|
||||||
|
if (recent.length > 25) recent.shift()
|
||||||
|
emit.progress({ phase: 'modpack', message: text, progress: undefined })
|
||||||
|
// Visible aussi dans la console du launcher.
|
||||||
|
emit.gameLog({ stream: 'stdout', line: `[packwiz] ${text}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.stdout.on('data', onLine)
|
||||||
|
child.stderr.on('data', onLine)
|
||||||
|
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) return resolve()
|
||||||
|
const tail = recent.slice(-12).join('\n')
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`packwiz-installer a échoué (code ${code}).` +
|
||||||
|
(tail ? `\nDernières lignes :\n${tail}` : '')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
emit.progress({ phase: 'modpack', message: 'Modpack à jour.', progress: 1 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Agent, interceptors, type Dispatcher } from 'undici'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatcher undici partagé pour TOUS les téléchargements d'install (@xmcl).
|
||||||
|
*
|
||||||
|
* Pourquoi : l'install de Minecraft 1.21 récupère ~3700 petits fichiers d'assets
|
||||||
|
* depuis resources.download.minecraft.net. Avec la concurrence par défaut, sur
|
||||||
|
* une connexion grand public, beaucoup de connexions dépassent le timeout de
|
||||||
|
* connexion d'undici (10 s) -> UND_ERR_CONNECT_TIMEOUT -> fichiers vides ->
|
||||||
|
* erreurs de checksum.
|
||||||
|
*
|
||||||
|
* On limite donc le nombre de connexions simultanées et on allonge les timeouts.
|
||||||
|
*
|
||||||
|
* On compose l'intercepteur `redirect` d'undici : c'est ce que fait le
|
||||||
|
* dispatcher par défaut de @xmcl. Il suit les redirections (l'installeur NeoForge
|
||||||
|
* sur maven.neoforged.net redirige vers un CDN) ET remplit `context.history`,
|
||||||
|
* que @xmcl/file-transfer lit (sans lui, `context` est undefined -> crash
|
||||||
|
* "Cannot use 'in' operator to search for 'history' in undefined").
|
||||||
|
*
|
||||||
|
* NB : on n'utilise PAS le RetryAgent d'undici. Il refait des requêtes Range qui
|
||||||
|
* entrent en conflit avec le téléchargement reprenable (par ranges) de
|
||||||
|
* @xmcl/file-transfer -> "content-range mismatch" + jars corrompus. Les reprises
|
||||||
|
* sont gérées au niveau au-dessus par withRetries(), qui relance l'install (les
|
||||||
|
* fichiers déjà valides sont ignorés).
|
||||||
|
*/
|
||||||
|
export const downloadDispatcher: Dispatcher = new Agent({
|
||||||
|
connections: 16, // sockets simultanés par origine (bride la concurrence réelle)
|
||||||
|
connect: { timeout: 60_000 }, // timeout de connexion (la cause du UND_ERR_CONNECT_TIMEOUT)
|
||||||
|
headersTimeout: 60_000,
|
||||||
|
bodyTimeout: 120_000
|
||||||
|
}).compose(interceptors.redirect({ maxRedirections: 5 }))
|
||||||
|
|
||||||
|
/** Concurrence de téléchargement des assets/libraries (volontairement modérée). */
|
||||||
|
export const DOWNLOAD_CONCURRENCY = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réessaie une opération asynchrone. Pour les installs @xmcl, un nouvel appel
|
||||||
|
* reprend là où ça s'est arrêté : les fichiers déjà valides sont ignorés, seuls
|
||||||
|
* les manquants/invalides sont re-téléchargés. Donc quelques passes suffisent à
|
||||||
|
* absorber les pertes réseau sur les milliers de petits assets.
|
||||||
|
*/
|
||||||
|
export async function withRetries<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
attempts: number,
|
||||||
|
onRetry?: (attempt: number, err: unknown) => void
|
||||||
|
): Promise<T> {
|
||||||
|
let lastErr: unknown
|
||||||
|
for (let i = 1; i <= attempts; i++) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err
|
||||||
|
if (i < attempts) onRetry?.(i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arborescence des données du launcher, rangée sous le userData d'Electron :
|
||||||
|
* Windows : %APPDATA%/OFLauncher
|
||||||
|
* Linux : ~/.config/OFLauncher
|
||||||
|
*
|
||||||
|
* On garde le runtime (MC/NeoForge/assets/libs/java) séparé de l'instance de
|
||||||
|
* jeu (mods/config/saves) pour que la sync packwiz ne touche jamais au runtime.
|
||||||
|
*/
|
||||||
|
class LauncherPaths {
|
||||||
|
/** Racine : userData d'Electron. */
|
||||||
|
get root(): string {
|
||||||
|
return app.getPath('userData')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dossier "Minecraft" géré par @xmcl : versions/, libraries/, assets/. */
|
||||||
|
get gameRoot(): string {
|
||||||
|
return this.ensure(join(this.root, 'minecraft'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dossier d'instance du modpack : mods/, config/, saves/ (cible packwiz). */
|
||||||
|
get instanceDir(): string {
|
||||||
|
return this.ensure(join(this.root, 'instance'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtimes Java téléchargés (un sous-dossier par composant Mojang). */
|
||||||
|
get javaDir(): string {
|
||||||
|
return this.ensure(join(this.root, 'java'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache des tokens d'auth (prismarine-auth). */
|
||||||
|
get authCache(): string {
|
||||||
|
return this.ensure(join(this.root, 'auth-cache'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fichier de réglages utilisateur. */
|
||||||
|
get settingsFile(): string {
|
||||||
|
return join(this.root, 'settings.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** jar packwiz-installer-bootstrap embarqué dans les resources. */
|
||||||
|
get packwizBootstrapJar(): string {
|
||||||
|
// En prod, electron-builder copie resources/ via extraResources.
|
||||||
|
// En dev, on lit directement le dossier resources/ du repo.
|
||||||
|
const base = app.isPackaged
|
||||||
|
? join(process.resourcesPath, 'resources')
|
||||||
|
: join(app.getAppPath(), 'resources')
|
||||||
|
return join(base, 'packwiz-installer-bootstrap.jar')
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensure(dir: string): string {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paths = new LauncherPaths()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ChildProcess } from 'child_process'
|
||||||
|
import { getCurrent } from './auth'
|
||||||
|
import { fetchPackMeta } from './modpack'
|
||||||
|
import { ensureJava } from './java'
|
||||||
|
import { installMinecraft, installNeoForge } from './install'
|
||||||
|
import { syncModpack } from './modpack'
|
||||||
|
import { launchGame } from './launch'
|
||||||
|
import { getSettings } from './settings'
|
||||||
|
import { emit } from './events'
|
||||||
|
|
||||||
|
/** Process de jeu courant (un seul à la fois). */
|
||||||
|
let gameProcess: ChildProcess | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Séquence complète "Jouer" :
|
||||||
|
* auth (déjà fait) -> pack.toml -> Java 21 -> Minecraft -> NeoForge ->
|
||||||
|
* sync modpack (delta) -> lancement.
|
||||||
|
*
|
||||||
|
* Chaque étape émet sa progression vers le renderer. Les étapes d'install sont
|
||||||
|
* idempotentes, donc à partir du 2e lancement seules les nouveautés du modpack
|
||||||
|
* sont téléchargées.
|
||||||
|
*/
|
||||||
|
export async function play(): Promise<void> {
|
||||||
|
if (gameProcess) {
|
||||||
|
throw new Error('Le jeu est déjà en cours.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = getCurrent()
|
||||||
|
if (!auth) {
|
||||||
|
throw new Error('Non connecté. Connecte-toi avec ton compte Microsoft d’abord.')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const meta = await fetchPackMeta()
|
||||||
|
const javaPath = await ensureJava()
|
||||||
|
await installMinecraft(meta.minecraft)
|
||||||
|
const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath)
|
||||||
|
await syncModpack(javaPath)
|
||||||
|
|
||||||
|
const settings = await getSettings()
|
||||||
|
const proc = await launchGame(versionId, auth, javaPath, settings)
|
||||||
|
gameProcess = proc
|
||||||
|
proc.on('close', () => {
|
||||||
|
gameProcess = null
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
emit.progress({ phase: 'error', message: (e as Error).message, progress: undefined })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { readFile, writeFile } from 'fs/promises'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { DEFAULT_SETTINGS, type UserSettings } from '../shared/ipc'
|
||||||
|
|
||||||
|
/** Lecture des réglages persistés (fusionnés avec les valeurs par défaut). */
|
||||||
|
export async function getSettings(): Promise<UserSettings> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(paths.settingsFile, 'utf-8')
|
||||||
|
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<UserSettings>) }
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Écriture des réglages (validés/clampés). */
|
||||||
|
export async function setSettings(next: UserSettings): Promise<UserSettings> {
|
||||||
|
const clean: UserSettings = {
|
||||||
|
maxMemoryMb: Math.max(2048, Math.min(32768, Math.round(next.maxMemoryMb || DEFAULT_SETTINGS.maxMemoryMb))),
|
||||||
|
extraJvmArgs: Array.isArray(next.extraJvmArgs) ? next.extraJvmArgs : []
|
||||||
|
}
|
||||||
|
await writeFile(paths.settingsFile, JSON.stringify(clean, null, 2))
|
||||||
|
return clean
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import electronUpdater, { type UpdateInfo, type ProgressInfo } from 'electron-updater'
|
||||||
|
import { emit } from './events'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-update du launcher via electron-updater (provider "generic" pointant sur
|
||||||
|
* une release Gitea à tag fixe, voir electron-builder.yml).
|
||||||
|
*
|
||||||
|
* UX : check au démarrage -> téléchargement en fond -> on relaie l'état vers le
|
||||||
|
* renderer (bandeau). L'install effective se fait quand l'utilisateur clique
|
||||||
|
* "Redémarrer pour installer" (quitAndInstallUpdate), ou à la fermeture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// electron-updater est CommonJS : on récupère autoUpdater via le default export.
|
||||||
|
const { autoUpdater } = electronUpdater
|
||||||
|
|
||||||
|
/** Re-check périodique (6 h) tant que le launcher reste ouvert. */
|
||||||
|
const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export function initUpdater(): void {
|
||||||
|
// L'updater ne fonctionne que sur une app packagée (sauf dev-app-update.yml).
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
console.info('[updater] dev non packagé : auto-update désactivé.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// On gère l'install manuellement (bouton "Redémarrer"), mais on installe
|
||||||
|
// quand même à la fermeture si la maj a été téléchargée.
|
||||||
|
autoUpdater.autoDownload = true
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
|
||||||
|
autoUpdater.on('checking-for-update', () => {
|
||||||
|
emit.updateStatus({ state: 'checking' })
|
||||||
|
})
|
||||||
|
autoUpdater.on('update-available', (info: UpdateInfo) => {
|
||||||
|
emit.updateStatus({ state: 'available', version: info.version })
|
||||||
|
})
|
||||||
|
autoUpdater.on('update-not-available', () => {
|
||||||
|
emit.updateStatus({ state: 'none' })
|
||||||
|
})
|
||||||
|
autoUpdater.on('download-progress', (p: ProgressInfo) => {
|
||||||
|
emit.updateStatus({ state: 'downloading', progress: p.percent / 100 })
|
||||||
|
})
|
||||||
|
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
|
||||||
|
emit.updateStatus({ state: 'downloaded', version: info.version })
|
||||||
|
})
|
||||||
|
autoUpdater.on('error', (err: Error) => {
|
||||||
|
console.error('[updater]', err)
|
||||||
|
emit.updateStatus({ state: 'error', message: err.message })
|
||||||
|
})
|
||||||
|
|
||||||
|
void autoUpdater.checkForUpdates()
|
||||||
|
setInterval(() => void autoUpdater.checkForUpdates(), RECHECK_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quitte et installe la mise à jour téléchargée (appelé par le bouton UI). */
|
||||||
|
export function quitAndInstallUpdate(): void {
|
||||||
|
autoUpdater.quitAndInstall()
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import type { LauncherApi } from './index'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
api: LauncherApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import {
|
||||||
|
IPC,
|
||||||
|
IPC_EVENT,
|
||||||
|
type PlayerProfile,
|
||||||
|
type ProgressEvent,
|
||||||
|
type GameLogLine,
|
||||||
|
type UserSettings,
|
||||||
|
type DeviceCodeInfo,
|
||||||
|
type UpdateStatus
|
||||||
|
} from '../shared/ipc'
|
||||||
|
|
||||||
|
/** API typée exposée au renderer via window.api. */
|
||||||
|
const api = {
|
||||||
|
// --- Auth ---
|
||||||
|
login: (): Promise<PlayerProfile> => ipcRenderer.invoke(IPC.authLogin),
|
||||||
|
logout: (): Promise<void> => ipcRenderer.invoke(IPC.authLogout),
|
||||||
|
getProfile: (): Promise<PlayerProfile | null> => ipcRenderer.invoke(IPC.authGetProfile),
|
||||||
|
|
||||||
|
// --- Jouer (install + sync + launch) ---
|
||||||
|
play: (): Promise<void> => ipcRenderer.invoke(IPC.play),
|
||||||
|
|
||||||
|
// --- Réglages ---
|
||||||
|
getSettings: (): Promise<UserSettings> => ipcRenderer.invoke(IPC.settingsGet),
|
||||||
|
setSettings: (s: UserSettings): Promise<UserSettings> => ipcRenderer.invoke(IPC.settingsSet, s),
|
||||||
|
|
||||||
|
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.appVersion),
|
||||||
|
|
||||||
|
/** Ouvre le dossier d'instance (mods/config/saves) dans l'explorateur. */
|
||||||
|
openInstanceDir: (): Promise<string> => ipcRenderer.invoke(IPC.openInstanceDir),
|
||||||
|
|
||||||
|
/** Quitte et installe la mise à jour téléchargée. */
|
||||||
|
installUpdate: (): Promise<void> => ipcRenderer.invoke(IPC.updateInstall),
|
||||||
|
|
||||||
|
// --- Abonnements aux événements (retournent une fonction de désabonnement) ---
|
||||||
|
onProgress: (cb: (e: ProgressEvent) => void): (() => void) => {
|
||||||
|
const handler = (_: unknown, e: ProgressEvent): void => cb(e)
|
||||||
|
ipcRenderer.on(IPC_EVENT.progress, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC_EVENT.progress, handler)
|
||||||
|
},
|
||||||
|
onGameLog: (cb: (l: GameLogLine) => void): (() => void) => {
|
||||||
|
const handler = (_: unknown, l: GameLogLine): void => cb(l)
|
||||||
|
ipcRenderer.on(IPC_EVENT.gameLog, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC_EVENT.gameLog, handler)
|
||||||
|
},
|
||||||
|
onGameClosed: (cb: (code: number | null) => void): (() => void) => {
|
||||||
|
const handler = (_: unknown, code: number | null): void => cb(code)
|
||||||
|
ipcRenderer.on(IPC_EVENT.gameClosed, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC_EVENT.gameClosed, handler)
|
||||||
|
},
|
||||||
|
onAuthCode: (cb: (info: DeviceCodeInfo) => void): (() => void) => {
|
||||||
|
const handler = (_: unknown, info: DeviceCodeInfo): void => cb(info)
|
||||||
|
ipcRenderer.on(IPC_EVENT.authCode, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC_EVENT.authCode, handler)
|
||||||
|
},
|
||||||
|
onUpdateStatus: (cb: (s: UpdateStatus) => void): (() => void) => {
|
||||||
|
const handler = (_: unknown, s: UpdateStatus): void => cb(s)
|
||||||
|
ipcRenderer.on(IPC_EVENT.updateStatus, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC_EVENT.updateStatus, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LauncherApi = typeof api
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'"
|
||||||
|
/>
|
||||||
|
<title>OFLauncher</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type {
|
||||||
|
PlayerProfile,
|
||||||
|
ProgressEvent,
|
||||||
|
GameLogLine,
|
||||||
|
DeviceCodeInfo,
|
||||||
|
UpdateStatus
|
||||||
|
} from '../../shared/ipc'
|
||||||
|
|
||||||
|
type Status = 'loading' | 'logged-out' | 'logged-in' | 'working' | 'running'
|
||||||
|
|
||||||
|
export default function App(): JSX.Element {
|
||||||
|
const [status, setStatus] = useState<Status>('loading')
|
||||||
|
const [profile, setProfile] = useState<PlayerProfile | null>(null)
|
||||||
|
const [progress, setProgress] = useState<ProgressEvent | null>(null)
|
||||||
|
const [logs, setLogs] = useState<GameLogLine[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [authCode, setAuthCode] = useState<DeviceCodeInfo | null>(null)
|
||||||
|
const [maxMemoryMb, setMaxMemoryMb] = useState(8192)
|
||||||
|
const [appVersion, setAppVersion] = useState('')
|
||||||
|
const [update, setUpdate] = useState<UpdateStatus | null>(null)
|
||||||
|
const consoleRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Restaure la session + réglages au démarrage.
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
const [p, s, v] = await Promise.all([
|
||||||
|
window.api.getProfile(),
|
||||||
|
window.api.getSettings(),
|
||||||
|
window.api.getAppVersion()
|
||||||
|
])
|
||||||
|
setMaxMemoryMb(s.maxMemoryMb)
|
||||||
|
setAppVersion(v)
|
||||||
|
setProfile(p)
|
||||||
|
setStatus(p ? 'logged-in' : 'logged-out')
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Abonnements aux événements main -> renderer.
|
||||||
|
useEffect(() => {
|
||||||
|
const offProgress = window.api.onProgress((e) => {
|
||||||
|
setProgress(e)
|
||||||
|
if (e.phase === 'running') setStatus('running')
|
||||||
|
if (e.phase === 'error') {
|
||||||
|
setError(e.message)
|
||||||
|
setStatus(profile ? 'logged-in' : 'logged-out')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const offLog = window.api.onGameLog((l) =>
|
||||||
|
setLogs((prev) => [...prev.slice(-800), l])
|
||||||
|
)
|
||||||
|
const offClosed = window.api.onGameClosed((code) => {
|
||||||
|
setStatus('logged-in')
|
||||||
|
setProgress(null)
|
||||||
|
setLogs((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ stream: 'stdout', line: `\n— Jeu fermé (code ${code ?? '?'}) —` }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
const offAuthCode = window.api.onAuthCode((info) => setAuthCode(info))
|
||||||
|
const offUpdate = window.api.onUpdateStatus((s) => setUpdate(s))
|
||||||
|
return () => {
|
||||||
|
offProgress()
|
||||||
|
offLog()
|
||||||
|
offClosed()
|
||||||
|
offAuthCode()
|
||||||
|
offUpdate()
|
||||||
|
}
|
||||||
|
}, [profile])
|
||||||
|
|
||||||
|
// Auto-scroll de la console.
|
||||||
|
useEffect(() => {
|
||||||
|
consoleRef.current?.scrollTo(0, consoleRef.current.scrollHeight)
|
||||||
|
}, [logs])
|
||||||
|
|
||||||
|
async function handleLogin(): Promise<void> {
|
||||||
|
setError(null)
|
||||||
|
setAuthCode(null)
|
||||||
|
try {
|
||||||
|
const p = await window.api.login()
|
||||||
|
setProfile(p)
|
||||||
|
setStatus('logged-in')
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Échec de connexion : ${(e as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
setAuthCode(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout(): Promise<void> {
|
||||||
|
await window.api.logout()
|
||||||
|
setProfile(null)
|
||||||
|
setStatus('logged-out')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenInstance(): void {
|
||||||
|
void window.api.openInstanceDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePlay(): Promise<void> {
|
||||||
|
setError(null)
|
||||||
|
setLogs([])
|
||||||
|
setStatus('working')
|
||||||
|
await window.api.setSettings({ maxMemoryMb, extraJvmArgs: [] })
|
||||||
|
try {
|
||||||
|
await window.api.play()
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Échec du lancement : ${(e as Error).message}`)
|
||||||
|
setStatus('logged-in')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="center muted">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'logged-out') {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="center">
|
||||||
|
<h1>OFLauncher</h1>
|
||||||
|
<p className="muted">Connecte-toi avec ton compte Microsoft pour jouer.</p>
|
||||||
|
<button className="play" onClick={handleLogin} disabled={!!authCode}>
|
||||||
|
{authCode ? 'En attente…' : 'Se connecter'}
|
||||||
|
</button>
|
||||||
|
{authCode && (
|
||||||
|
<div className="authcode">
|
||||||
|
<p>
|
||||||
|
Va sur{' '}
|
||||||
|
<a href={authCode.verificationUri} target="_blank" rel="noreferrer">
|
||||||
|
{authCode.verificationUri}
|
||||||
|
</a>{' '}
|
||||||
|
et entre le code :
|
||||||
|
</p>
|
||||||
|
<div className="code">{authCode.userCode}</div>
|
||||||
|
<p className="muted" style={{ fontSize: 12 }}>
|
||||||
|
La page s’est ouverte dans ton navigateur. Reviens ici une fois connecté.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
|
v{appVersion}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = status === 'working' || status === 'running'
|
||||||
|
const pct = progress?.progress
|
||||||
|
const indeterminate = busy && (pct === undefined || pct === null)
|
||||||
|
|
||||||
|
const updateBanner =
|
||||||
|
update?.state === 'downloading' ? (
|
||||||
|
<div className="updatebar">
|
||||||
|
<span>
|
||||||
|
Téléchargement de la mise à jour
|
||||||
|
{update.version ? ` ${update.version}` : ''}…{' '}
|
||||||
|
{update.progress != null ? `${Math.round(update.progress * 100)} %` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : update?.state === 'downloaded' ? (
|
||||||
|
<div className="updatebar ready">
|
||||||
|
<span>Mise à jour {update.version ?? ''} prête.</span>
|
||||||
|
<button className="linkbtn" onClick={() => void window.api.installUpdate()}>
|
||||||
|
Redémarrer pour installer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
OFLauncher
|
||||||
|
<span className="pack">All The Mods 10 · 1.21.1</span>
|
||||||
|
</div>
|
||||||
|
<div className="profile">
|
||||||
|
<img src={`https://mc-heads.net/avatar/${profile?.uuid}`} alt="" />
|
||||||
|
<span>{profile?.name}</span>
|
||||||
|
<button className="linkbtn" onClick={handleLogout} disabled={busy}>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateBanner}
|
||||||
|
|
||||||
|
<div className="main">
|
||||||
|
<div className="console" ref={consoleRef}>
|
||||||
|
{logs.map((l, i) => (
|
||||||
|
<div key={i} className={l.stream === 'stderr' ? 'stderr' : undefined}>
|
||||||
|
{l.line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="footer">
|
||||||
|
<div className="progress">
|
||||||
|
<div className="label">{busy ? progress?.message ?? 'Préparation…' : ''}</div>
|
||||||
|
<div className={`bar${indeterminate ? ' indeterminate' : ''}`}>
|
||||||
|
<div style={{ width: indeterminate ? undefined : `${Math.round((pct ?? 0) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings">
|
||||||
|
RAM
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={2048}
|
||||||
|
max={32768}
|
||||||
|
step={1024}
|
||||||
|
value={maxMemoryMb}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setMaxMemoryMb(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
Mo
|
||||||
|
<button className="linkbtn" onClick={handleOpenInstance}>
|
||||||
|
Ouvrir le dossier de l'instance
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="play" onClick={handlePlay} disabled={busy}>
|
||||||
|
{status === 'running' ? 'En jeu…' : busy ? 'Patiente…' : 'Jouer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0e1116;
|
||||||
|
--bg-soft: #161b22;
|
||||||
|
--border: #283040;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-dim: #8b97a7;
|
||||||
|
--accent: #3fb950;
|
||||||
|
--accent-hover: #46c95a;
|
||||||
|
--danger: #f85149;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand .pack {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updatebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updatebar.ready {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console {
|
||||||
|
flex: 1;
|
||||||
|
background: #07090d;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console .stderr {
|
||||||
|
color: #f0883e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console:empty::before {
|
||||||
|
content: 'La console du jeu s’affichera ici…';
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress .label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar > div {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.indeterminate > div {
|
||||||
|
width: 35% !important;
|
||||||
|
animation: slide 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide {
|
||||||
|
0% {
|
||||||
|
margin-left: -35%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
margin-left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.play {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #04250c;
|
||||||
|
border: none;
|
||||||
|
padding: 14px 34px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.play:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.play:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkbtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkbtn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings input {
|
||||||
|
width: 80px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authcode {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authcode a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authcode .code {
|
||||||
|
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
margin: 10px auto;
|
||||||
|
display: inline-block;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Configuration statique du launcher.
|
||||||
|
*
|
||||||
|
* Ces valeurs sont les seules choses que TOI (l'admin) dois ajuster pour ton
|
||||||
|
* serveur. Tout le reste (versions MC/NeoForge, liste des mods) vient du
|
||||||
|
* `pack.toml` packwiz hébergé, donc tu n'as PAS à toucher au code pour mettre
|
||||||
|
* à jour le modpack.
|
||||||
|
*/
|
||||||
|
export interface LauncherConfig {
|
||||||
|
/** Nom affiché du launcher. */
|
||||||
|
appName: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL publique de ton `pack.toml` packwiz (la source de vérité du modpack).
|
||||||
|
* Exemple GitHub Pages : https://<user>.github.io/OFModpack/pack.toml
|
||||||
|
* Exemple GitHub raw : https://raw.githubusercontent.com/<user>/OFModpack/main/pack.toml
|
||||||
|
*/
|
||||||
|
packTomlUrl: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client ID de TON application Azure AD (compte personnel / "consumers").
|
||||||
|
* Scope requis : XboxLive.signin. Voir README pour l'enregistrement.
|
||||||
|
* Tant que l'app Azure n'est pas approuvée pour l'API Minecraft, utilise un
|
||||||
|
* client ID de test connu (ex. celui du launcher officiel) en dev uniquement.
|
||||||
|
*/
|
||||||
|
azureClientId: string
|
||||||
|
|
||||||
|
/** Adresse du serveur à pré-remplir dans le bouton "Jouer" (optionnel). */
|
||||||
|
serverAddress?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config: LauncherConfig = {
|
||||||
|
appName: 'OFLauncher',
|
||||||
|
// TODO: remplace par l'URL de ton pack.toml une fois l'hébergement choisi.
|
||||||
|
packTomlUrl: 'https://gitea.ldpt.fr/zertus/OFModpack/raw/branch/main/pack.toml',
|
||||||
|
// TODO: remplace par le client ID de ton app Azure.
|
||||||
|
azureClientId: 'CHANGE_ME',
|
||||||
|
serverAddress: 'mc.ldpt.fr'
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Contrats partagés entre le process main et le renderer.
|
||||||
|
* Tout passe par le bridge `window.api` exposé dans le preload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Profil du joueur après login Microsoft. */
|
||||||
|
export interface PlayerProfile {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
/** URL du skin/tête si disponible. */
|
||||||
|
skinUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase courante de la séquence "Jouer". */
|
||||||
|
export type LaunchPhase =
|
||||||
|
| 'idle'
|
||||||
|
| 'auth'
|
||||||
|
| 'pack-meta'
|
||||||
|
| 'java'
|
||||||
|
| 'minecraft'
|
||||||
|
| 'neoforge'
|
||||||
|
| 'modpack'
|
||||||
|
| 'launching'
|
||||||
|
| 'running'
|
||||||
|
| 'done'
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
/** Événement de progression envoyé main -> renderer pendant "Jouer". */
|
||||||
|
export interface ProgressEvent {
|
||||||
|
phase: LaunchPhase
|
||||||
|
/** Libellé lisible à afficher. */
|
||||||
|
message: string
|
||||||
|
/** 0..1 si déterminable, sinon undefined (barre indéterminée). */
|
||||||
|
progress?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ligne de log du jeu (stdout/stderr) streamée vers la console du renderer. */
|
||||||
|
export interface GameLogLine {
|
||||||
|
stream: 'stdout' | 'stderr'
|
||||||
|
line: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Infos du flow "device code" Microsoft à présenter à l'utilisateur. */
|
||||||
|
export interface DeviceCodeInfo {
|
||||||
|
userCode: string
|
||||||
|
verificationUri: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** État de la mise à jour automatique du launcher (electron-updater). */
|
||||||
|
export type UpdateState =
|
||||||
|
| 'checking'
|
||||||
|
| 'available'
|
||||||
|
| 'downloading'
|
||||||
|
| 'downloaded'
|
||||||
|
| 'none'
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
/** Statut d'update envoyé main -> renderer pour piloter le bandeau de maj. */
|
||||||
|
export interface UpdateStatus {
|
||||||
|
state: UpdateState
|
||||||
|
/** Version distante (ex. "0.2.0") quand connue. */
|
||||||
|
version?: string
|
||||||
|
/** 0..1 pendant le téléchargement. */
|
||||||
|
progress?: number
|
||||||
|
/** Message d'erreur éventuel (state === 'error'). */
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réglages utilisateur persistés localement. */
|
||||||
|
export interface UserSettings {
|
||||||
|
/** RAM max allouée à la JVM, en Mo. */
|
||||||
|
maxMemoryMb: number
|
||||||
|
/** Args JVM additionnels (avancé). */
|
||||||
|
extraJvmArgs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: UserSettings = {
|
||||||
|
maxMemoryMb: 8192,
|
||||||
|
extraJvmArgs: []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Noms de canaux IPC (invoke renderer -> main). */
|
||||||
|
export const IPC = {
|
||||||
|
authLogin: 'auth:login',
|
||||||
|
authLogout: 'auth:logout',
|
||||||
|
authGetProfile: 'auth:getProfile',
|
||||||
|
play: 'play:start',
|
||||||
|
settingsGet: 'settings:get',
|
||||||
|
settingsSet: 'settings:set',
|
||||||
|
appVersion: 'app:version',
|
||||||
|
openInstanceDir: 'instance:open',
|
||||||
|
updateInstall: 'update:install'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Noms d'événements (send main -> renderer). */
|
||||||
|
export const IPC_EVENT = {
|
||||||
|
progress: 'evt:progress',
|
||||||
|
gameLog: 'evt:gameLog',
|
||||||
|
gameClosed: 'evt:gameClosed',
|
||||||
|
authCode: 'evt:authCode',
|
||||||
|
updateStatus: 'evt:updateStatus'
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "./out/types-node"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/main/**/*",
|
||||||
|
"src/preload/**/*",
|
||||||
|
"src/shared/**/*",
|
||||||
|
"electron.vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@renderer/*": ["src/renderer/src/*"]
|
||||||
|
},
|
||||||
|
"outDir": "./out/types-web"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/renderer/src/**/*",
|
||||||
|
"src/preload/index.d.ts",
|
||||||
|
"src/shared/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user