diff --git a/main.js b/main.js index f57a96c..1c7cdef 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, dialog, ipcMain } = require('electron'); +const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron'); const path = require('path'); const fs = require('fs'); const fsp = fs.promises; @@ -10,6 +10,9 @@ const APP_STATE_FILE = path.join(userDataDir, 'app-state.json'); const DROPSTATS_FILE = path.join(userDataDir, 'dropstats.json'); const COMMUNITY_DROPSTATS_FILE = path.join(userDataDir, 'community-dropstats.json'); const SESSION_HISTORY_FILE = path.join(userDataDir, 'session-history.json'); +const UPDATE_PREFS_FILE = path.join(userDataDir, 'update-prefs.json'); + +const RELEASES_API = 'https://git.shamiiow.com/api/v1/repos/shamiiow/farm-tracker-sao/releases/latest'; // ---------- Lecture / écriture disque (JSON indenté = facilement lisible) ---------- @@ -42,6 +45,8 @@ function loadCommunityDropStats() { return readJsonSafe(COMMUNITY_DROPSTATS_FILE function saveCommunityDropStats(obj) { return writeJsonAtomic(COMMUNITY_DROPSTATS_FILE, obj); } function loadSessionHistory() { return readJsonSafe(SESSION_HISTORY_FILE, { type: 'farm-tracker-session-history', version: 1, sessions: [] }); } function saveSessionHistory(obj) { return writeJsonAtomic(SESSION_HISTORY_FILE, obj); } +function loadUpdatePrefs() { return readJsonSafe(UPDATE_PREFS_FILE, { snoozedUntil: null }); } +function saveUpdatePrefs(obj) { return writeJsonAtomic(UPDATE_PREFS_FILE, obj); } function resolveLogPath(folderPath) { const candidate1 = path.join(folderPath, 'logs', 'latest.log'); @@ -51,6 +56,111 @@ function resolveLogPath(folderPath) { return null; } +// ---------- Mise à jour ---------- + +function isNewerVersion(remote, current) { + const r = remote.split('.').map(Number); + const c = current.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((r[i] || 0) > (c[i] || 0)) return true; + if ((r[i] || 0) < (c[i] || 0)) return false; + } + return false; +} + +const SNOOZE_OPTIONS = [ + { label: '1 jour', days: 1 }, + { label: '2 jours', days: 2 }, + { label: '5 jours', days: 5 }, + { label: '1 semaine', days: 7 }, + { label: '2 semaines', days: 14 }, + { label: 'Indéfiniment', days: null }, + { label: 'Annuler', days: -1 } +]; + +function fetchLatestRelease() { + return new Promise((resolve, reject) => { + const https = require('https'); + const req = https.get(RELEASES_API, { + timeout: 10000, + headers: { 'User-Agent': 'farm-tracker-updater' } + }, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch (e) { reject(new Error('Réponse invalide')); } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); + }); +} + +function resolveAssetUrl(release) { + const ext = process.platform === 'win32' ? '.exe' : '.AppImage'; + const asset = (release.assets || []).find(a => a.name.endsWith(ext)); + return asset ? asset.browser_download_url : null; +} + +async function showUpdateDialog(version, downloadUrl, ignoreSnooze = false) { + const current = app.getVersion(); + if (!isNewerVersion(version, current)) return false; + + if (!ignoreSnooze) { + const prefs = loadUpdatePrefs(); + if (prefs.snoozedUntil === 'indefinite') return false; + if (prefs.snoozedUntil && new Date(prefs.snoozedUntil) > new Date()) return false; + } + + const { response } = await dialog.showMessageBox(mainWindow, { + type: 'info', + title: 'Mise à jour disponible', + message: `Version ${version} disponible`, + detail: `Version actuelle : ${current}`, + buttons: ['Télécharger', 'Me rappeler dans...'], + defaultId: 0, + cancelId: 1 + }); + + if (response === 0) { + shell.openExternal(downloadUrl); + return true; + } + + const { response: snoozeResponse } = await dialog.showMessageBox(mainWindow, { + type: 'question', + title: 'Me rappeler dans...', + message: 'Ne plus afficher cette mise à jour pendant :', + buttons: SNOOZE_OPTIONS.map(o => o.label), + defaultId: 0, + cancelId: SNOOZE_OPTIONS.length - 1 + }); + + const chosen = SNOOZE_OPTIONS[snoozeResponse]; + if (chosen.days === -1) return false; + if (chosen.days === null) { + saveUpdatePrefs({ snoozedUntil: 'indefinite' }); + } else { + const until = new Date(); + until.setDate(until.getDate() + chosen.days); + saveUpdatePrefs({ snoozedUntil: until.toISOString() }); + } + return false; +} + +async function checkForUpdates(ignoreSnooze = false) { + try { + const release = await fetchLatestRelease(); + const version = (release.tag_name || '').replace(/^v/, ''); + const downloadUrl = resolveAssetUrl(release); + if (!version || !downloadUrl) return false; + return await showUpdateDialog(version, downloadUrl, ignoreSnooze); + } catch (_) { + return false; + } +} + let mainWindow; function createWindow() { @@ -71,7 +181,10 @@ function createWindow() { mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); } -app.whenReady().then(createWindow); +app.whenReady().then(() => { + createWindow(); + setTimeout(() => checkForUpdates(false), 5000); +}); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); @@ -184,6 +297,24 @@ ipcMain.handle('get-file-paths', () => ({ dropStats: DROPSTATS_FILE, communityDr ipcMain.handle('load-session-history', async () => loadSessionHistory()); ipcMain.handle('save-session-history', async (event, obj) => saveSessionHistory(obj)); +// ---------- IPC : mise à jour ---------- + +ipcMain.handle('check-update-manual', async () => { + try { + const release = await fetchLatestRelease(); + const version = (release.tag_name || '').replace(/^v/, ''); + const downloadUrl = resolveAssetUrl(release); + const current = app.getVersion(); + if (!version || !downloadUrl || !isNewerVersion(version, current)) { + return { checked: true, hasUpdate: false, current }; + } + await showUpdateDialog(version, downloadUrl, true); + return { checked: true, hasUpdate: true }; + } catch (e) { + return { checked: false, error: e.message }; + } +}); + // ---------- IPC : sync avec le serveur VPS ---------- ipcMain.handle('fetch-community-stats', async (event, { serverUrl }) => { diff --git a/preload.js b/preload.js index f991414..2d58ff2 100644 --- a/preload.js +++ b/preload.js @@ -22,5 +22,7 @@ contextBridge.exposeInMainWorld('api', { getFilePaths: () => ipcRenderer.invoke('get-file-paths'), loadSessionHistory: () => ipcRenderer.invoke('load-session-history'), - saveSessionHistory: (obj) => ipcRenderer.invoke('save-session-history', obj) + saveSessionHistory: (obj) => ipcRenderer.invoke('save-session-history', obj), + + checkUpdateManual: () => ipcRenderer.invoke('check-update-manual') }); diff --git a/renderer/index.html b/renderer/index.html index e910f7c..7f5539a 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -301,6 +301,7 @@
+ @@ -707,6 +708,23 @@ showScreen('hub'); }); homeBtn.addEventListener('click', () => showScreen('hub')); + + // ---------- Bouton "Mises à jour" ---------- + const checkUpdateBtn = $('checkUpdateBtn'); + checkUpdateBtn.addEventListener('click', async () => { + checkUpdateBtn.disabled = true; + checkUpdateBtn.textContent = 'Vérification...'; + const result = await window.api.checkUpdateManual(); + checkUpdateBtn.disabled = false; + checkUpdateBtn.textContent = 'Mises à jour'; + if (!result.checked) return; + if (!result.hasUpdate) { + checkUpdateBtn.textContent = 'A jour ✓'; + setTimeout(() => { checkUpdateBtn.textContent = 'Mises à jour'; }, 3000); + } + // Si hasUpdate: la dialog native s'est affichée côté main.js + }); + $('tileFarmTracker').addEventListener('click', () => showScreen('farmTracker')); $('tileDropBoard').addEventListener('click', () => { showScreen('dropBoard'); renderDropBoard(); }); $('tileHistory').addEventListener('click', () => { showScreen('history'); renderHistoryScreen(); }); diff --git a/tools/release.js b/tools/release.js new file mode 100644 index 0000000..1e528e8 --- /dev/null +++ b/tools/release.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// Usage: GITEA_TOKEN=xxx node tools/release.js +// Crée une release Gitea et upload les binaires dist/*.AppImage et dist/*.exe + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const TOKEN = process.env.GITEA_TOKEN; +if (!TOKEN) { + console.error('Erreur : variable GITEA_TOKEN manquante.\nUsage : GITEA_TOKEN=xxx node tools/release.js'); + process.exit(1); +} + +const OWNER = 'shamiiow'; +const REPO = 'farm-tracker-sao'; +const HOST = 'git.shamiiow.com'; + +const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); +const VERSION = pkg.version; +const TAG = `v${VERSION}`; + +const DIST = path.join(__dirname, '..', 'dist'); +const ASSETS = fs.readdirSync(DIST).filter(f => f.endsWith('.AppImage') || (f.endsWith('.exe') && !f.endsWith('.blockmap'))); + +if (!ASSETS.length) { + console.error('Aucun binaire trouvé dans dist/. Lance "npm run dist" et "npm run dist:win" d\'abord.'); + process.exit(1); +} + +function apiRequest(method, apiPath, body, extraHeaders = {}) { + return new Promise((resolve, reject) => { + const bodyBuf = body ? Buffer.from(JSON.stringify(body)) : null; + const options = { + hostname: HOST, + path: `/api/v1${apiPath}`, + method, + headers: { + 'Authorization': `token ${TOKEN}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...extraHeaders, + ...(bodyBuf ? { 'Content-Length': bodyBuf.length } : {}) + } + }; + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + if (res.statusCode >= 400) reject(new Error(`HTTP ${res.statusCode}: ${parsed.message || data}`)); + else resolve(parsed); + } catch { reject(new Error(`Réponse non-JSON (${res.statusCode}): ${data.slice(0, 200)}`)); } + }); + }); + req.on('error', reject); + if (bodyBuf) req.write(bodyBuf); + req.end(); + }); +} + +function uploadAsset(releaseId, filePath) { + return new Promise((resolve, reject) => { + const fileName = path.basename(filePath); + const fileData = fs.readFileSync(filePath); + const boundary = '----FormBoundary' + Math.random().toString(36).slice(2); + const header = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n` + ); + const footer = Buffer.from(`\r\n--${boundary}--\r\n`); + const body = Buffer.concat([header, fileData, footer]); + + const options = { + hostname: HOST, + path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets`, + method: 'POST', + headers: { + 'Authorization': `token ${TOKEN}`, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': body.length + } + }; + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + if (res.statusCode >= 400) reject(new Error(`Upload HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); + else { try { resolve(JSON.parse(data)); } catch { resolve({}); } } + }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +async function main() { + console.log(`\n📦 Release ${TAG} — ${ASSETS.length} fichier(s) à uploader\n`); + + // Vérifie si la release existe déjà + let release; + try { + release = await apiRequest('GET', `/repos/${OWNER}/${REPO}/releases/tags/${TAG}`); + console.log(`Release ${TAG} existante (id ${release.id}), ajout des assets...`); + } catch (_) { + console.log(`Création de la release ${TAG}...`); + release = await apiRequest('POST', `/repos/${OWNER}/${REPO}/releases`, { + tag_name: TAG, + name: `Farm Tracker ${TAG}`, + body: `Release ${TAG}`, + draft: false, + prerelease: false + }); + console.log(` Release créée (id ${release.id})`); + } + + for (const file of ASSETS) { + const filePath = path.join(DIST, file); + const sizeMB = (fs.statSync(filePath).size / 1024 / 1024).toFixed(1); + process.stdout.write(` Upload ${file} (${sizeMB} MB)...`); + await uploadAsset(release.id, filePath); + console.log(' OK'); + } + + console.log(`\n Release disponible : https://${HOST}/${OWNER}/${REPO}/releases/tag/${TAG}\n`); +} + +main().catch(e => { console.error('\nErreur :', e.message); process.exit(1); });