diff --git a/.gitignore b/.gitignore index b375f65..1a1e13b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ android/.gradle/ android/local.properties android/app/src/main/assets/public/ *.apk +releases/ # Base de données *.db diff --git a/server/src/index.ts b/server/src/index.ts index adcbbdb..8ff3eef 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,7 +1,8 @@ import express from 'express' -import { existsSync } from 'node:fs' +import { existsSync, statSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { APP_VERSION } from '@meteo-exam/shared' import { classesRouter } from './routes/classes.js' import { examsRouter } from './routes/exams.js' import { streamRouter } from './routes/stream.js' @@ -10,6 +11,10 @@ import { configRouter } from './routes/config.js' const here = dirname(fileURLToPath(import.meta.url)) const PORT = Number(process.env['PORT'] ?? 8787) +/** Dossier contenant l'APK à distribuer (latest.apk). */ +const RELEASES_DIR = resolve(here, '../releases') +const LATEST_APK = resolve(RELEASES_DIR, 'latest.apk') + /** Origines autorisées. L'app Android (WebView) est en cross-origin. */ const ALLOW_ORIGINS = (process.env['METEO_ORIGINS'] ?? '*') .split(',') @@ -40,6 +45,33 @@ app.use((req, res, next) => { app.get('/api/health', (_req, res) => { res.json({ ok: true }) }) + +// --- Mise à jour de l'APK ---------------------------------------------------- +// GET /api/version : la version courante du serveur + URL de téléchargement. +// L'app Android compare avec sa propre version et propose la mise à jour. +app.get('/api/version', (_req, res) => { + const apkAvailable = existsSync(LATEST_APK) + const apkUrl = apkAvailable ? '/download/latest.apk' : null + const apkSize = apkAvailable ? statSync(LATEST_APK).size : null + res.json({ + version: APP_VERSION, + apkUrl, + apkSize, + notes: process.env['METEO_UPDATE_NOTES'] ?? null, + }) +}) + +// GET /download/latest.apk : sert l'APK pour l'installation directe. +app.get('/download/latest.apk', (_req, res) => { + if (!existsSync(LATEST_APK)) { + res.status(404).json({ error: 'Aucune APK disponible pour le moment.' }) + return + } + res.setHeader('Content-Type', 'application/vnd.android.package-archive') + res.setHeader('Content-Disposition', 'attachment; filename="meteo-exam.apk"') + res.sendFile(LATEST_APK) +}) + app.use('/api/classes', classesRouter) app.use('/api/exams', examsRouter) app.use('/api/stream', streamRouter) diff --git a/shared/src/index.ts b/shared/src/index.ts index f33a293..9176876 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -195,3 +195,6 @@ export type StreamEvent = | { type: 'ping' } export const CLASS_CODE_LENGTH = 6 + +/** Version de l'application. Incrémentée à chaque release APK. */ +export const APP_VERSION = '0.3.0' diff --git a/web/src/main.ts b/web/src/main.ts index 6d53013..6734e2f 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,6 +7,8 @@ import { renderRadar } from './views/radar' import { renderMap } from './views/map' import { renderPlus } from './views/plus' import { getBaseUrl } from './config' +import { checkForUpdate } from './update' +import { showUpdateModal } from './update-modal' type Tab = 'meteo' | 'radar' | 'carte' | 'plus' @@ -105,5 +107,12 @@ subscribe(renderApp) void bootstrap().then(() => { renderApp() - console.log(`Météo Exam — serveur : ${getBaseUrl()}`) + console.log(`Météo Exam — v${__APP_VERSION__} — serveur : ${getBaseUrl()}`) + + // Vérification de mise à jour (Android natif uniquement). + void checkForUpdate().then((update) => { + if (update) { + showUpdateModal(update, () => renderApp()) + } + }) }) diff --git a/web/src/update-modal.ts b/web/src/update-modal.ts new file mode 100644 index 0000000..10894db --- /dev/null +++ b/web/src/update-modal.ts @@ -0,0 +1,85 @@ +import { h } from './dom' +import type { UpdateInfo } from './update' +import { currentAppVersion, dismissUpdate, downloadApk } from './update' +import { showToast } from './toast' + +/** Modale de mise à jour affichée au démarrage quand une version plus récente existe. */ +export function showUpdateModal(info: UpdateInfo, onAfter: () => void): void { + const overlay = h('div', { + class: 'fixed inset-0 z-[99] bg-slate-950/85 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-6', + }) + + const close = (): void => { + overlay.remove() + } + + const formatSize = (bytes: number | null): string => { + if (!bytes) return '' + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko` + return `${(bytes / (1024 * 1024)).toFixed(1)} Mo` + } + + const panel = h( + 'div', + { class: 'w-full sm:max-w-md bg-slate-900 border border-sky-500/30 rounded-t-[2rem] sm:rounded-[2rem] p-6 shadow-2xl' }, + h( + 'div', + { class: 'text-center mb-5' }, + h('div', { class: 'text-5xl mb-3' }, '🔄'), + h('h2', { class: 'text-xl font-black text-slate-100' }, 'Mise à jour disponible'), + h('p', { class: 'text-xs text-slate-400 font-semibold mt-1' }, + `Version ${currentAppVersion()} → ${info.version}`), + ), + h( + 'div', + { class: 'bg-slate-800/60 border border-slate-700/60 rounded-xl p-3.5 mb-4' }, + h('p', { class: 'text-[10px] font-black uppercase tracking-wider text-slate-500 mb-1' }, 'Nouveautés'), + h('p', { class: 'text-xs font-bold text-slate-200 leading-relaxed' }, + info.notes ?? 'Améliorations générales et corrections de bugs.'), + ), + info.apkSize + ? h('p', { class: 'text-[10px] text-slate-500 font-semibold text-center mb-4' }, + `Taille du fichier : ${formatSize(info.apkSize)}`) + : null, + h( + 'div', + { class: 'space-y-2.5' }, + h( + 'button', + { + class: 'w-full bg-sky-500 text-slate-950 text-sm font-extrabold py-3.5 rounded-xl active:scale-[0.98] transition-transform', + onclick: () => { + if (info.apkUrl) { + downloadApk(info.apkUrl) + showToast('Téléchargement lancé — ouvrez la notification pour installer', 'success') + dismissUpdate(info.version) + close() + onAfter() + } else { + showToast('Aucune APK disponible pour le moment', 'info') + } + }, + }, + h('i', { class: 'fa-solid fa-download mr-2' }), + 'Télécharger et installer', + ), + h( + 'button', + { + class: 'w-full bg-slate-800 border border-slate-700 text-slate-300 text-xs font-extrabold py-3 rounded-xl active:scale-95 transition-transform', + onclick: () => { + dismissUpdate(info.version) + close() + onAfter() + }, + }, + 'Plus tard', + ), + ), + h('p', { class: 'text-[10px] text-slate-600 font-semibold text-center mt-4' }, + 'Android installera l’APK par-dessus l’ancienne version — vos données et votre session sont conservées.'), + ) + + overlay.append(panel) + document.body.append(overlay) +} diff --git a/web/src/update.ts b/web/src/update.ts new file mode 100644 index 0000000..c1d4fe9 --- /dev/null +++ b/web/src/update.ts @@ -0,0 +1,78 @@ +import { Capacitor } from '@capacitor/core' +import { getBaseUrl } from './config' + +export interface UpdateInfo { + version: string + apkUrl: string | null + apkSize: number | null + notes: string | null +} + +/** Compare deux versions sémantiques. Renvoie 1 si a > b, -1 si a < b, 0 si égales. */ +function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] ?? 0 + const nb = pb[i] ?? 0 + if (na > nb) return 1 + if (na < nb) return -1 + } + return 0 +} + +/** + * Vérifie si une mise à jour est disponible sur le serveur. + * Ne retourne que sur Android natif (Capacitor) : la PWA iOS se met à jour + * en rechargeant simplement la page. + */ +export async function checkForUpdate(): Promise { + if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') { + return null + } + + const localVersion = __APP_VERSION__ + + try { + const response = await fetch(`${getBaseUrl()}/api/version`, { + headers: { Accept: 'application/json' }, + }) + if (!response.ok) return null + + const remote = (await response.json()) as UpdateInfo + + // Le serveur est plus vieux ou à jour : rien à faire. + if (compareVersions(remote.version, localVersion) <= 0) return null + + // L'utilisateur a déjà ignoré cette version-là. + const dismissed = localStorage.getItem('meteo-exam.dismissed-update') + if (dismissed === remote.version) return null + + return remote + } catch { + // Serveur injoignable : on ne bloque pas le démarrage de l'app. + return null + } +} + +export const currentAppVersion = (): string => __APP_VERSION__ + +export function dismissUpdate(version: string): void { + localStorage.setItem('meteo-exam.dismissed-update', version) +} + +/** + * Déclenche le téléchargement de l'APK. Sur Android, le WebView enregistre + * le fichier et l'utilisateur ouvre la notification pour installer. + */ +export function downloadApk(apkUrl: string): void { + const url = apkUrl.startsWith('http') ? apkUrl : `${getBaseUrl()}${apkUrl}` + const anchor = document.createElement('a') + anchor.href = url + anchor.download = 'meteo-exam.apk' + anchor.rel = 'noopener' + anchor.style.display = 'none' + document.body.append(anchor) + anchor.click() + anchor.remove() +} diff --git a/web/src/views/plus.ts b/web/src/views/plus.ts index ab67957..a8e31a7 100644 --- a/web/src/views/plus.ts +++ b/web/src/views/plus.ts @@ -9,6 +9,7 @@ import { updateClass, } from '../store' import { showToast } from '../toast' +import { currentAppVersion } from '../update' const inputClass = 'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 transition-colors' @@ -174,7 +175,7 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { h( 'p', { class: 'text-[10px] text-slate-600 font-semibold text-center' }, - 'Météo Exam — v0.2.0 · Radar de rumeurs en phase 2', + `Météo Exam — v${currentAppVersion()} · Radar de rumeurs en phase 2`, ), ) diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..bf0c88a --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,4 @@ +/// + +/** Version de l'app injectée par Vite au moment du build. */ +declare const __APP_VERSION__: string diff --git a/web/vite.config.ts b/web/vite.config.ts index 3ba6cc1..0838796 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,8 +1,12 @@ import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite' +import { APP_VERSION } from '../shared/src/index.js' export default defineConfig({ plugins: [tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(APP_VERSION), + }, server: { port: 5173, // Le cookie de session est same-origin : on proxifie l'API plutôt que