3 Commits

Author SHA1 Message Date
gitea-actions 48f99f62c4 ci: release v3.0.0 2026-06-17 18:44:16 +00:00
lucasdpt 161ea50234 feat: add way to select game dir 2026-06-17 20:43:27 +02:00
lucasdpt b8204c80bd feat: add java update for linux 2026-06-17 20:30:35 +02:00
10 changed files with 223 additions and 23 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "oflauncher",
"version": "2.0.0",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oflauncher",
"version": "2.0.0",
"version": "3.0.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oflauncher",
"version": "2.0.0",
"version": "3.0.0",
"description": "Launcher Minecraft custom pour le modpack ATM10 (1.21.1 / NeoForge)",
"main": "./out/main/index.js",
"author": "OFLauncher",
+35 -1
View File
@@ -1,4 +1,4 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
import { join } from 'path'
import { setMainWindow } from './events'
import { IPC, type UserSettings, type PlayOptions } from '../shared/ipc'
@@ -8,6 +8,12 @@ import { getPackMetaCached } from './modpack'
import { getSettings, setSettings } from './settings'
import { paths } from './paths'
import { initUpdater, quitAndInstallUpdate } from './updater'
import {
isFirstLaunch,
writeLauncherConfig,
defaultDataDir,
readLauncherConfigSync
} from './launcher-config'
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
@@ -60,6 +66,34 @@ function registerIpc(): void {
ipcMain.handle(IPC.openInstanceDir, () => shell.openPath(paths.instanceDir))
ipcMain.handle(IPC.openLogsDir, () => shell.openPath(paths.logsDir))
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(() => {
+8 -2
View File
@@ -20,6 +20,7 @@ import { emit } from './events'
*/
const JAVA_MAJOR = 21
const JAVA_MAX = 21
const MARKER = (): string => join(paths.javaDir, 'managed.json')
/** 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) {
if (!existsSync(c)) continue
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
}
@@ -147,7 +148,12 @@ export async function ensureJava(): Promise<string> {
if (existsSync(MARKER())) {
try {
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 {
/* marqueur corrompu : on réinstalle */
}
+33
View File
@@ -0,0 +1,33 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readFileSync } from 'fs'
import { writeFile } from 'fs/promises'
interface LauncherConfig {
dataDir?: string
}
function configPath(): string {
return join(app.getPath('userData'), 'launcher.json')
}
export function readLauncherConfigSync(): LauncherConfig {
try {
return JSON.parse(readFileSync(configPath(), 'utf-8')) as LauncherConfig
} catch {
return {}
}
}
export function isFirstLaunch(): boolean {
return !existsSync(configPath())
}
export async function writeLauncherConfig(cfg: LauncherConfig): Promise<void> {
const current = readLauncherConfigSync()
await writeFile(configPath(), JSON.stringify({ ...current, ...cfg }, null, 2))
}
export function defaultDataDir(): string {
return join(app.getPath('home'), 'Games', 'OFLauncher')
}
+22 -9
View File
@@ -1,19 +1,34 @@
import { app } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
import { readLauncherConfigSync } from './launcher-config'
/**
* Arborescence des données du launcher, rangée sous le userData d'Electron :
* Windows : %APPDATA%/OFLauncher
* Linux : ~/.config/OFLauncher
* Arborescence des données du launcher.
*
* 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.
* La racine est configurable via launcher.json (userData/launcher.json) :
* { "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 {
/** Racine : userData d'Electron. */
private _root: string | null = null
/** Racine des données du jeu (configurable). */
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/. */
@@ -53,8 +68,6 @@ class LauncherPaths {
/** 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')
+10 -1
View File
@@ -9,7 +9,8 @@ import {
type DeviceCodeInfo,
type UpdateStatus,
type PlayOptions,
type PackMeta
type PackMeta,
type DataDirInfo
} from '../shared/ipc'
/** 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. */
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) ---
onProgress: (cb: (e: ProgressEvent) => void): (() => void) => {
const handler = (_: unknown, e: ProgressEvent): void => cb(e)
+75 -6
View File
@@ -5,7 +5,8 @@ import type {
GameLogLine,
DeviceCodeInfo,
UpdateStatus,
PackMeta
PackMeta,
DataDirInfo
} from '../../shared/ipc'
/** 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)
}
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 {
const [status, setStatus] = useState<Status>('loading')
@@ -27,23 +28,34 @@ export default function App(): JSX.Element {
const [appVersion, setAppVersion] = useState('')
const [update, setUpdate] = useState<UpdateStatus | null>(null)
const [pack, setPack] = useState<PackMeta | null>(null)
const [dataDir, setDataDir] = useState<DataDirInfo | null>(null)
const [setupDir, setSetupDir] = useState('')
const consoleRef = useRef<HTMLDivElement>(null)
// Restaure la session + réglages au démarrage.
useEffect(() => {
;(async () => {
const [p, s, v] = await Promise.all([
window.api.getProfile(),
const [firstLaunch, dirInfo, s, v] = await Promise.all([
window.api.isFirstLaunch(),
window.api.getDataDir(),
window.api.getSettings(),
window.api.getAppVersion()
])
setDataDir(dirInfo)
setMaxMemoryMb(s.maxMemoryMb)
setJvmArgs(s.extraJvmArgs.join(' '))
setAppVersion(v)
if (firstLaunch) {
setSetupDir(dirInfo.defaultSuggestion)
setStatus('setup')
return
}
const p = await window.api.getProfile()
setProfile(p)
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))
}, [])
@@ -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') {
return (
<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 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') {
return (
<div className="app">
@@ -157,7 +223,7 @@ export default function App(): JSX.Element {
</p>
<div className="code">{authCode.userCode}</div>
<p className="muted" style={{ fontSize: 12 }}>
La page sest ouverte dans ton navigateur. Reviens ici une fois connecté.
La page s'est ouverte dans ton navigateur. Reviens ici une fois connecté.
</p>
</div>
)}
@@ -258,6 +324,9 @@ export default function App(): JSX.Element {
<button className="linkbtn" onClick={handleOpenLogs}>
Ouvrir les logs
</button>
<button className="linkbtn" onClick={() => void handleChangeDataDir()} disabled={busy} title={dataDir?.current}>
Dossier de données
</button>
</div>
{status === 'running' ? (
+24
View File
@@ -283,3 +283,27 @@ button.play.stop:hover:not(:disabled) {
display: inline-block;
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
View File
@@ -94,6 +94,14 @@ export const DEFAULT_SETTINGS: UserSettings = {
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). */
export const IPC = {
authLogin: 'auth:login',
@@ -107,7 +115,11 @@ export const IPC = {
openLogsDir: 'logs:open',
playStop: 'play:stop',
packGet: 'pack:get',
updateInstall: 'update:install'
updateInstall: 'update:install',
isFirstLaunch: 'app:isFirstLaunch',
dataDirGet: 'dataDir:get',
dataDirBrowse: 'dataDir:browse',
dataDirSet: 'dataDir:set'
} as const
/** Noms d'événements (send main -> renderer). */