V0.3
This commit is contained in:
@@ -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.")
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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(
|
||||
`
|
||||
[1m[32m✓ À DISTRIBUER[0m ${out} (${mb} Mo)
|
||||
` +
|
||||
` C'est CE fichier qu'on envoie à l'équipe : il contient l'application
|
||||
` +
|
||||
` ET son écran d'installation.
|
||||
|
||||
` +
|
||||
` [2m(le setup NSIS enfoui dans src-tauri/target/… ne sert qu'aux
|
||||
` +
|
||||
` mises à jour automatiques — ne le distribue pas)[0m
|
||||
`
|
||||
)
|
||||
Reference in New Issue
Block a user