v0.3.0 — mise à jour in-app pour Android
- Server : GET /api/version (version + APK URL + taille), GET /download/latest.apk - Server : releases/latest.apk servi avec Content-Type APK - Web : checkForUpdate() compare version locale vs serveur au démarrage - Web : modale de mise à jour (télécharger + installer, ou plus tard) - Web : version affichée dans l'onglet Plus - Vite : __APP_VERSION__ injecté au build depuis shared/APP_VERSION - Workflow : bump APP_VERSION → build APK → copier vers server/releases/latest.apk
This commit is contained in:
parent
246f989bdc
commit
90adee1eae
9 changed files with 220 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ android/.gradle/
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/app/src/main/assets/public/
|
android/app/src/main/assets/public/
|
||||||
*.apk
|
*.apk
|
||||||
|
releases/
|
||||||
|
|
||||||
# Base de données
|
# Base de données
|
||||||
*.db
|
*.db
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync, statSync } from 'node:fs'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { APP_VERSION } from '@meteo-exam/shared'
|
||||||
import { classesRouter } from './routes/classes.js'
|
import { classesRouter } from './routes/classes.js'
|
||||||
import { examsRouter } from './routes/exams.js'
|
import { examsRouter } from './routes/exams.js'
|
||||||
import { streamRouter } from './routes/stream.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 here = dirname(fileURLToPath(import.meta.url))
|
||||||
const PORT = Number(process.env['PORT'] ?? 8787)
|
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. */
|
/** Origines autorisées. L'app Android (WebView) est en cross-origin. */
|
||||||
const ALLOW_ORIGINS = (process.env['METEO_ORIGINS'] ?? '*')
|
const ALLOW_ORIGINS = (process.env['METEO_ORIGINS'] ?? '*')
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|
@ -40,6 +45,33 @@ app.use((req, res, next) => {
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ ok: true })
|
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/classes', classesRouter)
|
||||||
app.use('/api/exams', examsRouter)
|
app.use('/api/exams', examsRouter)
|
||||||
app.use('/api/stream', streamRouter)
|
app.use('/api/stream', streamRouter)
|
||||||
|
|
|
||||||
|
|
@ -195,3 +195,6 @@ export type StreamEvent =
|
||||||
| { type: 'ping' }
|
| { type: 'ping' }
|
||||||
|
|
||||||
export const CLASS_CODE_LENGTH = 6
|
export const CLASS_CODE_LENGTH = 6
|
||||||
|
|
||||||
|
/** Version de l'application. Incrémentée à chaque release APK. */
|
||||||
|
export const APP_VERSION = '0.3.0'
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { renderRadar } from './views/radar'
|
||||||
import { renderMap } from './views/map'
|
import { renderMap } from './views/map'
|
||||||
import { renderPlus } from './views/plus'
|
import { renderPlus } from './views/plus'
|
||||||
import { getBaseUrl } from './config'
|
import { getBaseUrl } from './config'
|
||||||
|
import { checkForUpdate } from './update'
|
||||||
|
import { showUpdateModal } from './update-modal'
|
||||||
|
|
||||||
type Tab = 'meteo' | 'radar' | 'carte' | 'plus'
|
type Tab = 'meteo' | 'radar' | 'carte' | 'plus'
|
||||||
|
|
||||||
|
|
@ -105,5 +107,12 @@ subscribe(renderApp)
|
||||||
|
|
||||||
void bootstrap().then(() => {
|
void bootstrap().then(() => {
|
||||||
renderApp()
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
85
web/src/update-modal.ts
Normal file
85
web/src/update-modal.ts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
78
web/src/update.ts
Normal file
78
web/src/update.ts
Normal file
|
|
@ -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<UpdateInfo | null> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
updateClass,
|
updateClass,
|
||||||
} from '../store'
|
} from '../store'
|
||||||
import { showToast } from '../toast'
|
import { showToast } from '../toast'
|
||||||
|
import { currentAppVersion } from '../update'
|
||||||
|
|
||||||
const inputClass =
|
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'
|
'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(
|
h(
|
||||||
'p',
|
'p',
|
||||||
{ class: 'text-[10px] text-slate-600 font-semibold text-center' },
|
{ 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`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
4
web/src/vite-env.d.ts
vendored
Normal file
4
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
/** Version de l'app injectée par Vite au moment du build. */
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { APP_VERSION } from '../shared/src/index.js'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
// Le cookie de session est same-origin : on proxifie l'API plutôt que
|
// Le cookie de session est same-origin : on proxifie l'API plutôt que
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue