feat: add way to select game dir
This commit is contained in:
+35
-1
@@ -1,4 +1,4 @@
|
|||||||
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'
|
||||||
@@ -8,6 +8,12 @@ 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({
|
||||||
@@ -60,6 +66,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(() => {
|
||||||
|
|||||||
@@ -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
@@ -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/. */
|
||||||
@@ -53,8 +68,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')
|
||||||
|
|||||||
+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