This commit is contained in:
2026-08-10 22:06:49 +02:00
parent 2a5d9bb0fd
commit 8bff7982ff
83 changed files with 17975 additions and 4824 deletions
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Génère les deux images de l'installeur NSIS à partir du logo de l'app.
python scripts/installer-art.py
NSIS impose du BMP à des tailles fixes :
· sidebar.bmp 164 x 314 — panneau de gauche (accueil et fin)
· header.bmp 150 x 57 — bandeau des pages intermédiaires
Les images sont versionnées dans src-tauri/installer/ : ce script n'est à
relancer que si le logo ou les couleurs de marque changent (il demande
Pillow, qui n'est PAS une dépendance du projet).
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "src-tauri" / "installer"
ICON = ROOT / "src-tauri" / "icons" / "128x128@2x.png"
# Dégradé du logo (public/logo.svg)
INDIGO = (99, 102, 241)
TEAL = (20, 184, 166)
INK = (16, 16, 24)
INK_SOFT = (30, 29, 48)
def font(size: int, bold: bool = False):
"""Segoe UI si disponible, repli sur la police par défaut de Pillow."""
for name in (["segoeuib.ttf", "seguisb.ttf"] if bold else ["segoeui.ttf"]):
try:
return ImageFont.truetype(f"C:/Windows/Fonts/{name}", size)
except OSError:
continue
return ImageFont.load_default()
def lerp(a, b, t):
return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))
def vertical_gradient(size, top, bottom):
w, h = size
img = Image.new("RGB", size)
d = ImageDraw.Draw(img)
for y in range(h):
d.line([(0, y), (w, y)], fill=lerp(top, bottom, y / max(h - 1, 1)))
return img
def horizontal_gradient(size, left, right):
w, h = size
img = Image.new("RGB", size)
d = ImageDraw.Draw(img)
for x in range(w):
d.line([(x, 0), (x, h)], fill=lerp(left, right, x / max(w - 1, 1)))
return img
def logo(px: int) -> Image.Image:
return Image.open(ICON).convert("RGBA").resize((px, px), Image.LANCZOS)
# ---------------------------------------------------------------------
# Panneau latéral 164 x 314 — fond sombre, logo, nom, liseré de marque
# ---------------------------------------------------------------------
def sidebar() -> Image.Image:
W, H = 164, 314
img = vertical_gradient((W, H), INK_SOFT, INK)
d = ImageDraw.Draw(img, "RGBA")
# Halo indigo diffus derrière le logo, pour éviter le fond plat.
cx, cy = W // 2, 96
for r in range(92, 0, -4):
alpha = int(30 * (1 - r / 92) ** 2)
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(*INDIGO, alpha))
mark = logo(76)
img.paste(mark, (cx - 38, cy - 38), mark)
title = font(15, bold=True)
sub = font(11)
d.text((cx, 168), "GameDev", font=title, fill=(255, 255, 255), anchor="mm")
d.text((cx, 186), "Tracker", font=title, fill=(255, 255, 255), anchor="mm")
d.text((cx, 210), "Gestion de projet", font=sub, fill=(150, 148, 170), anchor="mm")
d.text((cx, 225), "de jeu vidéo", font=sub, fill=(150, 148, 170), anchor="mm")
# Liseré dégradé en pied de panneau : la signature visuelle de la marque.
bar = horizontal_gradient((W, 4), INDIGO, TEAL)
img.paste(bar, (0, H - 4))
return img
# ---------------------------------------------------------------------
# Bandeau 150 x 57 — fond SOMBRE, accordé à MUI_BGCOLOR du template
# ---------------------------------------------------------------------
def header() -> Image.Image:
W, H = 150, 57
img = Image.new("RGB", (W, H), INK)
d = ImageDraw.Draw(img)
mark = logo(34)
img.paste(mark, (W - 34 - 12, (H - 34) // 2), mark)
name = font(11, bold=True)
d.text((W - 56, H // 2 - 7), "GameDev", font=name, fill=(255, 255, 255), anchor="rm")
d.text((W - 56, H // 2 + 7), "Tracker", font=name, fill=(255, 255, 255), anchor="rm")
bar = horizontal_gradient((W, 3), INDIGO, TEAL)
img.paste(bar, (0, H - 3))
return img
OUT.mkdir(parents=True, exist_ok=True)
for name, image in (("sidebar", sidebar()), ("header", header())):
path = OUT / f"{name}.bmp"
# BMP 24 bits sans alpha : le seul format que NSIS accepte partout.
image.convert("RGB").save(path, "BMP")
print(f" {path.relative_to(ROOT)} {image.width}x{image.height}")
print("\nImages générées.")
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env node
// =====================================================================
// Publication d'une nouvelle version.
//
// npm run release -- 0.3.0 "Ce qui change pour l'équipe"
//
// Ce que fait le script, dans l'ordre :
// 1. écrit le numéro de version dans package.json et tauri.conf.json
// 2. construit et SIGNE l'installeur Windows
// 3. téléverse l'installeur dans le bucket public « releases »
// 4. écrit latest.json — c'est ce fichier que les applications lisent
//
// À partir de là, chaque membre voit un bandeau « Version X disponible »
// au lancement, clique, et c'est fait.
// =====================================================================
import { execSync } from 'node:child_process'
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
import { join, basename } from 'node:path'
import { ROOT, KEY_PATH, signingEnv, tauriBin } from './signing.mjs'
const BUCKET = 'releases'
// ---------------------------------------------------------------------
// Petits utilitaires
// ---------------------------------------------------------------------
const c = {
ok: (m) => console.log(`\x1b[32m✓\x1b[0m ${m}`),
step: (m) => console.log(`\n\x1b[1m\x1b[36m▸ ${m}\x1b[0m`),
info: (m) => console.log(` ${m}`),
warn: (m) => console.log(`\x1b[33m!\x1b[0m ${m}`)
}
function fail(message, hint) {
console.error(`\n\x1b[31m✗ ${message}\x1b[0m`)
if (hint) console.error(`\n${hint}\n`)
process.exit(1)
}
/** Lit .env puis .env.local (le second gagne). */
function loadEnv() {
const env = {}
for (const file of ['.env', '.env.local']) {
const path = join(ROOT, file)
if (!existsSync(path)) continue
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/i)
if (m) env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
}
}
return env
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'))
}
function writeJson(path, data) {
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
}
// ---------------------------------------------------------------------
// 1) Arguments et vérifications
// ---------------------------------------------------------------------
const [version, ...noteParts] = process.argv.slice(2)
const notes = noteParts.join(' ').trim()
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
fail(
'Numéro de version manquant ou invalide.',
'Utilisation :\n' +
' npm run release -- 1.2.3 "Ce qui change pour l\'équipe"\n\n' +
'Le numéro doit être au format X.Y.Z.'
)
}
const env = loadEnv()
const SUPABASE_URL = env.VITE_SUPABASE_URL
const SERVICE_KEY = env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL) fail('VITE_SUPABASE_URL absent du fichier .env')
if (!SERVICE_KEY) {
fail(
'SUPABASE_SERVICE_ROLE_KEY absent du fichier .env.local',
'Cette clé sert UNIQUEMENT à téléverser depuis ta machine — elle ne part\n' +
"jamais dans l'application distribuée.\n\n" +
'Récupère-la dans Supabase → Settings → API → « service_role », puis\n' +
'crée un fichier .env.local à la racine avec :\n\n' +
' SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...\n\n' +
'(.env.local est ignoré par git : la clé ne sera jamais publiée.)'
)
}
const currentPkg = readJson(join(ROOT, 'package.json'))
if (currentPkg.version === version) {
c.warn(`package.json est déjà en ${version} — on republie par-dessus.`)
}
// ---------------------------------------------------------------------
// 2) Numéro de version
// ---------------------------------------------------------------------
c.step(`Version ${version}`)
const pkgPath = join(ROOT, 'package.json')
const pkg = readJson(pkgPath)
pkg.version = version
writeJson(pkgPath, pkg)
c.ok('package.json')
const confPath = join(ROOT, 'src-tauri', 'tauri.conf.json')
const conf = readJson(confPath)
conf.version = version
writeJson(confPath, conf)
c.ok('tauri.conf.json')
// ---------------------------------------------------------------------
// 3) Construction signée
// ---------------------------------------------------------------------
c.step('Construction de l\'installeur (plusieurs minutes)')
try {
execSync(`"${tauriBin()}" build`, { cwd: ROOT, stdio: 'inherit', env: signingEnv() })
} catch {
fail('La construction a échoué. Corrige l\'erreur ci-dessus puis relance.')
}
// ---------------------------------------------------------------------
// 4) Retrouver l'installeur et sa signature
// ---------------------------------------------------------------------
c.step('Recherche des fichiers produits')
const bundleDir = join(ROOT, 'src-tauri', 'target', 'release', 'bundle')
/** Cherche le premier fichier correspondant, dans l'ordre de préférence. */
function findBundle() {
// NSIS d'abord : c'est le seul format que l'updater Windows sait installer
// sans intervention (le .msi exige des droits administrateur à chaque fois).
for (const [dir, ext] of [
['nsis', '-setup.exe'],
['msi', '.msi']
]) {
const path = join(bundleDir, dir)
if (!existsSync(path)) continue
const file = readdirSync(path).find((f) => f.endsWith(ext) && f.includes(version))
if (file) return join(path, file)
}
return null
}
const installer = findBundle()
if (!installer) {
fail(
`Aucun installeur trouvé pour la version ${version}.`,
`Regarde dans ${bundleDir}.`
)
}
const sigPath = `${installer}.sig`
if (!existsSync(sigPath)) {
fail(
'Signature absente : l\'installeur n\'a pas été signé.',
`Vérifie que ${KEY_PATH} existe et que la section "plugins.updater"\n` +
'de tauri.conf.json contient bien "pubkey".'
)
}
const signature = readFileSync(sigPath, 'utf8').trim()
const installerName = basename(installer)
const installerBytes = readFileSync(installer)
c.ok(`${installerName} (${(installerBytes.length / 1048576).toFixed(1)} Mo)`)
// ---------------------------------------------------------------------
// 5) Téléversement
// ---------------------------------------------------------------------
c.step('Téléversement vers Supabase')
async function upload(name, body, contentType) {
const res = await fetch(
`${SUPABASE_URL}/storage/v1/object/${BUCKET}/${encodeURIComponent(name)}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${SERVICE_KEY}`,
'Content-Type': contentType,
'x-upsert': 'true', // republier une version écrase l'ancienne
'cache-control': 'max-age=60'
},
body
}
)
if (!res.ok) {
const text = await res.text()
if (/bucket not found/i.test(text)) {
fail(
`Le bucket « ${BUCKET} » n'existe pas.`,
'Exécute une seule fois, dans le SQL Editor de Supabase :\n\n' +
" insert into storage.buckets (id, name, public)\n" +
` values ('${BUCKET}', '${BUCKET}', true) on conflict (id) do nothing;\n\n` +
" create policy releases_read on storage.objects\n" +
` for select using (bucket_id = '${BUCKET}');`
)
}
fail(`Téléversement de ${name} refusé (HTTP ${res.status})`, text)
}
}
await upload(installerName, installerBytes, 'application/octet-stream')
c.ok(installerName)
// ---------------------------------------------------------------------
// 6) Le manifeste — c'est lui que les applications interrogent
// ---------------------------------------------------------------------
const manifest = {
version,
notes: notes || `Version ${version}`,
pub_date: new Date().toISOString(),
platforms: {
'windows-x86_64': {
signature,
url: `${SUPABASE_URL}/storage/v1/object/public/${BUCKET}/${encodeURIComponent(installerName)}`
}
}
}
await upload('latest.json', JSON.stringify(manifest, null, 2), 'application/json')
c.ok('latest.json')
// ---------------------------------------------------------------------
c.step(`Version ${version} publiée 🎉`)
c.info('Chaque membre verra le bandeau « Mise à jour disponible » au prochain')
c.info('lancement (ou dans les 2 heures si son application reste ouverte).')
c.info('')
c.info('Pense à valider les fichiers modifiés :')
c.info(' git add package.json src-tauri/tauri.conf.json && git commit -m "v' + version + '"')
console.log()
+43
View File
@@ -0,0 +1,43 @@
// =====================================================================
// Clé de signature des mises à jour — lecture commune aux scripts.
//
// Le CLI Tauri 2.11 n'honore pas TAURI_SIGNING_PRIVATE_KEY_PATH : il ne
// lit que TAURI_SIGNING_PRIVATE_KEY, qui accepte soit un chemin, soit la
// clé elle-même. On lui passe donc directement le contenu du fichier.
// =====================================================================
import { readFileSync, existsSync } from 'node:fs'
import { resolve, join } from 'node:path'
export const ROOT = resolve(import.meta.dirname, '..')
export const KEY_PATH = join(ROOT, '.tauri-signing-key.local')
/**
* Renvoie l'environnement à passer à `tauri build`, clé de signature
* incluse. Sort du programme avec un message clair si la clé manque —
* sans elle, aucune mise à jour ne serait acceptée par les installations
* existantes.
*/
export function signingEnv() {
if (!existsSync(KEY_PATH)) {
console.error(
'\n\x1b[31m✗ Clé de signature introuvable : .tauri-signing-key.local\x1b[0m\n\n' +
'Elle signe chaque mise à jour. Restaure ta sauvegarde (MISE_A_JOUR.md, étape 3).\n\n' +
"Si tu n'as PAS encore de clé — attention, les applications déjà installées\n" +
'avec une autre clé ne pourront plus jamais se mettre à jour :\n\n' +
' npx tauri signer generate --ci -p "" -w .tauri-signing-key.local\n'
)
process.exit(1)
}
return {
...process.env,
TAURI_SIGNING_PRIVATE_KEY: readFileSync(KEY_PATH, 'utf8').trim(),
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
}
}
/** Chemin du binaire Tauri local (npx inutile, et pas de shell à deviner). */
export function tauriBin() {
return join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri')
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// =====================================================================
// `npm run tauri:build`
//
// 1. construit et SIGNE l'application
// 2. dépose LE fichier à distribuer dans `release/`, sous son vrai nom
//
// Le setup NSIS produit dans `src-tauri/target/release/bundle/nsis/` ne
// sert QU'À l'updater (installation silencieuse des mises à jour). Il
// reste enfoui volontairement : c'est `release/GameDev Tracker.exe` qu'on
// envoie à l'équipe, et lui seul.
// =====================================================================
import { execSync } from 'node:child_process'
import { copyFileSync, mkdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { ROOT, signingEnv, tauriBin } from './signing.mjs'
const extra = process.argv.slice(2).join(' ')
execSync(`"${tauriBin()}" build ${extra}`.trim(), {
cwd: ROOT,
stdio: 'inherit',
env: signingEnv()
})
// --- Le fichier à distribuer -----------------------------------------
const built = join(ROOT, 'src-tauri', 'target', 'release', 'app.exe')
const outDir = join(ROOT, 'release')
const out = join(outDir, 'GameDev Tracker.exe')
mkdirSync(outDir, { recursive: true })
copyFileSync(built, out)
const mb = (statSync(out).size / 1048576).toFixed(1)
console.log(
`
✓ À DISTRIBUER ${out} (${mb} Mo)
` +
` C'est CE fichier qu'on envoie à l'équipe : il contient l'application
` +
` ET son écran d'installation.
` +
` (le setup NSIS enfoui dans src-tauri/target/… ne sert qu'aux
` +
` mises à jour automatiques — ne le distribue pas)
`
)