Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b380f5205 | |||
| ca598befc8 | |||
| 0299b53592 | |||
| 3a9a555f19 | |||
| 81f66e25eb | |||
| c0d7d7ce4f | |||
| a7a4bce19f | |||
| 48f99f62c4 | |||
| 161ea50234 | |||
| b8204c80bd |
@@ -55,4 +55,3 @@ jobs:
|
|||||||
GITEA_URL: ${{ github.server_url }}
|
GITEA_URL: ${{ github.server_url }}
|
||||||
GITEA_OWNER: ${{ github.repository_owner }}
|
GITEA_OWNER: ${{ github.repository_owner }}
|
||||||
GITEA_REPO: ${{ github.event.repository.name }}
|
GITEA_REPO: ${{ github.event.repository.name }}
|
||||||
GITEA_TAG: latest
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ linux:
|
|||||||
- deb
|
- deb
|
||||||
maintainer: oflauncher
|
maintainer: oflauncher
|
||||||
category: Game
|
category: Game
|
||||||
artifactName: ${productName}-${version}.${ext}
|
# Pas de version dans le nom : OFLauncher.AppImage est toujours écrasé sur
|
||||||
|
# place par l'auto-update, les raccourcis bureau restent valides après maj.
|
||||||
|
artifactName: ${productName}.${ext}
|
||||||
# Publication des binaires du launcher (auto-update).
|
# Publication des binaires du launcher (auto-update).
|
||||||
# Provider "generic" : electron-updater lit latest.yml à cette URL fixe.
|
# Provider "generic" : electron-updater lit latest.yml à cette URL fixe.
|
||||||
# Les artefacts (latest.yml + installeur + .blockmap) sont uploadés sur une
|
# Les artefacts (latest.yml + installeur + .blockmap) sont uploadés sur une
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oflauncher",
|
"name": "oflauncher",
|
||||||
"version": "2.0.0",
|
"version": "3.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oflauncher",
|
"name": "oflauncher",
|
||||||
"version": "2.0.0",
|
"version": "3.1.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oflauncher",
|
"name": "oflauncher",
|
||||||
"version": "2.0.0",
|
"version": "3.1.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",
|
||||||
|
|||||||
+75
-41
@@ -1,18 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Publie les artefacts d'auto-update du launcher sur une release Gitea à tag
|
* Publie les artefacts du launcher sur deux releases Gitea :
|
||||||
* fixe ("latest"). electron-updater (provider generic) lit ensuite latest.yml
|
|
||||||
* à l'URL configurée dans electron-builder.yml.
|
|
||||||
*
|
*
|
||||||
* Pré-requis :
|
* 1. Release à tag fixe "latest" — electron-updater (provider generic) lit
|
||||||
* - `npm run build:win` a produit dist/ (latest.yml + installeur + .blockmap)
|
* latest.yml à cette URL pour proposer les mises à jour automatiques.
|
||||||
* - variable d'env GITEA_TOKEN (scope write:repository)
|
|
||||||
*
|
*
|
||||||
* Config par variables d'env (avec valeurs par défaut) :
|
* 2. Release versionnée "vX.Y.Z" — archive permanente consultable dans l'UI
|
||||||
|
* Gitea, utile pour le suivi des versions et les téléchargements manuels.
|
||||||
|
*
|
||||||
|
* Config par variables d'env :
|
||||||
* GITEA_URL base de l'instance (def. https://gitea.ldpt.fr)
|
* GITEA_URL base de l'instance (def. https://gitea.ldpt.fr)
|
||||||
* GITEA_OWNER propriétaire du repo (def. zertus)
|
* GITEA_OWNER propriétaire du repo (def. zertus)
|
||||||
* GITEA_REPO nom du repo launcher (def. OFLauncher)
|
* GITEA_REPO nom du repo launcher (def. OFLauncher)
|
||||||
* GITEA_TAG tag fixe de la release (def. latest)
|
* GITEA_TOKEN token write:repository (obligatoire)
|
||||||
*/
|
*/
|
||||||
import { readdir, readFile } from 'node:fs/promises'
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
@@ -20,7 +20,6 @@ import { join } from 'node:path'
|
|||||||
const BASE = process.env.GITEA_URL ?? 'https://gitea.ldpt.fr'
|
const BASE = process.env.GITEA_URL ?? 'https://gitea.ldpt.fr'
|
||||||
const OWNER = process.env.GITEA_OWNER ?? 'zertus'
|
const OWNER = process.env.GITEA_OWNER ?? 'zertus'
|
||||||
const REPO = process.env.GITEA_REPO ?? 'OFLauncher'
|
const REPO = process.env.GITEA_REPO ?? 'OFLauncher'
|
||||||
const TAG = process.env.GITEA_TAG ?? 'latest'
|
|
||||||
const TOKEN = process.env.GITEA_TOKEN
|
const TOKEN = process.env.GITEA_TOKEN
|
||||||
|
|
||||||
const DIST = join(process.cwd(), 'dist')
|
const DIST = join(process.cwd(), 'dist')
|
||||||
@@ -31,12 +30,16 @@ if (!TOKEN) {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lit la version courante depuis package.json. */
|
||||||
|
async function readVersion() {
|
||||||
|
const pkg = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8'))
|
||||||
|
return pkg.version // ex. "3.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fichiers de dist/ à publier pour l'auto-update.
|
* Fichiers de dist/ à publier.
|
||||||
* - Windows : latest.yml + l'installeur NSIS (+ .blockmap)
|
* - Windows : latest.yml + installeur NSIS (+ .blockmap)
|
||||||
* - Linux : latest-linux.yml + l'AppImage (+ .blockmap) ; le .deb est publié
|
* - Linux : latest-linux.yml + AppImage (+ .blockmap) + .deb
|
||||||
* pour téléchargement manuel (electron-updater ne l'utilise pas).
|
|
||||||
* electron-updater choisit le bon latest*.yml selon la plateforme.
|
|
||||||
*/
|
*/
|
||||||
function isUpdateArtifact(name) {
|
function isUpdateArtifact(name) {
|
||||||
return (
|
return (
|
||||||
@@ -61,65 +64,96 @@ async function api(path, init = {}) {
|
|||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => '')
|
const body = await res.text().catch(() => '')
|
||||||
throw new Error(`Gitea ${init.method ?? 'GET'} ${path} -> ${res.status} ${body}`)
|
throw new Error(`Gitea ${init.method ?? 'GET'} ${path} → ${res.status} ${body}`)
|
||||||
}
|
}
|
||||||
return res.status === 204 ? null : res.json()
|
return res.status === 204 ? null : res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Récupère la release au tag fixe, ou la crée si absente. */
|
/** Récupère la release par tag, ou la crée avec les options données. */
|
||||||
async function getOrCreateRelease() {
|
async function getOrCreateRelease(tag, createBody) {
|
||||||
const res = await fetch(`${API}/repos/${OWNER}/${REPO}/releases/tags/${TAG}`, {
|
const res = await fetch(`${API}/repos/${OWNER}/${REPO}/releases/tags/${tag}`, {
|
||||||
headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json' }
|
headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json' }
|
||||||
})
|
})
|
||||||
if (res.ok) return res.json()
|
if (res.ok) return res.json()
|
||||||
if (res.status !== 404) {
|
if (res.status !== 404) {
|
||||||
throw new Error(`Gitea GET release -> ${res.status} ${await res.text()}`)
|
throw new Error(`Gitea GET release/${tag} → ${res.status} ${await res.text()}`)
|
||||||
}
|
}
|
||||||
console.log(`Création de la release "${TAG}"…`)
|
console.log(`Création de la release "${tag}"…`)
|
||||||
return api(`/repos/${OWNER}/${REPO}/releases`, {
|
return api(`/repos/${OWNER}/${REPO}/releases`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(createBody)
|
||||||
tag_name: TAG,
|
|
||||||
name: 'Dernière version',
|
|
||||||
body: 'Artefacts d’auto-update du launcher (écrasés à chaque publication).'
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteExistingAssets(releaseId) {
|
async function deleteExistingAssets(releaseId) {
|
||||||
const assets = await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets`)
|
const assets = await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets`)
|
||||||
for (const a of assets) {
|
for (const a of assets) {
|
||||||
console.log(`Suppression de l'ancien asset ${a.name}…`)
|
console.log(` Suppression de l'ancien asset "${a.name}"…`)
|
||||||
await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets/${a.id}`, {
|
await api(`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets/${a.id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadAsset(releaseId, name) {
|
async function uploadAssets(releaseId, files) {
|
||||||
const buf = await readFile(join(DIST, name))
|
for (const name of files) {
|
||||||
const form = new FormData()
|
const buf = await readFile(join(DIST, name))
|
||||||
form.append('attachment', new Blob([buf]), name)
|
const form = new FormData()
|
||||||
console.log(`Upload de ${name} (${(buf.length / 1e6).toFixed(1)} Mo)…`)
|
form.append('attachment', new Blob([buf]), name)
|
||||||
await api(
|
console.log(` Upload "${name}" (${(buf.length / 1e6).toFixed(1)} Mo)…`)
|
||||||
`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
|
await api(
|
||||||
{ method: 'POST', body: form }
|
`/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
|
||||||
)
|
{ method: 'POST', body: form }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishRelease(tag, createBody, files) {
|
||||||
|
console.log(`\n── Release "${tag}" ──`)
|
||||||
|
const release = await getOrCreateRelease(tag, createBody)
|
||||||
|
await deleteExistingAssets(release.id)
|
||||||
|
await uploadAssets(release.id, files)
|
||||||
|
console.log(` ✓ Release "${tag}" publiée.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const version = await readVersion()
|
||||||
|
const versionTag = `v${version}`
|
||||||
|
|
||||||
const files = (await readdir(DIST)).filter(isUpdateArtifact)
|
const files = (await readdir(DIST)).filter(isUpdateArtifact)
|
||||||
if (!files.some((f) => f === 'latest.yml')) {
|
if (!files.some((f) => f === 'latest.yml')) {
|
||||||
throw new Error('dist/latest.yml introuvable — lance d’abord `npm run build:win`.')
|
throw new Error("dist/latest.yml introuvable — lance d'abord `npm run build:win`.")
|
||||||
}
|
}
|
||||||
console.log(`Publication sur ${OWNER}/${REPO} (tag ${TAG}) : ${files.join(', ')}`)
|
console.log(`Version : ${versionTag}`)
|
||||||
|
console.log(`Artefacts : ${files.join(', ')}`)
|
||||||
|
console.log(`Dépôt : ${OWNER}/${REPO}`)
|
||||||
|
|
||||||
const release = await getOrCreateRelease()
|
// 1. Release fixe "latest" — point d'ancrage de l'auto-update.
|
||||||
await deleteExistingAssets(release.id)
|
await publishRelease(
|
||||||
for (const name of files) await uploadAsset(release.id, name)
|
'latest',
|
||||||
|
{
|
||||||
|
tag_name: 'latest',
|
||||||
|
name: 'Auto-update (dernière version)',
|
||||||
|
body: "Artefacts d'auto-update écrasés à chaque publication. Version courante : " + versionTag,
|
||||||
|
prerelease: false
|
||||||
|
},
|
||||||
|
files
|
||||||
|
)
|
||||||
|
|
||||||
console.log('✓ Publication terminée.')
|
// 2. Release versionnée — archive permanente par version.
|
||||||
|
await publishRelease(
|
||||||
|
versionTag,
|
||||||
|
{
|
||||||
|
tag_name: versionTag,
|
||||||
|
name: `OFLauncher ${versionTag}`,
|
||||||
|
body: '',
|
||||||
|
prerelease: false
|
||||||
|
},
|
||||||
|
files
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('\n✓ Publication terminée.')
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
|
|||||||
+43
-2
@@ -1,13 +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, type PlayOptions } 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, stopGame } from './play'
|
import { play, stopGame, isGameRunning } from './play'
|
||||||
import { getPackMetaCached } from './modpack'
|
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({
|
||||||
@@ -29,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)
|
||||||
@@ -60,6 +73,34 @@ function registerIpc(): void {
|
|||||||
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.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(() => {
|
||||||
|
|||||||
+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')
|
||||||
|
}
|
||||||
+26
-11
@@ -1,19 +1,34 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { mkdirSync } from 'fs'
|
import { mkdirSync } from 'fs'
|
||||||
|
import { readLauncherConfigSync } from './launcher-config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arborescence des données du launcher, rangée sous le userData d'Electron :
|
* Arborescence des données du launcher.
|
||||||
* Windows : %APPDATA%/OFLauncher
|
|
||||||
* Linux : ~/.config/OFLauncher
|
|
||||||
*
|
*
|
||||||
* On garde le runtime (MC/NeoForge/assets/libs/java) séparé de l'instance de
|
* La racine est configurable via launcher.json (userData/launcher.json) :
|
||||||
* jeu (mods/config/saves) pour que la sync packwiz ne touche jamais au runtime.
|
* { "dataDir": "/chemin/choisi/par/l/utilisateur" }
|
||||||
|
* Par défaut : userData d'Electron (%APPDATA%/OFLauncher sur Windows,
|
||||||
|
* ~/.config/OFLauncher sur Linux).
|
||||||
|
*
|
||||||
|
* La config launcher.json elle-même reste toujours dans userData
|
||||||
|
* (point d'ancrage fixe, indépendant du dataDir choisi).
|
||||||
*/
|
*/
|
||||||
class LauncherPaths {
|
class LauncherPaths {
|
||||||
/** Racine : userData d'Electron. */
|
private _root: string | null = null
|
||||||
|
|
||||||
|
/** Racine des données du jeu (configurable). */
|
||||||
get root(): string {
|
get root(): string {
|
||||||
return app.getPath('userData')
|
if (!this._root) {
|
||||||
|
const { dataDir } = readLauncherConfigSync()
|
||||||
|
this._root = dataDir ?? app.getPath('userData')
|
||||||
|
}
|
||||||
|
return this._root
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalide le cache de root (à appeler après écriture d'un nouveau dataDir). */
|
||||||
|
invalidate(): void {
|
||||||
|
this._root = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dossier "Minecraft" géré par @xmcl : versions/, libraries/, assets/. */
|
/** Dossier "Minecraft" géré par @xmcl : versions/, libraries/, assets/. */
|
||||||
@@ -31,9 +46,11 @@ class LauncherPaths {
|
|||||||
return this.ensure(join(this.root, 'java'))
|
return this.ensure(join(this.root, 'java'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cache des tokens d'auth (prismarine-auth). */
|
/** Cache des tokens d'auth (prismarine-auth). Toujours dans userData,
|
||||||
|
* indépendamment de dataDir — les tokens survivent ainsi aux changements
|
||||||
|
* de dossier de données et aux mises à jour du launcher. */
|
||||||
get authCache(): string {
|
get authCache(): string {
|
||||||
return this.ensure(join(this.root, 'auth-cache'))
|
return this.ensure(join(app.getPath('userData'), 'auth-cache'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fichier de réglages utilisateur. */
|
/** Fichier de réglages utilisateur. */
|
||||||
@@ -53,8 +70,6 @@ class LauncherPaths {
|
|||||||
|
|
||||||
/** 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')
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { ChildProcess } from 'child_process'
|
import type { ChildProcess } from 'child_process'
|
||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
import { getCurrent } from './auth'
|
import { getCurrent } from './auth'
|
||||||
import { fetchPackMeta } from './modpack'
|
import { fetchPackMeta } from './modpack'
|
||||||
import { ensureJava } from './java'
|
import { ensureJava } from './java'
|
||||||
import { installMinecraft, installNeoForge } from './install'
|
import { installMinecraft, installNeoForge } from './install'
|
||||||
import { syncModpack } from './modpack'
|
import { syncModpack } from './modpack'
|
||||||
|
import { ensureServerInList } from './server-list'
|
||||||
import { launchGame } from './launch'
|
import { launchGame } from './launch'
|
||||||
import { getSettings } from './settings'
|
import { getSettings } from './settings'
|
||||||
import { emit } from './events'
|
import { emit } from './events'
|
||||||
@@ -41,12 +43,17 @@ export async function play(opts?: PlayOptions): Promise<void> {
|
|||||||
await installMinecraft(meta.minecraft, repair)
|
await installMinecraft(meta.minecraft, repair)
|
||||||
const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath, repair)
|
const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath, repair)
|
||||||
await syncModpack(javaPath)
|
await syncModpack(javaPath)
|
||||||
|
await ensureServerInList()
|
||||||
|
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
const proc = await launchGame(versionId, auth, javaPath, settings)
|
const proc = await launchGame(versionId, auth, javaPath, settings)
|
||||||
gameProcess = proc
|
gameProcess = proc
|
||||||
proc.on('close', () => {
|
proc.on('close', () => {
|
||||||
gameProcess = null
|
gameProcess = null
|
||||||
|
const allHidden = BrowserWindow.getAllWindows().every((w) => !w.isVisible())
|
||||||
|
if (allHidden) {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit.progress({ phase: 'error', message: (e as Error).message, progress: undefined })
|
emit.progress({ phase: 'error', message: (e as Error).message, progress: undefined })
|
||||||
@@ -60,3 +67,7 @@ export function stopGame(): boolean {
|
|||||||
gameProcess.kill()
|
gameProcess.kill()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isGameRunning(): boolean {
|
||||||
|
return gameProcess !== null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { readFile, writeFile } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { config } from '../shared/config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assure que le serveur configuré est présent dans servers.dat de l'instance.
|
||||||
|
* Si le fichier n'existe pas, il est créé. Si le serveur est déjà listé (même
|
||||||
|
* IP), rien n'est modifié pour ne pas écraser les préférences utilisateur.
|
||||||
|
*
|
||||||
|
* servers.dat : NBT non compressé (contrairement à level.dat).
|
||||||
|
* Structure : TAG_Compound root -> TAG_List "servers" -> TAG_Compound[] serveurs.
|
||||||
|
*/
|
||||||
|
export async function ensureServerInList(): Promise<void> {
|
||||||
|
if (!config.serverAddress?.trim()) return
|
||||||
|
|
||||||
|
const ip = config.serverAddress.trim()
|
||||||
|
const serversDat = join(paths.instanceDir, 'servers.dat')
|
||||||
|
|
||||||
|
let servers: ServerEntry[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buf = await readFile(serversDat)
|
||||||
|
servers = decodeServersDat(buf)
|
||||||
|
} catch {
|
||||||
|
// Fichier absent ou illisible : on part d'une liste vide.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servers.some((s) => s.ip === ip)) return
|
||||||
|
|
||||||
|
// Notre serveur en tête de liste.
|
||||||
|
servers = [{ name: config.appName, ip }, ...servers]
|
||||||
|
await writeFile(serversDat, encodeServersDat(servers))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types internes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ServerEntry {
|
||||||
|
name: string
|
||||||
|
ip: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Encodeur NBT minimal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function b1(n: number): Buffer {
|
||||||
|
return Buffer.from([n])
|
||||||
|
}
|
||||||
|
function b2(n: number): Buffer {
|
||||||
|
const b = Buffer.allocUnsafe(2)
|
||||||
|
b.writeUInt16BE(n)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
function b4(n: number): Buffer {
|
||||||
|
const b = Buffer.allocUnsafe(4)
|
||||||
|
b.writeInt32BE(n)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
function encStr(s: string): Buffer {
|
||||||
|
const d = Buffer.from(s, 'utf8')
|
||||||
|
return Buffer.concat([b2(d.length), d])
|
||||||
|
}
|
||||||
|
function encTag(type: number, name: string, payload: Buffer): Buffer {
|
||||||
|
return Buffer.concat([b1(type), encStr(name), payload])
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeServersDat(servers: ServerEntry[]): Buffer {
|
||||||
|
const entries = servers.flatMap((s) => [
|
||||||
|
encTag(8, 'name', encStr(s.name)),
|
||||||
|
encTag(8, 'ip', encStr(s.ip)),
|
||||||
|
encTag(1, 'acceptTextures', b1(1)),
|
||||||
|
b1(0) // TAG_End clôture le compound dans la liste
|
||||||
|
])
|
||||||
|
|
||||||
|
const listPayload = Buffer.concat([b1(10), b4(servers.length), ...entries])
|
||||||
|
const rootPayload = Buffer.concat([encTag(9, 'servers', listPayload), b1(0)])
|
||||||
|
return Buffer.concat([b1(10), encStr(''), rootPayload])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Décodeur NBT minimal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Reader {
|
||||||
|
buf: Buffer
|
||||||
|
pos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function ru8(r: Reader): number {
|
||||||
|
return r.buf[r.pos++]
|
||||||
|
}
|
||||||
|
function ru16(r: Reader): number {
|
||||||
|
const v = r.buf.readUInt16BE(r.pos)
|
||||||
|
r.pos += 2
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
function ri32(r: Reader): number {
|
||||||
|
const v = r.buf.readInt32BE(r.pos)
|
||||||
|
r.pos += 4
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
function rstr(r: Reader): string {
|
||||||
|
const len = ru16(r)
|
||||||
|
const s = r.buf.subarray(r.pos, r.pos + len).toString('utf8')
|
||||||
|
r.pos += len
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipPayload(r: Reader, type: number): void {
|
||||||
|
switch (type) {
|
||||||
|
case 1: r.pos++; return // TAG_Byte
|
||||||
|
case 2: r.pos += 2; return // TAG_Short
|
||||||
|
case 3: r.pos += 4; return // TAG_Int
|
||||||
|
case 4: r.pos += 8; return // TAG_Long
|
||||||
|
case 5: r.pos += 4; return // TAG_Float
|
||||||
|
case 6: r.pos += 8; return // TAG_Double
|
||||||
|
case 7: r.pos += ri32(r); return // TAG_Byte_Array
|
||||||
|
case 8: r.pos += ru16(r); return // TAG_String
|
||||||
|
case 9: { // TAG_List
|
||||||
|
const et = ru8(r)
|
||||||
|
const n = ri32(r)
|
||||||
|
for (let i = 0; i < n; i++) skipPayload(r, et)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 10: { // TAG_Compound
|
||||||
|
while (r.pos < r.buf.length) {
|
||||||
|
const t = ru8(r)
|
||||||
|
if (t === 0) return
|
||||||
|
r.pos += ru16(r) // skip name
|
||||||
|
skipPayload(r, t)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 11: r.pos += ri32(r) * 4; return // TAG_Int_Array
|
||||||
|
case 12: r.pos += ri32(r) * 8; return // TAG_Long_Array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeServersDat(buf: Buffer): ServerEntry[] {
|
||||||
|
const r: Reader = { buf, pos: 0 }
|
||||||
|
|
||||||
|
if (ru8(r) !== 10) return [] // root doit être TAG_Compound
|
||||||
|
rstr(r) // nom de la racine (vide)
|
||||||
|
|
||||||
|
const servers: ServerEntry[] = []
|
||||||
|
|
||||||
|
while (r.pos < buf.length) {
|
||||||
|
const type = ru8(r)
|
||||||
|
if (type === 0) break
|
||||||
|
|
||||||
|
const key = rstr(r)
|
||||||
|
|
||||||
|
if (type === 9 && key === 'servers') {
|
||||||
|
const elemType = ru8(r)
|
||||||
|
const count = ri32(r)
|
||||||
|
|
||||||
|
if (elemType !== 10) {
|
||||||
|
// Type inattendu : skip
|
||||||
|
for (let i = 0; i < count; i++) skipPayload(r, elemType)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const entry: Partial<ServerEntry> = {}
|
||||||
|
while (r.pos < buf.length) {
|
||||||
|
const t = ru8(r)
|
||||||
|
if (t === 0) break
|
||||||
|
const k = rstr(r)
|
||||||
|
if (t === 8) {
|
||||||
|
const val = rstr(r)
|
||||||
|
if (k === 'name') entry.name = val
|
||||||
|
else if (k === 'ip') entry.ip = val
|
||||||
|
} else {
|
||||||
|
skipPayload(r, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.ip) servers.push({ name: entry.name ?? '', ip: entry.ip })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipPayload(r, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers
|
||||||
|
}
|
||||||
+5
-3
@@ -24,10 +24,12 @@ export function initUpdater(): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// On gère l'install manuellement (bouton "Redémarrer"), mais on installe
|
|
||||||
// quand même à la fermeture si la maj a été téléchargée.
|
|
||||||
autoUpdater.autoDownload = true
|
autoUpdater.autoDownload = true
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
// Sur Linux (AppImage), l'install automatique au quit utilise execFileSync
|
||||||
|
// et bloque le processus en attendant que le nouveau AppImage se ferme —
|
||||||
|
// comportement très inattendu. On désactive : l'utilisateur clique le bouton
|
||||||
|
// "Redémarrer pour installer" qui, lui, utilise spawnLog (async) et fonctionne.
|
||||||
|
autoUpdater.autoInstallOnAppQuit = process.platform !== 'linux'
|
||||||
|
|
||||||
autoUpdater.on('checking-for-update', () => {
|
autoUpdater.on('checking-for-update', () => {
|
||||||
emit.updateStatus({ state: 'checking' })
|
emit.updateStatus({ state: 'checking' })
|
||||||
|
|||||||
+10
-1
@@ -9,7 +9,8 @@ import {
|
|||||||
type DeviceCodeInfo,
|
type DeviceCodeInfo,
|
||||||
type UpdateStatus,
|
type UpdateStatus,
|
||||||
type PlayOptions,
|
type PlayOptions,
|
||||||
type PackMeta
|
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. */
|
||||||
@@ -39,6 +40,14 @@ const api = {
|
|||||||
/** 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)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type {
|
|||||||
GameLogLine,
|
GameLogLine,
|
||||||
DeviceCodeInfo,
|
DeviceCodeInfo,
|
||||||
UpdateStatus,
|
UpdateStatus,
|
||||||
PackMeta
|
PackMeta,
|
||||||
|
DataDirInfo
|
||||||
} from '../../shared/ipc'
|
} from '../../shared/ipc'
|
||||||
|
|
||||||
/** Découpe une chaîne d'args JVM en tableau (espaces, vides ignorés). */
|
/** Découpe une chaîne d'args JVM en tableau (espaces, vides ignorés). */
|
||||||
@@ -13,7 +14,7 @@ function parseArgs(s: string): string[] {
|
|||||||
return s.split(/\s+/).filter(Boolean)
|
return s.split(/\s+/).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status = 'loading' | 'logged-out' | 'logged-in' | 'working' | 'running'
|
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')
|
||||||
@@ -27,23 +28,34 @@ export default function App(): JSX.Element {
|
|||||||
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 [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(' '))
|
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')
|
||||||
})()
|
})()
|
||||||
// Récupère le nom/version réels du pack (tolérant au offline).
|
|
||||||
void window.api.getPackMeta().then((m) => m && setPack(m))
|
void window.api.getPackMeta().then((m) => m && setPack(m))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -129,6 +141,30 @@ export default function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
@@ -137,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">
|
||||||
@@ -157,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>
|
||||||
)}
|
)}
|
||||||
@@ -258,6 +324,9 @@ export default function App(): JSX.Element {
|
|||||||
<button className="linkbtn" onClick={handleOpenLogs}>
|
<button className="linkbtn" onClick={handleOpenLogs}>
|
||||||
Ouvrir les logs
|
Ouvrir les logs
|
||||||
</button>
|
</button>
|
||||||
|
<button className="linkbtn" onClick={() => void handleChangeDataDir()} disabled={busy} title={dataDir?.current}>
|
||||||
|
Dossier de données
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === 'running' ? (
|
{status === 'running' ? (
|
||||||
|
|||||||
@@ -283,3 +283,27 @@ button.play.stop:hover:not(: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;
|
||||||
|
}
|
||||||
|
|||||||
+13
-1
@@ -94,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',
|
||||||
@@ -107,7 +115,11 @@ export const IPC = {
|
|||||||
openLogsDir: 'logs:open',
|
openLogsDir: 'logs:open',
|
||||||
playStop: 'play:stop',
|
playStop: 'play:stop',
|
||||||
packGet: 'pack:get',
|
packGet: 'pack:get',
|
||||||
updateInstall: 'update:install'
|
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