From 161ea50234bc51931a72680ef0859058dc6a7951 Mon Sep 17 00:00:00 2001 From: lucasdpt Date: Wed, 17 Jun 2026 20:43:27 +0200 Subject: [PATCH] feat: add way to select game dir --- src/main/index.ts | 36 ++++++++++++++++- src/main/launcher-config.ts | 33 +++++++++++++++ src/main/paths.ts | 31 +++++++++----- src/preload/index.ts | 11 ++++- src/renderer/src/App.tsx | 81 ++++++++++++++++++++++++++++++++++--- src/renderer/src/index.css | 24 +++++++++++ src/shared/ipc.ts | 14 ++++++- 7 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 src/main/launcher-config.ts diff --git a/src/main/index.ts b/src/main/index.ts index 36c85e2..38b5c84 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(() => { diff --git a/src/main/launcher-config.ts b/src/main/launcher-config.ts new file mode 100644 index 0000000..65fd220 --- /dev/null +++ b/src/main/launcher-config.ts @@ -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 { + const current = readLauncherConfigSync() + await writeFile(configPath(), JSON.stringify({ ...current, ...cfg }, null, 2)) +} + +export function defaultDataDir(): string { + return join(app.getPath('home'), 'Games', 'OFLauncher') +} diff --git a/src/main/paths.ts b/src/main/paths.ts index 15d5070..ad53fb5 100644 --- a/src/main/paths.ts +++ b/src/main/paths.ts @@ -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') diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a8c85c..002118c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke(IPC.updateInstall), + // --- Dossier de données --- + isFirstLaunch: (): Promise => ipcRenderer.invoke(IPC.isFirstLaunch), + getDataDir: (): Promise => ipcRenderer.invoke(IPC.dataDirGet), + browseDataDir: (): Promise => ipcRenderer.invoke(IPC.dataDirBrowse), + /** relaunch=true : relance l'app (pour un changement post-installation). */ + setDataDir: (dir: string, relaunch: boolean): Promise => + 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) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9ae7d77..981cbab 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('loading') @@ -27,23 +28,34 @@ export default function App(): JSX.Element { const [appVersion, setAppVersion] = useState('') const [update, setUpdate] = useState(null) const [pack, setPack] = useState(null) + const [dataDir, setDataDir] = useState(null) + const [setupDir, setSetupDir] = useState('') const consoleRef = useRef(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 { + const picked = await window.api.browseDataDir() + if (picked) setSetupDir(picked) + } + + async function handleSetupConfirm(): Promise { + 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 { + 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 (
@@ -137,6 +173,36 @@ export default function App(): JSX.Element { ) } + if (status === 'setup') { + return ( +
+
+

OFLauncher

+

Bienvenue ! Choisis où seront stockés les fichiers du jeu.

+

+ Minecraft, Java, mods et sauvegardes iront dans ce dossier. +

+
+ setSetupDir(e.target.value)} + spellCheck={false} + /> + +
+ +
v{appVersion}
+
+
+ ) + } + if (status === 'logged-out') { return (
@@ -157,7 +223,7 @@ export default function App(): JSX.Element {

{authCode.userCode}

- 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é.

)} @@ -258,6 +324,9 @@ export default function App(): JSX.Element { +
{status === 'running' ? ( diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index d048731..0115a4f 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -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; +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index b93a81c..38892f1 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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). */