7 Commits

Author SHA1 Message Date
gitea-actions 7b380f5205 ci: release v3.1.1 2026-06-19 09:38:18 +00:00
lucasdpt ca598befc8 ci: update ci 2026-06-19 11:37:35 +02:00
gitea-actions 0299b53592 ci: release v3.1.0 2026-06-19 09:25:42 +00:00
lucasdpt 3a9a555f19 fix: fix release 2026-06-19 11:24:26 +02:00
lucasdpt 81f66e25eb fix: fix auth problem 2026-06-19 11:04:12 +02:00
gitea-actions c0d7d7ce4f ci: release v3.0.1 2026-06-17 20:15:31 +00:00
lucasdpt a7a4bce19f fix: fix problem when launcher is closed 2026-06-17 22:14:09 +02:00
10 changed files with 297 additions and 52 deletions
-1
View File
@@ -55,4 +55,3 @@ jobs:
GITEA_URL: ${{ github.server_url }}
GITEA_OWNER: ${{ github.repository_owner }}
GITEA_REPO: ${{ github.event.repository.name }}
GITEA_TAG: latest
+3 -1
View File
@@ -31,7 +31,9 @@ linux:
- deb
maintainer: oflauncher
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).
# Provider "generic" : electron-updater lit latest.yml à cette URL fixe.
# Les artefacts (latest.yml + installeur + .blockmap) sont uploadés sur une
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "oflauncher",
"version": "3.0.0",
"version": "3.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oflauncher",
"version": "3.0.0",
"version": "3.1.1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "oflauncher",
"version": "3.0.0",
"version": "3.1.1",
"description": "Launcher Minecraft custom pour le modpack ATM10 (1.21.1 / NeoForge)",
"main": "./out/main/index.js",
"author": "OFLauncher",
+68 -34
View File
@@ -1,18 +1,18 @@
#!/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.
* Publie les artefacts du launcher sur deux releases Gitea :
*
* Pré-requis :
* - `npm run build:win` a produit dist/ (latest.yml + installeur + .blockmap)
* - variable d'env GITEA_TOKEN (scope write:repository)
* 1. Release à tag fixe "latest" — electron-updater (provider generic) lit
* latest.yml à cette URL pour proposer les mises à jour automatiques.
*
* 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_OWNER propriétaire du repo (def. zertus)
* 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 { join } from 'node:path'
@@ -20,7 +20,6 @@ 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')
@@ -31,12 +30,16 @@ if (!TOKEN) {
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.
* - Windows : latest.yml + l'installeur NSIS (+ .blockmap)
* - Linux : latest-linux.yml + l'AppImage (+ .blockmap) ; le .deb est publié
* pour téléchargement manuel (electron-updater ne l'utilise pas).
* electron-updater choisit le bon latest*.yml selon la plateforme.
* Fichiers de dist/ à publier.
* - Windows : latest.yml + installeur NSIS (+ .blockmap)
* - Linux : latest-linux.yml + AppImage (+ .blockmap) + .deb
*/
function isUpdateArtifact(name) {
return (
@@ -61,65 +64,96 @@ async function api(path, init = {}) {
})
if (!res.ok) {
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()
}
/** 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}`, {
/** Récupère la release par tag, ou la crée avec les options données. */
async function getOrCreateRelease(tag, createBody) {
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()}`)
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tag_name: TAG,
name: 'Dernière version',
body: 'Artefacts dauto-update du launcher (écrasés à chaque publication).'
})
body: JSON.stringify(createBody)
})
}
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}`)
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) {
async function uploadAssets(releaseId, files) {
for (const name of files) {
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)…`)
console.log(` Upload "${name}" (${(buf.length / 1e6).toFixed(1)} Mo)…`)
await api(
`/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() {
const version = await readVersion()
const versionTag = `v${version}`
const files = (await readdir(DIST)).filter(isUpdateArtifact)
if (!files.some((f) => f === 'latest.yml')) {
throw new Error('dist/latest.yml introuvable — lance dabord `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()
await deleteExistingAssets(release.id)
for (const name of files) await uploadAsset(release.id, name)
// 1. Release fixe "latest" — point d'ancrage de l'auto-update.
await publishRelease(
'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) => {
+8 -1
View File
@@ -3,7 +3,7 @@ import { join } from 'path'
import { setMainWindow } from './events'
import { IPC, type UserSettings, type PlayOptions } from '../shared/ipc'
import { login, logout, restoreSession, getCurrent } from './auth'
import { play, stopGame } from './play'
import { play, stopGame, isGameRunning } from './play'
import { getPackMetaCached } from './modpack'
import { getSettings, setSettings } from './settings'
import { paths } from './paths'
@@ -35,6 +35,13 @@ function createWindow(): BrowserWindow {
win.on('ready-to-show', () => win.show())
win.on('close', (e) => {
if (isGameRunning()) {
e.preventDefault()
win.hide()
}
})
// Liens externes -> navigateur système.
win.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url)
+4 -2
View File
@@ -46,9 +46,11 @@ class LauncherPaths {
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 {
return this.ensure(join(this.root, 'auth-cache'))
return this.ensure(join(app.getPath('userData'), 'auth-cache'))
}
/** Fichier de réglages utilisateur. */
+11
View File
@@ -1,9 +1,11 @@
import type { ChildProcess } from 'child_process'
import { app, BrowserWindow } from 'electron'
import { getCurrent } from './auth'
import { fetchPackMeta } from './modpack'
import { ensureJava } from './java'
import { installMinecraft, installNeoForge } from './install'
import { syncModpack } from './modpack'
import { ensureServerInList } from './server-list'
import { launchGame } from './launch'
import { getSettings } from './settings'
import { emit } from './events'
@@ -41,12 +43,17 @@ export async function play(opts?: PlayOptions): Promise<void> {
await installMinecraft(meta.minecraft, repair)
const versionId = await installNeoForge(meta.neoforge, meta.minecraft, javaPath, repair)
await syncModpack(javaPath)
await ensureServerInList()
const settings = await getSettings()
const proc = await launchGame(versionId, auth, javaPath, settings)
gameProcess = proc
proc.on('close', () => {
gameProcess = null
const allHidden = BrowserWindow.getAllWindows().every((w) => !w.isVisible())
if (allHidden) {
app.quit()
}
})
} catch (e) {
emit.progress({ phase: 'error', message: (e as Error).message, progress: undefined })
@@ -60,3 +67,7 @@ export function stopGame(): boolean {
gameProcess.kill()
return true
}
export function isGameRunning(): boolean {
return gameProcess !== null
}
+188
View File
@@ -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
View File
@@ -24,10 +24,12 @@ export function initUpdater(): void {
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
// 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', () => {
emit.updateStatus({ state: 'checking' })